wiki/implementation/implementation_doc_userauth_process_concept.md
2026-01-13 20:01:11 +01:00

52 KiB

User Authentication Process - Concept Document

Overview

This document defines the architecture and data objects for the refactored user authentication process in PowerOn, including self-registration with magic links, password reset functionality, and the associated data model changes.

Last Updated: 2025-01-12 - Deep code review completed, ready for implementation.

Executive Summary

This document has been thoroughly reviewed against the actual codebase in gateway and frontend_agents. The analysis confirms that most features are not yet implemented but the infrastructure exists.

🚨 CRITICAL BUG DISCOVERED

resetUserPassword() method is called but NOT implemented!

  • Called in routeDataUsers.py (lines 218, 294)
  • Does NOT exist in interfaceDbAppObjects.py
  • This will cause a runtime error if admin tries to reset a password
  • MUST be implemented as part of this work

Critical Gaps (Ordered by Implementation Priority):

Priority Gap Effort Location
🔴 P0 resetUserPassword() method missing Small interfaceDbAppObjects.py
🔴 P1 sendEmailDirect() method in messaging Small mainServiceMessaging.py
🔴 P1 resetToken + resetTokenExpires fields Small datamodelUam.py
🔴 P1 Password reset endpoints Medium routeSecurityLocal.py
🟡 P2 Remove password from registration Medium routeSecurityLocal.py
🟡 P2 Frontend password reset pages Medium frontend_agents/public/
🟡 P2 Frontend API calls for reset Small apiCalls.js
🟢 P3 Config entries (Auth_RESET_TOKEN_EXPIRY_HOURS, Frontend_BASE_URL) Small config.ini

What Already Exists ( Verified):

  • Email sending infrastructure - serviceMessaging at self.services.messaging (line 96 in services/__init__.py)
  • Email connector - ConnectorMessagingEmail using Azure Communication Services
  • interfaceMessaging.send() - Unified interface for email/SMS (line 31 in interfaceMessaging.py)
  • Password hashing - _getPasswordHash() / _verifyPassword() (lines 442-448 in interfaceDbAppObjects.py)
  • User data models - User, UserInDB (lines 131, 166 in datamodelUam.py)
  • Admin password reset route - /api/users/{userId}/reset-password (line 200 in routeDataUsers.py)
  • Frontend pages - login.html, register.html (need modifications)
  • Timestamp utilities - getUtcTimestamp(), createExpirationTimestamp() (in timeUtils.py)

Implementation Estimate:

  • Backend: ~4-6 hours
  • Frontend: ~3-4 hours
  • Testing: ~2-3 hours
  • Total: ~10-13 hours

See doc_userauth_ui_adaptations.md for detailed UI implementation requirements.

Current State Analysis

Existing Authentication Flow (Verified)

Component File Status Notes
Login Page frontend_agents/public/login.html Works Missing password reset link
Microsoft Auth routeSecurityMsft.py Works -
Google Auth routeSecurityGoogle.py Works -
Local Auth routeSecurityLocal.py Works -
Registration register.html ⚠️ Needs change Requires password

Current User Data Model (datamodelUam.py)

# Line 166-172
class UserInDB(User):
    hashedPassword: Optional[str] = Field(None, description="Hash of the user password")
# ❌ MISSING: resetToken, resetTokenExpires fields

Reference: UserConnection model (line 95-111) uses float timestamps for connectedAt, lastChecked, expiresAt - same pattern to use for resetTokenExpires.

Current Authentication Logic (interfaceDbAppObjects.py)

# Lines 570-596 - authenticateLocalUser()
def authenticateLocalUser(self, username: str, password: str) -> Optional[User]:
    # ... gets user ...
    
    # Line 590: Check for password hash
    if not userRecord.get("hashedPassword"):
        raise ValueError("User has no password set")
    # ❌ MISSING: Check for resetToken (should block login if set)
    
    if not self._verifyPassword(password, userRecord["hashedPassword"]):
        raise ValueError("Invalid password")

Current Registration Logic (routeSecurityLocal.py)

# Line 192-248
@router.post("/register", response_model=User)
async def register_user(
    request: Request,
    userData: User = Body(...),
    password: str = Body(..., embed=True)  # ❌ Line 197: Password required - needs removal
) -> User:
    # ...
    user = appInterface.createUser(
        # ...
        enabled=False,  # ❌ Line 225: Should be True
        # ...
    )

Current createUser Logic (interfaceDbAppObjects.py)

# Lines 598-674
def createUser(
    self,
    username: str,
    password: str = None,  # Already optional!
    # ...
) -> User:
    # Line 618-624: Password required for LOCAL auth - needs modification
    if authenticationAuthority == AuthAuthority.LOCAL:
        if not password:
            raise ValueError("Password is required for local authentication")

🚨 Critical Bug: Missing resetUserPassword() Method

File: routeDataUsers.py calls this method but it doesn't exist!

# Lines 218 and 294 in routeDataUsers.py:
success = appInterface.resetUserPassword(userId, newPassword)
# ❌ This method does NOT exist in interfaceDbAppObjects.py!

Required implementation:

def resetUserPassword(self, userId: str, newPassword: str) -> bool:
    """Reset a user's password (admin function)."""
    try:
        hashedPassword = self._getPasswordHash(newPassword)
        self.db.recordModify(UserInDB, userId, {"hashedPassword": hashedPassword})
        return True
    except Exception as e:
        logger.error(f"Error resetting password for user {userId}: {str(e)}")
        return False

Frontend State Summary

