diff --git a/src/app.py b/src/app.py index 4f1838b..bbbac19 100644 --- a/src/app.py +++ b/src/app.py @@ -141,6 +141,7 @@ def register_setup_check(app: Flask) -> None: "participant.request_access", "participant.magic_login", "participant.dashboard", + "participant.logout", ]: return diff --git a/src/decorators/auth.py b/src/decorators/auth.py index 33da940..f024a35 100644 --- a/src/decorators/auth.py +++ b/src/decorators/auth.py @@ -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 diff --git a/src/models/magic_token.py b/src/models/magic_token.py index 150b891..8e1ad9b 100644 --- a/src/models/magic_token.py +++ b/src/models/magic_token.py @@ -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: diff --git a/src/routes/participant.py b/src/routes/participant.py index c89c75b..4dd0a3b 100644 --- a/src/routes/participant.py +++ b/src/routes/participant.py @@ -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/") -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/") +@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("/") diff --git a/src/templates/errors/403.html b/src/templates/errors/403.html new file mode 100644 index 0000000..ce76c28 --- /dev/null +++ b/src/templates/errors/403.html @@ -0,0 +1,12 @@ +{% extends "layouts/base.html" %} + +{% block title %}Forbidden - Sneaky Klaus{% endblock %} + +{% block content %} +
+
+

403 - Forbidden

+
+

You don't have permission to access this page.

+
+{% endblock %} diff --git a/src/templates/errors/magic_link_error.html b/src/templates/errors/magic_link_error.html new file mode 100644 index 0000000..410eb01 --- /dev/null +++ b/src/templates/errors/magic_link_error.html @@ -0,0 +1,13 @@ +{% extends "layouts/base.html" %} + +{% block title %}Magic Link Error - Sneaky Klaus{% endblock %} + +{% block content %} +
+
+

Magic Link Error

+
+

There was a problem with your magic link.

+

Please request a new one to access your participant dashboard.

+
+{% endblock %} diff --git a/src/templates/participant/dashboard.html b/src/templates/participant/dashboard.html new file mode 100644 index 0000000..ea65d17 --- /dev/null +++ b/src/templates/participant/dashboard.html @@ -0,0 +1,48 @@ +{% extends "layouts/base.html" %} + +{% block title %}{{ exchange.name }} - Participant Dashboard{% endblock %} + +{% block content %} +
+
+

{{ exchange.name }}

+

Welcome, {{ participant.name }}!

+
+ +
+

Exchange Details

+
+
Budget
+
{{ exchange.budget }}
+ +
Gift Exchange Date
+
{{ exchange.exchange_date.strftime('%Y-%m-%d') }}
+ +
Registration Deadline
+
{{ exchange.registration_close_date.strftime('%Y-%m-%d') }}
+
+
+ +
+

Your Information

