"""Auth routes — login, logout, me, switch-tenant, password reset.""" from __future__ import annotations import uuid from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy.ext.asyncio import AsyncSession from app.config import get_settings from app.core.auth import get_redis from app.core.db import get_db from app.core.rate_limit import check_rate_limit, get_client_ip, reset_rate_limit from app.schemas.auth import ( LoginRequest, PasswordResetConfirm, PasswordResetRequest, SwitchTenantRequest, ) from app.deps import get_current_user from app.services.auth_service import auth_service router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) settings = get_settings() @router.post("/login") async def login( request: Request, body: LoginRequest, db: AsyncSession = Depends(get_db), ): """Login with email+password. Sets session cookie.""" ip = get_client_ip(request) redis = get_redis() # Rate limit await check_rate_limit( f"auth:login:{ip}:{body.email}", settings.rate_limit_login_max, settings.rate_limit_login_window, ) result = await auth_service.login(db, redis, body.email, body.password) if result is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail={"detail": "Invalid email or password", "code": "invalid_credentials"}, ) session_id, csrf_token, user, tenant = result # Reset rate limit on success await reset_rate_limit(f"auth:login:{ip}:{body.email}") response = Response(status_code=status.HTTP_200_OK) response.set_cookie( key=settings.session_cookie_name, value=session_id, httponly=settings.session_cookie_httponly, secure=settings.session_cookie_secure, samesite=settings.session_cookie_samesite, max_age=settings.session_ttl_seconds, path="/", ) # Return auth info in body from fastapi.responses import JSONResponse resp = JSONResponse( status_code=status.HTTP_200_OK, content={ "user_id": str(user.id), "email": user.email, "name": user.name, "role": user.role, "is_system_admin": user.is_system_admin, "tenant_id": str(tenant.id), "tenant_name": tenant.name, "csrf_token": csrf_token, }, ) resp.set_cookie( key=settings.session_cookie_name, value=session_id, httponly=settings.session_cookie_httponly, secure=settings.session_cookie_secure, samesite=settings.session_cookie_samesite, max_age=settings.session_ttl_seconds, path="/", ) return resp @router.post("/logout") async def logout( request: Request, db: AsyncSession = Depends(get_db), ): """Logout — invalidate session, clear cookie.""" session_id = request.cookies.get(settings.session_cookie_name) redis = get_redis() if session_id: await auth_service.logout(redis, session_id) from fastapi.responses import JSONResponse resp = JSONResponse( status_code=status.HTTP_200_OK, content={"message": "Logged out"}, ) resp.delete_cookie(settings.session_cookie_name, path="/") return resp @router.get("/me") async def me( request: Request, db: AsyncSession = Depends(get_db), ): """Get current user + active tenant.""" session_id = request.cookies.get(settings.session_cookie_name) if not session_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail={"detail": "Not authenticated", "code": "not_authenticated"}, ) redis = get_redis() info = await auth_service.get_current_user_info(db, redis, session_id) if info is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail={"detail": "Session expired or invalid", "code": "session_invalid"}, ) from fastapi.responses import JSONResponse resp = JSONResponse(status_code=200, content=info) resp.set_cookie( key=settings.session_cookie_name, value=session_id, httponly=settings.session_cookie_httponly, secure=settings.session_cookie_secure, samesite=settings.session_cookie_samesite, max_age=settings.session_ttl_seconds, path="/", ) return resp @router.get("/me/permissions") async def me_permissions( request: Request, db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user), ): """Get resolved permissions for the current user. Returns permissions, denied_permissions, field_permissions, and is_system_admin. Used by frontend to enable/disable UI elements based on permissions. """ return { "permissions": current_user.get("permissions", []), "denied_permissions": current_user.get("denied_permissions", []), "field_permissions": current_user.get("field_permissions", {}), "is_system_admin": current_user.get("is_system_admin", False), } @router.post("/switch-tenant") async def switch_tenant( request: Request, body: SwitchTenantRequest, db: AsyncSession = Depends(get_db), ): """Switch active tenant for current session.""" session_id = request.cookies.get(settings.session_cookie_name) if not session_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail={"detail": "Not authenticated", "code": "not_authenticated"}, ) redis = get_redis() try: new_tenant_id = uuid.UUID(body.tenant_id) except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"detail": "Invalid tenant_id", "code": "invalid_tenant_id"}, ) from None result = await auth_service.switch_tenant(db, redis, session_id, new_tenant_id) if result is None: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={"detail": "Cannot switch to this tenant", "code": "tenant_switch_failed"}, ) return { "user_id": result["user_id"], "email": result["email"], "name": result["name"], "role": result["role"], "tenant_id": result["tenant_id"], "tenant_name": result.get("tenant_name"), } @router.post("/password-reset/request") async def password_reset_request( request: Request, body: PasswordResetRequest, db: AsyncSession = Depends(get_db), ): """Request password reset. Always returns 200 (no user enumeration).""" ip = get_client_ip(request) redis = get_redis() # noqa: F841 await check_rate_limit( f"auth:reset:{ip}", settings.rate_limit_reset_max, settings.rate_limit_reset_window, ) await auth_service.request_password_reset(db, body.email) return {"message": "If the email exists, a reset link has been sent."} @router.post("/password-reset/confirm") async def password_reset_confirm( request: Request, body: PasswordResetConfirm, db: AsyncSession = Depends(get_db), ): """Reset password with a valid token.""" ip = get_client_ip(request) redis = get_redis() # noqa: F841 await check_rate_limit( f"auth:reset_confirm:{ip}", settings.rate_limit_reset_confirm_max, settings.rate_limit_reset_confirm_window, ) success = await auth_service.confirm_password_reset(db, body.token, body.new_password) if not success: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"detail": "Invalid or expired token", "code": "invalid_token"}, ) return {"message": "Password has been reset successfully."}