From d3daee2801547baea58c822828e4426d36d76c80 Mon Sep 17 00:00:00 2001 From: Jamkris Date: Fri, 10 Jul 2026 11:23:08 +0900 Subject: [PATCH] feat: multi-user login with hashed passwords and signed sessions --- .env.example | 8 +- .gitignore | 4 +- CHANGELOG.md | 3 + README.md | 21 ++-- bastion/auth.py | 44 ++++++-- bastion/i18n.py | 12 +++ bastion/secretkey.py | 41 ++++++++ bastion/users.py | 123 ++++++++++++++++++++++ bastion/web/app.py | 93 +++++++++++++--- bastion/web/templates/base.html | 5 + bastion/web/templates/login.html | 3 +- bastion/web/templates/views/settings.html | 64 +++++++++++ tests/conftest.py | 5 +- tests/test_auth.py | 27 ++++- tests/test_users.py | 82 +++++++++++++++ tests/test_web.py | 45 ++++++++ 16 files changed, 542 insertions(+), 38 deletions(-) create mode 100644 bastion/secretkey.py create mode 100644 bastion/users.py create mode 100644 tests/test_users.py diff --git a/.env.example b/.env.example index 49f053f..4d844f2 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,11 @@ -# Login password. Set this to require a login; leave empty to run open (dev/demo). +# Login password (single-password mode). Leave empty to run open (dev/demo). +# Once you add users on the Settings page, Bastion switches to multi-user login +# and this password is ignored. BASTION_AUTH_PASSWORD= +# Secret used to sign multi-user session cookies. Optional — if unset, a random +# key is generated and stored at /.secret. Set it to share sessions +# across replicas or to control rotation. +# BASTION_SECRET_KEY= # Only needed for bare-metal (systemd) runs as an unprivileged user. Leave as-is for Docker. BASTION_SUDO=false # Jails to query (empty -> auto-discover via `fail2ban-client status`) diff --git a/.gitignore b/.gitignore index 7f6f414..77a164e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,9 @@ __pycache__/ *.mmdb .pytest_cache/ -# Runtime state written by the app (user preferences, trend history). +# Runtime state written by the app (preferences, users, history, session key). data/prefs.json data/history.jsonl +data/users.json +data/.secret data/*.tmp diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c0ea18..4bbd350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). independently toggleable in Settings (in addition to ban-spike alerts). - Home **trend sparklines** for banned IPs and attackers, backed by a capped time-series recorded by the poller. New `GET /api/history` endpoint. +- **Multi-user login** — named accounts with PBKDF2-hashed passwords, managed in + Settings → Users, with signed session cookies. Falls back to the single shared + password when no users exist; HTTP Basic auth works with user credentials. ### Changed - Firewall page now renders rules in **readable nft syntax** (e.g. `tcp dport 22 diff --git a/README.md b/README.md index 56de0b1..d9aae31 100644 --- a/README.md +++ b/README.md @@ -111,14 +111,19 @@ take effect immediately. ## Authentication -Set `BASTION_AUTH_PASSWORD` to require a login; the whole dashboard is then gated -behind a single-password sign-in (session stored in an HMAC-signed cookie). -Repeated failures from one IP are locked out (see *Login security* above). Leave -the password empty to run open (dev/demo) — a warning is logged on startup. -`/healthz`, `/login` and static assets stay public. - -This is a deliberately minimal gate; keep the app behind HTTPS. A full -multi-user model can be layered on later without changing call sites. +Three modes, in order of precedence: + +1. **Multi-user** — add accounts under **Settings → Users**; login is then + username + password (PBKDF2-hashed, stored in `data/users.json`; sessions are + signed with a key at `data/.secret` or `BASTION_SECRET_KEY`). +2. **Single password** — with no users but `BASTION_AUTH_PASSWORD` set, the whole + dashboard is gated behind one shared password (HMAC-signed cookie). +3. **Open** — neither configured; a warning is logged on startup. + +Repeated failures from one IP are locked out (see *Login security* above). +`/healthz`, `/login` and static assets stay public. Header-only clients can use +HTTP Basic auth (username + password in multi-user mode, or any username + the +shared password otherwise). Keep the app behind HTTPS regardless. ## GeoIP (country flags) diff --git a/bastion/auth.py b/bastion/auth.py index 845390c..82e93d3 100644 --- a/bastion/auth.py +++ b/bastion/auth.py @@ -17,6 +17,7 @@ import hmac COOKIE_NAME = "bastion_auth" +USER_COOKIE = "bastion_user" COOKIE_MAX_AGE = 60 * 60 * 24 * 7 # 7 days @@ -34,18 +35,43 @@ def is_authenticated(cookie_value: str | None, password: str) -> bool: return bool(cookie_value) and hmac.compare_digest(cookie_value, expected_token(password)) -def verify_basic(auth_header: str | None, password: str) -> bool: - """Accept HTTP Basic auth where the password half matches. The username is - ignored. Lets header-only API clients (dashboards, scripts) reach the JSON - API without the cookie login flow. Returns False when no password is set.""" - if not password or not auth_header: - return False +def sign_session(username: str, secret: str) -> str: + """Return a `username.signature` session token for multi-user mode.""" + sig = hmac.new(secret.encode(), username.encode(), hashlib.sha256).hexdigest() + return f"{username}.{sig}" + + +def verify_session(cookie_value: str | None, secret: str) -> str | None: + """Return the username if the session token is valid, else None.""" + if not cookie_value or "." not in cookie_value: + return None + username, _, sig = cookie_value.rpartition(".") + if not username: + return None + expected = hmac.new(secret.encode(), username.encode(), hashlib.sha256).hexdigest() + return username if hmac.compare_digest(sig, expected) else None + + +def decode_basic(auth_header: str | None) -> tuple[str, str] | None: + """Return (username, password) from an HTTP Basic header, or None.""" + if not auth_header: + return None scheme, _, encoded = auth_header.partition(" ") if scheme.lower() != "basic" or not encoded: - return False + return None try: decoded = base64.b64decode(encoded, validate=True).decode("utf-8") except (binascii.Error, ValueError, UnicodeDecodeError): + return None + username, sep, password = decoded.partition(":") + return (username, password) if sep else None + + +def verify_basic(auth_header: str | None, password: str) -> bool: + """Accept HTTP Basic auth where the password half matches. The username is + ignored. Lets header-only API clients (dashboards, scripts) reach the JSON + API without the cookie login flow. Returns False when no password is set.""" + if not password: return False - _, sep, candidate = decoded.partition(":") - return bool(sep) and hmac.compare_digest(candidate, password) + creds = decode_basic(auth_header) + return creds is not None and hmac.compare_digest(creds[1], password) diff --git a/bastion/i18n.py b/bastion/i18n.py index 0a3ee60..ef22410 100644 --- a/bastion/i18n.py +++ b/bastion/i18n.py @@ -21,6 +21,12 @@ "wrong_password": "Wrong password", "too_many_attempts": "Too many attempts. Please wait and try again.", "logout": "Logout", + "username": "Username", + "sec_users": "Users", + "sec_users_hint": "Named accounts with their own passwords. Adding the first user switches login to username + password (the single password is then ignored).", + "add_user": "Add user", + "change_password": "Change password", + "no_users": "No users yet — single-password mode is active.", "ban": "Ban", "unban": "Unban", "ban_ip": "Ban an IP", @@ -140,6 +146,12 @@ "wrong_password": "비밀번호가 틀렸습니다", "too_many_attempts": "시도가 너무 많습니다. 잠시 후 다시 시도하세요.", "logout": "로그아웃", + "username": "사용자명", + "sec_users": "사용자", + "sec_users_hint": "각자 비밀번호를 가진 계정입니다. 첫 사용자를 추가하면 로그인이 사용자명+비밀번호로 전환됩니다(단일 비밀번호는 무시됨).", + "add_user": "사용자 추가", + "change_password": "비밀번호 변경", + "no_users": "사용자 없음 — 단일 비밀번호 모드가 활성입니다.", "ban": "차단", "unban": "차단 해제", "ban_ip": "IP 차단", diff --git a/bastion/secretkey.py b/bastion/secretkey.py new file mode 100644 index 0000000..5d79de9 --- /dev/null +++ b/bastion/secretkey.py @@ -0,0 +1,41 @@ +"""Stable server secret used to sign multi-user session cookies. + +Resolution order: BASTION_SECRET_KEY env, then a persisted random key under the +data directory (created once, 0600), then an in-memory fallback if the data dir +is not writable (sessions won't survive a restart, but auth still works). +""" + +from __future__ import annotations + +import logging +import os +import secrets + +from bastion.config import settings + +log = logging.getLogger("bastion.secretkey") + +_ephemeral: str | None = None + + +def get() -> str: + env = os.environ.get("BASTION_SECRET_KEY") + if env: + return env + path = os.path.join(settings.data_dir, ".secret") + try: + if os.path.exists(path): + with open(path, "r", encoding="utf-8") as f: + return f.read().strip() + os.makedirs(settings.data_dir, exist_ok=True) + key = secrets.token_hex(32) + with open(path, "w", encoding="utf-8") as f: + f.write(key) + os.chmod(path, 0o600) + return key + except OSError as e: + global _ephemeral + if _ephemeral is None: + log.warning("data dir not writable (%s); using an ephemeral session key", e) + _ephemeral = secrets.token_hex(32) + return _ephemeral diff --git a/bastion/users.py b/bastion/users.py new file mode 100644 index 0000000..bd921b2 --- /dev/null +++ b/bastion/users.py @@ -0,0 +1,123 @@ +"""User account store with salted password hashing. + +Layered on top of the single-password gate: when at least one user exists, +Bastion runs in multi-user mode (username + password login); with no users it +falls back to `BASTION_AUTH_PASSWORD`, and with neither it runs open. + +Passwords are hashed with PBKDF2-HMAC-SHA256 (per-user random salt). The hashing +helpers are pure so they can be unit-tested with a fixed salt; the store does +best-effort JSON IO under the data directory. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import os +import re + +from bastion.config import settings + +log = logging.getLogger("bastion.users") + +USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,32}$") +_ITERATIONS = 200_000 + + +def hash_password(password: str, *, salt: bytes | str | None = None, iterations: int = _ITERATIONS) -> str: + """Return a self-describing `pbkdf2_sha256$iters$salt$hash` string. + Pass `salt` (hex or bytes) for a deterministic result in tests.""" + if salt is None: + salt = os.urandom(16) + elif isinstance(salt, str): + salt = bytes.fromhex(salt) + dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations) + return f"pbkdf2_sha256${iterations}${salt.hex()}${dk.hex()}" + + +def verify_hash(password: str, stored: str) -> bool: + """Constant-time check of `password` against a stored hash string.""" + try: + algo, iters, salt_hex, _ = stored.split("$") + except (ValueError, AttributeError): + return False + if algo != "pbkdf2_sha256": + return False + candidate = hash_password(password, salt=salt_hex, iterations=int(iters)) + return hmac.compare_digest(candidate, stored) + + +# ---------- store (best-effort JSON IO) ---------- +def _path() -> str: + return os.path.join(settings.data_dir, "users.json") + + +def load() -> dict[str, str]: + path = _path() + if not os.path.exists(path): + return {} + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except (OSError, ValueError) as e: + log.warning("could not read users (%s)", e) + return {} + + +def save(users: dict[str, str]) -> None: + os.makedirs(settings.data_dir, exist_ok=True) + path = _path() + tmp = path + ".tmp" + with open(tmp, "w", encoding="utf-8") as f: + json.dump(users, f, indent=2, sort_keys=True) + os.replace(tmp, path) + + +def count() -> int: + return len(load()) + + +def exists(username: str) -> bool: + return username in load() + + +def usernames() -> list[str]: + return sorted(load().keys()) + + +def add(username: str, password: str) -> None: + if not USERNAME_RE.match(username or ""): + raise ValueError(f"invalid username: {username!r}") + if not password: + raise ValueError("password must not be empty") + users = load() + users[username] = hash_password(password) + save(users) + log.info("user added: %s", username) + + +def set_password(username: str, password: str) -> None: + if not password: + raise ValueError("password must not be empty") + users = load() + if username not in users: + raise ValueError(f"no such user: {username!r}") + users[username] = hash_password(password) + save(users) + log.info("password changed: %s", username) + + +def remove(username: str) -> None: + users = load() + if username in users: + del users[username] + save(users) + log.info("user removed: %s", username) + + +def verify(username: str, password: str) -> bool: + stored = load().get(username) + return bool(stored) and verify_hash(password, stored) diff --git a/bastion/web/app.py b/bastion/web/app.py index fe40f8c..e61f591 100644 --- a/bastion/web/app.py +++ b/bastion/web/app.py @@ -21,7 +21,7 @@ from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from bastion import __version__, auth, history, i18n, prefs +from bastion import __version__, auth, history, i18n, prefs, secretkey, users from bastion.config import settings from bastion.ratelimit import RateLimiter from bastion.runner import CommandError @@ -54,8 +54,30 @@ async def _lifespan(app: FastAPI): # Paths reachable without authentication. _PUBLIC_PATHS = {"/login", "/logout", "/healthz", "/favicon.ico"} -if not settings.auth_password: - log.warning("BASTION_AUTH_PASSWORD is not set — the dashboard is OPEN (no login).") +if not settings.auth_password and users.count() == 0: + log.warning("No users and no BASTION_AUTH_PASSWORD — the dashboard is OPEN (no login).") + + +def _request_authenticated(request: Request) -> bool: + """Multi-user mode (any user exists) takes precedence; otherwise fall back + to the single shared password; otherwise the dashboard is open.""" + if users.count() > 0: + if auth.verify_session(request.cookies.get(auth.USER_COOKIE), secretkey.get()): + return True + creds = auth.decode_basic(request.headers.get("authorization")) + return bool(creds and users.verify(creds[0], creds[1])) + password = settings.auth_password + if not password: + return True + if auth.is_authenticated(request.cookies.get(auth.COOKIE_NAME), password): + return True + return auth.verify_basic(request.headers.get("authorization"), password) + + +def _current_user(request: Request) -> str | None: + if users.count() == 0: + return None + return auth.verify_session(request.cookies.get(auth.USER_COOKIE), secretkey.get()) # ---------- Intrusion alerting (background poller) ---------- @@ -201,14 +223,10 @@ async def _alert_loop() -> None: @app.middleware("http") async def auth_gate(request: Request, call_next): - password = settings.auth_password path = request.url.path - if not password or path in _PUBLIC_PATHS or path.startswith("/static/"): - return await call_next(request) - if auth.is_authenticated(request.cookies.get(auth.COOKIE_NAME), password): + if path in _PUBLIC_PATHS or path.startswith("/static/"): return await call_next(request) - # Header-only clients (Homepage/customapi, scripts) can use HTTP Basic auth. - if auth.verify_basic(request.headers.get("authorization"), password): + if _request_authenticated(request): return await call_next(request) if path.startswith(("/api", "/panel", "/action")): return JSONResponse({"error": "unauthorized"}, status_code=401) @@ -237,7 +255,9 @@ def _ctx(request: Request, lang: str, active: str = "", **extra) -> dict: "version": __version__, "active": active, "geoip_active": geoip.is_active(), - "auth_enabled": bool(settings.auth_password), + "auth_enabled": bool(settings.auth_password) or users.count() > 0, + "multi_user": users.count() > 0, + "current_user": _current_user(request), **extra, } @@ -278,15 +298,13 @@ async def login_submit(request: Request): ) form = await request.form() - if auth.verify_password(form.get("password"), settings.auth_password): + ok, cookie_name, cookie_value = _check_login(form) + if ok: _login_limiter.clear(key) response = RedirectResponse("/", status_code=303) response.set_cookie( - auth.COOKIE_NAME, - auth.expected_token(settings.auth_password), - httponly=True, - samesite="lax", - max_age=auth.COOKIE_MAX_AGE, + cookie_name, cookie_value, + httponly=True, samesite="lax", max_age=auth.COOKIE_MAX_AGE, ) return response @@ -297,10 +315,24 @@ async def login_submit(request: Request): ) +def _check_login(form) -> tuple[bool, str, str]: + """Validate credentials. Multi-user mode uses username+password and issues a + signed session cookie; otherwise the single shared password is checked.""" + if users.count() > 0: + username = form.get("username", "") + if users.verify(username, form.get("password", "")): + return True, auth.USER_COOKIE, auth.sign_session(username, secretkey.get()) + return False, "", "" + if auth.verify_password(form.get("password"), settings.auth_password): + return True, auth.COOKIE_NAME, auth.expected_token(settings.auth_password) + return False, "", "" + + @app.get("/logout") def logout(): response = RedirectResponse("/login", status_code=303) response.delete_cookie(auth.COOKIE_NAME) + response.delete_cookie(auth.USER_COOKIE) return response @@ -412,7 +444,9 @@ def settings_page(request: Request): request, "views/settings.html", _ctx(request, _lang(request), active="settings", n=p["notifications"], a=p["allowlist"], s=p["security"], + users_list=users.usernames(), saved=request.query_params.get("saved"), + err=request.query_params.get("err"), test_result=request.query_params.get("test")), ) @@ -428,6 +462,33 @@ async def settings_test_notify(request: Request): return RedirectResponse(f"/settings?test={'ok' if ok else 'fail'}", status_code=303) +@app.post("/settings/users/add") +async def settings_users_add(request: Request): + form = await request.form() + try: + users.add(form.get("username", ""), form.get("password", "")) + except ValueError as e: + return RedirectResponse(f"/settings?err={quote(str(e))}", status_code=303) + return RedirectResponse("/settings?saved=users", status_code=303) + + +@app.post("/settings/users/password") +async def settings_users_password(request: Request): + form = await request.form() + try: + users.set_password(form.get("username", ""), form.get("password", "")) + except ValueError as e: + return RedirectResponse(f"/settings?err={quote(str(e))}", status_code=303) + return RedirectResponse("/settings?saved=users", status_code=303) + + +@app.post("/settings/users/remove") +async def settings_users_remove(request: Request): + form = await request.form() + users.remove(form.get("username", "")) + return RedirectResponse("/settings?saved=users", status_code=303) + + @app.post("/settings/{section}") async def settings_save(request: Request, section: str): form = await request.form() diff --git a/bastion/web/templates/base.html b/bastion/web/templates/base.html index 1ed83dd..5130e19 100644 --- a/bastion/web/templates/base.html +++ b/bastion/web/templates/base.html @@ -66,6 +66,10 @@ } .profile .menu a:hover { background: #1c2128; } .profile .menu .ico { width: 16px; text-align: center; color: #7d8590; } + .profile .menu-user { + padding: 6px 10px 8px; margin-bottom: 4px; border-bottom: 1px solid #21262d; + color: #7d8590; font-size: 12px; word-break: break-all; + } /* Summary bar */ .summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; } @@ -185,6 +189,7 @@

