-
Notifications
You must be signed in to change notification settings - Fork 0
feat: multi-user login #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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) | ||||||||||||||||
|
Comment on lines
+48
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: A malformed hash entry in Prompt for AI agents
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
|
|
||||||||||||||||
| # ---------- 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: | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P3: Prompt for AI agents |
||||||||||||||||
| 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() | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Concurrent writes in Prompt for AI agents |
||||||||||||||||
| users[username] = hash_password(password) | ||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Prompt for AI agents |
||||||||||||||||
| 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) | ||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: The
.secretfile is created with the process's default umask permissions (viaopen(path, "w")) before being locked down to0o600by the subsequentos.chmodcall. A local attacker or another process on the system could read the secret during this microsecond window. Useos.openwithstat.S_IRUSR | stat.S_IWUSRto create the file with restricted permissions from the instant it hits disk, eliminating the race.Prompt for AI agents