182 lines
6.1 KiB
Python
182 lines
6.1 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.services.auth import (
|
|
AuthPasswordChangeError,
|
|
AuthenticatedSession,
|
|
authenticate_user,
|
|
change_password,
|
|
create_session,
|
|
revoke_session,
|
|
)
|
|
from app.services import login_throttle
|
|
|
|
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",
|
|
)
|
|
|
|
# --- 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)
|