1016 lines
41 KiB
Python
1016 lines
41 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, Path
|
|
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, getRequestContext, RequestContext
|
|
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, TokenPurpose
|
|
from modules.shared.configuration import APP_CONFIG
|
|
from modules.shared.timeUtils import getUtcTimestamp
|
|
from modules.shared.i18nRegistry import apiRouteContext
|
|
routeApiMsg = apiRouteContext("routeSecurityLocal")
|
|
|
|
# Configure logger
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _buildAuthEmailHtml(
|
|
greeting: str,
|
|
bodyLines: list,
|
|
buttonText: str = None,
|
|
buttonUrl: str = None,
|
|
footerText: str = None,
|
|
) -> str:
|
|
"""Build a branded HTML email for authentication flows.
|
|
|
|
Uses the same visual design as notifyMandateAdmins._renderHtmlEmail
|
|
(dark header, clean body, operator footer).
|
|
"""
|
|
import html as _html
|
|
|
|
paragraphsHtml = ""
|
|
for line in bodyLines:
|
|
if line == "":
|
|
paragraphsHtml += '<p style="margin: 0 0 14px 0;"> </p>\n'
|
|
else:
|
|
escaped = _html.escape(str(line))
|
|
paragraphsHtml += f'<p style="margin: 0 0 14px 0; color: #333333;">{escaped}</p>\n'
|
|
|
|
buttonBlock = ""
|
|
if buttonText and buttonUrl:
|
|
buttonBlock = f'''<div style="text-align: center; margin: 24px 0 8px 0;">
|
|
<a href="{_html.escape(buttonUrl)}"
|
|
style="display: inline-block; background-color: #2563eb; color: #ffffff;
|
|
font-size: 15px; font-weight: 600; text-decoration: none;
|
|
padding: 12px 32px; border-radius: 6px; mso-padding-alt: 0;">
|
|
{_html.escape(buttonText)}
|
|
</a>
|
|
</div>
|
|
<p style="margin: 8px 0 0 0; font-size: 12px; color: #9ca3af; word-break: break-all; text-align: center;">
|
|
{_html.escape(buttonUrl)}
|
|
</p>'''
|
|
|
|
footerNote = ""
|
|
if footerText:
|
|
footerNote = f'<p style="margin: 16px 0 0 0; font-size: 13px; color: #888888;">{_html.escape(footerText)}</p>\n'
|
|
|
|
operatorLine = ""
|
|
try:
|
|
from modules.shared.configuration import APP_CONFIG
|
|
parts = [p for p in [
|
|
APP_CONFIG.get("Operator_CompanyName", ""),
|
|
APP_CONFIG.get("Operator_Address", ""),
|
|
APP_CONFIG.get("Operator_VatNumber", ""),
|
|
] if p]
|
|
if parts:
|
|
operatorLine = (
|
|
f'<p style="margin: 4px 0 0 0; font-size: 11px; color: #b0b0b0; text-align: center;">'
|
|
f'{_html.escape(" | ".join(parts))}</p>\n'
|
|
)
|
|
except Exception:
|
|
pass
|
|
|
|
return f'''<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
|
|
<body style="margin: 0; padding: 0; background-color: #f4f4f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color: #f4f4f7; padding: 32px 16px;">
|
|
<tr><td align="center">
|
|
<table role="presentation" width="560" cellpadding="0" cellspacing="0" style="background-color: #ffffff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08);">
|
|
<!-- Header -->
|
|
<tr><td style="background-color: #1a1a2e; padding: 24px 32px;">
|
|
<h1 style="margin: 0; font-size: 18px; font-weight: 600; color: #ffffff;">PowerOn</h1>
|
|
</td></tr>
|
|
<!-- Body -->
|
|
<tr><td style="padding: 32px;">
|
|
<h2 style="margin: 0 0 20px 0; font-size: 20px; font-weight: 600; color: #1a1a2e;">{_html.escape(greeting)}</h2>
|
|
<div style="font-size: 15px; line-height: 1.6;">
|
|
{paragraphsHtml}
|
|
{buttonBlock}
|
|
</div>
|
|
{footerNote}
|
|
</td></tr>
|
|
<!-- Footer -->
|
|
<tr><td style="padding: 16px 32px; background-color: #f9fafb; border-top: 1px solid #e5e7eb;">
|
|
<p style="margin: 0; font-size: 12px; color: #9ca3af; text-align: center;">
|
|
Diese E-Mail wurde automatisch von PowerOn versendet.
|
|
</p>
|
|
{operatorLine}
|
|
</td></tr>
|
|
</table>
|
|
</td></tr>
|
|
</table>
|
|
</body>
|
|
</html>'''
|
|
|
|
|
|
def _sendAuthEmail(recipient: str, subject: str, message: str, userId: str = None, htmlOverride: 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 fallback (ignored when htmlOverride is given)
|
|
userId: Optional user ID for logging
|
|
htmlOverride: Pre-built branded HTML (from _buildAuthEmailHtml)
|
|
|
|
Returns:
|
|
bool: True if email was sent successfully
|
|
"""
|
|
try:
|
|
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
|
|
from modules.datamodels.datamodelMessaging import MessagingChannel
|
|
|
|
htmlMessage = htmlOverride
|
|
if not htmlMessage:
|
|
import 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"}
|
|
}
|
|
)
|
|
|
|
def _ensureHomeMandate(rootInterface, user) -> None:
|
|
"""Ensure user has a Home mandate, but only if they have no mandate memberships
|
|
AND no pending invitations.
|
|
|
|
Invited users should NOT get a Home mandate — they join existing mandates via
|
|
invitation acceptance and can create their own later via onboarding.
|
|
"""
|
|
userId = str(user.id)
|
|
userMandates = rootInterface.getUserMandates(userId)
|
|
|
|
if userMandates:
|
|
for um in userMandates:
|
|
mandate = rootInterface.getMandate(um.mandateId)
|
|
if mandate and (mandate.name or "").startswith("Home ") and not mandate.isSystem:
|
|
return
|
|
logger.debug(f"User {user.username} has {len(userMandates)} mandate(s) but no Home — skipping auto-creation")
|
|
return
|
|
|
|
try:
|
|
from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIf
|
|
appIf = _getRootIf()
|
|
normalizedEmail = (user.email or "").strip().lower() if user.email else None
|
|
pendingByUsername = appIf.getInvitationsByTargetUsername(user.username)
|
|
pendingByEmail = appIf.getInvitationsByEmail(normalizedEmail) if normalizedEmail else []
|
|
seenIds = set()
|
|
for inv in pendingByUsername + pendingByEmail:
|
|
if inv.id in seenIds:
|
|
continue
|
|
seenIds.add(inv.id)
|
|
if not inv.revokedAt and (inv.currentUses or 0) < (inv.maxUses or 1):
|
|
logger.info(f"User {user.username} has pending invitation(s) — skipping Home mandate creation")
|
|
return
|
|
except Exception as e:
|
|
logger.warning(f"Could not check pending invitations for {user.username}: {e}")
|
|
|
|
homeMandateName = f"Home {user.username}"
|
|
rootInterface._provisionMandateForUser(
|
|
userId=userId,
|
|
mandateName=homeMandateName,
|
|
planKey="TRIAL_14D",
|
|
)
|
|
logger.info(f"Created Home mandate '{homeMandateName}' for user {user.username}")
|
|
|
|
|
|
@router.post("/login")
|
|
@limiter.limit("30/minute")
|
|
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=routeApiMsg("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=routeApiMsg("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=routeApiMsg("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,
|
|
tokenPurpose=TokenPurpose.AUTH_SESSION,
|
|
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)
|
|
|
|
# Ensure user has a Home mandate (created on first login if missing)
|
|
try:
|
|
_ensureHomeMandate(rootInterface, user)
|
|
except Exception as homeErr:
|
|
logger.error(f"Error ensuring Home mandate for user {user.username}: {homeErr}")
|
|
|
|
# Activate PENDING subscriptions on first login (runs AFTER _ensureHomeMandate
|
|
# so that a freshly provisioned Home mandate subscription is also activated)
|
|
try:
|
|
activatedCount = rootInterface._activatePendingSubscriptions(str(user.id))
|
|
if activatedCount > 0:
|
|
logger.info(f"Activated {activatedCount} pending subscription(s) for user {user.username}")
|
|
except Exception as subErr:
|
|
logger.error(f"Error activating subscriptions on login: {subErr}")
|
|
|
|
# Log successful login (app log file + audit DB for traceability)
|
|
logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id))
|
|
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")
|
|
def register_user(
|
|
request: Request,
|
|
userData: User = Body(...),
|
|
frontendUrl: str = Body(..., embed=True),
|
|
registrationType: str = Body("personal", embed=True),
|
|
companyName: str = Body(None, embed=True),
|
|
) -> Dict[str, Any]:
|
|
"""Register a new local user (magic link based - no password required).
|
|
|
|
Unified registration path: invited users skip Home mandate provisioning
|
|
(they join the inviting mandate instead). Non-invited users get a Home
|
|
mandate with TRIAL_14D. Company mandate creation is deferred to onboarding.
|
|
|
|
Args:
|
|
userData: User data (username, email, fullName, language)
|
|
frontendUrl: The frontend URL to use in magic link (REQUIRED - provided by frontend)
|
|
registrationType: Kept for backward compat but ignored (company mandates via onboarding)
|
|
companyName: Kept for backward compat but ignored
|
|
"""
|
|
try:
|
|
appInterface = getRootInterface()
|
|
baseUrl = frontendUrl.rstrip("/")
|
|
normalizedEmail = userData.email.lower().strip() if userData.email else None
|
|
|
|
user = appInterface.createUser(
|
|
username=userData.username,
|
|
password=None,
|
|
email=normalizedEmail,
|
|
fullName=userData.fullName,
|
|
language=userData.language,
|
|
enabled=True,
|
|
authenticationAuthority=AuthAuthority.LOCAL
|
|
)
|
|
|
|
if not user:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=routeApiMsg("Failed to register user")
|
|
)
|
|
|
|
# Check for pending invitations BEFORE provisioning.
|
|
# Search by both username AND email (email-only invitations have targetUsername=None).
|
|
hasPendingInvitations = False
|
|
validInvitations = []
|
|
try:
|
|
from modules.datamodels.datamodelInvitation import Invitation
|
|
|
|
currentTime = getUtcTimestamp()
|
|
pendingByUsername = appInterface.getInvitationsByTargetUsername(userData.username)
|
|
pendingByEmail = appInterface.getInvitationsByEmail(normalizedEmail) if normalizedEmail else []
|
|
|
|
seenIds = set()
|
|
allPending = pendingByUsername + pendingByEmail
|
|
for invitation in allPending:
|
|
if invitation.id in seenIds:
|
|
continue
|
|
seenIds.add(invitation.id)
|
|
if (invitation.expiresAt or 0) < currentTime:
|
|
continue
|
|
if invitation.revokedAt:
|
|
continue
|
|
if (invitation.currentUses or 0) >= (invitation.maxUses or 1):
|
|
continue
|
|
validInvitations.append(invitation)
|
|
|
|
hasPendingInvitations = len(validInvitations) > 0
|
|
except Exception as invErr:
|
|
logger.warning(f"Failed to check pending invitations: {invErr}")
|
|
|
|
# Only provision Home mandate if user has NO pending invitations.
|
|
# Invited users join existing mandates; they can create their own later via onboarding.
|
|
provisionResult = None
|
|
if not hasPendingInvitations:
|
|
try:
|
|
homeMandateName = f"Home {user.username}"
|
|
provisionResult = appInterface._provisionMandateForUser(
|
|
userId=str(user.id),
|
|
mandateName=homeMandateName,
|
|
planKey="TRIAL_14D",
|
|
)
|
|
logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}")
|
|
except Exception as provErr:
|
|
logger.error(f"Error provisioning Home mandate for user {user.id}: {provErr}")
|
|
else:
|
|
logger.info(f"Skipping Home mandate for user {user.id} — has {len(validInvitations)} pending invitation(s)")
|
|
|
|
# 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"
|
|
emailHtml = _buildAuthEmailHtml(
|
|
greeting=f"Hallo {user.fullName or user.username}",
|
|
bodyLines=[
|
|
"Vielen Dank für Ihre Registrierung bei PowerOn.",
|
|
"",
|
|
f"Ihr Benutzername: {user.username}",
|
|
"",
|
|
"Klicken Sie auf die Schaltfläche, um Ihr Passwort zu setzen:",
|
|
],
|
|
buttonText="Passwort setzen",
|
|
buttonUrl=magicLink,
|
|
footerText=f"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="",
|
|
userId=str(user.id),
|
|
htmlOverride=emailHtml,
|
|
)
|
|
|
|
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)}")
|
|
|
|
# Create notifications for pending invitations
|
|
for invitation in validInvitations:
|
|
try:
|
|
from modules.routes.routeNotifications import createInvitationNotification
|
|
|
|
mandateId = invitation.mandateId
|
|
mandate = appInterface.getMandate(mandateId)
|
|
mandateName = (mandate.label or mandate.name) if mandate else "PowerOn"
|
|
|
|
inviterId = invitation.sysCreatedBy
|
|
inviter = appInterface.getUser(inviterId) if inviterId else None
|
|
inviterName = (inviter.fullName or inviter.username) if inviter else "PowerOn"
|
|
|
|
createInvitationNotification(
|
|
userId=str(user.id),
|
|
invitationId=str(invitation.id),
|
|
mandateName=mandateName,
|
|
inviterName=inviterName
|
|
)
|
|
logger.info(f"Created notification for new user {userData.username} for invitation {invitation.id}")
|
|
except Exception as notifErr:
|
|
logger.warning(f"Failed to create notification for invitation {invitation.id}: {notifErr}")
|
|
|
|
responseData = {
|
|
"message": "Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail für den Link zum Setzen Ihres Passworts."
|
|
}
|
|
if provisionResult:
|
|
responseData["mandateId"] = provisionResult.get("mandateId")
|
|
responseData["hasInvitations"] = hasPendingInvitations
|
|
return responseData
|
|
|
|
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")
|
|
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")
|
|
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=routeApiMsg("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=routeApiMsg("Invalid refresh token type"))
|
|
except jwt.ExpiredSignatureError:
|
|
raise HTTPException(status_code=401, detail=routeApiMsg("Refresh token expired"))
|
|
except jwt.JWTError:
|
|
raise HTTPException(status_code=401, detail=routeApiMsg("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=routeApiMsg("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=routeApiMsg("User not found"))
|
|
except Exception as e:
|
|
logger.error(f"Failed to get user from database: {str(e)}")
|
|
raise HTTPException(status_code=500, detail=routeApiMsg("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=routeApiMsg("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=routeApiMsg("Token refresh failed"))
|
|
|
|
@router.post("/logout")
|
|
@limiter.limit("30/minute")
|
|
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=routeApiMsg("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=routeApiMsg("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")
|
|
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")
|
|
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 but keep existing password valid until new one is set
|
|
rootInterface.setResetToken(user.id, token, expires, clearPassword=False)
|
|
|
|
# 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"
|
|
emailHtml = _buildAuthEmailHtml(
|
|
greeting=f"Hallo {user.fullName or user.username}",
|
|
bodyLines=[
|
|
"Sie haben eine Passwort-Zurücksetzung für Ihren PowerOn Account angefordert.",
|
|
"",
|
|
f"Benutzername: {user.username}",
|
|
"",
|
|
"Klicken Sie auf die Schaltfläche, um Ihr Passwort zurückzusetzen:",
|
|
],
|
|
buttonText="Passwort zurücksetzen",
|
|
buttonUrl=magicLink,
|
|
footerText=f"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="",
|
|
userId=str(user.id),
|
|
htmlOverride=emailHtml,
|
|
)
|
|
|
|
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("/onboarding")
|
|
@limiter.limit("5/minute")
|
|
def onboarding_provision(
|
|
request: Request,
|
|
currentUser: User = Depends(getCurrentUser),
|
|
companyName: str = Body(None, embed=True),
|
|
planKey: str = Body("TRIAL_14D", embed=True),
|
|
) -> Dict[str, Any]:
|
|
"""Post-login onboarding: create a mandate for the user.
|
|
|
|
Guard: user can only create a mandate if they are NOT already admin in any
|
|
non-system mandate. This prevents duplicate provisioning.
|
|
"""
|
|
try:
|
|
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
|
from modules.datamodels.datamodelRbac import Role
|
|
|
|
appInterface = getRootInterface()
|
|
db = appInterface.db
|
|
userId = str(currentUser.id)
|
|
|
|
# Check if user already has admin role in a non-system mandate
|
|
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
|
|
hasAdminMandate = False
|
|
for um in userMandates:
|
|
mandateId = um.get("mandateId")
|
|
mandate = db.getRecordset(Mandate, recordFilter={"id": mandateId})
|
|
if mandate and mandate[0].get("isSystem"):
|
|
continue
|
|
umId = um.get("id")
|
|
umRoles = db.getRecordset(UserMandateRole, recordFilter={"userMandateId": umId})
|
|
for umRole in umRoles:
|
|
roleId = umRole.get("roleId")
|
|
roles = db.getRecordset(Role, recordFilter={"id": roleId})
|
|
for role in roles:
|
|
if "admin" in (role.get("roleLabel") or "").lower():
|
|
hasAdminMandate = True
|
|
break
|
|
if hasAdminMandate:
|
|
break
|
|
if hasAdminMandate:
|
|
break
|
|
|
|
if hasAdminMandate:
|
|
logger.info(f"Onboarding: user {currentUser.username} already has admin mandate — skipping provisioning")
|
|
return {
|
|
"message": "User already has an admin mandate",
|
|
"mandateId": None,
|
|
"alreadyProvisioned": True,
|
|
}
|
|
|
|
mandateName = (companyName.strip() if companyName and companyName.strip()
|
|
else f"Home {currentUser.username}")
|
|
|
|
if planKey not in ("TRIAL_14D", "STARTER_MONTHLY", "STARTER_YEARLY", "PROFESSIONAL_MONTHLY", "PROFESSIONAL_YEARLY", "MAX_MONTHLY", "MAX_YEARLY"):
|
|
planKey = "TRIAL_14D"
|
|
|
|
result = appInterface._provisionMandateForUser(
|
|
userId=userId,
|
|
mandateName=mandateName,
|
|
planKey=planKey,
|
|
)
|
|
|
|
try:
|
|
activatedCount = appInterface._activatePendingSubscriptions(userId)
|
|
if activatedCount > 0:
|
|
logger.info(f"Activated {activatedCount} pending subscription(s) for user {currentUser.username} during onboarding")
|
|
except Exception as subErr:
|
|
logger.error(f"Error activating subscriptions during onboarding: {subErr}")
|
|
|
|
logger.info(f"Onboarding provision for {currentUser.username}: {result}")
|
|
return {
|
|
"message": "Mandate provisioned successfully",
|
|
"mandateId": result.get("mandateId") if result else None,
|
|
"alreadyProvisioned": False,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Onboarding provision failed: {e}")
|
|
raise HTTPException(
|
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e)
|
|
)
|
|
|
|
|
|
@router.post("/password-reset")
|
|
@limiter.limit("10/minute")
|
|
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=routeApiMsg("Ungültiger oder abgelaufener Reset-Link")
|
|
)
|
|
|
|
# Validate password strength
|
|
if len(password) < 8:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=routeApiMsg("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=routeApiMsg("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=routeApiMsg("Passwort-Zurücksetzung fehlgeschlagen")
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# Neutralization Mappings (user-level, view/delete)
|
|
# ============================================================
|
|
|
|
@router.get("/neutralization-mappings")
|
|
@limiter.limit("60/minute")
|
|
def _getNeutralizationMappings(
|
|
request: Request,
|
|
context: RequestContext = Depends(getRequestContext),
|
|
):
|
|
"""List the current user's neutralization placeholder mappings."""
|
|
userId = str(context.user.id)
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes
|
|
rootIf = getRootInterface()
|
|
records = rootIf.db.getRecordset(DataNeutralizerAttributes, recordFilter={"userId": userId})
|
|
return {"mappings": records}
|
|
|
|
|
|
@router.delete("/neutralization-mappings/{mappingId}")
|
|
@limiter.limit("30/minute")
|
|
def _deleteNeutralizationMapping(
|
|
request: Request,
|
|
mappingId: str = Path(..., description="ID of the mapping to delete"),
|
|
context: RequestContext = Depends(getRequestContext),
|
|
):
|
|
"""Delete a specific neutralization mapping owned by the current user."""
|
|
userId = str(context.user.id)
|
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes
|
|
rootIf = getRootInterface()
|
|
records = rootIf.db.getRecordset(DataNeutralizerAttributes, recordFilter={"id": mappingId})
|
|
if not records:
|
|
raise HTTPException(status_code=404, detail=routeApiMsg("Mapping not found"))
|
|
rec = records[0]
|
|
recUserId = rec.get("userId") if isinstance(rec, dict) else getattr(rec, "userId", None)
|
|
if recUserId != userId:
|
|
raise HTTPException(status_code=403, detail=routeApiMsg("Not your mapping"))
|
|
rootIf.db.recordDelete(DataNeutralizerAttributes, mappingId)
|
|
return {"deleted": True, "id": mappingId}
|