File Status Required Changes
login.html ⚠️ Add password reset link
register.html ⚠️ Remove password fields (lines 24-31)
auth.js ⚠️ Update validateRegistrationForm() (line 402)
apiCalls.js ⚠️ Update register() (line 343), add reset API calls
password-reset-request.html Create new
reset.html Create new

Missing Components Checklist

Backend (Priority Order):

  • resetUserPassword() in interfaceDbAppObjects.py - CRITICAL BUG FIX
  • resetToken + resetTokenExpires fields in UserInDB
  • sendEmailDirect() in mainServiceMessaging.py
  • findUserByEmailLocalAuth() in interfaceDbAppObjects.py
  • generateResetTokenAndExpiry() helper
  • verifyResetToken() helper
  • resetPasswordWithToken() helper
  • POST /api/local/password-reset-request endpoint
  • POST /api/local/password-reset endpoint
  • Auth_RESET_TOKEN_EXPIRY_HOURS in config.ini
  • Frontend_BASE_URL in config.ini

Frontend:

  • Password reset link in login.html
  • Remove password fields from register.html
  • Create password-reset-request.html
  • Create reset.html
  • Create passwordResetRequest.js
  • Create reset.js
  • Add requestPasswordReset() to apiCalls.js
  • Add resetPassword() to apiCalls.js
  • Update register() in apiCalls.js
  • Update validateRegistrationForm() in auth.js

Required Changes

1. Email Sending Service Integration

Overview

Email sending infrastructure exists and works. The messaging service uses Azure Communication Services (ACS).

Current Architecture (Verified)

services/__init__.py (line 96)
    └── self.messaging = PublicService(MessagingService(self))
            └── mainServiceMessaging.py
                    └── _getMessagingInterface() → interfaceMessaging.py
                            └── MessagingInterface.send()
                                    └── ConnectorMessagingEmail (Azure ACS)

Existing sendMessage() - Subscription-Based (Lines 41-131)

def sendMessage(
    self,
    subject: str,
    message: str,
    registration: MessagingSubscriptionRegistration  # ❌ Requires subscription object
) -> MessagingSendResult:

Required: Add sendEmailDirect() Method

File: gateway/modules/services/serviceMessaging/mainServiceMessaging.py Insert after line 131 (after sendMessage method):

def sendEmailDirect(
    self,
    recipient: str,
    subject: str,
    message: str,
    userId: Optional[str] = None
) -> bool:
    """
    Send email directly without requiring a subscription.
    Used for authentication flows (registration, password reset).
    
    Args:
        recipient: Email address of the recipient
        subject: Email subject
        message: Email body (can be HTML or plain text)
        userId: Optional user ID for logging/audit purposes
    
    Returns:
        bool: True if email was sent successfully, False otherwise
    """
    try:
        messagingInterface = self._getMessagingInterface()
        success = messagingInterface.send(
            channel=MessagingChannel.EMAIL,
            recipient=recipient,
            subject=subject,
            message=message
        )
        
        if success:
            logger.info(f"Email sent successfully to {recipient} (userId: {userId})")
        else:
            logger.warning(f"Failed to send email to {recipient} (userId: {userId})")
        
        return success
    except Exception as e:
        logger.error(f"Error sending email to {recipient}: {str(e)}", exc_info=True)
        return False

Email Connector Configuration (Already Exists)

File: connectorMessagingEmail.py - Uses these config vars:

connectionString = APP_CONFIG.get("MESSAGING_ACS_CONNECTION_STRING")  # Line 27
senderEmail = APP_CONFIG.get("MESSAGING_ACS_SENDER_EMAIL")            # Line 28

These should already be set in env_*.env files for the messaging service to work.

Usage in Authentication Flows

In Registration Endpoint (gateway/modules/routes/routeSecurityLocal.py):

# After creating user and generating reset token
from modules.datamodels.datamodelMessaging import MessagingChannel

# Get services instance (requires user context)
# For registration, we need root interface user
rootInterface = getRootInterface()
from modules.datamodels.datamodelUam import Mandate
defaultMandateId = rootInterface.getInitialId(Mandate)
rootInterface.mandateId = defaultMandateId

# Get root user for services context
rootUser = rootInterface.getUserById(rootInterface.getInitialId(UserInDB))
from modules.services import getInterface as getServices
services = getServices(rootUser)

# Generate magic link
frontendUrl = APP_CONFIG.get("Frontend_BASE_URL", "https://playground.poweron-center.net")
magicLink = f"{frontendUrl}/reset.html?token={resetToken}"

# Prepare email content
emailSubject = "PowerOn Registration - Set Your Password"
emailBody = f"""
Hello {user.fullName or user.username},

Thank you for registering with PowerOn.

Please click the link below to set your password:
{magicLink}

This link will expire in {expiryHours} hours.

If you did not register, please ignore this email.
"""

# Send email
emailSent = services.messaging.sendEmailDirect(
    recipient=user.email,
    subject=emailSubject,
    message=emailBody,
    userId=str(user.id)
)

if not emailSent:
    logger.error(f"Failed to send registration email to {user.email}")
    # Don't fail registration if email fails - user can request reset later

In Password Reset Request Endpoint:

# Similar pattern - use services.messaging.sendEmailDirect()
emailSubject = "PowerOn Password Reset"
emailBody = f"""
Hello {user.fullName or user.username},

You requested a password reset for your PowerOn account.

Please click the link below to reset your password:
{magicLink}

This link will expire in {expiryHours} hours.

If you did not request this, please ignore this email.
"""

emailSent = services.messaging.sendEmailDirect(
    recipient=user.email,
    subject=emailSubject,
    message=emailBody,
    userId=str(user.id)
)

Alternative: Helper Function in interfaceDbAppObjects

Alternatively, create a helper function in interfaceDbAppObjects.py that wraps the messaging service:

