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
|
(`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
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user