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

34 KiB

Participant Authentication Component - v0.2.0

Version: 0.2.0 Date: 2025-12-22 Status: Phase 2 Design

Overview

This document details the participant authentication system for Sneaky Klaus, focusing on the magic link flow, returning participant detection, and participant session management. This component enables passwordless authentication for participants, allowing them to access their exchange information without creating traditional accounts.

Requirements

Functional Requirements

  1. Registration with Auto-Auth: New participants register and receive a magic link immediately
  2. Returning Participant Detection: System detects when a participant already exists by email
  3. Magic Link Generation: Create secure, time-limited authentication tokens
  4. Magic Link Validation: Validate tokens and create participant sessions
  5. Session Persistence: Maintain participant sessions with 7-day sliding window
  6. Multiple Exchange Support: Participants can be in multiple exchanges with the same email

Non-Functional Requirements

  1. Security: Magic links must be cryptographically secure and single-use
  2. Usability: Minimal friction for participants to access their information
  3. Email Delivery: Reliable delivery of magic links via Resend
  4. Rate Limiting: Prevent abuse of magic link generation

Architecture

Component Diagram

flowchart TB
    subgraph "Participant Facing"
        RegPage[Registration Page]
        AccessLink[Request Access Link]
        SuccessPage[Success Page]
    end

    subgraph "Magic Link Flow"
        EmailClick[Click Magic Link]
        TokenValidation[Token Validation]
        SessionCreation[Session Creation]
        Dashboard[Participant Dashboard]
    end

    subgraph "Services"
        AuthService[Participant Auth Service]
        TokenService[Magic Token Service]
        EmailService[Notification Service]
    end

    subgraph "Data Layer"
        ParticipantDB[(Participant Table)]
        TokenDB[(Magic Token Table)]
        SessionDB[(Session Store)]
    end

    RegPage --> AuthService
    AccessLink --> AuthService
    AuthService --> TokenService
    TokenService --> TokenDB
    AuthService --> EmailService
    EmailService --> |Email with link| EmailClick
    EmailClick --> TokenValidation
    TokenValidation --> TokenService
    TokenValidation --> SessionCreation
    SessionCreation --> SessionDB
    SessionCreation --> Dashboard
    AuthService --> ParticipantDB

Registration Flow

New Participant Registration

Sequence Diagram:

sequenceDiagram
    participant P as Participant
    participant Browser as Browser
    participant App as Flask App
    participant DB as Database
    participant Email as Resend

    P->>Browser: Navigate to /exchange/{slug}/register
    Browser->>App: GET /exchange/{slug}/register
    App->>DB: Query exchange by slug
    DB-->>App: Exchange details
    App-->>Browser: Render registration form

    P->>Browser: Fill form (name, email, gift ideas)
    Browser->>App: POST /exchange/{slug}/register
    App->>App: Validate form (WTForms)
    App->>DB: Check email exists in exchange

    alt Email already registered
        DB-->>App: Participant found
        App-->>Browser: Flash error + show "Request Access" option
    else New participant
        DB-->>App: Email not found
        App->>DB: INSERT INTO participant
        DB-->>App: Participant created (id)
        App->>App: Generate magic token
        App->>DB: INSERT INTO magic_token
        App->>Email: Send registration confirmation with magic link
        Email-->>P: Confirmation email
        App-->>Browser: Redirect to success page
        Browser-->>P: "Check your email" message
    end

Implementation Details:

