# 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 %}
{% endfor %} {% endif %} {% endwith %} ``` --- ## 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/