feat: implement Story 5.1 - Magic Link Request
Allow participants to request magic links for dashboard access: - POST /exchange/<slug>/request-access handles form submission - Accept email, look up participant in database - Generate token (secrets.token_urlsafe(32)), store SHA-256 hash - Send magic link email via EmailService.send_magic_link() - Rate limit: 3 requests per hour per email - Always show generic success message (prevent enumeration) - Only send email if participant exists - Case-insensitive email lookup - Comprehensive test suite with 7 tests Includes: - MagicLinkRequestForm with email validation - request_access.html template - GET endpoint to display request form All tests passing (97 total), 91% coverage maintained. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
|
||||
from src.app import db
|
||||
from src.forms.participant import ParticipantRegistrationForm
|
||||
from src.forms.participant import MagicLinkRequestForm, ParticipantRegistrationForm
|
||||
from src.models.exchange import Exchange
|
||||
from src.models.magic_token import MagicToken
|
||||
from src.models.participant import Participant
|
||||
@@ -166,6 +166,102 @@ def register_success(slug: str):
|
||||
)
|
||||
|
||||
|
||||
@participant_bp.route("/exchange/<slug>/request-access", methods=["GET", "POST"])
|
||||
def request_access(slug: str):
|
||||
"""Request a magic link for participant access.
|
||||
|
||||
Args:
|
||||
slug: Exchange registration slug.
|
||||
|
||||
Returns:
|
||||
Rendered request access page template or success message.
|
||||
"""
|
||||
# Find the exchange by slug
|
||||
exchange = db.session.query(Exchange).filter_by(slug=slug).first()
|
||||
if not exchange:
|
||||
abort(404)
|
||||
|
||||
# Create the magic link request form
|
||||
form = MagicLinkRequestForm()
|
||||
|
||||
# Handle POST request
|
||||
if form.validate_on_submit():
|
||||
# Lowercase email for consistency
|
||||
email = form.email.data.lower()
|
||||
|
||||
# Rate limiting: 3 requests per hour per email
|
||||
rate_limit_key = f"magic_link:{slug}:{email}"
|
||||
|
||||
if check_rate_limit(rate_limit_key, max_attempts=3, window_minutes=60):
|
||||
abort(429) # Too Many Requests
|
||||
|
||||
# Look up participant
|
||||
participant = (
|
||||
db.session.query(Participant)
|
||||
.filter_by(exchange_id=exchange.id, email=email)
|
||||
.first()
|
||||
)
|
||||
|
||||
# Always show generic success message (prevent enumeration)
|
||||
# But only send email if participant exists
|
||||
if participant:
|
||||
# Generate magic token
|
||||
token = secrets.token_urlsafe(32)
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
magic_token = MagicToken(
|
||||
token_hash=token_hash,
|
||||
token_type="magic_link",
|
||||
email=email,
|
||||
participant_id=participant.id,
|
||||
exchange_id=exchange.id,
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=1),
|
||||
)
|
||||
magic_token.validate()
|
||||
db.session.add(magic_token)
|
||||
db.session.commit()
|
||||
|
||||
# Send magic link email
|
||||
magic_link_url = url_for(
|
||||
"participant.magic_login",
|
||||
token=token,
|
||||
_external=True,
|
||||
)
|
||||
|
||||
email_service = EmailService()
|
||||
try:
|
||||
email_service.send_magic_link(
|
||||
to=email,
|
||||
magic_link_url=magic_link_url,
|
||||
exchange_name=exchange.name,
|
||||
)
|
||||
except Exception as e:
|
||||
# Log error but don't fail the request
|
||||
print(f"Failed to send magic link email: {e}")
|
||||
|
||||
# Increment rate limit
|
||||
increment_rate_limit(rate_limit_key, window_minutes=60)
|
||||
|
||||
# Show generic success message
|
||||
flash(
|
||||
"If you're registered for this exchange, we've sent you a magic link. "
|
||||
"Please check your email.",
|
||||
"success",
|
||||
)
|
||||
return render_template(
|
||||
"participant/request_access.html",
|
||||
exchange=exchange,
|
||||
form=form,
|
||||
success=True,
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"participant/request_access.html",
|
||||
exchange=exchange,
|
||||
form=form,
|
||||
)
|
||||
|
||||
|
||||
@participant_bp.route("/auth/participant/magic/<token>")
|
||||
def magic_login(token: str): # noqa: ARG001
|
||||
"""Magic link login for participants.
|
||||
|
||||
Reference in New Issue
Block a user