mirror of
https://github.com/superdesigndev/treg.git
synced 2026-10-02 03:24:35 +08:00
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:
@@ -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
|
||||
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.
|
||||
- **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
|
||||
|
||||
|
||||
@@ -450,8 +450,10 @@ validated before resolving the shared HTTP client. `/auth/logout` remains an HTT
|
||||
before returning CLI users to the team picker.
|
||||
- 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/
|
||||
log disclosure only on guarded local SQLite; other deployments email the code. Verification
|
||||
issues an identity token and session cookie.
|
||||
log disclosure only on guarded local SQLite; other deployments email the code. An email listed
|
||||
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
|
||||
`email_token` authenticates through `/auth/invite-signin`, consumed once. Invalid/expired
|
||||
links return `/?invite_expired=1`. See [auth-secrets](../architecture/auth-secrets.md).
|
||||
|
||||
@@ -222,14 +222,20 @@ async def start_email_login(email: str, client_ip: str) -> dict:
|
||||
):
|
||||
await db.commit()
|
||||
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}"
|
||||
await ratestore.kv_put(
|
||||
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()
|
||||
|
||||
result = {"sent": True, "email": email}
|
||||
if fixed_hash:
|
||||
return result
|
||||
if get_settings().expose_dev_code:
|
||||
print(f"[email-otp] {email} -> {code}")
|
||||
result["dev_code"] = code
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
@@ -32,6 +33,14 @@ def platform_setting_name(provider: str) -> str:
|
||||
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
|
||||
def _blocked_email_domains(raw: str) -> frozenset[str]:
|
||||
"""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.
|
||||
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
|
||||
def blocked_email_domain_set(self) -> frozenset[str]:
|
||||
"""The normalised `TREG_BLOCKED_EMAIL_DOMAINS` entries; empty = nothing is blocked."""
|
||||
|
||||
@@ -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")
|
||||
assert len(events) == 1
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user