Native auth 2c-II: registration, verification, reset, change password/email (issue #147) - #169
Merged
Merged
Conversation
…/email Builds the rest of the user-facing native auth flows on top of the domain layer from 2a (issue #147). Everything ships dark: registration is gated on `native_registration`, the login page (and its cutover modal) on `native_login`, both OFF. - Registration: RegisterUser handler creates UserAccount (msp|<uuid7>) + Player in one transaction. Rejects an email that already belongs to a *Player*, not just to a user_account - in window A that collision is the user's own Auth0 account, and a second account would make the Stage B import skip their identity and strand their profile. - Welcome screen + verification email both carry the sign-in-link rescue: a window-A registrant who logs out meets the Auth0 form, which has no identity for them. - Email verification UI: anonymous /verify-email consumes the stateless token, resend button on profile settings (rate limited per account). - Password reset UI + email: /password-reset asks, /password-reset/{token} consumes. Uniform response for unknown/throttled addresses; the token page sends Referrer-Policy: no-referrer instead of parking the token in a session (keeps anonymous pages cookie-free, #164). - Native change password + change email, both requiring the current password. The edit-profile card follows the account class in the session, not a flag: a native account gets the native cards from the day it exists, a legacy Auth0 session keeps the #161 button until the Stage B import. - Cutover explainer modal on the login page (D15), localStorage-dismissed so it needs no session. EN copy only in this commit; the other five locales follow. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
Flips the 2c registration / verification / reset / change-password / change-email / cutover-modal checkboxes and records what each item actually does, including the two places the implementation diverged from the plan: - the edit-profile card swap needed no flag - it branches on whether the session holds a UserAccount, so window-A native registrants get native cards immediately and the Auth0 branch retires itself after the Stage B import; - the reset token stays in the URL with Referrer-Policy: no-referrer rather than in a session, which would put a cookie on every later page (#164). Removal of the three #161 classes and AUTH0_DB_CONNECTION stays open for Stage B, as specified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
87 keys × cs/de/es/fr/ja across messages and emails: registration, the welcome screen, email verification, password reset, the native change-password and change-email cards, the cutover explainer modal, and the two new transactional emails. D17 requires all six locales for anything auth-facing - Auth0's Universal Login was auto-localized, so English-only auth pages would be a regression. check-translations reports 0 missing keys in every locale; placeholders, inline HTML and block scalars verified byte-identical to the English source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
All four would have shipped dark and only bitten at Stage A/B. 1. Security::login() without an authenticator name throws through window A. The `main` firewall carries three authenticators (LoginFormAuthenticator, auth0.authenticator, login_link) and Security::login() refuses to guess - so EVERY native registration would have 500'd the moment the flag flipped. Now names LoginFormAuthenticator explicitly; RegisterControllerTest::testRegistrationCreatesAccountAndPlayerAndSignsTheUserIn fails with "Too many authenticators were found" without the fix. 2. The "forgot password" CSRF token id was not in stateless_token_ids, so rendering the form for an anonymous visitor reached for the session and put a cookie on the page - a straight #164 regression that would have made every later page uncacheable for that visitor. 3. Changing the account email left outstanding password-reset requests alive. Reset tokens bind to the account, not the address, and losing control of a mailbox is a common reason to change it - so whoever held the old inbox kept an hour-long way back in. Now invalidated with the address. 4. The reset-token route required exactly 64 hex chars, so a link a mail client wrapped or truncated 404'd instead of reaching the "this link does not work, here is a new one" page. Adds RegisterControllerTest: both flag states, the window-A player-email collision, weak-password rejection, no-session/no-shared-cache on the anonymous GET, and locale negotiation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
24 handler tests across registration, verification-link sending, password reset mailing, change-password and change-email, plus fixes for three defects they surfaced. Fixes: - ChangeAccountEmailHandler asked "fetch a player on this address and compare its user_id to mine". player.email is not unique - production carries 7 known duplicate pairs - and the lookup is an unordered LIMIT 1, so when the target address sat on both a stale row and the caller's own, the change was allowed or refused depending on which row Postgres returned. Replaced with PlayerRepository::emailBelongsToAnotherPlayer(), which asks the question directly. Covered by testAddressSharedWithAStaleDuplicateOfTheCallerIsStillRefused. - The verification email promised "valid for 24 hours" from a hardcoded literal while the token's real lifetime lived in a separate constant. Folded into EmailVerificationTokenSigner::LIFETIME_HOURS. - Two concurrent registrations on one address both pass the advisory checks and one loses at the unique index. That is the same situation for the user as a plain collision, so it now gets the same message instead of a generic failure and a Sentry alert for a benign race. Test-infrastructure fix, and it is mine: adding native_login_enabled / native_registration_enabled as Twig globals made them resolve on nearly every rendered page, which turned LoginControllerTest's existing `unset($_ENV['NATIVE_LOGIN_ENABLED'])` teardown into 355 suite-wide EnvNotFoundException errors. Both flag tests now restore the original value through the OverridesFeatureFlagEnv trait, and the registration tests vary REMOTE_ADDR so the per-IP limiter's cache (not rolled back between runs) cannot starve them. Full suite: 1363 tests green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
…147) The security-critical property of "forgot password" is that it gives nothing away: a known address, an unknown address and a throttled repeat all have to look identical from the outside (D8), even though only one of them sends mail. That was untested until now. Also covers the end-to-end token (the mailed link opens the form), the no-session/no-shared-cache requirement on the anonymous form page (#164), the mangled-link case that motivated relaxing the route requirement, and the Referrer-Policy that keeps the token out of outbound referers. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
An independent review pass found no security defect in the slice but did find a coverage gap: the handlers behind change-password, change-email and email verification were well tested, while the controllers in front of them were not - so a regression in an #[IsGranted] attribute, a window-A instanceof guard or the CSRF wiring would have gone unnoticed. - ChangeAccountCredentialsControllerTest: both pages closed to anonymous visitors, the current-password gate enforced through the form (and the stored hash / address left untouched when it fails), the new address starting unverified with its link going to the NEW inbox, and a legacy Auth0 session redirected rather than crashing on the #[CurrentUser] type. - VerifyEmailControllerTest: valid link confirms and grants no session, replay inside the lifetime still reads as success without moving the timestamp (D18), forged/missing/expired tokens each land on their own message, a link dies when the address it was issued for changes, and the page sets no cookie (#164). - The reset round trip end to end: the mailed link sets a new password, does not sign the browser in, and cannot be opened twice. Full suite: 1385 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
Two changes, both aimed at making this slice genuinely dark until Stage A.
**Cutover modal dropped (D15 amendment, Jan's call 2026-07-25).** The
site-wide notice strip shipped ahead of Stage A already reaches everyone,
anonymous visitors included, and links to the explainer page. A modal
repeating the same message on the login page is a second interruption with
the same content. Removed the template, the Stimulus controller, the include
and the copy in all six locales; the amendment is recorded in the README
decision table, the implementation plan and the communication plan so the
reasoning survives. The login page keeps UX-funnel layers 3, 4 and 6 - the
permanent microcopy, the prominent sign-in-link CTA and the failure helper -
which fire when somebody is actually stuck rather than on arrival.
**Password reset is now gated too.** It was the one page this branch left
live with both flags OFF, and because production's user_account table is
empty until the Stage B import, it could only ever answer "if an account
exists, a link is on its way" and then send nothing - a silent dead end, made
silent precisely by the anti-enumeration uniformity it needs elsewhere. Both
reset routes now redirect to /login until a native account can exist. Gated
on registration OR login rather than on either alone, so a rollback that
leaves login native does not take password reset down with it.
With both flags OFF every route this branch adds now redirects to /login:
/register, /password-reset, /password-reset/{token}, /welcome and the two
credential-settings pages. Verified against the running container.
Full suite: 1386 green; translations complete in all six locales.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
/edit-profile is the one page real users meet that this slice changed, and nothing rendered it in a test. Two cases now do: - a legacy Auth0 session still gets the #161 "send password change email" form and none of the native cards - the offer ~10k existing users see is unchanged, which is the property that actually matters on merge day; - a native UserAccount session is still bounced to /my-profile. The second pins the known 2c-II -> 2d dependency instead of the end state: RetrieveLoggedUserProfile still recognises only the Auth0 user class, so a native session has a null profile and this controller's guard redirects it, leaving the native cards unreachable. Harmless while both flags are OFF (no native account exists in production), and the first task of slice 2d - at which point this test has to flip, as its docblock says. Full suite: 1388 green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc
JanMikes
marked this pull request as ready for review
July 25, 2026 09:37
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.
Slice 2c-II of the Auth0 → native Symfony auth migration (issue #147), following #165 (2a data model), #166 (2b security plumbing) and #167 (2c-I login page + sign-in link).
Everything ships dark.
NATIVE_REGISTRATION_ENABLEDandNATIVE_LOGIN_ENABLEDstay OFF; nothing user-visible changes on merge.Done
RegisterUserhandler createsUserAccount(msp|<uuid7>) +Playerin one transaction. Rejects an email that already belongs to aPlayer, not just to auser_account: in window A that collision is the user's own Auth0 account, and letting a second account take the address would make the Stage B import skip their identity and strand their profile and every solving time./welcomescreen and the verification email both carry the sign-in-link URL, because a window-A registrant who logs out meets the Auth0 form, which has no identity for them and which we cannot brand or link from. Both suppress the warning once login is native, so it retires by itself.SendEmailVerificationLinkhandler + email, anonymous/verify-email(success / expired / invalid / failed, grants no session), rate-limited resend on profile settings./password-resetasks,/password-reset/{token}consumes. Unknown, throttled and known addresses are indistinguishable. The token stays in the URL withReferrer-Policy: no-referrerrather than in a session: a session cookie here would follow the visitor across every later page and break the Make anonymous HTML responses shared-cacheable (fixes #139) #164 anonymous-cacheability property.player.emailin step, resetsemail_verified_at, kills outstanding reset links aimed at the old inbox, and re-sends verification.edit-profile.html.twigbranches on whether the session holds aUserAccount, so window-A registrants get the native cards the day they register and the Auth0 branch dies on its own once Stage B has converted everyone. Removal of the three Add change password feature via Auth0 password reset email #161 classes +AUTH0_DB_CONNECTIONstays open for Stage B, as the plan specifies.check-translationsreports 0 missing keys.Bugs found and fixed while reviewing this slice
Worth calling out, because each would have shipped dark and only bitten once a flag flipped:
Security::login()without an authenticator name throws. Themainfirewall carries three authenticators through window A and refuses to guess between them — so every native registration would have 500'd on Stage A day.RegisterControllerTestfails withToo many authenticators were foundwithout the fix.player.emailis not unique (7 known duplicate pairs); comparing one arbitrary row'suser_idmeant the same request could be allowed or refused depending on what Postgres returned.Plus one test-infrastructure regression of my own: making the flags Twig globals meant they get resolved on nearly every rendered page, which turned
LoginControllerTest's pre-existingunset($_ENV[...])teardown into 355 suite-wideEnvNotFoundExceptionerrors. Both flag tests now restore the original value through a sharedOverridesFeatureFlagEnvtrait.Is it dark? Yes — verified route by route
With both flags OFF, every route this branch adds redirects to
/login(checked against the running container):/register,/password-reset,/password-reset/{token},/welcome, and the two credential-settings pages. The edit-profile Auth0 card renders byte-identical to main for Auth0 sessions, and the new navbar CTA is flag-gated.Two things were fixed to make that true rather than nearly-true:
/password-resetwas live. Because production'suser_accounttable stays empty until the Stage B import, it could only answer "if an account exists, a link is on its way" and then send nothing — a dead end made silent by the very anti-enumeration uniformity the page needs. Both reset routes are now gated on native auth being reachable (registration OR login, so a rollback of one does not take reset down with it).Deployment note: the two flags are now Twig globals, so they are resolved on nearly every page render instead of only on
/login. They are committed in.env(=0) and.dockerignoreexcludes only.env.*, so the image carries them — but production must keep those vars resolvable or page rendering fails.Independent review
A separate adversarial review pass over the diff found no authentication/authorization bypass, no window-A collision gap, no #164 regression and no CQRS/project-rule violation. It did find one real gap — the handlers were well covered but the controllers in front of them were not — so this branch now also tests, at the HTTP layer:
#[IsGranted]on both credential-settings pages, the current-password gate through the form, the window-Ainstanceof UserAccountguard (a legacy Auth0 session redirects instead of hitting aTypeError), all four/verify-emailoutcomes, and the reset round trip end to end including single use.Known dependency on the next slice
RetrieveLoggedUserProfilestill recognises onlyAuth0\Symfony\Models\User, so a nativeUserAccountsession getslogged_user.profile === null— which meansEditProfileControllerbounces it and the native change-password/change-email cards are unreachable until 2d lands. Nothing user-visible: both flags are OFF and Stage A ships after 2d. Recorded at the top of the 2d checklist so it gets done first.Gates
phpstan·cs-fix·--testsuite "Project Test Suite"(1386 green) ·doctrine:schema:validate·cache:warmup. Panther not run in this slice — it is a 2d/hardening gate.Stopped exactly at
Slice 2c-II complete; every §2c checkbox in the implementation plan is now
[x].Next
Slice 2d — the code sweep:
RetrieveLoggedUserProfilefor both user classes (do this first, see above),OAuth2AuthorizationSubscriber, the ~50-fileAuth0\Symfony\Models\User→UserAccount#[CurrentUser]sweep, theTestingLogin/TestLoginController/ Panther base rewrite,UserAccountfixtures, audit-logging listeners + counters,last_login_at, and the Panther login + OAuth2 authorize round-trip tests.EditProfileControllerwas already moved off the concrete Auth0Userhint here — it had to be, or a native account visiting profile settings would hit aTypeError. The rest of the sweep is untouched.🤖 Generated with Claude Code
https://claude.ai/code/session_017o4pbdfkhHDB4vdJPNY3tc