1450 lines
52 KiB
Markdown
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.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 = <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.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
|
|
<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.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": "<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)
|