Kyle Pope 619e220622 Fix QA review #2: W-03/W-04, S-01 through S-04
W-03: Unify split transactions — _create_db_session() now uses flush()
      instead of commit(), callers own the final commit.
W-04: Time-bound dedup key fetch to 7-day purge window.
S-01: Type admin dashboard response with RecentLoginItem/RecentAuditItem.
S-02: Convert starred events index to partial index WHERE is_starred = true.
S-03: EventTemplate.created_at default changed to func.now() for consistency.
S-04: Add single-worker scaling note to weather cache.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 05:41:16 +08:00

558 lines
19 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
"""
import uuid
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.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.calendar import Calendar
from app.schemas.auth import (
SetupRequest, LoginRequest, RegisterRequest,
ChangePasswordRequest, VerifyPasswordRequest,
)
from app.services.auth import (
hash_password,
verify_password_with_upgrade,
create_session_token,
verify_session_token,
create_mfa_token,
create_mfa_enforce_token,
)
from app.services.audit import log_audit_event
from app.config import settings as app_settings
router = APIRouter()
# ---------------------------------------------------------------------------
# Cookie helper
# ---------------------------------------------------------------------------
def _set_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
key="session",
value=token,
httponly=True,
secure=app_settings.COOKIE_SECURE,
max_age=app_settings.SESSION_MAX_AGE_DAYS * 86400,
samesite="lax",
)
# ---------------------------------------------------------------------------
# Auth dependencies — export get_current_user and get_current_settings
# ---------------------------------------------------------------------------
async def get_current_user(
request: Request,
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.
"""
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")
# Verify session is active in DB (covers revocation + expiry)
session_result = await db.execute(
select(UserSession).where(
UserSession.id == session_id,
UserSession.user_id == user_id,
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
)
)
db_session = session_result.scalar_one_or_none()
if not db_session:
raise HTTPException(status_code=401, detail="Session has been revoked or expired")
user_result = await db.execute(
select(User).where(User.id == user_id, User.is_active == True)
)
user = user_result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="User not found or inactive")
return user
async def get_current_settings(
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.
"""
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")
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")
# ---------------------------------------------------------------------------
# Account lockout helpers
# ---------------------------------------------------------------------------
async def _check_account_lockout(user: User) -> None:
"""Raise HTTP 423 if the account is currently locked."""
if user.locked_until and datetime.now() < user.locked_until:
remaining = int((user.locked_until - datetime.now()).total_seconds() / 60) + 1
raise HTTPException(
status_code=423,
detail=f"Account locked. Try again in {remaining} minutes.",
)
async def _record_failed_login(db: AsyncSession, user: User) -> None:
"""Increment failure counter; lock account after 10 failures."""
user.failed_login_count += 1
if user.failed_login_count >= 10:
user.locked_until = datetime.now() + timedelta(minutes=30)
await db.commit()
async def _record_successful_login(db: AsyncSession, user: User) -> None:
"""Reset failure counter and update last_login_at."""
user.failed_login_count = 0
user.locked_until = None
user.last_login_at = datetime.now()
await db.commit()
# ---------------------------------------------------------------------------
# Session creation helper
# ---------------------------------------------------------------------------
async def _create_db_session(
db: AsyncSession,
user: User,
ip: str,
user_agent: str | None,
) -> tuple[str, str]:
"""Insert a UserSession row and return (session_id, signed_cookie_token)."""
session_id = uuid.uuid4().hex
expires_at = datetime.now() + timedelta(days=app_settings.SESSION_MAX_AGE_DAYS)
db_session = UserSession(
id=session_id,
user_id=user.id,
expires_at=expires_at,
ip_address=ip[:45] if ip else None,
user_agent=(user_agent or "")[:255] if user_agent else None,
)
db.add(db_session)
await db.flush()
token = create_session_token(user.id, session_id)
return session_id, token
# ---------------------------------------------------------------------------
# User bootstrapping helper (Settings + default calendars)
# ---------------------------------------------------------------------------
async def _create_user_defaults(db: AsyncSession, user_id: int) -> None:
"""Create Settings row and default calendars for a new user."""
db.add(Settings(user_id=user_id))
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).
"""
existing = await db.execute(select(User))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Setup already completed")
password_hash = hash_password(data.password)
new_user = User(
username=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 = request.client.host if request.client else "unknown"
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 423 — account locked
"""
client_ip = request.client.host if request.client else "unknown"
result = await db.execute(select(User).where(User.username == data.username))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=401, detail="Invalid username or password")
await _check_account_lockout(user)
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
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")
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)
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 try a different username.")
password_hash = hash_password(data.password)
# SEC-01: Explicit field assignment — never **data.model_dump()
new_user = User(
username=data.username,
password_hash=password_hash,
role="standard",
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)
ip = request.client.host if request.client else "unknown"
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
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:
session_result = await db.execute(
select(UserSession).where(
UserSession.id == session_id,
UserSession.user_id == user_id,
UserSession.revoked == False,
UserSession.expires_at > datetime.now(),
)
)
if session_result.scalar_one_or_none() is not None:
authenticated = True
user_obj_result = await db.execute(
select(User).where(User.id == user_id, User.is_active == True)
)
u = user_obj_result.scalar_one_or_none()
if u:
role = u.role
else:
authenticated = False
# 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
return {
"authenticated": authenticated,
"setup_required": setup_required,
"role": role,
"registration_open": registration_open,
}
@router.post("/verify-password")
async def verify_password(
data: VerifyPasswordRequest,
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 = verify_password_with_upgrade(data.password, current_user.password_hash)
if not valid:
await _record_failed_login(db, current_user)
raise HTTPException(status_code=401, detail="Invalid password")
if new_hash:
current_user.password_hash = new_hash
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, _ = verify_password_with_upgrade(data.old_password, current_user.password_hash)
if not valid:
await _record_failed_login(db, current_user)
raise HTTPException(status_code=401, detail="Invalid current password")
current_user.password_hash = hash_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"}