219 lines
6.5 KiB
Python
219 lines
6.5 KiB
Python
|
|
"""Auth routes — login, logout, me, switch-tenant, password reset."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import uuid
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
|
||
|
|
from app.config import get_settings
|
||
|
|
from app.core.rate_limit import check_rate_limit, get_client_ip, reset_rate_limit
|
||
|
|
from app.core.auth import get_redis
|
||
|
|
from app.core.db import get_db
|
||
|
|
from app.deps import get_current_user
|
||
|
|
from app.schemas.auth import (
|
||
|
|
LoginRequest, PasswordResetConfirm, PasswordResetRequest,
|
||
|
|
SwitchTenantRequest, AuthResponse,
|
||
|
|
)
|
||
|
|
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,
|
||
|
|
"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"},
|
||
|
|
)
|
||
|
|
|
||
|
|
return info
|
||
|
|
|
||
|
|
|
||
|
|
@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"},
|
||
|
|
)
|
||
|
|
|
||
|
|
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()
|
||
|
|
|
||
|
|
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()
|
||
|
|
|
||
|
|
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."}
|