- Backend: reject is_active=False users with HTTP 403 after password verification but before session creation (prevents last_login_at update, lockout reset, and MFA token issuance for disabled accounts) - Frontend: optimistic setQueryData on successful login eliminates the form flash between mutation success and auth query refetch - LockScreen: replace lockoutMessage + toast.error with unified loginError inline alert for 401/403/423 responses Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
600 lines
21 KiB
Python
600 lines
21 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,
|
|
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()
|
|
|
|
# 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")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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,
|
|
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")
|
|
|
|
# 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")
|
|
|
|
# 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)
|
|
|
|
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, *, 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).
|
|
"""
|
|
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 403 — account disabled (is_active=False)
|
|
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:
|
|
# M-02: Run Argon2id against a dummy hash so the response time is
|
|
# indistinguishable from a wrong-password attempt (prevents username enumeration).
|
|
verify_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 = verify_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")
|
|
|
|
if new_hash:
|
|
user.password_hash = new_hash
|
|
|
|
# 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, ip=client_ip,
|
|
)
|
|
await db.commit()
|
|
raise HTTPException(status_code=403, detail="Account is disabled. Contact an administrator.")
|
|
|
|
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,
|
|
"username": u.username if authenticated and u else None,
|
|
"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"}
|