def sendPasswordResetEmail(
    self,
    user: User,
    token: str,
    emailType: str  # "registration" or "reset"
) -> bool:
    """
    Send password reset/registration email to user.
    
    Args:
        user: User object
        token: Reset token UUID
        emailType: "registration" or "reset"
    
    Returns:
        bool: True if email sent successfully
    """
    try:
        # Get frontend URL from config
        from modules.shared.configuration import APP_CONFIG
        frontendUrl = APP_CONFIG.get("Frontend_BASE_URL", "https://playground.poweron-center.net")
        magicLink = f"{frontendUrl}/reset.html?token={token}"
        
        # Get expiry hours from config
        expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
        
        # Prepare email content based on type
        if emailType == "registration":
            subject = "PowerOn Registration - Set Your Password"
            body = f"""
Hello {user.fullName or user.username},

Thank you for registering with PowerOn.

Please click the link below to set your password:
{magicLink}

This link will expire in {expiryHours} hours.

If you did not register, please ignore this email.
"""
        else:  # reset
            subject = "PowerOn Password Reset"
            body = f"""
Hello {user.fullName or user.username},

You requested a password reset for your PowerOn account.

Please click the link below to reset your password:
{magicLink}

This link will expire in {expiryHours} hours.

If you did not request this, please ignore this email.
"""
        
        # Get services instance
        from modules.services import getInterface as getServices
        services = getServices(self.currentUser)
        
        # Send email
        return services.messaging.sendEmailDirect(
            recipient=user.email,
            subject=subject,
            message=body,
            userId=str(user.id)
        )
    except Exception as e:
        logger.error(f"Error sending password reset email to {user.email}: {str(e)}", exc_info=True)
        return False

Configuration Requirements

File: gateway/config.ini

# Frontend URL for magic link generation
Frontend_BASE_URL = https://playground.poweron-center.net
# Or use environment-specific values:
# Frontend_BASE_URL_INT = https://playground-int.poweron-center.net
# Frontend_BASE_URL_PROD = https://playground.poweron-center.net

File: gateway/env_*.env (environment-specific)

# Email connector configuration (already exists for messaging service)
MESSAGING_ACS_CONNECTION_STRING=...
MESSAGING_ACS_SENDER_EMAIL=...

Implementation Status

  • serviceMessaging exists and is integrated
  • interfaceMessaging exists
  • Email connector exists
  • ⚠️ Need to add sendEmailDirect() method to MessagingService
  • ⚠️ Need to add sendPasswordResetEmail() helper to interfaceDbAppObjects (optional, but recommended)
  • ⚠️ Need to configure Frontend_BASE_URL in config

Testing

  • Test email sending with valid email addresses
  • Test email sending failure handling (invalid email, connector errors)
  • Test email content formatting (HTML vs plain text)
  • Test magic link generation with different frontend URLs
  • Test email sending in registration flow
  • Test email sending in password reset flow

2. Registration Process Changes

Current Behavior

  • User provides username, password, email, fullName, language
  • Password is required and hashed immediately
  • User is created with enabled=False and roleLabels=["user"]
  • User is assigned to root mandate
  • Note: MSFT/Google auth users are created with enabled=True (already implemented)

User Activation Policy

  • LOCAL auth users: After registration (with password set via magic link) → enabled=True
  • LOCAL auth users: After password reset → enabled=True
  • MSFT/Google auth users: On registration → enabled=True (already implemented)
  • Rationale: Reduce admin workload - users can login immediately after completing registration/password reset

New Behavior

  • User provides username, email, fullName, language (NO password)
  • Email Uniqueness Check: System checks if email is already used by any LOCAL auth user (across all mandates). If found, registration is rejected.
  • User is created with:
    • passwordHash = None (empty)
    • mandateId = root mandate ID
    • enabled = True (activated by default - no admin enablement needed)
    • roleLabels = ["user"]
    • resetToken = <UUID> (generated)
    • resetTokenExpires = <float timestamp> (current UTC timestamp + expiration delay from config in seconds)
  • Magic link is generated but NOT returned in API response
  • Email is sent to user with magic link for password setup
  • API response indicates: "Registration successful. Please check your email to set your password."

Registration Endpoint Changes

