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:
@@ -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("/")
|
||||
|
||||
Reference in New Issue
Block a user