Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions config/packages/csrf.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
// session-free (#164), so its token id must not fall back to the
// session-backed manager
'sign_in_link',
// Same reason: the "forgot password" form is rendered for anonymous
// visitors, and a session-backed token would put a cookie on the page
'request_password_reset',
],
],
],
Expand Down
27 changes: 27 additions & 0 deletions config/packages/rate_limiter.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,33 @@
'limit' => 20,
'interval' => '1 hour',
],
// Password reset is the same shape of hazard as the sign-in link: an
// unauthenticated endpoint that mails an address the caller picks.
'password_reset_email' => [
'policy' => 'sliding_window',
'limit' => 3,
'interval' => '15 minutes',
],
'password_reset_ip' => [
'policy' => 'sliding_window',
'limit' => 20,
'interval' => '1 hour',
],
// Registration creates rows and sends mail; generous enough for a
// household or a club behind one NAT, tight enough to be no fun to abuse
// (D8 - the unique-email form error is only acceptable when the endpoint
// cannot be enumerated in bulk).
'registration_ip' => [
'policy' => 'sliding_window',
'limit' => 10,
'interval' => '1 hour',
],
// "Resend the verification email" - authenticated, so the account is the key
'email_verification_resend' => [
'policy' => 'sliding_window',
'limit' => 3,
'interval' => '15 minutes',
],
],
],
]);
5 changes: 5 additions & 0 deletions config/packages/twig.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
// (issue #147, docs/features/feature_flags.md). ON by default: it is
// announcement copy, not a feature - the switch exists to retire it.
'sign_in_changes_notice_enabled' => '%env(bool:SIGN_IN_CHANGES_NOTICE_ENABLED)%',
// Stage A/B flags of the same migration. Templates need them to decide
// whether to offer the native "Create an account" CTA (Stage A) and the
// native change-password card (Stage B); both retire in Phase 6.
'native_registration_enabled' => '%nativeRegistrationEnabled%',
'native_login_enabled' => '%nativeLoginEnabled%',
],
'paths' => [
'%kernel.project_dir%/public/img' => 'images',
Expand Down
7 changes: 4 additions & 3 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@

// Auth0 -> native auth migration flags (issue #147, docs/features/feature_flags.md).
// Deploy != flip: all three ship OFF and are flipped via env at Stage A / Stage B.
// nativeRegistrationEnabled has no consumer yet (registration ships with the
// 2c-II slice) - it is a parameter only; bind it in defaults() once a service
// injects it. nativeLoginEnabled gates the native login page (LoginController).
// nativeRegistrationEnabled gates the native registration page (RegisterController)
// and the links that point at it; nativeLoginEnabled gates the native login page
// (LoginController) and the native change-password card on profile settings.
$parameters->set('nativeRegistrationEnabled', '%env(bool:NATIVE_REGISTRATION_ENABLED)%');
$parameters->set('nativeLoginEnabled', '%env(bool:NATIVE_LOGIN_ENABLED)%');
$parameters->set('auth0TrickleLoginEnabled', '%env(bool:AUTH0_TRICKLE_LOGIN_ENABLED)%');
Expand Down Expand Up @@ -76,6 +76,7 @@
->bind('$auth0DatabaseConnection', '%auth0DatabaseConnection%')
->bind('$auth0TrickleLoginEnabled', '%auth0TrickleLoginEnabled%')
->bind('$nativeLoginEnabled', '%nativeLoginEnabled%')
->bind('$nativeRegistrationEnabled', '%nativeRegistrationEnabled%')
->bind('$signInLinkLifetimeSeconds', '%signInLinkLifetimeSeconds%');

$services->set(PdoSessionHandler::class)
Expand Down
10 changes: 5 additions & 5 deletions docs/features/auth-migration/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Two user-visible stages instead of one big-bang cutover — sequencing insight:
| Stage | What flips | Target |
|---|---|---|
| **Stage A** | New **registrations** go native (`msp\|` accounts); Auth0 database connection gets **Disable Sign Ups**; announcement email + banner + FAQ go live; **pay Essentials + open the hash-export ticket the same day** | ~1 week from start (build-gated) |
| **Stage B** | **Login** flips to the native form (invisible trickle fallback active); hash export imported; sessions flushed; explainer modal live on the login page | ~1 week after Stage A (export-gated; hard decision gate at day 14 — see risk register) |
| **Stage B** | **Login** flips to the native form (invisible trickle fallback active); hash export imported; sessions flushed | ~1 week after Stage A (export-gated; hard decision gate at day 14 — see risk register) |
| Window | Trickle runs invisibly; metrics watched; straggler nudge at B+2w | 3–4 weeks after Stage B |
| Decommission | Trickle + fork + tenant deleted | ~6–7 weeks from start |

Expand Down Expand Up @@ -147,7 +147,7 @@ Rejected alternatives, for the record: **(a)** per-provider columns on `user_acc
| Remember me | Signature-based (no storage, auto-invalidates on password change), `secure: true`, `samesite: lax` | core |
| Login throttling | `login_throttling` (requires `symfony/rate-limiter`), default 5/min per username+IP | core |
| Audit logging | Listeners on `LoginSuccessEvent` / `LoginFailureEvent` / `LogoutEvent` → Monolog/Sentry | core |
| Cutover explainer modal | One-time auto-modal on the login page post-Stage-B (localStorage-dismissed, works for anonymous visitors) — see UX funnel below | app |
| ~~Cutover explainer modal~~ | **Dropped 2026-07-25 (D15 amendment)** — the site-wide notice strip already reaches everyone, anonymous visitors included | — |
| Post-login-link password setup | After a magic-link login on a `legacy_auth0` account: one-time skippable "set a fresh password" prompt — see UX funnel below | app |
| Login-failure helper | On failed password attempts, an inline helper appears: password-manager tip + one-click "email me a sign-in link" (pre-filled) | app |

Expand All @@ -160,7 +160,7 @@ Post-launch candidates (not blocking): Google/Facebook login (design settled —
The single real user pain: managers saved the credential under `speedpuzzling.eu.auth0.com`, so autofill won't fire on the new login page and users conclude "I don't know my password". The design treats this as a funnel — every layer catches who the previous one missed:

1. **T-7d announcement email** — "your email and password stay the same" + the tip up front: *search your password manager for "speedpuzzling" or "auth0"* (the tenant subdomain contains "speedpuzzling", so vault search finds it in every manager).
2. **One-time modal on the login page** (post-Stage-B, localStorage-dismissed): same email+password, the vault-search tip, and the magic-link rescue — shown exactly where confusion strikes, including to logged-out/anonymous visitors.
2. ~~**One-time modal on the login page**~~ — **dropped 2026-07-25 (D15 amendment).** Its job is done by the site-wide notice strip, which went live ahead of Stage A, reaches anonymous visitors too, and links to the explainer page. Layers 3, 4 and 6 below carry the rest, and they fire when somebody is stuck rather than on arrival.
3. **Login form itself**: password login primary; **"Email me a sign-in link"** as a prominent, permanent secondary action (not buried); "Forgot password?" tertiary; persistent microcopy for dormant players returning months later.
4. **Failure-state helper**: a wrong-password attempt reveals an inline box — vault-search tip + one-click sign-in-link button pre-filled with the typed email. Shown on any failure (no account-existence signal → no enumeration leak).
5. **Post-magic-link password setup**: after a sign-in-link login on a `legacy_auth0` account, a one-time skippable prompt: "Set a fresh password so your manager saves it under myspeedpuzzling.com" — `autocomplete="new-password"` field (native manager password-generation UI) plus a "Suggest strong password" button that fills the field (visible, copyable, editable — refined from the readonly-input idea: readonly blocks users who want their own password, and managers capture on submit either way). Skipping keeps the old password working.
Expand Down Expand Up @@ -197,8 +197,8 @@ The fork fixes + `AnonymousCacheHeadersSubscriber` (2026-07) made anonymous HTML
| D12 | Email deliverability | Before cutover, verify seznam.cz SMTP limits/SPF/DKIM; auth emails (reset/verify/login-link) become critical-path. Consider a dedicated transactional provider if limits are tight |
| D13 | Social-auth data model | `oauth_identity` table (one row per linked provider identity); password stays on `user_account`. Design settled now (see "Auth-method extensibility"), table ships with the first provider — not during migration |
| D14 | Staged rollout | **Registrations flip first** (Stage A: native `/register` + Auth0 "Disable Sign Ups"), login flips ~1 week later (Stage B). Freezing signups before the export is generated → export covers 100% of Auth0 users. Decided 2026-07-23 |
| D15 | Cutover explainer | One-time auto-modal on the **login page** (localStorage-dismissed) + dismissable site-wide banner (~4 weeks) + FAQ page. Not a site-wide modal — visitors who never sign in shouldn't be interrupted |
| D16 | Public "why" tone | **Layered**: emails/banner/modal keep it simple ("sign-in now lives on our own site"); the FAQ/blog tells the honest full story — Auth0 enabled fast early development, but the unmaintained Symfony SDK + rejected upstream fixes forced us to maintain a fork and slow us down |
| D15 | Cutover explainer | ~~One-time auto-modal on the **login page** (localStorage-dismissed)~~ + dismissable site-wide banner (~4 weeks) + FAQ page. Not a site-wide modal — visitors who never sign in shouldn't be interrupted. **Amended 2026-07-25 (Jan): the modal is dropped.** The site-wide notice strip went live ahead of Stage A and already reaches everyone including anonymous visitors, linking to the explainer page — a modal repeating it on the login page is a second interruption with the same content. The login page keeps funnel layers 3/4/6 (permanent microcopy, prominent sign-in-link CTA, failure helper), which fire when someone is actually stuck rather than on arrival |
| D16 | Public "why" tone | **Layered**: emails/banner keep it simple ("sign-in now lives on our own site"); the FAQ/blog tells the honest full story — Auth0 enabled fast early development, but the unmaintained Symfony SDK + rejected upstream fixes forced us to maintain a fork and slow us down |
| D17 | Locales | All 6 locales (en, cs, de, es, fr, ja) for every auth-facing page/modal/banner — matches #161 precedent; Auth0's Universal Login was auto-localized, English-only would regress. Emails per player `locale` |
| D18 | Token replayability (decided 2026-07-24) | **Magic login links must be single-use**: Symfony's `login_link` is signature+expiry only (replayable within lifetime), so the implementation adds consumption storage (hashed-token row consumed at login, reset-request pattern). Email *verification* links stay stateless/replayable within their 24h lifetime — accepted deliberately: the handler is an idempotent no-op after first use, no session is created, and nothing privileged gates on `email_verified_at`. Password reset tokens were already single-use |

Expand Down
18 changes: 5 additions & 13 deletions docs/features/auth-migration/communication-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ The one real user pain: **password managers saved the credential under `speedpuz
|---|---|---|
| All registered users with an email | ~10k | Announcement email at Stage A — operational/service email (batched via Messenger + rate limiter; listmonk fallback per D12) |
| Active players (90d) | ~3,300 | Same + in-app banner + straggler nudge |
| All visitors | — | Banner + FAQ page + login-page modal & microcopy |
| All visitors | — | Banner + FAQ page + login-page microcopy |

All user-facing copy ships in **all 6 locales** (en, cs, de, es, fr, ja — D17); emails pick the player's `locale`.

Expand All @@ -27,9 +27,9 @@ All user-facing copy ships in **all 6 locales** (en, cs, de, es, fr, ja — D17)
| **Now** (2026-07-24, ahead of Stage A) | Advance notice live: site-wide banner + explainer page (`/en/sign-in-is-moving`, six locales). Copy leads with the honest cost — signed out once, password managers stop autofilling — then the "look at your address bar, it says speedpuzzling.eu.auth0.com" hook, why Auth0 was right at the start and no longer is, and why waiting hurts more puzzlers than acting. No date promised |
| **Stage A day** (~Jul 29–31) | Announcement email to all users; FAQ page live; banner ON ("On {date}…"); native registrations quietly live; socials post |
| Stage B − 1d | Banner switches to "tomorrow" wording |
| **Stage B day** (~Aug 6–12) | Cutover. Login-page modal live; banner switches to "changed" wording (stays ~4 weeks); login microcopy permanent; socials post |
| **Stage B day** (~Aug 6–12) | Cutover. Banner switches to "changed" wording (stays ~4 weeks); login microcopy permanent; socials post |
| B + 2w | Straggler nudge email (active-in-6-months minus migrated) |
| B + 3–4w | Transition exit review (Phase 5 criteria); banner removed. Modal + microcopy + sign-in-link stay — dormant players return for months |
| B + 3–4w | Transition exit review (Phase 5 criteria); banner removed. Microcopy + sign-in-link stay — dormant players return for months |

If Stage B slips past the export-delay gate, announced dates must say "week of {date}" — never promise a day we can't hold.

Expand Down Expand Up @@ -59,17 +59,9 @@ Subject: **Sign-in is moving to myspeedpuzzling.com — same password, nothing t
- B−1d: > 🔑 **Tomorrow** sign-in moves to myspeedpuzzling.com. You'll be signed out once — same email and password. [Details]({faq_url})
- Stage B → B+4w: > 🔑 Sign-in has a new home on myspeedpuzzling.com — same email and password as before. Trouble signing in? [Read this]({faq_url})

### Login-page modal (Stage B, one-time per browser, localStorage-dismissed)
### ~~Login-page modal~~ — dropped 2026-07-25 (Jan)

Title: **Sign-in has moved home**

> Signing in now happens right here on myspeedpuzzling.com — no more redirect to auth0.com.
>
> - **Same email, same password.** Nothing was reset.
> - **Password manager not offering it?** It saved your password under our old sign-in domain. Search it for "**speedpuzzling**" or "**auth0**" — it's there.
> - **Can't find it?** Use **Email me a sign-in link** below — one click and you're in. You can set a fresh password afterwards.
>
> [Got it] · [Why did this change?]({faq_url})
The site-wide notice strip went live ahead of Stage A and already reaches everyone, anonymous visitors included, linking to the explainer page. A modal repeating the same message on the login page is a second interruption, so it was cut. The login page carries the permanent microcopy, the prominent sign-in-link CTA and the failure helper below instead — those fire when somebody is actually stuck rather than on arrival.

### Login-page microcopy (permanent)

Expand Down
Loading