File: gateway/modules/routes/routeSecurityLocal.py

  • Modify register_user() endpoint (line 192)
  • Remove password requirement
  • Check email uniqueness: Verify email is not already used by any LOCAL auth user (across all mandates)
  • If email exists for LOCAL auth user: Send password reset email to existing user (don't create duplicate), return generic success
  • If email is unique: Create new user, generate reset token and expiration (using float timestamp), send email
  • Set enabled = True (user activated by default)
  • Return generic success message without magic link (don't reveal if email existed)

2. Password Reset Button and Page

New UI Element

File: frontend_agents/public/login.html

  • Add "Password Reset" button (always visible, not conditional)
  • Button links to /password-reset-request.html page

New Password Reset Request Page

File: frontend_agents/public/password-reset-request.html

  • Similar structure to register page
  • Form field: Email address (required)
  • On submit:
    • Calls password reset request endpoint
    • Shows generic success message (regardless of whether email exists)
    • Redirects to login page after showing message

New Endpoint

File: gateway/modules/routes/routeSecurityLocal.py

  • Create POST /api/local/password-reset-request endpoint
  • Accepts email address
  • Searches for user by email across ALL mandates (using root interface)
  • Filters for LOCAL authentication authority only
  • If user found (first match):
    • Generates new reset token and expiration (using float timestamp)
    • Clears passwordHash (if exists)
    • Sends email with magic link (via shared email sending function)
  • If no user found: Do nothing (no email sent)
  • Always returns generic success message (security: don't reveal if email exists)

3. Password Reset Page

New Page

File: frontend_agents/public/reset.html

  • Similar structure to actan's reset.html
  • Form fields:
    • New Password (with strength validation)
    • Confirm Password
  • URL parameter: ?token=<UUID> (magic link token)
  • On submit:
    • Validates password strength (min 8 chars, requirements from config)
    • Calls reset password endpoint with token and new password
    • On success: Shows success message with spam folder reminder, redirects to login after 3 seconds

Reset Password Endpoint

File: gateway/modules/routes/routeSecurityLocal.py

  • Create POST /api/local/password-reset endpoint
  • Accepts:
    • token: UUID reset token
    • password: New password
  • Validates:
    • Token exists and is not expired
    • User has reset token set
    • Password meets strength requirements
  • Actions:
    • Hash new password
    • Set passwordHash in user record
    • Clear resetToken and resetTokenExpires
    • Set enabled = True (user activated by default)
    • Return success

4. Authentication Logic Updates

Login Blocking Rules

File: gateway/modules/interfaces/interfaceDbAppObjects.py

  • Update authenticateLocalUser() (line 529):
    • Check if user has resetToken set → Block login
    • Check if user has no passwordHash → Block login
    • Error message: "Password reset required. Please check your email for the reset link."

Current Check (Line 549)

if not userRecord.get("hashedPassword"):
    raise ValueError("User has no password set")

This already blocks login if no password, but we need to also check for reset token.

5. Configuration Changes

New Config Variable

File: gateway/config.ini

  • Add: Auth_RESET_TOKEN_EXPIRY_HOURS = 24
  • This defines how long magic links remain valid (24 hours)

6. User Data Model Changes

Extended User Model

File: gateway/modules/datamodels/datamodelUam.py

Add fields to UserInDB model:

resetToken: Optional[str] = Field(None, description="Password reset token (UUID)")
resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)")

Note: resetTokenExpires uses float timestamp format (UTC timestamp in seconds) consistent with other timestamp fields like connectedAt, lastChecked, expiresAt in the UserConnection model. Use getUtcTimestamp() from modules.shared.timeUtils and add expiration hours converted to seconds.

Example usage for expiration calculation:

from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.configuration import APP_CONFIG

expiry_hours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
resetTokenExpires = getUtcTimestamp() + (expiry_hours * 3600)

Note: These fields should NOT be in the public User model (security), only in UserInDB.

Architecture Overview

Data Flow: Registration

1. User submits registration form (no password)
   ↓
2. Frontend calls POST /api/local/register
   ↓
3. Backend:
   - Validates username availability
   - Checks email uniqueness (no LOCAL auth user with same email exists)
   - Creates user with:
     * passwordHash = None
     * resetToken = <UUID>
     * resetTokenExpires = <UTC timestamp float + expiry_hours * 3600>
     * enabled = True (activated by default)
     * mandateId = root mandate
     * roleLabels = ["user"]
   - Generates magic link: /reset.html?token=<UUID>
   - Sends email via shared email sending function
   ↓
4. Returns: {"message": "Registration successful. Check your email."}
   ↓
5. Frontend shows success with spam folder reminder, redirects to login

Data Flow: Password Reset Request

1. User clicks "Password Reset" button on login page
   ↓
2. User navigates to /password-reset-request.html
   ↓
3. User enters email address and submits
   ↓
4. Frontend calls POST /api/local/password-reset-request
   ↓
5. Backend:
   - Searches for user by email across ALL mandates (root interface)
   - Filters for LOCAL authentication authority only
   - If user found (first match):
     * Generates new resetToken and resetTokenExpires (UTC timestamp float)
     * Clears passwordHash (if exists)
     * Sends email with magic link via shared email sending function
   - If no user found: Do nothing (no email sent)
   ↓
6. Always returns: {"message": "If email exists, reset link sent."}
   ↓
7. Frontend shows generic success message with spam folder reminder, redirects to login
1. User clicks magic link: /reset.html?token=<UUID>
   ↓
2. Frontend loads reset.html, extracts token from URL
   ↓
3. User enters new password (twice for confirmation)
   ↓
4. Frontend calls POST /api/local/password-reset
   ↓
5. Backend:
   - Validates token exists and not expired (compare resetTokenExpires float with current UTC timestamp)
   - Validates password strength
   - Sets passwordHash = hash(newPassword)
   - Clears resetToken and resetTokenExpires
   - Sets enabled = True (user activated by default)
   ↓
6. Returns: {"message": "Password reset successful"}
   ↓
7. Frontend shows success message with spam folder reminder, redirects to login after 3s

Data Flow: Login (Updated)

1. User submits login form
   ↓
2. Frontend calls POST /api/local/login
   ↓
3. Backend authenticateLocalUser():
   - Checks user exists
   - Checks user.enabled == True
   - Checks authenticationAuthority == LOCAL
   - Checks passwordHash exists → Block if missing
   - Checks resetToken is None → Block if exists
   - Verifies password
   ↓
4. If all checks pass: Create session, return tokens
   If blocked: Return error "Password reset required"

Data Objects

UserInDB Model (Extended)

class UserInDB(User):
    hashedPassword: Optional[str] = Field(None, description="Hash of the user password")
    resetToken: Optional[str] = Field(None, description="Password reset token (UUID)")
    resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)")

Reset Token Format

  • Type: UUID v4 (string)
  • Storage: Stored in resetToken field
  • Expiration: UTC timestamp as float (seconds since epoch) in resetTokenExpires field
  • Example:
    • resetToken: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
    • resetTokenExpires: 1737374400.0 (UTC timestamp float in seconds)
  • Calculation: getUtcTimestamp() + (expiry_hours * 3600) where expiry_hours comes from config
  • URL: /reset.html?token=<UUID>
  • Example: /reset.html?token=a1b2c3d4-e5f6-7890-abcd-ef1234567890
  • Base URL: Should use frontend base URL from config

Email Templates

Registration Email

Subject: "PowerOn Registration - Set Your Password" Body:

Hello {fullName},

Thank you for registering with PowerOn.

Please click the link below to set your password:
{magic_link}

This link will expire in {expiry_hours} hours.

If you did not register, please ignore this email.

Password Reset Email

Subject: "PowerOn Password Reset" Body:

Hello {fullName},

You requested a password reset for your PowerOn account.

Please click the link below to reset your password:
{magic_link}

This link will expire in {expiry_hours} hours.

If you did not request this, please ignore this email.

Security Considerations

Token Security

  • Reset tokens are UUIDs (cryptographically random)
  • Tokens are single-use (cleared immediately after successful password reset)
  • Token invalidation: When new reset token is generated, old token is overwritten in database (only one token exists per user)
  • No multiple tokens risk: Token is stored in single field in UserInDB record - only last token is valid
  • Tokens expire after configurable time period (24 hours)
  • Expired token cleanup: Expired tokens are checked and cleared during authentication and token validation
  • Tokens are not returned in API responses (only sent via email)
  • Token reuse prevention: After password reset, token is cleared atomically with password setting (no reuse possible)
  • Rate limiting: Password reset request endpoint limited to prevent abuse (see API Endpoints)

Password Security

  • Passwords are hashed using bcrypt (existing implementation)
  • Password strength requirements enforced (from config)
  • Empty passwords block login
  • Reset tokens block login until password is set

User State Management

  • Users with reset tokens cannot login
  • Users without passwords cannot login
  • Users are enabled (enabled=True) by default after registration and password reset
  • No admin activation required - users can login immediately after setting password
  • Exception: Users registered via MSFT/Google are also enabled=True by default
  • Email normalization: All emails are stored and compared in lowercase to prevent case-sensitivity issues
  • Atomic operations: Password reset operations use database transactions to ensure consistency

Implementation Status

Already Implemented

  • Login page with MSFT/Google/Local auth
  • Registration page (but requires password)
  • User data model (User, UserInDB)
  • Authentication logic (authenticateLocalUser())
  • Password hashing (_getPasswordHash(), _verifyPassword())
  • Admin password reset endpoint (/api/users/{userId}/reset-password)

Not Yet Implemented

  • Reset token fields in UserInDB model
  • Password reset request endpoint
  • Password reset endpoint (public, token-based)
  • Email sending functionality
  • Magic link generation
  • Password reset pages (request and reset)
  • Password reset API calls in frontend
  • Registration without password
  • Reset token validation in authentication

Implementation Checklist (Ready for Development)

Phase 1: Critical Bug Fix + Data Model (🔴 MUST DO FIRST)

1.1 Fix Missing resetUserPassword() Method

File: gateway/modules/interfaces/interfaceDbAppObjects.py Insert after line 741 (after enableUser method):

def resetUserPassword(self, userId: str, newPassword: str) -> bool:
    """Reset a user's password (admin function)."""
    try:
        if not newPassword or len(newPassword) < 8:
            raise ValueError("Password must be at least 8 characters long")
        
        hashedPassword = self._getPasswordHash(newPassword)
        self.db.recordModify(UserInDB, userId, {"hashedPassword": hashedPassword})
        logger.info(f"Password reset for user {userId}")
        return True
    except Exception as e:
        logger.error(f"Error resetting password for user {userId}: {str(e)}")
        return False

1.2 Add Reset Token Fields to UserInDB

File: gateway/modules/datamodels/datamodelUam.py Modify lines 166-172:

class UserInDB(User):
    hashedPassword: Optional[str] = Field(None, description="Hash of the user password")
    resetToken: Optional[str] = Field(None, description="Password reset token (UUID)")
    resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)")

Phase 2: Email Sending + Helper Methods

2.1 Add sendEmailDirect() to MessagingService

File: gateway/modules/services/serviceMessaging/mainServiceMessaging.py Insert after line 131 (after sendMessage method) - see Section 1 above for code.

2.2 Add Helper Methods to interfaceDbAppObjects.py

File: gateway/modules/interfaces/interfaceDbAppObjects.py

# Insert after resetUserPassword() method

def generateResetTokenAndExpiry(self) -> tuple[str, float]:
    """Generate a new reset token and expiration timestamp.
    
    Returns:
        tuple: (token_uuid, expires_timestamp_float)
    """
    import uuid
    token = str(uuid.uuid4())
    expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
    expires = getUtcTimestamp() + (expiryHours * 3600)
    return token, expires

def findUserByEmailLocalAuth(self, email: str) -> Optional[User]:
    """Find LOCAL auth user by email (searches across all mandates).
    
    Args:
        email: Email address to search for (case-insensitive)
    
    Returns:
        User if found, None otherwise
    """
    if not email:
        return None
    
    normalizedEmail = email.lower().strip()
    
    try:
        # Use root interface to bypass RBAC for cross-mandate search
        users = self.db.getRecordset(
            UserInDB,
            recordFilter={
                "email": normalizedEmail,
                "authenticationAuthority": AuthAuthority.LOCAL.value
            }
        )
        
        if users:
            cleanedUser = {k: v for k, v in users[0].items() if not k.startswith("_")}
            if cleanedUser.get("roleLabels") is None:
                cleanedUser["roleLabels"] = []
            return User(**cleanedUser)
        
        return None
    except Exception as e:
        logger.error(f"Error finding user by email: {str(e)}")
        return None

