52 KiB
User Authentication Process - Concept Document
Overview
This document defines the architecture and data objects for the refactored user authentication process in PowerOn, including self-registration with magic links, password reset functionality, and the associated data model changes.
Last Updated: 2025-01-12 - Deep code review completed, ready for implementation.
Executive Summary
This document has been thoroughly reviewed against the actual codebase in gateway and frontend_agents. The analysis confirms that most features are not yet implemented but the infrastructure exists.
🚨 CRITICAL BUG DISCOVERED
resetUserPassword() method is called but NOT implemented!
- Called in
routeDataUsers.py(lines 218, 294) - Does NOT exist in
interfaceDbAppObjects.py - This will cause a runtime error if admin tries to reset a password
- MUST be implemented as part of this work
Critical Gaps (Ordered by Implementation Priority):
| Priority | Gap | Effort | Location |
|---|---|---|---|
| 🔴 P0 | resetUserPassword() method missing |
Small | interfaceDbAppObjects.py |
| 🔴 P1 | sendEmailDirect() method in messaging |
Small | mainServiceMessaging.py |
| 🔴 P1 | resetToken + resetTokenExpires fields |
Small | datamodelUam.py |
| 🔴 P1 | Password reset endpoints | Medium | routeSecurityLocal.py |
| 🟡 P2 | Remove password from registration | Medium | routeSecurityLocal.py |
| 🟡 P2 | Frontend password reset pages | Medium | frontend_agents/public/ |
| 🟡 P2 | Frontend API calls for reset | Small | apiCalls.js |
| 🟢 P3 | Config entries (Auth_RESET_TOKEN_EXPIRY_HOURS, Frontend_BASE_URL) |
Small | config.ini |
What Already Exists (✅ Verified):
- ✅ Email sending infrastructure -
serviceMessagingatself.services.messaging(line 96 inservices/__init__.py) - ✅ Email connector -
ConnectorMessagingEmailusing Azure Communication Services - ✅ interfaceMessaging.send() - Unified interface for email/SMS (line 31 in
interfaceMessaging.py) - ✅ Password hashing -
_getPasswordHash()/_verifyPassword()(lines 442-448 ininterfaceDbAppObjects.py) - ✅ User data models -
User,UserInDB(lines 131, 166 indatamodelUam.py) - ✅ Admin password reset route -
/api/users/{userId}/reset-password(line 200 inrouteDataUsers.py) - ✅ Frontend pages -
login.html,register.html(need modifications) - ✅ Timestamp utilities -
getUtcTimestamp(),createExpirationTimestamp()(intimeUtils.py)
Implementation Estimate:
- Backend: ~4-6 hours
- Frontend: ~3-4 hours
- Testing: ~2-3 hours
- Total: ~10-13 hours
See doc_userauth_ui_adaptations.md for detailed UI implementation requirements.
Current State Analysis
Existing Authentication Flow (Verified)
| Component | File | Status | Notes |
|---|---|---|---|
| Login Page | frontend_agents/public/login.html |
✅ Works | Missing password reset link |
| Microsoft Auth | routeSecurityMsft.py |
✅ Works | - |
| Google Auth | routeSecurityGoogle.py |
✅ Works | - |
| Local Auth | routeSecurityLocal.py |
✅ Works | - |
| Registration | register.html |
⚠️ Needs change | Requires password |
Current User Data Model (datamodelUam.py)
# Line 166-172
class UserInDB(User):
hashedPassword: Optional[str] = Field(None, description="Hash of the user password")
# ❌ MISSING: resetToken, resetTokenExpires fields
Reference: UserConnection model (line 95-111) uses float timestamps for connectedAt, lastChecked, expiresAt - same pattern to use for resetTokenExpires.
Current Authentication Logic (interfaceDbAppObjects.py)
# Lines 570-596 - authenticateLocalUser()
def authenticateLocalUser(self, username: str, password: str) -> Optional[User]:
# ... gets user ...
# Line 590: Check for password hash
if not userRecord.get("hashedPassword"):
raise ValueError("User has no password set")
# ❌ MISSING: Check for resetToken (should block login if set)
if not self._verifyPassword(password, userRecord["hashedPassword"]):
raise ValueError("Invalid password")
Current Registration Logic (routeSecurityLocal.py)
# Line 192-248
@router.post("/register", response_model=User)
async def register_user(
request: Request,
userData: User = Body(...),
password: str = Body(..., embed=True) # ❌ Line 197: Password required - needs removal
) -> User:
# ...
user = appInterface.createUser(
# ...
enabled=False, # ❌ Line 225: Should be True
# ...
)
Current createUser Logic (interfaceDbAppObjects.py)
# Lines 598-674
def createUser(
self,
username: str,
password: str = None, # Already optional!
# ...
) -> User:
# Line 618-624: Password required for LOCAL auth - needs modification
if authenticationAuthority == AuthAuthority.LOCAL:
if not password:
raise ValueError("Password is required for local authentication")
🚨 Critical Bug: Missing resetUserPassword() Method
File: routeDataUsers.py calls this method but it doesn't exist!
# Lines 218 and 294 in routeDataUsers.py:
success = appInterface.resetUserPassword(userId, newPassword)
# ❌ This method does NOT exist in interfaceDbAppObjects.py!
Required implementation:
def resetUserPassword(self, userId: str, newPassword: str) -> bool:
"""Reset a user's password (admin function)."""
try:
hashedPassword = self._getPasswordHash(newPassword)
self.db.recordModify(UserInDB, userId, {"hashedPassword": hashedPassword})
return True
except Exception as e:
logger.error(f"Error resetting password for user {userId}: {str(e)}")
return False
Frontend State Summary
| File | Status | Required Changes |
|---|---|---|
login.html |
⚠️ | Add password reset link |
register.html |
⚠️ | Remove password fields (lines 24-31) |
auth.js |
⚠️ | Update validateRegistrationForm() (line 402) |
apiCalls.js |
⚠️ | Update register() (line 343), add reset API calls |
password-reset-request.html |
❌ | Create new |
reset.html |
❌ | Create new |
Missing Components Checklist
Backend (Priority Order):
resetUserPassword()ininterfaceDbAppObjects.py- CRITICAL BUG FIXresetToken+resetTokenExpiresfields inUserInDBsendEmailDirect()inmainServiceMessaging.pyfindUserByEmailLocalAuth()ininterfaceDbAppObjects.pygenerateResetTokenAndExpiry()helperverifyResetToken()helperresetPasswordWithToken()helperPOST /api/local/password-reset-requestendpointPOST /api/local/password-resetendpointAuth_RESET_TOKEN_EXPIRY_HOURSinconfig.iniFrontend_BASE_URLinconfig.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()toapiCalls.js - Add
resetPassword()toapiCalls.js - Update
register()inapiCalls.js - Update
validateRegistrationForm()inauth.js
Required Changes
1. Email Sending Service Integration
Overview
Email sending infrastructure exists and works. The messaging service uses Azure Communication Services (ACS).
Current Architecture (Verified)
services/__init__.py (line 96)
└── self.messaging = PublicService(MessagingService(self))
└── mainServiceMessaging.py
└── _getMessagingInterface() → interfaceMessaging.py
└── MessagingInterface.send()
└── ConnectorMessagingEmail (Azure ACS)
Existing sendMessage() - Subscription-Based (Lines 41-131)
def sendMessage(
self,
subject: str,
message: str,
registration: MessagingSubscriptionRegistration # ❌ Requires subscription object
) -> MessagingSendResult:
Required: Add sendEmailDirect() Method
File: gateway/modules/services/serviceMessaging/mainServiceMessaging.py
Insert after line 131 (after sendMessage method):
def sendEmailDirect(
self,
recipient: str,
subject: str,
message: str,
userId: Optional[str] = None
) -> bool:
"""
Send email directly without requiring a subscription.
Used for authentication flows (registration, password reset).
Args:
recipient: Email address of the recipient
subject: Email subject
message: Email body (can be HTML or plain text)
userId: Optional user ID for logging/audit purposes
Returns:
bool: True if email was sent successfully, False otherwise
"""
try:
messagingInterface = self._getMessagingInterface()
success = messagingInterface.send(
channel=MessagingChannel.EMAIL,
recipient=recipient,
subject=subject,
message=message
)
if success:
logger.info(f"Email sent successfully to {recipient} (userId: {userId})")
else:
logger.warning(f"Failed to send email to {recipient} (userId: {userId})")
return success
except Exception as e:
logger.error(f"Error sending email to {recipient}: {str(e)}", exc_info=True)
return False
Email Connector Configuration (Already Exists)
File: connectorMessagingEmail.py - Uses these config vars:
connectionString = APP_CONFIG.get("MESSAGING_ACS_CONNECTION_STRING") # Line 27
senderEmail = APP_CONFIG.get("MESSAGING_ACS_SENDER_EMAIL") # Line 28
These should already be set in env_*.env files for the messaging service to work.
Usage in Authentication Flows
In Registration Endpoint (gateway/modules/routes/routeSecurityLocal.py):
# After creating user and generating reset token
from modules.datamodels.datamodelMessaging import MessagingChannel
# Get services instance (requires user context)
# For registration, we need root interface user
rootInterface = getRootInterface()
from modules.datamodels.datamodelUam import Mandate
defaultMandateId = rootInterface.getInitialId(Mandate)
rootInterface.mandateId = defaultMandateId
# Get root user for services context
rootUser = rootInterface.getUserById(rootInterface.getInitialId(UserInDB))
from modules.services import getInterface as getServices
services = getServices(rootUser)
# Generate magic link
frontendUrl = APP_CONFIG.get("Frontend_BASE_URL", "https://playground.poweron.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:
# Similar pattern - use services.messaging.sendEmailDirect()
emailSubject = "PowerOn Password Reset"
emailBody = f"""
Hello {user.fullName or user.username},
You requested a password reset for your PowerOn account.
Please click the link below to reset your password:
{magicLink}
This link will expire in {expiryHours} hours.
If you did not request this, please ignore this email.
"""
emailSent = services.messaging.sendEmailDirect(
recipient=user.email,
subject=emailSubject,
message=emailBody,
userId=str(user.id)
)
Alternative: Helper Function in interfaceDbAppObjects
Alternatively, create a helper function in interfaceDbAppObjects.py that wraps the messaging service:
def sendPasswordResetEmail(
self,
user: User,
token: str,
emailType: str # "registration" or "reset"
) -> bool:
"""
Send password reset/registration email to user.
Args:
user: User object
token: Reset token UUID
emailType: "registration" or "reset"
Returns:
bool: True if email sent successfully
"""
try:
# Get frontend URL from config
from modules.shared.configuration import APP_CONFIG
frontendUrl = APP_CONFIG.get("Frontend_BASE_URL", "https://playground.poweron.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
# 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)
# Email connector configuration (already exists for messaging service)
MESSAGING_ACS_CONNECTION_STRING=...
MESSAGING_ACS_SENDER_EMAIL=...
Implementation Status
- ✅
serviceMessagingexists and is integrated - ✅
interfaceMessagingexists - ✅ Email connector exists
- ⚠️ Need to add
sendEmailDirect()method toMessagingService - ⚠️ Need to add
sendPasswordResetEmail()helper tointerfaceDbAppObjects(optional, but recommended) - ⚠️ Need to configure
Frontend_BASE_URLin 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=FalseandroleLabels=["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 IDenabled = 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.htmlpage
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-requestendpoint - 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-resetendpoint - Accepts:
token: UUID reset tokenpassword: New password
- Validates:
- Token exists and is not expired
- User has reset token set
- Password meets strength requirements
- Actions:
- Hash new password
- Set
passwordHashin user record - Clear
resetTokenandresetTokenExpires - 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
resetTokenset → Block login - Check if user has no
passwordHash→ Block login - Error message: "Password reset required. Please check your email for the reset link."
- Check if user has
Current Check (Line 549)
if not userRecord.get("hashedPassword"):
raise ValueError("User has no password set")
This already blocks login if no password, but we need to also check for reset token.
5. Configuration Changes
New Config Variable
File: gateway/config.ini
- Add:
Auth_RESET_TOKEN_EXPIRY_HOURS = 24 - This defines how long magic links remain valid (24 hours)
6. User Data Model Changes
Extended User Model
File: gateway/modules/datamodels/datamodelUam.py
Add fields to UserInDB model:
resetToken: Optional[str] = Field(None, description="Password reset token (UUID)")
resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)")
Note: resetTokenExpires uses float timestamp format (UTC timestamp in seconds) consistent with other timestamp fields like connectedAt, lastChecked, expiresAt in the UserConnection model. Use getUtcTimestamp() from modules.shared.timeUtils and add expiration hours converted to seconds.
Example usage for expiration calculation:
from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.configuration import APP_CONFIG
expiry_hours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
resetTokenExpires = getUtcTimestamp() + (expiry_hours * 3600)
Note: These fields should NOT be in the public User model (security), only in UserInDB.
Architecture Overview
Data Flow: Registration
1. User submits registration form (no password)
↓
2. Frontend calls POST /api/local/register
↓
3. Backend:
- Validates username availability
- Checks email uniqueness (no LOCAL auth user with same email exists)
- Creates user with:
* passwordHash = None
* resetToken = <UUID>
* resetTokenExpires = <UTC timestamp float + expiry_hours * 3600>
* enabled = True (activated by default)
* mandateId = root mandate
* roleLabels = ["user"]
- Generates magic link: /reset.html?token=<UUID>
- Sends email via shared email sending function
↓
4. Returns: {"message": "Registration successful. Check your email."}
↓
5. Frontend shows success with spam folder reminder, redirects to login
Data Flow: Password Reset Request
1. User clicks "Password Reset" button on login page
↓
2. User navigates to /password-reset-request.html
↓
3. User enters email address and submits
↓
4. Frontend calls POST /api/local/password-reset-request
↓
5. Backend:
- Searches for user by email across ALL mandates (root interface)
- Filters for LOCAL authentication authority only
- If user found (first match):
* Generates new resetToken and resetTokenExpires (UTC timestamp float)
* Clears passwordHash (if exists)
* Sends email with magic link via shared email sending function
- If no user found: Do nothing (no email sent)
↓
6. Always returns: {"message": "If email exists, reset link sent."}
↓
7. Frontend shows generic success message with spam folder reminder, redirects to login
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)
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
resetTokenfield - Expiration: UTC timestamp as float (seconds since epoch) in
resetTokenExpiresfield - 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=Trueby 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
UserInDBmodel - Password reset request endpoint
- Password reset endpoint (public, token-based)
- Email sending functionality
- Magic link generation
- Password reset pages (request and reset)
- Password reset API calls in frontend
- Registration without password
- Reset token validation in authentication
Implementation Checklist (Ready for Development)
Phase 1: Critical Bug Fix + Data Model (🔴 MUST DO FIRST)
1.1 Fix Missing resetUserPassword() Method
File: gateway/modules/interfaces/interfaceDbAppObjects.py
Insert after line 741 (after enableUser method):
def resetUserPassword(self, userId: str, newPassword: str) -> bool:
"""Reset a user's password (admin function)."""
try:
if not newPassword or len(newPassword) < 8:
raise ValueError("Password must be at least 8 characters long")
hashedPassword = self._getPasswordHash(newPassword)
self.db.recordModify(UserInDB, userId, {"hashedPassword": hashedPassword})
logger.info(f"Password reset for user {userId}")
return True
except Exception as e:
logger.error(f"Error resetting password for user {userId}: {str(e)}")
return False
1.2 Add Reset Token Fields to UserInDB
File: gateway/modules/datamodels/datamodelUam.py
Modify lines 166-172:
class UserInDB(User):
hashedPassword: Optional[str] = Field(None, description="Hash of the user password")
resetToken: Optional[str] = Field(None, description="Password reset token (UUID)")
resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)")
Phase 2: Email Sending + Helper Methods
2.1 Add sendEmailDirect() to MessagingService
File: gateway/modules/services/serviceMessaging/mainServiceMessaging.py
Insert after line 131 (after sendMessage method) - see Section 1 above for code.
2.2 Add Helper Methods to interfaceDbAppObjects.py
File: gateway/modules/interfaces/interfaceDbAppObjects.py
# Insert after resetUserPassword() method
def generateResetTokenAndExpiry(self) -> tuple[str, float]:
"""Generate a new reset token and expiration timestamp.
Returns:
tuple: (token_uuid, expires_timestamp_float)
"""
import uuid
token = str(uuid.uuid4())
expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
expires = getUtcTimestamp() + (expiryHours * 3600)
return token, expires
def findUserByEmailLocalAuth(self, email: str) -> Optional[User]:
"""Find LOCAL auth user by email (searches across all mandates).
Args:
email: Email address to search for (case-insensitive)
Returns:
User if found, None otherwise
"""
if not email:
return None
normalizedEmail = email.lower().strip()
try:
# Use root interface to bypass RBAC for cross-mandate search
users = self.db.getRecordset(
UserInDB,
recordFilter={
"email": normalizedEmail,
"authenticationAuthority": AuthAuthority.LOCAL.value
}
)
if users:
cleanedUser = {k: v for k, v in users[0].items() if not k.startswith("_")}
if cleanedUser.get("roleLabels") is None:
cleanedUser["roleLabels"] = []
return User(**cleanedUser)
return None
except Exception as e:
logger.error(f"Error finding user by email: {str(e)}")
return None
def setResetToken(self, userId: str, token: str, expires: float) -> bool:
"""Set reset token for a user (clears password hash)."""
try:
self.db.recordModify(UserInDB, userId, {
"resetToken": token,
"resetTokenExpires": expires,
"hashedPassword": None # Clear password during reset flow
})
return True
except Exception as e:
logger.error(f"Error setting reset token for user {userId}: {str(e)}")
return False
def verifyResetToken(self, token: str) -> Optional[User]:
"""Verify reset token and return user if valid.
Returns:
User if token is valid and not expired, None otherwise
"""
if not token:
return None
try:
users = self.db.getRecordset(UserInDB, recordFilter={"resetToken": token})
if not users:
return None
userRecord = users[0]
# Check expiration
expires = userRecord.get("resetTokenExpires")
if not expires or getUtcTimestamp() > expires:
logger.warning(f"Reset token expired for user {userRecord.get('id')}")
return None
cleanedUser = {k: v for k, v in userRecord.items() if not k.startswith("_")}
if cleanedUser.get("roleLabels") is None:
cleanedUser["roleLabels"] = []
return User(**cleanedUser)
except Exception as e:
logger.error(f"Error verifying reset token: {str(e)}")
return None
def resetPasswordWithToken(self, token: str, newPassword: str) -> bool:
"""Reset password using token (atomic operation).
Returns:
True if successful, False otherwise
"""
try:
user = self.verifyResetToken(token)
if not user:
return False
hashedPassword = self._getPasswordHash(newPassword)
# Atomic update: set password, clear token, enable user
self.db.recordModify(UserInDB, user.id, {
"hashedPassword": hashedPassword,
"resetToken": None,
"resetTokenExpires": None,
"enabled": True
})
logger.info(f"Password reset completed for user {user.id}")
return True
except Exception as e:
logger.error(f"Error in resetPasswordWithToken: {str(e)}")
return False
Phase 3: Backend Endpoints
3.1 Add Configuration
File: gateway/config.ini
Add at end of Auth section:
# Reset token configuration
Auth_RESET_TOKEN_EXPIRY_HOURS = 24
# Frontend URL for magic links
Frontend_BASE_URL = https://playground.poweron.swiss
3.2 Add Password Reset Endpoints
File: gateway/modules/routes/routeSecurityLocal.py
Add after /available endpoint (after line 435):
@router.post("/password-reset-request")
@limiter.limit("5/minute")
async def passwordResetRequest(
request: Request,
email: str = Body(..., embed=True)
) -> Dict[str, Any]:
"""Request password reset email."""
try:
rootInterface = getRootInterface()
# Normalize email
normalizedEmail = email.lower().strip()
# Find user (don't reveal if found or not)
user = rootInterface.findUserByEmailLocalAuth(normalizedEmail)
if user:
# Generate reset token
token, expires = rootInterface.generateResetTokenAndExpiry()
# Set reset token (clears password)
rootInterface.setResetToken(user.id, token, expires)
# Get services for email sending
from modules.services import Services
services = Services(user)
# Generate magic link
frontendUrl = APP_CONFIG.get("Frontend_BASE_URL", "https://playground.poweron.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=Falsetoenabled=Trueon line 225 - Add reset token generation and email sending
Phase 4: Frontend Changes
4.1 Modify login.html
Add after line 34 (after submit button, before register-options):
<div class="password-reset-link" style="margin-top: 10px; text-align: center;">
<a href="password-reset-request.html" class="btn-link">
<i class="fas fa-key"></i> Passwort vergessen?
</a>
</div>
4.2 Modify register.html
Remove lines 23-31 (password and confirm-password fields) Add info message after form (before submit button):
<div class="registration-info" style="margin: 15px 0; padding: 10px; background: #e8f4fd; border-radius: 4px;">
<p style="margin: 0;">Nach der Registrierung erhalten Sie eine E-Mail mit einem Link zum Setzen Ihres Passworts.</p>
</div>
4.3 Add to apiCalls.js
Add after register function (after line 379):
requestPasswordReset: async function(email) {
try {
return await privateApi.post('/api/local/password-reset-request', { email });
} catch (error) {
ui.log.error('Password reset request error:', error);
throw error;
}
},
resetPassword: async function(token, password) {
try {
return await privateApi.post('/api/local/password-reset', { token, password });
} catch (error) {
ui.log.error('Password reset error:', error);
throw error;
}
},
4.4 Create New Files
password-reset-request.html- Seedoc_userauth_ui_adaptations.mdreset.html- Seedoc_userauth_ui_adaptations.mdjs/security/passwordResetRequest.jsjs/security/reset.js
Phase 5: Authentication Logic Update
File: gateway/modules/interfaces/interfaceDbAppObjects.py
Modify authenticateLocalUser() (lines 570-596) - Add reset token check:
# After line 591 (after hashedPassword check), add:
if userRecord.get("resetToken"):
raise ValueError("Passwort-Zurücksetzung erforderlich. Bitte prüfen Sie Ihre E-Mail.")
Testing Scenarios
Registration Flow
- ✅ User registers without password → User created with reset token
- ✅ Email uniqueness checked (reject if email already used by LOCAL auth user)
- ✅ Email sent with magic link
- ✅ User cannot login before setting password
- ✅ Magic link expires after 24 hours
- ✅ User can set password via magic link
- ✅ User can login immediately after password reset (enabled=True)
- ✅ No admin activation required
Password Reset Flow
- ✅ User clicks "Password Reset" button on login page
- ✅ User navigates to password-reset-request.html
- ✅ User enters email → System searches across all mandates for LOCAL auth user
- ✅ If user found: Reset token generated and email sent
- ✅ If user not found: No email sent, but generic success message shown
- ✅ User cannot login with old password (if existed)
- ✅ User sets new password via magic link
- ✅ User can login immediately after password reset (enabled=True)
- ✅ No admin activation required
Login Blocking
- ✅ User with no password cannot login
- ✅ User with reset token cannot login (even if expired token exists)
- ✅ User with valid password can login (if enabled=True)
- ✅ User with expired reset token cannot login
- ✅ User with reset token cannot login even if passwordHash exists (reset token takes precedence)
Additional Security Tests
- ✅ Multiple reset requests invalidate old tokens
- ✅ Token reuse attempt fails (token cleared after use)
- ✅ Email case variations handled (normalized to lowercase)
- ✅ Rate limiting prevents abuse
- ✅ Atomic operations prevent partial updates
- ✅ Expired tokens are rejected
- ✅ Invalid token formats are rejected
- ✅ 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 passwordverify_reset_token()(line 422): Validates tokenset_new_password()(line 438): Sets password and clears tokenis_user_in_password_reset()(line 504): Checks reset state
-
server/core/authentication.py:verify_password()(line 38): Checks for empty password hashauthenticate_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_tokenVARCHAR(36) NULL (UUID format)reset_token_expiresDOUBLE PRECISION NULL (UTC timestamp float in seconds)
- Both fields default to NULL
- No data migration required
Configuration Variables
New Config Entries
Backend (gateway/config.ini):
# Auth configuration
Auth_RESET_TOKEN_EXPIRY_HOURS = 24
Frontend (frontend_agents/public/config/env_*.env):
# Frontend URL for magic link generation (add to each env file)
APP_FRONTEND_URL = https://your-frontend-domain.com
# Examples:
# env_int.env: APP_FRONTEND_URL = https://playground-int.poweron.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
- Rate limit:
-
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
- Rate limit:
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
- Review and approve this concept document
- Create detailed implementation tasks
- Implement backend changes (data model, endpoints, authentication logic)
- Implement frontend changes (UI, API calls, reset page)
- Integrate email sending (messaging service)
- Test all flows (registration, reset request, reset, login blocking)
- Update documentation (user guide, admin guide)
Code Reuse Strategy
Shared Functions - Frontend
- 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)
- Registration page (
- Validates email format using regex
- Create
Shared Functions - Backend
-
Email Uniqueness Check (
gateway/modules/interfaces/interfaceDbAppObjects.py)- Create
checkEmailUniquenessForLocalAuth(email)method - Searches across ALL mandates for LOCAL auth users with same email
- Returns:
Trueif email is unique,Falseif already exists - Reused by:
- Registration endpoint (reject if email exists)
- Could be used for future email change validation
- Create
-
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
- Create
-
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
- Create
-
Email Sending (
gateway/modules/interfaces/interfaceDbAppObjects.pyor separate utility module)- Create
sendPasswordResetEmail(user, token, emailType)function - Parameters:
user: User object (for fullName, email)token: UUID token stringemailType: "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")
- Create
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
- Email Language: German (matches current UI language in
login.html,register.html) - Token Expiry: 24 hours (configurable via
Auth_RESET_TOKEN_EXPIRY_HOURS) - Rate Limits:
- Password reset request: 5/minute (stricter to prevent abuse)
- Password reset: 10/minute
- Registration: 10/minute (existing)
- Error Messages: Generic for security (don't reveal if email exists)
- Password cleared on reset request: Yes (user must set new password via link)