{% block title %}Bastion{% endblock %}

diff --git a/bastion/web/templates/login.html b/bastion/web/templates/login.html index b6de0a6..9f607cf 100644 --- a/bastion/web/templates/login.html +++ b/bastion/web/templates/login.html @@ -37,7 +37,8 @@

🛡 Bastion

{{ t("login_title") }}

{% if error %}
{{ error }}
{% endif %} - + {% if multi_user %}{% endif %} + diff --git a/bastion/web/templates/views/settings.html b/bastion/web/templates/views/settings.html index d06d0e1..4dd312e 100644 --- a/bastion/web/templates/views/settings.html +++ b/bastion/web/templates/views/settings.html @@ -76,6 +76,7 @@ {% if saved %}{% endif %} + {% if err %}{% endif %} {% if test_result == "ok" %}{% endif %} {% if test_result == "fail" %}{% endif %} @@ -200,6 +201,69 @@

{{ t("sec_security") }}

+ + +
+

{{ t("sec_users") }}

+
+

{{ t("sec_users_hint") }}

+ + {% if users_list %} + + {% for u in users_list %} + + + + + {% endfor %} +
{{ u }} +
+ + +
+
+ {% else %} +

{{ t("no_users") }}

+ {% endif %} + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + {% if users_list %} +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ {% endif %} +
+
diff --git a/tests/conftest.py b/tests/conftest.py index c624cfb..ebc51a5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,11 @@ import os +import tempfile # Isolate the suite from a developer's local .env (which may enable auth or -# demo mode). This must run before any `bastion.*` import triggers config load. +# demo mode) and from the real ./data dir (prefs, users, history, secret key). +# This must run before any `bastion.*` import triggers config load. os.environ["BASTION_NO_DOTENV"] = "1" +os.environ["BASTION_DATA_DIR"] = tempfile.mkdtemp(prefix="bastion-test-") for _k in ("BASTION_AUTH_PASSWORD", "BASTION_DEMO", "BASTION_SUDO", "BASTION_JAILS"): os.environ.pop(_k, None) diff --git a/tests/test_auth.py b/tests/test_auth.py index 6a265c3..080c9e9 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,6 +1,13 @@ import base64 -from bastion.auth import expected_token, is_authenticated, verify_basic, verify_password +from bastion.auth import ( + expected_token, + is_authenticated, + sign_session, + verify_basic, + verify_password, + verify_session, +) def _basic(user, pw): @@ -54,3 +61,21 @@ def test_verify_basic_rejects_malformed_or_missing(): assert verify_basic("Bearer token", "secret") is False assert verify_basic("Basic !!!notbase64", "secret") is False assert verify_basic(_basic("u", "secret"), "") is False # no password configured + + +def test_session_roundtrip(): + token = sign_session("alice", "server-secret") + assert verify_session(token, "server-secret") == "alice" + + +def test_session_rejects_tampered_or_wrong_secret(): + token = sign_session("alice", "server-secret") + assert verify_session(token, "other-secret") is None + assert verify_session("alice.deadbeef", "server-secret") is None + assert verify_session("admin." + token.split(".")[1], "server-secret") is None + + +def test_session_rejects_malformed(): + assert verify_session(None, "s") is None + assert verify_session("nodot", "s") is None + assert verify_session(".sig", "s") is None diff --git a/tests/test_users.py b/tests/test_users.py new file mode 100644 index 0000000..3245fce --- /dev/null +++ b/tests/test_users.py @@ -0,0 +1,82 @@ +from types import SimpleNamespace + +import pytest + +from bastion import users + + +@pytest.fixture +def store(tmp_path, monkeypatch): + monkeypatch.setattr(users, "settings", SimpleNamespace(data_dir=str(tmp_path))) + return tmp_path + + +# ---------- pure hashing ---------- +def test_hash_is_deterministic_with_fixed_salt(): + salt = "00" * 16 + assert users.hash_password("pw", salt=salt) == users.hash_password("pw", salt=salt) + + +def test_hash_differs_by_salt(): + assert users.hash_password("pw", salt="00" * 16) != users.hash_password("pw", salt="11" * 16) + + +def test_verify_hash_roundtrip(): + stored = users.hash_password("secret", salt="ab" * 16) + assert users.verify_hash("secret", stored) is True + assert users.verify_hash("wrong", stored) is False + + +def test_verify_hash_rejects_malformed(): + assert users.verify_hash("x", "not-a-hash") is False + assert users.verify_hash("x", "") is False + + +# ---------- store ---------- +def test_add_and_verify(store): + users.add("alice", "hunter2") + assert users.verify("alice", "hunter2") is True + assert users.verify("alice", "nope") is False + assert users.count() == 1 + assert users.exists("alice") + + +def test_stored_password_is_hashed_not_plaintext(store): + users.add("bob", "plaintext-pw") + raw = (store / "users.json").read_text(encoding="utf-8") + assert "plaintext-pw" not in raw + assert "pbkdf2_sha256$" in raw + + +def test_set_password(store): + users.add("carol", "old") + users.set_password("carol", "new") + assert users.verify("carol", "old") is False + assert users.verify("carol", "new") is True + + +def test_set_password_unknown_user_raises(store): + with pytest.raises(ValueError): + users.set_password("ghost", "x") + + +def test_remove(store): + users.add("dave", "pw") + users.remove("dave") + assert users.exists("dave") is False + + +def test_add_rejects_invalid_username(store): + with pytest.raises(ValueError): + users.add("bad name!", "pw") + + +def test_add_rejects_empty_password(store): + with pytest.raises(ValueError): + users.add("eve", "") + + +def test_usernames_sorted(store): + users.add("zoe", "pw") + users.add("amy", "pw") + assert users.usernames() == ["amy", "zoe"] diff --git a/tests/test_web.py b/tests/test_web.py index 48bab06..f37d649 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -1,3 +1,4 @@ +import pytest from fastapi.testclient import TestClient from bastion.ratelimit import RateLimiter @@ -107,6 +108,50 @@ def test_home_renders_with_sparkline_context(monkeypatch): assert '