def register_participant(exchange_slug: str, form_data: dict) -> RegistrationResult:
    """
    Register new participant for exchange.

    Args:
        exchange_slug: Exchange URL slug
        form_data: Registration form data (name, email, gift_ideas, reminder_enabled)

    Returns:
        RegistrationResult with success status and participant ID

    Raises:
        EmailAlreadyRegistered: If email already exists in this exchange
        RegistrationClosed: If exchange not in registration_open state
        ExchangeFull: If participant count >= max_participants
    """
    # 1. Load exchange by slug
    exchange = Exchange.query.filter_by(slug=exchange_slug).first_or_404()

    # 2. Validate exchange state
    if exchange.state != 'registration_open':
        raise RegistrationClosed("Registration is not currently open")

    # 3. Check participant count
    participant_count = Participant.query.filter_by(
        exchange_id=exchange.id,
        withdrawn_at=None
    ).count()

    if participant_count >= exchange.max_participants:
        raise ExchangeFull("This exchange has reached maximum capacity")

    # 4. Check email uniqueness within exchange
    email = form_data['email'].lower().strip()
    existing = Participant.query.filter_by(
        exchange_id=exchange.id,
        email=email,
        withdrawn_at=None
    ).first()

    if existing:
        raise EmailAlreadyRegistered("This email is already registered for this exchange")

    # 5. Create participant record
    participant = Participant(
        exchange_id=exchange.id,
        name=form_data['name'].strip(),
        email=email,
        gift_ideas=form_data.get('gift_ideas', '').strip(),
        reminder_enabled=form_data.get('reminder_enabled', True)
    )
    db.session.add(participant)
    db.session.flush()  # Get participant.id

    # 6. Generate magic token
    token = generate_magic_token(participant.id, exchange.id)

    # 7. Send confirmation email
    notification_service.send_registration_confirmation(
        participant_id=participant.id,
        token=token
    )

    # 8. Commit transaction
    db.session.commit()

    return RegistrationResult(
        success=True,
        participant_id=participant.id,
        email=email
    )

Returning Participant Detection

Request Access Flow

Purpose: Allow returning participants to request a new magic link without re-registering.

User Experience:

  1. Participant visits registration page
  2. Sees option: "Already registered? Request access link"
  3. Clicks link, enters email
  4. Receives magic link if email is registered

Sequence Diagram:

sequenceDiagram
    participant P as Participant
    participant Browser as Browser
    participant App as Flask App
    participant RateLimit as Rate Limiter
    participant DB as Database
    participant Email as Resend

    P->>Browser: Click "Request Access"
    Browser->>App: GET /exchange/{slug}/register (shows request form)

    P->>Browser: Enter email
    Browser->>App: POST /exchange/{slug}/request-access
    App->>App: Validate email format
    App->>RateLimit: Check rate limit (3/hour per email)

    alt Rate limit exceeded
        RateLimit-->>App: Limit exceeded
        App-->>Browser: Flash error (429)
    else Within limit
        RateLimit-->>App: Allowed
        App->>DB: Query participant by email + exchange_id

        alt Participant found
            DB-->>App: Participant record
            App->>App: Generate magic token
            App->>DB: INSERT INTO magic_token
            App->>Email: Send magic link email
            Email-->>P: Magic link email
            App->>RateLimit: Increment counter
        else Participant not found
            DB-->>App: Not found
            App->>App: Silent success (no email sent)
            Note over App: Same timing to prevent enumeration
        end

        App-->>Browser: Generic success message
        Browser-->>P: "If registered, check your email"
    end

Implementation Details:

def request_magic_link(exchange_slug: str, email: str) -> RequestResult:
    """
    Request magic link for existing participant.

    Args:
        exchange_slug: Exchange URL slug
        email: Participant email

    Returns:
        RequestResult (always success to prevent enumeration)

    Raises:
        RateLimitExceeded: If too many requests from this email
    """
    # 1. Check rate limit
    rate_limit_key = f"magic_link:{email.lower()}"
    if not check_rate_limit(rate_limit_key, max_attempts=3, window_hours=1):
        raise RateLimitExceeded("Too many requests. Try again later.")

    # 2. Load exchange
    exchange = Exchange.query.filter_by(slug=exchange_slug).first_or_404()

    # 3. Query participant
    email_normalized = email.lower().strip()
    participant = Participant.query.filter_by(
        exchange_id=exchange.id,
        email=email_normalized,
        withdrawn_at=None  # Don't send to withdrawn participants
    ).first()

    # 4. If participant exists, send magic link
    if participant:
        # Generate token
        token = generate_magic_token(participant.id, exchange.id)

        # Send email
        notification_service.send_magic_link(
            participant_id=participant.id,
            token=token
        )

        # Increment rate limit
        increment_rate_limit(rate_limit_key)

    # 5. Always return success (prevent email enumeration)
    # Timing should be consistent whether participant exists or not
    return RequestResult(
        success=True,
        message="If your email is registered, you'll receive an access link."
    )

Security Considerations:

  • Generic success message prevents email enumeration
  • Same response time whether email exists or not
  • Rate limiting prevents brute force email discovery
  • Withdrawn participants cannot request new links

Magic Token Service

Token Generation

