# 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`) ```python # 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`) ```python # 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`) ```python # 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`) ```python # 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! ```python # Lines 218 and 294 in routeDataUsers.py: success = appInterface.resetUserPassword(userId, newPassword) # ❌ This method does NOT exist in interfaceDbAppObjects.py! ``` **Required implementation**: ```python 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) ```python 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): ```python 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: ```python 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`): ```python # 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.swiss") 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**: ```python # 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: ```python 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.swiss") 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` ```ini # Frontend URL for magic link generation Frontend_BASE_URL = https://playground.poweron.swiss # Or use environment-specific values: # Frontend_BASE_URL_INT = https://playground-int.poweron.swiss # Frontend_BASE_URL_PROD = https://playground.poweron.swiss ``` **File**: `gateway/env_*.env` (environment-specific) ```env # 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 = ` (generated) - `resetTokenExpires = ` (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=` (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) ```python 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: ```python 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**: ```python 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 = * resetTokenExpires = * enabled = True (activated by default) * mandateId = root mandate * roleLabels = ["user"] - Generates magic link: /reset.html?token= - 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 ``` ### Data Flow: Password Reset (via Magic Link) ``` 1. User clicks magic link: /reset.html?token= ↓ 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) ```python 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 ### Magic Link Format - **URL**: `/reset.html?token=` - **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): ```python 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**: ```python 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` ```python # 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**: ```ini # Reset token configuration Auth_RESET_TOKEN_EXPIRY_HOURS = 24 # Frontend URL for magic links Frontend_BASE_URL = https://playground.poweron.swiss ``` #### 3.2 Add Password Reset Endpoints **File**: `gateway/modules/routes/routeSecurityLocal.py` **Add after `/available` endpoint (after line 435)**: ```python @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.swiss") 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): ```html ``` #### 4.2 Modify `register.html` **Remove lines 23-31** (password and confirm-password fields) **Add info message after form** (before submit button): ```html

Nach der Registrierung erhalten Sie eine E-Mail mit einem Link zum Setzen Ihres Passworts.

``` #### 4.3 Add to `apiCalls.js` **Add after `register` function (after line 379)**: ```javascript 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: ```python # 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`): ```ini # Auth configuration Auth_RESET_TOKEN_EXPIRY_HOURS = 24 ``` **Frontend** (`frontend_agents/public/config/env_*.env`): ```ini # 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.swiss # env_prod.env: APP_FRONTEND_URL = https://playground.poweron.swiss ``` ### 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": "", "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=` - 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)