Skip to content

Feature | Add MultiFactor Authentication. Initial migrations and entities - #125

Open
matiasperrone-exo wants to merge 11 commits into
mainfrom
feat/mfa-phase1---migrations--and--interfaces
Open

Feature | Add MultiFactor Authentication. Initial migrations and entities#125
matiasperrone-exo wants to merge 11 commits into
mainfrom
feat/mfa-phase1---migrations--and--interfaces

Conversation

@matiasperrone-exo

@matiasperrone-exo matiasperrone-exo commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Task:

Ref: https://app.clickup.com/t/86b9e8wm7

Changes

  • Add migration for 2FA schema foundation (Phase I)
  • Add UserTrustedDevice entity
  • Add TwoFactorAuditLog entity
  • Add UserRecoveryCode entity
  • Add repository interfaces for 2FA entities
  • Add Doctrine implementations for 2FA repositories
  • Register 2FA repositories in service container
  • Add repository round-trip tests for 2FA entities

Requested 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

  • Create Doctrine migration adding three columns to the users table: two_factor_enabled (boolean, default false), two_factor_method (string/enum, default 'email_otp'), two_factor_enforced_at
    (datetime, nullable)
  • Create UserTrustedDevice Doctrine entity with fields: id, user_id (FK), device_identifier (varchar 255), device_name (varchar 255), ip_address (varchar 45), user_agent (text), trusted_at,
    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)
  • Create TwoFactorAuditLog Doctrine entity with fields: id, user_id (FK), event_type (enum: challenge_issued, challenge_succeeded, challenge_failed, enrollment_changed, device_trusted,
    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)
  • Create UserRecoveryCode Doctrine entity with fields: id, user_id (FK), code_hash (varchar 255, bcrypt hash), used_at (datetime, nullable), created_at. Index on (user_id, used_at)
  • Create IUserTrustedDeviceRepository, IUserRecoveryCodeRepository, ITwoFactorAuditLogRepository interfaces and their Doctrine implementations
  • Register all new entities in the Doctrine ORM entity mappings
  • Verify migration runs cleanly on a fresh database and on an existing database with data

ACCEPTANCE CRITERIA

php artisan doctrine:migrations:migrate runs without errors on both fresh and populated databases
All 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:

  • New: app/libs/Auth/Models/UserTrustedDevice.php
  • New: app/libs/Auth/Models/TwoFactorAuditLog.php
  • New: app/libs/Auth/Models/UserRecoveryCode.php
  • New: database/migrations/Version*.php (single migration file)
  • Modified: Doctrine entity mapping configuration

Gotchas

  • The User entity uses Doctrine ORM, not Eloquent. All mappings follow the existing annotation/attribute pattern in the codebase.
  • device_identifier stores a SHA-256 hash, not the raw cookie token. Size varchar(255) is sufficient for hex-encoded SHA-256 (64 chars).
  • The OTP table (oauth2_otp) already has phone_number and connection='sms' columns from existing infrastructure. Do NOT modify this table.
  • two_factor_method should 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

    • 2FA foundation: trusted device management (trust/expire/revoke), single‑use recovery codes, and detailed 2FA audit logging with event/method types and user-scoped queries.
    • Added repository support and interfaces for trusted devices, recovery codes, and 2FA audit logs.
  • Tests

    • Comprehensive tests for trusted-device behavior, recovery-code lifecycle/deletion, audit-log ordering, and device-identifier uniqueness.
  • Chores

    • Database migrations to add 2FA columns/tables and enforce unique (user, device) identifiers.

Review Change Stack

@matiasperrone-exo matiasperrone-exo self-assigned this Apr 22, 2026
@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds 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.

Changes

Two-factor foundation

