feat(auth): implement Phase 4 user profile and API key endpoints
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:
Jeroen Schweitzer
2026-01-03 20:24:46 +01:00
co-authored by Claude Opus 4.5
parent 7752cd9d23
commit 397a47c8fc
4 changed files with 907 additions and 28 deletions
+265 -12
View File
@@ -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
+64
View File
@@ -145,3 +145,67 @@ class GroupRoleAssignmentResponse(BaseSchema):
group_id: uuid.UUID = Field(..., description="Group ID")
group_name: str = Field(..., description="Group name")
roles: list[str] = Field(..., description="Currently assigned role names")
# =============================================================================
# User Profile (Phase 4)
# =============================================================================
class UserProfileResponse(BaseSchema):
"""Response from GET /users/me - full user profile"""
user: UserSchema = Field(..., description="User profile")
roles: list[RoleSchema] = Field(..., description="User's permission roles")
preferences: UserPreferencesSchema = Field(..., description="User preferences")
class PreferencesUpdateRequest(BaseSchema):
"""Request for PATCH /users/me/preferences"""
theme: Optional[str] = Field(None, description="Theme preference: system, light, dark")
default_room: Optional[str] = Field(None, description="Default room for housekeeping")
preferences_json: Optional[dict] = Field(None, description="Extended preferences (merged)")
# =============================================================================
# API Keys (Phase 4)
# =============================================================================
class ApiKeyCreateRequest(BaseSchema):
"""Request for POST /users/me/api-keys"""
name: str = Field(..., min_length=1, max_length=100, description="Human-readable key name")
scopes: Optional[list[str]] = Field(None, description="Optional scope restriction (role names)")
expires_in_days: Optional[int] = Field(None, ge=1, le=365, description="Days until expiration (optional)")
class ApiKeyCreateResponse(BaseSchema):
"""Response from POST /users/me/api-keys - includes the key (shown only once)"""
id: uuid.UUID = Field(..., description="API key ID")
name: str = Field(..., description="Key name")
key: str = Field(..., description="The API key (shown only once!)")
key_prefix: str = Field(..., description="Key prefix for identification")
scopes: Optional[list[str]] = Field(None, description="Scope restriction")
expires_at: Optional[datetime] = Field(None, description="Expiration timestamp")
created_at: datetime = Field(..., description="Creation timestamp")
class ApiKeySchema(BaseSchema):
"""API key information (without the actual key)"""
id: uuid.UUID = Field(..., description="API key ID")
name: str = Field(..., description="Key name")
key_prefix: str = Field(..., description="Key prefix for identification (e.g., 'tak_abc1')")
scopes: Optional[list[str]] = Field(None, description="Scope restriction")
expires_at: Optional[datetime] = Field(None, description="Expiration timestamp")
last_used_at: Optional[datetime] = Field(None, description="Last usage timestamp")
created_at: datetime = Field(..., description="Creation timestamp")
is_expired: bool = Field(..., description="Whether the key has expired")
class ApiKeysListResponse(BaseSchema):
"""Response from GET /users/me/api-keys"""
items: list[ApiKeySchema] = Field(..., description="List of API keys")
total: int = Field(..., description="Total count of keys")
+268 -3
View File
@@ -3,9 +3,11 @@ Authentication Service
Business logic for user synchronization from Authentik.
"""
import hashlib
import re
import secrets
import uuid
from datetime import datetime, timezone
from datetime import datetime, timezone, timedelta
from typing import Optional
import httpx
@@ -15,10 +17,11 @@ from sqlalchemy.orm import selectinload
from src.shared.config import get_settings
from src.shared.logging import get_logger
from src.domains.auth.models import User, Role, UserPreferences, Group
from src.domains.auth.models import User, Role, UserPreferences, Group, ApiKey
from src.domains.auth.schemas import (
TokenInfoSchema, UserSchema, RoleSchema, UserPreferencesSchema,
UserListItemSchema, BulkSyncResultSchema, GroupListItemSchema
UserListItemSchema, BulkSyncResultSchema, GroupListItemSchema,
ApiKeySchema, ApiKeyCreateResponse,
)
logger = get_logger(__name__)
@@ -734,6 +737,268 @@ class AuthService:
errors=errors,
)
# =========================================================================
# Phase 4: User Profile & Settings
# =========================================================================
async def get_user_by_authentik_id(self, authentik_id: uuid.UUID) -> Optional[User]:
"""
Get user by Authentik UUID with roles and preferences loaded
Args:
authentik_id: The Authentik user UUID (from JWT 'sub' claim)
Returns:
User object or None if not found
"""
stmt = (
select(User)
.options(selectinload(User.roles), selectinload(User.preferences))
.where(User.authentik_id == authentik_id)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_user_by_id(self, user_id: uuid.UUID) -> Optional[User]:
"""
Get user by internal UUID with roles and preferences loaded
Args:
user_id: The internal user UUID
Returns:
User object or None if not found
"""
stmt = (
select(User)
.options(selectinload(User.roles), selectinload(User.preferences))
.where(User.id == user_id)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def update_preferences(
self,
user_id: uuid.UUID,
theme: Optional[str] = None,
default_room: Optional[str] = None,
preferences_json: Optional[dict] = None,
) -> UserPreferences:
"""
Update user preferences
Args:
user_id: User's UUID
theme: New theme value (or None to keep existing)
default_room: New default room (or None to keep existing)
preferences_json: JSON to merge with existing (or None to keep existing)
Returns:
Updated UserPreferences object
Raises:
ValueError: If user not found
"""
stmt = select(UserPreferences).where(UserPreferences.user_id == user_id)
result = await self.session.execute(stmt)
prefs = result.scalar_one_or_none()
if prefs is None:
# Create preferences if they don't exist
prefs = UserPreferences(user_id=user_id)
self.session.add(prefs)
if theme is not None:
if theme not in ("system", "light", "dark"):
raise ValueError(f"Invalid theme: {theme}")
prefs.theme = theme
if default_room is not None:
prefs.default_room = default_room
if preferences_json is not None:
# Merge with existing preferences
existing = prefs.preferences_json or {}
existing.update(preferences_json)
prefs.preferences_json = existing
await self.session.flush()
logger.info(f"Updated preferences for user {user_id}")
return prefs
# =========================================================================
# Phase 4: API Keys
# =========================================================================
def _generate_api_key(self) -> tuple[str, str, str]:
"""
Generate a new API key
Returns:
Tuple of (full_key, key_hash, key_prefix)
"""
# Generate 32 random bytes = 256 bits of entropy
random_bytes = secrets.token_bytes(32)
# Encode as base64-like string (URL-safe)
key_body = secrets.token_urlsafe(32)
# Prefix with 'tak_' (tatlock api key)
full_key = f"tak_{key_body}"
# Hash for storage
key_hash = hashlib.sha256(full_key.encode()).hexdigest()
# Prefix for identification (first 8 chars after 'tak_')
key_prefix = f"tak_{key_body[:4]}"
return full_key, key_hash, key_prefix
async def create_api_key(
self,
user_id: uuid.UUID,
name: str,
scopes: Optional[list[str]] = None,
expires_in_days: Optional[int] = None,
) -> tuple[ApiKey, str]:
"""
Create a new API key for a user
Args:
user_id: User's UUID
name: Human-readable key name
scopes: Optional list of scope restrictions
expires_in_days: Optional expiration in days
Returns:
Tuple of (ApiKey object, full key string)
The full key is only returned once at creation!
Raises:
ValueError: If user not found or API keys disabled
"""
# Check user exists and has API keys enabled
user = await self.get_user_by_id(user_id)
if user is None:
raise ValueError(f"User not found: {user_id}")
if not user.api_keys_enabled:
raise ValueError("API keys are disabled for this user")
# Generate the key
full_key, key_hash, key_prefix = self._generate_api_key()
# Calculate expiration
expires_at = None
if expires_in_days:
expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days)
# Create the key record
api_key = ApiKey(
user_id=user_id,
name=name,
key_hash=key_hash,
key_prefix=key_prefix,
scopes=scopes,
expires_at=expires_at,
)
self.session.add(api_key)
await self.session.flush()
logger.info(f"Created API key '{name}' for user {user_id}")
return api_key, full_key
async def list_user_api_keys(self, user_id: uuid.UUID) -> list[ApiKey]:
"""
List all API keys for a user
Args:
user_id: User's UUID
Returns:
List of ApiKey objects (without the actual keys)
"""
stmt = (
select(ApiKey)
.where(ApiKey.user_id == user_id)
.order_by(ApiKey.created_at.desc())
)
result = await self.session.execute(stmt)
return list(result.scalars().all())
async def delete_api_key(self, user_id: uuid.UUID, key_id: uuid.UUID) -> bool:
"""
Delete an API key
Args:
user_id: User's UUID (for authorization)
key_id: API key UUID
Returns:
True if deleted, False if not found
Raises:
ValueError: If key belongs to different user
"""
stmt = select(ApiKey).where(ApiKey.id == key_id)
result = await self.session.execute(stmt)
api_key = result.scalar_one_or_none()
if api_key is None:
return False
if api_key.user_id != user_id:
raise ValueError("API key belongs to different user")
await self.session.delete(api_key)
await self.session.flush()
logger.info(f"Deleted API key {key_id} for user {user_id}")
return True
async def validate_api_key(self, key: str) -> Optional[User]:
"""
Validate an API key and return the associated user
Args:
key: The full API key string
Returns:
User object if valid, None if invalid/expired
"""
# Hash the provided key
key_hash = hashlib.sha256(key.encode()).hexdigest()
# Look up by hash
stmt = (
select(ApiKey)
.options(selectinload(ApiKey.user).selectinload(User.roles))
.where(ApiKey.key_hash == key_hash)
)
result = await self.session.execute(stmt)
api_key = result.scalar_one_or_none()
if api_key is None:
return None
# Check expiration
if api_key.is_expired:
logger.warning(f"Expired API key used: {api_key.key_prefix}...")
return None
# Update last used timestamp
api_key.last_used_at = datetime.now(timezone.utc)
logger.debug(f"API key authenticated: {api_key.key_prefix}... for user {api_key.user.email}")
return api_key.user
def api_key_to_schema(self, api_key: ApiKey) -> ApiKeySchema:
"""Convert ApiKey model to schema"""
return ApiKeySchema(
id=api_key.id,
name=api_key.name,
key_prefix=api_key.key_prefix,
scopes=api_key.scopes,
expires_at=api_key.expires_at,
last_used_at=api_key.last_used_at,
created_at=api_key.created_at,
is_expired=api_key.is_expired,
)
# Factory function for dependency injection
def get_auth_service(session: AsyncSession) -> AuthService: