324 lines
12 KiB
Python
324 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import TYPE_CHECKING
|
|
|
|
from fastapi import APIRouter, HTTPException, Query, Request, Response, status
|
|
|
|
from cloud.auth import AuthProvider, Principal, USERS_ADMIN_SCOPE
|
|
from cloud.observability import current_correlation_id
|
|
from cloud.repository import LastAdministratorConflictError, UserConflictError
|
|
from cloud.sdk.models import (
|
|
LoginRequest,
|
|
PasswordChangeRequest,
|
|
PasswordResetRequest,
|
|
UserCreateRequest,
|
|
UserListResponse,
|
|
UserResponse,
|
|
UserUpdateRequest,
|
|
)
|
|
from cloud.user_auth import (
|
|
USER_CSRF_COOKIE,
|
|
USER_SESSION_COOKIE,
|
|
UserAuthenticationError,
|
|
UserValidationError,
|
|
utc_now,
|
|
validate_display_name,
|
|
validate_role,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from cloud.control_config import CloudControlConfig
|
|
from cloud.user_auth import UserAccount, UserAuthService
|
|
|
|
|
|
def create_user_auth_router(
|
|
*,
|
|
user_auth_service: UserAuthService,
|
|
auth_provider: AuthProvider,
|
|
config: CloudControlConfig,
|
|
version_prefix: str = "/v1",
|
|
) -> APIRouter:
|
|
router = APIRouter(prefix=version_prefix, tags=["cloud-user-authentication"])
|
|
|
|
def _principal(
|
|
request: Request,
|
|
*,
|
|
required_scope: str | None = None,
|
|
allow_password_change: bool = False,
|
|
) -> Principal:
|
|
principal = auth_provider.authenticate(request)
|
|
if principal is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="unauthorized",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
if principal.must_change_password and not allow_password_change:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="password must be changed before accessing this resource",
|
|
)
|
|
if required_scope is not None and not principal.has_scope(required_scope):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail=f"missing required scope: {required_scope}",
|
|
)
|
|
return principal
|
|
|
|
def _require_session(principal: Principal) -> tuple[str, str]:
|
|
if principal.session_id is None or not principal.id.startswith("user:"):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="a user session is required",
|
|
)
|
|
return principal.id.removeprefix("user:"), principal.session_id
|
|
|
|
def _require_csrf(request: Request, principal: Principal) -> None:
|
|
if principal.session_id is None:
|
|
return
|
|
if not user_auth_service.validate_csrf(
|
|
session_token=request.cookies.get(USER_SESSION_COOKIE),
|
|
csrf_cookie=request.cookies.get(USER_CSRF_COOKIE),
|
|
csrf_header=request.headers.get("x-csrf-token"),
|
|
):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="CSRF validation failed",
|
|
)
|
|
|
|
def _clear_cookies(response: Response) -> None:
|
|
response.delete_cookie(
|
|
USER_SESSION_COOKIE,
|
|
path="/",
|
|
secure=config.session_cookie_secure,
|
|
httponly=True,
|
|
samesite="lax",
|
|
)
|
|
response.delete_cookie(
|
|
USER_CSRF_COOKIE,
|
|
path="/",
|
|
secure=config.session_cookie_secure,
|
|
httponly=False,
|
|
samesite="lax",
|
|
)
|
|
|
|
@router.post("/auth/login", response_model=UserResponse)
|
|
def login(payload: LoginRequest, request: Request, response: Response) -> UserResponse:
|
|
try:
|
|
result = user_auth_service.login(
|
|
username=payload.username,
|
|
password=payload.password,
|
|
client_bucket=_client_bucket(request, config.trust_proxy_headers),
|
|
correlation_id=current_correlation_id(),
|
|
)
|
|
except UserAuthenticationError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="invalid username or password",
|
|
) from exc
|
|
response.set_cookie(
|
|
USER_SESSION_COOKIE,
|
|
result.session_token,
|
|
max_age=config.user_session_absolute_seconds,
|
|
path="/",
|
|
secure=config.session_cookie_secure,
|
|
httponly=True,
|
|
samesite="lax",
|
|
)
|
|
response.set_cookie(
|
|
USER_CSRF_COOKIE,
|
|
result.csrf_token,
|
|
max_age=config.user_session_absolute_seconds,
|
|
path="/",
|
|
secure=config.session_cookie_secure,
|
|
httponly=False,
|
|
samesite="lax",
|
|
)
|
|
return _user_response(result.user)
|
|
|
|
@router.get("/auth/me", response_model=UserResponse)
|
|
def current_user(request: Request) -> UserResponse:
|
|
principal = _principal(request, allow_password_change=True)
|
|
user_id, _ = _require_session(principal)
|
|
user = user_auth_service.repository.get_user(user_id) # type: ignore[attr-defined]
|
|
if user is None:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="unauthorized")
|
|
return _user_response(user)
|
|
|
|
@router.post("/auth/logout", status_code=status.HTTP_204_NO_CONTENT)
|
|
def logout(request: Request, response: Response) -> Response:
|
|
principal = _principal(request, allow_password_change=True)
|
|
user_id, session_id = _require_session(principal)
|
|
_require_csrf(request, principal)
|
|
user_auth_service.logout(
|
|
session_id=session_id,
|
|
user_id=user_id,
|
|
correlation_id=current_correlation_id(),
|
|
)
|
|
_clear_cookies(response)
|
|
response.status_code = status.HTTP_204_NO_CONTENT
|
|
return response
|
|
|
|
@router.post("/auth/password", status_code=status.HTTP_204_NO_CONTENT)
|
|
def change_password(
|
|
payload: PasswordChangeRequest,
|
|
request: Request,
|
|
response: Response,
|
|
) -> Response:
|
|
principal = _principal(request, allow_password_change=True)
|
|
user_id, _ = _require_session(principal)
|
|
_require_csrf(request, principal)
|
|
try:
|
|
user_auth_service.change_password(
|
|
user_id=user_id,
|
|
current_password=payload.current_password,
|
|
new_password=payload.new_password,
|
|
correlation_id=current_correlation_id(),
|
|
)
|
|
except UserAuthenticationError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="invalid username or password",
|
|
) from exc
|
|
except UserValidationError as exc:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
|
_clear_cookies(response)
|
|
response.status_code = status.HTTP_204_NO_CONTENT
|
|
return response
|
|
|
|
@router.get("/users", response_model=UserListResponse)
|
|
def list_users(
|
|
request: Request,
|
|
limit: int = Query(default=50, ge=1, le=100),
|
|
offset: int = Query(default=0, ge=0),
|
|
) -> UserListResponse:
|
|
_principal(request, required_scope=USERS_ADMIN_SCOPE)
|
|
users = user_auth_service.repository.list_users(limit=limit, offset=offset) # type: ignore[attr-defined]
|
|
return UserListResponse(
|
|
items=[_user_response(user) for user in users],
|
|
limit=limit,
|
|
offset=offset,
|
|
)
|
|
|
|
@router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
|
def create_user(payload: UserCreateRequest, request: Request) -> UserResponse:
|
|
principal = _principal(request, required_scope=USERS_ADMIN_SCOPE)
|
|
_require_csrf(request, principal)
|
|
try:
|
|
user = user_auth_service.create_user(
|
|
username=payload.username,
|
|
display_name=payload.display_name,
|
|
role=payload.role,
|
|
password=payload.password,
|
|
)
|
|
except (UserValidationError, UserConflictError) as exc:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
|
user_auth_service.record_admin_action(
|
|
actor_principal_id=principal.id,
|
|
target_user_id=user.id,
|
|
action="user_create",
|
|
correlation_id=current_correlation_id(),
|
|
metadata={"role": user.role},
|
|
)
|
|
return _user_response(user)
|
|
|
|
@router.patch("/users/{user_id}", response_model=UserResponse)
|
|
def update_user(
|
|
user_id: str,
|
|
payload: UserUpdateRequest,
|
|
request: Request,
|
|
) -> UserResponse:
|
|
principal = _principal(request, required_scope=USERS_ADMIN_SCOPE)
|
|
_require_csrf(request, principal)
|
|
try:
|
|
user = user_auth_service.repository.update_user( # type: ignore[attr-defined]
|
|
user_id,
|
|
display_name=(
|
|
validate_display_name(payload.display_name)
|
|
if payload.display_name is not None
|
|
else None
|
|
),
|
|
role=validate_role(payload.role) if payload.role is not None else None,
|
|
enabled=payload.enabled,
|
|
updated_at=utc_now(),
|
|
)
|
|
except KeyError as exc:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from exc
|
|
except LastAdministratorConflictError as exc:
|
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
|
|
except UserValidationError as exc:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
|
user_auth_service.record_admin_action(
|
|
actor_principal_id=principal.id,
|
|
target_user_id=user.id,
|
|
action="user_update",
|
|
correlation_id=current_correlation_id(),
|
|
metadata={"role": user.role, "enabled": str(user.enabled)},
|
|
)
|
|
return _user_response(user)
|
|
|
|
@router.post("/users/{user_id}/password", response_model=UserResponse)
|
|
def reset_password(
|
|
user_id: str,
|
|
payload: PasswordResetRequest,
|
|
request: Request,
|
|
) -> UserResponse:
|
|
principal = _principal(request, required_scope=USERS_ADMIN_SCOPE)
|
|
_require_csrf(request, principal)
|
|
try:
|
|
user = user_auth_service.reset_password(
|
|
user_id=user_id,
|
|
new_password=payload.password,
|
|
actor_principal_id=principal.id,
|
|
correlation_id=current_correlation_id(),
|
|
)
|
|
except KeyError as exc:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found") from exc
|
|
except UserValidationError as exc:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=str(exc)) from exc
|
|
return _user_response(user)
|
|
|
|
@router.delete("/users/{user_id}/sessions", status_code=status.HTTP_204_NO_CONTENT)
|
|
def revoke_user_sessions(user_id: str, request: Request) -> Response:
|
|
principal = _principal(request, required_scope=USERS_ADMIN_SCOPE)
|
|
_require_csrf(request, principal)
|
|
user = user_auth_service.repository.get_user(user_id) # type: ignore[attr-defined]
|
|
if user is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="user not found")
|
|
user_auth_service.repository.revoke_user_sessions( # type: ignore[attr-defined]
|
|
user_id,
|
|
revoked_at=utc_now(),
|
|
)
|
|
user_auth_service.record_admin_action(
|
|
actor_principal_id=principal.id,
|
|
target_user_id=user_id,
|
|
action="session_revoke",
|
|
correlation_id=current_correlation_id(),
|
|
)
|
|
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
|
|
return router
|
|
|
|
|
|
def _user_response(user: UserAccount) -> UserResponse:
|
|
return UserResponse(
|
|
id=user.id,
|
|
username=user.username,
|
|
display_name=user.display_name,
|
|
role=user.role,
|
|
enabled=user.enabled,
|
|
must_change_password=user.must_change_password,
|
|
scopes=sorted(user.scopes),
|
|
created_at=user.created_at,
|
|
updated_at=user.updated_at,
|
|
last_login_at=user.last_login_at,
|
|
)
|
|
|
|
|
|
def _client_bucket(request: Request, trust_proxy_headers: bool) -> str:
|
|
if trust_proxy_headers:
|
|
forwarded = request.headers.get("x-forwarded-for")
|
|
if forwarded:
|
|
return forwarded.split(",", maxsplit=1)[0].strip() or "unknown"
|
|
return request.client.host if request.client is not None else "unknown"
|