Skip to content

Feature | Add MultiFactor Authentication. Initial migrations and entities#123

Closed
matiasperrone-exo wants to merge 1 commit intomainfrom
feature/mfa-phase1
Closed

Feature | Add MultiFactor Authentication. Initial migrations and entities#123
matiasperrone-exo wants to merge 1 commit intomainfrom
feature/mfa-phase1

Conversation

@matiasperrone-exo
Copy link
Copy Markdown
Contributor

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

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.

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
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

📝 Walkthrough

Walkthrough

This PR introduces a comprehensive two-factor authentication infrastructure consisting of three new Doctrine ORM entities (TwoFactorAuditLog, UserRecoveryCode, UserTrustedDevice), their corresponding repository interfaces and implementations, a database migration creating the necessary tables, and integration tests validating the persistence layer.

Changes

Cohort / File(s) Summary
Entity Models
app/libs/Auth/Models/TwoFactorAuditLog.php, app/libs/Auth/Models/UserRecoveryCode.php, app/libs/Auth/Models/UserTrustedDevice.php
Three new Doctrine ORM entities for 2FA infrastructure: audit logging with event/method types, recovery code management with usage tracking, and trusted device tracking with revocation and expiration support. All include typed getters/setters and __get() magic accessors.
Repository Interfaces
app/libs/Auth/Repositories/ITwoFactorAuditLogRepository.php, app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php, app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php
Three new interfaces extending IBaseRepository, defining methods for querying audit logs by user, retrieving unused recovery codes, fetching active trusted devices by user and identifier, and deleting all recovery codes for a user.
Repository Implementations
app/Repositories/DoctrineTwoFactorAuditLogRepository.php, app/Repositories/DoctrineUserRecoveryCodeRepository.php, app/Repositories/DoctrineUserTrustedDeviceRepository.php
Three Doctrine-backed repository implementations providing concrete query logic: recent audit logs ordered by creation, unused recovery codes filtered by user, and active devices filtered by user and device identifier.
Service Container Configuration
app/Repositories/RepositoriesProvider.php
Added three singleton service bindings for the new repository interfaces, resolving to their Doctrine implementations via EntityManager, and updated provides() method to include the new interfaces for deferred loading.
Database Migration
database/migrations/Version20260416194357.php
Phase I 2FA schema expansion: augments users table with 2FA enablement flags and method defaults; creates user_trusted_devices (device trust/expiry tracking), two_factor_audit_log (event auditing), and user_recovery_codes (recovery code management) tables with appropriate indexes and cascade delete constraints.
Integration Tests
tests/TwoFactorRepositoriesTest.php
Test suite validating round-trip persistence and retrieval for all three entities: creates instances, persists via EntityManager, retrieves via repository methods, and verifies data integrity and deletion operations.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • smarcet
  • romanetar

Poem

🐰 With trusted devices and audit logs so fine,
Recovery codes in a row, all aligned,
The rabbit hops through migrations with glee,
Two-factor protection, secure as can be!
Tests pass and repositories dance all around,
Safety and spring—what a wonderful sound! 🔐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.25% 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
Title check ✅ Passed The pull request title 'Feature | Add MultiFactor Authentication' clearly and directly describes the main change—introducing multifactor authentication functionality to the system.
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.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/mfa-phase1

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 and usage tips.

@github-actions
Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

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

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

@matiasperrone-exo matiasperrone-exo self-assigned this Apr 21, 2026
@matiasperrone-exo matiasperrone-exo marked this pull request as ready for review April 21, 2026 20:10
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

Only repository collaborators, contributors, or members can run CodeRabbit commands.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (5)
database/migrations/Version20260416194357.php (2)

37-37: setDefault(null) on a nullable DateTime is redundant and may emit DEFAULT NULL explicitly.

