feat: optional SMTP, email verification and password reset - #54
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two link-based flows, both inert unless SMTP is configured.
Schema
usersgainsemail_verifiedandsecurity_stamp. A newuser_tokenstable 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:RequireEmailVerificationdefaults to false, and is forced to false wheneverSmtp:Enabledis 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
forgot-passwordandresend-verificationalways 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-passwordandverify-emailpages 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
ng buildclean (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_tokensgrows 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