def setResetToken(self, userId: str, token: str, expires: float) -> bool:
    """Set reset token for a user (clears password hash)."""
    try:
        self.db.recordModify(UserInDB, userId, {
            "resetToken": token,
            "resetTokenExpires": expires,
            "hashedPassword": None  # Clear password during reset flow
        })
        return True
    except Exception as e:
        logger.error(f"Error setting reset token for user {userId}: {str(e)}")
        return False

def verifyResetToken(self, token: str) -> Optional[User]:
    """Verify reset token and return user if valid.
    
    Returns:
        User if token is valid and not expired, None otherwise
    """
    if not token:
        return None
    
    try:
        users = self.db.getRecordset(UserInDB, recordFilter={"resetToken": token})
        
        if not users:
            return None
        
        userRecord = users[0]
        
        # Check expiration
        expires = userRecord.get("resetTokenExpires")
        if not expires or getUtcTimestamp() > expires:
            logger.warning(f"Reset token expired for user {userRecord.get('id')}")
            return None
        
        cleanedUser = {k: v for k, v in userRecord.items() if not k.startswith("_")}
        if cleanedUser.get("roleLabels") is None:
            cleanedUser["roleLabels"] = []
        return User(**cleanedUser)
    except Exception as e:
        logger.error(f"Error verifying reset token: {str(e)}")
        return None

def resetPasswordWithToken(self, token: str, newPassword: str) -> bool:
    """Reset password using token (atomic operation).
    
    Returns:
        True if successful, False otherwise
    """
    try:
        user = self.verifyResetToken(token)
        if not user:
            return False
        
        hashedPassword = self._getPasswordHash(newPassword)
        
        # Atomic update: set password, clear token, enable user
        self.db.recordModify(UserInDB, user.id, {
            "hashedPassword": hashedPassword,
            "resetToken": None,
            "resetTokenExpires": None,
            "enabled": True
        })
        
        logger.info(f"Password reset completed for user {user.id}")
        return True
    except Exception as e:
        logger.error(f"Error in resetPasswordWithToken: {str(e)}")
        return False

Phase 3: Backend Endpoints

3.1 Add Configuration

File: gateway/config.ini Add at end of Auth section:

# Reset token configuration
Auth_RESET_TOKEN_EXPIRY_HOURS = 24

# Frontend URL for magic links
Frontend_BASE_URL = https://playground.poweron-center.net

3.2 Add Password Reset Endpoints

File: gateway/modules/routes/routeSecurityLocal.py Add after /available endpoint (after line 435):

@router.post("/password-reset-request")
@limiter.limit("5/minute")
async def passwordResetRequest(
    request: Request,
    email: str = Body(..., embed=True)
) -> Dict[str, Any]:
    """Request password reset email."""
    try:
        rootInterface = getRootInterface()
        
        # Normalize email
        normalizedEmail = email.lower().strip()
        
        # Find user (don't reveal if found or not)
        user = rootInterface.findUserByEmailLocalAuth(normalizedEmail)
        
        if user:
            # Generate reset token
            token, expires = rootInterface.generateResetTokenAndExpiry()
            
            # Set reset token (clears password)
            rootInterface.setResetToken(user.id, token, expires)
            
            # Get services for email sending
            from modules.services import Services
            services = Services(user)
            
            # Generate magic link
            frontendUrl = APP_CONFIG.get("Frontend_BASE_URL", "https://playground.poweron-center.net")
            magicLink = f"{frontendUrl}/reset.html?token={token}"
            
            # Send email
            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.

Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:
{magicLink}

