2590c244d6
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>
97 lines
3.6 KiB
Python
97 lines
3.6 KiB
Python
"""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"
|
|
)
|