Feature | Add MultiFactor Authentication. Initial migrations and entities - #125
Feature | Add MultiFactor Authentication. Initial migrations and entities#125matiasperrone-exo wants to merge 11 commits into
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds Phase I two-factor: three Doctrine entities (trusted devices, audit logs, recovery codes), repository interfaces and Doctrine implementations, provider bindings, two migrations (schema + unique index), and integration tests exercising repository behavior. ChangesTwo-factor foundation
sequenceDiagram
rect rgba(100,150,240,0.5)
participant Client
end
rect rgba(120,200,160,0.5)
participant Repository
end
rect rgba(200,100,140,0.5)
participant EntityManager
end
rect rgba(240,200,100,0.5)
participant Database
end
Client->>Repository: persistTrustedDevice(data)
Repository->>EntityManager: persist(UserTrustedDevice)
EntityManager->>Database: INSERT user_trusted_devices
Database-->>EntityManager: OK
EntityManager-->>Repository: persisted entity
Client->>Repository: getActiveByUserAndIdentifier(user, id)
Repository->>EntityManager: createQuery(filter active conditions)
EntityManager->>Database: SELECT ... WHERE user_id=? AND device_identifier=? AND is_revoked=0 AND expires_at > now()
Database-->>EntityManager: rows
EntityManager-->>Repository: entity/NULL
Repository-->>Client: entity/NULL
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (6)
app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php (1)
25-28: Consider documenting transaction/atomicity expectations fordeleteAllForUser.Since this is typically invoked during recovery-code regeneration (delete-then-insert), callers likely need the operation wrapped in a transaction with the subsequent re-insert to avoid leaving a user with zero recovery codes on partial failure. Document whether callers must manage the transaction themselves, or have the implementation wrap it. A one-line docblock addition is sufficient.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php` around lines 25 - 28, Add a one-line sentence to the deleteAllForUser(User $user): int docblock clarifying transaction/atomicity expectations: state whether implementations will wrap this operation in a database transaction or whether callers must perform a surrounding transaction when doing delete-then-insert (e.g., "This method does NOT start a transaction; callers should wrap delete-and-reinsert operations in a transaction to ensure atomicity." or the converse if implementations guarantee transactional semantics). Reference the method name deleteAllForUser in the docblock so callers know the requirement.app/Repositories/DoctrineUserRecoveryCodeRepository.php (1)
34-42: Consider flushing pending UoW changes or documenting caller responsibility.
Query::execute()on a DQLDELETEbypasses the UnitOfWork and runs directly against the DB. If a caller has pending in-memory changes onUserRecoveryCodeentities for this user (e.g. newly persisted but not flushed, or amarkUsed()pending flush), those will not be considered by the bulk delete and may re-surface on a laterflush(), or conflict. Either document this (bulk operation; caller must flush/clear beforehand) or call$em->flush()/$em->clear(UserRecoveryCode::class)around the delete. Given the "used when regenerating" use case, a docblock note is likely sufficient.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Repositories/DoctrineUserRecoveryCodeRepository.php` around lines 34 - 42, The bulk DQL delete in deleteAllForUser bypasses the UnitOfWork and can conflict with pending in-memory UserRecoveryCode changes; update the docblock for DoctrineUserRecoveryCodeRepository::deleteAllForUser to state this is a bulk operation and callers must flush or clear pending UserRecoveryCode changes before calling, or alternatively call $em->flush() and/or $em->clear(UserRecoveryCode::class) around the delete inside deleteAllForUser to ensure UoW consistency (refer to getEntityManager(), UserRecoveryCode and the Query::execute() bulk delete in your change).app/libs/Auth/Models/UserRecoveryCode.php (1)
27-29: Minor: attribute order —JoinColumnis placed beforeManyToOne.Doctrine accepts either order, but the conventional pairing is
#[ManyToOne]first, then#[JoinColumn], which matches Doctrine docs/examples and the surrounding codebase's style. Purely stylistic; no behavior change.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/libs/Auth/Models/UserRecoveryCode.php` around lines 27 - 29, Swap the attribute order on the $user property so #[ORM\ManyToOne(...)] appears before #[ORM\JoinColumn(...)]; specifically update the attributes on the private $user property (currently using #[ORM\JoinColumn(...)] then #[ORM\ManyToOne(...)] ) to #[ORM\ManyToOne(targetEntity: \Auth\User::class)] followed by #[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete: 'CASCADE')], preserving the same arguments.app/Repositories/DoctrineUserTrustedDeviceRepository.php (1)
35-41: Consider deterministic ordering forgetActiveByUser.No
orderByis specified, so results come back in storage order. For a user-facing "trusted devices" listing, ordering bylast_seen_at DESC(ortrusted_at DESC) produces a more predictable/useful listing and avoids flaky tests if ever asserting order.♻️ Suggested change
public function getActiveByUser(User $user): array { return $this->findBy([ 'user' => $user, 'is_revoked' => false, - ]); + ], ['last_seen_at' => 'DESC']); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/Repositories/DoctrineUserTrustedDeviceRepository.php` around lines 35 - 41, getActiveByUser currently returns results with no deterministic ordering; update the repository method (getActiveByUser) to return active devices ordered by most-recent activity (e.g. order by last_seen_at DESC or trusted_at DESC depending on your entity field name) so the user-facing list and tests are stable—use the Doctrine findBy third parameter for orderBy or switch to the query builder in DoctrineUserTrustedDeviceRepository::getActiveByUser to apply the DESC ordering on the appropriate timestamp field.database/migrations/Version20260416194357.php (2)
33-39: Partial-state guard only checks one column.The
up()guard checks onlytwo_factor_enabled. If a prior run failed after addingtwo_factor_enabledbut before adding the other two columns (or vice versa in a manual intervention), subsequent runs will silently skip the missing columns. Consider guarding each column addition independently, e.g.:- if ($schema->hasTable("users") && !$builder->hasColumn("users", "two_factor_enabled")) { - $builder->table('users', function (Table $table) { - $table->boolean('two_factor_enabled')->setNotnull(true)->setDefault(false); - $table->string('two_factor_method', 32)->setNotnull(true)->setDefault('email_otp'); - $table->dateTime('two_factor_enforced_at')->setNotnull(false)->setDefault(null); - }); - } + if ($schema->hasTable("users")) { + $builder->table('users', function (Table $table) use ($builder) { + if (!$builder->hasColumn("users", "two_factor_enabled")) { + $table->boolean('two_factor_enabled')->setNotnull(true)->setDefault(false); + } + if (!$builder->hasColumn("users", "two_factor_method")) { + $table->string('two_factor_method', 32)->setNotnull(true)->setDefault('email_otp'); + } + if (!$builder->hasColumn("users", "two_factor_enforced_at")) { + $table->dateTime('two_factor_enforced_at')->setNotnull(false)->setDefault(null); + } + }); + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@database/migrations/Version20260416194357.php` around lines 33 - 39, The migration's up() currently gates adding all three columns behind a single check for two_factor_enabled, so if a prior run added one column and failed the rest will be skipped; change the logic to first verify $schema->hasTable("users") and then individually check $builder->hasColumn("users", "<column_name>") for each of the three columns (two_factor_enabled, two_factor_method, two_factor_enforced_at) and add each missing column (using the existing builder->table('users', function (Table $table) { ... }) block or separate blocks) so each column is created idempotently even if previous runs partially applied the migration.
55-58: Consider a UNIQUE index on(user_id, device_identifier).
DoctrineUserTrustedDeviceRepository::getActiveByUserAndIdentifier()is expected to return a single row for a given(user, device_identifier)pair, anddevice_identifieris a SHA-256 hex string (globally unique per device/token). A plain index allows duplicate rows to accumulate (e.g., re-trusting the same device on retries) and makes the "single active device" contract unenforceable at the DB layer. A unique constraint would prevent silent duplication and align the schema with the repository's semantics.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@database/migrations/Version20260416194357.php` around lines 55 - 58, Replace the non-unique index on (user_id, device_identifier) with a UNIQUE constraint so the DB enforces one row per (user_id, device_identifier) pair; update the migration where $table->index(["user_id", "device_identifier"], "utd_user_device_idx") is declared in Version20260416194357.php to create a unique index/constraint (e.g. using $table->unique(...) or the DBAL equivalent) with a clear name like "utd_user_device_unique", and remove or replace the existing plain index to avoid duplicate-key conflicts; this aligns the schema with DoctrineUserTrustedDeviceRepository::getActiveByUserAndIdentifier() expectations.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/libs/Auth/Models/UserRecoveryCode.php`:
- Line 66: The magic __get method on class UserRecoveryCode exposes
private/protected properties (notably code_hash) and bypasses the explicit
getters; replace it with a safe approach by removing the passthrough or
implementing an allow-list: in UserRecoveryCode::__get only return values for an
explicit whitelist of non-sensitive keys (e.g., id, user_id, created_at) or
throw/return null for anything else, so sensitive fields like code_hash are not
accessible via property access while preserving legacy compatibility for
approved fields.
In `@app/libs/Auth/Models/UserTrustedDevice.php`:
- Line 85: Remove the magic __get() passthrough from the UserTrustedDevice
entity: delete the public function __get($name) { return $this->{$name}; } so
callers no longer bypass explicit accessors (e.g. getIsRevoked(), isRevoked(),
getUser()) and Doctrine proxies fire their lazy-load hooks; update any call
sites that relied on property-style access to use the declared getters instead
to preserve type casting and avoid silent nulls for missing properties.
In `@tests/TwoFactorRepositoriesTest.php`:
- Around line 37-41: The test currently grabs an arbitrary real user via
IUserRepository->findOneBy([]) in setUp and testRecoveryCodeRoundTrip calls
RecoveryCodeRepository->deleteAllForUser($this->user), risking deletion of real
users' data; change setUp to create (and persist) a dedicated test user or
select a user explicitly marked for testing, then use that user object for the
test, and in tearDown remove that test user and assert deleteAllForUser removed
exactly the number of recovery codes inserted by the test (or call delete only
for the specific created entities) so that testRecoveryCodeRoundTrip no longer
wipes production/seeding data. Ensure you update any references to findOneBy,
testRecoveryCodeRoundTrip, deleteAllForUser, setUp and tearDown accordingly.
---
Nitpick comments:
In `@app/libs/Auth/Models/UserRecoveryCode.php`:
- Around line 27-29: Swap the attribute order on the $user property so
#[ORM\ManyToOne(...)] appears before #[ORM\JoinColumn(...)]; specifically update
the attributes on the private $user property (currently using
#[ORM\JoinColumn(...)] then #[ORM\ManyToOne(...)] ) to
#[ORM\ManyToOne(targetEntity: \Auth\User::class)] followed by
#[ORM\JoinColumn(name: 'user_id', referencedColumnName: 'id', onDelete:
'CASCADE')], preserving the same arguments.
In `@app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php`:
- Around line 25-28: Add a one-line sentence to the deleteAllForUser(User
$user): int docblock clarifying transaction/atomicity expectations: state
whether implementations will wrap this operation in a database transaction or
whether callers must perform a surrounding transaction when doing
delete-then-insert (e.g., "This method does NOT start a transaction; callers
should wrap delete-and-reinsert operations in a transaction to ensure
atomicity." or the converse if implementations guarantee transactional
semantics). Reference the method name deleteAllForUser in the docblock so
callers know the requirement.
In `@app/Repositories/DoctrineUserRecoveryCodeRepository.php`:
- Around line 34-42: The bulk DQL delete in deleteAllForUser bypasses the
UnitOfWork and can conflict with pending in-memory UserRecoveryCode changes;
update the docblock for DoctrineUserRecoveryCodeRepository::deleteAllForUser to
state this is a bulk operation and callers must flush or clear pending
UserRecoveryCode changes before calling, or alternatively call $em->flush()
and/or $em->clear(UserRecoveryCode::class) around the delete inside
deleteAllForUser to ensure UoW consistency (refer to getEntityManager(),
UserRecoveryCode and the Query::execute() bulk delete in your change).
In `@app/Repositories/DoctrineUserTrustedDeviceRepository.php`:
- Around line 35-41: getActiveByUser currently returns results with no
deterministic ordering; update the repository method (getActiveByUser) to return
active devices ordered by most-recent activity (e.g. order by last_seen_at DESC
or trusted_at DESC depending on your entity field name) so the user-facing list
and tests are stable—use the Doctrine findBy third parameter for orderBy or
switch to the query builder in
DoctrineUserTrustedDeviceRepository::getActiveByUser to apply the DESC ordering
on the appropriate timestamp field.
In `@database/migrations/Version20260416194357.php`:
- Around line 33-39: The migration's up() currently gates adding all three
columns behind a single check for two_factor_enabled, so if a prior run added
one column and failed the rest will be skipped; change the logic to first verify
$schema->hasTable("users") and then individually check
$builder->hasColumn("users", "<column_name>") for each of the three columns
(two_factor_enabled, two_factor_method, two_factor_enforced_at) and add each
missing column (using the existing builder->table('users', function (Table
$table) { ... }) block or separate blocks) so each column is created
idempotently even if previous runs partially applied the migration.
- Around line 55-58: Replace the non-unique index on (user_id,
device_identifier) with a UNIQUE constraint so the DB enforces one row per
(user_id, device_identifier) pair; update the migration where
$table->index(["user_id", "device_identifier"], "utd_user_device_idx") is
declared in Version20260416194357.php to create a unique index/constraint (e.g.
using $table->unique(...) or the DBAL equivalent) with a clear name like
"utd_user_device_unique", and remove or replace the existing plain index to
avoid duplicate-key conflicts; this aligns the schema with
DoctrineUserTrustedDeviceRepository::getActiveByUserAndIdentifier()
expectations.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 672e540a-70f5-47fd-8667-ab3e07d97782
📒 Files selected for processing (12)
app/Repositories/DoctrineTwoFactorAuditLogRepository.phpapp/Repositories/DoctrineUserRecoveryCodeRepository.phpapp/Repositories/DoctrineUserTrustedDeviceRepository.phpapp/Repositories/RepositoriesProvider.phpapp/libs/Auth/Models/TwoFactorAuditLog.phpapp/libs/Auth/Models/UserRecoveryCode.phpapp/libs/Auth/Models/UserTrustedDevice.phpapp/libs/Auth/Repositories/ITwoFactorAuditLogRepository.phpapp/libs/Auth/Repositories/IUserRecoveryCodeRepository.phpapp/libs/Auth/Repositories/IUserTrustedDeviceRepository.phpdatabase/migrations/Version20260416194357.phptests/TwoFactorRepositoriesTest.php
martinquiroga-exo
left a comment
There was a problem hiding this comment.
@matiasperrone-exo please see comments. Thanks
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
bd6e93f to
f75c63d
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
f75c63d to
4624ff5
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
|
@martinquiroga-exo please review again |
martinquiroga-exo
left a comment
There was a problem hiding this comment.
@matiasperrone-exo please see comments
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
app/libs/Auth/Models/UserTrustedDevice.php (1)
19-20: Add UniqueConstraint annotation to mirror the database migration.The migration enforces uniqueness on
(user_id, device_identifier)viaUNIQUE INDEX utd_user_device_uniq, but the entity metadata declares no correspondingUniqueConstraint. This drift causes schema validation mismatches and lets metadata-driven tools miss the constraint. Add a#[ORM\UniqueConstraint(name: 'utd_user_device_uniq', columns: ['user_id', 'device_identifier'])]attribute to the#[ORM\Table]declaration.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@app/libs/Auth/Models/UserTrustedDevice.php` around lines 19 - 20, The ORM entity for UserTrustedDevice is missing the UniqueConstraint that mirrors the DB migration; update the #[ORM\Table(...)] attribute on the UserTrustedDevice class to include #[ORM\UniqueConstraint(name: 'utd_user_device_uniq', columns: ['user_id', 'device_identifier'])] so the entity metadata matches the migration (ensure the attribute is added alongside the existing name and repositoryClass in the class declaration for UserTrustedDevice).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@app/libs/Auth/Models/TwoFactorAuditLog.php`:
- Around line 22-35: The setters for audit event/method must validate input
against the allowed constants to prevent typos from being persisted; update the
TwoFactorAuditLog class to add validation in the relevant setters (e.g., the
methods that set event and method values) by building arrays of allowed values
from the constants (Event* and Method*) and checking inputs with an explicit
membership test (throw a ValidationException/InvalidArgumentException or return
an error instead of accepting unknown strings), and apply the same guard to the
other setters referenced around lines 75-79 so only the defined constants can be
stored (also consider adding a DB constraint/migration as a follow-up).
In `@database/migrations/Version20260424120000.php`:
- Around line 27-33: In Version20260424120000.php inside the up(Schema $schema)
method, guard the ALTER TABLE that replaces utd_user_device_idx with the UNIQUE
utd_user_device_uniq by first running a preflight duplicate check on
user_trusted_devices for (user_id, device_identifier) and aborting/messaging the
migration if any duplicates exist; if duplicates are found implement a
deterministic dedupe/backfill step (e.g. keep the most recent row per (user_id,
device_identifier) or copy conflicting rows to a retention table and delete
extras) before executing the ALTER TABLE, and then proceed to DROP INDEX
utd_user_device_idx and ADD UNIQUE INDEX utd_user_device_uniq only after
deduplication completes successfully.
---
Nitpick comments:
In `@app/libs/Auth/Models/UserTrustedDevice.php`:
- Around line 19-20: The ORM entity for UserTrustedDevice is missing the
UniqueConstraint that mirrors the DB migration; update the #[ORM\Table(...)]
attribute on the UserTrustedDevice class to include #[ORM\UniqueConstraint(name:
'utd_user_device_uniq', columns: ['user_id', 'device_identifier'])] so the
entity metadata matches the migration (ensure the attribute is added alongside
the existing name and repositoryClass in the class declaration for
UserTrustedDevice).
🪄 Autofix (Beta)
❌ Autofix failed (check again to retry)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 8e15f9c4-68e3-4df9-a2d3-f1e2af5e201f
📒 Files selected for processing (6)
app/Repositories/DoctrineUserTrustedDeviceRepository.phpapp/libs/Auth/Models/TwoFactorAuditLog.phpapp/libs/Auth/Models/UserRecoveryCode.phpapp/libs/Auth/Models/UserTrustedDevice.phpdatabase/migrations/Version20260424120000.phptests/TwoFactorRepositoriesTest.php
16e6ebb to
8fab15c
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
8fab15c to
5b8c829
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
a5c0cff to
f21b6fb
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
3 similar comments
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
done |
|
Note Autofix is a beta feature. Expect some limitations and changes as we gather feedback and continue to improve it. Autofix skipped. No unresolved CodeRabbit review comments with fix instructions found. |
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
|
@smarcet please review now.
|
4bf6d11 to
2ce71ca
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
feat(2fa): add migration for 2FA schema foundation (Phase I) feat(2fa): add UserTrustedDevice entity feat(2fa): add TwoFactorAuditLog entity feat(2fa): add UserRecoveryCode entity feat(2fa): add repository interfaces for 2FA entities feat(2fa): add Doctrine implementations for 2FA repositories feat(2fa): register 2FA repositories in service container test(2fa): add repository round-trip tests for 2FA entities
… migration for user_recovery_codes
e323583 to
ed53348
Compare
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
…#126) * feat: Add MultiFactor Authentication * Feature | Add AuthService validateCredentials method (#127) * feat: Add AuthService validateCredentials method - test: cover canLogin()=false branch in validateCredentials() unit tests - docs: document known double-query cost in validateCredentials() - fix: use consistent error message in validateCredentials() * chore: lint file app/libs/Auth/AuthService.php * chore: Add PR's requested changes * chore: Add PR's requested changes Add tests changes with suggestion * chore: Fix issues created on rebase * Feature | MFA Challenge Strategy Pattern (Interface, Abstract, Factory, EmailOTP) (#129) * feat: Implement Multi-Factor Authentication challenge strategies and tests * chore: Add PR's requested changes * Feature | Add Device Trust Service (#133) * feat: Add Device Trust Service * Feature | Two-Factor Audit Service (#134) * feat: Two-Factor Audit Service * Feature | MFAGateService (Two-Factor Gate Decision Service) (#135) * feat: MFAGateService (Two-Factor Gate Decision Service) * Feature | UserController MFA Integration, Device Trust Cookie Management, Audit Wiring, and 2FA Rate Limiting (#136) * feat: UserController MFA Integration, Device Trust Cookie Management, Audit Wiring, and 2FA Rate Limiting * chore: Add PR's requested changed * chore: Add PR's requested changes * Add TWO_FACTOR_ENABLED global kill-switch to MFA gate MFAGateService::requiresChallenge() had no master on/off switch, contradicting the SDS idp-mfa.md §10.1 rollout plan, which requires being able to instantly revert to password-only login without a code rollback if something goes wrong post-deploy. config/two_factor.php gains an 'enabled' key (env TWO_FACTOR_ENABLED, default true) checked first in requiresChallenge(), short-circuiting before any per-user or device-trust evaluation. * Route MFA challenge responses through login_strategy, not hardcoded JSON postLogin()'s mfa_required response, and the display-strategy contract it depends on, bypassed $this->login_strategy entirely: every MFA response was Response::json(...) built by hand in the controller, ignoring OAuth2 display-strategy polymorphism (native vs page/popup/touch). Native OAuth2 clients (display=native) got JSON+200 with an ad hoc shape instead of the 412 + required_params/url/method contract every other login error already returns for that display mode. - ILoginStrategy::challengeRequired() / IDisplayResponseStrategy:: getChallengeRequiredResponse(): new methods, distinct from errorLogin() since a pending MFA challenge isn't a failed attempt. - DefaultLoginStrategy: identical bytes to before (200 + JSON) - zero behavior change for the plain IdP flow. - OAuth2LoginStrategy: rebuilds the auth_request from the memento (same pattern as errorLogin()) and delegates to DisplayResponseStrategyFactory. - DisplayResponseJsonStrategy (native): 412, matching its sibling getConsentResponse/getLoginResponse/getLoginErrorResponse methods. - DisplayResponseUserAgentStrategy (page/popup/touch): 200 JSON, same live in-SPA transition as the plain flow, since both render the same login.js. - ILoginStrategy::MFA_REQUIRED constant replaces the 'mfa_required' literal duplicated across three classes. Also closes a refresh-resilience gap PR #142's frontend already expected but the backend never delivered (its login.js constructor comment reads "Two-factor state (populated from the flash redirect...)"): postLogin() now flashes flow/mfa_method/otp_length/otp_lifetime to session on mfa_required so a page refresh mid-challenge restores the 2FA screen instead of dropping back to the password form. Cleared on successful verification/recovery and on session expiry; refreshed on resend2FA() (including method switches). New: OAuth2NativeMFALoginFlowTest exercises the real /oauth2/auth -> memento -> postLogin() path for display=native and asserts 412+mfa_required. TwoFactorLoginFlowTest gains coverage for the session-flash/clear behavior. * Clear pending MFA challenge and UI-restoration state on cancelLogin() None of the three login strategies' cancelLogin() cleared any 2FA session state - not the pre-existing 2fa_pending_user_id/2fa_pending_at/2fa_remember keys, nor the flow/mfa_method/otp_length/otp_lifetime keys added for refresh-resilience. PR #142's Cancel button resets the client's React state immediately and fires cancelLogin() as a best-effort background call, so the broken UX was masked within the same tab - but a subsequent full page load within the challenge's 300s TTL (back button, reopened tab, direct /login navigation) would restore the 2FA screen for a challenge the user explicitly abandoned, and the stale OTP could still complete it. UserController::cancelLogin() now resolves the pending strategy via the mfa_method session key (when present) and clears its pending state before delegating to the login strategy, plus clears the UI-restoration keys via the existing clearMFAUISessionState() helper. New test proves the strongest form of the property: an OTP valid before cancel returns mfa_session_expired afterward, not just that some session keys are gone. * Block passwordless MFA bypass; make challengeRequired self-contained Two related fixes to the MFA login flow: 1. Passwordless (flow=otp) login never checked shouldRequire2FA(), so an enforced-2FA user could bypass MFA entirely via emitOTP() + postLogin with flow=otp instead of flow=password (SDS idp-mfa.md §7.4 / Open Question #3 explicitly treats passwordless as single-factor). Now throws AuthenticationException before loginWithOTP(), reusing the existing errorLogin() redirect+flash path - the OTP form still submits as a native form POST, so this needed no new response contract. 2. challengeRequired()'s redirect-based implementations (DefaultLoginStrategy, DisplayResponseUserAgentStrategy) previously ignored the $params they received, silently depending on the caller having already flashed otp_length/otp_lifetime to session - an implicit contract that would silently break for any other caller. Both now flash their own $params (persistent, not one-shot, so it survives repeated refreshes) and set error_code, mirroring what DisplayResponseJsonStrategy already sends native clients in JSON. clearMFAUISessionState() now clears error_code too. The '2fa' flow value moves from a new ILoginStrategy constant to IAuthService::AuthenticationFlowMFA, alongside its siblings AuthenticationFlowPassword/AuthenticationFlowPasswordless - all three are the same session 'flow' enum (already flashed together in the AuthenticationException catch block), so splitting the third value into a different interface would have been inconsistent. New test: OAuth2NativeMFALoginFlowTest gains a non-native (page/popup/ touch) case proving the 302+session-flash contract, alongside the existing native 412+JSON case. TwoFactorLoginFlowTest covers the passwordless-bypass rejection (including that it still reuses errorLogin(), not a new JSON contract) and the error_code flash/clear. * Rate-limit the initial MFA challenge issuance in postLogin() The '2fa.rate' middleware could never gate postLogin()'s initial OTP issuance: its before-phase reads 2fa_pending_user_id from session to know which user to throttle, but that key is only written by issueChallenge() - inside the very request that would need throttling. A user with valid credentials could repeatedly POST to the plain login route and trigger unlimited email-OTP sends, bypassing the 5-per-15-minute resend cap entirely (SDS idp-mfa.md §4.12 explicitly requires the initial issuance to share the same 2fa_rate:resend:{user_id} window as resend()). Extracted the cache-key/window logic that lived only in TwoFactorRateLimitMiddleware into ITwoFactorRateLimitService / TwoFactorRateLimitService (same pattern as DeviceTrustService / TwoFactorAuditService / MFAGateService, registered in TwoFactorServiceProvider), so both the middleware (verify/recovery/resend routes) and UserController::postLogin() (initial issuance, now knows the user id post-validateCredentials()) share one source of truth instead of duplicating cache-key construction. postLogin() checks isRateLimited() before issuing a challenge and calls increment() after a successful issue. The rejection throws AuthenticationException, reusing the existing catch block's errorLogin() redirect+flash path - consistent with challengeRequired() already being redirect-based, since the password form still submits as a native form POST. resend2FA()/verify2FA()/verifyRecoveryCode() stay JSON+429 via the middleware, unaffected, since those are AJAX-only endpoints. New test proves postLogin() and resend() share the same window: after max_otp_requests postLogin() calls, the next one is rejected. * Fix op_browser_state ordering bug in AuthService::loginUser() Investigated the "session fixation" finding from the PR review (SDS idp-mfa.md §9.3 asks for a test proving 2fa_pending_user_id cannot be injected). Traced actual runtime behavior via debug instrumentation before writing a fix, since pattern-matching "no explicit Session::regenerate() call" as a vulnerability turned out to be wrong. Laravel's SessionGuard::login() (invoked via Auth::login(), already called unconditionally at the end of loginUser()) already calls $session->migrate(true) internally - the session-fixation window was already closed by the framework, with no code change needed for that property specifically. An added test asserting this (comparing session ID before/after login) passed identically with or without any fix, proving it was a false positive caused by this test harness resetting the session ID between $this->action() calls regardless of production behavior - that test was written and then discarded rather than kept for false confidence. What IS real, found via the same investigation: PrincipalService::register() (called by loginUser() before this fix) hashes the CURRENT session ID into op_browser_state, used for OIDC Session Management (check-session iframe). Since register() ran BEFORE Auth::login(), its hash was computed from a session ID that Auth::login()'s own migrate(true) was about to invalidate moments later - any relying party polling the check-session iframe would see a value that no longer matched what the server would recompute, incorrectly signaling a session change. Fix: call Auth::login() first, then principal_service->clear()/register() after, so the hash uses the final, stable post-login session ID. No new Session::regenerate() call needed - Auth::login() already provides one. New tests: - AuthServiceLoginUserTest (unit, Mockery-alias facades, same pattern as AuthServiceLogoutTest): asserts the call order directly. - TwoFactorLoginFlowTest::testCompletedMFALoginKeepsOPBrowserStateConsistentWithSessionId (integration): proves op_browser_state matches a freshly-computed hash of the post-login session ID end-to-end through the real MFA verify flow. Confirmed failing against the pre-fix ordering, passing after. * Add test proving OTP redeem rolls back on mid-transaction failure Ticket CU-86ba2zc6p's TESTS list requires: "OTP redeem is persisted only on commit; a failure inside the verify transaction rolls back the redeem." No such test existed anywhere in this branch or PR #142/#146 - the two closest existing tests (testOTPCodeRejectsReuseAfterSuccessfulVerification, testRecoveryCodeRejectsReuseAfterTransactionCommit) only prove the COMMIT path (a successful verification's redeem persists and blocks reuse), not that a FAILED verification's partial redeem rolls back. Pure test-coverage gap, no production fix needed - AuthService::verifyMFAChallenge() already wraps strategy->verifyChallenge() in tx_service->transaction(), and DoctrineTransactionService already rolls back and re-throws on failure. Confirmed the test has teeth: temporarily bypassing the transaction wrapper broke the pessimistic-lock acquisition inside verifyChallenge() (which requires an open transaction), proving the test environment genuinely depends on transactional context, not just coincidentally passing. testOTPRedeemRollsBackOnMidTransactionFailure wraps the real EmailOTPMFAChallengeStrategy in a test double that lets the genuine redeem happen, then throws immediately after - inside the same transaction. Asserts the OTP is refetched from the DB (post-rollback) still unredeemed. * Make verify2FARecovery audit logging best-effort EventRecoveryUsed was logged unguarded after loginUser() and clearPendingState(), so an audit-sink failure at that point propagated to the outer catch(Exception) and returned a 500 to a user who was already authenticated with an already-burned recovery code — the account's last-resort login path. Mirrors the same best-effort try/catch already applied to verify2FA()'s EventChallengeSucceeded audit call. Adds testRecoveryAuditFailureDoesNotBlockLogin, the recovery-path analogue of testAuditFailureDoesNotBlockLogin, reproducing the 500 before the fix and asserting a 302 + established session after it. * Add real concurrent-connection tests for OTP/recovery-code row locks testOTPCodeRejectsReuseAfterSuccessfulVerification and testRecoveryCodeRejectsReuseAfterTransactionCommit only prove sequential reuse is rejected after a transaction commits. Neither exercises the actual property refreshExclusiveLock() exists for: blocking a second, concurrent request from redeeming the same unredeemed OTP or recovery code while the first request's transaction still holds the row. Adds two tests that open a genuinely independent physical DB connection (verified via differing MySQL CONNECTION_ID()) and prove FOR UPDATE from that connection is blocked (lock wait timeout) while EmailOTPMFAChallengeStrategy/AbstractMFAChallengeStrategy's production refreshExclusiveLock() call holds the row. Verified the assertion is non-vacuous by temporarily disabling the lock call and confirming the test fails as expected, then restoring it. * Guard all MFA audit-log calls against Throwable, not just Exception Best-effort audit logging around the MFA flows only caught Exception, which misses Error subtypes (TypeError, ArgumentCountError, etc.). An Error escaping any of these would still turn a clean response into an uncaught 500 or, worse for the two failure-path calls, drop the error_code the rate-limit middleware keys its failure counter on (TwoFactorRateLimitMiddleware::isFailure() only sees the JSON body of whatever response actually gets returned). Applies the codebase's existing convention for this exact situation (see app/Audit/AuditLoggerFactory.php, TrackRequestMiddleware.php) to all 7 best-effort audit/device-trust sites in this controller: - postLogin(): initial challenge issuance audit log (was unguarded) - verify2FA(): failure-path audit log (was unguarded) - verify2FA(): queueDeviceTrustCookie() call (was catch(Exception)) - verify2FA(): success-path audit log (was catch(Exception)) - verify2FARecovery(): failure-path audit log (was unguarded) - verify2FARecovery(): success-path audit log (was catch(Exception)) - resend2FA(): challenge-reissue audit log (was unguarded) Verified: full Two Factor Authentication Test Suite (83 tests, 241 assertions) passes unchanged. Co-Authored-By: Claude <noreply@anthropic.com> * Honor the global 2FA kill-switch in User::shouldRequire2FA() The passwordless-login guard called shouldRequire2FA() directly, which ignored config('two_factor.enabled'), so an enforced admin stayed blocked from passwordless login even with the kill-switch off (SDS idp-mfa.md rollout, section 10.1). Move the enabled check into shouldRequire2FA() as the single source of truth shared by both the MFA gate and the passwordless guard, and drop the now-redundant check in MFAGateService. * Feature | Add Login UI MFA Flow (#142) * feat: Add Login UI MFA Flow * fix: rename HTMLRender.jsx to .js so webpack can resolve it webpack.common.js has no .jsx resolve extension configured, so the bare '../../shared/HTMLRender' import used by every login form component failed to resolve, breaking the build for this whole tree. * fix: revert password submit to native form POST The backend login strategies (DefaultLoginStrategy, DisplayResponseUserAgentStrategy) answer wrong-password and mfa_required with a 302 redirect plus flashed/persisted session state, meant to be consumed by a native top-level form submit - the same mechanism already used by the OTP and MFA screens. Converting the password step to AJAX (postRawRequestFull) broke that contract: the hidden XHR redirect-follow GET consumed the one-shot flash before the SPA could show it, silently dropping the wrong-password message, resetting login_attempts (disabling the server-side captcha escalation), and losing native password-manager save/update prompts. Reverts PasswordInputForm to the same native-submit adapter OTPInputForm already uses, and removes the now-dead AJAX path: handleAuthenticatePasswordFlow/Ok/Error, authenticateWithPassword, window.FORM_ACTION_ENDPOINT, and the MFA_CHALLENGE_REQUIRED constant (confirmed unused end-to-end - the server never emits mfa_required as JSON to browser clients either, only via session state under the 'flow' key). Also removes disabled={disableInput} from the password TextField and the 'remember' FormControlLabel. Under native submission, React's synchronous setState(disableInput: true) inside the same onSubmit handler commits the disabled attribute to the DOM before the browser constructs the form's data set - and the HTML spec excludes disabled controls from that set. The result was a silently dropped password field ('The password field is required.', confirmed live against the backend). OTPInputForm was never affected because it only disables its submit Button, never the field carrying the actual submitted value - the fix here matches that pattern. * fix: add missing React import in HTMLRender to prevent ReferenceError crash HTMLRender uses JSX (<Component .../>) but never imported React. The project's babel-preset-react runs in classic mode (webpack.common.js), which compiles JSX to React.createElement(...) calls requiring React in scope per-module - importing it in a sibling file doesn't help, since webpack wraps each module in its own function scope. Every other component in this PR imports React; this one was missed. It went unnoticed while the file's own path (HTMLRender.jsx) failed to resolve at all; once that resolution bug was fixed, the runtime ReferenceError surfaced and crashed the whole login page on any render path that hits this component (confirmed live: 'ReferenceError: React is not defined', white-screen crash after password submit). * fix: cancel login now invalidates the pending MFA challenge server-side The 'cancel' route was GET-only (pre-dates this feature, never had a JS caller before). This PR's new cancelLogin() action POSTs to it, which 405'd silently (no .catch on the fire-and-forget call) - so UserController::cancelLogin()'s MFA cleanup (clearPendingState() + clearMFAUISessionState()) never ran. An OTP issued before Cancel stayed valid server-side despite the UI resetting to the password screen. Registers 'cancel' as POST + csrf, matching the sibling verify/recovery/resend routes (GET would work too - Laravel's CSRF middleware only checks unsafe verbs - but modeling a state-mutating action as GET risks a prefetcher/link-scanner silently cancelling a real pending session). Adds error handling to the previously fire-and-forget JS call, and updates TwoFactorLoginFlowTest's cancelLogin() test helper to POST with a CSRF token (it called the old GET route directly and would 405 otherwise). Verified live: POST /auth/login/cancel -> 200, and tests/TwoFactorLoginFlowTest.php passes in full (31 tests, 116 assertions). * fix: stop the 2FA verify XHR from following cross-origin OAuth2 redirects Root cause: verify2FA()/verify2FARecovery() returned login_strategy->postLogin()'s raw RedirectResponse directly to the XHR that called them. postLogin() always redirects to a same-origin URL (e.g. /oauth2/auth), but when the OAuth2 client already has consent on file, that endpoint's own consent-bypass branch (InteractiveGrantType::handle(), the has_former_consent + auto_approval case) issues the authorization code and redirects straight to the client's cross-origin redirect_uri - a hop the XHR was transparently trying to follow. No browser XHR/fetch can read a cross-origin redirect's response (confirmed against superagent's own source: lib/client.js, the browser build this project ships, has zero redirect-handling logic - only lib/node/index.js implements the .redirects(n) option, so that setting is a silent no-op in the browser). Worse, that same consent-bypass branch calls memento_service->forget() right after building the response, since the server considers the authorization complete - so the silently-failed XHR follow-through burns a real, delivered authorization code with no way for the frontend to recover it. handleMfaError()'s fallback (window.location.reload()) then finds the OAuth2 memento gone and lands the user on their own profile instead of resuming the flow - confirmed live end-to-end against a real oauth2_test_app client with a pre-existing consent record. Fix: verify2FA()/verify2FARecovery() now capture postLogin()'s redirect target and return it as JSON data (redirect_url) instead of a raw redirect. The frontend does a real window.location.href navigation to that same-origin URL - top-level navigations are never subject to CORS, so the browser completes any further hop (including the cross-origin one) natively, exactly as the original pre-MFA native-form-submit login flow always did. Cleanup: postRawRequestFull's finalUrl/status become unused by all three remaining callers (verify2FA, resend2FA, verifyRecoveryCode) once this lands, making it functionally identical to postRawRequest - removed and callers switched over. Also replaces the three remaining raw Response::json calls (HTTP_UNAUTHORIZED) in UserController with JsonResponses::unauthorized(), completing the same trait-based convention already used for the other status codes in this controller; the now-unused Symfony Response import (HttpResponse) is removed. Verified live against a real OAuth2 authorization_code flow (oauth2_test_app, consent already on file): the post-2FA redirect now correctly lands on the client's registered redirect_uri instead of the user's own profile page. tests/TwoFactorLoginFlowTest.php passes in full (31 tests, 120 assertions), updated to match the new 200+redirect_url contract on verify2FA/recovery success (six assertions across five tests); the three assertions covering postLogin()'s own native-submit paths (mfa_required, OTP-flow rejection, rate-limited retry) are untouched since postLogin() itself still redirects directly for those callers. * fix: cancel and session-expiry now correctly return to the password screen Root cause was two-layered: 1. resetToPasswordFlow() reset authFlow to FLOW.PASSWORD but also cleared user_name/user_pic/user_fullname/user_verified in the same setState call. isPasswordFlow's render condition requires user_verified === true, so wiping it forced the render logic to showDefaultFlow (the email screen) regardless of authFlow being correct. 2. That alone wasn't sufficient: since the password step now submits as a native form POST (see the earlier native-submit fix), the mfa_required transition is a full page reload, not a client-side setState - the React app remounts from scratch and only recovers state the backend flashed to session. issueChallenge() (EmailOTPMFAChallengeStrategy/AbstractMFA ChallengeStrategy) only returns otp_length/otp_lifetime, so challengeRequired()'s session flash never carried username/user_fullname/ user_pic/user_verified in the first place - user_verified was already false the moment the 2FA screen first rendered, before Cancel was ever clicked. Fix #1 alone had nothing to preserve. Fix: postLogin()'s mfa_required branch now merges the same identity fields into the challengeRequired() payload that the AuthenticationException errorLogin() branch already flashes (same fields, same getters: username, user_fullname, user_pic, user_verified, user_is_active) - restoring the identity chip on the 2FA screen and giving resetToPasswordFlow() correct state to preserve. resetToPasswordFlow() no longer clears user_name/user_pic/ user_fullname/user_verified. Verified live: 2FA screen now shows the identity chip from first render: Cancel from the 2FA screen now returns directly to the password screen with the same user still identified, instead of resetting to the email-entry screen. tests/TwoFactorLoginFlowTest.php passes in full (31 tests, 120 assertions) - unaffected, since no test asserted the previously-missing identity fields. * fix: clear identity fields from session on MFA cancel/verify-success clearMFAUISessionState() only forgot flow/mfa_method/otp_length/otp_lifetime/ error_code. postLogin()'s challengeRequired() payload also persists username, user_fullname, user_pic, user_verified and user_is_active (needed to hydrate the React app on the initial post-redirect GET /login after an MFA challenge is issued), but those were never cleared - so they survived cancel, a successful verify, or a session-expiry indefinitely. On a shared browser session, the next visitor to hit /login would inherit the previous attempt's identity chip and skip straight to the password screen. Extend clearMFAUISessionState() to forget the same 5 keys, and extend testSuccessfulVerificationClearsUIState / testCancelClearsUIStateAndPendingChallenge to assert they're gone, matching the existing coverage for the other UI-state keys. * fix: invalidate pending MFA challenge when identity chip is cleared handleDelete() (the login page's identity chip "x") reset client-side state but never called cancelLogin(), unlike the explicit "Cancel" link (resetToPasswordFlow()). During the 2fa/recovery screens this left the pending 2fa_pending_user_id session state and the issued OTP alive server-side until the session TTL, instead of being invalidated immediately like Cancel does. PR #142 review finding #1. * fix: block submitting an expired MFA code TwoFactorForm computed `expired` to show the countdown message but never used it to gate submission. A submit after expiry always fails server-side with mfa_verification_failed, which counts against the 2fa.rate:verify middleware's 3-attempt window - letting a user burn that budget on guaranteed-fail submits and get 429-locked out of the login flow entirely. Disable the VERIFY button and short-circuit handleSubmit (Enter-key defense in depth) once expired is true. PR #142 review finding #4. * fix: seed the MFA countdown with the remaining OTP lifetime after refresh The session stored otp_lifetime as a static duration, so any mid-challenge GET /login re-seeded the countdown with the FULL TTL - a user refreshing 4 minutes into a 5-minute challenge saw a fresh 5:00 countdown for a code the server would reject much sooner, letting them burn the 2fa.rate:verify attempt window (3 failures / 15 min lockout) on a code the UI claimed was still valid. issueChallenge() now also returns otp_issued_at taken from the OTP entity's created_at - the same source isAlive()/getRemainingLifetime() use server-side, so the countdown can never drift from the actual expiry check (a controller-side time() stamp would land after issuance and overstate the remaining window). The timestamp rides the same challengeRequired()/session mechanism as otp_length/otp_lifetime, is kept in sync on resend, cleared with the rest of the MFA UI state, and the blade seeds config.otpLifetime with max(0, lifetime - elapsed). RED verified before the fix: the new reproducer rendered the full TTL (600) instead of the remaining ~500. Full TwoFactorLoginFlowTest suite green: 32 tests, 140 assertions. PR #142 review finding LOW #1. * feat: add expiry countdown to the passwordless OTP form, dedup shared code-entry UI The passwordless OTP expires exactly like the MFA one (same createOTPFromPayload infra) but its form gave no expiry feedback at all - the user only found out the code was dead after a full form POST the server rejected. emitOTP already returned otp_lifetime; the client just ignored it. Extract the duplicated code-entry cluster shared by TwoFactorForm and OTPInputForm into two reusable pieces: - use_otp_countdown.js: the 1s expiry ticker (reset via otpLifetime / codeVersion), lifted verbatim from TwoFactorForm. - otp_code_input.js: subtitle + OTP boxes + error + optional countdown (the ~25-line block both forms duplicated). OTPInputForm now shows the countdown and blocks submitting an expired code (same gating pattern as the MFA form). The countdown only renders when a fresh emitOTP happened in this page view (passwordlessLifetime state, null on restored views) - after a failed-submit reload the issuance time is unknown and showing a fresh full countdown would overstate the code's validity. Verified: babel parse on all five files + full yarn build (prod webpack) green. Net -14 lines including the new feature. * fix: surface cancelLogin failures instead of swallowing them in console Both cancel paths (resetToPasswordFlow and handleDelete) reset the UI optimistically and fired cancelLogin() without handling failure beyond a console.error - on a network failure the server-side pending challenge silently survived until its 300s TTL while the UI told the user it was cancelled. Extract the duplicated call into a single cancelPendingLogin() helper that warns the user via the existing snackbar when the server-side invalidation fails, so they know the pending verification will only die by its own TTL. The optimistic reset is kept - the user asked to cancel, so returning control immediately stays correct. PR #142 review finding LOW #2. * feat: add 30s resend cooldown to the passwordless OTP screen The 'resend email.' link in OTPHelpLinks had no throttle at all - each click fired a fresh emitOTP request immediately, unlike the MFA screen's 'resend code' link (TwoFactorForm), which already cools down for 30s. Mirror that exact pattern: OTPHelpLinks gains its own cooldown timer (useState/setInterval, same shape as TwoFactorForm's) and is also disabled while disableInput is true, matching the disableInput-gating convention already enforced elsewhere in this login flow. Promoted RESEND_COOLDOWN_SECONDS from a local constant in two_factor_form.js to constants.js so both forms share one value. Verified live in-browser (not just build): resend fires exactly one emitOTP request, the link disables and counts down (30s -> 1s), a click mid-cooldown fires zero additional requests, and the link re-enables with the countdown reset after expiry. Plan: docs/plans/2026-07-23-passwordless-otp-resend-cooldown.md, Task 1. * feat: rate-limit the passwordless OTP issuance endpoint server-side POST /auth/login/otp (emitOTP) had zero server-side throttle, unlike the MFA resend endpoint's 2fa.rate:resend. Reuses TwoFactorRateLimitMiddleware /TwoFactorRateLimitService via a new 'otp' action instead of duplicating a parallel middleware - the counting logic (cache-backed fixed window, 429 JSON shape) was already subject-agnostic; only the subject-resolution step needed a branch, since emitOTP() never writes any session state to key on (verified: zero Session::put calls in that method) unlike the session-keyed MFA actions. isRateLimited()/increment()/cacheKey() widen from int to string|int - source-compatible with both existing call sites (TwoFactorRateLimitMiddleware, UserController::postLogin()), which already pass an int. The otp subject is the submitted email, lowercased and trimmed - not just trimmed like postLogin()'s username normalization, which is safe only because it feeds a case-insensitive DB lookup before ever reaching a rate limiter. otp has no such lookup; the raw string IS the cache key, so trim-only normalization would let an attacker reset the budget every request by cycling the target email's casing (verified live: users.email collation is utf8mb3_unicode_ci). Caught and fixed via spec-review before implementation - see the case-insensitivity test below. New config keys max_otp_email_requests/otp_email_window_minutes (both default 5/15min, same as the MFA resend budget) are kept independent so ops can tune the anonymous endpoint separately. Client: emitOtpAction's error handler now shows a specific 'Too many attempts' message on 429 instead of the generic fallback. Two new PHPUnit tests: threshold + per-email isolation, and the case-insensitivity fix specifically. Both verified RED before implementation. flushRateLimitCounters() extended to also clear the new email-keyed cache entries between tests - a real cross-test contamination bug surfaced when running the full suite (an early test failed because the new tests' counters leaked into it), not merely anticipated. Verified: full TwoFactorLoginFlowTest suite green (34 tests, 145 assertions, includes regression coverage for the existing MFA rate limits). Live end-to-end in-browser: a real 429 with the specific snackbar message, confirmed against localhost with the limit temporarily lowered to 1. Also found and fixed, as a side effect of that live check, a pre-existing storage/framework/cache permission issue unrelated to this change's code (files owned by root from prior root-run test sessions blocked www-data's cache writes) - not part of this commit's diff. Plan: docs/plans/2026-07-23-passwordless-otp-resend-cooldown.md, Task 2. * fix: mock EmailOTPMFAChallengeStrategy's new getCreatedAt() call in unit tests CI broke on push: issueChallenge()/resendChallenge() gained a call to $otp->getCreatedAt() in an earlier commit this session (0330c3be, seeding the MFA countdown with the OTP's actual issuance time), but the strict Mockery mocks in EmailOTPMFAChallengeStrategyTest never declared that expectation - BadMethodCallException on every call, in both testIssueChallenge_storesPendingStateAndReturnsOtpInfo and testResendChallenge_delegatesToIssueChallenge. Only ran tests/TwoFactorLoginFlowTest.php locally in that earlier commit (the file the plan named), not the full suite - this unit test file was never exercised until CI's own full run caught it. Mock getCreatedAt() with a fixed DateTime and extend both tests' assertSame() to include the new otp_issued_at key in the expected result array, matching the real return shape. Verified in isolation: 5 tests, 8 assertions, green. * feat: passwordless OTP screen survives browser refresh Mirrors the MFA challenge flow's existing refresh-resilience pattern: emitOTP() now persists flow/username/user_verified/otp_length/otp_lifetime/ otp_issued_at and identity fields (when the user already exists) via Session::put(), the same keys login.blade.php already rehydrates generically for the MFA screen. user_verified is set unconditionally since loginWithOTP() auto-registers brand-new emails at redemption time. State is cleared via the existing clearMFAUISessionState() on a successful passwordless login and on cancel (login.js's handleDelete() now also invokes cancelPendingLogin() for the passwordless flow via a new isPasswordlessFlow() predicate, not just MFA). Also fixes a gap found during live browser verification: OTPInputForm read a separate, never-seeded state.passwordlessLifetime field instead of the session-restored otpLifetime prop, so the countdown disappeared on refresh even though the screen itself restored correctly. 4 new tests in TwoFactorLoginFlowTest.php cover: session persistence on emit, persistence for not-yet-registered emails, clearing on successful login, and clearing on cancel. Full suite: 38 tests, 184 assertions. * fix: show success snackbar when passwordless OTP code is (re)sent Root cause: emitOtpAction() (shared by the initial automatic passwordless send and the explicit "resend email" click) never called this.showAlert(...), unlike its sibling onResend2FA() which confirms a successful MFA resend. Adds the same showAlert(..., "success") call to emitOtpAction()'s success branch, mirroring onResend2FA() verbatim. Extracts the message into a new shared constant CODE_RESENT_MESSAGE so the two flows can't diverge in wording. Note: the snackbar now also fires on the initial code-send, not just an explicit resend, since both paths share emitOtpAction() - confirmed via live browser verification, a deliberate trade-off over adding a new isResend flag. * fix: set Retry-After/X-RateLimit-* headers on 2FA rate-limit 429s Root cause: TwoFactorRateLimitMiddleware.php:70-79 returned a 429 with no headers because ITwoFactorRateLimitService only exposed isRateLimited()/ increment() - no way to learn the configured limit or window reset time. Switches TwoFactorRateLimitService's internals from hand-rolled Cache::get/add/increment calls to Laravel's own Illuminate\Support\Facades\ RateLimiter (already used elsewhere in this codebase, already installed, implements the same fixed-window counter+timer pattern, and is driver-agnostic - this deployment's actual cache driver is 'file', so a Redis-specific TTL query would have silently misbehaved). Adds getLimit() and getRetryAfterSeconds() to the interface, backed by it. TwoFactorRateLimitMiddleware now attaches Retry-After, X-RateLimit-Limit, and X-RateLimit-Remaining to its 429 JSON response using these two methods. Same cache-key format preserved, so UserController::postLogin()'s direct isRateLimited()/increment() calls (the MFA-shares-resend-window rule) are unaffected. flushRateLimitCounters() test helper updated to also clear the new ":timer" companion key RateLimiter::hit() writes. Verified live: triggering a real 429 via curl against the running instance shows Retry-After: 899, X-RateLimit-Limit: 5, X-RateLimit-Remaining: 0. * fix: persist identity chip fallback for new passwordless-OTP users Root cause: UserController.php:410-414 (emitOTP()) gated Session::put('user_fullname', ...) behind an existing-user check, so a not-yet-registered email never got a persisted display name - but login.js:165-167 (emitOtpAction()) already falls back to the submitted email as the chip's display name in live client state. This asymmetry made the identity chip visible right after opting into OTP, then vanish entirely on a page refresh. Moves the user_fullname Session::put() outside the existing-user conditional, using the same email fallback the client already applies. user_pic/user_is_active remain conditional - confirmed login.js has no equivalent avatar fallback, so no client/server asymmetry existed there. Inverts the existing (bug-encoding) assertion in testEmitOtpForNewUserStillPersistsRefreshState rather than adding a new test - it covers the exact same code path. * Refactor 2FA rate limiting to use RateLimiter::for() named limiters Subject resolution and the 429 response shape for the MFA verify/ recovery/resend/otp actions now live in named RateLimiter::for() limiters registered in TwoFactorServiceProvider, instead of being hand-rolled in TwoFactorRateLimitMiddleware. The middleware keeps only what the stock throttle pipeline can't express: deciding *when* a hit counts (failure-only for verify/recovery per SDS idp-mfa.md §4.12, every-request for resend/otp). - ITwoFactorRateLimitService: add PENDING_USER_SESSION_KEY and RATE_LIMITER_NAME_PREFIX constants, and a getWindowSeconds() accessor so the named limiters carry the real max/window instead of placeholder defaults. - TwoFactorRateLimitService: implement getWindowSeconds(). - TwoFactorServiceProvider: register the verify/recovery/resend/otp named limiters (subject via Limit::by(), response via Limit::response()). - TwoFactorRateLimitMiddleware: drop resolveSessionSubject()/ resolveOtpSubject() and the hand-built 429 response; resolve both from the named limiter instead. - RouteServiceProvider: remove the RateLimiter::for('otp', ...) registration - dead since the throttle:otp route middleware was removed in 1167374c (Dec 2021) and never reattached. Its name collided with the new 2fa-rate 'otp' action before the RATE_LIMITER_NAME_PREFIX namespacing was added. Verified: TwoFactorLoginFlowTest (38 tests, 197 assertions) green before and after, inside the idp-app container. * Feat/fe testing infrastructure (#144) * feat: first tests Signed-off-by: romanetar <roman_ag@hotmail.com> * feat: add testing infrastructure for login MFA flow and E2E suite Signed-off-by: romanetar <roman_ag@hotmail.com> * feat: add testing infrastructure for login MFA flow and E2E suite Signed-off-by: romanetar <roman_ag@hotmail.com> * test: isolate login.spec.ts in CI, fix MFA mock route ordering Comment out login-mfa-flow.spec.ts and register.spec.ts so CI runs login.spec.ts alone to verify it now passes without account lockout interference. Also fix the MFA beforeEach mock: fulfill() must run before unroute(), otherwise Playwright auto-resolves the in-flight route on unroute and the later fulfill() throws "Route is already handled" - which was letting the real POST through with a wrong password and locking out test@test.com. * test: re-enable MFA and registration e2e suites login.spec.ts verified green in isolation; re-enable the MFA flow suite (route-ordering fix already applied) and the registration suite now that the account-lockout cascade is gone. Signed-off-by: romanetar <roman_ag@hotmail.com> * fix: align MFA e2e/JS tests with PR #142's native-form-POST mechanism PR #142 reverted the password login step from AJAX back to a native form POST + server redirect/session flow (commit 0eca371c), removing handleAuthenticatePasswordFlow/Ok/Error, authenticateWithPassword, and the MFA_CHALLENGE_REQUIRED constant. The tests added by this branch were written against the old AJAX contract and needed to be realigned. - tests/js/login/login.mfa.test.js: remove the handleAuthenticatePasswordOk describe block - it tested a client-side AJAX handler that no longer exists in login.js. - tests/e2e/tests/auth/login-mfa-flow.spec.ts: - beforeEach no longer mocks the password POST as JSON; it performs a real native login against a real MFA-enforced account, matching how postLogin() actually issues a challenge (redirect + session state). - Each TS-* test now uses its own seeded MFA user (mfa-ts-NNN@test.com) instead of sharing one fixed account - a real challenge issuance counts against two_factor.rate_limit.max_otp_requests, so 8 tests sharing one account exhausted the limit before the suite finished. - Fixed VERIFY_URL/RESEND_URL/RECOVERY_URL/CANCEL_URL glob patterns to end with '**': postRawRequest() appends every param as a query string in addition to the body, so the exact-suffix glob never matched and silently left every route mock inert (requests were hitting the real backend instead). - TS-004/TS-007: resetToPasswordFlow() keeps the verified identity and returns to the password step (authFlow: FLOW.PASSWORD) - it does not clear user_name/user_verified. Both tests asserted the email step was shown instead, contradicting their own titles and the function's name. - TS-002: widened the post-verify assertion timeout - onVerify2FA() always assigns window.location.href on success, so even a same-URL mock response occasionally triggers a real navigation that raced the original 1s timeout. - .github/workflows/{pull_request,push}_frontend_tests.yml: seed the 8 mfa-ts-NNN@test.com accounts alongside the existing test@test.com / e2e@test.com fixtures. - .gitignore: add /test-results/ (Playwright's screenshot/video/trace output directory) - only /tests/e2e/report/ was previously ignored. Verified: 40/40 PHP (TwoFactorLoginFlowTest), 23/23 Jest, 13/13 Playwright e2e, stable across repeated runs via `docker compose --profile e2e run --rm playwright npx playwright test`. * feat: add e2e coverage for the OAuth2 authorization code flow Adds tests/e2e/tests/oauth2/auth-code-flow.spec.ts, exercising the full authorization code grant end to end - including the memento (pending OAuth2 request) surviving a real MFA detour, consent-bypass for a returning user, and MFA-skip for a trusted device: - unauthenticated /oauth2/auth redirects to login (memento serialized). - full flow: real login -> real MFA challenge -> real OTP -> consent screen for the correct client -> Accept -> authorization code -> code exchanged at the token endpoint for a real access_token. - returning user with prior consent: a second /oauth2/auth for the same client+scope skips the consent screen entirely and redirects straight to redirect_uri (InteractiveGrantType::handle()'s has_former_consent + auto_approval branch). - trusted device: checking "Trust this device" during MFA sets the Secure device_trust_token cookie; logging out and logging back in then skips the MFA challenge entirely. Infrastructure needed to drive this for real (no mocks): - app/Console/Commands/GetLatestOtp.php (idp:get-latest-otp {email}): prints the newest not-yet-redeemed OTP for a user, since the mailer queues via Redis and there is no catchable local mailbox to read the code from. Registered in app/Console/Kernel.php. - tests/e2e/utils/otp.ts: reads that OTP from the test runner - directly via `php artisan` when reachable in-process (CI, host dev), or via `docker exec idp-app php artisan ...` when running against the dockerized stack (APP_URL points at nginx). - docker-compose/playwright/Dockerfile + docker-compose.yml: the playwright service now builds this image (adds the Docker CLI on top of the stock Playwright image) and mounts /var/run/docker.sock so the above `docker exec` path works from inside that container. Scoped to the e2e profile only. - The suite works around two config('app.url')-vs-actual-origin mismatches (e.g. app.url=http://localhost but this suite runs against http://nginx in the docker-compose e2e profile - cookies are domain-scoped, so following the server's literal absolute redirect/ form-action URLs client-side would drop the session): verify2FA's redirect_url, the consent form's action, and the password step's postLogin() redirect are all replayed via page.request (shares the page's cookies) instead of trusting the browser/client-side JS to follow them unassisted. - .github/workflows/{pull_request,push}_frontend_tests.yml: seed mfa-oauth2-consent@test.com and mfa-oauth2-trust@test.com alongside the existing mfa-oauth2@test.com fixture. Known environment limitation (not a bug): the trusted-device assertion requires a "potentially trustworthy origin" for the Secure cookie to persist - true for http://localhost (host dev, and CI, which already uses APP_URL=http://localhost:8001) but not for the docker-compose e2e profile's http://nginx, where browsers silently drop the cookie. Verified: 16/17 e2e via `docker compose --profile e2e run --rm playwright npx playwright test` (the trusted-device test is the one expected miss, per the above), 4/4 in tests/e2e/tests/oauth2/ via host (`npx playwright test`), 40/40 PHP (TwoFactorLoginFlowTest), 23/23 Jest. * fix: seed the e2e OAuth2 test client without depending on TestSeeder CI was red: tests/e2e/tests/oauth2/auth-code-flow.spec.ts authorizes against a client_id that only exists as a side effect of database/seeds/TestSeeder.php, which is wired ONLY into PHPUnit's BrowserKitTestCase ($this->seed('TestSeeder')) - never into `php artisan db:seed`, which is all the CI workflow runs. On a genuinely fresh database the client_id never resolves, so InteractiveGrantType::handle() throws InvalidClientException before ever reaching the "redirect to login" branch, and the very first oauth2 test ("unauthenticated request redirects to login") gets a 400 error page instead of a redirect - exactly what the failing CI run showed. Local testing never caught this because the long-lived docker-compose dev database already had TestSeeder's fixtures from past PHPUnit runs. TestSeeder itself is not a safe fix for CI: its run() truncates users/groups/oauth2_client (and otp/consent/session-adjacent tables) before reseeding its own fixed set - correct for PHPUnit's isolated test lifecycle, destructive against the same shared database this workflow also seeds idp:create-super-admin/idp:create-raw-user users into. - app/Console/Commands/CreateOAuth2TestClient.php (idp:create-oauth2-test-client): idempotent, additive-only - creates just the one confidential client (same client_id/secret/redirect_uri the e2e suite already uses) plus a dedicated owner user (the consent screen's getDeveloperEmail() dereferences the owner unconditionally - an ownerless client 500s as soon as a real login reaches /accounts/user/consent) and grants it the 'profile' scope. Registered in app/Console/Kernel.php. - .github/workflows/{pull_request,push}_frontend_tests.yml: run the new command alongside the existing user fixtures. Verified: 16/17 e2e via `docker compose --profile e2e run --rm playwright npx playwright test` (the 17th, trusted-device, is the pre-existing environment-only miss - Secure cookies don't persist over http://nginx), 40/40 PHP, 23/23 Jest. * feat: recovery code management (#146) * feat: recovery code management Signed-off-by: romanetar <roman_ag@hotmail.com> * fix: add missing postRawRequestFull to base_actions.js profile/actions.js imports postRawRequestFull for the new enableTwoFactor and regenerateRecoveryCodes flows, but it was never exported, causing a runtime TypeError on both actions. Falling back to postRawRequest is unsafe here since it copies params into the URL query string, which would leak current_password into access logs. * fix: reject enableTwoFactor when 2FA is already enabled enable2FA() had no already-enrolled guard, so a second POST to /2fa/enable for an enrolled user silently regenerated recovery codes with no password confirmation, bypassing the password-gated rotation flow required by CU-86ba2zp66 and sds/idp-mfa.md §4.10.3. * refactor: move 2FA enrollment orchestration into RecoveryCodeService The transaction plus enable2FA + repository->add + code generation lived in UserApiController, breaking the thin-controllers/fat-services convention and diverging from the regenerateRecoveryCodes path, which already delegates to the service. UserApiController::enableTwoFactor now only validates input and calls RecoveryCodeService::enableTwoFactorAndGenerateCodes. * fix: normalize recovery code server-side before hash check Hash::check() compared the raw submitted code against the dash-less uppercase hash, so the "strip separators + uppercase" contract was only enforced by the login.js client. Any other consumer submitting a code exactly as displayed (XXXX-XXXX) would fail verification on this lockout-critical path. Apply the same normalization in AbstractMFAChallengeStrategy::verifyRecoveryCode() before Hash::check. * feat: warn on low recovery codes after MFA recovery login CU-86ba2zp66 / sds/idp-mfa.md §4.10.3, §4.11 step 5 require a dismissable low-code warning after a successful MFA login, but it was only wired into the profile page - a user who burns codes at login never saw it unless they happened to visit their profile. verify2FARecovery now returns recovery_codes_remaining and the configured low threshold; login.js holds the post-login redirect and shows a dismissable banner when the count is low, before navigating away. The sessionStorage dismissal key is shared with the profile page's RecoveryCodesPanel via a new shared module so dismissing in either place suppresses it everywhere for the rest of the session. * test: cover recovery-code redemption, re-enrollment, and the real request layer Three gaps mapped to the riskiest parts of this PR were unpinned: 1. Nothing proved a code returned as XXXX-XXXX actually redeems through AbstractMFAChallengeStrategy::verifyRecoveryCode() - the hash is of the dash-less string, so the generate->display->redeem contract (including the dash normalization) was untested. 2. enableTwoFactor()'s already-enrolled guard (412) had no regression test. 3. Every JS test mocked profile/actions, so nothing exercised the real request layer - exactly where the missing postRawRequestFull export lived. tests/js/profile/actions.test.js only stubs the transport (superagent) and calls the real enableTwoFactor/regenerateRecoveryCodes; verified it reproduces the original "postRawRequestFull is not a function" TypeError when that export is removed. * fix: fix CI failures from the recovery-code round-trip test and dash normalization 1. testDisplayedRecoveryCodeRedeemsThroughVerifyRecoveryCode called AbstractMFAChallengeStrategy::verifyRecoveryCode() directly, but it takes a PESSIMISTIC_WRITE row lock that requires an open transaction (Doctrine\ORM\TransactionRequiredException in CI). Route it through IAuthService::verifyMFARecoveryCode(), like the real login flow, which wraps the call in a transaction. 2. Several pre-existing test fixtures hashed a "plain" recovery code with a literal "-" baked in (e.g. 'RECOVERY-REUSE-TX-' . uniqid()) and then submitted that same string for verification. The dash normalization added earlier in this PR strips separators from the submitted code before Hash::check(), so a hash made from a dash-containing string can never match its own normalized submission - a real generated code never contains a dash in its raw/hashed form, only in its display formatting. Fixed the 7 affected fixtures across TwoFactorLoginFlowTest and AbstractMFAChallengeStrategyTest to drop the literal dash. * fix: remove nested transaction in enableTwoFactorAndGenerateCodes enableTwoFactorAndGenerateCodes() wrapped enable2FA()/user persist in one transaction() call while also calling generateRecoveryCodes(), which opens its own. DoctrineTransactionService::transaction() closes the entity manager and connection on failure, so an inner failure could tear down the EM out from under the still-running outer transaction. Extracted the shared code-generation logic into a transaction-free regenerateCodesForUser(), so each public method now opens exactly one transaction. * fix: make recovery-code audit logging best-effort Both generateRecoveryCodes() and enableTwoFactorAndGenerateCodes() logged audit events after the codes were already committed and about to be returned to the client. An audit-logging failure there would 500 a response whose side effects already succeeded, and a client retry on that 500 would regenerate and invalidate the codes it was never shown. Wrap both in try/catch + Log::warning, matching the best-effort pattern already used for audit logging in UserController. * fix: use the configured app name in the downloaded recovery-codes file recovery_code_display.js hardcoded "FNTECH" in both the file header and the downloaded filename, which would misbrand any non-FNTECH deployment. Threaded the existing appName prop (already exposed by profile.blade.php as config.appName, sourced from Config::get('app.app_name')) down through ProfilePage -> TwoFactorSection -> RecoveryCodesPanel -> RecoveryCodeModal -> RecoveryCodeDisplay, with an OpenStackID fallback matching the config default. * fix: uppercase uniqid() in recovery-code test fixtures verifyRecoveryCode() uppercases the submitted code (in addition to stripping separators) before Hash::check() - real generated codes are always uppercase alphanumeric. Three fixtures built their "plain" code with a raw uniqid() suffix, which is lowercase hex, so the hash (made from the original mixed-case string) could never match its own normalized submission. Verified standalone with password_hash/ password_verify that the old fixture reproduces the exact CI failure and the fixed one passes. --------- Signed-off-by: romanetar <roman_ag@hotmail.com> --------- Signed-off-by: romanetar <roman_ag@hotmail.com> Co-authored-by: smarcet <smarcet@gmail.com> --------- Signed-off-by: romanetar <roman_ag@hotmail.com> Co-authored-by: smarcet <smarcet@gmail.com> Co-authored-by: Román Gutierrez <roman_ag@hotmail.com> --------- Signed-off-by: romanetar <roman_ag@hotmail.com> Co-authored-by: smarcet <smarcet@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Román Gutierrez <roman_ag@hotmail.com> --------- Signed-off-by: romanetar <roman_ag@hotmail.com> Co-authored-by: smarcet <smarcet@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Román Gutierrez <roman_ag@hotmail.com> --------- Signed-off-by: romanetar <roman_ag@hotmail.com> Co-authored-by: smarcet <smarcet@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Román Gutierrez <roman_ag@hotmail.com> --------- Signed-off-by: romanetar <roman_ag@hotmail.com> Co-authored-by: smarcet <smarcet@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Román Gutierrez <roman_ag@hotmail.com> --------- Signed-off-by: romanetar <roman_ag@hotmail.com> Co-authored-by: smarcet <smarcet@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Román Gutierrez <roman_ag@hotmail.com> --------- Signed-off-by: romanetar <roman_ag@hotmail.com> Co-authored-by: smarcet <smarcet@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Román Gutierrez <roman_ag@hotmail.com> --------- Signed-off-by: romanetar <roman_ag@hotmail.com> Co-authored-by: smarcet <smarcet@gmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Román Gutierrez <roman_ag@hotmail.com>
|
📘 OpenAPI / Swagger preview ➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/ This page is automatically updated on each push to this PR. |
There was a problem hiding this comment.
Pull request overview
This PR lays the groundwork for Multi-Factor Authentication (MFA/2FA) across the OpenStackID codebase by adding database schema, Doctrine entities/repositories, service-layer components (challenge strategy, device trust, audit logging, rate limiting), and expanding frontend/login/profile UX support with new unit and E2E coverage.
Changes:
- Add additive Doctrine migration for 2FA columns on
usersand new 2FA-related tables (trusted devices, audit log, recovery codes). - Introduce backend 2FA services/strategies/middleware (challenge issuance/verification, rate limiting, device trust, recovery-code management, audit logging) and wire them into routes/service providers.
- Add/expand frontend components + Jest/Playwright test infrastructure to validate login/profile 2FA flows.
Reviewed changes
Copilot reviewed 139 out of 142 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| webpack.common.js | Adjust module resolution for JSX. |
| tests/unit/OAuth2LoginStrategyTest.php | Add coverage for OAuth2 postLogin behavior + facade isolation. |
| tests/unit/MFAGateServiceTest.php | Unit tests for MFA gate decision logic. |
| tests/unit/MFA/MFAChallengeStrategyFactoryTest.php | Unit tests for challenge strategy factory. |
| tests/unit/DisqusSSOProfileMappingTest.php | Make Disqus SSO persistence test idempotent. |
| tests/TurnstileProtectedControllersTest.php | Ensure Turnstile tests run over HTTPS when SSL middleware is enabled. |
| tests/OAuth2NativeMFALoginFlowTest.php | Integration test for MFA-required response shaping by display mode. |
| tests/js/validator/validator.test.js | Jest tests for shared validators. |
| tests/js/setup.js | Jest environment setup (TextEncoder/TextDecoder). |
| tests/js/profile/actions.test.js | Jest tests for profile action request layer. |
| tests/js/login/login.mfa.test.js | Jest unit tests for login MFA error handling. |
| tests/js/login/components/two-factor-form.test.js | Component tests for MFA OTP form UI states. |
| tests/js/components/two_factor_section.test.js | Component tests for enabling 2FA in profile section. |
| tests/js/components/recovery_codes_panel.test.js | Component tests for recovery-code panel behavior. |
| tests/js/components/recovery_code_modal.test.js | Component tests for recovery-code modal timing/locking. |
| tests/js/components/recovery_code_display.test.js | Component tests for copy/download recovery-code display. |
| tests/js/components/DividerWithText.test.js | Component tests for divider helper. |
| tests/js/components/CustomSnackbar.test.js | Component tests for snackbar behavior. |
| tests/js/components/Banner.test.js | Component tests for banner rendering (text/HTML). |
| tests/js/mocks/fileMock.js | Jest asset stub. |
| tests/e2e/utils/otp.ts | Helper to fetch real OTP for E2E runs. |
| tests/e2e/tsconfig.json | TS config for Playwright E2E suite. |
| tests/e2e/tests/auth/login.spec.ts | Playwright login smoke tests. |
| tests/e2e/pages/RegisterPage.ts | Playwright page object for registration. |
| tests/e2e/pages/LoginPage.ts | Playwright page object for login + MFA steps. |
| tests/e2e/fixtures/index.ts | Playwright fixtures + origin rewriting for dockerized runs. |
| tests/AuthServiceValidateCredentialsIntegrationTest.php | DB integration tests for validateCredentials behavior. |
| tests/AuthServiceLoginUserTest.php | Unit test asserting Auth::login ordering vs principal register. |
| storage/framework/cache/data/.gitignore | Remove ignore rules for storage cache path. |
| start_local_server.sh | Improve local bootstrap ordering; add e2e prerequisites. |
| routes/web.php | Add 2FA endpoints, apply 2FA rate-limit middleware, change cancel to POST. |
| resources/views/profile.blade.php | Expose 2FA/recovery-code endpoints + state to profile SPA. |
| resources/views/auth/login.blade.php | Expose MFA endpoints/state/countdown params to login SPA. |
| resources/js/utils.js | Add shared text-file download helper. |
| resources/js/signup/signup.js | Make Turnstile requirement conditional on configured key. |
| resources/js/shared/recovery_codes.js | Shared sessionStorage keys + defaults for recovery-code UX. |
| resources/js/shared/HTMLRender.js | Centralized DOMPurify-backed HTML rendering helper. |
| resources/js/profile/profile.module.scss | Add styles for recovery-code/2FA section wrapper. |
| resources/js/profile/profile.js | Render TwoFactorSection on profile page. |
| resources/js/profile/actions.js | Add enable2FA/regenerate recovery-code request helpers. |
| resources/js/login/login.module.scss | Add styles for MFA UI elements (countdown, disabled links, etc.). |
| resources/js/login/constants.js | Centralize login/MFA constants. |
| resources/js/login/components/use_otp_countdown.js | Shared countdown hook for OTP/MFA code expiry. |
| resources/js/login/components/two_factor_form.js | MFA OTP verify form component + resend cooldown. |
| resources/js/login/components/third_party_identity_providers.js | Extract third-party IdP buttons component. |
| resources/js/login/components/recovery_code_form.js | Recovery-code entry form for MFA recovery path. |
| resources/js/login/components/password_input_form.js | Refactor password step into standalone form component. |
| resources/js/login/components/otp_input_form.js | Refactor passwordless OTP step into standalone form component. |
| resources/js/login/components/otp_help_links.js | Extract OTP resend/cooldown UI for passwordless. |
| resources/js/login/components/otp_code_input.js | Shared OTP code input block for OTP + MFA flows. |
| resources/js/login/components/help_links.js | Consolidate contextual help links rendering. |
| resources/js/login/components/existing_account_actions.js | Extract “sign in with OTP” / reset-password actions. |
| resources/js/login/components/email_input_form.js | Refactor email step into standalone form component. |
| resources/js/login/components/email_error_actions.js | Extract email-step error CTA buttons. |
| resources/js/login/actions.js | Add MFA verify/resend/recovery/cancel request helpers. |
| resources/js/components/two_factor_section.js | Profile UI for enabling 2FA and showing recovery codes. |
| resources/js/components/recovery_codes.module.scss | Styles for recovery-code panel/modal/display. |
| resources/js/components/recovery_codes_panel.js | Recovery-code count, regenerate flow, low-code warning. |
| resources/js/components/recovery_code_modal.js | One-time modal to present newly generated codes. |
| resources/js/components/recovery_code_display.js | Display + copy/download for recovery codes. |
| resources/js/base_actions.js | Add postRawRequestFull helper returning {response}. |
| readme.md | Document backend/Jest/Playwright test workflows. |
| playwright.config.ts | Playwright config + report output. |
| phpunit.xml | Add dedicated “Two Factor Authentication” PHPUnit suite. |
| package.json | Add Jest/Playwright scripts + dev deps. |
| jest.config.js | Jest configuration for jsdom + module mapping + coverage. |
| docker-compose/playwright/Dockerfile | Playwright runner image with Docker CLI for OTP retrieval. |
| docker-compose.yml | Add Playwright service (profile e2e) + cache volume. |
| doc/adrs/0001-recovery-code-generation-not-triggered-by-group-enforcement.md | ADR documenting recovery-code generation trade-off. |
| database/migrations/Version20260416194357.php | Additive migration for 2FA columns and 2FA tables. |
| config/two_factor.php | New 2FA config (kill switch, enforced groups, rate limits, cookie). |
| config/session.php | Make session cookie secure/samesite env-configurable. |
| config/auth.php | Add recovery-code policy configuration. |
| config/app.php | Register TwoFactorServiceProvider. |
| babel.config.js | Add babel env config for Jest runs. |
| app/Strategies/OAuth2LoginStrategy.php | Add challengeRequired response path for OAuth2 flow. |
| app/Strategies/MFA/MFAChallengeStrategyFactory.php | Factory for selecting MFA challenge strategy by method. |
| app/Strategies/MFA/IMFAChallengeStrategy.php | Interface for MFA challenge strategies. |
| app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php | Email OTP-based MFA challenge issuance/verification. |
| app/Strategies/MFA/AbstractMFAChallengeStrategy.php | Shared session pending-state + recovery-code verification. |
| app/Strategies/ILoginStrategy.php | Add MFA_REQUIRED constant + challengeRequired method. |
| app/Strategies/IDisplayResponseStrategy.php | Add getChallengeRequiredResponse contract. |
| app/Strategies/DisplayResponseUserAgentStrategy.php | Implement challengeRequired via redirect+session state. |
| app/Strategies/DisplayResponseJsonStrategy.php | Implement challengeRequired via JSON+412 response. |
| app/Strategies/DefaultLoginStrategy.php | Implement challengeRequired redirect behavior. |
| app/Services/Auth/TwoFactorServiceProvider.php | Register 2FA services and RateLimiter definitions. |
| app/Services/Auth/TwoFactorRateLimitService.php | Cache-backed 2FA rate-limit service. |
| app/Services/Auth/TwoFactorAuditService.php | Persist 2FA audit log + emit OTEL job attributes. |
| app/Services/Auth/RecoveryCodeService.php | Generate/regenerate recovery codes + audit logging. |
| app/Services/Auth/MFAGateService.php | Decide whether 2FA challenge is required (enforced + trust). |
| app/Services/Auth/ITwoFactorRateLimitService.php | Interface/constants for 2FA rate limit service. |
| app/Services/Auth/ITwoFactorGateService.php | Interface for 2FA gate decision logic. |
| app/Services/Auth/ITwoFactorAuditService.php | Interface for 2FA audit logger. |
| app/Services/Auth/IRecoveryCodeService.php | Interface for recovery-code management service. |
| app/Services/Auth/IDeviceTrustService.php | Interface for trusted-device management. |
| app/Services/Auth/DeviceTrustService.php | Implement trusted-device persistence and checks. |
| app/Repositories/RepositoriesProvider.php | Bind new 2FA Doctrine repositories. |
| app/Repositories/DoctrineUserTrustedDeviceRepository.php | Doctrine repo for trusted-device records. |
| app/Repositories/DoctrineUserRecoveryCodeRepository.php | Doctrine repo for recovery codes + locking + deletion. |
| app/Repositories/DoctrineTwoFactorAuditLogRepository.php | Doctrine repo for 2FA audit logs. |
| app/Providers/RouteServiceProvider.php | Remove old otp RateLimiter definition. |
| app/Providers/AppServiceProvider.php | Force HTTPS only when SSL_ENABLED is true. |
| app/libs/Utils/Services/IAuthService.php | Add MFA flow constants + MFA issue/verify/resend methods. |
| app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php | Repo interface for trusted devices. |
| app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php | Repo interface for recovery codes. |
| app/libs/Auth/Repositories/ITwoFactorAuditLogRepository.php | Repo interface for 2FA audit logs. |
| app/libs/Auth/Models/UserTrustedDevice.php | Doctrine entity for trusted devices. |
| app/libs/Auth/Models/UserRecoveryCode.php | Doctrine entity for recovery codes. |
| app/libs/Auth/Models/TwoFactorAuditLog.php | Doctrine entity for 2FA audit events. |
| app/Http/Middleware/TwoFactorRateLimitMiddleware.php | Middleware enforcing 2FA rate-limit semantics by action. |
| app/Http/Middleware/EncryptCookies.php | Exclude device trust cookie from encryption via config. |
| app/Http/Kernel.php | Register 2fa.rate route middleware alias. |
| app/Http/Controllers/Traits/MFACookieManager.php | Trait to read/queue trusted-device cookie token. |
| app/Http/Controllers/Traits/JsonResponses.php | Use HTTP status constants; add unauthorized helper. |
| app/Http/Controllers/Auth/RegisterController.php | Make Turnstile rule conditional on configured secret. |
| app/Http/Controllers/Api/UserApiController.php | Add enableTwoFactor + regenerateRecoveryCodes endpoints. |
| app/Console/Kernel.php | Register new artisan commands for E2E support. |
| app/Console/Commands/GetLatestOtp.php | Command to print latest pending OTP for an email. |
| app/Console/Commands/CreateRawUser.php | Command to create a plain verified user (E2E helper). |
| app/Console/Commands/CreateOAuth2TestClient.php | Command to seed a non-destructive OAuth2 E2E client. |
| .gitignore | Ignore Playwright/Jest outputs and misc local artifacts. |
| .github/workflows/push.yml | Add Turnstile secrets to backend workflow environment. |
| .github/workflows/push_frontend_tests.yml | New CI workflow for Jest + Playwright on push. |
| .github/workflows/pull_request_unit_tests.yml | Add Turnstile secrets to PR unit-test workflow environment. |
| .github/workflows/pull_request_frontend_tests.yml | New CI workflow for Jest + Playwright on PRs. |
Suppressed comments (1)
storage/framework/cache/data/.gitignore:1
- Removing this .gitignore can cause generated cache files under storage/framework/cache/data/ to show up as untracked changes and accidentally get committed.
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| */ | ||
|
|
||
| 'secure' => true, | ||
| 'secure' => env('SESSION_SECURE_COOKIE', false), |
| */ | ||
|
|
||
| 'same_site' => 'none', | ||
| 'same_site' => env('SESSION_COOKIE_SAME_SITE', 'lax'), |
| // 1) Add 2FA columns to users | ||
| if ($schema->hasTable("users") && !$builder->hasColumn("users", "two_factor_enabled")) { | ||
| $builder->table('users', function (Table $table) { | ||
| $table->boolean('two_factor_enabled')->setNotnull(true)->setDefault(false); | ||
| $table->string('two_factor_method', 32)->setNotnull(true)->setDefault('email_otp'); | ||
| $table->dateTime('two_factor_enforced_at')->setNotnull(false)->setDefault(null); | ||
| }); | ||
| } |
| $table->bigInteger("user_id")->setUnsigned(true); | ||
| $table->string('code_hash', 72)->setNotnull(true); | ||
| $table->dateTime('used_at')->setNotnull(false)->setDefault(null); | ||
| $table->index(["user_id", "used_at"], "urc_user_used_idx"); | ||
| $table->unique(["user_id", "code_hash"], "urc_user_codehash_uniq"); | ||
| $table->foreign("users", "user_id", "id", ["onDelete" => "CASCADE"]); |
| use Auth\User; | ||
| use Exception; | ||
| use Illuminate\Support\Facades\Config; | ||
| use Illuminate\Support\Facades\Cookie; | ||
| use Illuminate\Support\Facades\Request; | ||
| use Keepsuit\LaravelOpenTelemetry\Facades\Logger; | ||
| use Utils\IPHelper; |
| resolve: { | ||
| extensions: ['.js', '.jsx'], | ||
| fallback: { |
…OIDC circuit tests (#152) * fix(2fa): validate pending OAuth2 client before redeeming a recovery code verify2FARecovery() skipped the resolveClientFromMemento() guard that verify2FA() applies, so with a pending OAuth2 authorization request whose client no longer exists the single-use recovery code was burned and an IDP session established for an authorization request that could only fail at the /oauth2/auth hop. Apply the same guard before redemption; recovery-code checking itself stays client-agnostic. * refactor(2fa): extract MFA error_code literals into MFAConstants The error_code values emitted by UserController's MFA endpoints were hardcoded strings, duplicated in TwoFactorRateLimitMiddleware::FAILURE_CODES where a silent drift would break the rate-limit failure counting. Tests keep asserting the literal wire values on purpose, pinning the contract. * refactor(2fa): return a typed DTO from getPendingState() instead of an array MFAPendingState (getUserId / getPendingAt / shouldRemember) replaces the string-keyed array, so callers stop scattering 'user_id'/'remember' literals and casts, and the shape is enforced by the type system instead of by convention. * refactor(2fa): serialize recovery-codes standing via a RecoveryCodesStatus DTO verify2FARecovery() and getProfile() each hand-built the recovery_codes_remaining/total/low_threshold payload with their own config() reads and magic defaults. IRecoveryCodeService::getStatus() now returns a RecoveryCodesStatus DTO whose toArray() owns the wire keys, so both call sites merge the same serialized shape. Side effect: the recovery XHR response now also carries recovery_codes_total (additive, ignored by the SPA). * test(2fa): prove the full OIDC consent circuit for verify2FA and verify2FARecovery authorize -> login -> MFA challenge -> verify (OTP / recovery code) -> redirect_url back to the authorization endpoint (rebuilt from the session memento) -> consent screen -> AllowOnce -> authorization code delivered to the client redirect_uri. Locks in that the XHR verify contract composes with the interactive grant's memento round-trip. Note: OIDCProtocolTestCase's password-login circuits (e.g. testAuthCode) predate the MFA gate and post a wrong seed password - broken independently of this change. * test(oidc): repair OIDCProtocolTestCase login circuits (stale password + MFA gate) Two stacked breakages, both predating and unrelated to each individual test: - 021bee3 (jul 2024) changed the TestSeeder passwords from '1qaz2wsx' to '1Qaz2wsx!' without updating this class, so every password login leg has silently failed since - errorLogin() also answers 302, so the post-login assertion kept passing and tests died downstream instead. - The MFA gate now challenges the seeded login user (SuperAdminGroup is in two_factor.enforced_groups), so even a correct password stops at the 2FA challenge. This class exercises the OIDC protocol, not the gate - enforced groups are cleared in prepareForTests(); the gate plus the full authorize -> MFA -> consent -> code circuit live in TwoFactorLoginFlowTest. Result: 29 broken -> 3 (32/35 green). The 3 residuals have distinct pre-existing causes: testConsentLogin and testGetRefreshTokenWithPromptSetToConsentLogin lose the login hint because AuthService::logout()'s Session::flush() (4864f50 / #118) wipes the session-backed security context even when called with clear_security_ctx = false (prompt=login path); testTokenResponseModePost uses max_age=1 and the multi-request dance now takes longer than 1s, forcing a re-login. * fix(auth): honor clear_security_ctx=false across logout()'s session flush The Session::flush() hardening added in #118 wipes the whole session at the end of logout(), including the session-backed security context - even when the caller passed clear_security_ctx = false (the prompt=login re-authentication path in InteractiveGrantType::mustAuthenticateUser()), which broke the login-hint prefill on the login screen for prompt=login OIDC requests. Capture the context before the flush and re-save it after the session ID regenerate; everything else is still flushed, so the #118 hardening stands. * test(oidc): raise testTokenResponseModePost max_age from 1 to 3200 The test exercises response_mode=form_post, not max_age expiry (testMaxAge1AndWait2 owns that) - with max_age=1 the multi-request login+consent dance takes longer than 1s and the final authorize hop forced a re-login instead of delivering the form post. 3200 matches the sibling circuits. OIDCProtocolTestCase is now fully green: 35/35. * test(2fa): negative-path OIDC circuits for verify2FA and verify2FARecovery Six tests inside a pending OIDC authorization-code flow, three per endpoint: - wrong code then correct code: the rejection keeps the pending challenge and the OAuth2 memento alive, and the retry completes the full circuit (consent -> authorization code). - consecutive wrong codes up to the rate-limit threshold: every attempt is 401 without a session, and once the window closes even the CORRECT code answers 429 - brute-forcing inside a pending flow buys no extra attempts. - burned single-use code (used recovery code / redeemed OTP): rejected like any invalid code, and the flow still completes afterwards with a fresh code (new recovery code / resent OTP). * test(2fa): cover the error branches of verify2FA and verify2FARecovery - validator 412s (malformed request, no otp_value / recovery_code) - vanished pending user -> mfa_session_expired + pending state cleared - recovery without a pending challenge -> mfa_session_expired - stale OAuth2 client guard on verify2FA (parity with the recovery test): 412 before the OTP is redeemed - audit failure on the FAILED-verify path stays a clean 401 with the error_code the rate-limit middleware keys on, for both endpoints verify2FA line coverage 82.3% -> 95.2%, verify2FARecovery 82.7% -> 94.2%; the only uncovered lines left are the generic Exception -> 500 catches. * test(ci): actually run the protocol TestCase suites, stop hiding failure breadth Two changes to phpunit.xml: - The Application suite's <directory> scan only picks up *Test.php (PHPUnit's default suffix), so the four concrete *TestCase.php protocol suites (OAuth2Protocol, OIDCProtocol, OIDCPasswordless, OpenIdProtocol - 93 tests) were NEVER executed by CI. That is how OIDCProtocolTestCase stayed broken for two years with green builds. They are now listed explicitly. - stopOnFailure=false so a run reports every failure instead of dying on the first one. Also fixes the one test the newly-wired suites surfaced: testResourceServerIntrospectionNotValidIP expected an unconditional 400, but the resource-server IP check became opt-in in #98 (oauth2.validate_resource_server_ip, default off) - the test now enables the flag before asserting the rejection. Full-suite evidence (523 tests): green except 8 pre-existing environment-dependent Turnstile tests that need TEST_USER_EMAIL / TEST_USER_PASSWORD and the Turnstile secrets CI injects (they pass in CI; locally their markTestSkipped guard is defeated by a typed-property TypeError when the env vars are absent). * refactor(2fa): single home for every MFA string constant MFAConstants now owns all of them: - error codes: the existing three plus mfa_rate_limit and mfa_required. ITwoFactorRateLimitService::RATE_LIMIT_ERROR_CODE and ILoginStrategy::MFA_REQUIRED alias it, so consumers keep their names while the value is defined once. - 2fa_* session keys: previously defined TWICE in production (AbstractMFAChallengeStrategy's private consts and ITwoFactorRateLimitService::PENDING_USER_SESSION_KEY) - both now alias MFAConstants. Also promotes the rate-limit cache-key prefix ('2fa_rate:', previously a sprintf literal in TwoFactorRateLimitService duplicated by the test flush helper) to ITwoFactorRateLimitService::RATE_LIMIT_CACHE_KEY_PREFIX. All ~50 hardcoded literals across TwoFactorLoginFlowTest, AbstractMFAChallengeStrategyTest and EmailOTPMFAChallengeStrategyTest now reference the constants. * test(oidc): extract the seeded password into a SEED_PASSWORD constant The literal appeared at 26 call sites; a seed password change is now a one-line edit, matching TwoFactorLoginFlowTest. The trailing-space login test keeps its spacing explicit around the constant, since that spacing is the subject under test. Suite re-run in idp-app: 35/35, 506 assertions.
Task:
Ref: https://app.clickup.com/t/86b9e8wm7
Changes
UserTrustedDeviceentityTwoFactorAuditLogentityUserRecoveryCodeentityRequested Goal
Current state
The IDP database has no 2FA-related tables or columns. The User entity lacks fields for tracking two-factor enrollment or enforcement status.
Target state
Three new tables (user_trusted_devices, two_factor_audit_log, user_recovery_codes) and three new columns on the users table (two_factor_enabled, two_factor_method, two_factor_enforced_at) exist via a single additive Doctrine migration. Corresponding Doctrine entities and repositories are registered in the ORM mappings.
This migration is the foundation for every other Phase I ticket. It must run with zero downtime since it only adds nullable columns and new tables.
Tasks
(datetime, nullable)
expires_at, last_seen_at, is_revoked (boolean, default false), created_at, updated_at. Indexes on (user_id, device_identifier), (user_id, is_revoked), (expires_at)
device_revoked, recovery_used, settings_changed), method (enum: email_otp, sms_otp, totp, passkey, recovery), ip_address (varchar 45), user_agent (text), metadata (json, nullable), created_at.
Indexes on (user_id, event_type, created_at), (created_at)
ACCEPTANCE CRITERIA
php artisan doctrine:migrations:migrateruns without errors on both fresh and populated databasesAll three new tables exist with correct column types, defaults, and indexes
Users table has the three new columns with correct defaults (two_factor_enabled=false, two_factor_method='email_otp', two_factor_enforced_at=NULL)
Each Doctrine entity can be persisted and retrieved via its repository ( provide unit tests )
Migration is fully additive: no columns dropped, no data modified, no destructive operations
Existing application functionality is unaffected after migration (login, OTP flow, admin panel all work)
DEVELOPMENT NOTES
Key files:
app/libs/Auth/Models/UserTrustedDevice.phpapp/libs/Auth/Models/TwoFactorAuditLog.phpapp/libs/Auth/Models/UserRecoveryCode.phpdatabase/migrations/Version*.php(single migration file)Gotchas
Userentity uses Doctrine ORM, not Eloquent. All mappings follow the existing annotation/attribute pattern in the codebase.varchar(255)is sufficient for hex-encoded SHA-256 (64 chars).oauth2_otp) already hasphone_numberandconnection='sms'columns from existing infrastructure. Do NOT modify this table.two_factor_methodshould be stored as a string column, not a database-level ENUM, to allow adding values in Phase II/III without migration.Out of scope:
User entity PHP methods (separate ticket), service layer code, UI changes.
Summary by CodeRabbit
New Features
Tests
Chores