Skip to content

feat: optional SMTP, email verification and password reset - #54

Merged
aamcatamney merged 1 commit into
mainfrom
feat/email-verification-and-password-reset
Sep 3, 2026
Merged

feat: optional SMTP, email verification and password reset#54
aamcatamney merged 1 commit into
mainfrom
feat/email-verification-and-password-reset

Conversation

@aamcatamney

Copy link
Copy Markdown
Owner

Two link-based flows, both inert unless SMTP is configured.

Schema

users gains email_verified and security_stamp. A new user_tokens table holds one row per issued link.

Only a SHA-256 of the token is stored, never the token — a leak of that table should not hand anyone a working link, the same reasoning as password hashes. SHA-256 rather than BCrypt is deliberate: these are 256 bits of randomness with nothing to brute-force, so a slow hash would only add latency to every redemption.

Links are single-use: redemption is an UPDATE ... WHERE consumed_at IS NULL, so two concurrent requests race in the database and exactly one wins. Issuing a new link retires the outstanding one. Reset lasts an hour, verification 24.

The setting you asked about

Auth:RequireEmailVerification defaults to false, and is forced to false whenever Smtp:Enabled is false, whatever the setting says. Obeying it literally would leave every account — including yours — waiting on a confirmation nothing can send, with no way back in. Two tests pin this.

Behaviour when verification is required

  • Registering creates the account and sends the link but issues no session. Otherwise registration would hand out what logging in refuses.
  • Signing in unverified returns 403, not 401 — the credentials were right, the account is not ready — carrying a problem type so the client can offer a resend rather than parse a title.
  • forgot-password and resend-verification always answer 202, whether or not the address exists. Anything else turns them into account-enumeration oracles.

Sessions

A completed reset rotates the security stamp, carried as a claim and checked in OnValidatePrincipal, so every session opened before the reset stops working. People reset passwords precisely because someone else may be in the account. There's a test that logs in, resets, and asserts the first session is dead.

SMTP

MailKit. With SMTP disabled a no-op sender logs the message in Development only — those bodies contain working links, and a production log is not a place to put them.

Client

New forgot-password, reset-password and verify-email pages on the existing token layer and component classes — no new visual vocabulary. Plus a resend control on the not-verified login error, a "forgot your password?" link, and a check-your-email step after registering.

Verification

  • Backend: 43 integration + 6 unit, 0 failed — 15 new, covering single-use, superseded links, enumeration parity, session eviction, and the SMTP override.
  • Client: ng build clean (all three pages as lazy chunks) and 29 tests passing — run in a Node 24 container, because Angular 22 needs a newer Node than this machine has.

Notes

user_tokens grows and nothing prunes it. Consumed and expired rows are harmless, but a busy deployment will want a periodic delete — called out in ADR 0005 along with the rest of the reasoning.

🤖 Generated with Claude Code

https://claude.ai/code/session_012w5v1wJVMJzYKRD7bCqvEE

Adds two link-based flows, both off unless SMTP is configured.

Schema: users gains email_verified and security_stamp; a user_tokens table
holds one row per issued link, keyed by a SHA-256 of the token rather than
the token itself, so a leak of the table yields nothing usable. SHA-256 and
not BCrypt because these are 256 bits of randomness with nothing to
brute-force. Links are single-use — redemption is an UPDATE guarded on
consumed_at IS NULL, so concurrent requests race in the database and one
wins — and issuing a new link retires the outstanding one. Reset lasts an
hour, verification 24.

Resetting a password rotates the security stamp, which is carried as a claim
and checked in OnValidatePrincipal, so every session opened before the reset
stops working. People reset passwords because someone else may be in the
account; leaving that session alive defeats the point.

Auth:RequireEmailVerification is forced false whenever Smtp:Enabled is
false, whatever the setting says. Obeying it literally would leave every
account waiting on a confirmation nothing can send, with no way back in.

With verification required, registering creates the account and sends the
link but issues no session, and signing in unverified returns 403 carrying a
problem type the client can act on — not 401, because the credentials were
right. forgot-password and resend-verification always answer 202, so neither
becomes a way to test which addresses have accounts.

SMTP uses MailKit; with it disabled a no-op sender logs the message in
Development only, since these bodies contain working links and a production
log is not a place for them.

Client: forgot-password, reset-password and verify-email pages on the
existing token layer, a resend control on the not-verified login error, and
a check-your-email step after registering.

15 integration tests cover the flows, including that a reset evicts a
session opened beforehand and that SMTP being off overrides the setting.
Backend 43 + 6 green; client built and tested on Node 24 in a container,
since Angular 22 needs a newer Node than this machine has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012w5v1wJVMJzYKRD7bCqvEE
@aamcatamney
aamcatamney merged commit 998fa20 into main Sep 3, 2026
2 checks passed
@aamcatamney
aamcatamney deleted the feat/email-verification-and-password-reset branch September 3, 2026 13:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant