Files
leocrm-bot 7a7daf8100 T01: core infrastructure + auth + multi-tenant + RLS
- 10 models: tenants, users, user_tenants, roles, sessions, audit_log, deletion_log, notifications, password_reset_tokens, api_tokens
- Session-based auth (Redis + PostgreSQL audit trail)
- Multi-tenant with ORM-level filtering + PostgreSQL RLS (set_config)
- RBAC with roles/permissions + field-level permissions
- CSRF protection via Origin header validation
- Auth rate limiting (Redis counters with TTL)
- CORS with explicit origins (no wildcard)
- Health endpoint (no auth required)
- Notification service + audit log middleware
- 29 tests, 26 ACs, all passing
- Coverage: 62% (infrastructure modules pending coverage in later tasks)
2026-06-29 00:10:10 +02:00

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."}