Token Format:

  • 32 bytes of cryptographic randomness
  • Base64url encoded (43 characters)
  • Example: k7Jx9mP2qR4sT6vN8bC1dE3fG5hI0jK7lM9nO2pQ4rS6tU8vW1xY3zA5

Token Storage:

  • Only SHA-256 hash stored in database
  • Original token included in magic link URL
  • Token expires after 1 hour
  • Single-use only

Implementation:

import secrets
import hashlib
from datetime import datetime, timedelta
from dataclasses import dataclass

@dataclass
class MagicToken:
    """Magic token with original value and hash."""
    token: str  # Original token (for URL)
    token_hash: str  # SHA-256 hash (for storage)
    expires_at: datetime

def generate_magic_token(participant_id: int, exchange_id: int) -> str:
    """
    Generate magic link token for participant.

    Args:
        participant_id: Participant ID
        exchange_id: Exchange ID

    Returns:
        Original token string (to be included in email URL)
    """
    # 1. Generate cryptographically random token
    token = secrets.token_urlsafe(32)  # 32 bytes = 43 URL-safe characters

    # 2. Hash token for storage
    token_hash = hashlib.sha256(token.encode()).hexdigest()

    # 3. Calculate expiration (1 hour from now)
    expires_at = datetime.utcnow() + timedelta(hours=1)

    # 4. Load participant to get email
    participant = Participant.query.get(participant_id)

    # 5. Store token hash in database
    magic_token = MagicTokenModel(
        token_hash=token_hash,
        token_type='magic_link',
        email=participant.email,
        participant_id=participant_id,
        exchange_id=exchange_id,
        expires_at=expires_at
    )
    db.session.add(magic_token)
    db.session.commit()

    # 6. Return original token (for URL)
    return token

Token Validation

Validation Flow:

flowchart TD
    Start[Receive token from URL] --> Hash[Hash token with SHA-256]
    Hash --> Query[Query magic_token by hash]
    Query --> Exists{Token exists?}

    Exists -->|No| Invalid[Return: Invalid token]
    Exists -->|Yes| CheckExpiry{Expired?}

    CheckExpiry -->|Yes| Invalid
    CheckExpiry -->|No| CheckUsed{Already used?}

    CheckUsed -->|Yes| Invalid
    CheckUsed -->|No| CheckType{Type = magic_link?}

    CheckType -->|No| Invalid
    CheckType -->|Yes| Valid[Mark token as used]

    Valid --> LoadParticipant[Load participant]
    LoadParticipant --> CreateSession[Create participant session]
    CreateSession --> Success[Return: Success + session]

Implementation:

from typing import Optional

@dataclass
class TokenValidationResult:
    """Result of token validation."""
    valid: bool
    participant_id: Optional[int] = None
    exchange_id: Optional[int] = None
    error: Optional[str] = None

def validate_magic_token(token: str) -> TokenValidationResult:
    """
    Validate magic link token and return participant info.

    Args:
        token: Original token from URL

    Returns:
        TokenValidationResult with validity and participant info
    """
    # 1. Hash the token
    token_hash = hashlib.sha256(token.encode()).hexdigest()

    # 2. Query token by hash
    magic_token = MagicTokenModel.query.filter_by(
        token_hash=token_hash,
        token_type='magic_link'
    ).first()

    # 3. Validate token exists
    if not magic_token:
        return TokenValidationResult(
            valid=False,
            error="Invalid or expired token"
        )

    # 4. Check expiration
    if magic_token.expires_at < datetime.utcnow():
        return TokenValidationResult(
            valid=False,
            error="Token has expired"
        )

    # 5. Check if already used
    if magic_token.used_at is not None:
        return TokenValidationResult(
            valid=False,
            error="Token has already been used"
        )

    # 6. Mark token as used (single-use enforcement)
    magic_token.used_at = datetime.utcnow()
    db.session.commit()

    # 7. Return success with participant info
    return TokenValidationResult(
        valid=True,
        participant_id=magic_token.participant_id,
        exchange_id=magic_token.exchange_id
    )

Session Management

Participant Session Creation

Session Data Structure:

{
    'user_id': 123,           # Participant ID
    'user_type': 'participant',
    'exchange_id': 456,        # Exchange ID for this session
    '_fresh': True,
    '_permanent': True
}

