diff --git a/backend/app/domains/accounts/__init__.py b/backend/app/domains/accounts/__init__.py new file mode 100644 index 0000000..986d2a4 --- /dev/null +++ b/backend/app/domains/accounts/__init__.py @@ -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", +] diff --git a/backend/app/domains/accounts/errors.py b/backend/app/domains/accounts/errors.py new file mode 100644 index 0000000..68ca901 --- /dev/null +++ b/backend/app/domains/accounts/errors.py @@ -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).""" diff --git a/backend/app/domains/accounts/models.py b/backend/app/domains/accounts/models.py new file mode 100644 index 0000000..c027838 --- /dev/null +++ b/backend/app/domains/accounts/models.py @@ -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 diff --git a/backend/app/domains/accounts/service.py b/backend/app/domains/accounts/service.py new file mode 100644 index 0000000..b6e5874 --- /dev/null +++ b/backend/app/domains/accounts/service.py @@ -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 diff --git a/backend/tests/test_accounts_request_code.py b/backend/tests/test_accounts_request_code.py new file mode 100644 index 0000000..e7ac722 --- /dev/null +++ b/backend/tests/test_accounts_request_code.py @@ -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 == [] diff --git a/backend/tests/test_accounts_verify.py b/backend/tests/test_accounts_verify.py new file mode 100644 index 0000000..eff0df1 --- /dev/null +++ b/backend/tests/test_accounts_verify.py @@ -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