Kyle Pope ab84c7bc53 Fix review findings: transaction atomicity, perf, and UI polish
Backend fixes:
- session.py: record_failed/successful_login use flush() not commit()
  — callers own transaction boundary (BUG-2 atomicity fix)
- auth.py: Add explicit commits after record_failed_login where callers
  raise immediately; add commit before TOTP mfa_token return path
- passkeys.py: JOIN credential+user lookup in login/complete (W-1 perf)
- passkeys.py: Move mfa_enforce_pending clear before main commit (S-2)
- passkeys.py: Add Path(ge=1, le=2147483647) on DELETE endpoint (BUG-3)
- auth.py: Switch has_passkeys from COUNT to EXISTS with LIMIT 1 (W-2)
- passkey.py: Add single-worker nonce cache comment (H-1)

Frontend fixes:
- PasskeySection: emerald→green badge colors (W-3 palette)
- PasskeySection: text-[11px]/text-[10px]→text-xs (W-4 a11y minimum)
- PasskeySection: Scope deleteMutation.isPending to per-item (W-5)
- nginx.conf: Permissions-Policy publickey-credentials use (self) (H-2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:59:59 +08:00

681 lines
24 KiB
Python

"""
Authentication router — username/password with DB-backed sessions, account lockout,
role-based access control, and multi-user registration.
Session flow:
POST /setup → create admin User + Settings + calendars → issue session cookie
POST /login → verify credentials → check lockout → MFA/enforce checks → issue session
POST /register → create standard user (when registration enabled)
POST /logout → mark session revoked in DB → delete cookie
GET /status → verify user exists + session valid + role + registration_open
Security layers:
1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) — outer guard on auth endpoints
2. DB-backed account lockout (10 failures → 30-min lock, HTTP 423)
3. Session revocation stored in DB (survives container restarts)
4. bcrypt→Argon2id transparent upgrade on first login
5. Role-based authorization via require_role() dependency factory
"""
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response, Cookie
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from app.database import get_db
from app.services.connection import sync_birthday_to_contacts
from app.models.user import User
from app.models.session import UserSession
from app.models.settings import Settings
from app.models.system_config import SystemConfig
from app.models.passkey_credential import PasskeyCredential
from app.models.calendar import Calendar
from app.schemas.auth import (
SetupRequest, LoginRequest, RegisterRequest,
ChangePasswordRequest, VerifyPasswordRequest,
ProfileUpdate, ProfileResponse,
)
from app.services.auth import (
ahash_password,
averify_password,
averify_password_with_upgrade,
hash_password,
verify_password,
verify_password_with_upgrade,
create_session_token,
verify_session_token,
create_mfa_token,
create_mfa_enforce_token,
)
from app.services.audit import get_client_ip, log_audit_event
from app.services.session import (
set_session_cookie,
check_account_lockout,
record_failed_login,
record_successful_login,
create_db_session,
)
from app.config import settings as app_settings
router = APIRouter()
# Pre-computed dummy hash for timing equalization (M-02).
# When a login attempt targets a non-existent username, we still run
# Argon2id verification against this dummy hash so the response time
# is indistinguishable from a wrong-password attempt.
_DUMMY_HASH = hash_password("timing-equalization-dummy")
# ---------------------------------------------------------------------------
# Auth dependencies — export get_current_user and get_current_settings
# ---------------------------------------------------------------------------
async def get_current_user(
request: Request,
response: Response,
session_cookie: Optional[str] = Cookie(None, alias="session"),
db: AsyncSession = Depends(get_db),
) -> User:
"""
Dependency that verifies the session cookie and returns the authenticated User.
L-03 sliding window: if the session has less than
(SESSION_MAX_AGE_DAYS - 1) days remaining, silently extend expires_at
and re-issue the cookie so active users never hit expiration.
"""
if not session_cookie:
raise HTTPException(status_code=401, detail="Not authenticated")
payload = verify_session_token(session_cookie)
if payload is None:
raise HTTPException(status_code=401, detail="Invalid or expired session")
user_id: int = payload.get("uid")
session_id: str = payload.get("sid")
if user_id is None or session_id is None:
raise HTTPException(status_code=401, detail="Malformed session token")
# AC-1: Single JOIN query for session + user (was 2 sequential queries)
result = await db.execute(
select(UserSession, User)
.join(User, UserSession.user_id == User.id)
.where(
UserSession.id == session_id,
UserSession.user_id == user_id,
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
User.is_active == True,
)
)
row = result.one_or_none()
if not row:
raise HTTPException(status_code=401, detail="Session expired or user inactive")
db_session, user = row.tuple()
# L-03: Sliding window renewal — extend session if >1 day has elapsed since
# last renewal (i.e. remaining time < SESSION_MAX_AGE_DAYS - 1 day).
now = datetime.now()
renewal_threshold = timedelta(days=app_settings.SESSION_MAX_AGE_DAYS - 1)
if db_session.expires_at - now < renewal_threshold:
db_session.expires_at = now + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
await db.flush()
# Re-issue cookie with fresh signed token to reset browser max_age timer
fresh_token = create_session_token(user_id, session_id)
set_session_cookie(response, fresh_token)
# Stash session on request so lock/unlock endpoints can access it
request.state.db_session = db_session
# Defense-in-depth: block API access while session is locked.
# Exempt endpoints needed for unlocking, locking, checking status, and logout.
if db_session.is_locked:
lock_exempt = {
"/api/auth/lock", "/api/auth/verify-password",
"/api/auth/status", "/api/auth/logout",
}
if request.url.path not in lock_exempt:
raise HTTPException(status_code=423, detail="Session is locked")
return user
async def get_current_settings(
request: Request,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Settings:
"""
Convenience dependency for routers that need Settings access.
Always chain after get_current_user — never use standalone.
AC-3: Cache in request.state so multiple dependencies don't re-query.
"""
cached = getattr(request.state, "settings", None)
if cached is not None:
return cached
result = await db.execute(
select(Settings).where(Settings.user_id == current_user.id)
)
settings_obj = result.scalar_one_or_none()
if not settings_obj:
raise HTTPException(status_code=500, detail="Settings not found for user")
request.state.settings = settings_obj
return settings_obj
# ---------------------------------------------------------------------------
# Role-based authorization dependencies
# ---------------------------------------------------------------------------
def require_role(*allowed_roles: str):
"""Factory: returns a dependency that enforces role membership."""
async def _check(
current_user: User = Depends(get_current_user),
) -> User:
if current_user.role not in allowed_roles:
raise HTTPException(status_code=403, detail="Insufficient permissions")
return current_user
return _check
# Convenience aliases
require_admin = require_role("admin")
# ---------------------------------------------------------------------------
# User bootstrapping helper (Settings + default calendars)
# ---------------------------------------------------------------------------
async def _create_user_defaults(
db: AsyncSession, user_id: int, *, preferred_name: str | None = None,
) -> None:
"""Create Settings row and default calendars for a new user."""
db.add(Settings(user_id=user_id, preferred_name=preferred_name))
db.add(Calendar(
name="Personal", color="#3b82f6",
is_default=True, is_system=False, is_visible=True,
user_id=user_id,
))
db.add(Calendar(
name="Birthdays", color="#f59e0b",
is_default=False, is_system=True, is_visible=True,
user_id=user_id,
))
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.post("/setup")
async def setup(
data: SetupRequest,
response: Response,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""
First-time setup: create the admin User + Settings + default calendars.
Only works when no users exist (i.e., fresh install).
"""
user_count = await db.execute(select(func.count()).select_from(User))
if user_count.scalar_one() > 0:
raise HTTPException(status_code=400, detail="Setup already completed")
password_hash = await ahash_password(data.password)
new_user = User(
username=data.username,
umbral_name=data.username,
password_hash=password_hash,
role="admin",
last_password_change_at=datetime.now(),
)
db.add(new_user)
await db.flush()
await _create_user_defaults(db, new_user.id)
ip = get_client_ip(request)
user_agent = request.headers.get("user-agent")
_, token = await create_db_session(db, new_user, ip, user_agent)
set_session_cookie(response, token)
await log_audit_event(
db, action="auth.setup_complete", actor_id=new_user.id, ip=ip,
)
await db.commit()
return {"message": "Setup completed successfully", "authenticated": True}
@router.post("/login")
async def login(
data: LoginRequest,
request: Request,
response: Response,
db: AsyncSession = Depends(get_db),
):
"""
Authenticate with username + password.
Returns:
{ authenticated: true } — on success (no TOTP, no enforcement)
{ authenticated: false, totp_required: true, mfa_token: "..." } — TOTP pending
{ authenticated: false, mfa_setup_required: true, mfa_token: "..." } — MFA enforcement
{ authenticated: false, must_change_password: true } — forced password change after admin reset
HTTP 401 — wrong credentials
HTTP 403 — account disabled (is_active=False)
HTTP 423 — account locked
"""
client_ip = get_client_ip(request)
result = await db.execute(select(User).where(User.username == data.username))
user = result.scalar_one_or_none()
if not user:
# M-02: Run Argon2id against a dummy hash so the response time is
# indistinguishable from a wrong-password attempt (prevents username enumeration).
await averify_password("x", _DUMMY_HASH)
raise HTTPException(status_code=401, detail="Invalid username or password")
# M-02: Run password verification BEFORE lockout check so Argon2id always
# executes — prevents distinguishing "locked" from "wrong password" via timing.
valid, new_hash = await averify_password_with_upgrade(data.password, user.password_hash)
await check_account_lockout(user)
if not valid:
await record_failed_login(db, user)
await log_audit_event(
db, action="auth.login_failed", actor_id=user.id,
detail={"reason": "invalid_password"}, ip=client_ip,
)
await db.commit()
raise HTTPException(status_code=401, detail="Invalid username or password")
# Block disabled accounts — checked AFTER password verification to avoid
# leaking account-state info, and BEFORE record_successful_login so
# last_login_at and lockout counters are not reset for inactive users.
if not user.is_active:
await log_audit_event(
db, action="auth.login_blocked_inactive", actor_id=user.id,
detail={"reason": "account_disabled"}, ip=client_ip,
)
await db.commit()
raise HTTPException(status_code=403, detail="Account is disabled. Contact an administrator.")
if new_hash:
user.password_hash = new_hash
await record_successful_login(db, user)
# SEC-03: MFA enforcement — block login entirely until MFA setup completes
if user.mfa_enforce_pending and not user.totp_enabled:
enforce_token = create_mfa_enforce_token(user.id)
await log_audit_event(
db, action="auth.mfa_enforce_prompted", actor_id=user.id, ip=client_ip,
)
await db.commit()
return {
"authenticated": False,
"mfa_setup_required": True,
"mfa_token": enforce_token,
}
# If TOTP is enabled, issue a short-lived MFA challenge token
if user.totp_enabled:
mfa_token = create_mfa_token(user.id)
await db.commit()
return {
"authenticated": False,
"totp_required": True,
"mfa_token": mfa_token,
}
# SEC-12: Forced password change after admin reset
if user.must_change_password:
# Issue a session but flag the frontend to show password change
user_agent = request.headers.get("user-agent")
_, token = await create_db_session(db, user, client_ip, user_agent)
set_session_cookie(response, token)
await db.commit()
return {
"authenticated": True,
"must_change_password": True,
}
user_agent = request.headers.get("user-agent")
_, token = await create_db_session(db, user, client_ip, user_agent)
set_session_cookie(response, token)
await log_audit_event(
db, action="auth.login_success", actor_id=user.id, ip=client_ip,
)
await db.commit()
return {"authenticated": True}
@router.post("/register")
async def register(
data: RegisterRequest,
response: Response,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""
Create a new standard user account.
Only available when system_config.allow_registration is True.
"""
config_result = await db.execute(
select(SystemConfig).where(SystemConfig.id == 1)
)
config = config_result.scalar_one_or_none()
if not config or not config.allow_registration:
raise HTTPException(status_code=403, detail="Registration is not available")
# Check username availability (generic error to prevent enumeration)
existing = await db.execute(
select(User).where(User.username == data.username)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.")
# Check email uniqueness (generic error to prevent enumeration)
if data.email:
existing_email = await db.execute(
select(User).where(User.email == data.email)
)
if existing_email.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Registration could not be completed. Please check your details and try again.")
password_hash = await ahash_password(data.password)
# SEC-01: Explicit field assignment — never **data.model_dump()
new_user = User(
username=data.username,
umbral_name=data.username,
password_hash=password_hash,
role="standard",
email=data.email,
date_of_birth=data.date_of_birth,
last_password_change_at=datetime.now(),
)
# Check if MFA enforcement is enabled for new users
if config.enforce_mfa_new_users:
new_user.mfa_enforce_pending = True
db.add(new_user)
await db.flush()
await _create_user_defaults(db, new_user.id, preferred_name=data.preferred_name)
ip = get_client_ip(request)
user_agent = request.headers.get("user-agent")
await log_audit_event(
db, action="auth.registration", actor_id=new_user.id, ip=ip,
)
await db.commit()
# If MFA enforcement is pending, don't issue a session — require MFA setup first
if new_user.mfa_enforce_pending:
enforce_token = create_mfa_enforce_token(new_user.id)
return {
"message": "Registration successful",
"authenticated": False,
"mfa_setup_required": True,
"mfa_token": enforce_token,
}
_, token = await create_db_session(db, new_user, ip, user_agent)
set_session_cookie(response, token)
await db.commit()
return {"message": "Registration successful", "authenticated": True}
@router.post("/logout")
async def logout(
response: Response,
session_cookie: Optional[str] = Cookie(None, alias="session"),
db: AsyncSession = Depends(get_db),
):
"""Revoke the current session in DB and clear the cookie."""
if session_cookie:
payload = verify_session_token(session_cookie)
if payload:
session_id = payload.get("sid")
if session_id:
result = await db.execute(
select(UserSession).where(UserSession.id == session_id)
)
db_session = result.scalar_one_or_none()
if db_session:
db_session.revoked = True
await db.commit()
response.delete_cookie(
key="session",
httponly=True,
secure=app_settings.COOKIE_SECURE,
samesite="lax",
)
return {"message": "Logout successful"}
@router.get("/status")
async def auth_status(
session_cookie: Optional[str] = Cookie(None, alias="session"),
db: AsyncSession = Depends(get_db),
):
"""
Check authentication status, role, and whether initial setup/registration is available.
"""
user_count_result = await db.execute(
select(func.count()).select_from(User)
)
setup_required = user_count_result.scalar_one() == 0
authenticated = False
role = None
is_locked = False
u = None
if not setup_required and session_cookie:
payload = verify_session_token(session_cookie)
if payload:
user_id = payload.get("uid")
session_id = payload.get("sid")
if user_id and session_id:
# Single JOIN query (was 2 sequential queries — P-01 fix)
result = await db.execute(
select(UserSession, User)
.join(User, UserSession.user_id == User.id)
.where(
UserSession.id == session_id,
UserSession.user_id == user_id,
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
User.is_active == True,
)
)
row = result.one_or_none()
if row is not None:
db_sess, u = row.tuple()
authenticated = True
is_locked = db_sess.is_locked
role = u.role
# Check registration availability
registration_open = False
if not setup_required:
config_result = await db.execute(
select(SystemConfig).where(SystemConfig.id == 1)
)
config = config_result.scalar_one_or_none()
registration_open = config.allow_registration if config else False
# Check if authenticated user has passkeys registered (Q-04, W-2: EXISTS not COUNT)
has_passkeys = False
if authenticated and u:
pk_result = await db.execute(
select(PasskeyCredential.id).where(
PasskeyCredential.user_id == u.id
).limit(1)
)
has_passkeys = pk_result.scalar_one_or_none() is not None
return {
"authenticated": authenticated,
"setup_required": setup_required,
"role": role,
"username": u.username if authenticated and u else None,
"registration_open": registration_open,
"is_locked": is_locked,
"has_passkeys": has_passkeys,
}
@router.post("/lock")
async def lock_session(
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Mark the current session as locked. Frontend must verify password to unlock."""
db_session: UserSession = request.state.db_session
db_session.is_locked = True
db_session.locked_at = datetime.now()
await db.commit()
return {"locked": True}
@router.post("/verify-password")
async def verify_password_endpoint(
data: VerifyPasswordRequest,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
Verify the current user's password without changing anything.
Used by the frontend lock screen to re-authenticate without a full login.
"""
await check_account_lockout(current_user)
valid, new_hash = await averify_password_with_upgrade(data.password, current_user.password_hash)
if not valid:
await record_failed_login(db, current_user)
await db.commit()
raise HTTPException(status_code=401, detail="Invalid password")
if new_hash:
current_user.password_hash = new_hash
# Clear session lock on successful password verification
db_session: UserSession = request.state.db_session
db_session.is_locked = False
db_session.locked_at = None
await db.commit()
return {"verified": True}
@router.post("/change-password")
async def change_password(
data: ChangePasswordRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Change the current user's password. Requires old password verification."""
await check_account_lockout(current_user)
valid, _ = await averify_password_with_upgrade(data.old_password, current_user.password_hash)
if not valid:
await record_failed_login(db, current_user)
await db.commit()
raise HTTPException(status_code=401, detail="Invalid current password")
if data.new_password == data.old_password:
raise HTTPException(status_code=400, detail="New password must be different from your current password")
current_user.password_hash = await ahash_password(data.new_password)
current_user.last_password_change_at = datetime.now()
# Clear forced password change flag if set (SEC-12)
if current_user.must_change_password:
current_user.must_change_password = False
await db.commit()
return {"message": "Password changed successfully"}
@router.get("/profile", response_model=ProfileResponse)
async def get_profile(
current_user: User = Depends(get_current_user),
):
"""Return the current user's profile fields."""
return ProfileResponse.model_validate(current_user)
@router.put("/profile", response_model=ProfileResponse)
async def update_profile(
data: ProfileUpdate,
request: Request,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update the current user's profile fields (first_name, last_name, email, date_of_birth)."""
update_data = data.model_dump(exclude_unset=True)
if not update_data:
return ProfileResponse.model_validate(current_user)
# Email uniqueness check if email is changing
if "email" in update_data and update_data["email"] != current_user.email:
new_email = update_data["email"]
if new_email:
existing = await db.execute(
select(User).where(User.email == new_email, User.id != current_user.id)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Email is already in use")
# Umbral name uniqueness check if changing
if "umbral_name" in update_data and update_data["umbral_name"] != current_user.umbral_name:
new_name = update_data["umbral_name"]
existing = await db.execute(
select(User).where(User.umbral_name == new_name, User.id != current_user.id)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Umbral name is already taken")
# SEC-01: Explicit field assignment — only allowed profile fields
if "first_name" in update_data:
current_user.first_name = update_data["first_name"]
if "last_name" in update_data:
current_user.last_name = update_data["last_name"]
if "email" in update_data:
current_user.email = update_data["email"]
if "date_of_birth" in update_data:
current_user.date_of_birth = update_data["date_of_birth"]
settings_result = await db.execute(
select(Settings).where(Settings.user_id == current_user.id)
)
user_settings = settings_result.scalar_one_or_none()
share = user_settings.share_birthday if user_settings else False
await sync_birthday_to_contacts(db, current_user.id, share_birthday=share, date_of_birth=update_data["date_of_birth"])
if "umbral_name" in update_data:
current_user.umbral_name = update_data["umbral_name"]
await log_audit_event(
db, action="auth.profile_updated", actor_id=current_user.id,
detail={"fields": list(update_data.keys())},
ip=get_client_ip(request),
)
await db.commit()
await db.refresh(current_user)
return ProfileResponse.model_validate(current_user)