Added user profile modification

This commit is contained in:
2025-11-11 09:30:04 +01:00
parent 43603fe66f
commit 5f652b436e
9 changed files with 986 additions and 46 deletions

View File

@@ -4,7 +4,6 @@ FastAPI routes for authentication module.
This module provides ready-to-use FastAPI routes for all authentication
operations. Routes are organized in an APIRouter with /auth prefix.
"""
import os
from typing import Annotated
from fastapi import Depends, HTTPException, status, FastAPI
@@ -18,7 +17,7 @@ from ..models.email_verification import (
PasswordResetConfirm
)
from ..models.token import AccessTokenResponse, RefreshTokenRequest
from ..models.user import UserCreate, UserResponse
from ..models.user import UserCreate, UserResponse, UserUpdate, UserUpdateMe
# OAuth2 scheme for token authentication
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
@@ -86,6 +85,35 @@ def create_auth_app(auth_service: AuthService) -> FastAPI:
detail=e.message
)
def get_current_admin(current_user: Annotated[UserResponse, Depends(get_current_user)]) -> UserResponse:
"""
Dependency to ensure the current user has admin role.
This dependency can be used in any route that requires admin privileges.
It first validates the user's token (via get_current_user) then checks
for the 'admin' role.
Args:
current_user: The authenticated user (injected by get_current_user dependency).
Returns:
The current authenticated admin user.
Raises:
HTTPException: 403 if user does not have admin role.
Example:
>>> @auth_app.patch("/users/{user_id}")
>>> def update_user(user_id: str, admin: UserResponse = Depends(get_current_admin)):
>>> # Only admins can access this route
"""
if "admin" not in current_user.roles:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required"
)
return current_user
@auth_app.post(
"/register",
response_model=UserResponse,
@@ -375,6 +403,123 @@ def create_auth_app(auth_service: AuthService) -> FastAPI:
HTTPException: 401 if token is invalid or expired.
"""
return current_user
@auth_app.patch(
"/me",
response_model=UserResponse,
summary="Update own profile",
description="Update the current user's profile (restricted fields)."
)
def update_me(
updates: UserUpdateMe,
current_user: Annotated[UserResponse, Depends(get_current_user)]
) -> UserResponse:
"""
Update the current authenticated user's profile.
This endpoint allows users to update their own profile with restrictions:
- Allowed fields: email, username, password, user_settings
- Forbidden fields: roles, is_active, is_verified (must use other flows)
When changing password, the current session is preserved if refresh_token
is provided in the request body.
Args:
updates: User update data (restricted fields only).
current_user: The authenticated user (injected by dependency).
Returns:
Updated user information.
Raises:
HTTPException: 409 if new email already exists.
HTTPException: 422 if validation fails.
"""
try:
# Convert UserUpdateMe to UserUpdate (only allowed fields)
from ..models.user import UserUpdate
user_update = UserUpdate(
email=updates.email,
username=updates.username,
password=updates.password,
user_settings=updates.user_settings
)
# Update user with optional refresh_token to preserve session
updated_user = auth_service.update_user(
user_id=current_user.id,
updates=user_update,
refresh_token=updates.refresh_token
)
return UserResponse(
id=updated_user.id,
email=updated_user.email,
username=updated_user.username,
roles=updated_user.roles,
user_settings=updated_user.user_settings,
created_at=updated_user.created_at,
updated_at=updated_user.updated_at
)
except AuthError as e:
raise HTTPException(
status_code=e.status_code,
detail=e.message
)
@auth_app.patch(
"/users/{user_id}",
response_model=UserResponse,
summary="Update any user (admin only)",
description="Update any user's profile with full access to all fields (admin only)."
)
def update_user_by_id(
user_id: str,
updates: UserUpdate,
admin: Annotated[UserResponse, Depends(get_current_admin)]
) -> UserResponse:
"""
Update any user's profile (admin only).
This endpoint allows administrators to update any user with full access
to all fields including roles, is_active, and is_verified.
Args:
user_id: The ID of the user to update.
updates: User update data (all fields allowed).
admin: The authenticated admin user (injected by dependency).
Returns:
Updated user information.
Raises:
HTTPException: 403 if user is not an admin.
HTTPException: 404 if user not found.
HTTPException: 409 if new email already exists.
HTTPException: 422 if validation fails.
"""
try:
# Admin updates don't preserve refresh tokens (security)
updated_user = auth_service.update_user(
user_id=user_id,
updates=updates,
refresh_token=None
)
return UserResponse(
id=updated_user.id,
email=updated_user.email,
username=updated_user.username,
roles=updated_user.roles,
user_settings=updated_user.user_settings,
created_at=updated_user.created_at,
updated_at=updated_user.updated_at
)
except AuthError as e:
raise HTTPException(
status_code=e.status_code,
detail=e.message
)
return auth_app

