diff --git a/conda-store-server/conda_store_server/server/app.py b/conda-store-server/conda_store_server/server/app.py index 2378839ae..15ee41d7a 100644 --- a/conda-store-server/conda_store_server/server/app.py +++ b/conda-store-server/conda_store_server/server/app.py @@ -2,7 +2,7 @@ from flask import Flask from flask_cors import CORS -from traitlets import Bool, Unicode, Integer +from traitlets import Bool, Unicode, Integer, Type from traitlets.config import Application from conda_store_server.server import views, auth @@ -48,6 +48,19 @@ class CondaStoreServer(Application): "conda_store_config.py", help="config file to load for conda-store", config=True ) + authentication_class = Type( + default_value=auth.DummyAuthentication, + klass=auth.Authentication, + allow_none=False, + config=True, + ) + + secret_key = Unicode( + "super_secret_key", + config=True, + help="A secret key needed for some authentication methods, session storage, etc.", + ) + def initialize(self, *args, **kwargs): super().initialize(*args, **kwargs) self.load_config_file(self.config_file) @@ -55,6 +68,7 @@ def initialize(self, *args, **kwargs): def start(self): app = Flask(__name__) CORS(app, resources={r"/api/v1/*": {"origins": "*"}}) + app.secret_key = self.secret_key if self.enable_api: app.register_blueprint(views.app_api) @@ -69,7 +83,7 @@ def start(self): app.register_blueprint(views.app_metrics) app.conda_store = CondaStore(parent=self, log=self.log) - app.authentication = auth.Authentication(parent=self, log=self.log) + app.authentication = self.authentication_class(parent=self, log=self.log) # add dynamic routes for route, method, func in app.authentication.routes: diff --git a/conda-store-server/conda_store_server/server/auth.py b/conda-store-server/conda_store_server/server/auth.py index 96baf9241..127376b63 100644 --- a/conda-store-server/conda_store_server/server/auth.py +++ b/conda-store-server/conda_store_server/server/auth.py @@ -4,9 +4,19 @@ import datetime import jwt +import requests from traitlets.config import LoggingConfigurable -from traitlets import Dict, Unicode, Type -from flask import request, render_template, redirect, g, abort, jsonify +from traitlets import Dict, Unicode, Type, default +from flask import ( + request, + render_template, + redirect, + g, + abort, + jsonify, + url_for, + session, +) from sqlalchemy import or_, and_ from conda_store_server import schema, orm @@ -170,7 +180,7 @@ class Authentication(LoggingConfigurable): - + """, @@ -178,6 +188,9 @@ class Authentication(LoggingConfigurable): config=True, ) + def get_login_html(self): + return self.login_html + @property def authentication(self): if hasattr(self, "_authentication"): @@ -209,10 +222,10 @@ def authenticate(self, request): ) def get_login_method(self): - return render_template("login.html", login_html=self.login_html) + return render_template("login.html", login_html=self.get_login_html()) def post_login_method(self): - redirect_url = request.args.get("next", "/") + redirect_url = request.args.get("next", url_for("ui.ui_get_user")) response = redirect(redirect_url) authentication_token = self.authenticate(request) if authentication_token is None: @@ -331,3 +344,234 @@ def filter_namespaces(self, query): return query.filter(False) return query.filter(or_(*cases)) + + +class DummyAuthentication(Authentication): + """Dummy Authentication for testing + By default, any username + password is allowed + If a non-empty password is set, any username will be allowed + if it logs in with that password. + """ + + password = Unicode( + "password", + config=True, + help=""" + Set a global password for all users wanting to log in. + This allows users with any username to log in with the same static password. + """, + ) + + # login_html = Unicode() + + def authenticate(self, request): + """Checks against a global password if it's been set. If not, allow any user/pass combo""" + if self.password and request.form["password"] != self.password: + return None + + return schema.AuthenticationToken( + primary_namespace=request.form["username"], + role_bindings={ + "*/*": ["admin"], + }, + ) + + +class GenericOAuthAuthentication(Authentication): + """ + A provider-agnostic OAuth authentication provider. Configure endpoints, secrets and other + parameters to enable any OAuth-compatible platform. + """ + + access_token_url = Unicode( + config=True, + help="URL used to request an access token once app has been authorized", + ) + authorize_url = Unicode( + config=True, + help="URL used to request authorization to OAuth provider", + ) + client_id = Unicode( + config=True, + help="Unique string that identifies the app against the OAuth provider", + ) + client_secret = Unicode( + config=True, + help="Secret string used to authenticate the app against the OAuth provider", + ) + access_scope = Unicode( + config=True, + help="Permissions that will be requested to OAuth provider.", + ) + user_data_url = Unicode( + config=True, + help="API endpoint for OAuth provider that returns a JSON dict with user data", + ) + user_data_key = Unicode( + config=True, + help="Key in the payload returned by `user_data_url` endpoint that provides the username", + ) + login_html = Unicode( + """ +
+