+
+
Name
+
{{ participant.name }}
+ +
Email
+
{{ participant.email }}
+ + {% if participant.gift_ideas %} +
Gift Ideas
+
{{ participant.gift_ideas }}
+ {% endif %} +
+
+ +
+
+ +
+
+
+{% endblock %} diff --git a/tests/integration/test_story_5_2_magic_link_login.py b/tests/integration/test_story_5_2_magic_link_login.py new file mode 100644 index 0000000..ef15a85 --- /dev/null +++ b/tests/integration/test_story_5_2_magic_link_login.py @@ -0,0 +1,194 @@ +"""Tests for Story 5.2: Magic Link Login. + +As a participant, I want to click a magic link in my email +so that I can securely access my participant dashboard. +""" + +import hashlib +from datetime import UTC, datetime, timedelta + +from src.models.exchange import Exchange +from src.models.magic_token import MagicToken +from src.models.participant import Participant + + +class TestStory52MagicLinkLogin: + """Test suite for Story 5.2: Magic Link Login.""" + + def test_valid_magic_link_creates_session(self, client, db): + """Test that valid magic link creates session and redirects.""" + future_close_date = datetime.utcnow() + timedelta(days=7) + future_exchange_date = datetime.utcnow() + timedelta(days=14) + + exchange = Exchange( + slug="winter2025", + name="Office Secret Santa 2025", + budget="$50", + max_participants=10, + registration_close_date=future_close_date, + exchange_date=future_exchange_date, + timezone="America/New_York", + state=Exchange.STATE_REGISTRATION_OPEN, + ) + db.session.add(exchange) + db.session.flush() + + participant = Participant( + exchange_id=exchange.id, + name="John Doe", + email="john@example.com", + reminder_enabled=True, + ) + db.session.add(participant) + db.session.flush() + + # Create magic token + token = "test-token-12345" + token_hash = hashlib.sha256(token.encode()).hexdigest() + + magic_token = MagicToken( + token_hash=token_hash, + token_type="magic_link", + email="john@example.com", + participant_id=participant.id, + exchange_id=exchange.id, + expires_at=datetime.now(UTC) + timedelta(hours=1), + ) + db.session.add(magic_token) + db.session.commit() + + # Click magic link + response = client.get( + f"/auth/participant/magic/{token}", + follow_redirects=False, + ) + + # Should redirect to dashboard + assert response.status_code == 302 + assert f"/participant/exchange/{exchange.id}" in response.location + + # Should create session + with client.session_transaction() as sess: + assert "user_id" in sess + assert sess["user_id"] == participant.id + assert sess["user_type"] == "participant" + assert sess["exchange_id"] == exchange.id + + # Should mark token as used + db.session.refresh(magic_token) + assert magic_token.used_at is not None + + def test_expired_token_shows_error(self, client, db): + """Test that expired token shows error message.""" + future_close_date = datetime.utcnow() + timedelta(days=7) + future_exchange_date = datetime.utcnow() + timedelta(days=14) + + exchange = Exchange( + slug="test123", + name="Test Exchange", + budget="$30", + max_participants=10, + registration_close_date=future_close_date, + exchange_date=future_exchange_date, + timezone="America/New_York", + state=Exchange.STATE_REGISTRATION_OPEN, + ) + db.session.add(exchange) + db.session.flush() + + participant = Participant( + exchange_id=exchange.id, + name="Jane Smith", + email="jane@example.com", + reminder_enabled=True, + ) + db.session.add(participant) + db.session.flush() + + # Create expired token + token = "expired-token-12345" + token_hash = hashlib.sha256(token.encode()).hexdigest() + + magic_token = MagicToken( + token_hash=token_hash, + token_type="magic_link", + email="jane@example.com", + participant_id=participant.id, + exchange_id=exchange.id, + expires_at=datetime.now(UTC) - timedelta(hours=1), # Expired + ) + db.session.add(magic_token) + db.session.commit() + + # Try to use expired token + response = client.get(f"/auth/participant/magic/{token}") + + # Should show error + assert response.status_code == 200 + assert b"expired" in response.data or b"invalid" in response.data + + # Should not create session + with client.session_transaction() as sess: + assert "user_id" not in sess + + # Should not mark token as used + db.session.refresh(magic_token) + assert magic_token.used_at is None + + def test_used_token_shows_error(self, client, db): + """Test that already-used token shows error message.""" + future_close_date = datetime.utcnow() + timedelta(days=7) + future_exchange_date = datetime.utcnow() + timedelta(days=14) + + exchange = Exchange( + slug="test123", + name="Test Exchange", + budget="$30", + max_participants=10, + registration_close_date=future_close_date, + exchange_date=future_exchange_date, + timezone="America/New_York", + state=Exchange.STATE_REGISTRATION_OPEN, + ) + db.session.add(exchange) + db.session.flush() + + participant = Participant( + exchange_id=exchange.id, + name="Bob Jones", + email="bob@example.com", + reminder_enabled=True, + ) + db.session.add(participant) + db.session.flush() + + # Create used token + token = "used-token-12345" + token_hash = hashlib.sha256(token.encode()).hexdigest() + + magic_token = MagicToken( + token_hash=token_hash, + token_type="magic_link", + email="bob@example.com", + participant_id=participant.id, + exchange_id=exchange.id, + expires_at=datetime.now(UTC) + timedelta(hours=1), + used_at=datetime.now(UTC) - timedelta(minutes=5), # Already used + ) + db.session.add(magic_token) + db.session.commit() + + # Try to use token again + response = client.get(f"/auth/participant/magic/{token}") + + # Should show error + assert response.status_code == 200 + assert b"invalid" in response.data or b"already been used" in response.data + + def test_invalid_token_shows_error(self, client): + """Test that invalid/unknown token shows error.""" + response = client.get("/auth/participant/magic/invalid-token-xyz") + + # Should show error + assert response.status_code == 200 + assert b"Invalid" in response.data diff --git a/tests/integration/test_story_5_3_participant_session.py b/tests/integration/test_story_5_3_participant_session.py new file mode 100644 index 0000000..25e00f0 --- /dev/null +++ b/tests/integration/test_story_5_3_participant_session.py @@ -0,0 +1,182 @@ +"""Tests for Story 5.3: Participant Session. + +As a logged-in participant, I want to access my dashboard +and manage my session. +""" + +from datetime import datetime, timedelta + +from src.models.exchange import Exchange +from src.models.participant import Participant + + +class TestStory53ParticipantSession: + """Test suite for Story 5.3: Participant Session.""" + + def test_participant_dashboard_requires_login(self, client, db): + """Test that dashboard redirects to request access if not logged in.""" + future_close_date = datetime.utcnow() + timedelta(days=7) + future_exchange_date = datetime.utcnow() + timedelta(days=14) + + exchange = Exchange( + slug="test123", + name="Test Exchange", + budget="$30", + max_participants=10, + registration_close_date=future_close_date, + exchange_date=future_exchange_date, + timezone="America/New_York", + state=Exchange.STATE_REGISTRATION_OPEN, + ) + db.session.add(exchange) + db.session.commit() + + # Try to access dashboard without login + response = client.get( + f"/participant/exchange/{exchange.id}", + follow_redirects=False, + ) + + # Should redirect or show error + assert response.status_code in [302, 403] + + def test_logged_in_participant_sees_dashboard(self, client, db): + """Test that logged-in participant can access dashboard.""" + future_close_date = datetime.utcnow() + timedelta(days=7) + future_exchange_date = datetime.utcnow() + timedelta(days=14) + + exchange = Exchange( + slug="test123", + name="Test Exchange", + budget="$30", + max_participants=10, + registration_close_date=future_close_date, + exchange_date=future_exchange_date, + timezone="America/New_York", + state=Exchange.STATE_REGISTRATION_OPEN, + ) + db.session.add(exchange) + db.session.flush() + + participant = Participant( + exchange_id=exchange.id, + name="John Doe", + email="john@example.com", + reminder_enabled=True, + ) + db.session.add(participant) + db.session.commit() + + # Log in as participant + with client.session_transaction() as sess: + sess["user_id"] = participant.id + sess["user_type"] = "participant" + sess["exchange_id"] = exchange.id + + # Access dashboard + response = client.get(f"/participant/exchange/{exchange.id}") + + # Should show dashboard + assert response.status_code == 200 + assert b"Test Exchange" in response.data + assert b"John Doe" in response.data + + def test_participant_can_logout(self, client, db): + """Test that participant can log out.""" + future_close_date = datetime.utcnow() + timedelta(days=7) + future_exchange_date = datetime.utcnow() + timedelta(days=14) + + exchange = Exchange( + slug="test123", + name="Test Exchange", + budget="$30", + max_participants=10, + registration_close_date=future_close_date, + exchange_date=future_exchange_date, + timezone="America/New_York", + state=Exchange.STATE_REGISTRATION_OPEN, + ) + db.session.add(exchange) + db.session.flush() + + participant = Participant( + exchange_id=exchange.id, + name="Jane Smith", + email="jane@example.com", + reminder_enabled=True, + ) + db.session.add(participant) + db.session.commit() + + # Log in + with client.session_transaction() as sess: + sess["user_id"] = participant.id + sess["user_type"] = "participant" + sess["exchange_id"] = exchange.id + + # Logout + response = client.post("/participant/logout", follow_redirects=True) + + # Should show success message + assert response.status_code == 200 + assert b"logged out" in response.data + + def test_participant_cannot_access_other_exchange(self, client, db): + """Test that participant cannot access another exchange's dashboard.""" + future_close_date = datetime.utcnow() + timedelta(days=7) + future_exchange_date = datetime.utcnow() + timedelta(days=14) + + # Create two exchanges + exchange1 = Exchange( + slug="exchange1", + name="Exchange 1", + budget="$30", + max_participants=10, + registration_close_date=future_close_date, + exchange_date=future_exchange_date, + timezone="America/New_York", + state=Exchange.STATE_REGISTRATION_OPEN, + ) + exchange2 = Exchange( + slug="exchange2", + name="Exchange 2", + budget="$40", + max_participants=10, + registration_close_date=future_close_date, + exchange_date=future_exchange_date, + timezone="America/New_York", + state=Exchange.STATE_REGISTRATION_OPEN, + ) + db.session.add(exchange1) + db.session.add(exchange2) + db.session.flush() + + # Create participant for exchange1 + participant = Participant( + exchange_id=exchange1.id, + name="Alice", + email="alice@example.com", + reminder_enabled=True, + ) + db.session.add(participant) + db.session.commit() + + # Log in as participant for exchange1 + with client.session_transaction() as sess: + sess["user_id"] = participant.id + sess["user_type"] = "participant" + sess["exchange_id"] = exchange1.id + + # Try to access exchange2's dashboard + response = client.get(f"/participant/exchange/{exchange2.id}") + + # Should be forbidden + assert response.status_code == 403 + + def test_participant_required_decorator_works(self, client): + """Test that participant_required decorator protects routes.""" + # Try to access protected route without login + response = client.get("/participant/exchange/999") + + # Should redirect or show error + assert response.status_code in [302, 403]