""" Authentication: per-user login with signed bearer tokens. Stdlib only — no new deps. Design (deliberately minimal for a ~5-user internal finance tool): * Passwords: hashlib.scrypt (OpenSSL), per-user random salt, constant-time compare. * Tokens: HMAC-SHA256-signed JSON (user id, username, display name, expiry) — the same shape as a JWT but without the dependency. Signed with AR_SECRET_KEY; when unset, an ephemeral key is generated and a warning logged (every restart then logs everyone out — fine on a laptop, wrong on a server). * Enforcement: an HTTP middleware guards every /api/* route except the open set below. AR_AUTH=auto (default) requires login as soon as at least one user exists, so a fresh dev checkout and the test suite run without ceremony while creating the first real user turns authentication on by itself. * Identity: the verified display name feeds reviewed_by / approved_by / confirmed_by via actor_name(), replacing free-text name fields. Users are created with `python manage.py add-user` — there is no self-signup endpoint. """ from __future__ import annotations import base64 import datetime as dt import hashlib import hmac import json import logging import secrets import time from dataclasses import dataclass from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse from pydantic import BaseModel from sqlalchemy.orm import Session as OrmSession from ..config import AUTH_MODE, AUTH_TOKEN_HOURS, SECRET_KEY from ..db import models from ..db.database import SessionLocal from .deps import db_dep logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/auth", tags=["auth"]) # Paths reachable without a token: health probes, login itself, the "is auth on?" check, # and the forgot-password code flow (which by definition happens while locked out). OPEN_PATHS = {"/api/health", "/api/auth/login", "/api/auth/status", "/api/auth/request-code", "/api/auth/verify-code", "/api/auth/reset-password"} if SECRET_KEY: _SECRET = SECRET_KEY.encode() else: _SECRET = secrets.token_bytes(32) logger.warning( "AR_SECRET_KEY is not set — using an ephemeral signing key. Login sessions will " "not survive a restart. Set AR_SECRET_KEY in production." ) # --------------------------------------------------------------------- password hashing _SCRYPT_N, _SCRYPT_R, _SCRYPT_P = 16384, 8, 1 def hash_password(password: str) -> str: salt = secrets.token_bytes(16) digest = hashlib.scrypt(password.encode(), salt=salt, n=_SCRYPT_N, r=_SCRYPT_R, p=_SCRYPT_P, dklen=32) return (f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}" f"${salt.hex()}${digest.hex()}") def verify_password(password: str, stored: str) -> bool: try: algo, n, r, p, salt_hex, hash_hex = stored.split("$") if algo != "scrypt": return False digest = hashlib.scrypt(password.encode(), salt=bytes.fromhex(salt_hex), n=int(n), r=int(r), p=int(p), dklen=len(bytes.fromhex(hash_hex))) return hmac.compare_digest(digest, bytes.fromhex(hash_hex)) except (ValueError, TypeError): return False # ----------------------------------------------------------------------------- tokens def _b64(data: bytes) -> str: return base64.urlsafe_b64encode(data).rstrip(b"=").decode() def _unb64(data: str) -> bytes: return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4)) def create_token(user: models.User) -> str: payload = json.dumps({ "uid": user.id, "u": user.username, "dn": user.display_name, "exp": int(time.time()) + AUTH_TOKEN_HOURS * 3600, }, separators=(",", ":")).encode() sig = hmac.new(_SECRET, payload, hashlib.sha256).digest() return f"{_b64(payload)}.{_b64(sig)}" def parse_token(token: str) -> dict | None: """The signed payload, or None if the token is malformed, forged, or expired.""" try: payload_b64, sig_b64 = token.split(".") payload = _unb64(payload_b64) expected = hmac.new(_SECRET, payload, hashlib.sha256).digest() if not hmac.compare_digest(expected, _unb64(sig_b64)): return None data = json.loads(payload) if data.get("exp", 0) < time.time(): return None return data except (ValueError, TypeError, json.JSONDecodeError): return None # ------------------------------------------------------------------------- enforcement @dataclass class AuthUser: id: int username: str display_name: str # auto mode asks "do any users exist?" — cached briefly so it isn't a query per request. _users_exist_cache: tuple[float, bool] = (0.0, False) _USERS_CACHE_TTL_S = 10.0 def _users_exist() -> bool: global _users_exist_cache ts, val = _users_exist_cache now = time.time() if now - ts < _USERS_CACHE_TTL_S: return val db = SessionLocal() try: val = db.query(models.User.id).filter( models.User.is_active == True).first() is not None # noqa: E712 except Exception: # noqa: BLE001 — table may not exist mid-migration; fail open once val = False finally: db.close() _users_exist_cache = (now, val) return val def invalidate_users_cache() -> None: global _users_exist_cache _users_exist_cache = (0.0, False) def auth_required() -> bool: if AUTH_MODE == "off": return False if AUTH_MODE == "on": return True return _users_exist() # auto def _user_from_request(request: Request) -> AuthUser | None: header = request.headers.get("Authorization", "") if not header.startswith("Bearer "): return None data = parse_token(header[7:].strip()) if data is None: return None return AuthUser(id=data["uid"], username=data["u"], display_name=data["dn"]) async def auth_middleware(request: Request, call_next): """Guards every /api/* route except OPEN_PATHS. Registered in api/main.py.""" path = request.url.path.rstrip("/") or "/" if path.startswith("/api"): # Identity is attached whenever a valid token is present — including on open # paths, so e.g. a signed-in password-code request knows who is asking. user = _user_from_request(request) request.state.user = user if user is None and path not in OPEN_PATHS and auth_required(): return JSONResponse({"detail": "Not signed in (or the session expired). " "Sign in to continue."}, status_code=401) return await call_next(request) def current_user(request: Request) -> AuthUser | None: """The signed-in user, or None when auth is off/auto-without-users (dev, tests).""" return getattr(request.state, "user", None) def actor_name(request: Request, provided: str = "") -> str: """The name that lands in accountability fields (reviewed_by / approved_by / …). The verified identity always wins; the body-provided name is only honoured when no one is signed in (auth off / auto without users), which keeps dev and tests working.""" user = current_user(request) if user is not None and user.display_name: return user.display_name return (provided or "").strip() # ----------------------------------------------------------------------------- routes class LoginIn(BaseModel): username: str password: str @router.get("/status") def auth_status() -> dict: """Whether the frontend must show a login screen, and whether email codes work.""" from ..config import email_enabled return {"auth_required": auth_required(), "email_enabled": email_enabled()} @router.post("/login") def login(body: LoginIn, db: OrmSession = Depends(db_dep)) -> dict: user = db.query(models.User).filter( models.User.username == body.username.strip().lower()).first() if (user is None or not user.is_active or not verify_password(body.password, user.password_hash)): # One message for both wrong-user and wrong-password: don't confirm usernames. raise HTTPException(401, "Wrong username or password.") logger.info("login: %s", user.username) db.add(models.AuditLog(username=user.username, display_name=user.display_name, action="login")) db.commit() return { "token": create_token(user), "user": {"username": user.username, "display_name": user.display_name, "is_admin": bool(user.is_admin)}, "expires_in_hours": AUTH_TOKEN_HOURS, } def is_admin(request: Request, db: OrmSession) -> bool: """Whether the signed-in user holds the admin flag — read from the DB every time, so a revoke takes effect immediately rather than at token expiry. With auth off (dev/tests before the first user) everyone counts as admin, matching AR_AUTH=auto's philosophy.""" user = current_user(request) if user is None: return not auth_required() row = db.get(models.User, user.id) return bool(row is not None and row.is_active and row.is_admin) def require_admin(request: Request, db: OrmSession) -> None: if not is_admin(request, db): raise HTTPException(403, "Admin access required.") @router.get("/me") def me(request: Request, db: OrmSession = Depends(db_dep)) -> dict: user = current_user(request) if user is None: if auth_required(): raise HTTPException(401, "Not signed in.") return {"authenticated": False, "auth_required": False} return {"authenticated": True, "auth_required": True, "username": user.username, "display_name": user.display_name, "is_admin": is_admin(request, db)} # ------------------------------------------------------------- emailed password codes # Usernames ARE email addresses, so the code goes to the account's own address. The code # is stored as an HMAC (never plaintext), lives 10 minutes, works once, and the account # locks the flow after 5 wrong attempts (request a fresh code to retry). CODE_TTL_MINUTES = 10 CODE_MAX_ATTEMPTS = 5 def _hash_code(code: str) -> str: return hmac.new(_SECRET, f"pwcode:{code}".encode(), hashlib.sha256).hexdigest() class RequestCodeIn(BaseModel): username: str = "" # optional when signed in (defaults to the session's account) @router.post("/request-code") def request_password_code(body: RequestCodeIn, request: Request, db: OrmSession = Depends(db_dep)) -> dict: """Email a 6-digit password code to the account's address. Deliberately explicit for this small internal team: an unregistered address gets a clear 404 instead of an anti-enumeration non-answer.""" from ..config import email_enabled from ..services.mailer import MailerError, send_password_code if not email_enabled(): raise HTTPException(503, "Email is not set up on this server — ask the " "administrator to reset your password instead.") me_user = current_user(request) username = (me_user.username if me_user else body.username).strip().lower() if not username: raise HTTPException(400, "Enter your username (email address).") user = db.query(models.User).filter(models.User.username == username).first() if user is None or not user.is_active: logger.info("password code requested for unknown/inactive account: %s", username) raise HTTPException(404, f"{username} isn't a registered account — check the " f"address, or ask the administrator to create it.") # Light resend throttle: one code per minute (a resend invalidates the previous code). now = dt.datetime.utcnow() if user.reset_code_expires: issued_at = user.reset_code_expires - dt.timedelta(minutes=CODE_TTL_MINUTES) if now - issued_at < dt.timedelta(seconds=60): raise HTTPException(429, "A code was just sent — check your inbox, or try " "again in a minute.") code = f"{secrets.randbelow(1_000_000):06d}" user.reset_code_hash = _hash_code(code) user.reset_code_expires = now + dt.timedelta(minutes=CODE_TTL_MINUTES) user.reset_code_attempts = 0 db.commit() try: send_password_code(user.username, code, CODE_TTL_MINUTES) except MailerError as e: # Roll the code back — a code nobody received must not stay live. user.reset_code_hash = "" user.reset_code_expires = None db.commit() raise HTTPException(502, f"{e} Ask the administrator to reset your password.") return {"sent": True, "detail": f"Code sent to {username} — it expires in {CODE_TTL_MINUTES} minutes."} def _user_with_valid_code(db: OrmSession, username: str, code: str) -> models.User: """The account IF the code is currently valid — one generic error otherwise (never confirms which part was wrong). A wrong code counts toward the attempt lockout.""" generic = HTTPException(400, "That code is wrong, expired, or already used — " "request a fresh one.") if not username or not code.strip(): raise generic user = db.query(models.User).filter(models.User.username == username).first() now = dt.datetime.utcnow() if (user is None or not user.is_active or not user.reset_code_hash or not user.reset_code_expires or user.reset_code_expires < now or user.reset_code_attempts >= CODE_MAX_ATTEMPTS): raise generic if not hmac.compare_digest(_hash_code(code.strip()), user.reset_code_hash): user.reset_code_attempts += 1 db.commit() raise generic return user class VerifyCodeIn(BaseModel): username: str = "" # optional when signed in code: str @router.post("/verify-code") def verify_password_code(body: VerifyCodeIn, request: Request, db: OrmSession = Depends(db_dep)) -> dict: """Step check for the reset UI: is this code valid? Does NOT consume the code — the reset itself re-validates and burns it. Wrong guesses still count toward lockout.""" me_user = current_user(request) username = (me_user.username if me_user else body.username).strip().lower() _user_with_valid_code(db, username, body.code) return {"valid": True} class ResetPasswordIn(BaseModel): username: str = "" # optional when signed in code: str new_password: str @router.post("/reset-password") def reset_password_with_code(body: ResetPasswordIn, request: Request, db: OrmSession = Depends(db_dep)) -> dict: """Set a new password using the emailed code (works signed-in and from the login screen). One generic failure message — never confirms which part was wrong.""" me_user = current_user(request) username = (me_user.username if me_user else body.username).strip().lower() if len(body.new_password) < 8: raise HTTPException(400, "The new password must be at least 8 characters.") user = _user_with_valid_code(db, username, body.code) user.password_hash = hash_password(body.new_password) user.reset_code_hash = "" # single use user.reset_code_expires = None user.reset_code_attempts = 0 db.commit() logger.info("password reset via email code: %s", user.username) return {"changed": True} class ChangePasswordIn(BaseModel): current_password: str new_password: str @router.post("/change-password") def change_password(body: ChangePasswordIn, request: Request, db: OrmSession = Depends(db_dep)) -> dict: """Signed-in users change their own password (admins reset others via manage.py). Requires the current password so a walked-away-from session can't be hijacked into a permanent account takeover. Existing tokens stay valid until their normal expiry.""" user = current_user(request) if user is None: raise HTTPException(401, "Sign in to change your password.") row = db.get(models.User, user.id) if row is None or not row.is_active: raise HTTPException(401, "Account not found or deactivated.") if not verify_password(body.current_password, row.password_hash): raise HTTPException(400, "The current password is wrong.") if len(body.new_password) < 8: raise HTTPException(400, "The new password must be at least 8 characters.") if body.new_password == body.current_password: raise HTTPException(400, "The new password must be different from the current one.") row.password_hash = hash_password(body.new_password) db.commit() logger.info("password changed: %s", row.username) return {"changed": True}