View File

@@ -18,7 +18,7 @@ from ..exceptions import (
AccountDisabledError,
ExpiredTokenError,
InvalidTokenError,
RevokedTokenError
RevokedTokenError, UserAlreadyExistsError
)
from ..models.token import AccessTokenResponse, TokenData
from ..models.user import UserCreate, UserInDB, UserUpdate, UserCreateNoValidation
@@ -512,3 +512,61 @@ class AuthService:
:rtype: list
"""
return self.user_repository.list_users(skip, limit)
def update_user(self, user_id: str, updates: UserUpdate, refresh_token: Optional[str] = None) -> UserInDB:
"""
Update an existing user's information.
This method handles user profile updates with automatic security measures:
- Validates email uniqueness if email is changed
- Automatically sets is_verified=False if email is changed
- Hashes password if provided
- Revokes refresh tokens (except current one) if password is changed
Args:
user_id: The unique user identifier.
updates: Pydantic model containing fields to update.
refresh_token: Optional current refresh token to preserve when password changes.
Returns:
The updated user.
Raises:
UserNotFoundError: If user does not exist.
UserAlreadyExistsError: If new email is already used by another user.
Example:
>>> updates = UserUpdate(email="newemail@example.com")
>>> user = auth_service.update_user(user_id, updates)
>>>
>>> # Update password while preserving current session
>>> updates = UserUpdate(password="NewSecurePass123!")
>>> user = auth_service.update_user(user_id, updates, refresh_token=current_token)
"""
# Get current user to compare changes
current_user = self.user_repository.get_user_by_id(user_id)
if not current_user:
raise UserNotFoundError(f"User with id {user_id} not found")
# Handle email change
if updates.email is not None and updates.email != current_user.email:
# Check if new email is already used by another user
if self.user_repository.email_exists(updates.email):
raise UserAlreadyExistsError(f"Email {updates.email} is already in use")
# Force email verification to false when email changes
updates.is_verified = False
# Handle password change
if updates.password is not None:
# Hash the new password
hashed_password = self.password_manager.hash_password(updates.password)
updates.password = hashed_password
# Revoke all refresh tokens except the current one (if provided)
self.token_repository.revoke_all_user_tokens(user_id, "refresh", except_token=refresh_token)
# Update user in database
updated_user = self.user_repository.update_user(user_id, updates)
return updated_user

View File

@@ -153,6 +153,67 @@ class UserUpdate(BaseModel):
Raises:
ValueError: If username is provided but empty or too long.
"""
if value is None:
return None
return validate_username_not_empty(value)
class UserUpdateMe(BaseModel):
"""
Model for user self-update (restricted fields).
This model allows users to update their own profile with restrictions:
- Allowed fields: email, username, password, user_settings
- Forbidden fields: roles, is_active, is_verified
- Optional refresh_token to preserve current session when changing password
Attributes:
email: Optional new email address.
username: Optional new username.
password: Optional new password (will be hashed and validated).
user_settings: Optional new settings dict.
refresh_token: Optional refresh token to preserve when changing password.
"""
email: Optional[EmailStr] = None
username: Optional[str] = None
password: Optional[str] = None
user_settings: Optional[dict] = None
refresh_token: Optional[str] = None
@field_validator('password')
@classmethod
def validate_password_strength(cls, value: Optional[str]) -> Optional[str]:
"""
Validate password meets security requirements if provided.
Args:
value: The password to validate (can be None).
Returns:
The validated password or None.
Raises:
ValueError: If password is provided but does not meet security requirements.
"""
return validate_password_strength(value)
@field_validator('username')
@classmethod
def validate_username(cls, value: Optional[str]) -> Optional[str]:
"""
Validate username if provided.
Args:
value: The username to validate (can be None).
Returns:
The validated username or None.
Raises:
ValueError: If username is provided but empty or too long.
"""
if value is None:
return None
return validate_username_not_empty(value)

View File

