"""Browser accounts: salted password hashes and revocable server-side sessions.""" import hashlib import hmac import json import secrets import time from pathlib import Path from fastapi import Request, Response from pydantic import BaseModel, Field from pigway_frame_flow.errors import ErrorCode as E from pigway_frame_flow.errors import FlowError COOKIE = "frameflow_session" def password_hash(password: str) -> str: salt = secrets.token_hex(16) digest = hashlib.scrypt( password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1 ).hex() return f"scrypt${salt}${digest}" def verify_password(password: str, encoded: str) -> bool: try: _, salt, expected = encoded.split("$") actual = hashlib.scrypt( password.encode(), salt=bytes.fromhex(salt), n=16384, r=8, p=1 ).hex() return hmac.compare_digest(actual, expected) except (ValueError, TypeError): return False class Login(BaseModel): username: str = Field(min_length=1, max_length=80) password: str = Field(min_length=1, max_length=256, repr=False) remember: bool = True class AccountView(BaseModel): username: str csrf_token: str class PasswordChange(BaseModel): current_password: str = Field(min_length=1, max_length=256, repr=False) new_password: str = Field(min_length=8, max_length=256, repr=False) def seed_accounts(db, config_dir: Path): db.execute( "CREATE TABLE IF NOT EXISTS users (username TEXT PRIMARY KEY, password_hash TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1)" ) db.execute( "CREATE TABLE IF NOT EXISTS sessions (token_hash TEXT PRIMARY KEY, username TEXT NOT NULL, csrf TEXT NOT NULL, expires_at REAL NOT NULL)" ) db.execute( "CREATE TABLE IF NOT EXISTS login_attempts (identity TEXT PRIMARY KEY, failures INTEGER NOT NULL, blocked_until REAL NOT NULL)" ) path = config_dir / "accounts.json" if path.exists(): for user in json.loads(path.read_text()).get("users", []): db.execute( "INSERT OR IGNORE INTO users VALUES(?,?,1)", (user["username"], user["password_hash"]), ) def session_user(request: Request): raw = request.cookies.get(COOKIE, "") if not raw: return None db = request.app.state.service.db row = db.one( "SELECT s.*,u.enabled FROM sessions s JOIN users u USING(username) WHERE token_hash=?", (hashlib.sha256(raw.encode()).hexdigest(),), ) if not row or not row["enabled"] or row["expires_at"] <= time.time(): return None if request.method in {"POST", "PUT", "DELETE"}: if not hmac.compare_digest( request.headers.get("X-CSRF-Token", ""), row["csrf"] ): raise FlowError(E.FORBIDDEN, "Session CSRF validation failed", 403) return row def install_accounts(app, db): @app.post("/api/v1/auth/login", response_model=AccountView, tags=["Accounts"]) def login(payload: Login, request: Request, response: Response): identity = hashlib.sha256( (request.client.host if request.client else "unknown").encode() ).hexdigest() attempt = db.one("SELECT * FROM login_attempts WHERE identity=?", (identity,)) if attempt and attempt["blocked_until"] > time.time(): raise FlowError( E.RATE_LIMITED, "Too many login attempts; try again later", 429 ) row = db.one( "SELECT * FROM users WHERE username=? AND enabled=1", (payload.username,) ) # Perform a password calculation even for unknown usernames. fallback = "scrypt$" + "00" * 16 + "$" + "00" * 64 valid = verify_password( payload.password, row["password_hash"] if row else fallback ) if not row or not valid: failures = ( attempt["failures"] if attempt and attempt["blocked_until"] == 0 else 0 ) + 1 db.execute( "INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)", (identity, failures, time.time() + 300 if failures >= 5 else 0), ) raise FlowError(E.UNAUTHORIZED, "Invalid username or password", 401) db.execute("DELETE FROM login_attempts WHERE identity=?", (identity,)) raw, csrf = secrets.token_urlsafe(32), secrets.token_urlsafe(32) lifetime = 30 * 86400 if payload.remember else 12 * 3600 db.execute("DELETE FROM sessions WHERE expires_at<=?", (time.time(),)) old = request.cookies.get(COOKIE) if old: db.execute( "DELETE FROM sessions WHERE token_hash=?", (hashlib.sha256(old.encode()).hexdigest(),), ) db.execute( "INSERT INTO sessions VALUES(?,?,?,?)", ( hashlib.sha256(raw.encode()).hexdigest(), row["username"], csrf, time.time() + lifetime, ), ) response.set_cookie( COOKIE, raw, max_age=lifetime if payload.remember else None, httponly=True, samesite="strict", secure=request.url.scheme == "https", path="/", ) return AccountView(username=row["username"], csrf_token=csrf) def current(request): row = session_user(request) if not row: raise FlowError(E.UNAUTHORIZED, "Account login required", 401) return row @app.get("/api/v1/auth/me", response_model=AccountView, tags=["Accounts"]) def me(request: Request): row = current(request) return AccountView(username=row["username"], csrf_token=row["csrf"]) @app.post("/api/v1/auth/logout", status_code=204, tags=["Accounts"]) def logout(request: Request, response: Response): row = current(request) db.execute("DELETE FROM sessions WHERE token_hash=?", (row["token_hash"],)) response.delete_cookie(COOKIE, path="/") @app.put("/api/v1/auth/password", status_code=204, tags=["Accounts"]) def change_password(payload: PasswordChange, request: Request, response: Response): session = current(request) user = db.one("SELECT * FROM users WHERE username=?", (session["username"],)) if not verify_password(payload.current_password, user["password_hash"]): raise FlowError(E.UNAUTHORIZED, "Current password is incorrect", 401) db.execute( "UPDATE users SET password_hash=? WHERE username=?", (password_hash(payload.new_password), session["username"]), ) db.execute("DELETE FROM sessions WHERE username=?", (session["username"],)) response.delete_cookie(COOKIE, path="/")