Session Configuration:

  • Backend: Flask-Session with SQLAlchemy
  • Duration: 7 days (sliding window)
  • Cookie flags: HttpOnly, Secure, SameSite=Lax
  • Session extends on each request (sliding window)

Implementation:

from flask import session
from datetime import timedelta

def create_participant_session(participant_id: int, exchange_id: int):
    """
    Create Flask session for participant.

    Args:
        participant_id: Participant ID
        exchange_id: Exchange ID
    """
    # Clear any existing session
    session.clear()

    # Set session data
    session['user_id'] = participant_id
    session['user_type'] = 'participant'
    session['exchange_id'] = exchange_id
    session.permanent = True  # Enable persistent session

    # Set session lifetime (7 days)
    app.permanent_session_lifetime = timedelta(days=7)

    # Flask-Session automatically stores in database

Session Validation Decorator

Purpose: Protect participant routes and ensure participant has access to the exchange.

Implementation:

from functools import wraps
from flask import session, redirect, url_for, flash, g

def participant_required(f):
    """
    Decorator to require participant authentication.
    Validates session and loads participant into g.participant.
    """
    @wraps(f)
    def decorated_function(*args, **kwargs):
        # Check session exists
        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('public.landing'))

        # Load participant
        participant = Participant.query.get(session['user_id'])
        if not participant or participant.withdrawn_at is not None:
            session.clear()
            flash("Your session is invalid. Please request a new access link.", "error")
            return redirect(url_for('public.landing'))

        # Store in request context
        g.participant = participant
        g.exchange_id = session.get('exchange_id')

        return f(*args, **kwargs)

    return decorated_function

def exchange_access_required(f):
    """
    Decorator to require participant access to specific exchange.
    Must be used after @participant_required.
    """
    @wraps(f)
    def decorated_function(exchange_id, *args, **kwargs):
        # Check participant has access to this exchange
        if g.participant.exchange_id != exchange_id:
            flash("You don't have access to this exchange.", "error")
            return redirect(url_for('participant.dashboard'))

        return f(exchange_id, *args, **kwargs)

    return decorated_function

Usage Example:

@app.route('/participant/exchange/<int:exchange_id>')
@participant_required
@exchange_access_required
def view_exchange(exchange_id):
    """View exchange details (participant must be in this exchange)."""
    exchange = Exchange.query.get_or_404(exchange_id)
    return render_template('participant/exchange_detail.html', exchange=exchange)

Development Mode

When Development Mode is Active

Development mode email logging is automatically enabled when the application runs with FLASK_ENV=development. This allows QA and developers to test authentication flows without requiring email infrastructure.

Activation Condition: FLASK_ENV=development

What Gets Logged

When a magic link is generated in development mode, the complete magic link URL (including the token) is logged to the application logs at INFO level:

[INFO] DEV MODE: Magic link generated for participant alice@example.com
[INFO] DEV MODE: Full magic link URL: https://sneaky-klaus.local/auth/participant/magic/k7Jx9mP2qR4sT6vN8bC1dE3fG5hI0jK7lM9nO2pQ4rS6tU8vW1xY3zA5

For Podman Containers

# After registering a participant, retrieve the magic link from container logs
podman logs sneaky-klaus-qa | grep "DEV MODE"

# Find the "Full magic link URL" line and copy the complete URL
# Paste into browser or use with curl:
curl https://sneaky-klaus.local/auth/participant/magic/k7Jx9mP2qR4sT6vN8bC1dE3fG5hI0jK7lM9nO2pQ4rS6tU8vW1xY3zA5

For Local Development

# Run the application with FLASK_ENV=development
FLASK_ENV=development uv run flask run

# Magic links appear in terminal output when participants register
# Copy the full URL from the "DEV MODE: Full magic link URL:" line

Development Workflow Example

Scenario: QA testing participant registration and authentication without email service.

  1. Start Application: Run with FLASK_ENV=development
  2. Register Participant:
    • Navigate to exchange registration page
    • Fill in name, email, gift ideas
    • Submit form
  3. Retrieve Magic Link:
    • Check application logs using podman logs or terminal output
    • Find the line: DEV MODE: Full magic link URL: https://...
    • Copy the complete URL
  4. Authenticate:
    • Paste URL into browser or use with curl
    • Magic link validates the token and creates session
    • Redirected to participant dashboard
  5. Repeat: For each test participant/exchange combination, repeat steps 2-4

