325 lines
11 KiB
Python
325 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.routes.api.deps import require_csrf, require_session
|
|
from app.config import Settings
|
|
from app.dependencies import get_app_settings, get_db
|
|
from app.schemas.session import (
|
|
LoginRequest,
|
|
PasswordChangeRequest,
|
|
SessionResponse,
|
|
SessionUser,
|
|
)
|
|
from app.schemas.totp import (
|
|
TotpDisableRequest,
|
|
TotpEnableRequest,
|
|
TotpSetupResponse,
|
|
TotpStatusResponse,
|
|
)
|
|
from app.services.auth import (
|
|
AuthPasswordChangeError,
|
|
AuthenticatedSession,
|
|
authenticate_user,
|
|
change_password,
|
|
create_session,
|
|
revoke_session,
|
|
)
|
|
from app.services import login_throttle
|
|
from app.services import totp as totp_service
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/api", tags=["api-session"])
|
|
|
|
|
|
def _build_session_response(auth: AuthenticatedSession) -> SessionResponse:
|
|
return SessionResponse(
|
|
user=SessionUser(
|
|
username=auth.user.username,
|
|
force_password_change=auth.user.force_password_change,
|
|
),
|
|
csrf_token=auth.session.csrf_token,
|
|
)
|
|
|
|
|
|
@router.get("/session", response_model=SessionResponse)
|
|
def get_session(
|
|
auth: AuthenticatedSession = Depends(require_session),
|
|
) -> SessionResponse:
|
|
"""Return the current session user and CSRF token. Returns 401 if not authenticated."""
|
|
return _build_session_response(auth)
|
|
|
|
|
|
def _get_client_ip(request: Request, *, trust_forwarded_for: bool) -> str:
|
|
"""Extract the client IP address from the request.
|
|
|
|
When ``trust_forwarded_for`` is True (reverse-proxy deployments) the
|
|
left-most value from the ``X-Forwarded-For`` header is used. Otherwise the
|
|
direct socket IP (``request.client.host``) is used.
|
|
"""
|
|
if trust_forwarded_for:
|
|
xff = request.headers.get("X-Forwarded-For", "")
|
|
if xff:
|
|
return xff.split(",")[0].strip()
|
|
if request.client is not None:
|
|
return request.client.host
|
|
return "unknown"
|
|
|
|
|
|
@router.post("/auth/login", response_model=SessionResponse)
|
|
def post_login(
|
|
body: LoginRequest,
|
|
request: Request,
|
|
response: Response,
|
|
db: Session = Depends(get_db),
|
|
settings: Settings = Depends(get_app_settings),
|
|
) -> SessionResponse:
|
|
"""
|
|
Authenticate with username and password.
|
|
|
|
On success, sets an HttpOnly session cookie and returns the session user + CSRF token.
|
|
On failure, returns 401 with no cookie set.
|
|
Repeated failures trigger exponential back-off (429 + Retry-After).
|
|
No X-CSRF-Token required (unauthenticated endpoint).
|
|
"""
|
|
client_ip = _get_client_ip(request, trust_forwarded_for=settings.auth_trust_forwarded_for)
|
|
|
|
# --- Throttle check (before any password verification) ---
|
|
if settings.auth_login_throttle_enabled:
|
|
wait_seconds = login_throttle.check_and_get_wait(
|
|
db, ip=client_ip, username=body.username
|
|
)
|
|
if wait_seconds > 0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
detail="too many failed login attempts; please try again later",
|
|
headers={"Retry-After": str(wait_seconds)},
|
|
)
|
|
|
|
# --- Password verification ---
|
|
user = authenticate_user(db, username=body.username, password=body.password)
|
|
if user is None:
|
|
if settings.auth_login_throttle_enabled:
|
|
login_throttle.register_failure(db, ip=client_ip, username=body.username)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="invalid username or password",
|
|
)
|
|
|
|
# --- TOTP second-factor check (only when TOTP is enabled for the user) ---
|
|
if user.totp_enabled:
|
|
if not body.totp_code:
|
|
# Password correct but no TOTP code supplied: signal the front-end to
|
|
# prompt for the second factor. Do NOT issue a session.
|
|
# Deliberate: this is a normal two-step protocol step from a legitimate
|
|
# user — we do NOT register a throttle failure here. The attacker must
|
|
# already have the correct password to reach this branch, and counting
|
|
# each normal first-step as a failure would risk locking out the real
|
|
# admin on every login attempt.
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail={"totp_required": True},
|
|
)
|
|
|
|
# TOTP code supplied — verify against time-based code or a recovery code.
|
|
totp_ok = totp_service.verify_totp_code(user, body.totp_code)
|
|
if not totp_ok:
|
|
totp_ok = totp_service.verify_recovery_code(db, user=user, code=body.totp_code)
|
|
|
|
if not totp_ok:
|
|
# Second-factor failure is an active attack signal — register failure
|
|
# so that repeated wrong TOTP/recovery-code guesses trigger back-off.
|
|
if settings.auth_login_throttle_enabled:
|
|
login_throttle.register_failure(db, ip=client_ip, username=body.username)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="invalid username or password",
|
|
)
|
|
|
|
# --- Success: clear back-off state and issue session ---
|
|
if settings.auth_login_throttle_enabled:
|
|
login_throttle.clear(db, ip=client_ip, username=body.username)
|
|
|
|
auth_session, raw_token = create_session(db, user=user, settings=settings)
|
|
logger.info("Created API authenticated session for user '%s'", user.username)
|
|
|
|
response.set_cookie(
|
|
key=settings.auth_session_cookie_name,
|
|
value=raw_token,
|
|
max_age=settings.auth_session_ttl_hours * 3600,
|
|
httponly=True,
|
|
secure=settings.auth_cookie_secure,
|
|
samesite="lax",
|
|
path="/",
|
|
)
|
|
|
|
auth = AuthenticatedSession(user=user, session=auth_session)
|
|
return _build_session_response(auth)
|
|
|
|
|
|
@router.post("/auth/logout")
|
|
def post_logout(
|
|
response: Response,
|
|
db: Session = Depends(get_db),
|
|
settings: Settings = Depends(get_app_settings),
|
|
auth: AuthenticatedSession = Depends(require_session),
|
|
_csrf: None = Depends(require_csrf),
|
|
) -> Response:
|
|
"""
|
|
Revoke the current session and clear the session cookie.
|
|
Requires authentication and X-CSRF-Token header.
|
|
Returns 204 No Content.
|
|
"""
|
|
revoke_session(db, auth_session=auth.session)
|
|
logger.info("Revoked API authenticated session for user '%s'", auth.user.username)
|
|
no_content = Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
no_content.delete_cookie(settings.auth_session_cookie_name, path="/")
|
|
return no_content
|
|
|
|
|
|
@router.post("/auth/password")
|
|
def post_change_password(
|
|
body: PasswordChangeRequest,
|
|
db: Session = Depends(get_db),
|
|
auth: AuthenticatedSession = Depends(require_session),
|
|
_csrf: None = Depends(require_csrf),
|
|
) -> Response:
|
|
"""
|
|
Change the current user's password.
|
|
Requires authentication and X-CSRF-Token header.
|
|
On AuthPasswordChangeError returns 400 with a generic message.
|
|
On success, force_password_change becomes False (handled by the service).
|
|
Returns 204 No Content.
|
|
"""
|
|
try:
|
|
change_password(
|
|
db,
|
|
user=auth.user,
|
|
current_password=body.current_password,
|
|
new_password=body.new_password,
|
|
confirm_password=body.confirm_password,
|
|
)
|
|
except AuthPasswordChangeError as exc:
|
|
logger.info(
|
|
"Rejected password change for user '%s': %s",
|
|
auth.user.username,
|
|
exc,
|
|
)
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="password change failed",
|
|
) from exc
|
|
|
|
logger.info("Password updated for user '%s'", auth.user.username)
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TOTP endpoints (M4-T05)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/auth/totp/setup", response_model=TotpSetupResponse)
|
|
def post_totp_setup(
|
|
db: Session = Depends(get_db),
|
|
settings: Settings = Depends(get_app_settings),
|
|
auth: AuthenticatedSession = Depends(require_session),
|
|
_csrf: None = Depends(require_csrf),
|
|
) -> TotpSetupResponse:
|
|
"""
|
|
Generate a new pending TOTP secret, otpauth URI, and one-time recovery codes.
|
|
|
|
The secret is stored in the DB but TOTP is NOT yet enabled (totp_enabled stays
|
|
False until the user confirms with POST /api/auth/totp/enable).
|
|
|
|
Recovery codes are returned here as plaintext exactly once; their Argon2 hashes
|
|
are persisted immediately so enable only needs to flip the enabled flag.
|
|
|
|
Repeating this call replaces any prior pending secret and regenerates codes.
|
|
|
|
Requires: session cookie + X-CSRF-Token.
|
|
"""
|
|
secret, otpauth_uri, recovery_codes = totp_service.setup(
|
|
db,
|
|
user=auth.user,
|
|
issuer=settings.effective_totp_issuer,
|
|
)
|
|
return TotpSetupResponse(
|
|
secret=secret,
|
|
otpauth_uri=otpauth_uri,
|
|
recovery_codes=recovery_codes,
|
|
)
|
|
|
|
|
|
@router.post("/auth/totp/enable", status_code=status.HTTP_204_NO_CONTENT)
|
|
def post_totp_enable(
|
|
body: TotpEnableRequest,
|
|
db: Session = Depends(get_db),
|
|
auth: AuthenticatedSession = Depends(require_session),
|
|
_csrf: None = Depends(require_csrf),
|
|
) -> Response:
|
|
"""
|
|
Enable TOTP by confirming with the current 6-digit code from the authenticator app.
|
|
|
|
Requires a prior call to POST /api/auth/totp/setup (so that a pending secret
|
|
exists). On success, totp_enabled becomes True.
|
|
|
|
Returns 400 if the code is wrong or there is no pending secret.
|
|
Requires: session cookie + X-CSRF-Token.
|
|
"""
|
|
ok = totp_service.enable(db, user=auth.user, code=body.code)
|
|
if not ok:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="invalid TOTP code or no pending setup",
|
|
)
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
@router.post("/auth/totp/disable", status_code=status.HTTP_204_NO_CONTENT)
|
|
def post_totp_disable(
|
|
body: TotpDisableRequest,
|
|
db: Session = Depends(get_db),
|
|
auth: AuthenticatedSession = Depends(require_session),
|
|
_csrf: None = Depends(require_csrf),
|
|
) -> Response:
|
|
"""
|
|
Disable TOTP. The caller must provide exactly one of:
|
|
- ``password``: the user's current login password, OR
|
|
- ``code``: the current 6-digit TOTP code.
|
|
|
|
On success: totp_enabled=False, totp_secret cleared, all recovery codes deleted.
|
|
Returns 400 if neither credential matches or neither is provided.
|
|
Requires: session cookie + X-CSRF-Token.
|
|
"""
|
|
ok = totp_service.disable(
|
|
db,
|
|
user=auth.user,
|
|
password=body.password,
|
|
code=body.code,
|
|
)
|
|
if not ok:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="invalid credential; provide a valid password or TOTP code",
|
|
)
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
|
|
@router.get("/auth/totp", response_model=TotpStatusResponse)
|
|
def get_totp_status(
|
|
auth: AuthenticatedSession = Depends(require_session),
|
|
) -> TotpStatusResponse:
|
|
"""
|
|
Return the current TOTP status for the authenticated user.
|
|
|
|
Response contains only ``{"enabled": bool}``.
|
|
Secret and recovery codes are NEVER returned here.
|
|
Requires: session cookie only (no CSRF — read-only).
|
|
"""
|
|
return TotpStatusResponse(enabled=auth.user.totp_enabled)
|