Files
sneakyklaus/docs/designs/v0.2.0/components/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

25 KiB

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

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

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

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 /

Passwordless authentication for participants using time-limited magic links.

Sequence Diagram

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:

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

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:

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:

{
    'user_id': 1,  # Admin ID
    'user_type': 'admin',
    '_fresh': True,  # For Flask-Login compatibility (future)
    '_permanent': True
}

Participant Session:

{
    '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:

@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):

from flask import flash

# Success message
flash("Admin account created successfully!", "success")

# Error message
flash("Invalid email or password", "error")

Frontend (Jinja2 + JavaScript):

{% 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:

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:

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:

@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:

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

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