feat(cloud-console): add user authentication and administration
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
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"
|
||||
Reference in New Issue
Block a user