feat(slice-2): accounts domain — request_code/verify/get_account (INV-2/INV-3/INV-6)

Email-canonical get-or-create, peppered-hash one-time codes with 10-min TTL, single-use,
5-attempt cap, and 60s resend cooldown; uniform new-or-known (no enumeration, §6.6). All
identity rules in the domain layer once (INV-6); scenario + invariant tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 10:31:32 -07:00
parent 2d15f1a2cb
commit 95680c9960
6 changed files with 407 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
"""accounts domain — identity: account records, one-time-code issue/verify, account lookup.
Owns INV-2 (email canonical key) and INV-3 (one-time codes handled like secrets). Knows
nothing about storefronts (that is the storefronts domain, SLICE-3). Imported via this
package surface only (§6.2).
"""
from __future__ import annotations
from .errors import (
AccountsError,
CodeExhausted,
CodeExpired,
CodeMismatch,
InvalidEmail,
ResendCooldown,
)
from .models import Account
from .service import get_account, request_code, verify
__all__ = [
"Account",
"AccountsError",
"InvalidEmail",
"ResendCooldown",
"CodeMismatch",
"CodeExpired",
"CodeExhausted",
"request_code",
"verify",
"get_account",
]
+38
View File
@@ -0,0 +1,38 @@
"""accounts — domain exceptions.
Raised by the service layer; the BFF maps each to the §6.4 error shape. Keeping them here
(not in main) keeps the rules in the domain (INV-6).
"""
from __future__ import annotations
class AccountsError(Exception):
"""Base for accounts-domain errors."""
class InvalidEmail(AccountsError):
"""The supplied email is not a plausible address (§6.4 400 invalid_email)."""
class ResendCooldown(AccountsError):
"""A code was issued for this email under 60s ago (INV-3 → §6.4 429 resend_cooldown)."""
def __init__(self, retry_after_s: int) -> None:
super().__init__(f"resend available in {retry_after_s}s")
self.retry_after_s = retry_after_s
class CodeMismatch(AccountsError):
"""The submitted code is wrong (PUC-2a → §6.4 400 code_mismatch)."""
def __init__(self, attempts_remaining: int) -> None:
super().__init__("code did not match")
self.attempts_remaining = attempts_remaining
class CodeExpired(AccountsError):
"""No live code for this email — expired, consumed, or never issued (§6.4 400 code_expired)."""
class CodeExhausted(AccountsError):
"""The code's attempt budget is spent; it is invalidated (INV-3 → §6.4 400 code_exhausted)."""
+12
View File
@@ -0,0 +1,12 @@
"""accounts — domain models (SD-0001 §6.3)."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Account:
"""A person's identity on the platform. Email is the canonical key (INV-2)."""
id: int
email: str
+160
View File
@@ -0,0 +1,160 @@
"""accounts — the identity service (SD-0001 §6.5).
All identity rules live here exactly once (INV-6): email normalization (INV-2); one-time
code issue/verify handled like a secret (INV-3 — hashed with the app secret as pepper,
10-minute TTL, single-use, ≤5 attempts, 60s resend cooldown); and get-or-create account.
Uniform for new vs known emails so the surface never enumerates accounts (§6.6). Imports
only app.platform (downward); takes the connection and mailer as parameters (the BFF wires
them) so the domain owns no request/transport concerns.
"""
from __future__ import annotations
import hashlib
import hmac
import re
import secrets
from datetime import datetime, timedelta, timezone
import psycopg
from app.platform import config
from app.platform.mailer import Mailer
from .errors import CodeExhausted, CodeExpired, CodeMismatch, InvalidEmail, ResendCooldown
from .models import Account
# INV-3 constants — the one-time-code policy.
CODE_TTL = timedelta(minutes=10)
RESEND_COOLDOWN = timedelta(seconds=60)
MAX_ATTEMPTS = 5
_CODE_DIGITS = 6
# A pragmatic email check — rejects obvious non-addresses without an email-validator
# dependency. The provable test of an address is that the emailed code comes back (verify).
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
def normalize_email(email: str) -> str:
"""Lowercase + strip — the canonical form one account is keyed by (INV-2)."""
norm = email.strip().lower()
if not _EMAIL_RE.match(norm):
raise InvalidEmail(email)
return norm
def _hash_code(code: str) -> str:
"""HMAC-SHA256 of the code, peppered by the app secret (INV-3 — hashes only, not the code)."""
key = config.session_secret().encode("utf-8")
return hmac.new(key, code.encode("utf-8"), hashlib.sha256).hexdigest()
def _now() -> datetime:
return datetime.now(timezone.utc)
def request_code(conn: psycopg.Connection, mailer: Mailer, email: str) -> None:
"""Issue a one-time code to `email` and dispatch it. Uniform for new/known emails (§6.6).
Enforces the 60s resend cooldown (INV-3 → PUC-2c). Opportunistically purges this email's
spent/expired codes. Never reveals whether the email already has an account.
"""
email = normalize_email(email)
now = _now()
last = conn.execute(
"SELECT max(created_at) FROM auth_code WHERE email = %s AND consumed_at IS NULL",
(email,),
).fetchone()[0]
if last is not None:
elapsed = now - last
if elapsed < RESEND_COOLDOWN:
retry_after_s = int((RESEND_COOLDOWN - elapsed).total_seconds()) + 1
raise ResendCooldown(retry_after_s)
# Housekeeping: drop this email's consumed/expired codes (§6.3) before issuing a fresh one.
conn.execute(
"DELETE FROM auth_code WHERE email = %s AND (consumed_at IS NOT NULL OR expires_at < %s)",
(email, now),
)
code = "".join(secrets.choice("0123456789") for _ in range(_CODE_DIGITS))
conn.execute(
"INSERT INTO auth_code (email, code_hash, expires_at) VALUES (%s, %s, %s)",
(email, _hash_code(code), now + CODE_TTL),
)
conn.commit()
mailer.send(
to=email,
subject=f"Your ecomm code: {code}",
body=(
f"Your ecomm one-time code is {code}.\n"
f"It is valid for {int(CODE_TTL.total_seconds() // 60)} minutes.\n"
"If you didn't request this, ignore this message."
),
)
def verify(conn: psycopg.Connection, email: str, code: str) -> tuple[Account, bool]:
"""Verify a code and resolve the account. Returns (account, created).
Consumes the live code transactionally (single-use, INV-3): wrong codes spend an attempt
(PUC-2a) until MAX_ATTEMPTS invalidates it (CodeExhausted); expired/absent codes raise
CodeExpired (PUC-2b). On success the code is consumed and the account is got-or-created
by normalized email (INV-2). Account creation IS email verification (the address
provably received the code).
"""
email = normalize_email(email)
now = _now()
row = conn.execute(
"SELECT id, code_hash, expires_at, attempts FROM auth_code "
"WHERE email = %s AND consumed_at IS NULL "
"ORDER BY created_at DESC LIMIT 1 FOR UPDATE",
(email,),
).fetchone()
if row is None:
conn.commit()
raise CodeExpired(email) # nothing live to verify — offer a fresh code
code_id, code_hash, expires_at, attempts = row
if expires_at < now:
conn.commit()
raise CodeExpired(email)
if not hmac.compare_digest(code_hash, _hash_code(code)):
attempts += 1
if attempts >= MAX_ATTEMPTS:
conn.execute(
"UPDATE auth_code SET attempts = %s, consumed_at = %s WHERE id = %s",
(attempts, now, code_id),
)
conn.commit()
raise CodeExhausted(email)
conn.execute("UPDATE auth_code SET attempts = %s WHERE id = %s", (attempts, code_id))
conn.commit()
raise CodeMismatch(attempts_remaining=MAX_ATTEMPTS - attempts)
# Correct: consume the code and get-or-create the account in the same transaction.
conn.execute("UPDATE auth_code SET consumed_at = %s WHERE id = %s", (now, code_id))
account, created = _get_or_create_account(conn, email)
conn.commit()
return account, created
def _get_or_create_account(conn: psycopg.Connection, email: str) -> tuple[Account, bool]:
"""Insert the account if absent; return (account, created). Race-safe via the unique index."""
inserted = conn.execute(
"INSERT INTO account (email) VALUES (%s) ON CONFLICT (email) DO NOTHING RETURNING id",
(email,),
).fetchone()
if inserted is not None:
return Account(id=inserted[0], email=email), True
existing = conn.execute("SELECT id FROM account WHERE email = %s", (email,)).fetchone()
return Account(id=existing[0], email=email), False
def get_account(conn: psycopg.Connection, account_id: int) -> Account | None:
"""Load an account by id (for the signed-in `/me`)."""
row = conn.execute("SELECT id, email FROM account WHERE id = %s", (account_id,)).fetchone()
return Account(id=row[0], email=row[1]) if row else None
@@ -0,0 +1,66 @@
import re
import psycopg
import pytest
from app.domains import accounts
from app.platform import db, mailer
@pytest.fixture()
def conn(fresh_db_url):
with psycopg.connect(fresh_db_url) as c:
db.migrate(c)
yield c
def _code_in(outbox) -> str:
# The 6-digit code appears in the latest message body (LogMailer dev channel).
return re.search(r"\b(\d{6})\b", outbox[-1].body).group(1)
def test_14_01_0002_request_code_sends_a_six_digit_code(conn):
m = mailer.LogMailer()
accounts.request_code(conn, m, "Merchant@Example.com")
assert len(m.outbox) == 1
assert m.outbox[-1].to == "merchant@example.com" # normalized recipient (INV-2)
assert re.search(r"\b\d{6}\b", m.outbox[-1].body)
def test_14_01_0003_request_code_stores_only_a_hash(conn):
m = mailer.LogMailer()
accounts.request_code(conn, m, "merchant@example.com")
code = _code_in(m.outbox)
rows = conn.execute("SELECT code_hash FROM auth_code WHERE email = %s", ("merchant@example.com",)).fetchall()
assert len(rows) == 1
# The plaintext code is never stored (INV-3).
assert code not in rows[0][0]
assert len(rows[0][0]) >= 16 # a hash, not the 6-digit code
def test_request_code_uniform_for_new_and_known(conn):
# No account enumeration (§6.6): the call behaves identically whether or not the email
# already has an account — it never signals existence and never raises.
m = mailer.LogMailer()
accounts.request_code(conn, m, "newcomer@example.com") # no account exists
# create an account, then request again for a known email
conn.execute("INSERT INTO account (email) VALUES (%s)", ("known@example.com",))
conn.commit()
accounts.request_code(conn, m, "known@example.com")
assert len(m.outbox) == 2 # both sent a code; caller cannot tell new from known
def test_puc_02c_resend_is_rate_limited(conn):
m = mailer.LogMailer()
accounts.request_code(conn, m, "merchant@example.com")
with pytest.raises(accounts.ResendCooldown) as exc:
accounts.request_code(conn, m, "merchant@example.com")
assert 0 < exc.value.retry_after_s <= 60
assert len(m.outbox) == 1 # the second code was NOT sent
def test_request_code_rejects_invalid_email(conn):
m = mailer.LogMailer()
with pytest.raises(accounts.InvalidEmail):
accounts.request_code(conn, m, "not-an-email")
assert m.outbox == []
+100
View File
@@ -0,0 +1,100 @@
import re
from datetime import datetime, timedelta, timezone
import psycopg
import pytest
from app.domains import accounts
from app.platform import db, mailer
@pytest.fixture()
def conn(fresh_db_url):
with psycopg.connect(fresh_db_url) as c:
db.migrate(c)
yield c
def _issue(conn, email) -> str:
m = mailer.LogMailer()
accounts.request_code(conn, m, email)
return re.search(r"\b(\d{6})\b", m.outbox[-1].body).group(1)
def test_14_01_0004_verify_creates_account_and_reports_created(conn):
code = _issue(conn, "merchant@example.com")
account, created = accounts.verify(conn, "merchant@example.com", code)
assert created is True
assert account.email == "merchant@example.com"
assert account.id > 0
# the account row exists exactly once (INV-2)
n = conn.execute("SELECT count(*) FROM account WHERE email = %s", ("merchant@example.com",)).fetchone()[0]
assert n == 1
def test_14_01_0009_returning_account_reports_not_created(conn):
# First sign-up creates the account.
code1 = _issue(conn, "merchant@example.com")
a1, created1 = accounts.verify(conn, "merchant@example.com", code1)
assert created1 is True
# A later login resolves to the SAME account (INV-2 — email is canonical), created=False.
code2 = _issue(conn, "Merchant@Example.com") # different casing, same identity
a2, created2 = accounts.verify(conn, "Merchant@Example.com", code2)
assert created2 is False
assert a2.id == a1.id
n = conn.execute("SELECT count(*) FROM account").fetchone()[0]
assert n == 1 # no duplicate account (14.01.0010)
def test_verify_consumes_the_code_single_use(conn):
code = _issue(conn, "merchant@example.com")
accounts.verify(conn, "merchant@example.com", code)
# Reusing a consumed code is refused (single-use, INV-3): no live code -> CodeExpired.
with pytest.raises(accounts.CodeExpired):
accounts.verify(conn, "merchant@example.com", code)
def test_puc_02a_wrong_code_decrements_attempts(conn):
_issue(conn, "merchant@example.com")
with pytest.raises(accounts.CodeMismatch) as exc:
accounts.verify(conn, "merchant@example.com", "000000")
# 5 attempts allowed; one wrong -> 4 remaining (INV-3).
assert exc.value.attempts_remaining == 4
def test_puc_02b_expired_code_is_refused(conn):
code = _issue(conn, "merchant@example.com")
# Force the live code to be expired.
conn.execute(
"UPDATE auth_code SET expires_at = %s WHERE email = %s",
(datetime.now(timezone.utc) - timedelta(minutes=1), "merchant@example.com"),
)
conn.commit()
with pytest.raises(accounts.CodeExpired):
accounts.verify(conn, "merchant@example.com", code)
def test_inv3_attempts_exhausted_invalidates_code(conn):
code = _issue(conn, "merchant@example.com")
# Five wrong attempts: the 5th raises CodeExhausted and invalidates the code.
for _ in range(4):
with pytest.raises(accounts.CodeMismatch):
accounts.verify(conn, "merchant@example.com", "000000")
with pytest.raises(accounts.CodeExhausted):
accounts.verify(conn, "merchant@example.com", "000000")
# Even the correct code no longer works — a fresh request is required.
with pytest.raises((accounts.CodeExpired, accounts.CodeExhausted)):
accounts.verify(conn, "merchant@example.com", code)
def test_verify_with_no_request_is_code_expired(conn):
with pytest.raises(accounts.CodeExpired):
accounts.verify(conn, "stranger@example.com", "123456")
def test_get_account_loads_by_id(conn):
code = _issue(conn, "merchant@example.com")
account, _ = accounts.verify(conn, "merchant@example.com", code)
loaded = accounts.get_account(conn, account.id)
assert loaded is not None and loaded.email == "merchant@example.com"
assert accounts.get_account(conn, 999999) is None