Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 additions & 1 deletion api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from pydantic import BaseModel
from slowapi.errors import RateLimitExceeded # noqa: F401 — used indirectly via app handler

from emails import send_magic_link
from emails import send_magic_link, send_verify_email
from limiter import limiter

# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -180,6 +180,15 @@ async def _find_or_create_user(
else:
user = {"id": str(row["id"]), "email": row["email"]}

# Reaching this function means email ownership was proven — magic-link
# (clicked a link sent to the address) or Google OAuth (Google asserts the
# verified email). Mark the account verified so the beta/premium grant in
# ensure_profile can fire. This also verifies a pre-existing password account
# that later signs in via magic-link/Google with the same address.
await conn.execute(
"UPDATE auth_users SET email_verified = TRUE WHERE id = $1", user["id"],
)

# Ensure profiles row exists and email is current. Seed the name from the
# OAuth profile, but only when not already set (COALESCE keeps an edited
# name; NULLIF treats an empty incoming value as "no name").
Expand Down Expand Up @@ -229,6 +238,10 @@ class MagicLinkVerifyBody(BaseModel):
token: str


class VerifyEmailBody(BaseModel):
token: str


class PasswordSignupBody(BaseModel):
email: str
password: str
Expand Down Expand Up @@ -329,6 +342,48 @@ async def magic_link_verify(request: Request, body: MagicLinkVerifyBody):
return _token_response(user["id"], user["email"], rt)


@router.post("/verify-email")
@limiter.limit("10/minute")
async def verify_email(request: Request, body: VerifyEmailBody):
"""
Consume an email-verification token: mark the account verified and
re-evaluate the beta/premium grant at that point. Token-authenticated, so no
session is required (the user may open the link in a fresh browser). Reuses
the magic_tokens table — same one-time, TTL'd semantics as a magic link.
"""
token = body.token.strip()
now = datetime.now(timezone.utc)

async with _pool().acquire() as conn:
row = await conn.fetchrow(
"SELECT id, email, expires_at, used_at FROM magic_tokens WHERE token = $1",
token,
)
if row is None:
raise HTTPException(status_code=401, detail="Invalid or expired verification link")
if row["used_at"] is not None:
raise HTTPException(status_code=401, detail="Verification link already used")
if row["expires_at"] < now:
raise HTTPException(status_code=401, detail="Verification link has expired")

await conn.execute(
"UPDATE magic_tokens SET used_at = $1 WHERE id = $2", now, row["id"],
)
urow = await conn.fetchrow(
"UPDATE auth_users SET email_verified = TRUE WHERE email = $1 RETURNING id",
row["email"],
)
if urow is not None:
# Re-run ensure_profile now that email_verified is TRUE, so the
# beta/premium slot is (re)evaluated immediately rather than waiting
# for the next /me/profile call. Deferred import avoids a circular
# import (main imports this router at module load).
from main import ensure_profile # noqa: PLC0415
await ensure_profile(conn, str(urow["id"]), row["email"])

return {"verified": True}


# ── Password auth ────────────────────────────────────────────────────────────

@router.post("/password/signup", status_code=201)
Expand All @@ -353,6 +408,8 @@ async def password_signup(request: Request, body: PasswordSignupBody):
raise HTTPException(status_code=409, detail="Email already registered")

