feat: implement Stories 5.2 & 5.3 - Magic Link Login and Participant Session

Implemented complete participant authentication flow with magic link login and session management.

Story 5.2 - Magic Link Login:
- Participants can click magic links to securely access their dashboard
- Single-use tokens that expire after 1 hour
- Session creation with participant_id, user_type, and exchange_id
- Error handling for expired, used, or invalid tokens
- Fixed timezone-aware datetime comparison for SQLite compatibility

Story 5.3 - Participant Session:
- Authenticated participants can access their exchange dashboard
- participant_required decorator protects participant-only routes
- Participants can only access their own exchange (403 for others)
- Logout functionality clears session and redirects appropriately
- Unauthenticated access returns 403 Forbidden

Technical changes:
- Added magic_login() route for token validation and session creation
- Added dashboard() route with exchange and participant data
- Added logout() route with smart redirect to request access page
- Added participant_required decorator for route protection
- Enhanced MagicToken.is_expired for timezone-naive datetime handling
- Added participant.logout to setup check exclusions
- Created templates: dashboard.html, magic_link_error.html, 403.html
- Comprehensive test coverage for all user flows

Acceptance Criteria Met:
✓ Valid magic links create authenticated sessions
✓ Invalid/expired/used tokens show appropriate errors
✓ Authenticated participants see their dashboard
✓ Participants cannot access other exchanges
✓ Unauthenticated users cannot access protected routes
✓ Logout clears session and provides feedback

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-22 17:41:29 -07:00
parent 321d7b1395
commit 44ef77ca68
9 changed files with 584 additions and 7 deletions

View File

@@ -4,9 +4,19 @@ import hashlib
import secrets
from datetime import UTC, datetime, timedelta
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
from flask import (
Blueprint,
abort,
flash,
redirect,
render_template,
request,
session,
url_for,
)
from src.app import db
from src.decorators.auth import participant_required
from src.forms.participant import MagicLinkRequestForm, ParticipantRegistrationForm
from src.models.exchange import Exchange
from src.models.magic_token import MagicToken
@@ -263,14 +273,101 @@ def request_access(slug: str):
@participant_bp.route("/auth/participant/magic/<token>")
def magic_login(token: str): # noqa: ARG001
def magic_login(token: str):
"""Magic link login for participants.
Args:
token: Magic token from email link.
Returns:
Redirect to participant dashboard.
Redirect to participant dashboard or error page.
"""
# Placeholder for Story 5.2
abort(404)
# Hash the incoming token to look it up
token_hash = hashlib.sha256(token.encode()).hexdigest()
# Look up the token
magic_token = db.session.query(MagicToken).filter_by(token_hash=token_hash).first()
if not magic_token:
flash("Invalid or expired magic link.", "error")
return render_template("errors/magic_link_error.html"), 200
# Validate token
if magic_token.is_expired:
flash("This magic link has expired. Please request a new one.", "error")
return render_template("errors/magic_link_error.html"), 200
if magic_token.is_used:
flash(
"This magic link has already been used. Please request a new one.",
"error",
)
return render_template("errors/magic_link_error.html"), 200
# Mark token as used
magic_token.used_at = datetime.now(UTC)
db.session.commit()
# Create session
session["user_id"] = magic_token.participant_id
session["user_type"] = "participant"
session["exchange_id"] = magic_token.exchange_id
# Redirect to participant dashboard
return redirect(url_for("participant.dashboard", id=magic_token.exchange_id))
@participant_bp.route("/participant/exchange/<int:id>")
@participant_required
def dashboard(id: int): # noqa: A002
"""Participant dashboard.
Args:
id: Exchange ID.
Returns:
Rendered dashboard template.
"""
# Verify participant has access to this exchange
if session.get("exchange_id") != id:
abort(403)
# Get exchange and participant
exchange = db.session.query(Exchange).filter_by(id=id).first()
if not exchange:
abort(404)
participant = db.session.query(Participant).filter_by(id=session["user_id"]).first()
if not participant:
abort(404)
return render_template(
"participant/dashboard.html",
exchange=exchange,
participant=participant,
)
@participant_bp.route("/participant/logout", methods=["POST"])
def logout():
"""Participant logout.
Returns:
Redirect to homepage or success message.
"""
# Store exchange_id before clearing session
exchange_id = session.get("exchange_id")
# Clear session
session.clear()
flash("You've been logged out successfully.", "success")
# Redirect to the exchange's request access page if we know the exchange
if exchange_id:
exchange = db.session.query(Exchange).filter_by(id=exchange_id).first()
if exchange:
return redirect(url_for("participant.request_access", slug=exchange.slug))
# Fallback to root
return redirect("/")