375 lines
15 KiB
Python
375 lines
15 KiB
Python
"""
|
|
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/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)
|
|
return {
|
|
"token": create_token(user),
|
|
"user": {"username": user.username, "display_name": user.display_name},
|
|
"expires_in_hours": AUTH_TOKEN_HOURS,
|
|
}
|
|
|
|
|
|
@router.get("/me")
|
|
def me(request: Request) -> 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}
|
|
|
|
|
|
# ------------------------------------------------------------- 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
|
|
_GENERIC_CODE_MSG = ("If that account exists, a code has been emailed to it. "
|
|
"It expires in 10 minutes.")
|
|
|
|
|
|
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.
|
|
|
|
The response never reveals whether the username exists — same message either way, so
|
|
the login screen can't be used to enumerate accounts."""
|
|
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)
|
|
return {"sent": True, "detail": _GENERIC_CODE_MSG}
|
|
|
|
# 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": _GENERIC_CODE_MSG}
|
|
|
|
|
|
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()
|
|
generic = HTTPException(400, "That code is wrong, expired, or already used — "
|
|
"request a fresh one.")
|
|
if not username or not body.code.strip():
|
|
raise generic
|
|
if len(body.new_password) < 8:
|
|
raise HTTPException(400, "The new password must be at least 8 characters.")
|
|
|
|
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(body.code.strip()), user.reset_code_hash):
|
|
user.reset_code_attempts += 1
|
|
db.commit()
|
|
raise generic
|
|
|
|
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}
|