# 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
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', '
\n')
htmlMessage = f"""
{escaped}
"""
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()
# Get default mandate ID
defaultMandateId = rootInterface.getInitialId(Mandate)
if not defaultMandateId:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="No default mandate found"
)
# Set the mandate ID on the interface
rootInterface.mandateId = defaultMandateId
# Authenticate user
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()
# Get default mandate ID
defaultMandateId = appInterface.getInitialId(Mandate)
if not defaultMandateId:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="No default mandate found"
)
# Set the mandate ID on the interface
appInterface.mandateId = defaultMandateId
# 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 passwordResetRequest(
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 passwordReset(
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"
)