Skip to content

fix(upgrade): reconcile staff two-factor storage during upgrade - #2652

Open
omarelshopky wants to merge 6 commits into
lunarphp:2.xfrom
omarelshopky:fix/upgrade-reconcile-staff-two-factor
Open

fix(upgrade): reconcile staff two-factor storage during upgrade#2652
omarelshopky wants to merge 6 commits into
lunarphp:2.xfrom
omarelshopky:fix/upgrade-reconcile-staff-two-factor

Conversation

@omarelshopky

Copy link
Copy Markdown
Contributor

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 the
issue for the two failure modes.

Changes

Adds packages/upgrade/database/migrations/2026_06_01_000016_reconcile_staff_two_factor.php:

  • Rename (gated): when a pre-1.5 store still carries two_factor_secret, rename two_factor_*app_authentication_* and drop two_factor_confirmed_at. A 1.5+ store already has the v2 names and is skipped.
  • Re-encode: unwrap v1's encrypt(serialize(...)) secret and re-encrypt with encryptString, and bcrypt-hash each plaintext recovery code, storing encryptString(json_encode([hashes])) — matching the encrypted / encrypted:array casts on the v2 Staff model.
  • Idempotent (already-v2 data fails the serialize probe / is a JSON array → skipped; hashed codes are not re-hashed). An undecryptable record (e.g. a stale value from a prior key rotation) is skipped rather than aborting the whole upgrade. One-way, no down().

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 the two_factor_confirmed_at-absent branch.

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 glennjacobs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, but renameLegacyColumns() 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') before chunkById() 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.
@omarelshopky
omarelshopky force-pushed the fix/upgrade-reconcile-staff-two-factor branch from 30e964b to 3ebd16f Compare August 29, 2026 22:39

@glennjacobs glennjacobs left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@omarelshopky
omarelshopky force-pushed the fix/upgrade-reconcile-staff-two-factor branch from 760aeef to d4a162b Compare September 5, 2026 02:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

2 participants