Files
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

863 lines
25 KiB
Markdown

# Authentication - v0.1.0
**Version**: 0.1.0
**Date**: 2025-12-22
**Status**: Initial Design
## Overview
This document defines the authentication and authorization flows for Sneaky Klaus. The system supports two types of users with different authentication mechanisms:
1. **Admin**: Single administrator account with password-based authentication
2. **Participants**: Passwordless authentication via magic links
Session management is handled by Flask-Session, which stores session data server-side in SQLite.
## Authentication Flows
### Initial Admin Setup Flow
The first-run experience for new Sneaky Klaus installations.
#### Sequence Diagram
```mermaid
sequenceDiagram
participant User
participant Browser
participant App as Flask App
participant DB as SQLite Database
participant Session as Flask-Session
User->>Browser: Navigate to application
Browser->>App: GET /
App->>DB: Check if admin exists
DB-->>App: No admin found
App->>Browser: Redirect to /setup
Browser->>App: GET /setup
App-->>Browser: Render setup form
User->>Browser: Fill form (email, password)
Browser->>App: POST /setup
App->>App: Validate form (Flask-WTF)
App->>App: Hash password (bcrypt)
App->>DB: INSERT INTO admin
DB-->>App: Admin created (id=1)
App->>Session: Create admin session
Session-->>App: Session ID
App->>Browser: Set session cookie
App->>Browser: Flash success message
App->>Browser: Redirect to /admin/dashboard
Browser->>App: GET /admin/dashboard
App->>Session: Validate session
Session-->>App: Admin authenticated
App-->>Browser: Render dashboard
Note over Browser,App: Success message auto-dismisses after 5 seconds
```
#### Implementation Details
**First-Run Detection**:
- Check `SELECT COUNT(*) FROM admin` on application startup
- If count == 0, set `app.config['REQUIRES_SETUP'] = True`
- Before request handler checks flag and redirects to `/setup` if True
**Route**: `/setup`
**Methods**: GET, POST
**Authorization**:
- Accessible only when no admin exists
- Returns 404 if admin already exists
**Form Fields** (Flask-WTF):
- `email`: EmailField, required, validated with email validator
- `password`: PasswordField, required, min length 12 characters
- `password_confirm`: PasswordField, required, must match password
- CSRF token (automatic via Flask-WTF)
**POST Workflow**:
1. Validate form with Flask-WTF
2. Double-check no admin exists (prevent race condition)
3. Hash password with bcrypt (cost factor 12)
4. Insert admin record
5. Create session (Flask-Session)
6. Set session cookie (HttpOnly, Secure, SameSite=Lax)
7. Flash success message: "Admin account created successfully!"
8. Redirect to `/admin/dashboard`
**Auto-login**:
- Session created immediately after admin creation
- No separate login step required
- User automatically authenticated
---
### Admin Login Flow
Standard password-based authentication for the admin user.
#### Sequence Diagram
```mermaid
sequenceDiagram
participant User
participant Browser
participant App as Flask App
participant RateLimit as Rate Limiter
participant DB as SQLite Database
participant Session as Flask-Session
User->>Browser: Navigate to /admin/login
Browser->>App: GET /admin/login
App-->>Browser: Render login form
User->>Browser: Fill form (email, password)
Browser->>App: POST /admin/login
App->>App: Validate form (Flask-WTF)
App->>RateLimit: Check rate limit for email
alt Rate limit exceeded
RateLimit-->>App: Too many attempts
App->>Browser: Flash error message
App->>Browser: Render login form (429 status)
Note over Browser: Error message persists until dismissed
else Within rate limit
RateLimit-->>App: Allowed
App->>DB: SELECT * FROM admin WHERE email = ?
DB-->>App: Admin record
alt Invalid credentials
App->>App: Verify password (bcrypt)
App->>RateLimit: Increment failure count
App->>Browser: Flash error message
App->>Browser: Render login form
Note over Browser: Error message persists until dismissed
else Valid credentials
App->>App: Verify password (bcrypt)
App->>RateLimit: Reset rate limit counter
App->>Session: Create admin session
Session-->>App: Session ID
App->>Browser: Set session cookie
App->>Browser: Flash success message
App->>Browser: Redirect to /admin/dashboard
Browser->>App: GET /admin/dashboard
App->>Session: Validate session
Session-->>App: Admin authenticated
App-->>Browser: Render dashboard
Note over Browser: Success message auto-dismisses after 5 seconds
end
end
```
#### Implementation Details
**Route**: `/admin/login`
**Methods**: GET, POST
**Authorization**:
- Accessible to unauthenticated users only
- Redirects to dashboard if already authenticated
**Form Fields** (Flask-WTF):
- `email`: EmailField, required
- `password`: PasswordField, required
- `remember_me`: BooleanField, optional (extends session duration)
- CSRF token (automatic via Flask-WTF)
**Rate Limiting**:
- **Policy**: 5 attempts per 15 minutes per email
- **Key**: `login:admin:{email_lowercase}`
- **Implementation**: Check `rate_limit` table before authentication
- **Failure Handling**: Increment attempt counter on failed login
- **Success Handling**: Reset counter on successful login
- **Lockout Message**: "Too many login attempts. Please try again in {minutes} minutes."
**POST Workflow**:
1. Validate form with Flask-WTF
2. Normalize email to lowercase
3. Check rate limit for `login:admin:{email}`
4. If rate limited: flash error, return 429
5. Query admin by email
6. If admin not found or password invalid:
- Increment rate limit counter
- Flash error: "Invalid email or password"
- Re-render form
7. If credentials valid:
- Reset rate limit counter
- Create session via Flask-Session
- Set session expiration based on `remember_me`:
- Checked: 30 days
- Unchecked: 7 days (default)
- Set session cookie
- Flash success: "Welcome back!"
- Redirect to `/admin/dashboard`
**Session Cookie Configuration**:
- `HttpOnly`: True (prevent JavaScript access)
- `Secure`: True (HTTPS only in production)
- `SameSite`: Lax (CSRF protection)
- `Max-Age`: Based on remember_me
---
### Admin Logout Flow
Terminates the admin session.
#### Sequence Diagram
```mermaid
sequenceDiagram
participant User
participant Browser
participant App as Flask App
participant Session as Flask-Session
User->>Browser: Click logout
Browser->>App: POST /admin/logout
App->>Session: Get current session ID
App->>Session: Delete session
Session-->>App: Session deleted
App->>Browser: Clear session cookie
App->>Browser: Flash success message
App->>Browser: Redirect to /
Note over Browser: Success message auto-dismisses after 5 seconds
```
#### Implementation Details
**Route**: `/admin/logout`
**Methods**: POST (GET redirects to dashboard)
**Authorization**: Requires active admin session
**POST Workflow**:
1. Validate CSRF token
2. Delete session from Flask-Session store
3. Clear session cookie
4. Flash success: "You have been logged out"
5. Redirect to `/`
---
### Participant Magic Link Authentication Flow
Passwordless authentication for participants using time-limited magic links.
#### Sequence Diagram
```mermaid
sequenceDiagram
participant Participant
participant Browser
participant App as Flask App
participant RateLimit as Rate Limiter
participant DB as SQLite Database
participant Email as Resend
participant Session as Flask-Session
Note over Participant,Browser: Participant requests access
Participant->>Browser: Navigate to /exchange/{slug}
Browser->>App: GET /exchange/{slug}
App->>DB: SELECT * FROM exchange WHERE slug = ?
DB-->>App: Exchange record
App-->>Browser: Render participant login form
Participant->>Browser: Enter email
Browser->>App: POST /exchange/{slug}/auth
App->>App: Validate form (Flask-WTF)
App->>RateLimit: Check rate limit for email
alt Rate limit exceeded
RateLimit-->>App: Too many requests
App->>Browser: Flash error message
App->>Browser: Render form (429 status)
Note over Browser: Error message persists until dismissed
else Within rate limit
RateLimit-->>App: Allowed
App->>DB: SELECT * FROM participant WHERE email = ? AND exchange_id = ?
alt Participant not found
App->>Browser: Flash error message
App->>Browser: Render form
Note over Browser: Generic error for security
else Participant found
DB-->>App: Participant record
App->>App: Generate magic token (32 bytes, secrets module)
App->>App: Hash token (SHA-256)
App->>DB: INSERT INTO magic_token
App->>Email: Send magic link email
Email-->>Participant: Email with magic link
App->>RateLimit: Increment request counter
App->>Browser: Flash success message
App->>Browser: Redirect to /exchange/{slug}/auth/sent
Note over Browser: Success message auto-dismisses after 5 seconds
end
end
Note over Participant,Email: Participant clicks magic link
Participant->>Email: Click link
Email->>Browser: Open /exchange/{slug}/auth/verify?token={token}
Browser->>App: GET /exchange/{slug}/auth/verify?token={token}
App->>App: Hash token
App->>DB: SELECT * FROM magic_token WHERE token_hash = ?
alt Token invalid/expired/used
DB-->>App: No token found / Token expired
App->>Browser: Flash error message
App->>Browser: Redirect to /exchange/{slug}
Note over Browser: Error message persists until dismissed
else Token valid
DB-->>App: Token record
App->>DB: UPDATE magic_token SET used_at = NOW()
App->>DB: SELECT * FROM participant WHERE id = ?
DB-->>App: Participant record
App->>Session: Create participant session
Session-->>App: Session ID
App->>Browser: Set session cookie
App->>Browser: Flash success message
App->>Browser: Redirect to /exchange/{slug}/dashboard
Browser->>App: GET /exchange/{slug}/dashboard
App->>Session: Validate session
Session-->>App: Participant authenticated
App-->>Browser: Render participant dashboard
Note over Browser: Success message auto-dismisses after 5 seconds
end
```
#### Implementation Details
**Route**: `/exchange/{slug}/auth`
**Methods**: GET, POST
**Authorization**: Public (unauthenticated)
**Form Fields** (Flask-WTF):
- `email`: EmailField, required
- CSRF token (automatic via Flask-WTF)
**Rate Limiting**:
- **Policy**: 3 requests per hour per email
- **Key**: `magic_link:{email_lowercase}`
- **Lockout Message**: "Too many magic link requests. Please try again in {minutes} minutes."
**POST Workflow** (Request Magic Link):
1. Validate form with Flask-WTF
2. Normalize email to lowercase
3. Check rate limit for `magic_link:{email}`
4. If rate limited: flash error, return 429
5. Query participant by email and exchange_id
6. If participant not found:
- Flash generic error: "If this email is registered, you will receive a magic link."
- Return success response (prevent email enumeration)
- Do NOT send email
7. If participant found but withdrawn:
- Same as not found (prevent information disclosure)
8. If participant found and active:
- Generate token: 32 bytes from `secrets.token_urlsafe()`
- Hash token: SHA-256
- Store hash in `magic_token` table with:
- `token_type`: 'magic_link'
- `email`: participant email
- `participant_id`: participant ID
- `exchange_id`: exchange ID
- `expires_at`: NOW() + 1 hour
- Send email with magic link
- Increment rate limit counter
- Flash success: "Check your email for a magic link!"
- Redirect to `/exchange/{slug}/auth/sent`
**Route**: `/exchange/{slug}/auth/verify`
**Methods**: GET
**Query Parameters**:
- `token`: Magic token (URL-safe base64)
**GET Workflow** (Verify Token):
1. Extract token from query string
2. Hash token with SHA-256
3. Query `magic_token` table by hash
4. Validate token:
- Exists in database
- `expires_at` > NOW()
- `used_at` IS NULL
- `token_type` = 'magic_link'
5. If invalid:
- Flash error: "This magic link is invalid or has expired."
- Redirect to `/exchange/{slug}`
6. If valid:
- Mark token as used: `UPDATE magic_token SET used_at = NOW()`
- Load participant record
- Create session via Flask-Session with:
- `user_id`: participant.id
- `user_type`: 'participant'
- `exchange_id`: exchange.id
- Set session cookie (7-day expiration)
- Flash success: "Welcome, {participant.name}!"
- Redirect to `/exchange/{slug}/dashboard`
**Token Generation**:
```python
import secrets
import hashlib
# Generate token
token = secrets.token_urlsafe(32) # 32 bytes = 43 URL-safe characters
# Hash for storage
token_hash = hashlib.sha256(token.encode()).hexdigest()
# Magic link URL
magic_link = f"{base_url}/exchange/{slug}/auth/verify?token={token}"
```
---
### Participant Logout Flow
Terminates the participant session.
#### Sequence Diagram
```mermaid
sequenceDiagram
participant Participant
participant Browser
participant App as Flask App
participant Session as Flask-Session
Participant->>Browser: Click logout
Browser->>App: POST /exchange/{slug}/logout
App->>Session: Get current session ID
App->>Session: Delete session
Session-->>App: Session deleted
App->>Browser: Clear session cookie
App->>Browser: Flash success message
App->>Browser: Redirect to /exchange/{slug}
Note over Browser: Success message auto-dismisses after 5 seconds
```
#### Implementation Details
**Route**: `/exchange/{slug}/logout`
**Methods**: POST
**Authorization**: Requires active participant session for this exchange
**POST Workflow**:
1. Validate CSRF token
2. Verify session belongs to participant for this exchange
3. Delete session from Flask-Session store
4. Clear session cookie
5. Flash success: "You have been logged out"
6. Redirect to `/exchange/{slug}`
---
## Session Management
### Flask-Session Configuration
**Backend**: SQLAlchemy (SQLite)
**Table**: `sessions` (created and managed by Flask-Session)
**Configuration**:
```python
app.config['SESSION_TYPE'] = 'sqlalchemy'
app.config['SESSION_SQLALCHEMY'] = db # SQLAlchemy instance
app.config['SESSION_PERMANENT'] = True
app.config['SESSION_USE_SIGNER'] = True # Sign session cookies
app.config['SESSION_KEY_PREFIX'] = 'sk:' # Prefix for session keys
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Default
```
### Session Data Structure
**Admin Session**:
```python
{
'user_id': 1, # Admin ID
'user_type': 'admin',
'_fresh': True, # For Flask-Login compatibility (future)
'_permanent': True
}
```
**Participant Session**:
```python
{
'user_id': 123, # Participant ID
'user_type': 'participant',
'exchange_id': 456, # Exchange participant is authenticated for
'_fresh': True,
'_permanent': True
}
```
### Session Validation
**Before Request Handler**:
```python
@app.before_request
def load_session_user():
if 'user_id' in session and 'user_type' in session:
if session['user_type'] == 'admin':
g.admin = Admin.query.get(session['user_id'])
elif session['user_type'] == 'participant':
g.participant = Participant.query.get(session['user_id'])
g.exchange_id = session.get('exchange_id')
```
### Session Expiration
**Default Expiration**: 7 days from last activity
**Remember Me** (Admin only): 30 days from last activity
**Sliding Window**: Flask-Session automatically updates `last_activity` on each request
**Cleanup**: Flask-Session handles expired session cleanup automatically
---
## Flash Messages
Flash messages provide user feedback for authentication actions.
### Flash Message Types
**Success Messages**:
- Category: `'success'`
- Auto-dismiss: 5 seconds
- Examples:
- "Admin account created successfully!"
- "Welcome back!"
- "You have been logged out"
- "Check your email for a magic link!"
- "Welcome, {name}!"
**Error Messages**:
- Category: `'error'`
- Auto-dismiss: Manual (user must dismiss)
- Examples:
- "Invalid email or password"
- "Too many login attempts. Please try again in {minutes} minutes."
- "This magic link is invalid or has expired."
### Implementation
**Backend** (Flask):
```python
from flask import flash
# Success message
flash("Admin account created successfully!", "success")
# Error message
flash("Invalid email or password", "error")
```
**Frontend** (Jinja2 + JavaScript):
```html
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash-message flash-{{ category }}"
{% if category == 'success' %}data-auto-dismiss="5000"{% endif %}>
{{ message }}
<button class="flash-dismiss" aria-label="Dismiss">&times;</button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<script>
// Auto-dismiss success messages after 5 seconds
document.querySelectorAll('[data-auto-dismiss]').forEach(el => {
const delay = parseInt(el.dataset.autoDismiss);
setTimeout(() => {
el.style.opacity = '0';
setTimeout(() => el.remove(), 300);
}, delay);
});
// Manual dismiss for all messages
document.querySelectorAll('.flash-dismiss').forEach(btn => {
btn.addEventListener('click', () => {
const message = btn.parentElement;
message.style.opacity = '0';
setTimeout(() => message.remove(), 300);
});
});
</script>
```
---
## Authorization Patterns
### Route Protection Decorators
**Admin-Only Routes**:
```python
from functools import wraps
from flask import session, redirect, url_for, flash, g
def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user_id' not in session or session.get('user_type') != 'admin':
flash("You must be logged in as admin to access this page.", "error")
return redirect(url_for('admin_login'))
return f(*args, **kwargs)
return decorated_function
@app.route('/admin/dashboard')
@admin_required
def admin_dashboard():
return render_template('admin/dashboard.html')
```
**Participant-Only Routes**:
```python
def participant_required(f):
@wraps(f)
def decorated_function(slug, *args, **kwargs):
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('exchange_auth', slug=slug))
# Verify participant is authenticated for this exchange
exchange = Exchange.query.filter_by(slug=slug).first_or_404()
if session.get('exchange_id') != exchange.id:
flash("You are not authorized to access this exchange.", "error")
return redirect(url_for('exchange_auth', slug=slug))
return f(slug, *args, **kwargs)
return decorated_function
@app.route('/exchange/<slug>/dashboard')
@participant_required
def participant_dashboard(slug):
return render_template('participant/dashboard.html')
```
### Setup Requirement Check
**Before Request Handler**:
```python
@app.before_request
def check_setup_required():
# Skip check for setup route and static files
if request.endpoint in ['setup', 'static']:
return
# Check if admin exists
if app.config.get('REQUIRES_SETUP'):
admin_count = Admin.query.count()
if admin_count == 0:
return redirect(url_for('setup'))
else:
app.config['REQUIRES_SETUP'] = False
```
---
## Security Considerations
### Password Security
**Hashing Algorithm**: bcrypt with cost factor 12
**Minimum Password Length**: 12 characters
**Password Validation**:
- Enforced at form level (Flask-WTF)
- No complexity requirements (length is primary security measure)
**Implementation**:
```python
from flask_bcrypt import Bcrypt
bcrypt = Bcrypt(app)
# Hash password
password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
# Verify password
bcrypt.check_password_hash(admin.password_hash, password)
```
### CSRF Protection
**Implementation**: Flask-WTF automatic CSRF protection
**All Forms**: Include CSRF token automatically via `{{ form.csrf_token }}`
**All POST Routes**: Validate CSRF token automatically via Flask-WTF
**Exempt Routes**: None (all state-changing operations require CSRF token)
### Rate Limiting
**Storage**: SQLite `rate_limit` table
**Policies**:
- Admin login: 5 attempts / 15 minutes / email
- Magic link request: 3 requests / hour / email
**Bypass**: None (applies to all users including admin)
**Cleanup**: Expired entries purged daily via background job
### Session Security
**Cookie Settings**:
- `HttpOnly`: True (prevent XSS)
- `Secure`: True in production (HTTPS only)
- `SameSite`: Lax (CSRF protection)
**Session Signing**: Enabled via `SESSION_USE_SIGNER`
**Secret Key**: Loaded from environment variable `SECRET_KEY`
### Magic Link Security
**Token Generation**: `secrets.token_urlsafe(32)` (cryptographically secure)
**Token Storage**: SHA-256 hash only (original token never stored)
**Token Expiration**: 1 hour from creation
**Single Use**: Marked as used immediately upon verification
**Email Enumeration Prevention**: Generic success message for all email submissions
### Timing Attack Prevention
**Password Verification**: bcrypt naturally resistant to timing attacks
**Magic Link Lookup**: Use constant-time comparison for token hashes (handled by database query)
---
## Error Handling
### Authentication Errors
**Invalid Credentials**:
- HTTP Status: 200 (re-render form)
- Flash Message: "Invalid email or password"
- Rate Limit: Increment counter
**Rate Limit Exceeded**:
- HTTP Status: 429 Too Many Requests
- Flash Message: "Too many {action} attempts. Please try again in {minutes} minutes."
- Behavior: Re-render form with error
**Expired Magic Link**:
- HTTP Status: 200 (redirect to login)
- Flash Message: "This magic link is invalid or has expired."
- Behavior: Redirect to exchange login page
**CSRF Validation Failure**:
- HTTP Status: 400 Bad Request
- Flash Message: "Security validation failed. Please try again."
- Behavior: Redirect to form
### Session Errors
**Session Expired**:
- HTTP Status: 302 (redirect to login)
- Flash Message: "Your session has expired. Please log in again."
- Behavior: Redirect to appropriate login page
**Invalid Session**:
- HTTP Status: 302 (redirect to login)
- Flash Message: "You must be logged in to access this page."
- Behavior: Redirect to appropriate login page
---
## Testing Considerations
### Unit Tests
**Setup Flow**:
- Test admin creation with valid data
- Test duplicate admin prevention
- Test auto-login after setup
- Test setup route 404 when admin exists
**Admin Login**:
- Test successful login with valid credentials
- Test failed login with invalid credentials
- Test rate limiting after 5 failed attempts
- Test rate limit reset after successful login
- Test remember_me session duration
**Magic Link**:
- Test magic link generation for valid participant
- Test rate limiting after 3 requests
- Test token verification with valid token
- Test token expiration after 1 hour
- Test single-use token enforcement
### Integration Tests
**Full Authentication Flows**:
- Test setup → login → logout flow
- Test magic link request → email → verify → dashboard flow
- Test concurrent session handling
- Test session persistence across requests
### Security Tests
**CSRF Protection**:
- Test all POST routes require valid CSRF token
- Test CSRF token validation
**Rate Limiting**:
- Test rate limits are enforced correctly
- Test rate limit window expiration
**Session Security**:
- Test session cookie settings (HttpOnly, Secure, SameSite)
- Test session expiration
---
## Future Enhancements
Potential improvements for future versions:
1. **Two-Factor Authentication**: TOTP for admin login
2. **Password Reset**: Admin password reset via email
3. **Session Management UI**: Admin view of active sessions
4. **Account Lockout**: Temporary lockout after repeated failed logins
5. **Audit Logging**: Track all authentication events
6. **Multiple Admins**: Support for multiple admin accounts with roles
7. **OAuth Integration**: Social login options for participants
These enhancements are out of scope for v0.1.0.
---
## References
- [Flask-Session Documentation](https://flask-session.readthedocs.io/)
- [Flask-WTF Documentation](https://flask-wtf.readthedocs.io/)
- [bcrypt Documentation](https://github.com/pyca/bcrypt/)
- [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
- [ADR-0001: Core Technology Stack](../../decisions/0001-core-technology-stack.md)
- [ADR-0002: Authentication Strategy](../../decisions/0002-authentication-strategy.md)
- [Data Model v0.1.0](../data-model.md)