Implements Phase 2 infrastructure for participant registration and authentication: Database Models: - Add Participant model with exchange scoping and soft deletes - Add MagicToken model for passwordless authentication - Add participants relationship to Exchange model - Include proper indexes and foreign key constraints Migration Infrastructure: - Generate Alembic migration for new models - Create entrypoint.sh script for automatic migrations on container startup - Update Containerfile to use entrypoint script and include uv binary - Remove db.create_all() in favor of migration-based schema management This establishes the foundation for implementing stories 4.1-4.3, 5.1-5.3, and 10.1. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1120 lines
34 KiB
Markdown
1120 lines
34 KiB
Markdown
# Participant Authentication Component - v0.2.0
|
|
|
|
**Version**: 0.2.0
|
|
**Date**: 2025-12-22
|
|
**Status**: Phase 2 Design
|
|
|
|
## Overview
|
|
|
|
This document details the participant authentication system for Sneaky Klaus, focusing on the magic link flow, returning participant detection, and participant session management. This component enables passwordless authentication for participants, allowing them to access their exchange information without creating traditional accounts.
|
|
|
|
## Requirements
|
|
|
|
### Functional Requirements
|
|
|
|
1. **Registration with Auto-Auth**: New participants register and receive a magic link immediately
|
|
2. **Returning Participant Detection**: System detects when a participant already exists by email
|
|
3. **Magic Link Generation**: Create secure, time-limited authentication tokens
|
|
4. **Magic Link Validation**: Validate tokens and create participant sessions
|
|
5. **Session Persistence**: Maintain participant sessions with 7-day sliding window
|
|
6. **Multiple Exchange Support**: Participants can be in multiple exchanges with the same email
|
|
|
|
### Non-Functional Requirements
|
|
|
|
1. **Security**: Magic links must be cryptographically secure and single-use
|
|
2. **Usability**: Minimal friction for participants to access their information
|
|
3. **Email Delivery**: Reliable delivery of magic links via Resend
|
|
4. **Rate Limiting**: Prevent abuse of magic link generation
|
|
|
|
## Architecture
|
|
|
|
### Component Diagram
|
|
|
|
```mermaid
|
|
flowchart TB
|
|
subgraph "Participant Facing"
|
|
RegPage[Registration Page]
|
|
AccessLink[Request Access Link]
|
|
SuccessPage[Success Page]
|
|
end
|
|
|
|
subgraph "Magic Link Flow"
|
|
EmailClick[Click Magic Link]
|
|
TokenValidation[Token Validation]
|
|
SessionCreation[Session Creation]
|
|
Dashboard[Participant Dashboard]
|
|
end
|
|
|
|
subgraph "Services"
|
|
AuthService[Participant Auth Service]
|
|
TokenService[Magic Token Service]
|
|
EmailService[Notification Service]
|
|
end
|
|
|
|
subgraph "Data Layer"
|
|
ParticipantDB[(Participant Table)]
|
|
TokenDB[(Magic Token Table)]
|
|
SessionDB[(Session Store)]
|
|
end
|
|
|
|
RegPage --> AuthService
|
|
AccessLink --> AuthService
|
|
AuthService --> TokenService
|
|
TokenService --> TokenDB
|
|
AuthService --> EmailService
|
|
EmailService --> |Email with link| EmailClick
|
|
EmailClick --> TokenValidation
|
|
TokenValidation --> TokenService
|
|
TokenValidation --> SessionCreation
|
|
SessionCreation --> SessionDB
|
|
SessionCreation --> Dashboard
|
|
AuthService --> ParticipantDB
|
|
```
|
|
|
|
## Registration Flow
|
|
|
|
### New Participant Registration
|
|
|
|
**Sequence Diagram**:
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant P as Participant
|
|
participant Browser as Browser
|
|
participant App as Flask App
|
|
participant DB as Database
|
|
participant Email as Resend
|
|
|
|
P->>Browser: Navigate to /exchange/{slug}/register
|
|
Browser->>App: GET /exchange/{slug}/register
|
|
App->>DB: Query exchange by slug
|
|
DB-->>App: Exchange details
|
|
App-->>Browser: Render registration form
|
|
|
|
P->>Browser: Fill form (name, email, gift ideas)
|
|
Browser->>App: POST /exchange/{slug}/register
|
|
App->>App: Validate form (WTForms)
|
|
App->>DB: Check email exists in exchange
|
|
|
|
alt Email already registered
|
|
DB-->>App: Participant found
|
|
App-->>Browser: Flash error + show "Request Access" option
|
|
else New participant
|
|
DB-->>App: Email not found
|
|
App->>DB: INSERT INTO participant
|
|
DB-->>App: Participant created (id)
|
|
App->>App: Generate magic token
|
|
App->>DB: INSERT INTO magic_token
|
|
App->>Email: Send registration confirmation with magic link
|
|
Email-->>P: Confirmation email
|
|
App-->>Browser: Redirect to success page
|
|
Browser-->>P: "Check your email" message
|
|
end
|
|
```
|
|
|
|
**Implementation Details**:
|
|
|
|
```python
|
|
def register_participant(exchange_slug: str, form_data: dict) -> RegistrationResult:
|
|
"""
|
|
Register new participant for exchange.
|
|
|
|
Args:
|
|
exchange_slug: Exchange URL slug
|
|
form_data: Registration form data (name, email, gift_ideas, reminder_enabled)
|
|
|
|
Returns:
|
|
RegistrationResult with success status and participant ID
|
|
|
|
Raises:
|
|
EmailAlreadyRegistered: If email already exists in this exchange
|
|
RegistrationClosed: If exchange not in registration_open state
|
|
ExchangeFull: If participant count >= max_participants
|
|
"""
|
|
# 1. Load exchange by slug
|
|
exchange = Exchange.query.filter_by(slug=exchange_slug).first_or_404()
|
|
|
|
# 2. Validate exchange state
|
|
if exchange.state != 'registration_open':
|
|
raise RegistrationClosed("Registration is not currently open")
|
|
|
|
# 3. Check participant count
|
|
participant_count = Participant.query.filter_by(
|
|
exchange_id=exchange.id,
|
|
withdrawn_at=None
|
|
).count()
|
|
|
|
if participant_count >= exchange.max_participants:
|
|
raise ExchangeFull("This exchange has reached maximum capacity")
|
|
|
|
# 4. Check email uniqueness within exchange
|
|
email = form_data['email'].lower().strip()
|
|
existing = Participant.query.filter_by(
|
|
exchange_id=exchange.id,
|
|
email=email,
|
|
withdrawn_at=None
|
|
).first()
|
|
|
|
if existing:
|
|
raise EmailAlreadyRegistered("This email is already registered for this exchange")
|
|
|
|
# 5. Create participant record
|
|
participant = Participant(
|
|
exchange_id=exchange.id,
|
|
name=form_data['name'].strip(),
|
|
email=email,
|
|
gift_ideas=form_data.get('gift_ideas', '').strip(),
|
|
reminder_enabled=form_data.get('reminder_enabled', True)
|
|
)
|
|
db.session.add(participant)
|
|
db.session.flush() # Get participant.id
|
|
|
|
# 6. Generate magic token
|
|
token = generate_magic_token(participant.id, exchange.id)
|
|
|
|
# 7. Send confirmation email
|
|
notification_service.send_registration_confirmation(
|
|
participant_id=participant.id,
|
|
token=token
|
|
)
|
|
|
|
# 8. Commit transaction
|
|
db.session.commit()
|
|
|
|
return RegistrationResult(
|
|
success=True,
|
|
participant_id=participant.id,
|
|
email=email
|
|
)
|
|
```
|
|
|
|
## Returning Participant Detection
|
|
|
|
### Request Access Flow
|
|
|
|
**Purpose**: Allow returning participants to request a new magic link without re-registering.
|
|
|
|
**User Experience**:
|
|
1. Participant visits registration page
|
|
2. Sees option: "Already registered? Request access link"
|
|
3. Clicks link, enters email
|
|
4. Receives magic link if email is registered
|
|
|
|
**Sequence Diagram**:
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant P as Participant
|
|
participant Browser as Browser
|
|
participant App as Flask App
|
|
participant RateLimit as Rate Limiter
|
|
participant DB as Database
|
|
participant Email as Resend
|
|
|
|
P->>Browser: Click "Request Access"
|
|
Browser->>App: GET /exchange/{slug}/register (shows request form)
|
|
|
|
P->>Browser: Enter email
|
|
Browser->>App: POST /exchange/{slug}/request-access
|
|
App->>App: Validate email format
|
|
App->>RateLimit: Check rate limit (3/hour per email)
|
|
|
|
alt Rate limit exceeded
|
|
RateLimit-->>App: Limit exceeded
|
|
App-->>Browser: Flash error (429)
|
|
else Within limit
|
|
RateLimit-->>App: Allowed
|
|
App->>DB: Query participant by email + exchange_id
|
|
|
|
alt Participant found
|
|
DB-->>App: Participant record
|
|
App->>App: Generate magic token
|
|
App->>DB: INSERT INTO magic_token
|
|
App->>Email: Send magic link email
|
|
Email-->>P: Magic link email
|
|
App->>RateLimit: Increment counter
|
|
else Participant not found
|
|
DB-->>App: Not found
|
|
App->>App: Silent success (no email sent)
|
|
Note over App: Same timing to prevent enumeration
|
|
end
|
|
|
|
App-->>Browser: Generic success message
|
|
Browser-->>P: "If registered, check your email"
|
|
end
|
|
```
|
|
|
|
**Implementation Details**:
|
|
|
|
```python
|
|
def request_magic_link(exchange_slug: str, email: str) -> RequestResult:
|
|
"""
|
|
Request magic link for existing participant.
|
|
|
|
Args:
|
|
exchange_slug: Exchange URL slug
|
|
email: Participant email
|
|
|
|
Returns:
|
|
RequestResult (always success to prevent enumeration)
|
|
|
|
Raises:
|
|
RateLimitExceeded: If too many requests from this email
|
|
"""
|
|
# 1. Check rate limit
|
|
rate_limit_key = f"magic_link:{email.lower()}"
|
|
if not check_rate_limit(rate_limit_key, max_attempts=3, window_hours=1):
|
|
raise RateLimitExceeded("Too many requests. Try again later.")
|
|
|
|
# 2. Load exchange
|
|
exchange = Exchange.query.filter_by(slug=exchange_slug).first_or_404()
|
|
|
|
# 3. Query participant
|
|
email_normalized = email.lower().strip()
|
|
participant = Participant.query.filter_by(
|
|
exchange_id=exchange.id,
|
|
email=email_normalized,
|
|
withdrawn_at=None # Don't send to withdrawn participants
|
|
).first()
|
|
|
|
# 4. If participant exists, send magic link
|
|
if participant:
|
|
# Generate token
|
|
token = generate_magic_token(participant.id, exchange.id)
|
|
|
|
# Send email
|
|
notification_service.send_magic_link(
|
|
participant_id=participant.id,
|
|
token=token
|
|
)
|
|
|
|
# Increment rate limit
|
|
increment_rate_limit(rate_limit_key)
|
|
|
|
# 5. Always return success (prevent email enumeration)
|
|
# Timing should be consistent whether participant exists or not
|
|
return RequestResult(
|
|
success=True,
|
|
message="If your email is registered, you'll receive an access link."
|
|
)
|
|
```
|
|
|
|
**Security Considerations**:
|
|
- Generic success message prevents email enumeration
|
|
- Same response time whether email exists or not
|
|
- Rate limiting prevents brute force email discovery
|
|
- Withdrawn participants cannot request new links
|
|
|
|
## Magic Token Service
|
|
|
|
### Token Generation
|
|
|
|
**Token Format**:
|
|
- 32 bytes of cryptographic randomness
|
|
- Base64url encoded (43 characters)
|
|
- Example: `k7Jx9mP2qR4sT6vN8bC1dE3fG5hI0jK7lM9nO2pQ4rS6tU8vW1xY3zA5`
|
|
|
|
**Token Storage**:
|
|
- Only SHA-256 hash stored in database
|
|
- Original token included in magic link URL
|
|
- Token expires after 1 hour
|
|
- Single-use only
|
|
|
|
**Implementation**:
|
|
|
|
```python
|
|
import secrets
|
|
import hashlib
|
|
from datetime import datetime, timedelta
|
|
from dataclasses import dataclass
|
|
|
|
@dataclass
|
|
class MagicToken:
|
|
"""Magic token with original value and hash."""
|
|
token: str # Original token (for URL)
|
|
token_hash: str # SHA-256 hash (for storage)
|
|
expires_at: datetime
|
|
|
|
def generate_magic_token(participant_id: int, exchange_id: int) -> str:
|
|
"""
|
|
Generate magic link token for participant.
|
|
|
|
Args:
|
|
participant_id: Participant ID
|
|
exchange_id: Exchange ID
|
|
|
|
Returns:
|
|
Original token string (to be included in email URL)
|
|
"""
|
|
# 1. Generate cryptographically random token
|
|
token = secrets.token_urlsafe(32) # 32 bytes = 43 URL-safe characters
|
|
|
|
# 2. Hash token for storage
|
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
|
|
# 3. Calculate expiration (1 hour from now)
|
|
expires_at = datetime.utcnow() + timedelta(hours=1)
|
|
|
|
# 4. Load participant to get email
|
|
participant = Participant.query.get(participant_id)
|
|
|
|
# 5. Store token hash in database
|
|
magic_token = MagicTokenModel(
|
|
token_hash=token_hash,
|
|
token_type='magic_link',
|
|
email=participant.email,
|
|
participant_id=participant_id,
|
|
exchange_id=exchange_id,
|
|
expires_at=expires_at
|
|
)
|
|
db.session.add(magic_token)
|
|
db.session.commit()
|
|
|
|
# 6. Return original token (for URL)
|
|
return token
|
|
```
|
|
|
|
### Token Validation
|
|
|
|
**Validation Flow**:
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
Start[Receive token from URL] --> Hash[Hash token with SHA-256]
|
|
Hash --> Query[Query magic_token by hash]
|
|
Query --> Exists{Token exists?}
|
|
|
|
Exists -->|No| Invalid[Return: Invalid token]
|
|
Exists -->|Yes| CheckExpiry{Expired?}
|
|
|
|
CheckExpiry -->|Yes| Invalid
|
|
CheckExpiry -->|No| CheckUsed{Already used?}
|
|
|
|
CheckUsed -->|Yes| Invalid
|
|
CheckUsed -->|No| CheckType{Type = magic_link?}
|
|
|
|
CheckType -->|No| Invalid
|
|
CheckType -->|Yes| Valid[Mark token as used]
|
|
|
|
Valid --> LoadParticipant[Load participant]
|
|
LoadParticipant --> CreateSession[Create participant session]
|
|
CreateSession --> Success[Return: Success + session]
|
|
```
|
|
|
|
**Implementation**:
|
|
|
|
```python
|
|
from typing import Optional
|
|
|
|
@dataclass
|
|
class TokenValidationResult:
|
|
"""Result of token validation."""
|
|
valid: bool
|
|
participant_id: Optional[int] = None
|
|
exchange_id: Optional[int] = None
|
|
error: Optional[str] = None
|
|
|
|
def validate_magic_token(token: str) -> TokenValidationResult:
|
|
"""
|
|
Validate magic link token and return participant info.
|
|
|
|
Args:
|
|
token: Original token from URL
|
|
|
|
Returns:
|
|
TokenValidationResult with validity and participant info
|
|
"""
|
|
# 1. Hash the token
|
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
|
|
# 2. Query token by hash
|
|
magic_token = MagicTokenModel.query.filter_by(
|
|
token_hash=token_hash,
|
|
token_type='magic_link'
|
|
).first()
|
|
|
|
# 3. Validate token exists
|
|
if not magic_token:
|
|
return TokenValidationResult(
|
|
valid=False,
|
|
error="Invalid or expired token"
|
|
)
|
|
|
|
# 4. Check expiration
|
|
if magic_token.expires_at < datetime.utcnow():
|
|
return TokenValidationResult(
|
|
valid=False,
|
|
error="Token has expired"
|
|
)
|
|
|
|
# 5. Check if already used
|
|
if magic_token.used_at is not None:
|
|
return TokenValidationResult(
|
|
valid=False,
|
|
error="Token has already been used"
|
|
)
|
|
|
|
# 6. Mark token as used (single-use enforcement)
|
|
magic_token.used_at = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
# 7. Return success with participant info
|
|
return TokenValidationResult(
|
|
valid=True,
|
|
participant_id=magic_token.participant_id,
|
|
exchange_id=magic_token.exchange_id
|
|
)
|
|
```
|
|
|
|
## Session Management
|
|
|
|
### Participant Session Creation
|
|
|
|
**Session Data Structure**:
|
|
|
|
```python
|
|
{
|
|
'user_id': 123, # Participant ID
|
|
'user_type': 'participant',
|
|
'exchange_id': 456, # Exchange ID for this session
|
|
'_fresh': True,
|
|
'_permanent': True
|
|
}
|
|
```
|
|
|
|
**Session Configuration**:
|
|
- Backend: Flask-Session with SQLAlchemy
|
|
- Duration: 7 days (sliding window)
|
|
- Cookie flags: HttpOnly, Secure, SameSite=Lax
|
|
- Session extends on each request (sliding window)
|
|
|
|
**Implementation**:
|
|
|
|
```python
|
|
from flask import session
|
|
from datetime import timedelta
|
|
|
|
def create_participant_session(participant_id: int, exchange_id: int):
|
|
"""
|
|
Create Flask session for participant.
|
|
|
|
Args:
|
|
participant_id: Participant ID
|
|
exchange_id: Exchange ID
|
|
"""
|
|
# Clear any existing session
|
|
session.clear()
|
|
|
|
# Set session data
|
|
session['user_id'] = participant_id
|
|
session['user_type'] = 'participant'
|
|
session['exchange_id'] = exchange_id
|
|
session.permanent = True # Enable persistent session
|
|
|
|
# Set session lifetime (7 days)
|
|
app.permanent_session_lifetime = timedelta(days=7)
|
|
|
|
# Flask-Session automatically stores in database
|
|
```
|
|
|
|
### Session Validation Decorator
|
|
|
|
**Purpose**: Protect participant routes and ensure participant has access to the exchange.
|
|
|
|
**Implementation**:
|
|
|
|
```python
|
|
from functools import wraps
|
|
from flask import session, redirect, url_for, flash, g
|
|
|
|
def participant_required(f):
|
|
"""
|
|
Decorator to require participant authentication.
|
|
Validates session and loads participant into g.participant.
|
|
"""
|
|
@wraps(f)
|
|
def decorated_function(*args, **kwargs):
|
|
# Check session exists
|
|
if 'user_id' not in session or session.get('user_type') != 'participant':
|
|
flash("You must be logged in to access this page.", "error")
|
|
return redirect(url_for('public.landing'))
|
|
|
|
# Load participant
|
|
participant = Participant.query.get(session['user_id'])
|
|
if not participant or participant.withdrawn_at is not None:
|
|
session.clear()
|
|
flash("Your session is invalid. Please request a new access link.", "error")
|
|
return redirect(url_for('public.landing'))
|
|
|
|
# Store in request context
|
|
g.participant = participant
|
|
g.exchange_id = session.get('exchange_id')
|
|
|
|
return f(*args, **kwargs)
|
|
|
|
return decorated_function
|
|
|
|
def exchange_access_required(f):
|
|
"""
|
|
Decorator to require participant access to specific exchange.
|
|
Must be used after @participant_required.
|
|
"""
|
|
@wraps(f)
|
|
def decorated_function(exchange_id, *args, **kwargs):
|
|
# Check participant has access to this exchange
|
|
if g.participant.exchange_id != exchange_id:
|
|
flash("You don't have access to this exchange.", "error")
|
|
return redirect(url_for('participant.dashboard'))
|
|
|
|
return f(exchange_id, *args, **kwargs)
|
|
|
|
return decorated_function
|
|
```
|
|
|
|
**Usage Example**:
|
|
|
|
```python
|
|
@app.route('/participant/exchange/<int:exchange_id>')
|
|
@participant_required
|
|
@exchange_access_required
|
|
def view_exchange(exchange_id):
|
|
"""View exchange details (participant must be in this exchange)."""
|
|
exchange = Exchange.query.get_or_404(exchange_id)
|
|
return render_template('participant/exchange_detail.html', exchange=exchange)
|
|
```
|
|
|
|
## Development Mode
|
|
|
|
### When Development Mode is Active
|
|
|
|
Development mode email logging is automatically enabled when the application runs with `FLASK_ENV=development`. This allows QA and developers to test authentication flows without requiring email infrastructure.
|
|
|
|
**Activation Condition**: `FLASK_ENV=development`
|
|
|
|
### What Gets Logged
|
|
|
|
When a magic link is generated in development mode, the complete magic link URL (including the token) is logged to the application logs at INFO level:
|
|
|
|
```
|
|
[INFO] DEV MODE: Magic link generated for participant alice@example.com
|
|
[INFO] DEV MODE: Full magic link URL: https://sneaky-klaus.local/auth/participant/magic/k7Jx9mP2qR4sT6vN8bC1dE3fG5hI0jK7lM9nO2pQ4rS6tU8vW1xY3zA5
|
|
```
|
|
|
|
### How QA Retrieves Links
|
|
|
|
#### For Podman Containers
|
|
|
|
```bash
|
|
# After registering a participant, retrieve the magic link from container logs
|
|
podman logs sneaky-klaus-qa | grep "DEV MODE"
|
|
|
|
# Find the "Full magic link URL" line and copy the complete URL
|
|
# Paste into browser or use with curl:
|
|
curl https://sneaky-klaus.local/auth/participant/magic/k7Jx9mP2qR4sT6vN8bC1dE3fG5hI0jK7lM9nO2pQ4rS6tU8vW1xY3zA5
|
|
```
|
|
|
|
#### For Local Development
|
|
|
|
```bash
|
|
# Run the application with FLASK_ENV=development
|
|
FLASK_ENV=development uv run flask run
|
|
|
|
# Magic links appear in terminal output when participants register
|
|
# Copy the full URL from the "DEV MODE: Full magic link URL:" line
|
|
```
|
|
|
|
### Development Workflow Example
|
|
|
|
**Scenario**: QA testing participant registration and authentication without email service.
|
|
|
|
1. **Start Application**: Run with `FLASK_ENV=development`
|
|
2. **Register Participant**:
|
|
- Navigate to exchange registration page
|
|
- Fill in name, email, gift ideas
|
|
- Submit form
|
|
3. **Retrieve Magic Link**:
|
|
- Check application logs using `podman logs` or terminal output
|
|
- Find the line: `DEV MODE: Full magic link URL: https://...`
|
|
- Copy the complete URL
|
|
4. **Authenticate**:
|
|
- Paste URL into browser or use with curl
|
|
- Magic link validates the token and creates session
|
|
- Redirected to participant dashboard
|
|
5. **Repeat**: For each test participant/exchange combination, repeat steps 2-4
|
|
|
|
### Security Warning
|
|
|
|
**CRITICAL**: This feature is **ONLY** enabled in development mode (`FLASK_ENV=development`).
|
|
|
|
**This must NEVER be enabled in production.**
|
|
|
|
Logging magic links (which contain cryptographic tokens) to application logs would be a severe security vulnerability in production. The application includes a runtime check that ensures this feature remains disabled unless explicitly running with `FLASK_ENV=development`.
|
|
|
|
**Production environments**:
|
|
- Set `FLASK_ENV=production` or leave unset
|
|
- Magic links are never logged
|
|
- Only transmitted via email
|
|
|
|
### Implementation
|
|
|
|
Magic link logging is implemented in the notification service:
|
|
|
|
```python
|
|
def send_registration_confirmation(participant_id: int, token: str):
|
|
"""Send registration confirmation email with magic link."""
|
|
# ... build magic_link_url ...
|
|
|
|
# Development mode: log the link for QA access
|
|
if should_log_dev_mode_links(): # Only True when FLASK_ENV=development
|
|
logger.info(f"DEV MODE: Magic link generated for participant {participant.email}")
|
|
logger.info(f"DEV MODE: Full magic link URL: {magic_link_url}")
|
|
|
|
# Send email (if available)
|
|
try:
|
|
send_email(...)
|
|
except EmailServiceUnavailable:
|
|
# In development, logging fallback ensures testing can proceed
|
|
logger.info(f"Email service unavailable, but link logged for DEV MODE testing")
|
|
```
|
|
|
|
The `should_log_dev_mode_links()` function checks `FLASK_ENV` at runtime:
|
|
|
|
```python
|
|
def should_log_dev_mode_links() -> bool:
|
|
"""
|
|
Check if development mode email logging is enabled.
|
|
|
|
CRITICAL: This must NEVER be True in production.
|
|
Only enable when explicitly running with FLASK_ENV=development.
|
|
"""
|
|
env = os.environ.get('FLASK_ENV', '').lower()
|
|
return env == 'development'
|
|
```
|
|
|
|
## Multiple Exchange Support
|
|
|
|
### Challenge
|
|
|
|
A participant may register for multiple exchanges using the same email address. Each magic link session is scoped to a single exchange.
|
|
|
|
**Participant Data Model**:
|
|
- Separate `Participant` record for each exchange
|
|
- Same email can appear in multiple exchanges
|
|
- Each participant record has unique ID
|
|
|
|
**Session Scoping**:
|
|
- Session includes `exchange_id`
|
|
- Participant can only view data for the exchange in their current session
|
|
- To access different exchange, must authenticate via that exchange's magic link
|
|
|
|
**Example Scenario**:
|
|
1. Alice registers for "Family Christmas" with alice@example.com
|
|
2. Alice registers for "Office Party" with alice@example.com
|
|
3. Two separate `Participant` records created (different IDs)
|
|
4. Magic link for "Family Christmas" creates session with exchange_id=1
|
|
5. Magic link for "Office Party" creates session with exchange_id=2
|
|
6. Sessions are independent; Alice must use appropriate magic link for each
|
|
|
|
**Implementation Note**:
|
|
```python
|
|
# Two separate participant records
|
|
family_participant = Participant(
|
|
id=100,
|
|
exchange_id=1, # Family Christmas
|
|
email="alice@example.com",
|
|
name="Alice"
|
|
)
|
|
|
|
office_participant = Participant(
|
|
id=200,
|
|
exchange_id=2, # Office Party
|
|
email="alice@example.com",
|
|
name="Alice"
|
|
)
|
|
|
|
# Magic link for Family Christmas creates session:
|
|
# session['user_id'] = 100
|
|
# session['exchange_id'] = 1
|
|
|
|
# Magic link for Office Party creates session:
|
|
# session['user_id'] = 200
|
|
# session['exchange_id'] = 2
|
|
```
|
|
|
|
## Email Templates
|
|
|
|
### Registration Confirmation Email
|
|
|
|
**Template**: `templates/emails/participant/registration_confirmation.html`
|
|
|
|
**Subject**: "Welcome to {exchange_name}!"
|
|
|
|
**Variables**:
|
|
```python
|
|
{
|
|
"participant_name": str,
|
|
"exchange_name": str,
|
|
"exchange_date": datetime,
|
|
"budget": str,
|
|
"magic_link_url": str
|
|
}
|
|
```
|
|
|
|
**Key Content**:
|
|
- Welcome message
|
|
- Exchange details (date, budget)
|
|
- Magic link for access
|
|
- Expiration notice (1 hour)
|
|
|
|
### Magic Link Email
|
|
|
|
**Template**: `templates/emails/participant/magic_link.html`
|
|
|
|
**Subject**: "Access Your Sneaky Klaus Registration"
|
|
|
|
**Variables**:
|
|
```python
|
|
{
|
|
"participant_name": str,
|
|
"exchange_name": str,
|
|
"magic_link_url": str,
|
|
"expiration_minutes": int # 60
|
|
}
|
|
```
|
|
|
|
**Key Content**:
|
|
- Access link request confirmation
|
|
- Prominent magic link button
|
|
- Expiration warning
|
|
- Security note (didn't request? ignore)
|
|
|
|
## Rate Limiting
|
|
|
|
### Policies
|
|
|
|
| Endpoint | Limit | Window | Key |
|
|
|----------|-------|--------|-----|
|
|
| POST /exchange/{slug}/register | 10 attempts | 1 hour | IP address |
|
|
| POST /exchange/{slug}/request-access | 3 requests | 1 hour | email |
|
|
|
|
### Implementation
|
|
|
|
**Rate Limit Service**:
|
|
|
|
```python
|
|
from datetime import datetime, timedelta
|
|
|
|
def check_rate_limit(key: str, max_attempts: int, window_hours: int) -> bool:
|
|
"""
|
|
Check if rate limit allows another attempt.
|
|
|
|
Args:
|
|
key: Rate limit key (e.g., "magic_link:alice@example.com")
|
|
max_attempts: Maximum attempts allowed in window
|
|
window_hours: Time window in hours
|
|
|
|
Returns:
|
|
True if allowed, False if limit exceeded
|
|
"""
|
|
# Query or create rate limit record
|
|
rate_limit = RateLimit.query.filter_by(key=key).first()
|
|
|
|
now = datetime.utcnow()
|
|
window_start = now - timedelta(hours=window_hours)
|
|
|
|
if not rate_limit:
|
|
# First attempt, create record
|
|
rate_limit = RateLimit(
|
|
key=key,
|
|
attempts=0,
|
|
window_start=now,
|
|
expires_at=now + timedelta(hours=window_hours)
|
|
)
|
|
db.session.add(rate_limit)
|
|
db.session.commit()
|
|
return True
|
|
|
|
# Check if window has expired
|
|
if rate_limit.window_start < window_start:
|
|
# Reset window
|
|
rate_limit.attempts = 0
|
|
rate_limit.window_start = now
|
|
rate_limit.expires_at = now + timedelta(hours=window_hours)
|
|
db.session.commit()
|
|
return True
|
|
|
|
# Check attempts
|
|
if rate_limit.attempts >= max_attempts:
|
|
return False
|
|
|
|
return True
|
|
|
|
def increment_rate_limit(key: str):
|
|
"""Increment rate limit counter."""
|
|
rate_limit = RateLimit.query.filter_by(key=key).first()
|
|
if rate_limit:
|
|
rate_limit.attempts += 1
|
|
db.session.commit()
|
|
```
|
|
|
|
## Error Handling
|
|
|
|
### Common Errors
|
|
|
|
| Error | HTTP Status | User Message | Action |
|
|
|-------|-------------|--------------|--------|
|
|
| Email already registered | 400 | "This email is already registered. Click 'Request Access' to get a new link." | Show request access option |
|
|
| Registration closed | 400 | "Registration is not currently open for this exchange." | Show exchange info, no form |
|
|
| Exchange full | 400 | "This exchange has reached maximum capacity." | Show apology message |
|
|
| Rate limit exceeded | 429 | "Too many requests. Please try again in {minutes} minutes." | Show retry time |
|
|
| Invalid token | 400 | "This link is invalid or has expired. Request a new one." | Link to request access |
|
|
| Token expired | 400 | "This link has expired (valid for 1 hour). Request a new one." | Link to request access |
|
|
| Token already used | 400 | "This link has already been used. Request a new one." | Link to request access |
|
|
|
|
### Error Response Pattern
|
|
|
|
```python
|
|
@app.errorhandler(EmailAlreadyRegistered)
|
|
def handle_email_already_registered(error):
|
|
"""Handle duplicate email registration attempt."""
|
|
flash(
|
|
"This email is already registered for this exchange. "
|
|
"Click 'Request Access' below to get a new access link.",
|
|
"error"
|
|
)
|
|
# Render registration page with "Request Access" option highlighted
|
|
return render_template('participant/register.html', show_request_access=True), 400
|
|
|
|
@app.errorhandler(RateLimitExceeded)
|
|
def handle_rate_limit(error):
|
|
"""Handle rate limit exceeded."""
|
|
flash("Too many requests. Please try again later.", "error")
|
|
return render_template('participant/register.html'), 429
|
|
```
|
|
|
|
## Security Considerations
|
|
|
|
### Email Enumeration Prevention
|
|
|
|
**Threat**: Attacker tries to discover which emails are registered.
|
|
|
|
**Mitigation**:
|
|
- Request access returns generic success message
|
|
- Same response time whether email exists or not
|
|
- No indication if email is registered
|
|
|
|
**Implementation**:
|
|
```python
|
|
# Good: Generic message
|
|
return "If your email is registered, you'll receive a link."
|
|
|
|
# Bad: Reveals registration status
|
|
if participant:
|
|
return "Link sent!"
|
|
else:
|
|
return "Email not found."
|
|
```
|
|
|
|
### Token Security
|
|
|
|
**Threats**:
|
|
- Token interception
|
|
- Token guessing
|
|
- Replay attacks
|
|
|
|
**Mitigations**:
|
|
- 32-byte cryptographic randomness (2^256 possible tokens)
|
|
- Single-use tokens (marked as used after validation)
|
|
- 1-hour expiration
|
|
- Tokens sent only over HTTPS
|
|
- Token hash stored, not plaintext
|
|
|
|
### Session Security
|
|
|
|
**Threats**:
|
|
- Session hijacking
|
|
- Session fixation
|
|
|
|
**Mitigations**:
|
|
- HttpOnly cookies (prevent JavaScript access)
|
|
- Secure flag (HTTPS only)
|
|
- SameSite=Lax (CSRF protection)
|
|
- New session ID on authentication
|
|
- Server-side session storage
|
|
|
|
## Testing
|
|
|
|
### Unit Tests
|
|
|
|
```python
|
|
class TestParticipantAuth(unittest.TestCase):
|
|
|
|
def test_register_new_participant(self):
|
|
"""Test successful new participant registration."""
|
|
result = register_participant(
|
|
exchange_slug='test-exchange',
|
|
form_data={
|
|
'name': 'Alice',
|
|
'email': 'alice@example.com',
|
|
'gift_ideas': 'Books',
|
|
'reminder_enabled': True
|
|
}
|
|
)
|
|
|
|
self.assertTrue(result.success)
|
|
self.assertIsNotNone(result.participant_id)
|
|
|
|
# Verify participant created
|
|
participant = Participant.query.get(result.participant_id)
|
|
self.assertEqual(participant.name, 'Alice')
|
|
self.assertEqual(participant.email, 'alice@example.com')
|
|
|
|
def test_duplicate_email_rejected(self):
|
|
"""Test that duplicate email registration is rejected."""
|
|
# Register first participant
|
|
register_participant('test-exchange', {
|
|
'name': 'Alice',
|
|
'email': 'alice@example.com',
|
|
'gift_ideas': 'Books'
|
|
})
|
|
|
|
# Attempt duplicate registration
|
|
with self.assertRaises(EmailAlreadyRegistered):
|
|
register_participant('test-exchange', {
|
|
'name': 'Alice Again',
|
|
'email': 'alice@example.com',
|
|
'gift_ideas': 'Different ideas'
|
|
})
|
|
|
|
def test_generate_magic_token(self):
|
|
"""Test magic token generation."""
|
|
participant = create_test_participant()
|
|
token = generate_magic_token(participant.id, participant.exchange_id)
|
|
|
|
# Token should be URL-safe base64
|
|
self.assertEqual(len(token), 43)
|
|
self.assertTrue(token.replace('-', '').replace('_', '').isalnum())
|
|
|
|
# Hash should be stored
|
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
magic_token = MagicTokenModel.query.filter_by(token_hash=token_hash).first()
|
|
self.assertIsNotNone(magic_token)
|
|
self.assertEqual(magic_token.participant_id, participant.id)
|
|
|
|
def test_validate_magic_token_success(self):
|
|
"""Test successful token validation."""
|
|
participant = create_test_participant()
|
|
token = generate_magic_token(participant.id, participant.exchange_id)
|
|
|
|
result = validate_magic_token(token)
|
|
|
|
self.assertTrue(result.valid)
|
|
self.assertEqual(result.participant_id, participant.id)
|
|
self.assertEqual(result.exchange_id, participant.exchange_id)
|
|
|
|
def test_validate_expired_token(self):
|
|
"""Test that expired tokens are rejected."""
|
|
participant = create_test_participant()
|
|
token = generate_magic_token(participant.id, participant.exchange_id)
|
|
|
|
# Manually expire the token
|
|
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
magic_token = MagicTokenModel.query.filter_by(token_hash=token_hash).first()
|
|
magic_token.expires_at = datetime.utcnow() - timedelta(hours=1)
|
|
db.session.commit()
|
|
|
|
result = validate_magic_token(token)
|
|
|
|
self.assertFalse(result.valid)
|
|
self.assertEqual(result.error, "Token has expired")
|
|
|
|
def test_single_use_token(self):
|
|
"""Test that tokens can only be used once."""
|
|
participant = create_test_participant()
|
|
token = generate_magic_token(participant.id, participant.exchange_id)
|
|
|
|
# First use - success
|
|
result1 = validate_magic_token(token)
|
|
self.assertTrue(result1.valid)
|
|
|
|
# Second use - failure
|
|
result2 = validate_magic_token(token)
|
|
self.assertFalse(result2.valid)
|
|
self.assertEqual(result2.error, "Token has already been used")
|
|
|
|
def test_request_magic_link_rate_limit(self):
|
|
"""Test rate limiting on magic link requests."""
|
|
participant = create_test_participant()
|
|
email = participant.email
|
|
|
|
# First 3 requests succeed
|
|
for i in range(3):
|
|
result = request_magic_link('test-exchange', email)
|
|
self.assertTrue(result.success)
|
|
|
|
# 4th request fails (rate limit)
|
|
with self.assertRaises(RateLimitExceeded):
|
|
request_magic_link('test-exchange', email)
|
|
```
|
|
|
|
### Integration Tests
|
|
|
|
```python
|
|
class TestParticipantAuthIntegration(TestCase):
|
|
|
|
def test_full_registration_flow(self):
|
|
"""Test complete registration flow from form to email."""
|
|
with mail.record_messages() as outbox:
|
|
# Submit registration form
|
|
response = self.client.post('/exchange/test-slug/register', data={
|
|
'name': 'Alice',
|
|
'email': 'alice@example.com',
|
|
'gift_ideas': 'Books, coffee',
|
|
'reminder_enabled': True,
|
|
'csrf_token': get_csrf_token()
|
|
})
|
|
|
|
# Should redirect to success page
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertIn('/register/success', response.location)
|
|
|
|
# Email should be sent
|
|
self.assertEqual(len(outbox), 1)
|
|
email = outbox[0]
|
|
self.assertIn('Welcome to', email.subject)
|
|
self.assertIn('alice@example.com', email.recipients)
|
|
|
|
# Extract magic link from email
|
|
magic_link_match = re.search(r'/auth/participant/magic/([A-Za-z0-9_-]+)', email.body)
|
|
self.assertIsNotNone(magic_link_match)
|
|
token = magic_link_match.group(1)
|
|
|
|
# Click magic link
|
|
response = self.client.get(f'/auth/participant/magic/{token}')
|
|
|
|
# Should redirect to dashboard
|
|
self.assertEqual(response.status_code, 302)
|
|
self.assertIn('/participant/dashboard', response.location)
|
|
|
|
# Session should be created
|
|
with self.client.session_transaction() as sess:
|
|
self.assertEqual(sess['user_type'], 'participant')
|
|
self.assertIsNotNone(sess['user_id'])
|
|
```
|
|
|
|
## Future Enhancements
|
|
|
|
1. **Remember Device**: Optional "remember this device" for 30-day sessions
|
|
2. **Multi-Exchange Dashboard**: Single view showing all exchanges for an email
|
|
3. **Biometric Auth**: WebAuthn support for returning participants
|
|
4. **QR Code Links**: Generate QR codes for magic links (easier mobile access)
|
|
5. **Session Management**: View active sessions and revoke access
|
|
|
|
## References
|
|
|
|
- [ADR-0002: Authentication Strategy](../../../decisions/0002-authentication-strategy.md)
|
|
- [Data Model v0.2.0](../data-model.md)
|
|
- [API Specification v0.2.0](../api-spec.md)
|
|
- [Notifications Component](./notifications.md)
|
|
- [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
|