Dieser Link ist 24 Stunden gültig.

Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignorieren.
"""
            
            services.messaging.sendEmailDirect(
                recipient=user.email,
                subject=emailSubject,
                message=emailBody,
                userId=str(user.id)
            )
            
            logger.info(f"Password reset email sent to {normalizedEmail}")
        else:
            logger.info(f"Password reset requested for unknown email: {normalizedEmail}")
        
        # Always return same message (security)
        return {
            "message": "Falls ein Konto mit dieser E-Mail existiert, wurde ein Reset-Link 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 dieser E-Mail existiert, wurde ein Reset-Link 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"
        )

3.3 Modify Registration Endpoint

File: gateway/modules/routes/routeSecurityLocal.py Modify register_user() (lines 192-248) - Remove password requirement, change to email-based:

Key changes:

  • Remove password: str = Body(..., embed=True) from line 197
  • Change enabled=False to enabled=True on line 225
  • Add reset token generation and email sending

Phase 4: Frontend Changes

4.1 Modify login.html

Add after line 34 (after submit button, before register-options):

<div class="password-reset-link" style="margin-top: 10px; text-align: center;">
    <a href="password-reset-request.html" class="btn-link">
        <i class="fas fa-key"></i> Passwort vergessen?
    </a>
</div>

4.2 Modify register.html

Remove lines 23-31 (password and confirm-password fields) Add info message after form (before submit button):

<div class="registration-info" style="margin: 15px 0; padding: 10px; background: #e8f4fd; border-radius: 4px;">
    <p style="margin: 0;">Nach der Registrierung erhalten Sie eine E-Mail mit einem Link zum Setzen Ihres Passworts.</p>
</div>

4.3 Add to apiCalls.js

Add after register function (after line 379):

requestPasswordReset: async function(email) {
    try {
        return await privateApi.post('/api/local/password-reset-request', { email });
    } catch (error) {
        ui.log.error('Password reset request error:', error);
        throw error;
    }
},

resetPassword: async function(token, password) {
    try {
        return await privateApi.post('/api/local/password-reset', { token, password });
    } catch (error) {
        ui.log.error('Password reset error:', error);
        throw error;
    }
},

4.4 Create New Files

  • password-reset-request.html - See doc_userauth_ui_adaptations.md
  • reset.html - See doc_userauth_ui_adaptations.md
  • js/security/passwordResetRequest.js
  • js/security/reset.js

Phase 5: Authentication Logic Update

File: gateway/modules/interfaces/interfaceDbAppObjects.py Modify authenticateLocalUser() (lines 570-596) - Add reset token check:

# After line 591 (after hashedPassword check), add:
if userRecord.get("resetToken"):
    raise ValueError("Passwort-Zurücksetzung erforderlich. Bitte prüfen Sie Ihre E-Mail.")

Testing Scenarios

Registration Flow

  1. User registers without password → User created with reset token
  2. Email uniqueness checked (reject if email already used by LOCAL auth user)
  3. Email sent with magic link
  4. User cannot login before setting password
  5. Magic link expires after 24 hours
  6. User can set password via magic link
  7. User can login immediately after password reset (enabled=True)
  8. No admin activation required

Password Reset Flow

  1. User clicks "Password Reset" button on login page
  2. User navigates to password-reset-request.html
  3. User enters email → System searches across all mandates for LOCAL auth user
  4. If user found: Reset token generated and email sent
  5. If user not found: No email sent, but generic success message shown
  6. User cannot login with old password (if existed)
  7. User sets new password via magic link
  8. User can login immediately after password reset (enabled=True)
  9. No admin activation required

Login Blocking

  1. User with no password cannot login
  2. User with reset token cannot login (even if expired token exists)
  3. User with valid password can login (if enabled=True)
  4. User with expired reset token cannot login
  5. User with reset token cannot login even if passwordHash exists (reset token takes precedence)

Additional Security Tests

  1. Multiple reset requests invalidate old tokens
  2. Token reuse attempt fails (token cleared after use)
  3. Email case variations handled (normalized to lowercase)
  4. Rate limiting prevents abuse
  5. Atomic operations prevent partial updates
  6. Expired tokens are rejected
  7. Invalid token formats are rejected
  8. Cross-mandate email search works correctly

Reference Implementation

The actan project (actan/sanctions-code) provides a reference implementation:

Key Files:

  • server/core/usermanagement.py:

    • set_password_reset() (line 371): Sets reset token and clears password
    • verify_reset_token() (line 422): Validates token
    • set_new_password() (line 438): Sets password and clears token
    • is_user_in_password_reset() (line 504): Checks reset state
  • server/core/authentication.py:

    • verify_password() (line 38): Checks for empty password hash
    • authenticate_user() (line 206): Blocks login if changepwd=True
  • public/reset.html:

    • Password reset page UI
    • Token extraction from URL
    • Form submission handling
  • public/js/login.js:

    • Login form handling
    • Error message display

Migration Notes

Existing Users

  • Existing users with passwords are unaffected
  • No migration needed for existing data
  • New fields (resetToken, resetTokenExpires) are optional and nullable

Database Schema

  • Add columns to users table:
    • reset_token VARCHAR(36) NULL (UUID format)
    • reset_token_expires DOUBLE PRECISION NULL (UTC timestamp float in seconds)
  • Both fields default to NULL
  • No data migration required

Configuration Variables

New Config Entries

Backend (gateway/config.ini):

# Auth configuration
Auth_RESET_TOKEN_EXPIRY_HOURS = 24

Frontend (frontend_agents/public/config/env_*.env):

# Frontend URL for magic link generation (add to each env file)
APP_FRONTEND_URL = https://your-frontend-domain.com
# Examples:
# env_int.env: APP_FRONTEND_URL = https://playground-int.poweron-center.net
# env_prod.env: APP_FRONTEND_URL = https://playground.poweron-center.net

Usage

  • Read from config in registration and reset endpoints
  • Calculate expiration: getUtcTimestamp() + (expiry_hours * 3600)
  • Store as float: resetTokenExpires = current_timestamp + (24 * 3600) (for 24 hours)
  • Compare expiration: if resetTokenExpires > getUtcTimestamp(): valid

API Endpoints Summary

Modified Endpoints

  • POST /api/local/register
    • Change: Remove password requirement
    • Add: Generate reset token, send email
    • Response: Success message (no token)

New Endpoints

  • POST /api/local/password-reset-request

    • Rate limit: 5/minute (stricter than registration to prevent abuse)
    • Input: { "email": "user@example.com" } (email normalized to lowercase)
    • Output: { "message": "If email exists, reset link sent." } (always same message)
    • Behavior: Invalidates old reset token if user found, generates new one
  • POST /api/local/password-reset

    • Rate limit: 10/minute
    • Input: { "token": "<UUID>", "password": "newpassword" }
    • Output: { "message": "Password reset successful" }
    • Behavior: Atomic operation - sets password, clears token, enables user in single transaction

Error Handling

Registration Errors

  • Username already exists → 400 Bad Request
  • Email already exists for LOCAL auth user: Send password reset email to existing user, return generic success (don't reveal email existed)
  • Email sending fails → Log error, still return success (don't reveal email issues)

Password Reset Request Errors

  • Email not found → Return generic success (security)
  • Email sending fails → Log error, return generic success

Password Reset Errors

  • Token format invalid → 400 Bad Request "Invalid or expired reset token" (generic message)
  • Token not found → 400 Bad Request "Invalid or expired reset token" (generic message)
  • Token expired (resetTokenExpires < current UTC timestamp) → 400 Bad Request "Invalid or expired reset token"
  • Token already used (passwordHash exists, resetToken is None) → 400 Bad Request "Invalid or expired reset token"
  • User not found → 400 Bad Request "Invalid or expired reset token" (generic message)
  • Password too weak → 400 Bad Request with specific requirements (safe to reveal)
  • User disabled → 400 Bad Request "Account is disabled" (or allow reset but keep disabled)

Next Steps

  1. Review and approve this concept document
  2. Create detailed implementation tasks
  3. Implement backend changes (data model, endpoints, authentication logic)
  4. Implement frontend changes (UI, API calls, reset page)
  5. Integrate email sending (messaging service)
  6. Test all flows (registration, reset request, reset, login blocking)
  7. Update documentation (user guide, admin guide)

Code Reuse Strategy

Shared Functions - Frontend

  1. Email Validation (frontend_agents/public/js/security/auth.js)
    • Create validateEmailFormat(email) function
    • Reused by:
      • Registration page (register.html)
      • Password reset request page (password-reset-request.html)
    • Validates email format using regex

Shared Functions - Backend

  1. Email Uniqueness Check (gateway/modules/interfaces/interfaceDbAppObjects.py)

    • Create checkEmailUniquenessForLocalAuth(email) method
    • Searches across ALL mandates for LOCAL auth users with same email
    • Returns: True if email is unique, False if already exists
    • Reused by:
      • Registration endpoint (reject if email exists)
      • Could be used for future email change validation
  2. Find User by Email (gateway/modules/interfaces/interfaceDbAppObjects.py)

    • Create findUserByEmailLocalAuth(email) method
    • Email normalization: Convert email to lowercase before search
    • Uses root interface to search across ALL mandates (bypasses RBAC)
    • Searches with filter: {"email": email.lower(), "authenticationAuthority": AuthAuthority.LOCAL}
    • Returns: First matching User or None
    • Handles NULL emails gracefully
    • Reused by:
      • Password reset request endpoint
      • Email uniqueness check
  3. Token Generation (gateway/modules/interfaces/interfaceDbAppObjects.py)

    • Create generateResetTokenAndExpiry() helper method
    • Returns tuple: (token_uuid: str, expires_timestamp_float: float)
    • Uses uuid.uuid4() for token generation (cryptographically random)
    • Uses getUtcTimestamp() + (expiry_hours * 3600) for expiration
    • Token invalidation: Before generating new token, clears any existing resetToken for user
    • Reused by:
      • Registration endpoint
      • Password reset request endpoint
  4. Email Sending (gateway/modules/interfaces/interfaceDbAppObjects.py or separate utility module)

    • Create sendPasswordResetEmail(user, token, emailType) function
    • Parameters:
      • user: User object (for fullName, email)
      • token: UUID token string
      • emailType: "registration" or "reset" (determines email template)
    • Generates magic link: {APP_FRONTEND_URL}/reset.html?token=<UUID>
    • Uses frontend base URL from frontend env config (APP_FRONTEND_URL)
    • Backend reads frontend URL from request origin or frontend config
    • Sends email via messaging service
    • Error handling: Logs email sending failures but doesn't fail the operation
    • Reused by:
      • Registration endpoint (emailType="registration")
      • Password reset request endpoint (emailType="reset")

Security Considerations (Additional)

Email Normalization

  • All emails are normalized to lowercase before storage and comparison
  • Prevents case-sensitivity issues: User@Example.com = user@example.com
  • Applied in: registration, password reset request, email uniqueness check

Token Invalidation

  • When new reset token is generated, old token is invalidated
  • Prevents multiple valid tokens per user
  • Ensures user always knows which token to use (the most recent one)

Atomic Operations

  • Password reset uses database transactions
  • Ensures all-or-nothing: password set + token cleared + user enabled
  • Prevents partial updates if operation fails

Rate Limiting

  • Registration: 10/minute (existing)
  • Password reset request: 5/minute (stricter to prevent abuse)
  • Password reset: 10/minute (less critical, token is single-use)

Audit Logging

  • Log all password reset requests (with email hash for privacy)
  • Log successful password resets
  • Log failed token validations (for security monitoring)
  • Log token generation events

Questions / Open Points - Final Status

# Item Status Resolution
1 Email Service RESOLVED serviceMessaging exists. Add sendEmailDirect() method. Azure ACS config in env_*.env
2 Root Mandate RESOLVED getInitialId(Mandate) used in lines 61, 206 of routeSecurityLocal.py
3 Password Strength RESOLVED Config exists. Min 8 chars enforced. Full validation optional (future)
4 Token Format RESOLVED UUID v4 string + float timestamp (matches UserConnection pattern)
5 Email Templates DECIDED Hardcode in German for MVP. i18n can be added later based on user.language
6 User Activation DECIDED Change enabled=False to enabled=True in registration
7 Token Cleanup DECIDED Lazy cleanup on validation. No periodic job needed
8 Resend Email 📋 BACKLOG Future enhancement. Not in MVP scope
9 resetUserPassword() 🚨 BUG FIX MUST implement - currently causes runtime error
10 Frontend Base URL DECIDED Use Frontend_BASE_URL in config.ini. Can be overridden per environment

Implementation Decision Log

  1. Email Language: German (matches current UI language in login.html, register.html)
  2. Token Expiry: 24 hours (configurable via Auth_RESET_TOKEN_EXPIRY_HOURS)
  3. Rate Limits:
    • Password reset request: 5/minute (stricter to prevent abuse)
    • Password reset: 10/minute
    • Registration: 10/minute (existing)
  4. Error Messages: Generic for security (don't reveal if email exists)
  5. Password cleared on reset request: Yes (user must set new password via link)