new_id = str(uuid.uuid4())
# email_verified defaults to FALSE (migration 032): a password account
# must confirm ownership before it can claim a beta/premium slot.
await conn.execute(
"""
INSERT INTO auth_users (id, email, password_hash)
Expand All @@ -368,8 +425,23 @@ async def password_signup(request: Request, body: PasswordSignupBody):
await conn.execute(
"UPDATE auth_users SET last_sign_in_at = now() WHERE id = $1", new_id
)
# Verification token (reuses the magic_tokens one-time, TTL'd table).
verify_token = secrets.token_urlsafe(32)
await conn.execute(
"INSERT INTO magic_tokens (email, token, expires_at) VALUES ($1, $2, $3)",
email, verify_token, datetime.now(timezone.utc) + _MAGIC_TTL,
)
rt = await _issue_refresh_token(conn, new_id)

# Send the verification email out of band. The account is usable immediately
# for the free instruments (which need no account at all); verification only
# unlocks the free Full Moon slot, so a send failure must not fail signup.
verify_link = f"{_FRONTEND_URL}/auth/callback?type=verify&token={verify_token}"
try:
await send_verify_email(email, verify_link)
except Exception as exc:
print(f"[auth] verification email failed for {email}: {exc}")

return _token_response(new_id, email, rt)


Expand Down
62 changes: 62 additions & 0 deletions api/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,48 @@ def _lang(native_language: str | None) -> str:
"da": "Hvis du ikke har anmodet om dette link, kan du roligt ignorere denne e-mail.",
},

# ── Email verification (password signup) ─────────────────────────────────
"verify_subject": {
"en": "Confirm your email — Cèrcol",
"ca": "Confirma el teu correu — Cèrcol",
"es": "Confirma tu correo — Cèrcol",
"fr": "Confirmez votre e-mail — Cèrcol",
"de": "Bestätige deine E-Mail — Cèrcol",
"da": "Bekræft din e-mail — Cèrcol",
},
"verify_heading": {
"en": "Confirm your email",
"ca": "Confirma el teu correu",
"es": "Confirma tu correo",
"fr": "Confirmez votre e-mail",
"de": "Bestätige deine E-Mail",
"da": "Bekræft din e-mail",
},
"verify_body": {
"en": "Confirm your email to unlock your free Full Moon assessment. The link is valid for 15 minutes.",
"ca": "Confirma el teu correu per desbloquejar la teva avaluació Lluna Plena gratuïta. L'enllaç és vàlid durant 15 minuts.",
"es": "Confirma tu correo para desbloquear tu evaluación Luna Llena gratuita. El enlace es válido durante 15 minutos.",
"fr": "Confirmez votre e-mail pour débloquer votre évaluation Pleine Lune gratuite. Le lien est valide pendant 15 minutes.",
"de": "Bestätige deine E-Mail, um deine kostenlose Vollmond-Auswertung freizuschalten. Der Link ist 15 Minuten gültig.",
"da": "Bekræft din e-mail for at låse op for din gratis Fuldmåne-vurdering. Linket er gyldigt i 15 minutter.",
},
"verify_button": {
"en": "Confirm email",
"ca": "Confirma el correu",
"es": "Confirmar correo",
"fr": "Confirmer l'e-mail",
"de": "E-Mail bestätigen",
"da": "Bekræft e-mail",
},
"verify_ignore": {
"en": "If you did not create a Cèrcol account, you can safely ignore this email.",
"ca": "Si no has creat un compte a Cèrcol, pots ignorar aquest correu.",
"es": "Si no has creado una cuenta en Cèrcol, puedes ignorar este correo.",
"fr": "Si vous n'avez pas créé de compte Cèrcol, vous pouvez ignorer cet e-mail.",
"de": "Falls du kein Cèrcol-Konto erstellt hast, kannst du diese E-Mail ignorieren.",
"da": "Hvis du ikke har oprettet en Cèrcol-konto, kan du roligt ignorere denne e-mail.",
},

# ── Witness assigned ─────────────────────────────────────────────────────
"wa_subject": {
"en": "{subject_display} has asked you to evaluate them on Cèrcol",
Expand Down Expand Up @@ -638,6 +680,16 @@ def _magic_link_html(link: str, lang: str) -> str:
)


def _verify_email_html(link: str, lang: str) -> str:
return _base(
_h1(_t("verify_heading", lang))
+ _p(_t("verify_body", lang))
+ _btn(link, _t("verify_button", lang))
+ _p(_t("verify_ignore", lang), muted=True),
lang=lang,
)


def _witness_assigned_html(
witness_name: str, subject_display: str, link: str, lang: str
) -> str:
Expand Down Expand Up @@ -708,6 +760,16 @@ async def send_magic_link(to_email: str, link: str, lang: str = "en") -> None:
)


async def send_verify_email(to_email: str, link: str, lang: str = "en") -> None:
"""Send a password-signup email-verification link."""
l = _lang(lang)
await _send(
to = to_email,
subject = _t("verify_subject", l),
html = _verify_email_html(link, l),
)


async def send_witness_assigned(
witness_name: str,
witness_email: str,
Expand Down
40 changes: 27 additions & 13 deletions api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,15 @@ def get_optional_user(

BETA_TOTAL = 500

# Grant guard: a beta/premium slot is only claimed by an account whose email is
# verified. Magic-link and Google prove ownership by construction (set TRUE at
# account creation); password accounts start FALSE and flip TRUE via
# /auth/verify-email. This closes disposable-email slot-farming on the unverified
# password-signup path. $1 is user_id in both ensure_profile branches. The
# fragment injects only static SQL (no user data), so f-string interpolation is
# safe; asyncpg still binds every value as a numbered parameter.
_EMAIL_VERIFIED_SQL = "COALESCE((SELECT email_verified FROM auth_users WHERE id = $1), FALSE)"


async def ensure_profile(conn: asyncpg.Connection, user_id: str, email: str | None = None) -> None:
"""Create/update profile row, claim beta premium if slots remain, link pending invitations.
Expand All @@ -353,24 +362,27 @@ async def ensure_profile(conn: asyncpg.Connection, user_id: str, email: str | No
/me/results, /results) the row already exists and the INSERT branch never
wins. Without granting on conflict, the first ~500 beta users were silently
left on premium = false and sent to the paywall. The grant condition mirrors
the INSERT: a slot remains AND the row is not already premium or beta. We
never revoke an existing grant or a paid premium (OR with the current value),
and a paid user is never relabelled beta (the grant requires NOT premium)."""
the INSERT: a slot remains AND the row is not already premium or beta AND the
account's email is verified (_EMAIL_VERIFIED_SQL). We never revoke an existing
grant or a paid premium (OR with the current value), and a paid user is never
relabelled beta (the grant requires NOT premium)."""
if email:
await conn.execute(
"""
f"""
INSERT INTO profiles (id, email, premium, is_beta)
SELECT $1, $2,
(SELECT COUNT(*) < $3 FROM profiles WHERE is_beta = TRUE),
(SELECT COUNT(*) < $3 FROM profiles WHERE is_beta = TRUE)
((SELECT COUNT(*) < $3 FROM profiles WHERE is_beta = TRUE) AND {_EMAIL_VERIFIED_SQL}),
((SELECT COUNT(*) < $3 FROM profiles WHERE is_beta = TRUE) AND {_EMAIL_VERIFIED_SQL})
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
premium = profiles.premium OR (
NOT profiles.premium AND NOT profiles.is_beta
AND (SELECT COUNT(*) < $3 FROM profiles WHERE is_beta = TRUE)),
AND (SELECT COUNT(*) < $3 FROM profiles WHERE is_beta = TRUE)
AND {_EMAIL_VERIFIED_SQL}),
is_beta = profiles.is_beta OR (
NOT profiles.premium AND NOT profiles.is_beta
AND (SELECT COUNT(*) < $3 FROM profiles WHERE is_beta = TRUE))
AND (SELECT COUNT(*) < $3 FROM profiles WHERE is_beta = TRUE)
AND {_EMAIL_VERIFIED_SQL})
""",
user_id, email.lower(), BETA_TOTAL,
)
Expand All @@ -384,18 +396,20 @@ async def ensure_profile(conn: asyncpg.Connection, user_id: str, email: str | No
)
else:
await conn.execute(
"""
f"""
INSERT INTO profiles (id, premium, is_beta)
SELECT $1,
(SELECT COUNT(*) < $2 FROM profiles WHERE is_beta = TRUE),
(SELECT COUNT(*) < $2 FROM profiles WHERE is_beta = TRUE)
((SELECT COUNT(*) < $2 FROM profiles WHERE is_beta = TRUE) AND {_EMAIL_VERIFIED_SQL}),
((SELECT COUNT(*) < $2 FROM profiles WHERE is_beta = TRUE) AND {_EMAIL_VERIFIED_SQL})
ON CONFLICT (id) DO UPDATE SET
premium = profiles.premium OR (
NOT profiles.premium AND NOT profiles.is_beta
AND (SELECT COUNT(*) < $2 FROM profiles WHERE is_beta = TRUE)),
AND (SELECT COUNT(*) < $2 FROM profiles WHERE is_beta = TRUE)
AND {_EMAIL_VERIFIED_SQL}),
is_beta = profiles.is_beta OR (
NOT profiles.premium AND NOT profiles.is_beta
AND (SELECT COUNT(*) < $2 FROM profiles WHERE is_beta = TRUE))
AND (SELECT COUNT(*) < $2 FROM profiles WHERE is_beta = TRUE)
AND {_EMAIL_VERIFIED_SQL})
""",
user_id, BETA_TOTAL,
)
Expand Down
45 changes: 35 additions & 10 deletions api/tests/test_beta_grant.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,21 +53,46 @@ def test_conflict_branch_grants_premium_and_beta():
assert "profiles.is_beta OR" in conflict
# The cap is enforced by the remaining-slots subquery.
assert "COUNT(*) <" in conflict
# The grant is now gated on a verified email (migration 032). Both the fresh
# INSERT and the ON CONFLICT branch must consult auth_users.email_verified.
assert "email_verified" in upsert.split("ON CONFLICT", 1)[0]
assert "email_verified" in conflict


