gateway/modules/routes/routeDataUsers.py
2026-01-13 20:01:50 +01:00

494 lines
18 KiB
Python

# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
User routes for the backend API.
Implements the endpoints for user management.
"""
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
from typing import List, Dict, Any, Optional
from fastapi import status
import logging
import json
# Import interfaces and models
import modules.interfaces.interfaceDbAppObjects as interfaceDbAppObjects
from modules.auth import getCurrentUser, limiter
# Import the attribute definition and helper functions
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
# Configure logger
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/users",
tags=["Manage Users"],
responses={404: {"description": "Not found"}}
)
@router.get("/", response_model=PaginatedResponse[User])
@limiter.limit("30/minute")
async def get_users(
request: Request,
mandateId: Optional[str] = Query(None, description="Mandate ID to filter users"),
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
currentUser: User = Depends(getCurrentUser)
) -> PaginatedResponse[User]:
"""
Get users with optional pagination, sorting, and filtering.
Query Parameters:
- mandateId: Optional mandate ID to filter users
- pagination: JSON-encoded PaginationParams object, or None for no pagination
Examples:
- GET /api/users/ (no pagination - returns all users)
- GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]}
"""
try:
# Parse pagination parameter
paginationParams = None
if pagination:
try:
paginationDict = json.loads(pagination)
if paginationDict:
paginationDict = normalize_pagination_dict(paginationDict)
paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
detail=f"Invalid pagination parameter: {str(e)}"
)
appInterface = interfaceDbAppObjects.getInterface(currentUser)
# If mandateId is provided, use it, otherwise use the current user's mandate
targetMandateId = mandateId or currentUser.mandateId
# Get users with optional pagination
result = appInterface.getUsersByMandate(targetMandateId, pagination=paginationParams)
# If pagination was requested, result is PaginatedResult
# If no pagination, result is List[User]
if paginationParams:
return PaginatedResponse(
items=result.items,
pagination=PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
)
)
else:
return PaginatedResponse(
items=result,
pagination=None
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting users: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get users: {str(e)}"
)
@router.get("/{userId}", response_model=User)
@limiter.limit("30/minute")
async def get_user(
request: Request,
userId: str = Path(..., description="ID of the user"),
currentUser: User = Depends(getCurrentUser)
) -> User:
"""Get a specific user by ID"""
try:
appInterface = interfaceDbAppObjects.getInterface(currentUser)
# Get user without filtering by enabled status
user = appInterface.getUser(userId)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {userId} not found"
)
return user
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting user {userId}: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to get user: {str(e)}"
)
@router.post("", response_model=User)
@limiter.limit("10/minute")
async def create_user(
request: Request,
user_data: User = Body(...),
password: Optional[str] = Body(None, embed=True),
currentUser: User = Depends(getCurrentUser)
) -> User:
"""Create a new user"""
appInterface = interfaceDbAppObjects.getInterface(currentUser)
# Extract fields from User model and call createUser with individual parameters
from modules.datamodels.datamodelUam import AuthAuthority
newUser = appInterface.createUser(
username=user_data.username,
password=password,
email=user_data.email,
fullName=user_data.fullName,
language=user_data.language,
enabled=user_data.enabled,
roleLabels=user_data.roleLabels if user_data.roleLabels else ["user"],
authenticationAuthority=user_data.authenticationAuthority
)
return newUser
@router.put("/{userId}", response_model=User)
@limiter.limit("10/minute")
async def update_user(
request: Request,
userId: str = Path(..., description="ID of the user to update"),
userData: User = Body(...),
currentUser: User = Depends(getCurrentUser)
) -> User:
"""Update an existing user"""
appInterface = interfaceDbAppObjects.getInterface(currentUser)
# Check if the user exists
existingUser = appInterface.getUser(userId)
if not existingUser:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {userId} not found"
)
# Update user
updatedUser = appInterface.updateUser(userId, userData)
if not updatedUser:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Error updating the user"
)
return updatedUser
@router.post("/{userId}/reset-password")
@limiter.limit("5/minute")
async def reset_user_password(
request: Request,
userId: str = Path(..., description="ID of the user to reset password for"),
newPassword: str = Body(..., embed=True),
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Reset user password (Admin only)"""
try:
# Check if current user is admin
if "admin" not in (currentUser.roleLabels or []) and "sysadmin" not in (currentUser.roleLabels or []):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only administrators can reset passwords"
)
# Get user interface
appInterface = interfaceDbAppObjects.getInterface(currentUser)
# Get target user
target_user = appInterface.getUserById(userId)
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Validate password strength
if len(newPassword) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Password must be at least 8 characters long"
)
# Reset password
success = appInterface.resetUserPassword(userId, newPassword)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to reset password"
)
# SECURITY: Automatically revoke all tokens for the user after password reset
try:
from modules.datamodels.datamodelUam import AuthAuthority
revoked_count = appInterface.revokeTokensByUser(
userId=userId,
authority=None, # Revoke all authorities
mandateId=None, # Revoke across all mandates
revokedBy=currentUser.id,
reason="password_reset"
)
logger.info(f"Revoked {revoked_count} tokens for user {userId} after password reset")
except Exception as e:
logger.error(f"Failed to revoke tokens after password reset for user {userId}: {str(e)}")
# Don't fail the password reset if token revocation fails
# Log password reset
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId=str(currentUser.mandateId),
action="password_reset",
details=f"Reset password for user {userId}"
)
except Exception:
pass
return {
"message": "Password reset successfully",
"user_id": userId
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error resetting password: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Password reset failed: {str(e)}"
)
@router.post("/change-password")
@limiter.limit("5/minute")
async def change_password(
request: Request,
currentPassword: str = Body(..., embed=True),
newPassword: str = Body(..., embed=True),
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Change current user's password"""
try:
# Get user interface
appInterface = interfaceDbAppObjects.getInterface(currentUser)
# Verify current password
if not appInterface.verifyPassword(currentPassword, currentUser.passwordHash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Current password is incorrect"
)
# Validate new password strength
if len(newPassword) < 8:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="New password must be at least 8 characters long"
)
# Change password
success = appInterface.resetUserPassword(str(currentUser.id), newPassword)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to change password"
)
# SECURITY: Automatically revoke all tokens for the user after password change
try:
from modules.datamodels.datamodelUam import AuthAuthority
revoked_count = appInterface.revokeTokensByUser(
userId=str(currentUser.id),
authority=None, # Revoke all authorities
mandateId=None, # Revoke across all mandates
revokedBy=currentUser.id,
reason="password_change"
)
logger.info(f"Revoked {revoked_count} tokens for user {currentUser.id} after password change")
except Exception as e:
logger.error(f"Failed to revoke tokens after password change for user {currentUser.id}: {str(e)}")
# Don't fail the password change if token revocation fails
# Log password change
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId=str(currentUser.mandateId),
action="password_change",
details="User changed their own password"
)
except Exception:
pass
return {
"message": "Password changed successfully. Please log in again with your new password."
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error changing password: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Password change failed: {str(e)}"
)
@router.post("/{userId}/send-password-link")
@limiter.limit("10/minute")
async def sendPasswordLink(
request: Request,
userId: str = Path(..., description="ID of the user to send password setup link"),
frontendUrl: str = Body(..., embed=True),
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Send password setup/reset link to a user (admin function).
This allows admins to send a magic link to users to set or reset their password.
Used when creating users without password or to help users who forgot their password.
Args:
userId: ID of the user to send the link to
frontendUrl: The frontend URL to use in the magic link
"""
try:
from modules.shared.configuration import APP_CONFIG
from modules.interfaces.interfaceDbAppObjects import getRootInterface
# Get user interface
appInterface = interfaceDbAppObjects.getInterface(currentUser)
# Get target user
targetUser = appInterface.getUser(userId)
if not targetUser:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Check if user has an email
if not targetUser.email:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="User has no email address configured"
)
# Use root interface for token operations
rootInterface = getRootInterface()
# Generate reset token
token, expires = rootInterface.generateResetTokenAndExpiry()
# Set reset token (don't clear password - user might have one already)
rootInterface.setResetToken(userId, token, expires, clearPassword=False)
# Send email with magic link
baseUrl = frontendUrl.rstrip("/")
magicLink = f"{baseUrl}/reset?token={token}"
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
try:
from modules.services import Services
services = Services(targetUser)
emailSubject = "PowerOn - Passwort setzen"
emailBody = f"""
Hallo {targetUser.fullName or targetUser.username},
Ein Administrator hat einen Link zum Setzen Ihres Passworts angefordert.
Ihr Benutzername: {targetUser.username}
Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen:
{magicLink}
Dieser Link ist {expiryHours} Stunden gültig.
Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren Administrator.
"""
emailSent = services.messaging.sendEmailDirect(
recipient=targetUser.email,
subject=emailSubject,
message=emailBody,
userId=str(targetUser.id)
)
if not emailSent:
logger.warning(f"Failed to send password setup email to {targetUser.email}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to send email"
)
except HTTPException:
raise
except Exception as emailErr:
logger.error(f"Error sending password setup email: {str(emailErr)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to send email: {str(emailErr)}"
)
# Log the action
try:
from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId=str(currentUser.mandateId),
action="send_password_link",
details=f"Sent password setup link to user {userId} ({targetUser.email})"
)
except Exception:
pass
logger.info(f"Password setup link sent to {targetUser.email} for user {targetUser.username} by admin {currentUser.username}")
return {
"message": f"Password setup link sent to {targetUser.email}",
"userId": userId,
"email": targetUser.email
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error sending password link: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to send password link: {str(e)}"
)
@router.delete("/{userId}", response_model=Dict[str, Any])
@limiter.limit("10/minute")
async def delete_user(
request: Request,
userId: str = Path(..., description="ID of the user to delete"),
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Delete a user"""
appInterface = interfaceDbAppObjects.getInterface(currentUser)
# Check if the user exists
existingUser = appInterface.getUser(userId)
if not existingUser:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"User with ID {userId} not found"
)
success = appInterface.deleteUser(userId)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Error deleting the user"
)
return {"message": f"User with ID {userId} successfully deleted"}