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>
This commit is contained in:
862
docs/designs/v0.2.0/components/auth.md
Normal file
862
docs/designs/v0.2.0/components/auth.md
Normal file
@@ -0,0 +1,862 @@
|
||||
# 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">×</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)
|
||||
Reference in New Issue
Block a user