Skip to content

Commit ec64f2f

Browse files
authored
MFA hardening: OAuth2 memento guard on recovery, DTO refactors, full 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.
1 parent 68a6c8d commit ec64f2f

20 files changed

Lines changed: 877 additions & 106 deletions

app/Http/Controllers/UserController.php

Lines changed: 29 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use App\Services\Auth\IUserService as AuthUserService;
2929
use Auth\Exceptions\AuthenticationException;
3030
use Auth\Exceptions\UnverifiedEmailMemberException;
31+
use Auth\MFAConstants;
3132
use Auth\User;
3233
use Exception;
3334
use Illuminate\Http\Request as LaravelRequest;
@@ -762,7 +763,7 @@ public function verify2FA()
762763
return $this->mfaSessionExpired();
763764
}
764765

765-
$user = $this->auth_service->getUserById((int) $pending['user_id']);
766+
$user = $this->auth_service->getUserById($pending->getUserId());
766767
if (is_null($user) || !$user->isTwoFactorMethodEnabled($method)) {
767768
$strategy->clearPendingState();
768769
return $this->mfaSessionExpired();
@@ -784,7 +785,7 @@ public function verify2FA()
784785
} catch (AuthenticationException $ex) {
785786
Log::warning($ex);
786787
// Re-fetch user: the tx wrapper closed/reset the EM on failure, detaching the entity.
787-
$userId = (int) $pending['user_id'];
788+
$userId = $pending->getUserId();
788789
$user = $this->auth_service->getUserById($userId) ?? $user;
789790
// Best-effort: an audit-logging failure here must not turn a
790791
// clean 401 into a 500 (which would also drop the error_code
@@ -799,11 +800,11 @@ public function verify2FA()
799800
} catch (\Throwable $auditEx) {
800801
Log::warning($auditEx);
801802
}
802-
return $this->unauthorized(['error_code' => 'mfa_verification_failed']);
803+
return $this->unauthorized(['error_code' => MFAConstants::ERROR_CODE_VERIFICATION_FAILED]);
803804
}
804805

805806
// Second factor verified: establish the session.
806-
$this->auth_service->loginUser($user, (bool) $pending['remember']);
807+
$this->auth_service->loginUser($user, $pending->shouldRemember());
807808

808809
if ($trust_device) {
809810
// Best-effort: the OTP is already redeemed and the session
@@ -879,18 +880,26 @@ public function verify2FARecovery()
879880
return $this->mfaSessionExpired();
880881
}
881882

882-
$user = $this->auth_service->getUserById((int) $pending['user_id']);
883+
$user = $this->auth_service->getUserById($pending->getUserId());
883884
if (is_null($user)) {
884885
$strategy->clearPendingState();
885886
return $this->mfaSessionExpired();
886887
}
887888

889+
// Same guard verify2FA() applies before redeeming: a pending OAuth2
890+
// authorization request must still resolve to an existing client,
891+
// or the single-use recovery code would be burned (and a session
892+
// established) for an authorization request that can only fail at
893+
// the /oauth2/auth hop. Recovery-code checking itself is
894+
// client-agnostic, so the resolved client is not passed down.
895+
$this->resolveClientFromMemento();
896+
888897
try {
889898
$this->auth_service->verifyMFARecoveryCode($user, $strategy, $recovery_code);
890899
} catch (AuthenticationException $ex) {
891900
Log::warning($ex);
892901
// Re-fetch user: the tx wrapper closed/reset the EM on failure, detaching the entity.
893-
$userId = (int) $pending['user_id'];
902+
$userId = $pending->getUserId();
894903
$user = $this->auth_service->getUserById($userId) ?? $user;
895904
// Best-effort: see verify2FA() for rationale.
896905
try {
@@ -903,10 +912,10 @@ public function verify2FARecovery()
903912
} catch (\Throwable $auditEx) {
904913
Log::warning($auditEx);
905914
}
906-
return $this->unauthorized(['error_code' => 'mfa_invalid_recovery']);
915+
return $this->unauthorized(['error_code' => MFAConstants::ERROR_CODE_INVALID_RECOVERY]);
907916
}
908917

909-
$this->auth_service->loginUser($user, (bool) $pending['remember']);
918+
$this->auth_service->loginUser($user, $pending->shouldRemember());
910919
$strategy->clearPendingState();
911920
$this->clearMFAUISessionState();
912921

@@ -925,16 +934,14 @@ public function verify2FARecovery()
925934
}
926935

