Release 0.7.0: email/OTC sign-in (Gitea OAuth retained as fallback)
Replaces the Gitea OAuth gesture as the primary human-auth path (roadmap item #5, SPEC §6.2). Users sign in by entering their email, receiving a six-digit code via the existing SMTP layer, and entering the code on a two-step /login surface. The Gitea OAuth callback remains functional during migration — the new UI links to it as a fallback for users with active OAuth sessions or older invite paths — and is scheduled for removal in a future release once OTC adoption is universal. Existing users are linked by email on first OTC sign- in (gitea_id preserved); new users are provisioned with NULL gitea_id and rely on email as the identity key. The migration introduces backend/migrations/012_otc.sql (otc_codes table + users schema rebuild for nullable gitea_id and a partial unique index on email), two new endpoints (POST /auth/otc/request, POST /auth/otc/verify), bcrypt as a new backend dependency for code hashing, and 11 new tests in test_otc_vertical.py covering the happy path, expired and consumed and wrong codes, the per-email rate limit, the allowlist gate, the OAuth-era link path, fresh provisioning, and prior-code invalidation on re-request. No new secrets are required — the existing SECRET_KEY signs sessions and bcrypt's per-row salt covers the code hashes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -81,3 +81,14 @@ WEBHOOK_EMAIL_BOUNCE_SECRET=
|
||||
# Production default is hourly; tests override to seconds via the same
|
||||
# env var.
|
||||
HYGIENE_TICK_SECONDS=3600
|
||||
|
||||
# --- v0.7.0: email + one-time-code sign-in (§6.2) ---
|
||||
# How long a one-time code stays valid after issuance. Re-requesting
|
||||
# invalidates the prior code immediately regardless of TTL.
|
||||
OTC_TTL_MINUTES=10
|
||||
|
||||
# Per-email cooldown between successive /auth/otc/request calls. The
|
||||
# endpoint returns HTTP 429 when the cooldown blocks a request (the
|
||||
# loud-failure shape so the abuse path is visible). Set to 0 to
|
||||
# disable the cooldown — useful for tests but never in production.
|
||||
OTC_REQUEST_COOLDOWN_SECONDS=60
|
||||
|
||||
+8
-2
@@ -193,10 +193,16 @@ def current_user(request: Request) -> SessionUser | None:
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
# v0.7.0: OTC-provisioned users have NULL gitea_id / gitea_login.
|
||||
# Coerce nulls to the SessionUser's typed defaults so downstream
|
||||
# code (Actor, _on_behalf_trailer) reads a stable shape regardless
|
||||
# of which sign-in path the row came from. The DB remains the
|
||||
# source of truth for "is this an OAuth-linked user" (gitea_id IS
|
||||
# NOT NULL); the in-memory SessionUser is the per-request handle.
|
||||
return SessionUser(
|
||||
user_id=row["id"],
|
||||
gitea_id=row["gitea_id"],
|
||||
gitea_login=row["gitea_login"],
|
||||
gitea_id=row["gitea_id"] or 0,
|
||||
gitea_login=row["gitea_login"] or "",
|
||||
display_name=row["display_name"],
|
||||
email=row["email"] or "",
|
||||
avatar_url=row["avatar_url"] or "",
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Outbound OTC email — a thin wrapper over the existing SMTP layer.
|
||||
|
||||
The §15.4 notification mailer in `email.py` is purpose-built for
|
||||
inbox-driven mail (unsubscribe footers, quiet-hours holds, bundling).
|
||||
OTC mail is structurally different: it carries a credential, has no
|
||||
inbox row behind it, and ignores user-preferences (a contributor
|
||||
who's opted out of every notification still needs to receive the
|
||||
code they explicitly requested).
|
||||
|
||||
So this module reuses `EmailConfig.from_env()` for the SMTP plumbing
|
||||
and the From identity, but writes its own envelope. In dev (no
|
||||
SMTP_HOST set), the envelope is logged at INFO level and pushed to
|
||||
the same `_SENT` buffer the notification mailer uses, so the
|
||||
integration tests can assert on the outbound shape without standing
|
||||
up an SMTP server.
|
||||
|
||||
The send is synchronous. The `/auth/otc/request` endpoint always
|
||||
returns 202 regardless of send outcome — the user-facing surface
|
||||
doesn't know whether the SMTP relay was reachable, since revealing
|
||||
that would let an attacker probe for valid emails on a tight loop.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
|
||||
from .email import EmailConfig, _SENT
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_otc_email(to_address: str, code: str) -> bool:
|
||||
"""Compose and send the one-time-code email. Returns True on the
|
||||
happy path; False on SMTP failure. The notifier-side buffer
|
||||
`_SENT` is appended either way so tests can assert on content.
|
||||
|
||||
The subject and body intentionally avoid branding strings that
|
||||
belong to a deployment — only `EMAIL_FROM_NAME` (operator-supplied
|
||||
via env) lands in the From line. The body names the code, the
|
||||
TTL, and a single instruction line. No tracking pixel, no
|
||||
deep-link query, no embedded JS — plain text only."""
|
||||
cfg = EmailConfig.from_env()
|
||||
subject = f"Your sign-in code for {cfg.from_name}"
|
||||
body = _body(code, cfg)
|
||||
envelope = {
|
||||
"to": to_address,
|
||||
"from": formataddr((cfg.from_name, cfg.from_address)),
|
||||
"subject": subject,
|
||||
"body": body,
|
||||
"kind": "otc",
|
||||
}
|
||||
_SENT.append(envelope)
|
||||
|
||||
if not cfg.enabled:
|
||||
log.info("otc email disabled (EMAIL_ENABLED=0): to=%s", to_address)
|
||||
return True
|
||||
if not cfg.smtp_host:
|
||||
# Dev fallback: surface the code at INFO so the operator can
|
||||
# complete a sign-in flow without an SMTP relay. In production
|
||||
# SMTP_HOST is always set per OHM's overlay.
|
||||
log.info("otc email (stdout fallback): to=%s code=%s", to_address, code)
|
||||
return True
|
||||
|
||||
try:
|
||||
msg = EmailMessage()
|
||||
msg["From"] = envelope["from"]
|
||||
msg["To"] = to_address
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(body)
|
||||
smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
|
||||
try:
|
||||
if cfg.smtp_starttls:
|
||||
smtp.starttls()
|
||||
if cfg.smtp_user:
|
||||
smtp.login(cfg.smtp_user, cfg.smtp_password)
|
||||
smtp.send_message(msg)
|
||||
finally:
|
||||
smtp.quit()
|
||||
return True
|
||||
except Exception:
|
||||
log.exception("otc email send failed: to=%s", to_address)
|
||||
return False
|
||||
|
||||
|
||||
def _body(code: str, cfg: EmailConfig) -> str:
|
||||
return (
|
||||
f"Your sign-in code is:\n\n"
|
||||
f" {code}\n\n"
|
||||
f"Enter this code in the sign-in screen to finish signing in.\n"
|
||||
f"The code expires in 10 minutes. If you did not request this,\n"
|
||||
f"you can safely ignore this email — no account was created.\n\n"
|
||||
f"---\n"
|
||||
f"{cfg.from_name} · {cfg.app_url}\n"
|
||||
)
|
||||
+61
-1
@@ -12,9 +12,21 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import APIRouter, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from . import api as api_routes, auth, cache, db, digest, hygiene, providers as providers_mod, webhooks
|
||||
from . import (
|
||||
api as api_routes,
|
||||
auth,
|
||||
cache,
|
||||
db,
|
||||
digest,
|
||||
email_otc,
|
||||
hygiene,
|
||||
otc,
|
||||
providers as providers_mod,
|
||||
webhooks,
|
||||
)
|
||||
from .bot import Bot
|
||||
from .config import load_config
|
||||
from .gitea import Gitea
|
||||
@@ -23,6 +35,15 @@ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name
|
||||
log = logging.getLogger("rfc_app")
|
||||
|
||||
|
||||
class OtcRequestBody(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
|
||||
|
||||
class OtcVerifyBody(BaseModel):
|
||||
email: str = Field(min_length=3, max_length=320)
|
||||
code: str = Field(min_length=1, max_length=16)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
config = load_config()
|
||||
@@ -122,4 +143,43 @@ def _oauth_router(config) -> APIRouter:
|
||||
request.session.clear()
|
||||
return RedirectResponse("/")
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# v0.7.0: email + one-time-code sign-in (§6.2).
|
||||
#
|
||||
# Replaces the OAuth gesture as the primary human-auth path. The
|
||||
# /auth/callback handler above remains functional as a fallback;
|
||||
# the new UI no longer surfaces it. A future release retires the
|
||||
# OAuth path entirely once every active user has signed in at
|
||||
# least once via OTC.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
@router.post("/auth/otc/request")
|
||||
async def otc_request(body: OtcRequestBody):
|
||||
outcome = otc.request_code(body.email)
|
||||
if outcome.reason == "cooldown":
|
||||
# Loud failure per the rate-limit primitive — the abuse
|
||||
# surface should be visible to clients hammering /request.
|
||||
raise HTTPException(429, "Wait before requesting another code")
|
||||
if outcome.sent and outcome.code is not None:
|
||||
email_otc.send_otc_email(body.email.strip(), outcome.code)
|
||||
# 202 regardless of allowlist/invalid — don't leak which
|
||||
# emails are recognized.
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/auth/otc/verify")
|
||||
async def otc_verify(body: OtcVerifyBody, request: Request):
|
||||
result = otc.verify_code(body.email, body.code)
|
||||
if not result.ok or result.user is None:
|
||||
raise HTTPException(400, "Invalid or expired code")
|
||||
auth.store_session(request, result.user)
|
||||
return {
|
||||
"ok": True,
|
||||
"user": {
|
||||
"id": result.user.user_id,
|
||||
"display_name": result.user.display_name,
|
||||
"email": result.user.email,
|
||||
"role": result.user.role,
|
||||
},
|
||||
}
|
||||
|
||||
return router
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
"""§6.2 / v0.7.0: email + one-time-code sign-in.
|
||||
|
||||
Replaces the Gitea OAuth gesture as the primary human-auth path. The
|
||||
Gitea bot user + token are still needed for server-side git
|
||||
operations (repo reads, PR creation); only the operator-facing
|
||||
sign-in surface moves through this module.
|
||||
|
||||
The shape:
|
||||
|
||||
* `request_code(email)` generates a 6-digit decimal code,
|
||||
hashes it (bcrypt), stores the hash + expiry in `otc_codes`,
|
||||
and dispatches a plain-text email via `email_otc.send`. It
|
||||
invalidates any prior unused codes for the same email so a
|
||||
re-request keeps the surface to one outstanding code per
|
||||
address. The TTL comes from `OTC_TTL_MINUTES` (default 10).
|
||||
A per-email cooldown (`OTC_REQUEST_COOLDOWN_SECONDS`, default
|
||||
60) refuses back-to-back requests inside the window.
|
||||
|
||||
* `verify_code(email, code)` walks the most recent unconsumed
|
||||
non-expired row for the email, checks the bcrypt hash, marks
|
||||
the row consumed, and returns the linked or freshly-provisioned
|
||||
user row.
|
||||
|
||||
* `provision_or_link_user(email)` is the migration path: if a
|
||||
`users` row already carries `email` (case-insensitive), it is
|
||||
reused — `gitea_id` is left alone so a grandfathered OAuth-era
|
||||
user keeps the linker intact. Otherwise a fresh contributor
|
||||
row is provisioned with `gitea_id = NULL`, `gitea_login = NULL`.
|
||||
|
||||
The endpoints in `main.py` thin-wrap this module. The allowlist gate
|
||||
from v0.3.0 is consulted at request time — if `allowed_emails` is
|
||||
populated and the requested address isn't on it, the request returns
|
||||
202 as usual but no email is sent. This intentionally does not leak
|
||||
allowlist state to the caller; the §19.2 candidate for v0.8.0
|
||||
replaces this gate with an admin-grant flow.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from dataclasses import dataclass
|
||||
|
||||
import bcrypt
|
||||
|
||||
from . import db
|
||||
from .auth import SessionUser, allowlist_is_active
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tunables — env-driven with defaults so v0.7.0 needs no new secrets.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ttl_minutes() -> int:
|
||||
raw = os.environ.get("OTC_TTL_MINUTES", "").strip()
|
||||
if not raw:
|
||||
return 10
|
||||
try:
|
||||
return max(1, int(raw))
|
||||
except ValueError:
|
||||
return 10
|
||||
|
||||
|
||||
def _cooldown_seconds() -> int:
|
||||
raw = os.environ.get("OTC_REQUEST_COOLDOWN_SECONDS", "").strip()
|
||||
if not raw:
|
||||
return 60
|
||||
try:
|
||||
return max(0, int(raw))
|
||||
except ValueError:
|
||||
return 60
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Code generation + hashing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _new_code() -> str:
|
||||
"""Six decimal digits. `secrets.randbelow` is CSPRNG-backed so the
|
||||
code resists guessing even at the small (10^6) keyspace. The TTL
|
||||
+ rate-limit are what carry the security weight — the entropy of a
|
||||
six-digit code by itself is intentionally human-readable."""
|
||||
return f"{secrets.randbelow(1_000_000):06d}"
|
||||
|
||||
|
||||
def _hash_code(code: str) -> str:
|
||||
"""bcrypt over the code bytes. The hash is stored at rest; the code
|
||||
itself only travels in the outbound email and the inbound verify
|
||||
body."""
|
||||
return bcrypt.hashpw(code.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
|
||||
|
||||
|
||||
def _check_code(code: str, code_hash: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(code.encode("utf-8"), code_hash.encode("ascii"))
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist gate — shared with the OAuth flow.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _allowlist_admits(email: str) -> bool:
|
||||
"""The same allowlist v0.3.0 introduced for OAuth, applied to OTC
|
||||
requests. If the allowlist is populated and the email is not on it,
|
||||
we still respond 202 to the caller, but no code is sent."""
|
||||
if not allowlist_is_active():
|
||||
return True
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class RequestOutcome:
|
||||
"""The outcome of a `request_code` call.
|
||||
|
||||
`code` is None whenever no code was generated — either because the
|
||||
allowlist denied the email or because the cooldown window blocked
|
||||
the request. The caller (the API endpoint) does not surface this
|
||||
distinction to the user; it returns 202 either way.
|
||||
"""
|
||||
sent: bool
|
||||
code: str | None
|
||||
reason: str # 'sent' | 'allowlist' | 'cooldown' | 'invalid'
|
||||
|
||||
|
||||
def request_code(email: str) -> RequestOutcome:
|
||||
email = (email or "").strip()
|
||||
if not email or "@" not in email:
|
||||
return RequestOutcome(sent=False, code=None, reason="invalid")
|
||||
|
||||
# Cooldown: refuse if a code was issued for this email in the last
|
||||
# COOLDOWN_SECONDS. We surface it as a distinct outcome so the
|
||||
# endpoint can return 429 — the spec calls this out as a "loud
|
||||
# failure" so the abuse path is visible rather than swallowed.
|
||||
cooldown = _cooldown_seconds()
|
||||
if cooldown > 0:
|
||||
row = db.conn().execute(
|
||||
f"""
|
||||
SELECT 1 FROM otc_codes
|
||||
WHERE email = ?
|
||||
AND datetime(created_at, '+{cooldown} seconds') > datetime('now')
|
||||
LIMIT 1
|
||||
""",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
return RequestOutcome(sent=False, code=None, reason="cooldown")
|
||||
|
||||
# Allowlist: silently drop the send if the email isn't on the list.
|
||||
# The row is not written either — there's nothing for verify to
|
||||
# match against, so the user-facing experience is "I never got an
|
||||
# email", which is the intended shape for the private-beta gate.
|
||||
if not _allowlist_admits(email):
|
||||
return RequestOutcome(sent=False, code=None, reason="allowlist")
|
||||
|
||||
# Invalidate prior unused codes for this email. A re-request is
|
||||
# always for the most recent code; older codes are dead.
|
||||
db.conn().execute(
|
||||
"""
|
||||
UPDATE otc_codes
|
||||
SET consumed_at = datetime('now')
|
||||
WHERE email = ?
|
||||
AND consumed_at IS NULL
|
||||
""",
|
||||
(email,),
|
||||
)
|
||||
|
||||
code = _new_code()
|
||||
code_hash = _hash_code(code)
|
||||
ttl = _ttl_minutes()
|
||||
db.conn().execute(
|
||||
f"""
|
||||
INSERT INTO otc_codes (email, code_hash, expires_at)
|
||||
VALUES (?, ?, datetime('now', '+{ttl} minutes'))
|
||||
""",
|
||||
(email, code_hash),
|
||||
)
|
||||
return RequestOutcome(sent=True, code=code, reason="sent")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verify path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class VerifyOutcome:
|
||||
"""Result of a `verify_code` call.
|
||||
|
||||
`user` is populated only on success. `reason` distinguishes the
|
||||
failure modes the UI can render — 'expired', 'consumed', 'wrong',
|
||||
'unknown' (no outstanding code at all). The endpoint maps the
|
||||
failure modes to a single 400 with a generic message; the reason
|
||||
is logged for the operator.
|
||||
"""
|
||||
ok: bool
|
||||
user: SessionUser | None
|
||||
reason: str
|
||||
|
||||
|
||||
def verify_code(email: str, code: str) -> VerifyOutcome:
|
||||
email = (email or "").strip()
|
||||
code = (code or "").strip()
|
||||
if not email or not code:
|
||||
return VerifyOutcome(ok=False, user=None, reason="invalid")
|
||||
|
||||
rows = db.conn().execute(
|
||||
"""
|
||||
SELECT id, code_hash, expires_at, consumed_at
|
||||
FROM otc_codes
|
||||
WHERE email = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 5
|
||||
""",
|
||||
(email,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return VerifyOutcome(ok=False, user=None, reason="unknown")
|
||||
|
||||
# Walk the recent rows so a user who pasted an older code still
|
||||
# gets a sensible error — without this, the most-recent-row check
|
||||
# would mask "you entered yesterday's code" as "wrong code".
|
||||
matched = None
|
||||
for row in rows:
|
||||
if _check_code(code, row["code_hash"]):
|
||||
matched = row
|
||||
break
|
||||
|
||||
if matched is None:
|
||||
return VerifyOutcome(ok=False, user=None, reason="wrong")
|
||||
|
||||
if matched["consumed_at"] is not None:
|
||||
return VerifyOutcome(ok=False, user=None, reason="consumed")
|
||||
|
||||
expired = db.conn().execute(
|
||||
"SELECT datetime(?) < datetime('now') AS expired",
|
||||
(matched["expires_at"],),
|
||||
).fetchone()["expired"]
|
||||
if expired:
|
||||
return VerifyOutcome(ok=False, user=None, reason="expired")
|
||||
|
||||
# Stamp consumed before provisioning so a parallel verify of the
|
||||
# same row can't double-sign-in.
|
||||
db.conn().execute(
|
||||
"UPDATE otc_codes SET consumed_at = datetime('now') WHERE id = ?",
|
||||
(matched["id"],),
|
||||
)
|
||||
user = provision_or_link_user(email)
|
||||
return VerifyOutcome(ok=True, user=user, reason="ok")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provisioning — the migration path from OAuth identity to email identity.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def provision_or_link_user(email: str) -> SessionUser:
|
||||
"""Link the OTC sign-in to a `users` row.
|
||||
|
||||
Match order:
|
||||
1. An existing row whose email equals (case-insensitive) the
|
||||
requested email — the OAuth-era user is grandfathered in via
|
||||
this path. `gitea_id` is preserved so a future OAuth round
|
||||
trip still resolves the same row.
|
||||
2. Otherwise: a fresh contributor row with `gitea_id = NULL`,
|
||||
`gitea_login = NULL`. The display name defaults to the local
|
||||
part of the email (everything before the `@`) — users can
|
||||
rename later via the §19.2 first-OTC profile-capture flow
|
||||
that v0.8.0 introduces.
|
||||
|
||||
The §6.1 owner-zero bootstrap still applies: if the email matches
|
||||
the configured `OWNER_GITEA_LOGIN`-derived owner identity, the row
|
||||
is provisioned with role='owner'. v0.7.0 keeps that field as the
|
||||
Gitea login (so existing deployments don't break); a future
|
||||
release may add a parallel `OWNER_EMAIL` env if the OAuth route is
|
||||
dropped entirely.
|
||||
"""
|
||||
email = email.strip()
|
||||
existing = db.conn().execute(
|
||||
"SELECT * FROM users WHERE email = ? COLLATE NOCASE",
|
||||
(email,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
db.conn().execute(
|
||||
"UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
|
||||
(existing["id"],),
|
||||
)
|
||||
return SessionUser(
|
||||
user_id=existing["id"],
|
||||
gitea_id=existing["gitea_id"] or 0,
|
||||
gitea_login=existing["gitea_login"] or "",
|
||||
display_name=existing["display_name"],
|
||||
email=existing["email"] or email,
|
||||
avatar_url=existing["avatar_url"] or "",
|
||||
role=existing["role"],
|
||||
)
|
||||
|
||||
display = email.split("@", 1)[0] or email
|
||||
cur = db.conn().execute(
|
||||
"""
|
||||
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role)
|
||||
VALUES (NULL, NULL, ?, ?, '', 'contributor')
|
||||
""",
|
||||
(email, display),
|
||||
)
|
||||
user_id = cur.lastrowid
|
||||
return SessionUser(
|
||||
user_id=user_id,
|
||||
gitea_id=0,
|
||||
gitea_login="",
|
||||
display_name=display,
|
||||
email=email,
|
||||
avatar_url="",
|
||||
role="contributor",
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
-- §6.2 / v0.7.0: email + one-time-code sign-in.
|
||||
--
|
||||
-- Replaces the Gitea OAuth gesture as the primary human-auth path.
|
||||
-- The Gitea bot user + token are still needed for server-side git
|
||||
-- operations (repo reads, PR creation); only the operator-facing
|
||||
-- sign-in surface moves. The /auth/callback OAuth route remains
|
||||
-- functional during migration as a fallback, scheduled for removal
|
||||
-- in a future release once every active user has signed in via OTC
|
||||
-- at least once.
|
||||
--
|
||||
-- A row in `otc_codes` represents an outstanding 6-digit code that
|
||||
-- was emailed to `email`. Codes are stored hashed (bcrypt) rather
|
||||
-- than plaintext, so a database compromise does not expose the
|
||||
-- in-flight code. TTL is enforced by `expires_at`. Each `verify`
|
||||
-- success stamps `consumed_at` and refuses every later attempt
|
||||
-- against the same row.
|
||||
--
|
||||
-- The §6.2 identity model under v0.7.0:
|
||||
--
|
||||
-- * `users.email` is the primary identity key for new sign-ins.
|
||||
-- * `users.gitea_id` stays populated for users grandfathered in
|
||||
-- via the OAuth-era flow; new users have `gitea_id = NULL`.
|
||||
-- The unique-constraint on `gitea_id` is relaxed (in v0.5.0 it
|
||||
-- was `INTEGER UNIQUE NOT NULL`) to permit the NULL.
|
||||
-- * `users.email` becomes a (case-insensitive) unique key. An
|
||||
-- existing OAuth user whose Gitea profile carried an email is
|
||||
-- linked on first OTC sign-in; if no row matches, a fresh
|
||||
-- contributor row is provisioned.
|
||||
--
|
||||
-- New env vars (v0.7.0):
|
||||
-- * `OTC_TTL_MINUTES` (default 10): how long a code stays valid.
|
||||
-- * `OTC_REQUEST_COOLDOWN_SECONDS` (default 60): per-email rate
|
||||
-- limit between successive `/auth/otc/request` calls.
|
||||
|
||||
CREATE TABLE otc_codes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL COLLATE NOCASE,
|
||||
code_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at TEXT NOT NULL,
|
||||
consumed_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX idx_otc_codes_email ON otc_codes (email, consumed_at, expires_at);
|
||||
|
||||
-- Relax `users.gitea_id` from `INTEGER UNIQUE NOT NULL` to a nullable
|
||||
-- column with a partial unique index that ignores nulls. SQLite does
|
||||
-- not support ALTER COLUMN, so we rebuild the table.
|
||||
--
|
||||
-- A few defensive notes:
|
||||
-- * Every foreign key into `users(id)` continues to resolve — `id`
|
||||
-- is the same INTEGER PRIMARY KEY in the rebuilt table.
|
||||
-- * `email` is now declared NOCASE so a `WHERE email = ?` match
|
||||
-- is case-insensitive without changing every read site. The
|
||||
-- prior column accepted any text; existing rows pass through
|
||||
-- unchanged.
|
||||
-- * `gitea_login` likewise relaxes from NOT NULL to nullable, so
|
||||
-- users provisioned by OTC alone don't carry a synthetic login.
|
||||
|
||||
CREATE TABLE users_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
gitea_id INTEGER,
|
||||
gitea_login TEXT,
|
||||
email TEXT COLLATE NOCASE,
|
||||
display_name TEXT NOT NULL,
|
||||
avatar_url TEXT,
|
||||
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
|
||||
muted INTEGER NOT NULL DEFAULT 0,
|
||||
email_personal_direct INTEGER NOT NULL DEFAULT 1,
|
||||
email_watched_structural INTEGER NOT NULL DEFAULT 0,
|
||||
email_admin_actionable INTEGER NOT NULL DEFAULT 1,
|
||||
email_opt_out_all INTEGER NOT NULL DEFAULT 0,
|
||||
digest_cadence TEXT NOT NULL DEFAULT 'weekly' CHECK (digest_cadence IN ('off', 'weekly', 'daily')),
|
||||
notification_quiet_hours_start TEXT,
|
||||
notification_quiet_hours_end TEXT,
|
||||
notification_quiet_hours_timezone TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_seen_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
INSERT INTO users_new (
|
||||
id, gitea_id, gitea_login, email, display_name, avatar_url, role,
|
||||
muted, email_personal_direct, email_watched_structural,
|
||||
email_admin_actionable, email_opt_out_all, digest_cadence,
|
||||
notification_quiet_hours_start, notification_quiet_hours_end,
|
||||
notification_quiet_hours_timezone, created_at, last_seen_at
|
||||
)
|
||||
SELECT
|
||||
id, gitea_id, gitea_login, email, display_name, avatar_url, role,
|
||||
muted, email_personal_direct, email_watched_structural,
|
||||
email_admin_actionable, email_opt_out_all, digest_cadence,
|
||||
notification_quiet_hours_start, notification_quiet_hours_end,
|
||||
notification_quiet_hours_timezone, created_at, last_seen_at
|
||||
FROM users;
|
||||
|
||||
DROP TABLE users;
|
||||
ALTER TABLE users_new RENAME TO users;
|
||||
|
||||
CREATE INDEX idx_users_role ON users (role);
|
||||
-- Partial unique indexes so NULLs are permitted but populated values
|
||||
-- collide. Gitea linkage stays unique per gitea_id; OTC-era identity
|
||||
-- is keyed on email (case-insensitive via NOCASE on the column).
|
||||
CREATE UNIQUE INDEX idx_users_gitea_id ON users (gitea_id) WHERE gitea_id IS NOT NULL;
|
||||
CREATE UNIQUE INDEX idx_users_gitea_login ON users (gitea_login) WHERE gitea_login IS NOT NULL;
|
||||
CREATE UNIQUE INDEX idx_users_email ON users (email) WHERE email IS NOT NULL AND email != '';
|
||||
@@ -8,3 +8,4 @@ anthropic>=0.39
|
||||
google-generativeai>=0.8
|
||||
openai>=1.50
|
||||
PyYAML>=6.0
|
||||
bcrypt>=4.2
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
"""End-to-end integration tests for the v0.7.0 email/OTC sign-in
|
||||
vertical (§6.2).
|
||||
|
||||
The release replaces the Gitea OAuth gesture as the primary human
|
||||
sign-in path. The tests prove:
|
||||
|
||||
* `/auth/otc/request` is rate-limited per-email — back-to-back
|
||||
requests inside `OTC_REQUEST_COOLDOWN_SECONDS` are refused with
|
||||
429 (the loud-failure shape the spec calls out).
|
||||
* The happy path: request → code lands in the outbound buffer →
|
||||
verify with the code → session cookie surfaces an authenticated
|
||||
user via `/api/auth/me`.
|
||||
* Expired codes refuse with 400.
|
||||
* Already-consumed codes refuse with 400 on re-use.
|
||||
* Wrong codes refuse with 400.
|
||||
* Allowlist gate: when `allowed_emails` is populated and the email
|
||||
isn't on it, the response is still 202 (no leak), but no email
|
||||
lands in the outbound buffer and verify finds no matching code.
|
||||
* Migration link: an existing OAuth-era user (with a `users.email`
|
||||
row) is linked by email on first OTC sign-in — `gitea_id` is
|
||||
preserved.
|
||||
* Provisioning path: an unrecognized email creates a fresh
|
||||
contributor row with `gitea_id = NULL`.
|
||||
|
||||
The Gitea bot user + token are still required at process construction
|
||||
(every test harness sets the same `GITEA_*` env vars); the OTC flow
|
||||
itself never reaches Gitea. The fakes from `test_propose_vertical`
|
||||
remain in scope so the rest of the app boots cleanly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from test_propose_vertical import ( # noqa: F401
|
||||
FakeGitea,
|
||||
app_with_fake_gitea,
|
||||
provision_user_row,
|
||||
tmp_env,
|
||||
)
|
||||
|
||||
|
||||
def _reset_outbound():
|
||||
from app import email as email_mod
|
||||
email_mod.reset_sent_envelopes()
|
||||
|
||||
|
||||
def _outbound_otc_codes(to_address: str | None = None) -> list[str]:
|
||||
"""Pluck the `code` line out of every OTC email in the test buffer.
|
||||
|
||||
The OTC mailer stamps `kind='otc'` on the envelope so the §15.4
|
||||
notification mailer's envelopes (the unsubscribe-footer shape)
|
||||
don't accidentally satisfy the assertion. Each envelope's body
|
||||
carries the code on its own indented line; this helper extracts
|
||||
just that token so the test reads the same way the user would
|
||||
read the email.
|
||||
"""
|
||||
from app import email as email_mod
|
||||
out = []
|
||||
for env in email_mod.sent_envelopes():
|
||||
if env.get("kind") != "otc":
|
||||
continue
|
||||
if to_address is not None and env["to"] != to_address:
|
||||
continue
|
||||
for line in env["body"].splitlines():
|
||||
tok = line.strip()
|
||||
if tok.isdigit() and len(tok) == 6:
|
||||
out.append(tok)
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Happy path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_then_verify_signs_in_a_fresh_user(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
|
||||
# Request: 202 + a single OTC envelope to the requested address.
|
||||
r = client.post("/auth/otc/request", json={"email": "newcomer@example.com"})
|
||||
assert r.status_code == 200, r.text
|
||||
codes = _outbound_otc_codes("newcomer@example.com")
|
||||
assert len(codes) == 1
|
||||
code = codes[0]
|
||||
|
||||
# Verify: 200 + session cookie + me-shape now reads authenticated.
|
||||
r = client.post("/auth/otc/verify", json={"email": "newcomer@example.com", "code": code})
|
||||
assert r.status_code == 200, r.text
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is True
|
||||
assert me["user"]["email"] == "newcomer@example.com"
|
||||
# Fresh provisioning: no gitea linker. The display name is the
|
||||
# local part of the email per §6.2.
|
||||
assert me["user"]["role"] == "contributor"
|
||||
assert me["user"]["display_name"] == "newcomer"
|
||||
|
||||
# The `users` row reflects the same: gitea_id NULL, email set.
|
||||
from app import db
|
||||
row = db.conn().execute(
|
||||
"SELECT gitea_id, email FROM users WHERE email = ? COLLATE NOCASE",
|
||||
("newcomer@example.com",),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["gitea_id"] is None
|
||||
assert row["email"] == "newcomer@example.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Failure modes on verify
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_verify_refuses_wrong_code(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": "000000"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_otc_verify_refuses_consumed_code(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
# First verify succeeds.
|
||||
r1 = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
|
||||
assert r1.status_code == 200
|
||||
# Drop the session cookie so the re-verify reads as fresh.
|
||||
client.cookies.clear()
|
||||
# Second verify with the same code is refused — `consumed_at`
|
||||
# stamped on the row blocks the replay.
|
||||
r2 = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
|
||||
assert r2.status_code == 400
|
||||
|
||||
|
||||
def test_otc_verify_refuses_expired_code(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
code = _outbound_otc_codes("alice@example.com")[-1]
|
||||
# Backdate the row's expires_at to the past. The TTL setting is
|
||||
# an env var (default 10 min); rather than waiting, the test
|
||||
# rewrites the row.
|
||||
db.conn().execute(
|
||||
"UPDATE otc_codes SET expires_at = datetime('now', '-1 minute') WHERE email = ?",
|
||||
("alice@example.com",),
|
||||
)
|
||||
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": code})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rate limiting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_rate_limited_per_email(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
r1 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r1.status_code == 200
|
||||
# Cooldown defaults to 60s; the second back-to-back call is
|
||||
# refused with a loud 429.
|
||||
r2 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r2.status_code == 429
|
||||
# The buffer still has exactly one envelope — the rate-limited
|
||||
# call didn't double-send.
|
||||
assert len(_outbound_otc_codes("alice@example.com")) == 1
|
||||
|
||||
|
||||
def test_otc_request_cooldown_is_per_email_not_global(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
r1 = client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
assert r1.status_code == 200
|
||||
# Different email, fresh cooldown.
|
||||
r2 = client.post("/auth/otc/request", json={"email": "bob@example.com"})
|
||||
assert r2.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Allowlist gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_request_silently_drops_when_email_not_on_allowlist(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
# Populate the allowlist so the gate turns on.
|
||||
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
|
||||
|
||||
r = client.post("/auth/otc/request", json={"email": "stranger@example.com"})
|
||||
# Still 202 — the allowlist's state is not leaked to callers.
|
||||
assert r.status_code == 200
|
||||
# But no email was sent, and no row landed in otc_codes.
|
||||
assert _outbound_otc_codes("stranger@example.com") == []
|
||||
row = db.conn().execute(
|
||||
"SELECT 1 FROM otc_codes WHERE email = ?",
|
||||
("stranger@example.com",),
|
||||
).fetchone()
|
||||
assert row is None
|
||||
|
||||
|
||||
def test_otc_request_admits_allowlisted_email(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
db.conn().execute("INSERT INTO allowed_emails (email) VALUES (?)", ("invited@example.com",))
|
||||
r = client.post("/auth/otc/request", json={"email": "invited@example.com"})
|
||||
assert r.status_code == 200
|
||||
assert len(_outbound_otc_codes("invited@example.com")) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration path — link by email to an OAuth-era user
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_links_to_existing_oauth_user_by_email(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
# Seed an OAuth-era row. `provision_user_row` writes
|
||||
# email=<login>@test, so we sign in via OTC with the matching
|
||||
# email and expect the same `users.id` to come back.
|
||||
provision_user_row(user_id=42, login="legacyuser", role="contributor")
|
||||
existing = db.conn().execute(
|
||||
"SELECT id, gitea_id FROM users WHERE id = ?", (42,)
|
||||
).fetchone()
|
||||
assert existing["gitea_id"] == 42 # OAuth linker is set.
|
||||
|
||||
r = client.post("/auth/otc/request", json={"email": "legacyuser@test"})
|
||||
assert r.status_code == 200
|
||||
code = _outbound_otc_codes("legacyuser@test")[-1]
|
||||
r = client.post("/auth/otc/verify", json={"email": "legacyuser@test", "code": code})
|
||||
assert r.status_code == 200
|
||||
|
||||
# /api/auth/me reports the linked user — same id, original role.
|
||||
me = client.get("/api/auth/me").json()
|
||||
assert me["authenticated"] is True
|
||||
assert me["user"]["id"] == 42
|
||||
assert me["user"]["role"] == "contributor"
|
||||
|
||||
# gitea_id is preserved on the linked row — the migration path
|
||||
# doesn't disturb the OAuth linker.
|
||||
row = db.conn().execute(
|
||||
"SELECT gitea_id FROM users WHERE id = ?", (42,)
|
||||
).fetchone()
|
||||
assert row["gitea_id"] == 42
|
||||
|
||||
|
||||
def test_otc_provisions_fresh_user_when_email_matches_no_one(app_with_fake_gitea):
|
||||
from fastapi.testclient import TestClient
|
||||
from app import db
|
||||
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
r = client.post("/auth/otc/request", json={"email": "newperson@example.com"})
|
||||
assert r.status_code == 200
|
||||
code = _outbound_otc_codes("newperson@example.com")[-1]
|
||||
r = client.post("/auth/otc/verify", json={"email": "newperson@example.com", "code": code})
|
||||
assert r.status_code == 200
|
||||
|
||||
# A fresh row landed with NULL gitea_id (no OAuth linker).
|
||||
row = db.conn().execute(
|
||||
"SELECT id, gitea_id, gitea_login, role FROM users WHERE email = ? COLLATE NOCASE",
|
||||
("newperson@example.com",),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row["gitea_id"] is None
|
||||
assert row["gitea_login"] is None
|
||||
assert row["role"] == "contributor"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Re-request invalidates prior code
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_otc_re_request_invalidates_prior_unused_code(app_with_fake_gitea, monkeypatch):
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
# Drop the cooldown so the second request lands instead of 429ing.
|
||||
monkeypatch.setenv("OTC_REQUEST_COOLDOWN_SECONDS", "0")
|
||||
app, _fake = app_with_fake_gitea
|
||||
with TestClient(app) as client:
|
||||
_reset_outbound()
|
||||
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
first = _outbound_otc_codes("alice@example.com")[-1]
|
||||
client.post("/auth/otc/request", json={"email": "alice@example.com"})
|
||||
second = _outbound_otc_codes("alice@example.com")[-1]
|
||||
assert first != second
|
||||
|
||||
# The old code is invalidated — verify with `first` now refuses.
|
||||
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": first})
|
||||
assert r.status_code == 400
|
||||
|
||||
# The new code still works.
|
||||
r = client.post("/auth/otc/verify", json={"email": "alice@example.com", "code": second})
|
||||
assert r.status_code == 200
|
||||
Reference in New Issue
Block a user