670 lines
25 KiB
Python
670 lines
25 KiB
Python
# Copyright (c) 2025 Patrick Motsch
|
|
# All rights reserved.
|
|
"""
|
|
Routes for local security and authentication.
|
|
"""
|
|
|
|
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response, Body
|
|
from fastapi.security import OAuth2PasswordRequestForm
|
|
import logging
|
|
from typing import Dict, Any
|
|
from datetime import datetime
|
|
from fastapi.responses import JSONResponse, HTMLResponse, RedirectResponse
|
|
import uuid
|
|
from jose import jwt
|
|
|
|
# Import auth modules
|
|
from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM
|
|
from modules.auth import createAccessToken, createRefreshToken, setAccessTokenCookie, setRefreshTokenCookie, clearAccessTokenCookie, clearRefreshTokenCookie
|
|
from modules.interfaces.interfaceDbApp import getInterface, getRootInterface
|
|
from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Mandate
|
|
from modules.datamodels.datamodelSecurity import Token
|
|
from modules.shared.configuration import APP_CONFIG
|
|
|
|
# Configure logger
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = None) -> bool:
|
|
"""
|
|
Send authentication-related email directly without requiring full Services initialization.
|
|
Used for registration, password reset, and other auth flows.
|
|
|
|
Args:
|
|
recipient: Email address
|
|
subject: Email subject
|
|
message: Plain text message (will be converted to HTML)
|
|
userId: Optional user ID for logging
|
|
|
|
Returns:
|
|
bool: True if email was sent successfully
|
|
"""
|
|
try:
|
|
import html
|
|
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
|
|
from modules.datamodels.datamodelMessaging import MessagingChannel
|
|
|
|
# Convert plain text to simple HTML
|
|
escaped = html.escape(message)
|
|
escaped = escaped.replace('\n', '<br>\n')
|
|
htmlMessage = f"""<!DOCTYPE html>
|
|
<html>
|
|
<head><meta charset="utf-8"></head>
|
|
<body style="font-family: Arial, sans-serif; line-height: 1.6;">
|
|
{escaped}
|
|
</body>
|
|
</html>"""
|
|
|
|
messagingInterface = getMessagingInterface()
|
|
success = messagingInterface.send(
|
|
channel=MessagingChannel.EMAIL,
|
|
recipient=recipient,
|
|
subject=subject,
|
|
message=htmlMessage
|
|
)
|
|
|
|
if success:
|
|
logger.info(f"Auth email sent successfully to {recipient} (userId: {userId})")
|
|
else:
|
|
logger.warning(f"Failed to send auth email to {recipient} (userId: {userId})")
|
|
|
|
return success
|
|
except Exception as e:
|
|
logger.error(f"Error sending auth email to {recipient}: {str(e)}", exc_info=True)
|
|
return False
|
|
|
|
# Create router for Local Security endpoints
|
|
router = APIRouter(
|
|
prefix="/api/local",
|
|
tags=["Security Local"],
|
|
responses={
|
|
404: {"description": "Not found"},
|
|
400: {"description": "Bad request"},
|
|
401: {"description": "Unauthorized"},
|
|
403: {"description": "Forbidden"},
|
|
500: {"description": "Internal server error"}
|
|
}
|
|
)
|
|
|
|
@router.post("/login")
|
|
@limiter.limit("30/minute")
|
|
async def login(
|
|
request: Request,
|
|
response: Response,
|
|
formData: OAuth2PasswordRequestForm = Depends(),
|
|
) -> Dict[str, Any]:
|
|
"""Get access token for local user authentication"""
|
|
try:
|
|
# Validate CSRF token
|
|
csrf_token = request.headers.get("X-CSRF-Token")
|
|
if not csrf_token:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_403_FORBIDDEN,
|
|
detail="CSRF token missing"
|
|
)
|
|
|
|
# Get gateway interface with root privileges for authentication
|
|
rootInterface = getRootInterface()
|
|
|
|
# Authenticate user
|
|
# Note: authenticateLocalUser uses _getUserForAuthentication which bypasses RBAC
|
|
# This is correct because users are mandate-independent (Multi-Tenant Design)
|
|
user = rootInterface.authenticateLocalUser(
|
|
username=formData.username,
|
|
password=formData.password
|
|
)
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Invalid username or password",
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
|
|
# Create token data
|
|
# MULTI-TENANT: Token does NOT contain mandateId anymore
|
|
# Mandate context is determined per request via X-Mandate-Id header
|
|
token_data = {
|
|
"sub": user.username,
|
|
"userId": str(user.id),
|
|
"authenticationAuthority": AuthAuthority.LOCAL
|
|
# NO mandateId in token - stateless multi-tenant design
|
|
}
|
|
|
|
# Create session id and include in token claims for session-scoped logout
|
|
session_id = str(uuid.uuid4())
|
|
token_data["sid"] = session_id
|
|
|
|
# Create access token + set cookie
|
|
access_token, _access_expires = createAccessToken(token_data)
|
|
setAccessTokenCookie(response, access_token)
|
|
|
|
# Create refresh token + set cookie
|
|
refresh_token, _refresh_expires = createRefreshToken(token_data)
|
|
setRefreshTokenCookie(response, refresh_token)
|
|
|
|
# Get expiration time for response
|
|
try:
|
|
payload = jwt.decode(access_token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
expires_at = datetime.fromtimestamp(payload.get("exp"))
|
|
except Exception as e:
|
|
logger.error(f"Failed to decode access token: {str(e)}")
|
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to finalize token")
|
|
|
|
# Get user-specific interface for token operations
|
|
userInterface = getInterface(user)
|
|
|
|
# Get jti from already decoded payload
|
|
jti = payload.get("jti")
|
|
|
|
# Create token record in database
|
|
# MULTI-TENANT: Token model no longer has mandateId field
|
|
token = Token(
|
|
id=jti,
|
|
userId=user.id,
|
|
authority=AuthAuthority.LOCAL,
|
|
tokenAccess=access_token,
|
|
tokenType="bearer",
|
|
expiresAt=expires_at.timestamp(),
|
|
sessionId=session_id
|
|
# NO mandateId - Token is not mandate-bound
|
|
)
|
|
|
|
# Save access token
|
|
userInterface.saveAccessToken(token)
|
|
|
|
# Log successful login
|
|
# MULTI-TENANT: Login is a system-level function, no mandate context
|
|
try:
|
|
from modules.shared.auditLogger import audit_logger
|
|
audit_logger.logUserAccess(
|
|
userId=str(user.id),
|
|
mandateId="system",
|
|
action="login",
|
|
successInfo="local_auth_success",
|
|
ipAddress=request.client.host if request.client else None,
|
|
userAgent=request.headers.get("user-agent"),
|
|
success=True
|
|
)
|
|
except Exception:
|
|
# Don't fail if audit logging fails
|
|
pass
|
|
|
|
# Create response data (tokens are now in httpOnly cookies)
|
|
response_data = {
|
|
"type": "local_auth_success",
|
|
"message": "Login successful - tokens set in httpOnly cookies",
|
|
"authenticationAuthority": "local",
|
|
"expires_at": expires_at.isoformat()
|
|
}
|
|
|
|
return response_data
|
|
|
|
except ValueError as e:
|
|
# Handle authentication errors
|
|
error_msg = str(e)
|
|
logger.warning(f"Authentication failed for user {formData.username}: {error_msg}")
|
|
|
|
# Check if user is disabled and provide specific message
|
|
if error_msg == "User is disabled":
|
|
error_msg = "Your account is disabled. Please send an email to p.motsch@valueon.ch to get access to the PowerOn center."
|
|
|
|
# Log failed login attempt
|
|
try:
|
|
from modules.shared.auditLogger import audit_logger
|
|
audit_logger.logUserAccess(
|
|
userId=formData.username or "unknown",
|
|
mandateId="system",
|
|
action="login_failed",
|
|
successInfo=f"failed: {error_msg}",
|
|
ipAddress=request.client.host if request.client else None,
|
|
userAgent=request.headers.get("user-agent"),
|
|
success=False
|
|
)
|
|
except Exception:
|
|
# Don't fail if audit logging fails
|
|
pass
|
|
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail=error_msg,
|
|
headers={"WWW-Authenticate": "Bearer"},
|
|
)
|
|
except Exception as e:
|
|
# Handle other errors
|
|
error_msg = f"Login failed: {str(e)}"
|
|
logger.error(f"Unexpected error during login for user {formData.username}: {error_msg}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=error_msg
|
|
)
|
|
|
|
@router.post("/register")
|
|
@limiter.limit("10/minute")
|
|
async def register_user(
|
|
request: Request,
|
|
userData: User = Body(...),
|
|
frontendUrl: str = Body(..., embed=True)
|
|
) -> Dict[str, Any]:
|
|
"""Register a new local user (magic link based - no password required).
|
|
|
|
Args:
|
|
userData: User data (username, email, fullName, language)
|
|
frontendUrl: The frontend URL to use in magic link (REQUIRED - provided by frontend)
|
|
"""
|
|
try:
|
|
# Get gateway interface with root privileges since this is a public endpoint
|
|
appInterface = getRootInterface()
|
|
|
|
# Note: User registration does NOT require mandateId context
|
|
# Users are mandate-independent (Multi-Tenant Design)
|
|
# Mandate assignment happens via createUserMandate() after registration
|
|
|
|
# Frontend URL is required - no fallback
|
|
baseUrl = frontendUrl.rstrip("/")
|
|
|
|
# Normalize email
|
|
normalizedEmail = userData.email.lower().strip() if userData.email else None
|
|
|
|
# Note: Email can be shared across multiple users (different mandates)
|
|
# Username uniqueness is enforced in createUser() - that's the primary constraint
|
|
|
|
# Create user with local authentication (no password - magic link based)
|
|
user = appInterface.createUser(
|
|
username=userData.username,
|
|
password=None, # No password - will be set via magic link
|
|
email=normalizedEmail,
|
|
fullName=userData.fullName,
|
|
language=userData.language,
|
|
enabled=True, # Users are enabled by default (can login after setting password)
|
|
authenticationAuthority=AuthAuthority.LOCAL
|
|
)
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Failed to register user"
|
|
)
|
|
|
|
# Generate reset token for password setup
|
|
token, expires = appInterface.generateResetTokenAndExpiry()
|
|
appInterface.setResetToken(user.id, token, expires, clearPassword=False)
|
|
|
|
# Send registration email with magic link
|
|
try:
|
|
magicLink = f"{baseUrl}/reset?token={token}"
|
|
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
|
|
|
|
emailSubject = "PowerOn Registrierung - Passwort setzen"
|
|
emailBody = f"""Hallo {user.fullName or user.username},
|
|
|
|
Vielen Dank für Ihre Registrierung bei PowerOn.
|
|
|
|
Ihr Benutzername: {user.username}
|
|
|
|
Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen:
|
|
{magicLink}
|
|
|
|
Dieser Link ist {expiryHours} Stunden gültig.
|
|
|
|
Falls Sie sich nicht registriert haben, können Sie diese E-Mail ignorieren."""
|
|
|
|
emailSent = _sendAuthEmail(
|
|
recipient=user.email,
|
|
subject=emailSubject,
|
|
message=emailBody,
|
|
userId=str(user.id)
|
|
)
|
|
|
|
if not emailSent:
|
|
logger.warning(f"Failed to send registration email to {user.email}")
|
|
except Exception as emailErr:
|
|
logger.error(f"Error sending registration email: {str(emailErr)}")
|
|
# Don't fail registration if email fails - user can request reset later
|
|
|
|
return {
|
|
"message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts."
|
|
}
|
|
|
|
except ValueError as e:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=str(e)
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"Error registering user: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to register user: {str(e)}"
|
|
)
|
|
|
|
@router.get("/me", response_model=User)
|
|
@limiter.limit("30/minute")
|
|
async def read_user_me(
|
|
request: Request,
|
|
currentUser: User = Depends(getCurrentUser)
|
|
) -> User:
|
|
"""Get current user info"""
|
|
try:
|
|
return currentUser
|
|
except Exception as e:
|
|
logger.error(f"Error getting user me: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to get current user: {str(e)}"
|
|
)
|
|
|
|
@router.post("/refresh")
|
|
@limiter.limit("60/minute")
|
|
async def refresh_token(
|
|
request: Request,
|
|
response: Response
|
|
) -> Dict[str, Any]:
|
|
"""Refresh access token using refresh token from cookie"""
|
|
try:
|
|
# Get refresh token from cookie
|
|
refresh_token = request.cookies.get('refresh_token')
|
|
if not refresh_token:
|
|
raise HTTPException(status_code=401, detail="No refresh token found")
|
|
|
|
# Validate refresh token
|
|
try:
|
|
payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
if payload.get("type") != "refresh":
|
|
raise HTTPException(status_code=401, detail="Invalid refresh token type")
|
|
except jwt.ExpiredSignatureError:
|
|
raise HTTPException(status_code=401, detail="Refresh token expired")
|
|
except jwt.JWTError:
|
|
raise HTTPException(status_code=401, detail="Invalid refresh token")
|
|
|
|
# Get user information from refresh token payload
|
|
user_id = payload.get("userId")
|
|
if not user_id:
|
|
raise HTTPException(status_code=401, detail="Invalid refresh token - missing user ID")
|
|
|
|
# Get user from database using the user ID from refresh token
|
|
try:
|
|
app_interface = getRootInterface()
|
|
current_user = app_interface.getUser(user_id)
|
|
if not current_user:
|
|
raise HTTPException(status_code=401, detail="User not found")
|
|
except Exception as e:
|
|
logger.error(f"Failed to get user from database: {str(e)}")
|
|
raise HTTPException(status_code=500, detail="Failed to validate user")
|
|
|
|
# Create new token data
|
|
# MULTI-TENANT: Token does NOT contain mandateId anymore
|
|
token_data = {
|
|
"sub": current_user.username,
|
|
"userId": str(current_user.id),
|
|
"authenticationAuthority": current_user.authenticationAuthority
|
|
# NO mandateId in token
|
|
}
|
|
|
|
# Create new access token + set cookie
|
|
access_token, _expires = createAccessToken(token_data)
|
|
setAccessTokenCookie(response, access_token)
|
|
|
|
# Get expiration time
|
|
try:
|
|
payload = jwt.decode(access_token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
expires_at = datetime.fromtimestamp(payload.get("exp"))
|
|
except Exception as e:
|
|
logger.error(f"Failed to decode new access token: {str(e)}")
|
|
raise HTTPException(status_code=500, detail="Failed to create new token")
|
|
|
|
return {
|
|
"type": "token_refresh_success",
|
|
"message": "Token refreshed successfully",
|
|
"expires_at": expires_at.isoformat()
|
|
}
|
|
|
|
except HTTPException as e:
|
|
# If it's a 503 error (service unavailable due to missing token table), return it as-is
|
|
if e.status_code == 503:
|
|
raise
|
|
# For other HTTP exceptions, re-raise them
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Token refresh error: {str(e)}")
|
|
raise HTTPException(status_code=500, detail="Token refresh failed")
|
|
|
|
@router.post("/logout")
|
|
@limiter.limit("30/minute")
|
|
async def logout(request: Request, response: Response, currentUser: User = Depends(getCurrentUser)) -> JSONResponse:
|
|
"""Logout from local authentication"""
|
|
try:
|
|
# Get user interface with current user context
|
|
appInterface = getInterface(currentUser)
|
|
|
|
# Get token from cookie or Authorization header
|
|
token = request.cookies.get('auth_token')
|
|
if not token:
|
|
auth_header = request.headers.get("Authorization")
|
|
if auth_header and auth_header.lower().startswith("bearer "):
|
|
token = auth_header.split(" ", 1)[1].strip()
|
|
|
|
if not token:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No token found")
|
|
|
|
try:
|
|
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
|
session_id = payload.get("sid") or payload.get("sessionId")
|
|
jti = payload.get("jti")
|
|
except Exception as e:
|
|
logger.error(f"Failed to decode JWT on logout: {str(e)}")
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid token")
|
|
|
|
revoked = 0
|
|
if session_id:
|
|
revoked = appInterface.revokeTokensBySessionId(session_id, currentUser.id, AuthAuthority.LOCAL, revokedBy=currentUser.id, reason="logout")
|
|
elif jti:
|
|
appInterface.revokeTokenById(jti, revokedBy=currentUser.id, reason="logout")
|
|
revoked = 1
|
|
|
|
# Log successful logout
|
|
# MULTI-TENANT: Logout is a system-level function, no mandate context
|
|
try:
|
|
from modules.shared.auditLogger import audit_logger
|
|
audit_logger.logUserAccess(
|
|
userId=str(currentUser.id),
|
|
mandateId="system",
|
|
action="logout",
|
|
successInfo=f"revoked_tokens: {revoked}",
|
|
ipAddress=request.client.host if request.client else None,
|
|
userAgent=request.headers.get("user-agent"),
|
|
success=True
|
|
)
|
|
except Exception:
|
|
# Don't fail if audit logging fails
|
|
pass
|
|
|
|
# Create the JSON response first
|
|
json_response = JSONResponse({
|
|
"message": "Successfully logged out - cookies cleared",
|
|
"revokedTokens": revoked
|
|
})
|
|
|
|
# Clear httpOnly cookies on the response we're actually returning
|
|
clearAccessTokenCookie(json_response)
|
|
clearRefreshTokenCookie(json_response)
|
|
|
|
return json_response
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error during logout: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Logout failed: {str(e)}"
|
|
)
|
|
|
|
@router.get("/available")
|
|
@limiter.limit("10/minute")
|
|
async def check_username_availability(
|
|
request: Request,
|
|
username: str,
|
|
authenticationAuthority: str = "local"
|
|
) -> Dict[str, Any]:
|
|
"""Check if a username is available for registration."""
|
|
try:
|
|
# Get root interface
|
|
appInterface = getRootInterface()
|
|
|
|
# Use the interface's method to check availability
|
|
result = appInterface.checkUsernameAvailability({
|
|
"username": username,
|
|
"authenticationAuthority": authenticationAuthority
|
|
})
|
|
|
|
return {
|
|
"username": username,
|
|
"authenticationAuthority": authenticationAuthority,
|
|
"available": result["available"],
|
|
"message": result["message"]
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error checking username availability: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail=f"Failed to check username availability: {str(e)}"
|
|
)
|
|
|
|
@router.post("/password-reset-request")
|
|
@limiter.limit("5/minute")
|
|
async def password_reset_request(
|
|
request: Request,
|
|
username: str = Body(..., embed=True),
|
|
frontendUrl: str = Body(..., embed=True)
|
|
) -> Dict[str, Any]:
|
|
"""Request password reset email.
|
|
|
|
Finds user by username (globally unique) and sends reset email to their email address.
|
|
|
|
Args:
|
|
username: User's username (globally unique identifier)
|
|
frontendUrl: The frontend URL to use in magic link (REQUIRED - provided by frontend)
|
|
"""
|
|
try:
|
|
rootInterface = getRootInterface()
|
|
|
|
# Frontend URL is required - no fallback
|
|
baseUrl = frontendUrl.rstrip("/")
|
|
|
|
# Find user by username (username is globally unique)
|
|
user = rootInterface.findUserByUsernameLocalAuth(username)
|
|
|
|
if user and user.email:
|
|
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
|
|
|
|
try:
|
|
# Generate reset token
|
|
token, expires = rootInterface.generateResetTokenAndExpiry()
|
|
|
|
# Set reset token (clears password)
|
|
rootInterface.setResetToken(user.id, token, expires)
|
|
|
|
# Generate magic link using provided frontend URL
|
|
magicLink = f"{baseUrl}/reset?token={token}"
|
|
|
|
# Send email using dedicated auth email function
|
|
emailSubject = "PowerOn - Passwort zurücksetzen"
|
|
emailBody = f"""Hallo {user.fullName or user.username},
|
|
|
|
Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert.
|
|
|
|
Benutzername: {user.username}
|
|
|
|
Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:
|
|
{magicLink}
|
|
|
|
Dieser Link ist {expiryHours} Stunden gültig.
|
|
|
|
Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren."""
|
|
|
|
emailSent = _sendAuthEmail(
|
|
recipient=user.email,
|
|
subject=emailSubject,
|
|
message=emailBody,
|
|
userId=str(user.id)
|
|
)
|
|
|
|
if emailSent:
|
|
logger.info(f"Password reset email sent to {user.email} for user {user.username}")
|
|
else:
|
|
logger.warning(f"Failed to send password reset email to {user.email}")
|
|
except Exception as userErr:
|
|
logger.error(f"Failed to send reset email for user {username}: {str(userErr)}")
|
|
else:
|
|
logger.info(f"Password reset requested for unknown username: {username}")
|
|
|
|
# Always return same message (security - don't reveal if user exists)
|
|
return {
|
|
"message": "Falls ein Konto mit diesem Benutzernamen existiert, wurde ein Reset-Link an die hinterlegte E-Mail-Adresse gesendet."
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error in password reset request: {str(e)}")
|
|
# Still return success for security
|
|
return {
|
|
"message": "Falls ein Konto mit diesem Benutzernamen existiert, wurde ein Reset-Link an die hinterlegte E-Mail-Adresse gesendet."
|
|
}
|
|
|
|
@router.post("/password-reset")
|
|
@limiter.limit("10/minute")
|
|
async def password_reset(
|
|
request: Request,
|
|
token: str = Body(..., embed=True),
|
|
password: str = Body(..., embed=True)
|
|
) -> Dict[str, Any]:
|
|
"""Reset password using token from magic link."""
|
|
try:
|
|
# Validate token format (UUID)
|
|
try:
|
|
uuid.UUID(token)
|
|
except ValueError:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Ungültiger oder abgelaufener Reset-Link"
|
|
)
|
|
|
|
# Validate password strength
|
|
if len(password) < 8:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Passwort muss mindestens 8 Zeichen lang sein"
|
|
)
|
|
|
|
rootInterface = getRootInterface()
|
|
|
|
# Verify and reset
|
|
success = rootInterface.resetPasswordWithToken(token, password)
|
|
|
|
if not success:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Ungültiger oder abgelaufener Reset-Link"
|
|
)
|
|
|
|
# Log success
|
|
try:
|
|
from modules.shared.auditLogger import audit_logger
|
|
audit_logger.logSecurityEvent(
|
|
userId="unknown",
|
|
mandateId="unknown",
|
|
action="password_reset_via_token",
|
|
details="Password reset completed via magic link"
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return {"message": "Passwort erfolgreich gesetzt"}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Error in password reset: {str(e)}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
detail="Passwort-Zurücksetzung fehlgeschlagen"
|
|
)
|