diff --git a/fast_dash/Components.py b/fast_dash/Components.py index bb6615e..2c0f500 100644 --- a/fast_dash/Components.py +++ b/fast_dash/Components.py @@ -437,6 +437,7 @@ def generate_navbar_container(self): dmc.Group( [ *(right_items or []), + *self._auth_header_items(), dmc.Switch( id="theme-toggle", offLabel=DashIconify(icon="radix-icons:sun", width=16), @@ -461,6 +462,28 @@ def generate_navbar_container(self): return header_children + def _auth_header_items(self): + """Return header items for the auth gate (Sign out button). + + Empty list when auth is disabled. Rendered just before the + theme toggle in the right-hand header group. Uses an html.A + wrapper because dmc.ActionIcon does not support href directly. + """ + if not getattr(self.app, "_auth_config", None): + return [] + return [ + html.A( + dmc.Button( + "Sign out", + id="logout-button", + variant="subtle", + color="gray", + size="compact-sm", + ), + href="/logout", + ) + ] + def generate_input_component(self): """Build the sidebar navbar content with inputs.""" sidebar_children = [] diff --git a/fast_dash/__init__.py b/fast_dash/__init__.py index 2a8118c..03cfc72 100644 --- a/fast_dash/__init__.py +++ b/fast_dash/__init__.py @@ -34,6 +34,7 @@ from dash import Output, Input, State, callback, no_update from fast_dash.fast_dash import FastDash, fastdash, update, notify from fast_dash.utils import Fastify, depends_on, from_step +from fast_dash.auth import current_user __all__ = [ "FastDash", @@ -41,6 +42,7 @@ "Fastify", "depends_on", "from_step", + "current_user", "Text", "TextArea", "Slider", diff --git a/fast_dash/auth.py b/fast_dash/auth.py new file mode 100644 index 0000000..a460e69 --- /dev/null +++ b/fast_dash/auth.py @@ -0,0 +1,210 @@ +"""Lightweight password authentication for Fast Dash apps. + +Fast Dash apps default to no auth — anyone with the URL can use them. +This module adds an opt-in password gate so a developer can share an +app behind a tunnel (cloudflared, ngrok, ...) without exposing it to +the public internet. + +Usage:: + + from fast_dash import fastdash + + @fastdash(auth={"alice": "wonderland", "bob": "builder"}) + def greet(name: str = "world") -> str: + return f"Hello, {name}!" + +The first time a visitor hits any URL on the app, they are redirected +to ``/login``. After a successful login, a session cookie is set and +they can access the app normally. ``/logout`` clears the session. + +The current user's name is exposed inside callbacks via +``fast_dash.current_user()``. + +Security notes (read these before using in anger): +- The user dict is stored in memory; passwords are compared in constant + time but **not hashed**. For shared/multi-user environments, hash + externally (e.g. ``argon2`` / ``bcrypt``) and supply a custom + ``verify_fn`` instead of a plain dict. +- Sessions are signed with Flask's ``SECRET_KEY``. Fast Dash generates + a random key per process; restart-stable sessions require the user + to set ``FASTDASH_SECRET_KEY`` in the environment. +- This is a single-tenant gate, not a full identity provider. For + Google / GitHub / Microsoft sign-in, a future ``auth="oidc"`` + variant is planned. +""" + +from __future__ import annotations + +import hmac +import os +import secrets +from typing import Callable, Mapping + +from flask import ( + Flask, + abort, + g, + redirect, + render_template_string, + request, + session, + url_for, +) + + +# ---- public surface -------------------------------------------------- + + +def current_user() -> str | None: + """Return the username for the active request, or ``None``. + + Safe to call from inside any Fast Dash callback. Returns ``None`` + when auth is disabled or when called outside a request context. + """ + try: + return session.get("_fastdash_user") + except RuntimeError: + # Outside an app/request context. + return None + + +# ---- configuration --------------------------------------------------- + + +def _normalize_auth_config(auth) -> _AuthConfig | None: + """Turn whatever the user passed into a typed config object. + + Accepts: + - ``None`` / ``False`` → auth disabled (returns ``None``). + - ``{"alice": "secret"}`` → password dict. + - A callable ``verify_fn(username, password) -> bool`` for custom + verification (e.g. against a hashed store). + """ + if not auth: + return None + if callable(auth): + return _AuthConfig(verify=auth) + if isinstance(auth, Mapping): + users = dict(auth) + return _AuthConfig(verify=lambda u, p: _verify_against_dict(users, u, p)) + raise TypeError( + "auth= must be a dict of {username: password}, a verify function, " + f"or None. Got {type(auth).__name__}." + ) + + +class _AuthConfig: + """Bundle of the verifier callable used by the middleware.""" + + def __init__(self, verify: Callable[[str, str], bool]): + self.verify = verify + + +def _verify_against_dict(users: dict, username: str, password: str) -> bool: + """Constant-time check of (username, password) against a user dict.""" + expected = users.get(username) + if expected is None: + # Hash comparison still runs against a known-bad value to keep + # timing constant whether or not the username exists. + return hmac.compare_digest(b"x", b"y") + return hmac.compare_digest( + password.encode("utf-8"), str(expected).encode("utf-8") + ) + + +# ---- middleware ------------------------------------------------------ + + +_LOGIN_TEMPLATE = """\ + + + + + Sign in{% if title %} — {{ title }}{% endif %} + + + + +
+

{% if title %}{{ title }}{% else %}Fast Dash app{% endif %}

+ {% if error %}
{{ error }}
{% endif %} + + + + + + +
+ + +""" + + +def install_auth(server: Flask, config: _AuthConfig, title: str | None = None) -> None: + """Wire login / logout routes and a ``before_request`` gate onto the app.""" + + # Random per-process key. Users who restart frequently and want + # session continuity should set FASTDASH_SECRET_KEY. + if not server.secret_key: + server.secret_key = os.environ.get( + "FASTDASH_SECRET_KEY", secrets.token_hex(32) + ) + + open_paths = {"/login", "/logout"} + + @server.before_request + def _require_login(): + if request.path in open_paths: + return None + if request.path.startswith("/_dash") and "_fastdash_user" not in session: + # Dash's internal endpoints (callback updates, asset bundles) + # would otherwise leak app state to unauthenticated callers. + abort(401) + if "_fastdash_user" not in session: + return redirect(url_for("_fastdash_login", next=request.path)) + g.fastdash_user = session["_fastdash_user"] + return None + + @server.route("/login", methods=["GET", "POST"]) + def _fastdash_login(): + next_url = request.values.get("next") or "/" + if request.method == "POST": + username = (request.form.get("username") or "").strip() + password = request.form.get("password") or "" + if config.verify(username, password): + session.clear() + session["_fastdash_user"] = username + session.permanent = False + return redirect(next_url) + return ( + render_template_string( + _LOGIN_TEMPLATE, + error="Invalid username or password.", + next=next_url, + title=title, + ), + 401, + ) + return render_template_string( + _LOGIN_TEMPLATE, error=None, next=next_url, title=title + ) + + @server.route("/logout") + def _fastdash_logout(): + session.clear() + return redirect(url_for("_fastdash_login")) diff --git a/fast_dash/fast_dash.py b/fast_dash/fast_dash.py index 2cbe956..d9dffa6 100644 --- a/fast_dash/fast_dash.py +++ b/fast_dash/fast_dash.py @@ -127,6 +127,7 @@ def __init__( run_kwargs=dict(), tab_titles=None, steps=None, + auth=None, **kwargs ): """ @@ -205,6 +206,13 @@ def __init__( steps (list of funcs, optional): A linear pipeline of step functions. Each step gets its own \ page in a stepper UI; outputs of earlier steps can feed downstream steps via ``from_step``. \ When provided, ``callback_fn`` is ignored. Defaults to None. + + auth (dict or callable, optional): Enable a password gate in front of the app. \ + Pass ``{"alice": "secret", ...}`` for a static user/password dict, or a \ + callable ``(username, password) -> bool`` for custom verification (e.g. \ + against a hashed store). When set, every URL except ``/login`` and \ + ``/logout`` requires a valid session. Use ``fast_dash.current_user()`` \ + inside callbacks to read the signed-in username. Defaults to None (no auth). """ # Detect pipeline (steps) mode @@ -293,6 +301,15 @@ def __init__( self.server = self.app.server self.callback = self.app.callback + # Optional password gate. Installs before any callbacks so + # /login and /logout are reachable without auth, and every + # other request gets redirected if the user isn't signed in. + from .auth import _normalize_auth_config, install_auth + + self._auth_config = _normalize_auth_config(auth) + if self._auth_config is not None: + install_auth(self.server, self._auth_config, title=title) + if stream == True: socketio = SocketIO(self.app.server) @@ -1658,6 +1675,7 @@ def fastdash( disable_logs=False, scale_height=1, run_kwargs=dict(), + auth=None, **kwargs ): """ @@ -1770,6 +1788,7 @@ def wrapper_fastdash(**kwargs): disable_logs=disable_logs, scale_height=scale_height, run_kwargs=run_kwargs, + auth=auth, **kwargs ) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..f8c6e5d --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,222 @@ +"""Tests for the password-auth gate added in v0.2.17. + +These tests use Flask's built-in test client so we can exercise the +middleware without a browser. Each test builds a small FastDash app +with ``auth={...}`` or ``auth=callable`` and walks through the login +flow. +""" + +import pytest + +from fast_dash import FastDash, current_user + + +def _make_app(auth=None, callback=None): + """Helper: build a tiny FastDash app with optional auth.""" + if callback is None: + def callback(name: str = "world") -> str: + return f"Hello, {name}!" + return FastDash(callback_fn=callback, auth=auth) + + +# --- baseline: no auth ---------------------------------------------------- + + +class TestAuthDisabled: + def test_default_app_has_no_auth(self): + app = _make_app() + assert app._auth_config is None + + def test_app_root_is_reachable_without_login_when_auth_off(self): + app = _make_app() + client = app.server.test_client() + resp = client.get("/") + assert resp.status_code == 200 + + def test_no_login_form_when_auth_disabled(self): + app = _make_app() + client = app.server.test_client() + # When auth is off, /login isn't claimed by the auth blueprint — + # it falls through to Dash's catch-all (which serves the SPA), + # so the login form is never rendered. + resp = client.get("/login") + assert b"Sign in" not in resp.data + + def test_current_user_returns_none_outside_request_context(self): + # No exception, no login — just None. + assert current_user() is None + + +# --- dict-based auth ------------------------------------------------------ + + +class TestDictAuth: + def test_unauthenticated_root_redirects_to_login(self): + app = _make_app(auth={"alice": "wonderland"}) + client = app.server.test_client() + resp = client.get("/") + assert resp.status_code == 302 + assert "/login" in resp.headers["Location"] + + def test_login_page_renders(self): + app = _make_app(auth={"alice": "wonderland"}) + client = app.server.test_client() + resp = client.get("/login") + assert resp.status_code == 200 + assert b"Sign in" in resp.data + + def test_correct_credentials_grant_access(self): + app = _make_app(auth={"alice": "wonderland"}) + client = app.server.test_client() + resp = client.post( + "/login", + data={"username": "alice", "password": "wonderland", "next": "/"}, + follow_redirects=False, + ) + assert resp.status_code == 302 + # Now the root should be reachable on the authenticated session. + with client.session_transaction() as s: + assert s["_fastdash_user"] == "alice" + resp = client.get("/") + assert resp.status_code == 200 + + def test_wrong_password_returns_401_with_error(self): + app = _make_app(auth={"alice": "wonderland"}) + client = app.server.test_client() + resp = client.post( + "/login", + data={"username": "alice", "password": "WRONG", "next": "/"}, + ) + assert resp.status_code == 401 + assert b"Invalid username or password" in resp.data + + def test_unknown_user_returns_401(self): + app = _make_app(auth={"alice": "wonderland"}) + client = app.server.test_client() + resp = client.post( + "/login", + data={"username": "mallory", "password": "anything", "next": "/"}, + ) + assert resp.status_code == 401 + + def test_logout_clears_session(self): + app = _make_app(auth={"alice": "wonderland"}) + client = app.server.test_client() + client.post( + "/login", + data={"username": "alice", "password": "wonderland", "next": "/"}, + ) + resp = client.get("/logout", follow_redirects=False) + assert resp.status_code == 302 + assert "/login" in resp.headers["Location"] + with client.session_transaction() as s: + assert "_fastdash_user" not in s + + def test_dash_internal_endpoints_blocked_when_unauthenticated(self): + """Dash's _dash-* endpoints would otherwise leak callback state.""" + app = _make_app(auth={"alice": "wonderland"}) + client = app.server.test_client() + # Pick a Dash endpoint that exists by default. + resp = client.get("/_dash-layout") + assert resp.status_code == 401 + + def test_login_preserves_next_url(self): + app = _make_app(auth={"alice": "wonderland"}) + client = app.server.test_client() + resp = client.get("/some/path") + assert resp.status_code == 302 + # The redirect should embed /some/path as the `next` query arg + # so a successful login bounces the user back where they came from. + location = resp.headers["Location"] + assert "/some/path" in location and "next=" in location + + +# --- callable verifier --------------------------------------------------- + + +class TestCallableAuth: + def test_custom_verify_fn_is_called(self): + calls = [] + + def verify(u, p): + calls.append((u, p)) + return u == "carol" and p == "topsecret" + + app = _make_app(auth=verify) + client = app.server.test_client() + resp = client.post( + "/login", + data={"username": "carol", "password": "topsecret", "next": "/"}, + ) + assert resp.status_code == 302 + assert ("carol", "topsecret") in calls + + def test_custom_verify_fn_can_reject(self): + app = _make_app(auth=lambda u, p: False) + client = app.server.test_client() + resp = client.post( + "/login", + data={"username": "anyone", "password": "anything", "next": "/"}, + ) + assert resp.status_code == 401 + + +# --- input validation ---------------------------------------------------- + + +class TestAuthConfigValidation: + def test_invalid_auth_type_raises(self): + with pytest.raises(TypeError, match="auth="): + _make_app(auth=12345) + + def test_falsy_values_disable_auth(self): + for falsy in (None, False, {}): + app = _make_app(auth=falsy) + assert app._auth_config is None + + +# --- header logout button ----------------------------------------------- + + +class TestLogoutButton: + def test_logout_button_in_header_when_auth_enabled(self): + app = _make_app(auth={"alice": "wonderland"}) + layout_str = str(app.app.layout) + assert "logout-button" in layout_str + assert "Sign out" in layout_str + + def test_no_logout_button_when_auth_disabled(self): + app = _make_app() + layout_str = str(app.app.layout) + assert "logout-button" not in layout_str + assert "Sign out" not in layout_str + + def test_logout_button_links_to_logout_route(self): + app = _make_app(auth={"alice": "wonderland"}) + layout_str = str(app.app.layout) + # The button is wrapped in an . + assert "/logout" in layout_str + + +# --- current_user inside a request -------------------------------------- + + +class TestCurrentUserBinding: + def test_current_user_is_accessible_inside_a_request(self): + app = _make_app(auth={"alice": "wonderland"}) + + # Register the probe route *before* the first request lands — + # Flask freezes the route table after that. + @app.server.route("/_whoami") + def _whoami(): + return current_user() or "anonymous" + + client = app.server.test_client() + client.post( + "/login", + data={"username": "alice", "password": "wonderland", "next": "/"}, + ) + + resp = client.get("/_whoami") + assert resp.status_code == 200 + assert resp.data == b"alice"