Please sign in via OAuth

+ Sign in with OAuth +
+ """, + help="html form to use for login", + config=True, + ) + + def get_login_html(self): + state = secrets.token_urlsafe() + session["oauth_state"] = state + authorization_url = self.oauth_route( + auth_url=self.authorize_url, + client_id=self.client_id, + redirect_uri=url_for("post_login_method", _external=True), + scope=self.access_scope, + state=state, + ) + return self.login_html.format(authorization_url=authorization_url) + + @staticmethod + def oauth_route(auth_url, client_id, redirect_uri, scope=None, state=None): + r = f"{auth_url}?client_id={client_id}&redirect_uri={redirect_uri}&response_type=code" + if scope is not None: + r += f"&scope={scope}" + if state is not None: + r += f"&state={state}" + return r + + @property + def routes(self): + return [ + ("/login/", "GET", self.get_login_method), + ("/logout/", "POST", self.post_logout_method), + ("/oauth_callback/", "GET", self.post_login_method), + ] + + def authenticate(self, request): + # 1. using the callback_url code and state in request + oauth_access_token = self._get_oauth_token(request) + if oauth_access_token is None: + return None # authentication failed + + # 2. Who is the username? We need one more request + username = self._get_username(oauth_access_token) + + # 3. create our own internal token + return schema.AuthenticationToken( + primary_namespace=username, + role_bindings={ + "*/*": ["admin"], + }, + ) + + def _get_oauth_token(self, request): + # 1. Get callback URI params, which include `code` and `state` + # `code` will be used to request the token; `state` must match our session's! + code = request.args.get("code") + state = request.args.get("state") + if session["oauth_state"] != state: + response = jsonify( + {"status": "error", "message": "OAuth states do not match"} + ) + response.status_code = 401 + abort(response) + del session["oauth_state"] + + # 2. Request actual access token with code and secret + r_response = requests.post( + self.access_token_url, + data={ + "code": code, + "grant_type": "authorization_code", + "client_id": self.client_id, + "client_secret": self.client_secret, + }, + headers={"Accept": "application/json"}, + ) + if r_response.status_code != 200: + return None + data = r_response.json() + return data["access_token"] + + def _get_username(self, authentication_token): + response = requests.get( + self.user_data_url, + headers={"Authorization": f"token {authentication_token}"}, + ) + response.raise_for_status() + return response.json()[self.user_data_key] + + +class GithubOAuthAuthentication(GenericOAuthAuthentication): + github_url = Unicode("https://github.com", config=True) + + github_api = Unicode("https://api.github.com", config=True) + + @default("access_token_url") + def _access_token_url_default(self): + return "%s/login/oauth/access_token" % (self.github_url) + + @default("authorize_url") + def _authorize_url_default(self): + return "%s/login/oauth/authorize" % (self.github_url) + + @default("access_scope") + def _access_scope_default(self): + return "user:email" + + @default("user_data_url") + def _user_data_url_default(self): + return "%s/user" % (self.github_api) + + @default("user_data_key") + def _user_data_key_default(self): + return "login" + + @default("login_html") + def _login_html_default(self): + return """ +
+

Please sign in via OAuth

+ Sign in with GitHub +
+ """ + + +class JupyterHubOAuthAuthentication(GenericOAuthAuthentication): + jupyterhub_url = Unicode( + help="base url for jupyterhub not including the '/hub/'", + config=True, + ) + + @default("access_token_url") + def _access_token_url_default(self): + return "%s/hub/api/oauth2/token" % (self.jupyterhub_url) + + @default("authorize_url") + def _authorize_url_default(self): + return "%s/hub/api/oauth2/authorize" % (self.jupyterhub_url) + + @default("access_scope") + def _access_scope_default(self): + return "profile" + + @default("user_data_url") + def _user_data_url_default(self): + return "%s/hub/api/user" % (self.jupyterhub_url) + + @default("user_data_key") + def _user_data_key_default(self): + return "name" + + @default("login_html") + def _login_html_default(self): + return """ +
+

Please sign in via OAuth

