feat(access): one-time codes by email for a whitelist #17

Merged
ryan.mcgee merged 41 commits from feat/email-otp into main 2026-10-15 18:08:38 +00:00
Owner

Summary

  • Resources can let visitors in with a one-time code sent by email, for a whitelist of addresses and domains. First of four roadmap items (then TCP SNI routing, domain ownership and wildcard certificates, country and ASN rules).
  • Stacked on #16 (feat/two-factor).

Changes

  • A resource gets email_otp_enabled and a whitelist (alice@example.com, @example.com; a domain does not include subdomains). Switching it on needs a non-empty whitelist and a mail server. Off by default.
  • The visitor page asks for an address, then a code. The answer is the same whether or not the address is listed, nothing is sent to unlisted addresses, and the mail is sent from a task so the response time does not tell either. Smoke test: a stranger and an allowed address see identical pages; only the allowed one got a real SMTP message.
  • Throttling: 3 codes per address, 10 requests per client, 50 per resource in 15 minutes (unlisted requests count too, so all requests look alike); wrong codes also count against the existing per-client failure throttle.
  • Codes are random six digits (rejection sampling), stored only as HMAC-SHA256 keyed from the secret key base (Secrets.mac), work once (atomic claim, also under racing requests), expire after 10 minutes and die after 5 wrong tries.
  • Email sessions carry the address and are re-checked on every request: removing the address from the whitelist, or turning codes off, ends them and discards pending codes. The backend receives Remote-Email (no Remote-User).
  • Access page, API (email, allowed_emails, in the OpenAPI document) and blueprints (access.email_codes, exported when on; a non-empty list turns codes on). Sign-ins and failures are audited with the address.
  • Found while testing: a crafted non-text email form value crashed the endpoint; fixed.

Verification

  • mix format --check-formatted, mix compile --warnings-as-errors, mix credo --strict, mix dialyzer: clean.
  • mix test: 1387 passed on SQLite and PostgreSQL (whitelist parsing, settings, request and login flows, throttling, expiry, attempts, single use, revocation, controller, LiveView, API, blueprints incl. round trip).
  • Migration round trip with data on both backends (array column, new session column, new table).
  • Smoke test with Mailpit over real SMTP and a browser: settings saved in the access page, stranger versus allowed visitor, wrong code refused with the form kept, right code redirected with an exchange token; audit shows the failed and the successful sign-in with the address. The redirect target was not followed: no Traefik was running.
## Summary - Resources can let visitors in with a one-time code sent by email, for a whitelist of addresses and domains. First of four roadmap items (then TCP SNI routing, domain ownership and wildcard certificates, country and ASN rules). - Stacked on #16 (`feat/two-factor`). ## Changes - A resource gets `email_otp_enabled` and a whitelist (`alice@example.com`, `@example.com`; a domain does not include subdomains). Switching it on needs a non-empty whitelist and a mail server. Off by default. - The visitor page asks for an address, then a code. The answer is the same whether or not the address is listed, nothing is sent to unlisted addresses, and the mail is sent from a task so the response time does not tell either. Smoke test: a stranger and an allowed address see identical pages; only the allowed one got a real SMTP message. - Throttling: 3 codes per address, 10 requests per client, 50 per resource in 15 minutes (unlisted requests count too, so all requests look alike); wrong codes also count against the existing per-client failure throttle. - Codes are random six digits (rejection sampling), stored only as HMAC-SHA256 keyed from the secret key base (`Secrets.mac`), work once (atomic claim, also under racing requests), expire after 10 minutes and die after 5 wrong tries. - Email sessions carry the address and are re-checked on every request: removing the address from the whitelist, or turning codes off, ends them and discards pending codes. The backend receives `Remote-Email` (no `Remote-User`). - Access page, API (`email`, `allowed_emails`, in the OpenAPI document) and blueprints (`access.email_codes`, exported when on; a non-empty list turns codes on). Sign-ins and failures are audited with the address. - Found while testing: a crafted non-text `email` form value crashed the endpoint; fixed. ## Verification - `mix format --check-formatted`, `mix compile --warnings-as-errors`, `mix credo --strict`, `mix dialyzer`: clean. - `mix test`: 1387 passed on SQLite and PostgreSQL (whitelist parsing, settings, request and login flows, throttling, expiry, attempts, single use, revocation, controller, LiveView, API, blueprints incl. round trip). - Migration round trip with data on both backends (array column, new session column, new table). - Smoke test with Mailpit over real SMTP and a browser: settings saved in the access page, stranger versus allowed visitor, wrong code refused with the form kept, right code redirected with an exchange token; audit shows the failed and the successful sign-in with the address. The redirect target was not followed: no Traefik was running.
- a resource can let visitors in with a six-digit code mailed to an
  address on its whitelist (addresses and @domains); needs a mail
  server and is off by default
- the page answers the same for addresses that are not listed and
  sends them nothing; mail goes out off the request path
- requests are throttled per address, client and resource; codes work
  once for 10 minutes, die after 5 wrong tries and are stored as keyed
  hashes only
- email sessions end when the address leaves the whitelist or codes
  are turned off; the backend gets Remote-Email
- settings in the access page, API (email, allowed_emails) and
  blueprints (access.email_codes)
ryan.mcgee changed target branch from feat/two-factor to main 2026-10-15 18:08:32 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
White_Ghost_Cyber_Systems/traefik-control!17
No description provided.