218 lines
6.4 KiB
Python
218 lines
6.4 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from typing import NoReturn
|
|
|
|
from fastapi import APIRouter, Depends, Request
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.api.deps import get_api_current_user
|
|
from app.api.errors import ApiException
|
|
from app.api.responses import success_payload
|
|
from app.api.schemas import (
|
|
AuthMeEnvelope,
|
|
ChangePasswordRequest,
|
|
CsrfBootstrapEnvelope,
|
|
LoginEnvelope,
|
|
LoginRequest,
|
|
MessageEnvelope,
|
|
)
|
|
from app.auth import runtime as auth_runtime
|
|
from app.auth.deps import get_auth_service, get_login_rate_limiter
|
|
from app.auth.rate_limit import SlidingWindowRateLimiter
|
|
from app.auth.service import AuthService
|
|
from app.auth.session import set_session_user
|
|
from app.db.models import User
|
|
from app.db.session import get_db_session
|
|
from app.logging_utils import structured_log
|
|
from app.security.csrf import ensure_csrf_token
|
|
from app.services.users import application as user_service
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
router = APIRouter(prefix="/auth", tags=["api-auth"])
|
|
|
|
|
|
def _login_limiter_key_and_email(request: Request, payload: LoginRequest) -> tuple[str, str]:
|
|
return auth_runtime.login_rate_limit_key(request, payload.email), payload.email.strip().lower()
|
|
|
|
|
|
def _raise_rate_limited(normalized_email: str, retry_after_seconds: int) -> None:
|
|
structured_log(
|
|
logger,
|
|
"warning",
|
|
"api.auth.login_rate_limited",
|
|
email=normalized_email,
|
|
retry_after_seconds=retry_after_seconds,
|
|
)
|
|
raise ApiException(
|
|
status_code=429,
|
|
code="rate_limited",
|
|
message="Too many login attempts. Please try again later.",
|
|
details={"retry_after_seconds": retry_after_seconds},
|
|
)
|
|
|
|
|
|
def _serialize_user_payload(user: User) -> dict[str, object]:
|
|
return {
|
|
"id": int(user.id),
|
|
"email": user.email,
|
|
"is_admin": bool(user.is_admin),
|
|
"is_active": bool(user.is_active),
|
|
}
|
|
|
|
|
|
def _raise_invalid_credentials(*, normalized_email: str) -> NoReturn:
|
|
structured_log(logger, "info", "api.auth.login_failed", email=normalized_email)
|
|
raise ApiException(
|
|
status_code=401,
|
|
code="invalid_credentials",
|
|
message="Invalid email or password.",
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/login",
|
|
response_model=LoginEnvelope,
|
|
)
|
|
async def login(
|
|
payload: LoginRequest,
|
|
request: Request,
|
|
db_session: AsyncSession = Depends(get_db_session),
|
|
auth_service: AuthService = Depends(get_auth_service),
|
|
rate_limiter: SlidingWindowRateLimiter = Depends(get_login_rate_limiter),
|
|
):
|
|
limiter_key, normalized_email = _login_limiter_key_and_email(request, payload)
|
|
decision = rate_limiter.check(limiter_key)
|
|
if not decision.allowed:
|
|
_raise_rate_limited(normalized_email, int(decision.retry_after_seconds))
|
|
|
|
user = await auth_service.authenticate_user(
|
|
db_session,
|
|
email=payload.email,
|
|
password=payload.password,
|
|
)
|
|
if user is None:
|
|
rate_limiter.record_failure(limiter_key)
|
|
_raise_invalid_credentials(normalized_email=normalized_email)
|
|
|
|
rate_limiter.reset(limiter_key)
|
|
set_session_user(
|
|
request,
|
|
user_id=user.id,
|
|
email=user.email,
|
|
is_admin=user.is_admin,
|
|
)
|
|
structured_log(logger, "info", "api.auth.login_succeeded", user_id=user.id, is_admin=user.is_admin)
|
|
return success_payload(
|
|
request,
|
|
data={
|
|
"authenticated": True,
|
|
"csrf_token": ensure_csrf_token(request),
|
|
"user": _serialize_user_payload(user),
|
|
},
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/me",
|
|
response_model=AuthMeEnvelope,
|
|
)
|
|
async def get_current_session(
|
|
request: Request,
|
|
current_user: User = Depends(get_api_current_user),
|
|
):
|
|
return success_payload(
|
|
request,
|
|
data={
|
|
"authenticated": True,
|
|
"csrf_token": ensure_csrf_token(request),
|
|
"user": _serialize_user_payload(current_user),
|
|
},
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/csrf",
|
|
response_model=CsrfBootstrapEnvelope,
|
|
)
|
|
async def get_csrf_bootstrap(
|
|
request: Request,
|
|
db_session: AsyncSession = Depends(get_db_session),
|
|
):
|
|
current_user = await auth_runtime.get_authenticated_user(request, db_session)
|
|
return success_payload(
|
|
request,
|
|
data={
|
|
"csrf_token": ensure_csrf_token(request),
|
|
"authenticated": current_user is not None,
|
|
},
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/change-password",
|
|
response_model=MessageEnvelope,
|
|
)
|
|
async def change_password(
|
|
payload: ChangePasswordRequest,
|
|
request: Request,
|
|
current_user: User = Depends(get_api_current_user),
|
|
db_session: AsyncSession = Depends(get_db_session),
|
|
auth_service: AuthService = Depends(get_auth_service),
|
|
):
|
|
if not auth_service.verify_password(
|
|
password_hash=current_user.password_hash,
|
|
password=payload.current_password,
|
|
):
|
|
raise ApiException(
|
|
status_code=400,
|
|
code="invalid_current_password",
|
|
message="Current password is incorrect.",
|
|
)
|
|
if payload.new_password != payload.confirm_password:
|
|
raise ApiException(
|
|
status_code=400,
|
|
code="password_confirmation_mismatch",
|
|
message="New password and confirmation do not match.",
|
|
)
|
|
try:
|
|
validated_password = user_service.validate_password(payload.new_password)
|
|
except user_service.UserServiceError as exc:
|
|
raise ApiException(
|
|
status_code=400,
|
|
code="invalid_password",
|
|
message=str(exc),
|
|
) from exc
|
|
|
|
await user_service.set_user_password_hash(
|
|
db_session,
|
|
user=current_user,
|
|
password_hash=auth_service.hash_password(validated_password),
|
|
)
|
|
structured_log(logger, "info", "api.auth.password_changed", user_id=int(current_user.id))
|
|
return success_payload(
|
|
request,
|
|
data={"message": "Password updated successfully."},
|
|
)
|
|
|
|
|
|
@router.post(
|
|
"/logout",
|
|
response_model=MessageEnvelope,
|
|
)
|
|
async def logout(
|
|
request: Request,
|
|
db_session: AsyncSession = Depends(get_db_session),
|
|
):
|
|
current_user = await auth_runtime.get_authenticated_user(request, db_session)
|
|
auth_runtime.invalidate_session(request)
|
|
structured_log(
|
|
logger, "info", "api.auth.logout", user_id=int(current_user.id) if current_user is not None else None
|
|
)
|
|
return success_payload(
|
|
request,
|
|
data={
|
|
"message": "Logged out.",
|
|
},
|
|
)
|