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

@@ -141,6 +141,7 @@ def register_setup_check(app: Flask) -> None:
"participant.request_access",
"participant.magic_login",
"participant.dashboard",
"participant.logout",
]:
return

View File

@@ -2,7 +2,7 @@
from functools import wraps
from flask import flash, redirect, session, url_for
from flask import abort, flash, redirect, session, url_for
def admin_required(f):
@@ -26,3 +26,24 @@ def admin_required(f):
return f(*args, **kwargs)
return decorated_function
def participant_required(f):
"""Decorator to require participant authentication for a route.
Checks if user is logged in as participant. If not, returns 403 Forbidden.
Args:
f: The function to decorate.
Returns:
Decorated function that checks authentication.
"""
@wraps(f)
def decorated_function(*args, **kwargs):
if "user_id" not in session or session.get("user_type") != "participant":
abort(403)
return f(*args, **kwargs)
return decorated_function

View File

@@ -82,7 +82,16 @@ class MagicToken(db.Model): # type: ignore[name-defined]
@property
def is_expired(self) -> bool:
"""Check if token has expired."""
return bool(datetime.now(UTC) > self.expires_at)
# Handle both timezone-aware and timezone-naive datetimes
# SQLite stores datetimes as strings and may lose timezone info
now = datetime.now(UTC)
expires = self.expires_at
# If expires_at is timezone-naive, make now timezone-naive for comparison
if expires.tzinfo is None:
now = now.replace(tzinfo=None)
return bool(now > expires)
@property
def is_used(self) -> bool:

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("/")

View File

@@ -0,0 +1,12 @@
{% extends "layouts/base.html" %}
{% block title %}Forbidden - Sneaky Klaus{% endblock %}
{% block content %}
<article>
<header>
<h1>403 - Forbidden</h1>
</header>
<p>You don't have permission to access this page.</p>
</article>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends "layouts/base.html" %}
{% block title %}Magic Link Error - Sneaky Klaus{% endblock %}
{% block content %}
<article>
<header>
<h1>Magic Link Error</h1>
</header>
<p>There was a problem with your magic link.</p>
<p>Please request a new one to access your participant dashboard.</p>
</article>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends "layouts/base.html" %}
{% block title %}{{ exchange.name }} - Participant Dashboard{% endblock %}
{% block content %}
<article>
<header>
<h1>{{ exchange.name }}</h1>
<p>Welcome, {{ participant.name }}!</p>
</header>
<section>
<h2>Exchange Details</h2>
<dl>
<dt>Budget</dt>
<dd>{{ exchange.budget }}</dd>
<dt>Gift Exchange Date</dt>
<dd>{{ exchange.exchange_date.strftime('%Y-%m-%d') }}</dd>
<dt>Registration Deadline</dt>
<dd>{{ exchange.registration_close_date.strftime('%Y-%m-%d') }}</dd>
</dl>
</section>
<section>
<h2>Your Information</h2>
<dl>
<dt>Name</dt>
<dd>{{ participant.name }}</dd>
<dt>Email</dt>
<dd>{{ participant.email }}</dd>
{% if participant.gift_ideas %}
<dt>Gift Ideas</dt>
<dd>{{ participant.gift_ideas }}</dd>
{% endif %}
</dl>
</section>
<section>
<form method="POST" action="{{ url_for('participant.logout') }}">
<button type="submit">Logout</button>
</form>
</section>
</article>
{% endblock %}