Kyle Pope a0b50a2b13 Remediate pentest findings F-01, F-02, F-06
- Remove ineffective in-memory IP rate limiter from auth.py (F-01):
  nginx limit_req_zone handles real-IP throttling, DB lockout is the per-user guard
- Block RFC 1918 + IPv6 ULA ranges in ntfy SSRF guard (F-02):
  prevents requests to Docker-internal services via user-controlled ntfy URL
- Rate-limit /api/auth/setup at nginx with burst=3 (F-06)
- Document production deployment checklist in .env.example (F-03/F-04/F-05)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 02:25:37 +08:00

379 lines
13 KiB
Python

"""
Authentication router — username/password with DB-backed sessions and account lockout.
Session flow:
POST /setup → create User + Settings row → issue session cookie
POST /login → verify credentials → check lockout → insert UserSession → issue cookie
→ if TOTP enabled: return mfa_token instead of full session
POST /logout → mark session revoked in DB → delete cookie
GET /status → verify user exists + session valid
Security layers:
1. Nginx limit_req_zone (real-IP, 10 req/min burst 5) — outer guard on all auth endpoints
2. DB-backed account lockout (10 failures → 30-min lock, HTTP 423) — per-user guard
3. Session revocation stored in DB (survives container restarts)
4. bcrypt→Argon2id transparent upgrade on first login with migrated hash
"""
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
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.schemas.auth import SetupRequest, LoginRequest, ChangePasswordRequest, VerifyPasswordRequest
from app.services.auth import (
hash_password,
verify_password_with_upgrade,
create_session_token,
verify_session_token,
create_mfa_token,
)
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.
Replaces the old get_current_session (which returned Settings).
Any router that hasn't been updated will get a compile-time type error.
"""
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
# ---------------------------------------------------------------------------
# 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, # clamp to column width
user_agent=(user_agent or "")[:255] if user_agent else None,
)
db.add(db_session)
await db.commit()
token = create_session_token(user.id, session_id)
return session_id, token
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@router.post("/setup")
async def setup(
data: SetupRequest,
response: Response,
request: Request,
db: AsyncSession = Depends(get_db),
):
"""
First-time setup: create the User record and a linked Settings row.
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)
db.add(new_user)
await db.flush() # assign new_user.id before creating Settings
# Create Settings row linked to this user with all defaults
new_settings = Settings(user_id=new_user.id)
db.add(new_settings)
await db.commit()
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)
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)
{ authenticated: false, totp_required: true, mfa_token: "..." } — TOTP pending
HTTP 401 — wrong credentials (generic; never reveals which field is wrong)
HTTP 423 — account locked
HTTP 429 — IP rate limited
"""
client_ip = request.client.host if request.client else "unknown"
# Lookup user — do NOT differentiate "user not found" from "wrong password"
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)
# Transparent bcrypt→Argon2id upgrade
valid, new_hash = verify_password_with_upgrade(data.password, user.password_hash)
if not valid:
await _record_failed_login(db, user)
raise HTTPException(status_code=401, detail="Invalid username or password")
# Persist upgraded hash if migration happened
if new_hash:
user.password_hash = new_hash
await _record_successful_login(db, user)
# If TOTP is enabled, issue a short-lived MFA challenge token instead of a full session
if user.totp_enabled:
mfa_token = create_mfa_token(user.id)
return {
"authenticated": False,
"totp_required": True,
"mfa_token": mfa_token,
}
user_agent = request.headers.get("user-agent")
_, token = await _create_db_session(db, user, client_ip, user_agent)
_set_session_cookie(response, token)
return {"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 and whether initial setup has been performed.
Used by the frontend to decide whether to show login vs setup screen.
"""
user_result = await db.execute(select(User))
existing_user = user_result.scalar_one_or_none()
setup_required = existing_user is None
authenticated = False
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(),
)
)
authenticated = session_result.scalar_one_or_none() is not None
return {"authenticated": authenticated, "setup_required": setup_required}
@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.
Also handles transparent bcrypt→Argon2id upgrade.
Shares the same lockout guards as /login. Nginx limit_req_zone handles IP rate limiting.
"""
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")
# Persist upgraded hash if migration happened
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)
await db.commit()
return {"message": "Password changed successfully"}