Security Warning

CRITICAL: This feature is ONLY enabled in development mode (FLASK_ENV=development).

This must NEVER be enabled in production.

Logging magic links (which contain cryptographic tokens) to application logs would be a severe security vulnerability in production. The application includes a runtime check that ensures this feature remains disabled unless explicitly running with FLASK_ENV=development.

Production environments:

  • Set FLASK_ENV=production or leave unset
  • Magic links are never logged
  • Only transmitted via email

Implementation

Magic link logging is implemented in the notification service:

def send_registration_confirmation(participant_id: int, token: str):
    """Send registration confirmation email with magic link."""
    # ... build magic_link_url ...

    # Development mode: log the link for QA access
    if should_log_dev_mode_links():  # Only True when FLASK_ENV=development
        logger.info(f"DEV MODE: Magic link generated for participant {participant.email}")
        logger.info(f"DEV MODE: Full magic link URL: {magic_link_url}")

    # Send email (if available)
    try:
        send_email(...)
    except EmailServiceUnavailable:
        # In development, logging fallback ensures testing can proceed
        logger.info(f"Email service unavailable, but link logged for DEV MODE testing")

The should_log_dev_mode_links() function checks FLASK_ENV at runtime:

def should_log_dev_mode_links() -> bool:
    """
    Check if development mode email logging is enabled.

    CRITICAL: This must NEVER be True in production.
    Only enable when explicitly running with FLASK_ENV=development.
    """
    env = os.environ.get('FLASK_ENV', '').lower()
    return env == 'development'

Multiple Exchange Support

Challenge

A participant may register for multiple exchanges using the same email address. Each magic link session is scoped to a single exchange.

Participant Data Model:

  • Separate Participant record for each exchange
  • Same email can appear in multiple exchanges
  • Each participant record has unique ID

Session Scoping:

  • Session includes exchange_id
  • Participant can only view data for the exchange in their current session
  • To access different exchange, must authenticate via that exchange's magic link

Example Scenario:

  1. Alice registers for "Family Christmas" with alice@example.com
  2. Alice registers for "Office Party" with alice@example.com
  3. Two separate Participant records created (different IDs)
  4. Magic link for "Family Christmas" creates session with exchange_id=1
  5. Magic link for "Office Party" creates session with exchange_id=2
  6. Sessions are independent; Alice must use appropriate magic link for each

Implementation Note:

# Two separate participant records
family_participant = Participant(
    id=100,
    exchange_id=1,  # Family Christmas
    email="alice@example.com",
    name="Alice"
)

office_participant = Participant(
    id=200,
    exchange_id=2,  # Office Party
    email="alice@example.com",
    name="Alice"
)

# Magic link for Family Christmas creates session:
# session['user_id'] = 100
# session['exchange_id'] = 1

# Magic link for Office Party creates session:
# session['user_id'] = 200
# session['exchange_id'] = 2

Email Templates

Registration Confirmation Email

Template: templates/emails/participant/registration_confirmation.html

Subject: "Welcome to {exchange_name}!"

Variables:

{
    "participant_name": str,
    "exchange_name": str,
    "exchange_date": datetime,
    "budget": str,
    "magic_link_url": str
}

Key Content:

  • Welcome message
  • Exchange details (date, budget)
  • Magic link for access
  • Expiration notice (1 hour)

Template: templates/emails/participant/magic_link.html

Subject: "Access Your Sneaky Klaus Registration"

Variables:

{
    "participant_name": str,
    "exchange_name": str,
    "magic_link_url": str,
    "expiration_minutes": int  # 60
}

