feat(auth): fixed sign-in codes for designated accounts without an inbox

An app directory's reviewers sign in to a demo account with fixed credentials
and cannot receive an emailed code. TREG_FIXED_LOGIN_CODES maps such an email
to the SHA-256 of a fixed code; the email-code door then issues that hash in
place of a random code and sends nothing, so verification, the attempt limit,
the TTL and the start limits are the same as for an emailed code. Unset by
default; malformed entries refuse to boot.
This commit is contained in:
SToneX
2026-09-23 20:49:49 +08:00
parent d42bee1ece
commit 6e741b9ec3
5 changed files with 102 additions and 3 deletions
+5
View File
@@ -49,6 +49,11 @@ provider account and bypass this platform ownership table.
(`TREG_BLOCKED_EMAIL_DOMAINS`, unset means no blocks) remains a configurable speed bump at every (`TREG_BLOCKED_EMAIL_DOMAINS`, unset means no blocks) remains a configurable speed bump at every
sign-in/sign-up door and both team-creating endpoints; it fails open on classifier errors. sign-in/sign-up door and both team-creating endpoints; it fails open on classifier errors.
Suspend abusive users and teams separately, retaining their records for investigation. Suspend abusive users and teams separately, retaining their records for investigation.
- **Designated sign-in codes are a password, stored only as a hash.** `TREG_FIXED_LOGIN_CODES`
(unset by default) maps an email with no inbox, such as an app directory reviewer's demo account,
to the SHA-256 of a fixed code. The email-code door then sends nothing and accepts only that code,
under the same five-guess and start limits as an emailed code. Use a long random code, give the
account a dedicated team with sample data only, and rotate by changing the hash.
## Archive object storage credentials ## Archive object storage credentials
+4 -2
View File
@@ -450,8 +450,10 @@ validated before resolving the shared HTTP client. `/auth/logout` remains an HTT
before returning CLI users to the team picker. before returning CLI users to the team picker.
- Email: `POST /auth/email/start` and `/verify`. Six-digit codes, attempt counts and per-email/ - Email: `POST /auth/email/start` and `/verify`. Six-digit codes, attempt counts and per-email/
per-IP start limits live in DB-backed `Ephemeral` state. `expose_dev_code` permits response/ per-IP start limits live in DB-backed `Ephemeral` state. `expose_dev_code` permits response/
log disclosure only on guarded local SQLite; other deployments email the code. Verification log disclosure only on guarded local SQLite; other deployments email the code. An email listed
issues an identity token and session cookie. in `TREG_FIXED_LOGIN_CODES` (an account with no inbox, such as a directory reviewer's demo
account) is issued its configured code hash instead and nothing is sent; attempts, TTL and
start limits are unchanged. Verification issues an identity token and session cookie.
- Invite sign-in: the admin-visible invite code is join-only. An independent inbox-only - Invite sign-in: the admin-visible invite code is join-only. An independent inbox-only
`email_token` authenticates through `/auth/invite-signin`, consumed once. Invalid/expired `email_token` authenticates through `/auth/invite-signin`, consumed once. Invalid/expired
links return `/?invite_expired=1`. See [auth-secrets](../architecture/auth-secrets.md). links return `/?invite_expired=1`. See [auth-secrets](../architecture/auth-secrets.md).
+7 -1
View File
@@ -222,14 +222,20 @@ async def start_email_login(email: str, client_ip: str) -> dict:
): ):
await db.commit() await db.commit()
raise EmailAuthError("rate_limited") raise EmailAuthError("rate_limited")
# A designated account has no inbox: its configured code hash is issued instead, so the
# verify path, attempt count and TTL are exactly those of an emailed code.
fixed_hash = get_settings().fixed_login_code_hashes.get(email)
code = f"{_secrets.randbelow(1_000_000):06d}" code = f"{_secrets.randbelow(1_000_000):06d}"
await ratestore.kv_put( await ratestore.kv_put(
db, OTP_NS, email, db, OTP_NS, email,
{"hash": crypto.hash_token(code), "attempts": MAX_OTP_ATTEMPTS}, EMAIL_CODE_TTL, {"hash": fixed_hash or crypto.hash_token(code), "attempts": MAX_OTP_ATTEMPTS},
EMAIL_CODE_TTL,
) )
await db.commit() await db.commit()
result = {"sent": True, "email": email} result = {"sent": True, "email": email}
if fixed_hash:
return result
if get_settings().expose_dev_code: if get_settings().expose_dev_code:
print(f"[email-otp] {email} -> {code}") print(f"[email-otp] {email} -> {code}")
result["dev_code"] = code result["dev_code"] = code
+30
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import re
from functools import lru_cache from functools import lru_cache
from typing import Literal from typing import Literal
@@ -32,6 +33,14 @@ def platform_setting_name(provider: str) -> str:
return "platform_key_" + (provider or "").lower().replace("-", "_") return "platform_key_" + (provider or "").lower().replace("-", "_")
@lru_cache
def _fixed_login_codes(raw: str) -> dict[str, str]:
"""Parse `TREG_FIXED_LOGIN_CODES` (`email=sha256hex,...`) into {normalised email: code hash}.
Malformed entries are refused by the field validator, so parsing here can trust the shape."""
pairs = (part.split("=", 1) for part in raw.split(",") if part.strip())
return {email.strip().lower(): digest.strip().lower() for email, digest in pairs}
@lru_cache @lru_cache
def _blocked_email_domains(raw: str) -> frozenset[str]: def _blocked_email_domains(raw: str) -> frozenset[str]:
"""Parse `TREG_BLOCKED_EMAIL_DOMAINS` once per distinct value, not per request: split on commas, """Parse `TREG_BLOCKED_EMAIL_DOMAINS` once per distinct value, not per request: split on commas,
@@ -563,6 +572,27 @@ class Settings(BaseSettings):
# deliberately: no allowlist, no table, no admin UI. # deliberately: no allowlist, no table, no admin UI.
blocked_email_domains: str = "" blocked_email_domains: str = ""
# Sign-in codes for designated accounts that cannot receive email, such as the demo account an
# app directory's reviewers use: `email=<sha256 hex of the code>,...`. For a listed email the
# email-code door sends nothing and accepts only the configured code, under the same attempt and
# start limits as an emailed one. Only the hash is configured; use a long random code. Empty
# (the default) leaves every email on the normal emailed code.
fixed_login_codes: str = ""
@field_validator("fixed_login_codes")
@classmethod
def _fixed_login_codes_shape(cls, v: str) -> str:
for part in (p for p in v.split(",") if p.strip()):
email, sep, digest = part.partition("=")
if not sep or "@" not in email or not re.fullmatch(r"[0-9a-fA-F]{64}", digest.strip()):
raise ValueError("fixed_login_codes entries must be email=<64-hex sha256>")
return v
@property
def fixed_login_code_hashes(self) -> dict[str, str]:
"""The normalised `TREG_FIXED_LOGIN_CODES` entries; empty = no designated accounts."""
return _fixed_login_codes(self.fixed_login_codes)
@property @property
def blocked_email_domain_set(self) -> frozenset[str]: def blocked_email_domain_set(self) -> frozenset[str]:
"""The normalised `TREG_BLOCKED_EMAIL_DOMAINS` entries; empty = nothing is blocked.""" """The normalised `TREG_BLOCKED_EMAIL_DOMAINS` entries; empty = nothing is blocked."""
+56
View File
@@ -121,3 +121,59 @@ async def test_social_signup_tracking_reuses_same_new_user_rule(client, monkeypa
await _provision_social_user("social@example.test", "unused", method, "arena") await _provision_social_user("social@example.test", "unused", method, "arena")
assert len(events) == 1 assert len(events) == 1
assert events[0][1:] == ("signup_completed", {"signup_method": method, "entry_surface": "arena"}) assert events[0][1:] == ("signup_completed", {"signup_method": method, "entry_surface": "arena"})
REVIEWER = "reviewer@example.com"
REVIEWER_CODE = "40718293561728394056"
@pytest.fixture
def fixed_code(monkeypatch):
import hashlib
digest = hashlib.sha256(REVIEWER_CODE.encode()).hexdigest()
monkeypatch.setattr(get_settings(), "fixed_login_codes", f"Reviewer@Example.com={digest}")
async def test_designated_account_signs_in_with_its_fixed_code_and_nothing_is_sent(
client, fixed_code, monkeypatch,
):
from treg import email as email_sender
sent: list[str] = []
async def record(email, code, **_):
sent.append(email)
monkeypatch.setattr(get_settings(), "email_dev_mode", False)
monkeypatch.setattr(email_sender, "send_otp", record)
for _ in range(2): # the same code works on every sign-in, not once
start = await client.post("/auth/email/start", json={"email": REVIEWER})
assert start.status_code == 200 and "dev_code" not in start.json()
ok = await client.post("/auth/email/verify", json={"email": REVIEWER, "code": REVIEWER_CODE})
assert ok.status_code == 200, ok.text
assert sent == [] # a designated account has no inbox, so no code is ever mailed
await client.post("/auth/email/start", json={"email": "someone@matrix.io"})
assert sent == ["someone@matrix.io"] # every other email still gets its emailed code
async def test_designated_account_keeps_the_attempt_limit(client, fixed_code):
await client.post("/auth/email/start", json={"email": REVIEWER})
from treg.application.auth import MAX_OTP_ATTEMPTS
for _ in range(MAX_OTP_ATTEMPTS):
bad = await client.post("/auth/email/verify", json={"email": REVIEWER, "code": "123456"})
assert bad.status_code == 401
# The issued code is spent after the allowed wrong guesses, so even the right code needs a new start.
spent = await client.post("/auth/email/verify", json={"email": REVIEWER, "code": REVIEWER_CODE})
assert spent.status_code == 401
await client.post("/auth/email/start", json={"email": REVIEWER})
ok = await client.post("/auth/email/verify", json={"email": REVIEWER, "code": REVIEWER_CODE})
assert ok.status_code == 200
def test_fixed_login_codes_refuses_a_malformed_entry():
from pydantic import ValidationError
from treg.config import Settings
with pytest.raises(ValidationError):
Settings(fixed_login_codes="reviewer@example.com=not-a-sha256")
with pytest.raises(ValidationError):
Settings(fixed_login_codes="reviewer@example.com")