wiki/z-archive/implementation/implementation_doc_userauth_process_concept.md

1450 lines
52 KiB
Markdown

# 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-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**:
```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-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`
```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)
```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 = <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)
```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 = <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
```
### Data Flow: Password Reset (via Magic Link)
```
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)
```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=<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):
```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-center.net
```
#### 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-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):
```html
<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):
```html
<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)**:
```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-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)