Key Content:

  • Access link request confirmation
  • Prominent magic link button
  • Expiration warning
  • Security note (didn't request? ignore)

Rate Limiting

Policies

Endpoint Limit Window Key
POST /exchange/{slug}/register 10 attempts 1 hour IP address
POST /exchange/{slug}/request-access 3 requests 1 hour email

Implementation

Rate Limit Service:

from datetime import datetime, timedelta

def check_rate_limit(key: str, max_attempts: int, window_hours: int) -> bool:
    """
    Check if rate limit allows another attempt.

    Args:
        key: Rate limit key (e.g., "magic_link:alice@example.com")
        max_attempts: Maximum attempts allowed in window
        window_hours: Time window in hours

    Returns:
        True if allowed, False if limit exceeded
    """
    # Query or create rate limit record
    rate_limit = RateLimit.query.filter_by(key=key).first()

    now = datetime.utcnow()
    window_start = now - timedelta(hours=window_hours)

    if not rate_limit:
        # First attempt, create record
        rate_limit = RateLimit(
            key=key,
            attempts=0,
            window_start=now,
            expires_at=now + timedelta(hours=window_hours)
        )
        db.session.add(rate_limit)
        db.session.commit()
        return True

    # Check if window has expired
    if rate_limit.window_start < window_start:
        # Reset window
        rate_limit.attempts = 0
        rate_limit.window_start = now
        rate_limit.expires_at = now + timedelta(hours=window_hours)
        db.session.commit()
        return True

    # Check attempts
    if rate_limit.attempts >= max_attempts:
        return False

    return True

def increment_rate_limit(key: str):
    """Increment rate limit counter."""
    rate_limit = RateLimit.query.filter_by(key=key).first()
    if rate_limit:
        rate_limit.attempts += 1
        db.session.commit()

Error Handling

Common Errors

Error HTTP Status User Message Action
Email already registered 400 "This email is already registered. Click 'Request Access' to get a new link." Show request access option
Registration closed 400 "Registration is not currently open for this exchange." Show exchange info, no form
Exchange full 400 "This exchange has reached maximum capacity." Show apology message
Rate limit exceeded 429 "Too many requests. Please try again in {minutes} minutes." Show retry time
Invalid token 400 "This link is invalid or has expired. Request a new one." Link to request access
Token expired 400 "This link has expired (valid for 1 hour). Request a new one." Link to request access
Token already used 400 "This link has already been used. Request a new one." Link to request access

Error Response Pattern

@app.errorhandler(EmailAlreadyRegistered)
def handle_email_already_registered(error):
    """Handle duplicate email registration attempt."""
    flash(
        "This email is already registered for this exchange. "
        "Click 'Request Access' below to get a new access link.",
        "error"
    )
    # Render registration page with "Request Access" option highlighted
    return render_template('participant/register.html', show_request_access=True), 400

@app.errorhandler(RateLimitExceeded)
def handle_rate_limit(error):
    """Handle rate limit exceeded."""
    flash("Too many requests. Please try again later.", "error")
    return render_template('participant/register.html'), 429

Security Considerations

Email Enumeration Prevention

Threat: Attacker tries to discover which emails are registered.

Mitigation:

  • Request access returns generic success message
  • Same response time whether email exists or not
  • No indication if email is registered

Implementation:

# Good: Generic message
return "If your email is registered, you'll receive a link."

# Bad: Reveals registration status
if participant:
    return "Link sent!"
else:
    return "Email not found."

Token Security

Threats:

  • Token interception
  • Token guessing
  • Replay attacks

Mitigations:

  • 32-byte cryptographic randomness (2^256 possible tokens)
  • Single-use tokens (marked as used after validation)
  • 1-hour expiration
  • Tokens sent only over HTTPS
  • Token hash stored, not plaintext

Session Security

Threats:

  • Session hijacking
  • Session fixation

Mitigations:

  • HttpOnly cookies (prevent JavaScript access)
  • Secure flag (HTTPS only)
  • SameSite=Lax (CSRF protection)
  • New session ID on authentication
  • Server-side session storage

Testing

Unit Tests

class TestParticipantAuth(unittest.TestCase):

    def test_register_new_participant(self):
        """Test successful new participant registration."""
        result = register_participant(
            exchange_slug='test-exchange',
            form_data={
                'name': 'Alice',
                'email': 'alice@example.com',
                'gift_ideas': 'Books',
                'reminder_enabled': True
            }
        )

        self.assertTrue(result.success)
        self.assertIsNotNone(result.participant_id)

        # Verify participant created
        participant = Participant.query.get(result.participant_id)
        self.assertEqual(participant.name, 'Alice')
        self.assertEqual(participant.email, 'alice@example.com')

    def test_duplicate_email_rejected(self):
        """Test that duplicate email registration is rejected."""
        # Register first participant
        register_participant('test-exchange', {
            'name': 'Alice',
            'email': 'alice@example.com',
            'gift_ideas': 'Books'
        })

        # Attempt duplicate registration
        with self.assertRaises(EmailAlreadyRegistered):
            register_participant('test-exchange', {
                'name': 'Alice Again',
                'email': 'alice@example.com',
                'gift_ideas': 'Different ideas'
            })

    def test_generate_magic_token(self):
        """Test magic token generation."""
        participant = create_test_participant()
        token = generate_magic_token(participant.id, participant.exchange_id)

        # Token should be URL-safe base64
        self.assertEqual(len(token), 43)
        self.assertTrue(token.replace('-', '').replace('_', '').isalnum())

        # Hash should be stored
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        magic_token = MagicTokenModel.query.filter_by(token_hash=token_hash).first()
        self.assertIsNotNone(magic_token)
        self.assertEqual(magic_token.participant_id, participant.id)

    def test_validate_magic_token_success(self):
        """Test successful token validation."""
        participant = create_test_participant()
        token = generate_magic_token(participant.id, participant.exchange_id)

        result = validate_magic_token(token)

        self.assertTrue(result.valid)
        self.assertEqual(result.participant_id, participant.id)
        self.assertEqual(result.exchange_id, participant.exchange_id)

    def test_validate_expired_token(self):
        """Test that expired tokens are rejected."""
        participant = create_test_participant()
        token = generate_magic_token(participant.id, participant.exchange_id)

        # Manually expire the token
        token_hash = hashlib.sha256(token.encode()).hexdigest()
        magic_token = MagicTokenModel.query.filter_by(token_hash=token_hash).first()
        magic_token.expires_at = datetime.utcnow() - timedelta(hours=1)
        db.session.commit()

        result = validate_magic_token(token)

        self.assertFalse(result.valid)
        self.assertEqual(result.error, "Token has expired")

    def test_single_use_token(self):
        """Test that tokens can only be used once."""
        participant = create_test_participant()
        token = generate_magic_token(participant.id, participant.exchange_id)

        # First use - success
        result1 = validate_magic_token(token)
        self.assertTrue(result1.valid)

        # Second use - failure
        result2 = validate_magic_token(token)
        self.assertFalse(result2.valid)
        self.assertEqual(result2.error, "Token has already been used")

    def test_request_magic_link_rate_limit(self):
        """Test rate limiting on magic link requests."""
        participant = create_test_participant()
        email = participant.email

        # First 3 requests succeed
        for i in range(3):
            result = request_magic_link('test-exchange', email)
            self.assertTrue(result.success)

        # 4th request fails (rate limit)
        with self.assertRaises(RateLimitExceeded):
            request_magic_link('test-exchange', email)

Integration Tests

class TestParticipantAuthIntegration(TestCase):

    def test_full_registration_flow(self):
        """Test complete registration flow from form to email."""
        with mail.record_messages() as outbox:
            # Submit registration form
            response = self.client.post('/exchange/test-slug/register', data={
                'name': 'Alice',
                'email': 'alice@example.com',
                'gift_ideas': 'Books, coffee',
                'reminder_enabled': True,
                'csrf_token': get_csrf_token()
            })

            # Should redirect to success page
            self.assertEqual(response.status_code, 302)
            self.assertIn('/register/success', response.location)

            # Email should be sent
            self.assertEqual(len(outbox), 1)
            email = outbox[0]
            self.assertIn('Welcome to', email.subject)
            self.assertIn('alice@example.com', email.recipients)

            # Extract magic link from email
            magic_link_match = re.search(r'/auth/participant/magic/([A-Za-z0-9_-]+)', email.body)
            self.assertIsNotNone(magic_link_match)
            token = magic_link_match.group(1)

            # Click magic link
            response = self.client.get(f'/auth/participant/magic/{token}')

            # Should redirect to dashboard
            self.assertEqual(response.status_code, 302)
            self.assertIn('/participant/dashboard', response.location)

            # Session should be created
            with self.client.session_transaction() as sess:
                self.assertEqual(sess['user_type'], 'participant')
                self.assertIsNotNone(sess['user_id'])

Future Enhancements

  1. Remember Device: Optional "remember this device" for 30-day sessions
  2. Multi-Exchange Dashboard: Single view showing all exchanges for an email
  3. Biometric Auth: WebAuthn support for returning participants
  4. QR Code Links: Generate QR codes for magic links (easier mobile access)
  5. Session Management: View active sessions and revoke access

References