feat(auth): implement Phase 4 user profile and API key endpoints
Build and Push / build (release) Successful in 1m10s
Build and Push / build (release) Successful in 1m10s
Add user profile, preferences, and API key management endpoints: - GET /auth/users/me - full user profile with roles and preferences - GET/PATCH /auth/users/me/preferences - user preferences management - GET/POST/DELETE /auth/users/me/api-keys - API key lifecycle API keys use tak_ prefix, SHA-256 hashing, and are shown only once on creation. Preferences support partial updates with JSON merge behavior. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
7752cd9d23
commit
397a47c8fc
+265
-12
@@ -15,9 +15,12 @@ from src.shared.database import get_async_session
|
||||
from src.domains.auth.schemas import (
|
||||
AuthSyncRequest, AuthSyncResponse, UsersListResponse,
|
||||
BulkSyncResultSchema, GroupsListResponse, RolesListResponse,
|
||||
GroupRoleAssignmentResponse,
|
||||
GroupRoleAssignmentResponse, UserProfileResponse, PreferencesUpdateRequest,
|
||||
UserPreferencesSchema, ApiKeyCreateRequest, ApiKeyCreateResponse,
|
||||
ApiKeysListResponse,
|
||||
)
|
||||
from src.domains.auth.service import AuthService
|
||||
from src.domains.auth.oidc import get_current_user
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -308,30 +311,280 @@ class AuthController(BaseController):
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
# =====================================================================
|
||||
# Phase 4: User Profile & Settings
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
"/users/me",
|
||||
summary="Get current user profile",
|
||||
response_model=AuthSyncResponse,
|
||||
response_model=UserProfileResponse,
|
||||
responses={
|
||||
200: {"description": "User profile"},
|
||||
200: {"description": "User profile with roles and preferences"},
|
||||
401: {"description": "Not authenticated"},
|
||||
404: {"description": "User not found in database"},
|
||||
},
|
||||
)
|
||||
async def get_me(
|
||||
async def get_current_user_profile(
|
||||
user_claims: dict = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> JSONResponse:
|
||||
) -> UserProfileResponse:
|
||||
"""
|
||||
Get the current authenticated user's profile
|
||||
|
||||
Note: This endpoint requires a valid session or API key.
|
||||
For now, returns 501 Not Implemented until session management is added.
|
||||
Returns the user's profile, roles, and preferences.
|
||||
Requires authentication via Bearer token or API key.
|
||||
"""
|
||||
# TODO: Implement with get_current_user dependency
|
||||
raise HTTPException(
|
||||
status_code=501,
|
||||
detail="Not implemented - use /auth/sync with access token",
|
||||
service = AuthService(session)
|
||||
|
||||
# Get authentik_id from claims (JWT 'sub' field)
|
||||
authentik_id_str = user_claims.get("sub")
|
||||
if not authentik_id_str or authentik_id_str == "local-user":
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
authentik_id = uuid.UUID(authentik_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||
|
||||
user = await service.get_user_by_authentik_id(authentik_id)
|
||||
if user is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail="User not found - please sync via /auth/sync first",
|
||||
)
|
||||
|
||||
return UserProfileResponse(
|
||||
user=service.user_to_schema(user),
|
||||
roles=service.roles_to_schema(user.roles),
|
||||
preferences=service.preferences_to_schema(user.preferences),
|
||||
)
|
||||
|
||||
@router.get(
|
||||
"/users/me/preferences",
|
||||
summary="Get user preferences",
|
||||
response_model=UserPreferencesSchema,
|
||||
responses={
|
||||
200: {"description": "User preferences"},
|
||||
401: {"description": "Not authenticated"},
|
||||
},
|
||||
)
|
||||
async def get_preferences(
|
||||
user_claims: dict = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> UserPreferencesSchema:
|
||||
"""
|
||||
Get the current user's preferences
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
authentik_id_str = user_claims.get("sub")
|
||||
if not authentik_id_str or authentik_id_str == "local-user":
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
authentik_id = uuid.UUID(authentik_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||
|
||||
user = await service.get_user_by_authentik_id(authentik_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
return service.preferences_to_schema(user.preferences)
|
||||
|
||||
@router.patch(
|
||||
"/users/me/preferences",
|
||||
summary="Update user preferences",
|
||||
response_model=UserPreferencesSchema,
|
||||
responses={
|
||||
200: {"description": "Updated preferences"},
|
||||
401: {"description": "Not authenticated"},
|
||||
422: {"description": "Invalid preference value"},
|
||||
},
|
||||
)
|
||||
async def update_preferences(
|
||||
request: PreferencesUpdateRequest,
|
||||
user_claims: dict = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> UserPreferencesSchema:
|
||||
"""
|
||||
Update the current user's preferences
|
||||
|
||||
Only provided fields are updated. preferences_json is merged
|
||||
with existing values (not replaced).
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
authentik_id_str = user_claims.get("sub")
|
||||
if not authentik_id_str or authentik_id_str == "local-user":
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
authentik_id = uuid.UUID(authentik_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||
|
||||
user = await service.get_user_by_authentik_id(authentik_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
try:
|
||||
prefs = await service.update_preferences(
|
||||
user_id=user.id,
|
||||
theme=request.theme,
|
||||
default_room=request.default_room,
|
||||
preferences_json=request.preferences_json,
|
||||
)
|
||||
await session.commit()
|
||||
return service.preferences_to_schema(prefs)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=422, detail=str(e))
|
||||
|
||||
# =====================================================================
|
||||
# Phase 4: API Keys
|
||||
# =====================================================================
|
||||
|
||||
@router.get(
|
||||
"/users/me/api-keys",
|
||||
summary="List user's API keys",
|
||||
response_model=ApiKeysListResponse,
|
||||
responses={
|
||||
200: {"description": "List of API keys"},
|
||||
401: {"description": "Not authenticated"},
|
||||
},
|
||||
)
|
||||
async def list_api_keys(
|
||||
user_claims: dict = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> ApiKeysListResponse:
|
||||
"""
|
||||
List all API keys for the current user
|
||||
|
||||
Returns key metadata only - the actual key values are never
|
||||
retrievable after creation.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
authentik_id_str = user_claims.get("sub")
|
||||
if not authentik_id_str or authentik_id_str == "local-user":
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
authentik_id = uuid.UUID(authentik_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||
|
||||
user = await service.get_user_by_authentik_id(authentik_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
keys = await service.list_user_api_keys(user.id)
|
||||
return ApiKeysListResponse(
|
||||
items=[service.api_key_to_schema(k) for k in keys],
|
||||
total=len(keys),
|
||||
)
|
||||
|
||||
@router.post(
|
||||
"/users/me/api-keys",
|
||||
summary="Create a new API key",
|
||||
response_model=ApiKeyCreateResponse,
|
||||
responses={
|
||||
201: {"description": "API key created"},
|
||||
401: {"description": "Not authenticated"},
|
||||
403: {"description": "API keys disabled for user"},
|
||||
},
|
||||
)
|
||||
async def create_api_key(
|
||||
request: ApiKeyCreateRequest,
|
||||
user_claims: dict = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> ApiKeyCreateResponse:
|
||||
"""
|
||||
Create a new API key for the current user
|
||||
|
||||
**IMPORTANT**: The full API key is only returned once in this response!
|
||||
Store it securely - it cannot be retrieved again.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
authentik_id_str = user_claims.get("sub")
|
||||
if not authentik_id_str or authentik_id_str == "local-user":
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
authentik_id = uuid.UUID(authentik_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||
|
||||
user = await service.get_user_by_authentik_id(authentik_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
try:
|
||||
api_key, full_key = await service.create_api_key(
|
||||
user_id=user.id,
|
||||
name=request.name,
|
||||
scopes=request.scopes,
|
||||
expires_in_days=request.expires_in_days,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
return ApiKeyCreateResponse(
|
||||
id=api_key.id,
|
||||
name=api_key.name,
|
||||
key=full_key, # Only time this is returned!
|
||||
key_prefix=api_key.key_prefix,
|
||||
scopes=api_key.scopes,
|
||||
expires_at=api_key.expires_at,
|
||||
created_at=api_key.created_at,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
|
||||
@router.delete(
|
||||
"/users/me/api-keys/{key_id}",
|
||||
summary="Delete an API key",
|
||||
responses={
|
||||
204: {"description": "API key deleted"},
|
||||
401: {"description": "Not authenticated"},
|
||||
404: {"description": "API key not found"},
|
||||
},
|
||||
)
|
||||
async def delete_api_key(
|
||||
key_id: uuid.UUID = Path(..., description="API key ID to delete"),
|
||||
user_claims: dict = Depends(get_current_user),
|
||||
session: AsyncSession = Depends(get_async_session),
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Delete an API key
|
||||
|
||||
The key will be immediately invalidated.
|
||||
"""
|
||||
service = AuthService(session)
|
||||
|
||||
authentik_id_str = user_claims.get("sub")
|
||||
if not authentik_id_str or authentik_id_str == "local-user":
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
try:
|
||||
authentik_id = uuid.UUID(authentik_id_str)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=401, detail="Invalid user identifier")
|
||||
|
||||
user = await service.get_user_by_authentik_id(authentik_id)
|
||||
if user is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
try:
|
||||
deleted = await service.delete_api_key(user.id, key_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
await session.commit()
|
||||
return JSONResponse(status_code=204, content=None)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=403, detail=str(e))
|
||||
|
||||
return router
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user