Initialize Sneaky Klaus project with: - uv package management and pyproject.toml - Flask application structure (app.py, config.py) - SQLAlchemy models for Admin and Exchange - Alembic database migrations - Pre-commit hooks configuration - Development tooling (pytest, ruff, mypy) Initial structure follows design documents in docs/: - src/app.py: Application factory with Flask extensions - src/config.py: Environment-based configuration - src/models/: Admin and Exchange models - migrations/: Alembic migration setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
863 lines
25 KiB
Markdown
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">×</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)
|