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
+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: