feat: HTTP basic authentication

This is performed via two configuration settings. Note however that we
don't yet support automatic reloading of credentials. This is
unfortunate because there is no way to reload tutor Deck...
This commit is contained in:
Régis Behmo 2025-08-12 21:32:47 +02:00 committed by Régis Behmo
parent b60655ed54
commit 8002da7a66
3 changed files with 62 additions and 1 deletions

View File

@ -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
***************

View File

@ -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),
]
)

View File

@ -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"))