def _granted(premium: bool, is_beta: bool, slots_remain: bool) -> tuple[bool, bool]:
"""Python mirror of the SQL grant expression for premium and is_beta."""
grant = (not premium) and (not is_beta) and slots_remain
def test_verified_gate_present_in_both_ensure_profile_branches():
# Email branch (above) and the no-email branch must both gate on verification.
conn = RecordingConn()
_run(main_module.ensure_profile(conn, "user-2", None))
upsert = next(q for q in conn.executed if "INSERT INTO profiles" in q)
assert "email_verified" in upsert
assert "COUNT(*) <" in upsert # slot cap still present alongside the gate


def _granted(premium: bool, is_beta: bool, slots_remain: bool, verified: bool) -> tuple[bool, bool]:
"""Python mirror of the SQL grant expression for premium and is_beta.

Mirrors: NOT premium AND NOT is_beta AND slots_remain AND email_verified.
"""
grant = (not premium) and (not is_beta) and slots_remain and verified
return (premium or grant, is_beta or grant)


def test_grant_truth_table():
# Defectively-denied beta user, slots remain -> granted both.
assert _granted(False, False, True) == (True, True)
# Slots exhausted -> stays paywalled.
assert _granted(False, False, False) == (False, False)
# Verified, defectively-denied beta user, slots remain -> granted both.
assert _granted(False, False, True, True) == (True, True)
# Slots exhausted -> stays paywalled even when verified.
assert _granted(False, False, False, True) == (False, False)
# Already a beta user -> unchanged, never double-touched.
assert _granted(True, True, True) == (True, True)
assert _granted(True, True, True, True) == (True, True)
# Paid customer (premium, not beta): keeps premium, never relabelled beta.
assert _granted(True, False, True) == (True, False)
assert _granted(True, False, False) == (True, False)
assert _granted(True, False, True, True) == (True, False)
assert _granted(True, False, False, True) == (True, False)


