diff --git a/README.rst b/README.rst index ac6dcb9..55b95da 100644 --- a/README.rst +++ b/README.rst @@ -73,6 +73,11 @@ Restart platform via GUI to apply changes: .. image:: https://github.com/overhangio/tutor-deck/raw/release/images/apply.png :alt: Apply Image +You may add HTTP basic authentication by defining the following Tutor settings:: + + tutor config save --set DECK_AUTH_USERNAME=myusername \ + --set DECK_AUTH_PASSWORD=s3cr3tpassw0rd + Troubleshooting *************** diff --git a/tutordeck/plugin.py b/tutordeck/plugin.py index cc81bdc..a3aa20a 100644 --- a/tutordeck/plugin.py +++ b/tutordeck/plugin.py @@ -14,6 +14,8 @@ from .server import app hooks.Filters.CONFIG_DEFAULTS.add_items( [ ("DECK_VERSION", __version__), + ("DECK_AUTH_USERNAME", None), + ("DECK_AUTH_PASSWORD", None), ] ) diff --git a/tutordeck/server/app.py b/tutordeck/server/app.py index 8005665..082db35 100644 --- a/tutordeck/server/app.py +++ b/tutordeck/server/app.py @@ -46,12 +46,66 @@ def run(root: str, **app_kwargs: t.Any) -> None: tutorclient.logger.addHandler(handler) tutorclient.logger.setLevel(logging.INFO) + # Configure authentication + HttpAuthCredentials.load_credentials() + # TODO app.run() should be called only in development app.run(**app_kwargs) +class HttpAuthCredentials: + USERNAME: str = "" + PASSWORD: str = "" + + @classmethod + def load_credentials(cls) -> None: + """ + Note that credentials will not be automatically reloaded on configuration change. + + TODO reload credentials automatically when needed. + """ + config = tutorclient.Project.get_config() + cls.USERNAME = t.cast(str, config.get("DECK_AUTH_USERNAME", "")) + cls.PASSWORD = t.cast(str, config.get("DECK_AUTH_PASSWORD", "")) + + @classmethod + def is_auth_success(cls) -> bool: + """ + Returns True if the current request has the right HTTP basic auth credentials. + """ + if not cls.USERNAME or not cls.PASSWORD: + # No credential required + return True + + if not request.authorization: + # No credential was provided + return False + + # Check provided credentials + username = request.authorization.parameters.get("username") + password = request.authorization.parameters.get("password") + return username == cls.USERNAME and password == cls.PASSWORD + + +@app.before_request +def http_basic_auth() -> None | tuple[str, int, dict[str, str]]: + """ + Check authentication headers if necessary. + """ + if not HttpAuthCredentials.is_auth_success(): + # https://quart.palletsprojects.com/en/latest/reference/response_values/#tuple-str-int-dict-str-str + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/401 + # https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Authentication#authentication_schemes + return "", 401, {"WWW-Authenticate": "basic"} + + return None + + @app.before_request async def before_request() -> None: + """ + Store installed and enabled plugins as global attributes. + """ # Shared views and template context g.installed_plugins = tutorclient.Client.installed_plugins() g.enabled_plugins = tutorclient.Client.enabled_plugins() @@ -222,7 +276,7 @@ async def plugin_upgrade(name: str) -> BaseResponse: @app.post("/plugins/update") -async def plugins_update() -> WerkzeugResponse: +async def plugins_update() -> BaseResponse: tutorclient.CliPool.run_sequential(["plugins", "update"]) return redirect(url_for("plugin_store"))