365 lines
13 KiB
Python
365 lines
13 KiB
Python
"""
|
|
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.security.auth import getCurrentUser, limiter, getCurrentUser
|
|
|
|
# Import the attribute definition and helper functions
|
|
from modules.datamodels.datamodelUam import User
|
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata
|
|
|
|
# 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)
|
|
paginationParams = PaginationParams(**paginationDict) if paginationDict else None
|
|
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.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"}
|
|
|