def test_unverified_never_claims_a_slot():
# The slot-farming close: an unverified account never gets the grant, even
# with slots free. It becomes eligible only once its email is verified.
assert _granted(False, False, True, False) == (False, False)
assert _granted(False, False, True, True) == (True, True)
# Verification never revokes a paid customer's premium.
assert _granted(True, False, True, False) == (True, False)
60 changes: 60 additions & 0 deletions api/tests/test_email_verification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""
Email-verification gating for the beta/premium grant (migration 032).

No database in CI, so we assert on the SQL a recording connection receives:
_find_or_create_user (the magic-link / Google path) must mark the account
email_verified = TRUE, because reaching it means ownership was proven. The
password-signup path deliberately does NOT go through _find_or_create_user, so
it stays unverified until /auth/verify-email flips the flag.
"""

from __future__ import annotations

import asyncio
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))

os.environ.setdefault("JWT_SECRET", "x" * 48)

import auth as auth_module # noqa: E402


class RecordingConn:
"""Records execute() SQL; fetchrow() returns None so the 'new user' branch runs."""

def __init__(self):
self.executed: list[str] = []

async def execute(self, query, *args):
self.executed.append(query)
return "INSERT 0 1"

async def fetchrow(self, query, *args):
return None


def _run(coro):
return asyncio.get_event_loop().run_until_complete(coro)


def test_magic_link_new_user_is_marked_verified():
conn = RecordingConn()
_run(auth_module._find_or_create_user(conn, "person@example.com"))
assert any(
"UPDATE auth_users SET email_verified = TRUE" in q for q in conn.executed
), "magic-link/Google account creation must set email_verified = TRUE"


def test_google_new_user_is_marked_verified():
conn = RecordingConn()
_run(auth_module._find_or_create_user(conn, "person@example.com", google_id="g-123"))
assert any(
"UPDATE auth_users SET email_verified = TRUE" in q for q in conn.executed
)


def test_verify_email_route_registered():
paths = {getattr(r, "path", "") for r in auth_module.router.routes}
assert "/auth/verify-email" in paths
Loading
Loading