+ Sign in with JupyterHub +
+ """ diff --git a/conda-store-server/conda_store_server/server/templates/navigation.html b/conda-store-server/conda_store_server/server/templates/navigation.html index 20dafcb91..75d55f71d 100644 --- a/conda-store-server/conda_store_server/server/templates/navigation.html +++ b/conda-store-server/conda_store_server/server/templates/navigation.html @@ -14,9 +14,9 @@ {% else %}
  • -
    - -
    + + User +
  • {% endif %}
  • diff --git a/conda-store-server/conda_store_server/server/templates/user.html b/conda-store-server/conda_store_server/server/templates/user.html new file mode 100644 index 000000000..3f525f8b5 --- /dev/null +++ b/conda-store-server/conda_store_server/server/templates/user.html @@ -0,0 +1,19 @@ +{% extends 'base.html' %} + +{% block title %}User info{% endblock %} + +{% block content %} + +{% block user %} +
    +

    Logged in as {{ username }}

    + {% if email is defined %} +

    Email: {{ email }}

    + {% endif %} +
    + +
    +
    +{% endblock %} + +{% endblock %} diff --git a/conda-store-server/conda_store_server/server/views/ui.py b/conda-store-server/conda_store_server/server/views/ui.py index 716482dcb..ff637034b 100644 --- a/conda-store-server/conda_store_server/server/views/ui.py +++ b/conda-store-server/conda_store_server/server/views/ui.py @@ -159,6 +159,18 @@ def ui_get_build(build_id): return render_template("build.html", **context) +@app_ui.route("/user/", methods=["GET"]) +def ui_get_user(): + auth = get_auth() + + entity = auth.authenticate_request() + if entity is None: + return redirect("/login/") + + context = {"username": entity.primary_namespace} + return render_template("user.html", **context) + + @app_ui.route("/build//logs/", methods=["GET"]) def api_get_build_logs(build_id): conda_store = get_conda_store() diff --git a/docs/development.md b/docs/development.md index c71875300..6cbcb4c21 100644 --- a/docs/development.md +++ b/docs/development.md @@ -45,3 +45,7 @@ These commands should build the extension. Then, `jupyter lab` +## Note about Response objects + +We use both `flask` and `requests`, which both return `response` objects. In case of ambiguity, +we prefix them like `f_response` and `r_response`, respectively. \ No newline at end of file diff --git a/tests/assets/conda_store_config.py b/tests/assets/conda_store_config.py index c9261f5bd..234a2ec09 100644 --- a/tests/assets/conda_store_config.py +++ b/tests/assets/conda_store_config.py @@ -1,6 +1,7 @@ import logging from conda_store_server.storage import S3Storage +from conda_store_server.server.auth import JupyterHubOAuthAuthentication # ================================== # conda-store settings @@ -32,6 +33,21 @@ c.CondaStoreServer.address = "0.0.0.0" c.CondaStoreServer.port = 5000 + +# ================================== +# auth settings +# ================================== +c.CondaStoreServer.authentication_class = JupyterHubOAuthAuthentication +c.JupyterHubOAuthAuthentication.jupyterhub_url = "http://jupyterhub:8000" +c.JupyterHubOAuthAuthentication.client_id = "service-this-is-a-jupyterhub-client" +c.JupyterHubOAuthAuthentication.client_secret = "this-is-a-jupyterhub-secret" +# in the case of docker-compose the internal and external dns +# routes do not match. Inside the docker compose deployment +# jupyterhub is accessible via the `jupyterhub` hostname in dns +# however outside of the docker it is accessible via localhost +# hence this small change needed for testing +c.JupyterHubOAuthAuthentication.authorize_url = "http://localhost:8000/hub/api/oauth2/authorize" + # ================================== # worker settings # ================================== diff --git a/tests/assets/jupyterhub_config.py b/tests/assets/jupyterhub_config.py index e99b5de4c..5dbe37fb6 100644 --- a/tests/assets/jupyterhub_config.py +++ b/tests/assets/jupyterhub_config.py @@ -5,3 +5,13 @@ c.Spawner.cmd=["jupyter-labhub"] c.JupyterHub.spawner_class = 'jupyterhub.spawner.SimpleLocalProcessSpawner' + +c.JupyterHub.services = [ + { + 'name': "conda-store", + 'oauth_client_id': "service-this-is-a-jupyterhub-client", + 'admin': True, + 'api_token': "this-is-a-jupyterhub-secret", + 'oauth_redirect_uri': 'http://localhost:5000/oauth_callback/', + } +]