fix(upgrade): reconcile staff two-factor storage during upgrade - #2652
fix(upgrade): reconcile staff two-factor storage during upgrade#2652omarelshopky wants to merge 6 commits into
Conversation
Lunar 1.5 renamed the Filament two-factor columns to the app-authentication names (two_factor_secret -> app_authentication_secret, two_factor_recovery_codes -> app_authentication_recovery_codes, two_factor_confirmed_at dropped) via an admin-package migration, and the v2 panel (spec 0049, lunarphp#2558) matches Filament's app-authentication: the secret behind an `encrypted` cast read with decryptString (no unserialize), recovery codes behind `encrypted:array` verified bcrypt-hashed. But lunar:upgrade carries the staff columns across verbatim, reconciling neither: - A store upgraded from a pre-1.5 line never ran the rename, so it lands with the old column names and the v2 Staff model (which reads app_authentication_*) sees no 2FA at all. - v1 stored the secret as encrypt(serialize(...)) and recovery codes as plaintext. Read back through v2's non-serializing `encrypted` cast, the secret decrypts to the serialization wrapper (s:16:"JBSW...";) and google2fa throws "Invalid characters in the base32 string"; recovery codes json_decode to null. So staff cannot pass the 2FA challenge after upgrading. Add an upgrade step that renames the columns when a pre-1.5 store still carries them (a 1.5+ store already has the v2 names and is skipped), then re-encodes the secret without the serialize wrapper and bcrypt-hashes each plaintext recovery code, matching the `encrypted` / `encrypted:array` casts. Idempotent (already-v2 data is left untouched); an undecryptable record is skipped rather than aborting the whole upgrade. One-way, like the other upgrade data steps.
glennjacobs
left a comment
There was a problem hiding this comment.
Thanks for this — the approach is right and the encoding probes hold up in every shape I checked (pre-1.5 stephenjude/Fortify-style, 1.5+ Filament v4 native, already-v2). One blocker before this can go in though.
Unconfirmed v1 enrolments get promoted to active 2FA, which can lock staff out.
The pre-1.5 plugin is Fortify-derived: the secret and recovery codes are written when enrolment starts, and two_factor_confirmed_at is only set once the user confirms a TOTP code — until then v1 treats 2FA as off. renameLegacyColumns() drops two_factor_confirmed_at without reading it, so a half-finished enrolment (secret set, confirmed_at NULL) comes out of the upgrade with a filled app_authentication_secret. In v2, AppAuthentication::isEnabled() is just filled($secret), and the challenge controller offers no email fallback once a secret exists — only TOTP or a recovery code, neither of which that staff member ever finished setting up. That's a hard lockout, fixable only by manually nulling the columns.
Could you have renameLegacyColumns() null two_factor_secret / two_factor_recovery_codes where two_factor_confirmed_at IS NULL (when that column is present) before renaming/dropping, and add a test for the unconfirmed-enrolment row? The 1.5 rename migration had the same latent flaw, but for 1.5+ stores the column is already gone — the pre-1.5 path is the one chance to get this right.
Two minor things while you're in there (take or leave):
- After the rename, the data pass bails unless both
app_authentication_*columns exist, butrenameLegacyColumns()deliberately guards each column independently for part-way-fixed stores — so a secret-only store gets renamed but never re-encoded. Either handle the columns independently in the data pass too, or simplify the rename guard to match. - The
->orderBy('id')beforechunkById()is redundant —chunkById()applies its own id ordering.
The test coverage is otherwise really solid — thanks for the mixed-pass and idempotency cases in particular.
…umns independently
Address review on the staff two-factor reconcile step:
- Blocker: a Fortify-derived pre-1.5 store writes the secret when enrolment starts
and only stamps two_factor_confirmed_at once the user verifies a code — until then
2FA is off. Carrying an unconfirmed secret across promoted it to an active v2 secret
with no email fallback, a hard lockout. Clear the secret / recovery columns where
two_factor_confirmed_at IS NULL before the rename.
- Handle the two app_authentication_* columns independently in the data pass, matching
the per-column rename guard, so a part-way-fixed secret-only store is re-encoded
instead of skipped.
- Drop the redundant ->orderBy('id') before chunkById() (it applies its own).
Adds tests for the unconfirmed-enrolment and secret-only-column cases.
30e964b to
3ebd16f
Compare
glennjacobs
left a comment
There was a problem hiding this comment.
Thanks for this — it's a really well put-together fix. The gating, idempotency probes, and the decision to discard unconfirmed enrolments before the rename are all spot on, and the test coverage is thorough. I've run the upgrade suite, PHPStan, and Pint against the branch locally and everything passes.
One change I'd like before merging, plus a smaller optional one:
Guard the rename against the target column already existing. In renameLegacyColumns() each rename is gated on the source column being present, but not on the target being absent. The migration already caters to stores part-way through a manual fix — and a store that manually copied two_factor_secret into app_authentication_secret (rather than renaming it) will carry both columns, at which point renameColumn() throws and aborts the whole upgrade. Could you guard each rename with && ! Schema::hasColumn($staff, 'app_authentication_secret') (and the same for the recovery-codes pair)? When both exist, skipping the rename is enough — the data pass will still reconcile whatever's in the new column. A test for that shape would be great too.
Optional: surface skipped undecryptable rows. The DecryptException comment says the staff member "re-enrols", but in practice they can't self-serve that — the encrypted cast throws when the challenge reads the stale secret, so they're stuck until an admin clears the columns. Leaving the data untouched is the right call (that row was equally broken in v1, and clearing it would be a silent 2FA downgrade), but a Log::warning with the affected row id would let the operator know who needs manual attention. At minimum, rewording the comment would avoid overpromising.
Address further review on the staff two-factor reconcile step: - renameLegacyColumns() gated each rename only on the v1 source column being present, not on the v2 target being absent. A store that manually COPIED two_factor_secret into app_authentication_secret (rather than renaming it) carries both columns, at which point renameColumn() throws and aborts the whole upgrade. Guard each rename on the target also being absent; when both exist the rename is skipped and the data pass reconciles the v2 column directly. - The DecryptException branch now logs a warning with the affected staff id: the staff member cannot self-serve a re-enrolment (the encrypted cast throws when their challenge reads the stale secret), so an operator must clear the columns. Reworded the comment, which had overpromised that they "re-enrol". Adds tests for the both-columns (copied, not renamed) shape: one whose v2 column still carries the v1 wrapper and is reconciled, and one already reconciled by hand that the data pass leaves byte-identical.
760aeef to
d4a162b
Compare
Summary
Fixes #2651. Reconciles the staff two-factor columns from v1 to v2 during
lunar:upgrade— both the column names and the encoding, which the upgrade package previously left untouched (it had no staff/two-factor migration at all). See theissue for the two failure modes.
Changes
Adds
packages/upgrade/database/migrations/2026_06_01_000016_reconcile_staff_two_factor.php:two_factor_secret, renametwo_factor_*→app_authentication_*and droptwo_factor_confirmed_at. A 1.5+ store already has the v2 names and is skipped.encrypt(serialize(...))secret and re-encrypt withencryptString, and bcrypt-hash each plaintext recovery code, storingencryptString(json_encode([hashes]))— matching theencrypted/encrypted:arraycasts on the v2Staffmodel.Tests
Adds
tests/upgrade/Feature/ReconcileStaffTwoFactorTest.php: pre-1.5 rename + re-encode, already-renamed (1.5) re-encode, idempotent re-run, a no-2FA row, a mixed single pass (v1 + already-v2 + null + secret-only), and thetwo_factor_confirmed_at-absent branch.