Refactor backend and frontend cleanup paths

This commit is contained in:
curo1305
2026-06-16 11:50:17 +02:00
parent 6b56763689
commit e97ca164d7
29 changed files with 1106 additions and 2280 deletions
+49 -73
View File
@@ -1,20 +1,4 @@
"""
Auth service — pure Python, no FastAPI coupling.
Handles:
- Password hashing (Argon2 via pwdlib) and constant-time verification (SEC-06)
- JWT access token creation/decode (PyJWT)
- Refresh token lifecycle with family revocation on reuse (AUTH-07, RFC 9700)
- TOTP provisioning and verification with replay prevention (AUTH-08)
- Backup code generation, storage, and constant-time verification (AUTH-02)
- HaveIBeenPwned k-anonymity check (SEC-03)
- Admin account bootstrap (D-04, D-05, D-06)
Security invariants:
- All token/code comparisons use hmac.compare_digest (constant-time, SEC-06)
- No function raises HTTPException — callers (api/) map ValueError to HTTP errors
- refresh token family revocation enqueues send_security_alert_email.delay (AUTH-07)
"""
"""Authentication, token, TOTP, and account bootstrap services."""
from __future__ import annotations
import base64
@@ -32,7 +16,7 @@ import jwt
import pyotp
from pwdlib import PasswordHash
from pwdlib.hashers.argon2 import Argon2Hasher
from sqlalchemy import select, update
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
@@ -45,8 +29,6 @@ _PASSWORD_DETAIL = (
logger = logging.getLogger(__name__)
# ── Password hashing ────────────────────────────────────────────────────────────
# Single shared PasswordHash instance; Argon2 is the only enabled hasher.
_pwd = PasswordHash([Argon2Hasher()])
@@ -82,10 +64,8 @@ def validate_password_strength(password: str) -> None:
raise ValueError(_PASSWORD_DETAIL)
# ── JWT helpers ─────────────────────────────────────────────────────────────────
def _compute_fgp(user_agent: str, accept_lang: str) -> str:
"""Return 16-char hex fingerprint binding a token to its client context (D-04)."""
"""Return a short fingerprint binding a token to its client context."""
return hmac.new(
settings.secret_key.encode(),
(user_agent + "\x00" + accept_lang).encode(),
@@ -93,6 +73,36 @@ def _compute_fgp(user_agent: str, accept_lang: str) -> str:
).hexdigest()[:16]
def _jwt_private_key() -> str:
return base64.b64decode(settings.jwt_private_key).decode()
def _jwt_public_key() -> str:
return base64.b64decode(settings.jwt_public_key).decode()
def _encode_jwt(payload: dict) -> str:
return jwt.encode(payload, _jwt_private_key(), algorithm="ES256")
def _decode_jwt(
token: str,
expected_type: str,
expired_message: str,
invalid_prefix: str,
) -> dict:
try:
payload = jwt.decode(token, _jwt_public_key(), algorithms=["ES256"])
except jwt.ExpiredSignatureError as exc:
raise ValueError(expired_message) from exc
except jwt.PyJWTError as exc:
raise ValueError(f"{invalid_prefix}: {exc}") from exc
if payload.get("typ") != expected_type:
raise ValueError(f"Token type mismatch: expected '{expected_type}'")
return payload
def create_access_token(
user_id: str,
role: str,
@@ -114,8 +124,7 @@ def create_access_token(
"jti": str(uuid.uuid4()),
"fgp": _compute_fgp(user_agent, accept_lang),
}
private_pem = base64.b64decode(settings.jwt_private_key).decode()
return jwt.encode(payload, private_pem, algorithm="ES256")
return _encode_jwt(payload)
def decode_access_token(token: str) -> dict:
@@ -124,17 +133,7 @@ def decode_access_token(token: str) -> dict:
Verifies: signature, expiry, and typ='access' (T-02-01 — prevents password-reset
tokens from being used as access tokens).
"""
try:
public_pem = base64.b64decode(settings.jwt_public_key).decode()
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
except jwt.ExpiredSignatureError as exc:
raise ValueError("Token has expired") from exc
except jwt.PyJWTError as exc:
raise ValueError(f"Invalid token: {exc}") from exc
if payload.get("typ") != "access":
raise ValueError("Token type mismatch: expected 'access'")
return payload
return _decode_jwt(token, "access", "Token has expired", "Invalid token")
def create_password_reset_token(user_id: str) -> str:
@@ -149,8 +148,7 @@ def create_password_reset_token(user_id: str) -> str:
"iat": now,
"exp": now + timedelta(seconds=3600),
}
private_pem = base64.b64decode(settings.jwt_private_key).decode()
return jwt.encode(payload, private_pem, algorithm="ES256")
return _encode_jwt(payload)
def decode_password_reset_token(token: str) -> str:
@@ -158,21 +156,15 @@ def decode_password_reset_token(token: str) -> str:
Returns the user_id string.
"""
try:
public_pem = base64.b64decode(settings.jwt_public_key).decode()
payload = jwt.decode(token, public_pem, algorithms=["ES256"])
except jwt.ExpiredSignatureError as exc:
raise ValueError("Reset token has expired") from exc
except jwt.PyJWTError as exc:
raise ValueError(f"Invalid reset token: {exc}") from exc
if payload.get("typ") != "password-reset":
raise ValueError("Token type mismatch: expected 'password-reset'")
payload = _decode_jwt(
token,
"password-reset",
"Reset token has expired",
"Invalid reset token",
)
return payload["sub"]
# ── Refresh token lifecycle ─────────────────────────────────────────────────────
async def create_refresh_token(
session: AsyncSession, user_id: uuid.UUID, remember_me: bool = False
) -> str:
@@ -181,8 +173,8 @@ async def create_refresh_token(
The raw token is returned to the caller and set as an httpOnly cookie.
Only the SHA-256 hash is stored in the database.
remember_me=False (default): TTL = refresh_token_expire_hours (16h short session, D-09, D-10)
remember_me=True: TTL = refresh_token_expire_days (30d extended session, D-11)
remember_me=False uses the short-session TTL; remember_me=True uses the
extended-session TTL.
"""
raw = secrets.token_urlsafe(32)
token_hash = hashlib.sha256(raw.encode()).hexdigest()
@@ -231,9 +223,7 @@ async def rotate_refresh_token(
raise ValueError("Refresh token has expired")
if row.revoked:
# T-02-02: reuse of revoked token — family revocation
await revoke_all_refresh_tokens(session, row.user_id)
# Enqueue security alert email (deferred import to avoid circular dependency)
from tasks.email_tasks import send_security_alert_email # noqa: PLC0415
send_security_alert_email.delay(str(row.user_id))
raise ValueError("token_family_revoked")
@@ -273,8 +263,6 @@ async def revoke_all_refresh_tokens(
return count
# ── TOTP provisioning ───────────────────────────────────────────────────────────
async def provision_totp(
session: AsyncSession, user_id: uuid.UUID
) -> tuple[str, str]:
@@ -329,13 +317,10 @@ async def verify_totp(
return True
# ── Backup codes ────────────────────────────────────────────────────────────────
def generate_backup_codes(n: int = 10) -> list[str]:
"""Return *n* random 8-character uppercase alphanumeric backup codes."""
codes = []
for _ in range(n):
# secrets.token_hex(4) returns 8 hex chars; uppercase for readability
code = secrets.token_hex(4).upper()
codes.append(code)
return codes
@@ -349,7 +334,6 @@ async def store_backup_codes(
Each code is stored as an Argon2 hash (never plaintext, T-02-03).
Existing unused codes are deleted first to prevent accumulation.
"""
# Delete existing unused codes
result = await session.execute(
select(BackupCode).where(
BackupCode.user_id == user_id,
@@ -360,7 +344,6 @@ async def store_backup_codes(
await session.delete(row)
await session.flush()
# Insert new hashed codes
for code in codes:
row = BackupCode(
id=uuid.uuid4(),
@@ -392,21 +375,17 @@ async def verify_backup_code(
matched_row: Optional[BackupCode] = None
for row in rows:
# Always call verify_password for ALL rows (constant-time: no early exit)
if verify_password(code, row.code_hash):
matched_row = row # record match but keep iterating
matched_row = row # keep iterating for constant-time behavior
if matched_row is None:
return False
# Mark the matched code as used
matched_row.used_at = datetime.now(timezone.utc)
await session.commit()
return True
# ── HaveIBeenPwned check ────────────────────────────────────────────────────────
async def check_hibp(password: str) -> bool:
"""Check if password appears in HaveIBeenPwned using the k-anonymity model.
@@ -442,19 +421,17 @@ async def check_hibp(password: str) -> bool:
return False
# ── Admin bootstrap ─────────────────────────────────────────────────────────────
async def bootstrap_admin(session: AsyncSession) -> None:
"""Idempotent admin account bootstrap (D-04, D-05, D-06).
"""Idempotent admin account bootstrap.
If the users table is empty AND settings.admin_email and settings.admin_password
are both non-empty, creates an admin User row with a Quota row.
Logs a WARNING if env vars are missing (D-05) but never raises.
Logs a warning if env vars are missing but never raises.
"""
if not settings.admin_email or not settings.admin_password:
logger.warning(
"Admin bootstrap skipped: ADMIN_EMAIL and/or ADMIN_PASSWORD not set (D-05). "
"Admin bootstrap skipped: ADMIN_EMAIL and/or ADMIN_PASSWORD not set. "
"Set both env vars to seed the first admin account on startup."
)
return
@@ -462,7 +439,6 @@ async def bootstrap_admin(session: AsyncSession) -> None:
# Check if any users exist
result = await session.execute(select(User).limit(1))
if result.scalar_one_or_none() is not None:
# Users already exist — idempotent, skip (D-04)
return
admin_id = uuid.uuid4()
@@ -477,7 +453,7 @@ async def bootstrap_admin(session: AsyncSession) -> None:
)
quota = Quota(
user_id=admin_id,
limit_bytes=104857600, # 100 MB default (D-06)
limit_bytes=104857600,
used_bytes=0,
)
session.add(admin_user)