Layer / File(s) Summary
Entities & domain models
app/libs/Auth/Models/TwoFactorAuditLog.php, app/libs/Auth/Models/UserRecoveryCode.php, app/libs/Auth/Models/UserTrustedDevice.php
New Doctrine entities mapped to two_factor_audit_log, user_recovery_codes, and user_trusted_devices with fields, relations to users, lifecycle initialization, getters/setters, validation (allowlists and bcrypt check), and repositoryClass hints.
Repository contracts
app/libs/Auth/Repositories/ITwoFactorAuditLogRepository.php, app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php, app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php
New interfaces declaring repository operations: getRecentByUser(User,int): array, getUnusedByUser(User): array, deleteAllForUser(User): int, getActiveByUserAndIdentifier(User,string): ?UserTrustedDevice, and getActiveByUser(User): array.
Doctrine repository implementations
app/Repositories/DoctrineTwoFactorAuditLogRepository.php, app/Repositories/DoctrineUserRecoveryCodeRepository.php, app/Repositories/DoctrineUserTrustedDeviceRepository.php
Concrete repositories wired to their entity classes: recent-audit query ordered by created_at desc; retrieval and DQL deletion of recovery codes; active trusted-device queries enforcing is_revoked = false and expires_at > now (UTC).
Service provider wiring
app/Repositories/RepositoriesProvider.php
Container bindings registered for the three repository interfaces to their Doctrine repositories and added to the provider provides() list for deferred resolution.
Migrations
database/migrations/Version20260416194357.php, database/migrations/Version20260424120000.php
Migration adding 2FA columns to users and creating user_trusted_devices, two_factor_audit_log, user_recovery_codes; separate migration to convert the user/device index into a unique index after checking for duplicates.
Integration tests
tests/TwoFactorRepositoriesTest.php
Repository integration tests: trusted device round-trips, exclusion of expired/revoked devices from active queries, uniqueness index assertion, audit-log ordering and retrieval, recovery-code lifecycle (unused, mark used, deleteAllForUser), and validation on double-use.
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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested reviewers

  • smarcet

Poem

🐰
I hopped through tables, logs, and codes,
Marked trusted pawprints on safe roads,
I logged each challenge with timestamp bright,
Hid recovery seeds out of sight,
Two-factor blooms — a rabbit's delight.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 8.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and concisely summarizes the main objective: adding Multi-Factor Authentication foundation through migrations and entities.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/mfa-phase1---migrations--and--interfaces

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/

This page is automatically updated on each push to this PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (6)
app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php (1)

25-28: Consider documenting transaction/atomicity expectations for deleteAllForUser.

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 DQL DELETE bypasses the UnitOfWork and runs directly against the DB. If a caller has pending in-memory changes on UserRecoveryCode entities for this user (e.g. newly persisted but not flushed, or a markUsed() pending flush), those will not be considered by the bulk delete and may re-surface on a later flush(), 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 — JoinColumn is placed before ManyToOne.

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 for getActiveByUser.

No orderBy is specified, so results come back in storage order. For a user-facing "trusted devices" listing, ordering by last_seen_at DESC (or trusted_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 only two_factor_enabled. If a prior run failed after adding two_factor_enabled but 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, and device_identifier is 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

📥 Commits

Reviewing files that changed from the base of the PR and between db9f777 and 6a2bd34.

📒 Files selected for processing (12)
  • app/Repositories/DoctrineTwoFactorAuditLogRepository.php
  • app/Repositories/DoctrineUserRecoveryCodeRepository.php
  • app/Repositories/DoctrineUserTrustedDeviceRepository.php
  • app/Repositories/RepositoriesProvider.php
  • app/libs/Auth/Models/TwoFactorAuditLog.php
  • app/libs/Auth/Models/UserRecoveryCode.php
  • app/libs/Auth/Models/UserTrustedDevice.php
  • app/libs/Auth/Repositories/ITwoFactorAuditLogRepository.php
  • app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php
  • app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php
  • database/migrations/Version20260416194357.php
  • tests/TwoFactorRepositoriesTest.php

Comment thread app/libs/Auth/Models/UserRecoveryCode.php Outdated
Comment thread app/libs/Auth/Models/UserTrustedDevice.php Outdated
Comment thread tests/TwoFactorRepositoriesTest.php Outdated

@martinquiroga-exo martinquiroga-exo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@matiasperrone-exo please see comments. Thanks

Comment thread app/Repositories/DoctrineUserTrustedDeviceRepository.php
Comment thread database/migrations/Version20260416194357.php Outdated
Comment thread tests/TwoFactorRepositoriesTest.php
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/

This page is automatically updated on each push to this PR.

@matiasperrone-exo
matiasperrone-exo force-pushed the feat/mfa-phase1---migrations--and--interfaces branch from bd6e93f to f75c63d Compare April 24, 2026 20:59
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/

This page is automatically updated on each push to this PR.

@matiasperrone-exo
matiasperrone-exo force-pushed the feat/mfa-phase1---migrations--and--interfaces branch from f75c63d to 4624ff5 Compare April 24, 2026 21:04
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/

This page is automatically updated on each push to this PR.

@matiasperrone-exo

Copy link
Copy Markdown
Contributor Author

@martinquiroga-exo please review again

@martinquiroga-exo martinquiroga-exo left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@matiasperrone-exo please see comments

Comment thread app/libs/Auth/Models/TwoFactorAuditLog.php Outdated
Comment thread app/Repositories/DoctrineUserTrustedDeviceRepository.php Outdated
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/

This page is automatically updated on each push to this PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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) via UNIQUE INDEX utd_user_device_uniq, but the entity metadata declares no corresponding UniqueConstraint. 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