@@ -8,6 +8,7 @@ PostgreSQL, custom engines, etc.).
"""
from abc import ABC, abstractmethod
from typing import Optional
from ..models.token import TokenData
from ..models.user import UserCreate, UserInDB, UserUpdate
@@ -203,16 +204,19 @@ class TokenRepository(ABC):
pass
@abstractmethod
def revoke_all_user_tokens(self, user_id: str, token_type: str) -> int:
def revoke_all_user_tokens(self, user_id: str, token_type: str, except_token: Optional[str] = None) -> int:
"""
Revoke all tokens of a specific type for a user.
This is useful for logout-all-devices functionality or when a user's
password is changed (invalidating all refresh tokens).
password is changed (invalidating all refresh tokens). Optionally,
a specific token can be excluded from revocation to preserve the
current session.
Args:
user_id: The user whose tokens should be revoked.
token_type: Type of tokens to revoke ("refresh" or "password_reset").
except_token: Optional token string to exclude from revocation.
Returns:
Number of tokens revoked.

View File

@@ -13,9 +13,9 @@ from typing import Optional
from uuid import uuid4
from .base import UserRepository, TokenRepository
from ..models.user import UserCreate, UserInDB, UserUpdate
from ..models.token import TokenData
from ..exceptions import UserAlreadyExistsError, UserNotFoundError
from ..models.token import TokenData
from ..models.user import UserCreate, UserInDB, UserUpdate
class SQLiteUserRepository(UserRepository):
@@ -364,38 +364,38 @@ class SQLiteUserRepository(UserRepository):
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM users WHERE email = ? LIMIT 1", (email,))
return cursor.fetchone() is not None
def list_users(self, skip: int = 0, limit: int = 100) -> list[UserInDB]:
"""
List users with pagination.
"""
List users with pagination.
Args:
skip: Number of users to skip (default: 0).
limit: Maximum number of users to return (default: 100).
Args:
skip: Number of users to skip (default: 0).
limit: Maximum number of users to return (default: 100).
Returns:
List of users.
"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT id,
email,
username,
hashed_password,
roles,
user_settings,
is_verified,
is_active,
created_at,
updated_at
FROM users
ORDER BY created_at DESC LIMIT ?
OFFSET ?
""", (limit, skip))
rows = cursor.fetchall()
return [self._row_to_user(row) for row in rows]
Returns:
List of users.
"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT id,
email,
username,
hashed_password,
roles,
user_settings,
is_verified,
is_active,
created_at,
updated_at
FROM users
ORDER BY created_at DESC LIMIT ?
OFFSET ?
""", (limit, skip))
rows = cursor.fetchall()
return [self._row_to_user(row) for row in rows]
def count_users(self) -> int:
"""
@@ -410,6 +410,7 @@ class SQLiteUserRepository(UserRepository):
result = cursor.fetchone()
return result[0] if result else 0
class SQLiteTokenRepository(TokenRepository):
"""
SQLite implementation of TokenRepository.
@@ -589,25 +590,44 @@ class SQLiteTokenRepository(TokenRepository):
conn.commit()
return cursor.rowcount > 0
def revoke_all_user_tokens(self, user_id: str, token_type: str) -> int:
def revoke_all_user_tokens(self, user_id: str, token_type: str, except_token: Optional[str] = None) -> int:
"""
Revoke all tokens of a specific type for a user.
This is useful for logout-all-devices functionality or when a user's
password is changed (invalidating all refresh tokens). Optionally,
a specific token can be excluded from revocation to preserve the
current session.
Args:
user_id: The user whose tokens should be revoked.
token_type: Type of tokens to revoke.
token_type: Type of tokens to revoke ("refresh" or "password_reset").
except_token: Optional token string to exclude from revocation.
Returns:
Number of tokens revoked.
"""
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
UPDATE tokens
SET is_revoked = 1
WHERE user_id = ?
AND token_type = ?
""", (user_id, token_type))
if except_token is not None:
# Revoke all tokens except the specified one
cursor.execute("""
UPDATE tokens
SET is_revoked = 1
WHERE user_id = ?
AND token_type = ?
AND token != ?
""", (user_id, token_type, except_token))
else:
# Revoke all tokens
cursor.execute("""
UPDATE tokens
SET is_revoked = 1
WHERE user_id = ?
AND token_type = ?
""", (user_id, token_type))
conn.commit()
return cursor.rowcount
@@ -652,4 +672,4 @@ class SQLiteTokenRepository(TokenRepository):
if token_data.expires_at < datetime.now():
return False
return True
return True