mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-05 10:55:29 +00:00
Follow-up hardening beyond the explicit review findings: - Propagate session revocation across uvicorn workers: token validation now syncs issuance AND revocation from sessions.json (mtime-gated), _save_sessions merges on-disk state under an inter-process flock so concurrent workers can't lose each other's sessions, and revocation tombstones prevent a just-revoked token from being re-merged. - Restrict sessions.json and auth.json to 0600 (bearer tokens and password hashes; same policy as data/app.db, #4420), applied atomically at write time and retroactively at load. - Password-login session cookie: SECURE_COOKIES=false can no longer downgrade the cookie when the request arrived over HTTPS (spoofable X-Forwarded-Proto still requires TRUST_PROXY_HEADERS opt-in). - Document why OIDC state tokens are deliberately not single-use and which mechanisms bound the replay window. - Warn once per process (not twice per login) when OIDC_ALLOW_INSECURE_COOKIES is enabled; pass the variable through the Compose files so the documented dev override actually reaches containers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GRiLb12nnLnBnYsg14oSWd
1100 lines
48 KiB
Python
1100 lines
48 KiB
Python
"""
|
|
Authentication module — multi-user password hashing, session tokens, config persistence.
|
|
Config stored in data/auth.json. Uses bcrypt directly.
|
|
"""
|
|
|
|
import enum
|
|
import json
|
|
import os
|
|
import secrets
|
|
import threading
|
|
import time
|
|
import logging
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
from typing import Optional, Dict, Any, List
|
|
|
|
# POSIX-only: fcntl provides inter-process file locking used by
|
|
# _interprocess_auth_lock. On native Windows it doesn't exist, so
|
|
# we fall back to intra-process-only serialisation (single-worker
|
|
# deployments are the norm there, and OIDC defaults to off).
|
|
try:
|
|
import fcntl
|
|
HAS_FCNTL = True
|
|
except ImportError:
|
|
HAS_FCNTL = False
|
|
fcntl = None # type: ignore[assignment]
|
|
|
|
import bcrypt
|
|
import pyotp
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
from core.atomic_io import atomic_write_json as _atomic_write_json # noqa: E402
|
|
from core.middleware import INTERNAL_TOOL_USER # noqa: E402
|
|
|
|
DEFAULT_PRIVILEGES = {
|
|
"can_use_agent": True,
|
|
"can_use_browser": True,
|
|
"can_use_bash": False,
|
|
"can_use_documents": True,
|
|
"can_use_research": True,
|
|
"can_generate_images": True,
|
|
"can_manage_memory": True,
|
|
"max_messages_per_day": 0,
|
|
"allowed_models": [],
|
|
"allowed_models_restricted": False,
|
|
# Explicit "block every model" sentinel. An empty `allowed_models` list is
|
|
# ambiguous — it's also what gets sent when the admin clicks "[All]" — so
|
|
# we need a dedicated flag to express "this user may use no models at all"
|
|
# distinctly from "this user has no restriction".
|
|
"block_all_models": False,
|
|
}
|
|
|
|
# Admins get everything
|
|
ADMIN_PRIVILEGES = {k: (True if isinstance(v, bool) else (0 if isinstance(v, int) else [])) for k, v in DEFAULT_PRIVILEGES.items()}
|
|
ADMIN_PRIVILEGES["allowed_models_restricted"] = False
|
|
# Admins must never be blocked from using models — the generic dict
|
|
# comprehension above flips every boolean default to True, which would be
|
|
# backwards for this sentinel.
|
|
ADMIN_PRIVILEGES["block_all_models"] = False
|
|
|
|
from src.constants import AUTH_FILE, PASSWORD_MIN_LENGTH
|
|
DEFAULT_AUTH_PATH = AUTH_FILE
|
|
TOKEN_TTL = 60 * 60 * 24 * 7 # 7 days
|
|
|
|
# Usernames the auth + middleware layer reserve as internal "synthetic owner"
|
|
# sentinels; they must never belong to a real account. The most dangerous is
|
|
# "internal-tool": `core.middleware.require_admin` treats any request whose
|
|
# `current_user == "internal-tool"` as the in-process tool loopback and grants
|
|
# admin, and because the cookie auth path sets `current_user` to the raw
|
|
# username, an account literally named "internal-tool" would be silently
|
|
# treated as an admin by every `require_admin`-gated route. "api" collides with
|
|
# the bearer-token owner-attribution sentinel. "demo"/"system" round out the
|
|
# synthetic-owner set the rest of the codebase already special-cases (see
|
|
# `_SYNTHETIC_OWNERS` in routes/assistant_routes.py and the matching guards in
|
|
# src/task_scheduler.py / routes/research_routes.py) — a real account with one
|
|
# of those names would be denied an assistant and inconsistently owner-scoped.
|
|
# Refuse to create or rename into any of them so the sentinels can't be
|
|
# impersonated. (Keep this in sync with that synthetic-owner set.)
|
|
RESERVED_USERNAMES = frozenset({INTERNAL_TOOL_USER, "api", "demo", "system"})
|
|
|
|
# Intra-process mutex that serialises all auth.json mutations within the same
|
|
# Python process. fcntl.flock (used by _interprocess_auth_lock) only blocks
|
|
# *other* processes — two threads in the same process calling flock(LOCK_EX)
|
|
# on the same file both succeed immediately. This lock closes that gap so the
|
|
# critical section is serialised across both threads and workers.
|
|
#
|
|
# RLock (reentrant) so a mutation method that acquires the inter-process lock
|
|
# can safely call another mutation method that also acquires it (e.g. setup()
|
|
# calling create_user()).
|
|
_auth_intraprocess_lock = threading.RLock()
|
|
|
|
|
|
def normalize_known_username(users: Dict[str, Any], username: str | None) -> Optional[str]:
|
|
"""Return a normalized username only when it exists in the auth user map."""
|
|
key = str(username or "").strip().lower()
|
|
if not key or key not in users:
|
|
return None
|
|
return key
|
|
|
|
|
|
def _hash_password(password: str) -> str:
|
|
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
|
|
|
|
|
def _verify_password(password: str, hashed: str) -> bool:
|
|
return bcrypt.checkpw(password.encode("utf-8"), hashed.encode("utf-8"))
|
|
|
|
|
|
class SetAdminResult(enum.Enum):
|
|
"""Outcome of AuthManager.set_admin, so callers can map each case to a
|
|
precise response instead of guessing from a bare bool."""
|
|
OK = "ok"
|
|
USER_NOT_FOUND = "user_not_found"
|
|
NOT_AUTHORIZED = "not_authorized" # requester is not an admin
|
|
LAST_ADMIN = "last_admin" # would remove the last remaining admin
|
|
|
|
|
|
class AuthManager:
|
|
"""Manages multi-user password + session-token auth system."""
|
|
|
|
def __init__(self, auth_path: str = DEFAULT_AUTH_PATH):
|
|
self.auth_path = auth_path
|
|
self._sessions_path = os.path.join(os.path.dirname(auth_path), "sessions.json")
|
|
self._config: Dict[str, Any] = {}
|
|
self._sessions: Dict[str, Dict[str, Any]] = {} # token -> {username, expiry}
|
|
# Guards mutations of self._sessions and the on-disk sessions.json.
|
|
# Validate/create/revoke run concurrently from the FastAPI threadpool.
|
|
self._sessions_lock = threading.RLock()
|
|
# Guards all mutations of self._config and the on-disk auth.json so
|
|
# concurrent create/delete/rename/privilege operations don't interleave
|
|
# and corrupt the user database.
|
|
self._config_lock = threading.Lock()
|
|
# Path for the inter-process file lock (fcntl.flock). Shared across
|
|
# all uvicorn workers so first-admin bootstrap and auth.json mutations
|
|
# are serialised across processes, not just threads within one worker.
|
|
self._ipc_lock_path = auth_path + ".lock"
|
|
# mtime of sessions.json at last load — lets validate_token cheaply
|
|
# detect sessions written by other uvicorn workers (see
|
|
# _reload_sessions_if_changed).
|
|
self._sessions_mtime_ns = -1
|
|
# Tokens present in sessions.json at the last disk sync. Used to
|
|
# distinguish "revoked by another worker" (was on disk, now gone —
|
|
# drop it) from "issued locally moments ago, racing its own save"
|
|
# (never seen on disk — keep it).
|
|
self._disk_tokens: set = set()
|
|
# Tokens this worker revoked whose removal may not yet be visible
|
|
# on disk. A disk sync must never re-add these; pruned once the
|
|
# on-disk file no longer contains them.
|
|
self._revoked_tokens: set = set()
|
|
self._load()
|
|
self._load_sessions()
|
|
self._migrate_single_user()
|
|
self._drop_reserved_loaded_users()
|
|
self._migrate_legacy_admin_role()
|
|
|
|
def _load(self):
|
|
try:
|
|
if os.path.exists(self.auth_path):
|
|
# Contains password hashes — restrict pre-existing files
|
|
# written before the 0600 policy.
|
|
try:
|
|
os.chmod(self.auth_path, 0o600)
|
|
except OSError:
|
|
pass
|
|
with open(self.auth_path, "r", encoding="utf-8") as f:
|
|
self._config = json.load(f)
|
|
# Normalize all stored usernames to lowercase so they match
|
|
# the .strip().lower() applied at login/verify time. Fixes
|
|
# "Invalid credentials" when auth.json was written with
|
|
# mixed-case keys (e.g. via manual edit or a future migration).
|
|
if "users" in self._config:
|
|
self._config["users"] = {
|
|
k.strip().lower(): v
|
|
for k, v in self._config["users"].items()
|
|
}
|
|
logger.info("Auth config loaded")
|
|
else:
|
|
self._config = {}
|
|
logger.info("No auth config found — first-run setup required")
|
|
except Exception as e:
|
|
logger.error(f"Failed to load auth config: {e}")
|
|
self._config = {}
|
|
|
|
def _load_sessions(self):
|
|
"""Load persisted session tokens from disk, pruning expired ones."""
|
|
try:
|
|
if os.path.exists(self._sessions_path):
|
|
# Session tokens are bearer credentials — never leave the
|
|
# file readable by other local users (same policy as
|
|
# data/app.db, #4420).
|
|
try:
|
|
os.chmod(self._sessions_path, 0o600)
|
|
except OSError:
|
|
pass
|
|
self._sessions_mtime_ns = os.stat(self._sessions_path).st_mtime_ns
|
|
with open(self._sessions_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
now = time.time()
|
|
self._sessions = {k: v for k, v in data.items() if v.get("expiry", 0) > now}
|
|
self._disk_tokens = set(data)
|
|
pruned = len(data) - len(self._sessions)
|
|
if pruned > 0:
|
|
self._save_sessions()
|
|
logger.info(f"Loaded {len(self._sessions)} session(s) from disk")
|
|
except Exception as e:
|
|
logger.error(f"Failed to load sessions: {e}")
|
|
self._sessions = {}
|
|
|
|
def _reload_sessions_if_changed(self):
|
|
"""Sync session state written by other uvicorn workers.
|
|
|
|
The OIDC callback (or a password login/logout) may run on one
|
|
worker while the browser's next request lands on another; each
|
|
worker loads sessions.json only at startup, so cross-worker
|
|
issuance and revocation would otherwise be invisible. Called on
|
|
every token validation: when the file's mtime has changed since
|
|
the last sync, re-read it and
|
|
|
|
- add unknown unexpired tokens (issued by another worker), and
|
|
- drop in-memory tokens that were on disk at the last sync but
|
|
are gone now (revoked by another worker).
|
|
|
|
A token never yet seen on disk is kept — it was issued locally
|
|
moments ago and may be racing its own _save_sessions. The mtime
|
|
gate keeps the steady-state cost at one os.stat per validation,
|
|
not a JSON parse.
|
|
"""
|
|
try:
|
|
stat = os.stat(self._sessions_path)
|
|
except OSError:
|
|
return
|
|
with self._sessions_lock:
|
|
if stat.st_mtime_ns == self._sessions_mtime_ns:
|
|
return
|
|
try:
|
|
with open(self._sessions_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
except Exception as e:
|
|
logger.error(f"Failed to reload sessions: {e}")
|
|
return
|
|
self._sessions_mtime_ns = stat.st_mtime_ns
|
|
if not isinstance(data, dict):
|
|
return
|
|
self._apply_disk_sessions(data)
|
|
|
|
def _apply_disk_sessions(self, data: Dict[str, Any]) -> None:
|
|
"""Merge parsed sessions.json content into memory.
|
|
|
|
Caller must hold ``_sessions_lock``. Adds unknown unexpired
|
|
tokens (unless this worker revoked them and the removal hasn't
|
|
reached disk yet), drops tokens revoked by other workers, and
|
|
refreshes the disk-snapshot bookkeeping.
|
|
"""
|
|
now = time.time()
|
|
for tok, sess in data.items():
|
|
if (
|
|
tok not in self._sessions
|
|
and tok not in self._revoked_tokens
|
|
and isinstance(sess, dict)
|
|
and sess.get("expiry", 0) > now
|
|
):
|
|
self._sessions[tok] = sess
|
|
revoked_elsewhere = [
|
|
tok for tok in self._sessions
|
|
if tok not in data and tok in self._disk_tokens
|
|
]
|
|
for tok in revoked_elsewhere:
|
|
self._sessions.pop(tok, None)
|
|
self._disk_tokens = set(data)
|
|
# A tombstone is only needed while the token is still on disk.
|
|
self._revoked_tokens &= self._disk_tokens
|
|
|
|
@contextmanager
|
|
def _interprocess_sessions_lock(self):
|
|
"""Serialise sessions.json read-merge-write cycles across uvicorn
|
|
workers. Separate lock file from the auth.json IPC lock so a
|
|
session save can never deadlock a caller already holding the auth
|
|
lock (flock is not re-entrant across file descriptors)."""
|
|
if not HAS_FCNTL:
|
|
yield
|
|
return
|
|
fd = os.open(self._sessions_path + ".lock", os.O_CREAT | os.O_RDWR, 0o600)
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
yield
|
|
finally:
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
os.close(fd)
|
|
|
|
def _save_sessions(self):
|
|
"""Persist session tokens to disk (atomic, merge-on-write).
|
|
|
|
Merges the current on-disk state before writing, under an
|
|
inter-process flock — a plain overwrite would clobber sessions
|
|
issued by other workers since this worker's last sync (lost
|
|
update). Tombstones in ``_revoked_tokens`` keep just-revoked
|
|
tokens from being re-merged and resurrected.
|
|
"""
|
|
try:
|
|
with self._interprocess_sessions_lock(), self._sessions_lock:
|
|
try:
|
|
with open(self._sessions_path, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
if isinstance(data, dict):
|
|
self._apply_disk_sessions(data)
|
|
except OSError:
|
|
pass # first save — no file yet
|
|
except Exception as e:
|
|
logger.error(f"Failed to merge sessions before save: {e}")
|
|
snapshot = dict(self._sessions)
|
|
_atomic_write_json(self._sessions_path, snapshot, mode=0o600)
|
|
self._disk_tokens = set(snapshot)
|
|
self._revoked_tokens &= self._disk_tokens
|
|
except Exception as e:
|
|
logger.error(f"Failed to save sessions: {e}")
|
|
|
|
def _migrate_single_user(self):
|
|
"""Migrate old single-user format to multi-user format."""
|
|
if "password_hash" in self._config and "users" not in self._config:
|
|
old_user = str(self._config.get("username", "admin") or "admin").strip().lower()
|
|
if old_user in RESERVED_USERNAMES:
|
|
logger.warning(
|
|
"Migrating legacy single-user reserved username '%s' to 'admin'",
|
|
old_user,
|
|
)
|
|
old_user = "admin"
|
|
old_hash = self._config["password_hash"]
|
|
with self._config_lock:
|
|
self._config = {
|
|
"users": {
|
|
old_user: {
|
|
"password_hash": old_hash,
|
|
"created": time.time(),
|
|
"is_admin": True,
|
|
}
|
|
}
|
|
}
|
|
self._save()
|
|
logger.info(f"Migrated single-user auth to multi-user (admin: {old_user})")
|
|
|
|
def _drop_reserved_loaded_users(self):
|
|
"""Fail closed for legacy/manual auth rows that collide with sentinels."""
|
|
users = self._config.get("users")
|
|
if not isinstance(users, dict):
|
|
return
|
|
normalized = {}
|
|
removed = []
|
|
for username, data in users.items():
|
|
key = str(username or "").strip().lower()
|
|
if not key:
|
|
continue
|
|
if key in RESERVED_USERNAMES:
|
|
removed.append(key)
|
|
continue
|
|
normalized[key] = data
|
|
if removed or normalized != users:
|
|
with self._config_lock:
|
|
self._config["users"] = normalized
|
|
self._save()
|
|
if removed:
|
|
logger.warning(
|
|
"Removed reserved username(s) from auth config: %s",
|
|
", ".join(sorted(set(removed))),
|
|
)
|
|
|
|
def _migrate_legacy_admin_role(self):
|
|
"""Normalize setup.py's old role='admin' marker to is_admin=True."""
|
|
changed = False
|
|
for username, user in self.users.items():
|
|
if user.get("role") == "admin" and "is_admin" not in user:
|
|
user["is_admin"] = True
|
|
changed = True
|
|
logger.info(f"Migrated legacy admin role for '{username}'")
|
|
if changed:
|
|
self._save()
|
|
|
|
def _save(self):
|
|
# Password hashes — owner-only, same policy as sessions.json.
|
|
_atomic_write_json(self.auth_path, self._config, indent=2, mode=0o600)
|
|
|
|
@property
|
|
def users(self) -> Dict[str, Any]:
|
|
return self._config.get("users", {})
|
|
|
|
@property
|
|
def signup_enabled(self) -> bool:
|
|
return self._config.get("signup_enabled", False)
|
|
|
|
@signup_enabled.setter
|
|
def signup_enabled(self, value: bool):
|
|
with self._interprocess_auth_lock(), self._config_lock:
|
|
self._load()
|
|
self._config["signup_enabled"] = value
|
|
self._save()
|
|
|
|
@property
|
|
def is_configured(self) -> bool:
|
|
return len(self.users) > 0
|
|
|
|
def policy(self) -> dict:
|
|
"""Return public auth policy constants for the frontend."""
|
|
return {
|
|
"password_min_length": PASSWORD_MIN_LENGTH,
|
|
"reserved_usernames": sorted(RESERVED_USERNAMES),
|
|
"signup_enabled": self.signup_enabled,
|
|
"session_days": TOKEN_TTL // 86400,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Account management
|
|
# ------------------------------------------------------------------
|
|
|
|
@contextmanager
|
|
def _interprocess_auth_lock(self):
|
|
"""Acquire an exclusive lock on auth.json — serialised across both
|
|
threads (intra-process) and workers/processes (inter-process).
|
|
|
|
The module-level threading.Lock serialises threads within the same
|
|
Python process. fcntl.flock serialises across different processes
|
|
(uvicorn workers). The kernel releases flock automatically when
|
|
the process exits, so a crash cannot leave a stale lock.
|
|
|
|
On platforms without fcntl (native Windows), this degrades to
|
|
intra-process-only serialisation. OIDC defaults to off and
|
|
single-worker deployments are the norm there, so the degraded
|
|
mode is safe for most Windows use cases.
|
|
"""
|
|
with _auth_intraprocess_lock:
|
|
if not HAS_FCNTL:
|
|
yield
|
|
return
|
|
# Open in read-write mode; create the lock file if it doesn't exist.
|
|
fd = os.open(self._ipc_lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
try:
|
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
yield
|
|
finally:
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
os.close(fd)
|
|
|
|
def setup(self, username: str, password: str) -> bool:
|
|
"""First-run admin setup. Only works if no users exist."""
|
|
username = username.strip().lower()
|
|
with self._interprocess_auth_lock(), self._config_lock:
|
|
# Reload from disk so we see what another worker may have
|
|
# written since our last _load().
|
|
self._load()
|
|
if self.is_configured:
|
|
return False
|
|
# _create_user_locked assumes the interprocess lock is already
|
|
# held, avoiding a nested fcntl.flock deadlock (flock is not
|
|
# reentrant across different file descriptors).
|
|
return self._create_user_locked(username, password, is_admin=True)
|
|
|
|
def create_user(self, username: str, password: str, is_admin: bool = False) -> bool:
|
|
"""Create a new user account.
|
|
|
|
Serialised across workers via the shared inter-process lock so a
|
|
concurrent OIDC admin sync cannot lose a newly-created user.
|
|
"""
|
|
username = username.strip().lower()
|
|
if not username:
|
|
return False
|
|
if username in RESERVED_USERNAMES:
|
|
logger.warning("Refused to create reserved username '%s'", username)
|
|
return False
|
|
with self._interprocess_auth_lock(), self._config_lock:
|
|
self._load()
|
|
return self._create_user_locked(username, password, is_admin)
|
|
|
|
def _create_user_locked(self, username: str, password: str, is_admin: bool) -> bool:
|
|
"""Internal helper — caller must hold _interprocess_auth_lock
|
|
and _config_lock. Does not reload (caller did that)."""
|
|
username = username.strip().lower()
|
|
if username in RESERVED_USERNAMES:
|
|
logger.warning("Refused to create reserved username '%s'", username)
|
|
return False
|
|
if username in self._config.get("users", {}):
|
|
return False
|
|
if "users" not in self._config:
|
|
self._config["users"] = {}
|
|
self._config["users"][username] = {
|
|
"password_hash": _hash_password(password),
|
|
"created": time.time(),
|
|
"is_admin": is_admin,
|
|
"privileges": dict(ADMIN_PRIVILEGES if is_admin else DEFAULT_PRIVILEGES),
|
|
}
|
|
self._save()
|
|
logger.info(f"Created user '{username}' (admin={is_admin})")
|
|
return True
|
|
|
|
def get_user_by_oidc(self, sub: str, issuer: str) -> Optional[str]:
|
|
"""Find a username by OIDC (sub, issuer) pair. Returns None if no match."""
|
|
for username, data in self.users.items():
|
|
if data.get("oidc_sub") == sub and data.get("oidc_issuer") == issuer:
|
|
return username
|
|
return None
|
|
|
|
def create_user_oidc(self, username: str, sub: str, issuer: str, email: str = "",
|
|
is_admin: bool = False) -> Optional[str]:
|
|
"""Create a passwordless user linked to an OIDC identity.
|
|
|
|
Returns the final username (may differ from *username* if a local
|
|
password user already owns that name), or ``None`` when creation
|
|
fails (e.g. all candidate usernames collide with different OIDC
|
|
identities).
|
|
|
|
OIDC users have no password hash — they can only authenticate
|
|
through the OIDC flow. An existing OIDC user with the same
|
|
(sub, issuer) is returned as-is (idempotent).
|
|
|
|
When OIDC is the only auth path (no password admin exists) or
|
|
OIDC_ADMIN_GROUPS is unset, the first OIDC user becomes admin
|
|
by default to prevent zero-admin lockout. Set
|
|
OIDC_FIRST_USER_IS_ADMIN=false to disable this bootstrap.
|
|
"""
|
|
username = username.strip().lower()
|
|
if not username:
|
|
return None
|
|
if username in RESERVED_USERNAMES:
|
|
logger.warning("Refused OIDC user with reserved username '%s'", username)
|
|
return None
|
|
|
|
with self._interprocess_auth_lock(), self._config_lock:
|
|
# Reload from disk so we see what another process (or the
|
|
# local-setup path) may have written since our last _load().
|
|
self._load()
|
|
|
|
if "users" not in self._config:
|
|
self._config["users"] = {}
|
|
users = self._config["users"]
|
|
|
|
# Idempotent: same identity already exists (inside lock so
|
|
# two concurrent callbacks for the same OIDC identity cannot
|
|
# both observe an empty user map and create duplicate entries).
|
|
for uname, data in users.items():
|
|
if data.get("oidc_sub") == sub and data.get("oidc_issuer") == issuer:
|
|
return uname
|
|
|
|
# Bootstrap: if no users exist yet, OIDC_ADMIN_GROUPS is
|
|
# unset, and OIDC_FIRST_USER_IS_ADMIN isn't explicitly false,
|
|
# make the first OIDC user an admin. The check is inside the
|
|
# inter-process + process-local locks so two workers (or a
|
|
# concurrent local setup) cannot both observe an empty user
|
|
# map and both persist as admin.
|
|
if not is_admin:
|
|
first_user_admin = os.getenv("OIDC_FIRST_USER_IS_ADMIN", "true").lower() != "false"
|
|
oidc_admin_groups = os.getenv("OIDC_ADMIN_GROUPS", "").strip()
|
|
if first_user_admin and not users and not oidc_admin_groups:
|
|
is_admin = True
|
|
logger.info(
|
|
"First OIDC user '%s' promoted to admin (bootstrap, "
|
|
"no OIDC_ADMIN_GROUPS configured). "
|
|
"Set OIDC_FIRST_USER_IS_ADMIN=false to opt out.",
|
|
username,
|
|
)
|
|
|
|
# If the requested username is taken by a *different* identity
|
|
# (another OIDC user or a local password user), find a free
|
|
# slot by appending a numeric suffix.
|
|
base = username
|
|
candidate = username
|
|
suffix = 1
|
|
while candidate in users:
|
|
suffix += 1
|
|
candidate = f"{base}{suffix}"
|
|
if suffix > 100: # safety valve
|
|
logger.error("OIDC username collision loop for '%s'", username)
|
|
return None
|
|
|
|
users[candidate] = {
|
|
"password_hash": None,
|
|
"created": time.time(),
|
|
"is_admin": is_admin,
|
|
"privileges": dict(ADMIN_PRIVILEGES if is_admin else DEFAULT_PRIVILEGES),
|
|
"oidc_sub": sub,
|
|
"oidc_issuer": issuer,
|
|
"oidc_email": email,
|
|
}
|
|
self._save()
|
|
|
|
logger.info(
|
|
"Created OIDC user '%s' (sub=%s issuer=%s admin=%s)",
|
|
candidate, sub, issuer, is_admin,
|
|
)
|
|
return candidate
|
|
|
|
def is_oidc_user(self, username: str) -> bool:
|
|
"""Return True when *username* was created via OIDC (has no password)."""
|
|
user = self.users.get(username.strip().lower(), {})
|
|
return bool(user.get("oidc_sub"))
|
|
|
|
def set_oidc_user_admin(self, username: str, is_admin: bool) -> bool:
|
|
"""Set (or clear) admin status for an OIDC user.
|
|
|
|
Called on every OIDC login so admin follows the IdP's group
|
|
membership. Returns ``False`` if the user doesn't exist or is
|
|
not an OIDC user (password-account admins must be managed manually).
|
|
|
|
Serialised across workers via the shared inter-process lock so a
|
|
stale in-memory snapshot cannot overwrite users concurrently
|
|
created by another worker.
|
|
"""
|
|
username = username.strip().lower()
|
|
with self._interprocess_auth_lock(), self._config_lock:
|
|
# Reload from disk so we see what another process may have
|
|
# written since our last _load() — e.g. a concurrent
|
|
# create_user_oidc() on a different worker.
|
|
self._load()
|
|
user = self._config.get("users", {}).get(username, {})
|
|
if not user.get("oidc_sub"):
|
|
return False # not an OIDC user (or removed) — don't touch
|
|
if user.get("is_admin") == is_admin:
|
|
return True # no change needed
|
|
# Refuse to demote the only remaining administrator. Group
|
|
# membership changes must not silently make the instance
|
|
# unadministrable; password admins count as recovery admins.
|
|
if user.get("is_admin") and not is_admin:
|
|
admin_count = sum(
|
|
1 for data in self._config.get("users", {}).values()
|
|
if data.get("is_admin")
|
|
)
|
|
if admin_count <= 1:
|
|
logger.warning(
|
|
"Refusing to demote last admin '%s' during OIDC sync",
|
|
username,
|
|
)
|
|
return False
|
|
self._config["users"][username]["is_admin"] = is_admin
|
|
if is_admin:
|
|
self._config["users"][username]["privileges"] = dict(ADMIN_PRIVILEGES)
|
|
else:
|
|
self._config["users"][username]["privileges"] = dict(DEFAULT_PRIVILEGES)
|
|
self._save()
|
|
logger.info(
|
|
"OIDC user '%s' admin=%s (synced from IdP group membership)",
|
|
username, is_admin,
|
|
)
|
|
return True
|
|
|
|
def check_oidc_totp(self, username: str) -> bool:
|
|
"""Return True when *username* has TOTP enabled AND is an OIDC user.
|
|
|
|
Reloads auth.json from disk under the inter-process lock so a
|
|
manually-edited or externally-mutated config is visible.
|
|
Callers must invoke this via ``asyncio.to_thread()`` — file
|
|
locking inside the critical section blocks the calling thread.
|
|
|
|
This is defense-in-depth: normal OIDC users cannot enable local
|
|
TOTP (route guards prevent it), but an externally-edited auth.json or
|
|
a pre-OIDC legacy account could have both ``oidc_sub`` and
|
|
``totp_enabled`` set.
|
|
"""
|
|
username = username.strip().lower()
|
|
with self._interprocess_auth_lock(), self._config_lock:
|
|
self._load()
|
|
user = self._config.get("users", {}).get(username, {})
|
|
if not user.get("oidc_sub"):
|
|
return False # not an OIDC user
|
|
return bool(user.get("totp_enabled"))
|
|
|
|
def delete_user(self, username: str, requesting_user: str) -> bool:
|
|
"""Delete a user. Only admins can delete, and can't delete themselves.
|
|
|
|
SECURITY: also revoke every active session token belonging to this
|
|
user so any open browser tab they have gets kicked back to /login
|
|
on the next request. Without this the user kept full access until
|
|
their cookie expired naturally (default ~30 days).
|
|
"""
|
|
username = username.strip().lower()
|
|
with self._interprocess_auth_lock(), self._config_lock:
|
|
self._load()
|
|
if username not in self.users:
|
|
return False
|
|
if username == requesting_user:
|
|
return False
|
|
if not self.users.get(requesting_user, {}).get("is_admin"):
|
|
return False
|
|
# Revoke API bearer tokens before removing the auth row. The bearer
|
|
# path authenticates from ApiToken rows and does not require the
|
|
# owner to still exist, so a successful delete must not leave active
|
|
# rows behind. If the token store is unavailable, fail closed and
|
|
# keep the user/session state intact so the admin can retry.
|
|
try:
|
|
from core.database import get_db_session, ApiToken
|
|
with get_db_session() as db:
|
|
removed_tokens = db.query(ApiToken).filter(ApiToken.owner == username).delete()
|
|
if removed_tokens:
|
|
logger.info(
|
|
f"Revoked {removed_tokens} API token(s) owned by deleted user '{username}'"
|
|
)
|
|
except Exception:
|
|
logger.warning(f"Failed to revoke API tokens for deleted user '{username}'")
|
|
return False
|
|
del self._config["users"][username]
|
|
self._save()
|
|
# Purge all sessions belonging to this user. validate_token doesn't
|
|
# cross-check `self.users`, so without this step a deleted user's
|
|
# cookie keeps authenticating.
|
|
revoked = 0
|
|
with self._sessions_lock:
|
|
to_drop = [tok for tok, sess in self._sessions.items()
|
|
if (sess or {}).get("username") == username]
|
|
for tok in to_drop:
|
|
self._sessions.pop(tok, None)
|
|
self._revoked_tokens.add(tok)
|
|
revoked += 1
|
|
if revoked:
|
|
self._save_sessions()
|
|
logger.info(f"Deleted user '{username}' (by {requesting_user}); revoked {revoked} active session(s)")
|
|
return True
|
|
|
|
def rename_user(self, old_username: str, new_username: str, requesting_user: str) -> bool:
|
|
"""Rename a user in auth config and active sessions. Admin only."""
|
|
old_username = old_username.strip().lower()
|
|
new_username = new_username.strip().lower()
|
|
requesting_user = (requesting_user or "").strip().lower()
|
|
if not old_username or not new_username:
|
|
return False
|
|
if new_username in RESERVED_USERNAMES:
|
|
logger.warning("Refused to rename '%s' into reserved username '%s'", old_username, new_username)
|
|
return False
|
|
with self._interprocess_auth_lock(), self._config_lock:
|
|
self._load()
|
|
if old_username not in self.users:
|
|
return False
|
|
if new_username in self.users:
|
|
return False
|
|
if not self.users.get(requesting_user, {}).get("is_admin"):
|
|
return False
|
|
self._config.setdefault("users", {})[new_username] = self._config["users"].pop(old_username)
|
|
self._save()
|
|
|
|
renamed_sessions = 0
|
|
with self._sessions_lock:
|
|
for sess in self._sessions.values():
|
|
sess_user = str((sess or {}).get("username") or "").strip().lower()
|
|
if sess_user == old_username:
|
|
sess["username"] = new_username
|
|
renamed_sessions += 1
|
|
if renamed_sessions:
|
|
self._save_sessions()
|
|
logger.info(
|
|
"Renamed user '%s' -> '%s' (by %s); updated %d active session(s)",
|
|
old_username, new_username, requesting_user, renamed_sessions,
|
|
)
|
|
return True
|
|
|
|
def is_admin(self, username: str) -> bool:
|
|
return self.users.get(username, {}).get("is_admin", False)
|
|
|
|
def list_users(self) -> List[Dict[str, Any]]:
|
|
result = []
|
|
for u, d in self.users.items():
|
|
entry = {
|
|
"username": u,
|
|
"is_admin": d.get("is_admin", False),
|
|
"privileges": self.get_privileges(u),
|
|
}
|
|
if d.get("oidc_sub"):
|
|
entry["oidc"] = True
|
|
entry["oidc_issuer"] = d.get("oidc_issuer", "")
|
|
entry["oidc_email"] = d.get("oidc_email", "")
|
|
result.append(entry)
|
|
return result
|
|
|
|
def get_privileges(self, username: str) -> Dict[str, Any]:
|
|
"""Get privileges for a user. Admins get all privileges."""
|
|
user = self.users.get(username, {})
|
|
if user.get("is_admin"):
|
|
return dict(ADMIN_PRIVILEGES)
|
|
# Merge stored privileges with defaults (in case new privileges were added)
|
|
stored = user.get("privileges", {})
|
|
return {**DEFAULT_PRIVILEGES, **stored}
|
|
|
|
def set_privileges(self, username: str, privileges: Dict[str, Any]) -> bool:
|
|
"""Update privileges for a user. Can't modify admin privileges."""
|
|
username = username.strip().lower()
|
|
with self._interprocess_auth_lock(), self._config_lock:
|
|
self._load()
|
|
if username not in self.users:
|
|
return False
|
|
if self.users[username].get("is_admin"):
|
|
return False # admins always have full access
|
|
# Only allow known privilege keys
|
|
current = self.get_privileges(username)
|
|
for k, v in privileges.items():
|
|
if k in DEFAULT_PRIVILEGES:
|
|
current[k] = v
|
|
self._config["users"][username]["privileges"] = current
|
|
self._save()
|
|
logger.info(f"Updated privileges for '{username}': {current}")
|
|
return True
|
|
|
|
def set_admin(self, username: str, is_admin: bool,
|
|
requesting_user: str) -> SetAdminResult:
|
|
"""Promote/demote an existing user to/from admin. Admin only.
|
|
|
|
Refuses to remove the last remaining admin so the instance can never
|
|
be locked out of admin access; self-demotion is allowed as long as
|
|
another admin remains. Admin status is re-checked live on every
|
|
request, so unlike delete/rename no session or token revocation is
|
|
needed — a demoted admin simply fails the next is_admin() gate.
|
|
|
|
Promotion stashes the user's current privilege map and demotion
|
|
restores it, so a temporary admin stint can't silently broaden a
|
|
user's non-admin access; users without a stash (created as admin,
|
|
or promoted before stashing existed) demote to DEFAULT_PRIVILEGES.
|
|
|
|
Counting admins and flipping the flag happen in one critical section
|
|
so two concurrent demotions can't race the admin count to zero.
|
|
"""
|
|
username = (username or "").strip().lower()
|
|
requesting_user = (requesting_user or "").strip().lower()
|
|
is_admin = bool(is_admin)
|
|
with self._interprocess_auth_lock(), self._config_lock:
|
|
self._load()
|
|
target = self._config.get("users", {}).get(username)
|
|
if target is None:
|
|
return SetAdminResult.USER_NOT_FOUND
|
|
if not self.users.get(requesting_user, {}).get("is_admin"):
|
|
return SetAdminResult.NOT_AUTHORIZED
|
|
currently_admin = bool(target.get("is_admin"))
|
|
if currently_admin == is_admin:
|
|
return SetAdminResult.OK # no-op; leave privileges untouched
|
|
if currently_admin and not is_admin:
|
|
admin_count = sum(1 for d in self.users.values() if d.get("is_admin"))
|
|
if admin_count <= 1:
|
|
return SetAdminResult.LAST_ADMIN
|
|
# Write order matters for lock-free readers: get_privileges()
|
|
# reads without _config_lock and trusts is_admin, so the admin
|
|
# flag must be flipped while the stored map is safe to expose —
|
|
# before writing admin privileges on promote, after restoring
|
|
# the pre-admin map on demote.
|
|
if is_admin:
|
|
target["is_admin"] = True
|
|
# Stash the pre-admin map so a later demotion can restore it.
|
|
# While is_admin is set the stored map is inert: get_privileges
|
|
# short-circuits to ADMIN_PRIVILEGES and set_privileges refuses
|
|
# admins, so only set_admin ever touches the stash.
|
|
target["privileges_before_admin"] = dict(
|
|
target.get("privileges") or DEFAULT_PRIVILEGES
|
|
)
|
|
target["privileges"] = dict(ADMIN_PRIVILEGES)
|
|
else:
|
|
# Restore the stashed pre-admin map. Fall back to defaults for
|
|
# users created as admins (their stored map is ADMIN_PRIVILEGES,
|
|
# which must not leak past demotion — e.g. can_use_bash) and
|
|
# for admins promoted before the stash existed.
|
|
target["privileges"] = dict(
|
|
target.pop("privileges_before_admin", None)
|
|
or DEFAULT_PRIVILEGES
|
|
)
|
|
target["is_admin"] = False
|
|
self._save()
|
|
logger.info("Set is_admin=%s for '%s' (by '%s')", is_admin, username, requesting_user)
|
|
return SetAdminResult.OK
|
|
|
|
def change_password(self, username: str, current_password: str, new_password: str) -> bool:
|
|
username = username.strip().lower()
|
|
with self._interprocess_auth_lock(), self._config_lock:
|
|
self._load()
|
|
if username not in self.users:
|
|
return False
|
|
pw_hash = self.users[username].get("password_hash")
|
|
if pw_hash is None:
|
|
return False # OIDC-only user — password changes must go through the IdP
|
|
if not _verify_password(current_password, pw_hash):
|
|
return False
|
|
self._config["users"][username]["password_hash"] = _hash_password(new_password)
|
|
self._save()
|
|
return True
|
|
|
|
# ------------------------------------------------------------------
|
|
# TOTP two-factor authentication
|
|
# ------------------------------------------------------------------
|
|
|
|
def totp_enabled(self, username: str) -> bool:
|
|
"""Check if 2FA is enabled for a user."""
|
|
user = self.users.get(username.strip().lower(), {})
|
|
return bool(user.get("totp_enabled"))
|
|
|
|
def totp_generate_secret(self, username: str) -> Optional[str]:
|
|
"""Generate a new TOTP secret for a user. Returns the secret (not yet enabled)."""
|
|
username = username.strip().lower()
|
|
secret = pyotp.random_base32()
|
|
with self._interprocess_auth_lock(), self._config_lock:
|
|
self._load()
|
|
if username not in self.users:
|
|
return None
|
|
self._config["users"][username]["totp_secret_pending"] = secret
|
|
self._save()
|
|
return secret
|
|
|
|
def totp_get_provisioning_uri(self, username: str, secret: str) -> str:
|
|
"""Get the otpauth:// URI for QR code generation."""
|
|
totp = pyotp.TOTP(secret)
|
|
return totp.provisioning_uri(name=username, issuer_name="Odysseus")
|
|
|
|
def totp_confirm_enable(self, username: str, code: str) -> bool:
|
|
"""Verify a TOTP code against the pending secret, then enable 2FA."""
|
|
username = username.strip().lower()
|
|
with self._interprocess_auth_lock(), self._config_lock:
|
|
self._load()
|
|
user = self._config.get("users", {}).get(username, {})
|
|
secret = user.get("totp_secret_pending")
|
|
if not secret:
|
|
return False
|
|
totp = pyotp.TOTP(secret)
|
|
if not totp.verify(code, valid_window=1):
|
|
return False
|
|
# Enable 2FA
|
|
self._config["users"][username]["totp_secret"] = secret
|
|
self._config["users"][username]["totp_enabled"] = True
|
|
self._config["users"][username].pop("totp_secret_pending", None)
|
|
# Generate backup codes
|
|
backup = [secrets.token_hex(4) for _ in range(8)]
|
|
self._config["users"][username]["totp_backup_codes"] = backup
|
|
self._save()
|
|
logger.info(f"2FA enabled for '{username}'")
|
|
return True
|
|
|
|
def totp_verify(self, username: str, code: str) -> bool:
|
|
"""Verify a TOTP code for login."""
|
|
username = username.strip().lower()
|
|
user = self.users.get(username, {})
|
|
if not user.get("totp_enabled"):
|
|
return True # 2FA not enabled, always pass
|
|
secret = user.get("totp_secret")
|
|
if not secret:
|
|
# 2FA is enabled but no secret is stored (corrupt/partially-written
|
|
# auth.json). Fail closed — returning True here bypassed the second
|
|
# factor entirely.
|
|
return False
|
|
# Check backup codes first
|
|
backup = user.get("totp_backup_codes", [])
|
|
if code in backup:
|
|
with self._interprocess_auth_lock(), self._config_lock:
|
|
self._load()
|
|
latest_backup = self._config.get("users", {}).get(username, {}).get("totp_backup_codes", [])
|
|
if code in latest_backup:
|
|
latest_backup.remove(code)
|
|
self._config["users"][username]["totp_backup_codes"] = latest_backup
|
|
self._save()
|
|
logger.info(f"Backup code used for '{username}' ({len(latest_backup)} remaining)")
|
|
return True
|
|
return False
|
|
totp = pyotp.TOTP(secret)
|
|
return totp.verify(code, valid_window=1)
|
|
|
|
def totp_disable(self, username: str, password: str) -> bool:
|
|
"""Disable 2FA for a user. Requires password confirmation."""
|
|
username = username.strip().lower()
|
|
if not self.verify_password(username, password):
|
|
return False
|
|
with self._interprocess_auth_lock(), self._config_lock:
|
|
self._load()
|
|
if username not in self.users:
|
|
return False
|
|
self._config["users"][username].pop("totp_secret", None)
|
|
self._config["users"][username].pop("totp_secret_pending", None)
|
|
self._config["users"][username].pop("totp_backup_codes", None)
|
|
self._config["users"][username]["totp_enabled"] = False
|
|
self._save()
|
|
logger.info(f"2FA disabled for '{username}'")
|
|
return True
|
|
|
|
# ------------------------------------------------------------------
|
|
# Login / logout / session tokens
|
|
# ------------------------------------------------------------------
|
|
|
|
def verify_password(self, username: str, password: str) -> bool:
|
|
username = username.strip().lower()
|
|
if username not in self.users:
|
|
return False
|
|
pw_hash = self.users[username].get("password_hash")
|
|
if pw_hash is None:
|
|
return False # OIDC-only user — no password set
|
|
return _verify_password(password, pw_hash)
|
|
|
|
def create_session(self, username: str, password: str) -> Optional[str]:
|
|
"""Verify credentials and return a session token, or None."""
|
|
username = username.strip().lower()
|
|
if not self.verify_password(username, password):
|
|
return None
|
|
return self.create_session_trusted(username)
|
|
|
|
def create_session_trusted(self, username: str) -> Optional[str]:
|
|
"""Issue a session token for an already-verified user.
|
|
Call only after verify_password (and TOTP if enabled) have passed."""
|
|
username = username.strip().lower()
|
|
token = secrets.token_hex(32)
|
|
with self._config_lock:
|
|
if username not in self.users:
|
|
logger.warning("Refused to issue session for missing user '%s'", username)
|
|
return None
|
|
with self._sessions_lock:
|
|
self._sessions[token] = {
|
|
"username": username,
|
|
"expiry": time.time() + TOKEN_TTL,
|
|
}
|
|
self._save_sessions()
|
|
return token
|
|
|
|
def validate_token(self, token: Optional[str]) -> bool:
|
|
if not token:
|
|
return False
|
|
# Sync issuance/revocation from other workers (mtime-gated).
|
|
self._reload_sessions_if_changed()
|
|
expired = False
|
|
deleted_user = False
|
|
with self._sessions_lock:
|
|
session = self._sessions.get(token)
|
|
if session is None:
|
|
return False
|
|
if time.time() > session["expiry"]:
|
|
self._sessions.pop(token, None)
|
|
expired = True
|
|
else:
|
|
# SECURITY: if the user record has since been removed (admin
|
|
# deleted them while their cookie was still valid), drop the
|
|
# session so the next request kicks them out instead of
|
|
# silently authenticating against a non-existent account.
|
|
if session.get("username") not in self.users:
|
|
self._sessions.pop(token, None)
|
|
self._revoked_tokens.add(token)
|
|
deleted_user = True
|
|
if expired or deleted_user:
|
|
self._save_sessions()
|
|
return False
|
|
return True
|
|
|
|
def get_username_for_token(self, token: Optional[str]) -> Optional[str]:
|
|
"""Return the username associated with a valid token."""
|
|
if not token:
|
|
return None
|
|
# Sync issuance/revocation from other workers (mtime-gated).
|
|
self._reload_sessions_if_changed()
|
|
expired = False
|
|
deleted_user = False
|
|
with self._sessions_lock:
|
|
session = self._sessions.get(token)
|
|
if session is None:
|
|
return None
|
|
if time.time() > session["expiry"]:
|
|
self._sessions.pop(token, None)
|
|
expired = True
|
|
else:
|
|
_u = session["username"]
|
|
# SECURITY: orphan check — same rationale as validate_token.
|
|
if _u not in self.users:
|
|
self._sessions.pop(token, None)
|
|
self._revoked_tokens.add(token)
|
|
deleted_user = True
|
|
else:
|
|
return _u
|
|
if expired or deleted_user:
|
|
self._save_sessions()
|
|
return None
|
|
|
|
def revoke_token(self, token: str):
|
|
with self._sessions_lock:
|
|
self._sessions.pop(token, None)
|
|
self._revoked_tokens.add(token)
|
|
self._save_sessions()
|
|
|
|
def revoke_user_sessions(self, username: str, except_token: Optional[str] = None) -> int:
|
|
"""Revoke active browser sessions for a user, optionally preserving one."""
|
|
username = username.strip().lower()
|
|
revoked = 0
|
|
with self._sessions_lock:
|
|
to_drop = [
|
|
token for token, session in self._sessions.items()
|
|
if token != except_token and (session or {}).get("username") == username
|
|
]
|
|
for token in to_drop:
|
|
self._sessions.pop(token, None)
|
|
self._revoked_tokens.add(token)
|
|
revoked += 1
|
|
# Save outside _sessions_lock: _save_sessions acquires the
|
|
# inter-process flock before _sessions_lock, and taking them in
|
|
# the opposite order here could deadlock two threads.
|
|
if revoked:
|
|
self._save_sessions()
|
|
return revoked
|
|
|
|
def status(self, token: Optional[str]) -> Dict[str, Any]:
|
|
username = self.get_username_for_token(token)
|
|
authenticated = username is not None
|
|
result = {
|
|
"configured": self.is_configured,
|
|
"authenticated": authenticated,
|
|
"username": username,
|
|
"is_admin": self.is_admin(username) if username else False,
|
|
}
|
|
if authenticated:
|
|
result["privileges"] = self.get_privileges(username)
|
|
return result
|