927936
// See verify2FA() for rationale: return the destination as data so a real
928-
// top-level navigation (not this XHR) performs any cross-origin hop.
937+
// top-level navigation (not this XHR) performs any cross-origin hop. The
938+
// recovery-codes standing rides along so the login page can warn the user
939+
// when they've just burned into their last few codes (see RecoveryCodesStatus).
929940
$redirect = $this->login_strategy->postLogin();
930-
return $this->ok([
931-
'redirect_url' => $redirect->getTargetUrl(),
932-
// CU-86ba2zp66 / sds/idp-mfa.md §4.10.3, §4.11 step 5: the login page
933-
// must be able to warn the user when they've just burned into their
934-
// last few recovery codes, since it may be their only way back in.
935-
'recovery_codes_remaining' => $this->recovery_code_service->countUnusedRecoveryCodes($user),
936-
'recovery_codes_low_threshold' => (int) config('auth.recovery_codes.low_threshold', 3),
937-
]);
941+
return $this->ok(array_merge(
942+
['redirect_url' => $redirect->getTargetUrl()],
943+
$this->recovery_code_service->getStatus($user)->toArray()
944+
));
938945
} catch (ValidationException $ex) {
939946
Log::warning($ex);
940947
return $this->error412($ex->getMessages());
@@ -969,13 +976,13 @@ public function resend2FA()
969976
return $this->mfaSessionExpired();
970977
}
971978

972-
$user = $this->auth_service->getUserById((int) $pending['user_id']);
979+
$user = $this->auth_service->getUserById($pending->getUserId());
973980
if (is_null($user) || !$user->isTwoFactorMethodEnabled($method)) {
974981
$strategy->clearPendingState();
975982
return $this->mfaSessionExpired();
976983
}
977984

978-
$payload = $this->auth_service->resendMFAChallenge($user, $strategy, $this->resolveClientFromMemento(), (bool) $pending['remember']);
985+
$payload = $this->auth_service->resendMFAChallenge($user, $strategy, $this->resolveClientFromMemento(), $pending->shouldRemember());
979986

980987
// Keep the refresh-restorable session state in sync with the
981988
// fresh challenge (e.g. otp_lifetime countdown resets on resend,
@@ -1021,7 +1028,7 @@ public function resend2FA()
10211028
private function mfaSessionExpired()
10221029
{
10231030
$this->clearMFAUISessionState();
1024-
return $this->unauthorized(['error_code' => 'mfa_session_expired']);
1031+
return $this->unauthorized(['error_code' => MFAConstants::ERROR_CODE_SESSION_EXPIRED]);
10251032
}
10261033

10271034
/**
@@ -1191,7 +1198,7 @@ public function getProfile()
11911198
$lang2Code[] = $lang;
11921199
}
11931200

1194-
return View::make("profile", [
1201+
return View::make("profile", array_merge([
11951202
'user' => json_encode(SerializerRegistry::getInstance()->getSerializer(
11961203
$user, SerializerRegistry::SerializerType_Private)->serialize()),
11971204
"openid_url" => $this->server_configuration_service->getUserIdentityEndpointURL($user->getIdentifier()),
@@ -1200,10 +1207,7 @@ public function getProfile()
12001207
'countries' => CountryList::getCountries(),
12011208
'languages' => $lang2Code,
12021209
'two_factor_enabled' => $user->shouldRequire2FA(),
1203-
'recovery_codes_remaining' => $this->recovery_code_service->countUnusedRecoveryCodes($user),
1204-
'recovery_codes_total' => (int)config('auth.recovery_codes.count', 10),
1205-
'recovery_codes_low_threshold' => (int)config('auth.recovery_codes.low_threshold', 3),
1206-
]);
1210+
], $this->recovery_code_service->getStatus($user)->toArray()));
12071211
}
12081212

12091213
public function deleteTrustedSite($id)

app/Http/Middleware/TwoFactorRateLimitMiddleware.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
**/
1414

1515
use App\Services\Auth\ITwoFactorRateLimitService;
16+
use Auth\MFAConstants;
1617
use Closure;
1718
use Illuminate\Cache\RateLimiting\Unlimited;
1819
use Illuminate\Support\Facades\Log;
@@ -41,8 +42,8 @@ final class TwoFactorRateLimitMiddleware
4142
* Response error_code values that count as a verification failure.
4243
*/
4344
private const FAILURE_CODES = [
44-
'mfa_verification_failed',
45-
'mfa_invalid_recovery',
45+
MFAConstants::ERROR_CODE_VERIFICATION_FAILED,
46+
MFAConstants::ERROR_CODE_INVALID_RECOVERY,
4647
];
4748

4849
public function __construct(private readonly ITwoFactorRateLimitService $rate_limit_service)

app/Services/Auth/IRecoveryCodeService.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,10 @@ public function enableTwoFactorAndGenerateCodes(User $user, string $method): arr
6161
* @return int count of unused recovery codes
6262
*/
6363
public function countUnusedRecoveryCodes(User $user): int;
64+
65+
/**
66+
* @param User $user
67+
* @return RecoveryCodesStatus remaining/total/low-threshold standing for the user
68+
*/
69+
public function getStatus(User $user): RecoveryCodesStatus;
6470
}

app/Services/Auth/ITwoFactorRateLimitService.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
namespace App\Services\Auth;
44

5+
use Auth\MFAConstants;
6+
57
/**
68
* Copyright 2026 OpenStack Foundation
79
* Licensed under the Apache License, Version 2.0 (the "License");
@@ -38,14 +40,14 @@ interface ITwoFactorRateLimitService
3840
public const ActionResend = 'resend';
3941
public const ActionOtp = 'otp';
4042

41-
public const RATE_LIMIT_ERROR_CODE = 'mfa_rate_limit';
43+
public const RATE_LIMIT_ERROR_CODE = MFAConstants::ERROR_CODE_RATE_LIMIT;
4244
public const RATE_LIMIT_MESSAGE = 'Too many attempts. Please try again later.';
4345

4446
/**
4547
* Session key holding the user id of the pending MFA challenge - the
4648
* subject the verify/recovery/resend named limiters throttle by.
4749
*/
48-
public const PENDING_USER_SESSION_KEY = '2fa_pending_user_id';
50+
public const PENDING_USER_SESSION_KEY = MFAConstants::SESSION_KEY_PENDING_USER_ID;
4951

5052
/**
5153
* Prefix applied to the Action* constants when registering/looking up
@@ -57,6 +59,14 @@ interface ITwoFactorRateLimitService
5759
*/
5860
public const RATE_LIMITER_NAME_PREFIX = '2fa-rate:';
5961

62+
/**
63+
* Prefix of the cache keys holding the per-subject attempt counters
64+
* (and their companion ":timer" keys) - see cacheKey() in the
65+
* implementation. Distinct from RATE_LIMITER_NAME_PREFIX (dash), which
66+
* names the limiters, not the storage.
67+
*/
68+
public const RATE_LIMIT_CACHE_KEY_PREFIX = '2fa_rate:';
69+
6070
/**
6171
* @param string $action one of self::ActionVerify|ActionRecovery|ActionResend|ActionOtp
6272
* @param string|int $subject a user id for session-keyed actions, or a raw

app/Services/Auth/RecoveryCodeService.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,16 @@ public function countUnusedRecoveryCodes(User $user): int
156156
{
157157
return count($this->repository->getUnusedByUser($user));
158158
}
159+
160+
/**
161+
* @inheritDoc
162+
*/
163+
public function getStatus(User $user): RecoveryCodesStatus
164+
{
165+
return new RecoveryCodesStatus(
166+
$this->countUnusedRecoveryCodes($user),
167+
(int) config('auth.recovery_codes.count', 10),
168+
(int) config('auth.recovery_codes.low_threshold', 3)
169+
);
170+
}
159171
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
<?php
2+
namespace App\Services\Auth;
3+
/**
4+
* Copyright 2026 OpenStack Foundation
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
**/
15+
16+
/**
17+
* Immutable snapshot of a user's recovery-codes standing, built by
18+
* IRecoveryCodeService::getStatus(). toArray() owns the wire keys consumed by
19+
* the login SPA (resources/js/login/login.js) and the profile page
20+
* (resources/views/profile.blade.php) - CU-86ba2zp66 / sds/idp-mfa.md §4.10.3,
21+
* §4.11 step 5: the UI must be able to warn the user when they've burned into
22+
* their last few recovery codes, since those may be their only way back in.
23+
*
24+
* @package App\Services\Auth
25+
*/
26+
final class RecoveryCodesStatus
27+
{
28+
public function __construct(
29+
private readonly int $remaining,
30+
private readonly int $total,
31+
private readonly int $low_threshold,
32+
) {}
33+
34+
public function getRemaining(): int
35+
{
36+
return $this->remaining;
37+
}
38+
39+
public function getTotal(): int
40+
{
41+
return $this->total;
42+
}
43+
44+
public function getLowThreshold(): int
45+
{
46+
return $this->low_threshold;
47+
}
48+
49+
/**
50+
* @return array<string,int>
51+
*/
52+
public function toArray(): array
53+
{
54+
return [
55+
'recovery_codes_remaining' => $this->remaining,
56+
'recovery_codes_total' => $this->total,
57+
'recovery_codes_low_threshold' => $this->low_threshold,
58+
];
59+
}
60+
}

app/Services/Auth/TwoFactorRateLimitService.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,6 @@ private function limitsFor(string $action): array
9494
*/
9595
private function cacheKey(string $action, string|int $subject): string
9696
{
97-
return sprintf('2fa_rate:%s:%s', $action, $subject);
97+
return sprintf('%s%s:%s', self::RATE_LIMIT_CACHE_KEY_PREFIX, $action, $subject);
9898
}
9999
}

app/Strategies/ILoginStrategy.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
<?php namespace Strategies;
2+
3+
use Auth\MFAConstants;
4+
25
/**
36
* Interface ILoginStrategy
47
* @package Strategies
@@ -9,7 +12,7 @@ interface ILoginStrategy
912
* error_code returned by challengeRequired() when factor 1 passed but a
1013
* 2FA challenge is pending.
1114
*/
12-
const MFA_REQUIRED = 'mfa_required';
15+
const MFA_REQUIRED = MFAConstants::ERROR_CODE_MFA_REQUIRED;
1316

1417
/**
1518
* @return mixed

app/Strategies/MFA/AbstractMFAChallengeStrategy.php

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php namespace Strategies\MFA;
22

33
use Auth\Exceptions\AuthenticationException;
4+
use Auth\MFAConstants;
45
use Auth\Repositories\IUserRecoveryCodeRepository;
56
use Auth\User;
67
use Illuminate\Support\Facades\Hash;
@@ -10,14 +11,14 @@
1011
abstract class AbstractMFAChallengeStrategy implements IMFAChallengeStrategy
1112
{
1213
private const SESSION_TTL = 300;
13-
private const KEY_USER_ID = '2fa_pending_user_id';
14-
private const KEY_PENDING_AT = '2fa_pending_at';
15-
private const KEY_REMEMBER = '2fa_remember';
16-
private const KEY_RECOVERY_ATTEMPTS = '2fa_recovery_attempts';
14+
private const KEY_USER_ID = MFAConstants::SESSION_KEY_PENDING_USER_ID;
15+
private const KEY_PENDING_AT = MFAConstants::SESSION_KEY_PENDING_AT;
16+
private const KEY_REMEMBER = MFAConstants::SESSION_KEY_REMEMBER;
17+
private const KEY_RECOVERY_ATTEMPTS = MFAConstants::SESSION_KEY_RECOVERY_ATTEMPTS;
1718

1819
public function __construct(protected IUserRecoveryCodeRepository $recovery_code_repository) {}
1920

20-
public function getPendingState(): ?array
21+
public function getPendingState(): ?MFAPendingState
2122
{
2223
$user_id = Session::get(self::KEY_USER_ID);
2324
$pending_at = Session::get(self::KEY_PENDING_AT);
@@ -31,11 +32,11 @@ public function getPendingState(): ?array
3132
return null;
3233
}
3334

34-
return [
35-
'user_id' => $user_id,
36-
'pending_at' => $pending_at,
37-
'remember' => Session::get(self::KEY_REMEMBER, false),
38-
];
35+
return new MFAPendingState(
36+
(int) $user_id,
37+
(int) $pending_at,
38+
(bool) Session::get(self::KEY_REMEMBER, false)
39+
);
3940
}
4041

4142
public function clearPendingState(): void

app/Strategies/MFA/IMFAChallengeStrategy.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ interface IMFAChallengeStrategy
88
public function issueChallenge(User $user, ?Client $client, bool $remember): array;
99
public function verifyChallenge(User $user, string $code, ?Client $client = null): void;
1010
public function resendChallenge(User $user, ?Client $client, bool $remember): array;
11-
public function getPendingState(): ?array;
11+
public function getPendingState(): ?MFAPendingState;
1212
public function clearPendingState(): void;
1313
public function verifyRecoveryCode(User $user, string $code): void;
1414
}

0 commit comments

Comments
 (0)