# 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 ```mermaid 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**: ```mermaid 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**: ```python 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**: ```mermaid 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**: ```python 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**: ```python 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**: ```mermaid 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**: ```python 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**: ```python { '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**: ```python 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**: ```python 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**: ```python @app.route('/participant/exchange/') @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 ``` ### How QA Retrieves Links #### For Podman Containers ```bash # 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 ```bash # 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: ```python 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: ```python 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**: ```python # 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**: ```python { "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) ### Magic Link Email **Template**: `templates/emails/participant/magic_link.html` **Subject**: "Access Your Sneaky Klaus Registration" **Variables**: ```python { "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**: ```python 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 ```python @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**: ```python # 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 ```python 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 ```python 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 - [ADR-0002: Authentication Strategy](../../../decisions/0002-authentication-strategy.md) - [Data Model v0.2.0](../data-model.md) - [API Specification v0.2.0](../api-spec.md) - [Notifications Component](./notifications.md) - [OWASP Authentication Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)