Nullable columns default to NULL without an explicit default clause. Dropping ->setDefault(null) avoids any DBAL quirks with DateTime defaults across platforms.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@database/migrations/Version20260416194357.php` at line 37, Remove the
explicit ->setDefault(null) on the two_factor_enforced_at DateTime column
definition; keep it nullable via ->setNotnull(false) (or equivalent) and let the
column default to NULL implicitly to avoid emitting an explicit DEFAULT NULL and
DBAL DateTime quirks—look for the chain that references
'two_factor_enforced_at', 'setNotnull' and 'setDefault' and delete the
setDefault(null) call.

33-39: Idempotency check only covers one column.

The guard checks !hasColumn("users", "two_factor_enabled") and then adds all three columns inside the closure. If a previous partial run (or manual DBA step) already added two_factor_enabled but not two_factor_method / two_factor_enforced_at — or vice versa — this migration will either skip needed work or fail when re-adding an existing column. Consider guarding each column independently:

🛠️ Suggested per-column guard
-        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);
+                }
+            });
+        }
🤖 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 currently checks only !hasColumn("users","two_factor_enabled") before
adding three columns, which breaks idempotency if some columns already exist;
update the logic to check each column individually (use
$builder->hasColumn('users', 'two_factor_enabled'),
hasColumn('users','two_factor_method'),
hasColumn('users','two_factor_enforced_at')) and only add the specific missing
columns to the users table (call $builder->table('users', ...) or separate
closures accordingly) so you never try to re-create an existing column.
app/libs/Auth/Repositories/ITwoFactorAuditLogRepository.php (1)

18-24: Consider documenting/validating $limit.

Callers passing 0 or a negative value will propagate straight to findBy. A docblock noting the expected range (or a guard in the implementation) would be a nice-to-have.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/libs/Auth/Repositories/ITwoFactorAuditLogRepository.php` around lines 18
- 24, The interface method ITwoFactorAuditLogRepository::getRecentByUser
currently allows callers to pass 0 or negative $limit which will flow into
underlying findBy calls; update the interface PHPDoc to document the expected
valid range (e.g. positive integer) for $limit and add a guard in the
implementation of getRecentByUser to normalize/validate $limit (throw
InvalidArgumentException or coerce to a minimum of 1 or default 50) before
delegating to findBy; reference TwoFactorAuditLog return type and
IBaseRepository usage so reviewers can locate and update the concrete repository
implementation accordingly.
app/Repositories/DoctrineUserRecoveryCodeRepository.php (1)

34-42: DQL bulk delete bypasses the UnitOfWork.

DELETE via DQL does not trigger lifecycle callbacks and does not detach already-managed UserRecoveryCode entities from the EntityManager. If a caller loaded codes via getUnusedByUser() and then invoked deleteAllForUser() within the same request, those entities remain managed (and stale) in the UoW, which can cause surprising behavior on subsequent flushes.

Consider $em->clear(UserRecoveryCode::class) after execute, or document this constraint at the interface level. Low priority since Phase I callers likely run this in isolation.

🤖 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, deleteAllForUser performs a DQL bulk DELETE which bypasses the UnitOfWork
and leaves any previously loaded UserRecoveryCode entities managed and stale
(e.g. those returned by getUnusedByUser). After executing the delete query in
deleteAllForUser, call the EntityManager->clear(UserRecoveryCode::class) to
detach any managed UserRecoveryCode instances (or alternatively document this
behavior on the repository interface); locate deleteAllForUser and the
getEntityManager() usage to add the clear(UserRecoveryCode::class) call
immediately after $qb->getQuery()->execute() so the UoW no longer holds stale
entities.
tests/TwoFactorRepositoriesTest.php (1)

130-136: Assert the unused-code query returns and then excludes the target code.

assertNotEmpty($unused) can pass because of unrelated seeded rows. Check that $id is in the unused result, then after markUsed() flushes, assert that the same ID is no longer returned.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/TwoFactorRepositoriesTest.php` around lines 130 - 136, The test
currently only asserts $unused is non-empty which can be satisfied by other
seeded rows; update the test around the getUnusedByUser() call to explicitly
assert that the created code id ($id) is present in the $unused results (e.g.
check for an element with id $id from getUnusedByUser($this->user)), then after
calling markUsed() on the reloaded UserRecoveryCode and flushing, assert that
the same id is no longer present in the results of getUnusedByUser($this->user);
use the repository method getUnusedByUser, the entity UserRecoveryCode, and the
$id variable to locate and perform these checks.
🤖 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 75-79: The setters setEventType(string $value) and
setMethod(string $value) currently accept any string; change them to validate
$value against the class's allowed constants (the EVENT_* and METHOD_* sets
declared in this model) and reject unknown values by throwing an
InvalidArgumentException (or similar) with a clear message. Implement validation
either by checking in_array($value, self::ALLOWED_EVENTS) /
self::ALLOWED_METHODS (or building the allowed list from the defined EVENT_* /
METHOD_* constants) before assigning to $this->event_type / $this->method so
typos or invalid inputs are rejected.

In `@app/libs/Auth/Models/UserRecoveryCode.php`:
- Around line 61-64: The markUsed method in UserRecoveryCode currently
overwrites the used_at timestamp on every call; change markUsed() so it is
idempotent by only setting $this->used_at to a new UTC DateTime when used_at is
null/empty (i.e., if ($this->used_at === null) { ... }), preserving the original
first-use timestamp on subsequent calls; reference the
UserRecoveryCode::markUsed method and the $used_at property when making this
change.

In `@app/libs/Auth/Models/UserTrustedDevice.php`:
- Around line 28-29: The UserTrustedDevice entity currently allows multiple
active rows per (user_id, device_identifier) which breaks the expectation of
getActiveByUserAndIdentifier() returning a single record; update the ORM mapping
on class UserTrustedDevice to add a UNIQUE constraint across (user_id,
device_identifier) (adjust the #[ORM\Table] or #[ORM\UniqueConstraint]
annotations) so the DB enforces uniqueness, and/or modify the creation flow
(where trusted devices are saved) to atomically revoke any existing active
device for that user+identifier before inserting (update repository method
getActiveByUserAndIdentifier() and the save/create logic to perform the
revoke-then-insert or to surface unique constraint violations). Ensure you touch
the UserTrustedDevice entity annotations and the repository/save path that calls
getActiveByUserAndIdentifier() to enforce this invariant.

In `@tests/TwoFactorRepositoriesTest.php`:
- Around line 36-40: The test TwoFactorRepositoriesTest currently reuses an
arbitrary seeded user via IUserRepository->findOneBy([]) and then calls
deleteAllForUser($this->user), risking deletion of shared fixtures; change the
setup to create an isolated test user (e.g., via the IUserRepository create
method or a factory) for each test or run the test inside a DB
transaction/rollback (use a RefreshDatabase/transaction wrapper) so
deleteAllForUser($this->user) only affects the per-test user; apply the same
change to the other occurrence around lines 138-139 where deleteAllForUser is
invoked.
- Around line 69-75: The repository methods getActiveByUserAndIdentifier and
getActiveByUser currently only check revocation; update their query logic to
also exclude devices whose expires_at is in the past (i.e., only return rows
where expires_at is NULL or expires_at > now) so both revocation and expiration
are enforced; then add unit tests that create a revoked device and an expired
device and assert they are not returned by getActiveByUserAndIdentifier and
getActiveByUser while a valid non-revoked/non-expired device is returned to
prevent regressions.

---

Nitpick comments:
In `@app/libs/Auth/Repositories/ITwoFactorAuditLogRepository.php`:
- Around line 18-24: The interface method
ITwoFactorAuditLogRepository::getRecentByUser currently allows callers to pass 0
or negative $limit which will flow into underlying findBy calls; update the
interface PHPDoc to document the expected valid range (e.g. positive integer)
for $limit and add a guard in the implementation of getRecentByUser to
normalize/validate $limit (throw InvalidArgumentException or coerce to a minimum
of 1 or default 50) before delegating to findBy; reference TwoFactorAuditLog
return type and IBaseRepository usage so reviewers can locate and update the
concrete repository implementation accordingly.

In `@app/Repositories/DoctrineUserRecoveryCodeRepository.php`:
- Around line 34-42: deleteAllForUser performs a DQL bulk DELETE which bypasses
the UnitOfWork and leaves any previously loaded UserRecoveryCode entities
managed and stale (e.g. those returned by getUnusedByUser). After executing the
delete query in deleteAllForUser, call the
EntityManager->clear(UserRecoveryCode::class) to detach any managed
UserRecoveryCode instances (or alternatively document this behavior on the
repository interface); locate deleteAllForUser and the getEntityManager() usage
to add the clear(UserRecoveryCode::class) call immediately after
$qb->getQuery()->execute() so the UoW no longer holds stale entities.

In `@database/migrations/Version20260416194357.php`:
- Line 37: Remove the explicit ->setDefault(null) on the two_factor_enforced_at
DateTime column definition; keep it nullable via ->setNotnull(false) (or
equivalent) and let the column default to NULL implicitly to avoid emitting an
explicit DEFAULT NULL and DBAL DateTime quirks—look for the chain that
references 'two_factor_enforced_at', 'setNotnull' and 'setDefault' and delete
the setDefault(null) call.
- Around line 33-39: The migration currently checks only
!hasColumn("users","two_factor_enabled") before adding three columns, which
breaks idempotency if some columns already exist; update the logic to check each
column individually (use $builder->hasColumn('users', 'two_factor_enabled'),
hasColumn('users','two_factor_method'),
hasColumn('users','two_factor_enforced_at')) and only add the specific missing
columns to the users table (call $builder->table('users', ...) or separate
closures accordingly) so you never try to re-create an existing column.

In `@tests/TwoFactorRepositoriesTest.php`:
- Around line 130-136: The test currently only asserts $unused is non-empty
which can be satisfied by other seeded rows; update the test around the
getUnusedByUser() call to explicitly assert that the created code id ($id) is
present in the $unused results (e.g. check for an element with id $id from
getUnusedByUser($this->user)), then after calling markUsed() on the reloaded
UserRecoveryCode and flushing, assert that the same id is no longer present in
the results of getUnusedByUser($this->user); use the repository method
getUnusedByUser, the entity UserRecoveryCode, and the $id variable to locate and
perform these checks.
🪄 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: 3ff776b1-55ed-47da-812e-4b2c36f918d2

📥 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 on lines +75 to +79
public function getEventType(): string { return $this->event_type; }
public function setEventType(string $value): void { $this->event_type = $value; }

public function getMethod(): string { return $this->method; }
public function setMethod(string $value): void { $this->method = $value; }
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Validate audit event and method values.

The class defines the allowed event/method constants, but the setters accept arbitrary strings. Reject unknown values here to avoid unclassifiable MFA audit rows from typos or caller bugs.

Proposed fix
-    public function setEventType(string $value): void { $this->event_type = $value; }
+    public function setEventType(string $value): void
+    {
+        if (!in_array($value, [
+            self::EventChallengeIssued,
+            self::EventChallengeSucceeded,
+            self::EventChallengeFailed,
+            self::EventEnrollmentChanged,
+            self::EventDeviceTrusted,
+            self::EventDeviceRevoked,
+            self::EventRecoveryUsed,
+            self::EventSettingsChanged,
+        ], true)) {
+            throw new \InvalidArgumentException(sprintf('Unsupported two-factor audit event type "%s".', $value));
+        }
+
+        $this->event_type = $value;
+    }
 
     public function getMethod(): string { return $this->method; }
-    public function setMethod(string $value): void { $this->method = $value; }
+    public function setMethod(string $value): void
+    {
+        if (!in_array($value, [
+            self::MethodEmailOtp,
+            self::MethodSmsOtp,
+            self::MethodTotp,
+            self::MethodPasskey,
+            self::MethodRecovery,
+        ], true)) {
+            throw new \InvalidArgumentException(sprintf('Unsupported two-factor audit method "%s".', $value));
+        }
+
+        $this->method = $value;
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/libs/Auth/Models/TwoFactorAuditLog.php` around lines 75 - 79, The setters
setEventType(string $value) and setMethod(string $value) currently accept any
string; change them to validate $value against the class's allowed constants
(the EVENT_* and METHOD_* sets declared in this model) and reject unknown values
by throwing an InvalidArgumentException (or similar) with a clear message.
Implement validation either by checking in_array($value, self::ALLOWED_EVENTS) /
self::ALLOWED_METHODS (or building the allowed list from the defined EVENT_* /
METHOD_* constants) before assigning to $this->event_type / $this->method so
typos or invalid inputs are rejected.

Comment on lines +61 to +64
public function markUsed(): void
{
$this->used_at = new \DateTime('now', new \DateTimeZone('UTC'));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Preserve the first-use timestamp.

markUsed() currently overwrites used_at on every call. For one-time recovery codes, keep this state transition idempotent so retries or duplicate handling do not erase the original usage time.

Proposed fix
 public function markUsed(): void
 {
+    if ($this->isUsed()) {
+        return;
+    }
     $this->used_at = new \DateTime('now', new \DateTimeZone('UTC'));
 }
🤖 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 61 - 64, The markUsed
method in UserRecoveryCode currently overwrites the used_at timestamp on every
call; change markUsed() so it is idempotent by only setting $this->used_at to a
new UTC DateTime when used_at is null/empty (i.e., if ($this->used_at === null)
{ ... }), preserving the original first-use timestamp on subsequent calls;
reference the UserRecoveryCode::markUsed method and the $used_at property when
making this change.

Comment on lines +28 to +29
#[ORM\Column(name: 'device_identifier', type: 'string', length: 255)]
private $device_identifier;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Repository lookup implementation:"
rg -n -C4 'function\s+getActiveByUserAndIdentifier|getActiveByUserAndIdentifier\s*\('

echo
echo "Trusted-device schema indexes/constraints:"
rg -n -C4 'user_trusted_devices|utd_user_device_idx|device_identifier'

Repository: OpenStackweb/openstackid

Length of output: 9908


🏁 Script executed:

cat -n app/libs/Auth/Models/UserTrustedDevice.php | head -80

Repository: OpenStackweb/openstackid

Length of output: 3863


🏁 Script executed:

rg -n 'uniqueConstraints|UNIQUE|unique' app/libs/Auth/Models/UserTrustedDevice.php

Repository: OpenStackweb/openstackid

Length of output: 50


🏁 Script executed:

rg -n -B5 -A10 'new\s+UserTrustedDevice|setUser.*setDeviceIdentifier|addTrusted' --type php

Repository: OpenStackweb/openstackid

Length of output: 17008


🏁 Script executed:

git ls-files database/migrations/ | grep -i trusted

Repository: OpenStackweb/openstackid

Length of output: 50


🏁 Script executed:

rg -n 'createTrustedDevice|addTrustedDevice|revokeBefore' --type php

Repository: OpenStackweb/openstackid

Length of output: 50


🏁 Script executed:

rg -n 'UserTrustedDevice' --type php | grep -i 'service\|repository\|factory' | head -20

Repository: OpenStackweb/openstackid

Length of output: 2042


🏁 Script executed:

rg -n -B5 -A15 'class.*UserTrustedDevice.*Service|class.*UserTrustedDevice.*Factory' --type php

Repository: OpenStackweb/openstackid

Length of output: 50


🏁 Script executed:

rg -n -B3 -A10 'persist.*device|flush.*device' tests/ --type php | grep -A10 -B3 'UserTrustedDevice'

Repository: OpenStackweb/openstackid

Length of output: 50


🏁 Script executed:

# Search for any service/class that might create UserTrustedDevice
fd -type f -name '*Service*.php' -o -name '*Factory*.php' | xargs rg -l 'UserTrustedDevice' 2>/dev/null

Repository: OpenStackweb/openstackid

Length of output: 493


🏁 Script executed:

cat -n tests/TwoFactorRepositoriesTest.php

Repository: OpenStackweb/openstackid

Length of output: 5977


🏁 Script executed:

rg -n 'is_revoked|revoke' app/libs/Auth/Models/UserTrustedDevice.php app/Repositories/DoctrineUserTrustedDeviceRepository.php

Repository: OpenStackweb/openstackid

Length of output: 795


🏁 Script executed:

# Check if there's any revoke-before-insert or conflict handling
rg -n -B5 -A10 'revoke.*device|device.*revoke' --type php

Repository: OpenStackweb/openstackid

Length of output: 2869


Add UNIQUE constraint for active trusted devices per user/identifier.

The schema defines only a non-unique index on (user_id, device_identifier), but getActiveByUserAndIdentifier() expects singular lookup and uses findOneBy() which returns only the first match. Multiple active rows for the same user and identifier will silently select an arbitrary device instead of failing loudly or enforcing single ownership. Add a UNIQUE constraint on (user_id, device_identifier) at the database level, or implement an application-level invariant (revoke previous active device before adding a new one) to prevent this security gap in MFA trusted-device handling.

🤖 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 28 - 29, The
UserTrustedDevice entity currently allows multiple active rows per (user_id,
device_identifier) which breaks the expectation of
getActiveByUserAndIdentifier() returning a single record; update the ORM mapping
on class UserTrustedDevice to add a UNIQUE constraint across (user_id,
device_identifier) (adjust the #[ORM\Table] or #[ORM\UniqueConstraint]
annotations) so the DB enforces uniqueness, and/or modify the creation flow
(where trusted devices are saved) to atomically revoke any existing active
device for that user+identifier before inserting (update repository method
getActiveByUserAndIdentifier() and the save/create logic to perform the
revoke-then-insert or to surface unique constraint violations). Ensure you touch
the UserTrustedDevice entity annotations and the repository/save path that calls
getActiveByUserAndIdentifier() to enforce this invariant.

Comment on lines +36 to +40
// Pull any persisted user; tests don't assert on user fields, only on FK linkage
$userRepo = App::make(IUserRepository::class);
$this->user = $userRepo->findOneBy([]);
if (is_null($this->user)) {
$this->markTestSkipped('No User exists; database must be seeded.');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Use an isolated test user before calling broad deletes.

The test selects any seeded user, then deleteAllForUser($this->user) removes all recovery codes for that user. This can corrupt shared fixtures and make test order matter; create a dedicated user/fixture per test or wrap the test in a rollback transaction.

Also applies to: 138-139

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/TwoFactorRepositoriesTest.php` around lines 36 - 40, The test
TwoFactorRepositoriesTest currently reuses an arbitrary seeded user via
IUserRepository->findOneBy([]) and then calls deleteAllForUser($this->user),
risking deletion of shared fixtures; change the setup to create an isolated test
user (e.g., via the IUserRepository create method or a factory) for each test or
run the test inside a DB transaction/rollback (use a RefreshDatabase/transaction
wrapper) so deleteAllForUser($this->user) only affects the per-test user; apply
the same change to the other occurrence around lines 138-139 where
deleteAllForUser is invoked.

Comment on lines +69 to +75
$found = $repo->getActiveByUserAndIdentifier($this->user, $deviceId);
$this->assertNotNull($found);
$this->assertEquals($deviceId, $found->getDeviceIdentifier());
$this->assertFalse($found->isRevoked());

$active = $repo->getActiveByUser($this->user);
$this->assertNotEmpty($active);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C6 'function\s+getActiveByUser|expires_at|is_revoked' --iglob '*DoctrineUserTrustedDeviceRepository.php'

Repository: OpenStackweb/openstackid

Length of output: 2028


🏁 Script executed:

#!/bin/bash
# Find UserTrustedDevice model definition
fd -type f -name '*.php' | xargs rg -l 'class UserTrustedDevice' | head -5

Repository: OpenStackweb/openstackid

Length of output: 298


🏁 Script executed:

#!/bin/bash
# Check the UserTrustedDevice model for expires_at field
fd -type f -name '*.php' | xargs rg -A 20 'class UserTrustedDevice'

Repository: OpenStackweb/openstackid

Length of output: 1819


🏁 Script executed:

#!/bin/bash
# Search for any usage of expires_at in the codebase related to trusted devices
rg -n 'expires_at' --type php | grep -i 'trusted\|device' | head -20

Repository: OpenStackweb/openstackid

Length of output: 497


🏁 Script executed:

#!/bin/bash
# Check TwoFactorRepositoriesTest.php for current test coverage
cat -n tests/TwoFactorRepositoriesTest.php | head -100

Repository: OpenStackweb/openstackid

Length of output: 4335


🏁 Script executed:

#!/bin/bash
# Search for any expiration validation logic in the codebase
rg -n 'expires_at\|getExpiresAt\|isExpired' app/ | head -30

Repository: OpenStackweb/openstackid

Length of output: 50


Critical issue: Repository methods do not filter expired trusted devices.

The getActiveByUserAndIdentifier() and getActiveByUser() methods filter only by revocation status, completely ignoring the expires_at field. Since trusted devices gate MFA bypass, expired devices that should be inactive remain active. The repository implementation must filter expires_at alongside is_revoked. Additionally, add test cases covering expired and revoked device exclusion to prevent regression.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/TwoFactorRepositoriesTest.php` around lines 69 - 75, The repository
methods getActiveByUserAndIdentifier and getActiveByUser currently only check
revocation; update their query logic to also exclude devices whose expires_at is
in the past (i.e., only return rows where expires_at is NULL or expires_at >
now) so both revocation and expiration are enforced; then add unit tests that
create a revoked device and an expired device and assert they are not returned
by getActiveByUserAndIdentifier and getActiveByUser while a valid
non-revoked/non-expired device is returned to prevent regressions.

@matiasperrone-exo matiasperrone-exo deleted the feature/mfa-phase1 branch April 22, 2026 18:56
@matiasperrone-exo matiasperrone-exo changed the title Feature | Add MultiFactor Authentication Feature | Add MultiFactor Authentication. Initial migrations and entities Apr 22, 2026
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.

1 participant