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:
@@ -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",
|
||||||
|
]
|
||||||
@@ -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)."""
|
||||||
@@ -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
|
||||||
@@ -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 == []
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user