Files
sneakyklaus/docs/designs/v0.2.0/components/participant-auth.md
Phil Skentelbery eaafa78cf3 feat: add Participant and MagicToken models with automatic migrations
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>
2025-12-22 16:23:47 -07:00

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)