📥 Commits

Reviewing files that changed from the base of the PR and between 6a2bd34 and 16e6ebb.

📒 Files selected for processing (6)
  • app/Repositories/DoctrineUserTrustedDeviceRepository.php
  • app/libs/Auth/Models/TwoFactorAuditLog.php
  • app/libs/Auth/Models/UserRecoveryCode.php
  • app/libs/Auth/Models/UserTrustedDevice.php
  • database/migrations/Version20260424120000.php
  • tests/TwoFactorRepositoriesTest.php

Comment thread app/libs/Auth/Models/TwoFactorAuditLog.php Outdated
Comment thread database/migrations/Version20260424120000.php Outdated
@matiasperrone-exo
matiasperrone-exo force-pushed the feat/mfa-phase1---migrations--and--interfaces branch from 16e6ebb to 8fab15c Compare April 29, 2026 19:19
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/

This page is automatically updated on each push to this PR.

@matiasperrone-exo
matiasperrone-exo force-pushed the feat/mfa-phase1---migrations--and--interfaces branch from 8fab15c to 5b8c829 Compare April 29, 2026 19:22
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/

This page is automatically updated on each push to this PR.

@matiasperrone-exo
matiasperrone-exo force-pushed the feat/mfa-phase1---migrations--and--interfaces branch 2 times, most recently from a5c0cff to f21b6fb Compare April 29, 2026 19:55
@github-actions

Copy link
Copy Markdown

📘 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
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/

This page is automatically updated on each push to this PR.

@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/

This page is automatically updated on each push to this PR.

@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/

This page is automatically updated on each push to this PR.

@matiasperrone-exo

Copy link
Copy Markdown
Contributor Author

Add UniqueConstraint annotation to mirror the database migration.

done

@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown

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.

@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/

This page is automatically updated on each push to this PR.

@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/

This page is automatically updated on each push to this PR.

@matiasperrone-exo

Copy link
Copy Markdown
Contributor Author

@smarcet please review now.

  • TwoFactorAuditLog now extends from BaseEntity

@matiasperrone-exo
matiasperrone-exo force-pushed the feat/mfa-phase1---migrations--and--interfaces branch from 4bf6d11 to 2ce71ca Compare May 18, 2026 21:24
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/

This page is automatically updated on each push to this PR.

@smarcet smarcet left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM

@github-actions

Copy link
Copy Markdown

📘 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
@matiasperrone-exo
matiasperrone-exo force-pushed the feat/mfa-phase1---migrations--and--interfaces branch from e323583 to ed53348 Compare June 8, 2026 19:55
@github-actions

github-actions Bot commented Jun 8, 2026

Copy link
Copy Markdown

📘 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>
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-125/

This page is automatically updated on each push to this PR.

@smarcet
smarcet requested review from romanetar and a lite review from Copilot and removed request for tomrndom August 11, 2026 17:02
@smarcet smarcet assigned smarcet and unassigned caseylocker Aug 11, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 users and 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.

Comment thread config/session.php
*/

'secure' => true,
'secure' => env('SESSION_SECURE_COOKIE', false),
Comment thread config/session.php
*/

'same_site' => 'none',
'same_site' => env('SESSION_COOKIE_SAME_SITE', 'lax'),
Comment on lines +33 to +40
// 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);
});
}
Comment on lines +88 to +93
$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"]);
Comment on lines +15 to +21
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;
Comment thread webpack.common.js
Comment on lines 41 to 43
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants