Refactor backend and frontend cleanup paths
This commit is contained in:
+49
-73
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user