Files
leocrm/app/routes/auth.py
T
Agent Zero abbe7a18fc fix(audit): P0-P3 audit fixes — 838 ruff errors → 0, 30 F821 bugs fixed, 118 files changed
- P0: hooks.py 3-tuple fix, trigger_dispatcher Contract, contacts/plugin unregister_actions_by_owner
- P0: 5 test files — check_permission mocks removed, hardcoded DB credential → env var
- P1: attachment_service DmsFile via Contract helper, restore_registry/history_hooks dedup
- P1: mail/plugin restore unregister, mcp_client datetime.now(UTC), saved_views/filters patterns
- P1: ProtectedRoute fail-closed, 13 test assertion fixes (bcrypt, DB-URLs, SECRET_KEYs)
- P2: deprecated notifications → post_system_message (3 files), forgejo Base, report_generator lazy import
- P2: webhooks permissions, deps.py/roles.py plugin perms removed, import_export default
- P2: address/tags/entity_links patterns removed, worker.py Contract-Umgehungen fixed
- P2: 28 frontend TODOs (hardcoded constants, deprecated notification API)
- P3: dead code, duplicates, deprecated imports, private attr, __import__ inline
- P3: 8 frontend TODOs (LucideIcons, inline styles, XSS, i18n)
- ruff: 838 → 0 (612 auto-fix + 246 manual + 27 F821 regression fix)
- F821: 30 → 0 (AutomationDefinition, DmsFile, user_id, Path, Any, String)
- Contract-Umgehungen: 2 neue gefunden (worker.py:169, worker.py:280) und gefixt
2026-08-16 01:17:18 +02:00

256 lines
7.8 KiB
Python

"""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_auth_db
from app.core.rate_limit import (
RateLimitPolicy,
check_rate_limit_policy,
get_client_ip,
reset_rate_limit,
)
from app.deps import get_current_user
from app.schemas.auth import (
AuthResponse,
LoginRequest,
MessageResponse,
PasswordResetConfirm,
PasswordResetRequest,
SwitchTenantRequest,
)
from app.services.auth_service import auth_service
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
settings = get_settings()
@router.post("/login", response_model=AuthResponse)
async def login(
request: Request,
body: LoginRequest,
db: AsyncSession = Depends(get_auth_db),
):
"""Login with email+password. Sets session cookie."""
ip = get_client_ip(request)
redis = get_redis()
# Rate limit — unified AUTH policy
await check_rate_limit_policy(
f"rate:auth:login:{ip}:{body.email}",
RateLimitPolicy.AUTH,
)
result = await auth_service.login(db, redis, body.email, body.password, body.tenant_slug)
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, role = result
# Reset rate limit on success
await reset_rate_limit(f"rate: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": 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", response_model=MessageResponse)
async def logout(
request: Request,
db: AsyncSession = Depends(get_auth_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", response_model=AuthResponse)
async def me(
request: Request,
db: AsyncSession = Depends(get_auth_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_auth_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_auth_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_auth_db),
):
"""Request password reset. Always returns 200 (no user enumeration)."""
ip = get_client_ip(request)
redis = get_redis() # noqa: F841
await check_rate_limit_policy(
f"rate:auth:reset:{ip}",
RateLimitPolicy.AUTH,
)
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_auth_db),
):
"""Reset password with a valid token."""
ip = get_client_ip(request)
redis = get_redis() # noqa: F841
await check_rate_limit_policy(
f"rate:auth:reset_confirm:{ip}",
RateLimitPolicy.AUTH,
)
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."}