diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 97a54674..fdd3b482 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -28,6 +28,7 @@ use App\Services\Auth\IUserService as AuthUserService; use Auth\Exceptions\AuthenticationException; use Auth\Exceptions\UnverifiedEmailMemberException; +use Auth\MFAConstants; use Auth\User; use Exception; use Illuminate\Http\Request as LaravelRequest; @@ -762,7 +763,7 @@ public function verify2FA() return $this->mfaSessionExpired(); } - $user = $this->auth_service->getUserById((int) $pending['user_id']); + $user = $this->auth_service->getUserById($pending->getUserId()); if (is_null($user) || !$user->isTwoFactorMethodEnabled($method)) { $strategy->clearPendingState(); return $this->mfaSessionExpired(); @@ -784,7 +785,7 @@ public function verify2FA() } catch (AuthenticationException $ex) { Log::warning($ex); // Re-fetch user: the tx wrapper closed/reset the EM on failure, detaching the entity. - $userId = (int) $pending['user_id']; + $userId = $pending->getUserId(); $user = $this->auth_service->getUserById($userId) ?? $user; // Best-effort: an audit-logging failure here must not turn a // clean 401 into a 500 (which would also drop the error_code @@ -799,11 +800,11 @@ public function verify2FA() } catch (\Throwable $auditEx) { Log::warning($auditEx); } - return $this->unauthorized(['error_code' => 'mfa_verification_failed']); + return $this->unauthorized(['error_code' => MFAConstants::ERROR_CODE_VERIFICATION_FAILED]); } // Second factor verified: establish the session. - $this->auth_service->loginUser($user, (bool) $pending['remember']); + $this->auth_service->loginUser($user, $pending->shouldRemember()); if ($trust_device) { // Best-effort: the OTP is already redeemed and the session @@ -879,18 +880,26 @@ public function verify2FARecovery() return $this->mfaSessionExpired(); } - $user = $this->auth_service->getUserById((int) $pending['user_id']); + $user = $this->auth_service->getUserById($pending->getUserId()); if (is_null($user)) { $strategy->clearPendingState(); return $this->mfaSessionExpired(); } + // Same guard verify2FA() applies before redeeming: a pending OAuth2 + // authorization request must still resolve to an existing client, + // or the single-use recovery code would be burned (and a session + // established) for an authorization request that can only fail at + // the /oauth2/auth hop. Recovery-code checking itself is + // client-agnostic, so the resolved client is not passed down. + $this->resolveClientFromMemento(); + try { $this->auth_service->verifyMFARecoveryCode($user, $strategy, $recovery_code); } catch (AuthenticationException $ex) { Log::warning($ex); // Re-fetch user: the tx wrapper closed/reset the EM on failure, detaching the entity. - $userId = (int) $pending['user_id']; + $userId = $pending->getUserId(); $user = $this->auth_service->getUserById($userId) ?? $user; // Best-effort: see verify2FA() for rationale. try { @@ -903,10 +912,10 @@ public function verify2FARecovery() } catch (\Throwable $auditEx) { Log::warning($auditEx); } - return $this->unauthorized(['error_code' => 'mfa_invalid_recovery']); + return $this->unauthorized(['error_code' => MFAConstants::ERROR_CODE_INVALID_RECOVERY]); } - $this->auth_service->loginUser($user, (bool) $pending['remember']); + $this->auth_service->loginUser($user, $pending->shouldRemember()); $strategy->clearPendingState(); $this->clearMFAUISessionState(); @@ -925,16 +934,14 @@ public function verify2FARecovery() } // See verify2FA() for rationale: return the destination as data so a real - // top-level navigation (not this XHR) performs any cross-origin hop. + // top-level navigation (not this XHR) performs any cross-origin hop. The + // recovery-codes standing rides along so the login page can warn the user + // when they've just burned into their last few codes (see RecoveryCodesStatus). $redirect = $this->login_strategy->postLogin(); - return $this->ok([ - 'redirect_url' => $redirect->getTargetUrl(), - // CU-86ba2zp66 / sds/idp-mfa.md §4.10.3, §4.11 step 5: the login page - // must be able to warn the user when they've just burned into their - // last few recovery codes, since it may be their only way back in. - 'recovery_codes_remaining' => $this->recovery_code_service->countUnusedRecoveryCodes($user), - 'recovery_codes_low_threshold' => (int) config('auth.recovery_codes.low_threshold', 3), - ]); + return $this->ok(array_merge( + ['redirect_url' => $redirect->getTargetUrl()], + $this->recovery_code_service->getStatus($user)->toArray() + )); } catch (ValidationException $ex) { Log::warning($ex); return $this->error412($ex->getMessages()); @@ -969,13 +976,13 @@ public function resend2FA() return $this->mfaSessionExpired(); } - $user = $this->auth_service->getUserById((int) $pending['user_id']); + $user = $this->auth_service->getUserById($pending->getUserId()); if (is_null($user) || !$user->isTwoFactorMethodEnabled($method)) { $strategy->clearPendingState(); return $this->mfaSessionExpired(); } - $payload = $this->auth_service->resendMFAChallenge($user, $strategy, $this->resolveClientFromMemento(), (bool) $pending['remember']); + $payload = $this->auth_service->resendMFAChallenge($user, $strategy, $this->resolveClientFromMemento(), $pending->shouldRemember()); // Keep the refresh-restorable session state in sync with the // fresh challenge (e.g. otp_lifetime countdown resets on resend, @@ -1021,7 +1028,7 @@ public function resend2FA() private function mfaSessionExpired() { $this->clearMFAUISessionState(); - return $this->unauthorized(['error_code' => 'mfa_session_expired']); + return $this->unauthorized(['error_code' => MFAConstants::ERROR_CODE_SESSION_EXPIRED]); } /** @@ -1191,7 +1198,7 @@ public function getProfile() $lang2Code[] = $lang; } - return View::make("profile", [ + return View::make("profile", array_merge([ 'user' => json_encode(SerializerRegistry::getInstance()->getSerializer( $user, SerializerRegistry::SerializerType_Private)->serialize()), "openid_url" => $this->server_configuration_service->getUserIdentityEndpointURL($user->getIdentifier()), @@ -1200,10 +1207,7 @@ public function getProfile() 'countries' => CountryList::getCountries(), 'languages' => $lang2Code, 'two_factor_enabled' => $user->shouldRequire2FA(), - 'recovery_codes_remaining' => $this->recovery_code_service->countUnusedRecoveryCodes($user), - 'recovery_codes_total' => (int)config('auth.recovery_codes.count', 10), - 'recovery_codes_low_threshold' => (int)config('auth.recovery_codes.low_threshold', 3), - ]); + ], $this->recovery_code_service->getStatus($user)->toArray())); } public function deleteTrustedSite($id) diff --git a/app/Http/Middleware/TwoFactorRateLimitMiddleware.php b/app/Http/Middleware/TwoFactorRateLimitMiddleware.php index e197735b..26e8d7e9 100644 --- a/app/Http/Middleware/TwoFactorRateLimitMiddleware.php +++ b/app/Http/Middleware/TwoFactorRateLimitMiddleware.php @@ -13,6 +13,7 @@ **/ use App\Services\Auth\ITwoFactorRateLimitService; +use Auth\MFAConstants; use Closure; use Illuminate\Cache\RateLimiting\Unlimited; use Illuminate\Support\Facades\Log; @@ -41,8 +42,8 @@ final class TwoFactorRateLimitMiddleware * Response error_code values that count as a verification failure. */ private const FAILURE_CODES = [ - 'mfa_verification_failed', - 'mfa_invalid_recovery', + MFAConstants::ERROR_CODE_VERIFICATION_FAILED, + MFAConstants::ERROR_CODE_INVALID_RECOVERY, ]; public function __construct(private readonly ITwoFactorRateLimitService $rate_limit_service) diff --git a/app/Services/Auth/IRecoveryCodeService.php b/app/Services/Auth/IRecoveryCodeService.php index 51df966b..ab6de16e 100644 --- a/app/Services/Auth/IRecoveryCodeService.php +++ b/app/Services/Auth/IRecoveryCodeService.php @@ -61,4 +61,10 @@ public function enableTwoFactorAndGenerateCodes(User $user, string $method): arr * @return int count of unused recovery codes */ public function countUnusedRecoveryCodes(User $user): int; + + /** + * @param User $user + * @return RecoveryCodesStatus remaining/total/low-threshold standing for the user + */ + public function getStatus(User $user): RecoveryCodesStatus; } diff --git a/app/Services/Auth/ITwoFactorRateLimitService.php b/app/Services/Auth/ITwoFactorRateLimitService.php index 47f1c587..0f2a0da2 100644 --- a/app/Services/Auth/ITwoFactorRateLimitService.php +++ b/app/Services/Auth/ITwoFactorRateLimitService.php @@ -2,6 +2,8 @@ namespace App\Services\Auth; +use Auth\MFAConstants; + /** * Copyright 2026 OpenStack Foundation * Licensed under the Apache License, Version 2.0 (the "License"); @@ -38,14 +40,14 @@ interface ITwoFactorRateLimitService public const ActionResend = 'resend'; public const ActionOtp = 'otp'; - public const RATE_LIMIT_ERROR_CODE = 'mfa_rate_limit'; + public const RATE_LIMIT_ERROR_CODE = MFAConstants::ERROR_CODE_RATE_LIMIT; public const RATE_LIMIT_MESSAGE = 'Too many attempts. Please try again later.'; /** * Session key holding the user id of the pending MFA challenge - the * subject the verify/recovery/resend named limiters throttle by. */ - public const PENDING_USER_SESSION_KEY = '2fa_pending_user_id'; + public const PENDING_USER_SESSION_KEY = MFAConstants::SESSION_KEY_PENDING_USER_ID; /** * Prefix applied to the Action* constants when registering/looking up @@ -57,6 +59,14 @@ interface ITwoFactorRateLimitService */ public const RATE_LIMITER_NAME_PREFIX = '2fa-rate:'; + /** + * Prefix of the cache keys holding the per-subject attempt counters + * (and their companion ":timer" keys) - see cacheKey() in the + * implementation. Distinct from RATE_LIMITER_NAME_PREFIX (dash), which + * names the limiters, not the storage. + */ + public const RATE_LIMIT_CACHE_KEY_PREFIX = '2fa_rate:'; + /** * @param string $action one of self::ActionVerify|ActionRecovery|ActionResend|ActionOtp * @param string|int $subject a user id for session-keyed actions, or a raw diff --git a/app/Services/Auth/RecoveryCodeService.php b/app/Services/Auth/RecoveryCodeService.php index 1abaeb9c..a626dd82 100644 --- a/app/Services/Auth/RecoveryCodeService.php +++ b/app/Services/Auth/RecoveryCodeService.php @@ -156,4 +156,16 @@ public function countUnusedRecoveryCodes(User $user): int { return count($this->repository->getUnusedByUser($user)); } + + /** + * @inheritDoc + */ + public function getStatus(User $user): RecoveryCodesStatus + { + return new RecoveryCodesStatus( + $this->countUnusedRecoveryCodes($user), + (int) config('auth.recovery_codes.count', 10), + (int) config('auth.recovery_codes.low_threshold', 3) + ); + } } diff --git a/app/Services/Auth/RecoveryCodesStatus.php b/app/Services/Auth/RecoveryCodesStatus.php new file mode 100644 index 00000000..c99e9f2c --- /dev/null +++ b/app/Services/Auth/RecoveryCodesStatus.php @@ -0,0 +1,60 @@ +remaining; + } + + public function getTotal(): int + { + return $this->total; + } + + public function getLowThreshold(): int + { + return $this->low_threshold; + } + + /** + * @return array + */ + public function toArray(): array + { + return [ + 'recovery_codes_remaining' => $this->remaining, + 'recovery_codes_total' => $this->total, + 'recovery_codes_low_threshold' => $this->low_threshold, + ]; + } +} diff --git a/app/Services/Auth/TwoFactorRateLimitService.php b/app/Services/Auth/TwoFactorRateLimitService.php index 13967462..df461192 100644 --- a/app/Services/Auth/TwoFactorRateLimitService.php +++ b/app/Services/Auth/TwoFactorRateLimitService.php @@ -94,6 +94,6 @@ private function limitsFor(string $action): array */ private function cacheKey(string $action, string|int $subject): string { - return sprintf('2fa_rate:%s:%s', $action, $subject); + return sprintf('%s%s:%s', self::RATE_LIMIT_CACHE_KEY_PREFIX, $action, $subject); } } diff --git a/app/Strategies/ILoginStrategy.php b/app/Strategies/ILoginStrategy.php index 5894bc4d..7d895329 100644 --- a/app/Strategies/ILoginStrategy.php +++ b/app/Strategies/ILoginStrategy.php @@ -1,4 +1,7 @@ $user_id, - 'pending_at' => $pending_at, - 'remember' => Session::get(self::KEY_REMEMBER, false), - ]; + return new MFAPendingState( + (int) $user_id, + (int) $pending_at, + (bool) Session::get(self::KEY_REMEMBER, false) + ); } public function clearPendingState(): void diff --git a/app/Strategies/MFA/IMFAChallengeStrategy.php b/app/Strategies/MFA/IMFAChallengeStrategy.php index c395551d..ddc34dc7 100644 --- a/app/Strategies/MFA/IMFAChallengeStrategy.php +++ b/app/Strategies/MFA/IMFAChallengeStrategy.php @@ -8,7 +8,7 @@ interface IMFAChallengeStrategy public function issueChallenge(User $user, ?Client $client, bool $remember): array; public function verifyChallenge(User $user, string $code, ?Client $client = null): void; public function resendChallenge(User $user, ?Client $client, bool $remember): array; - public function getPendingState(): ?array; + public function getPendingState(): ?MFAPendingState; public function clearPendingState(): void; public function verifyRecoveryCode(User $user, string $code): void; } diff --git a/app/Strategies/MFA/MFAPendingState.php b/app/Strategies/MFA/MFAPendingState.php new file mode 100644 index 00000000..1ff41451 --- /dev/null +++ b/app/Strategies/MFA/MFAPendingState.php @@ -0,0 +1,49 @@ +user_id; + } + + /** + * Unix timestamp of when the challenge was issued. + */ + public function getPendingAt(): int + { + return $this->pending_at; + } + + /** + * Whether the original login submission asked for a remembered session. + */ + public function shouldRemember(): bool + { + return $this->remember; + } +} diff --git a/app/libs/Auth/AuthService.php b/app/libs/Auth/AuthService.php index 665956d7..f90af6cd 100644 --- a/app/libs/Auth/AuthService.php +++ b/app/libs/Auth/AuthService.php @@ -504,8 +504,16 @@ public function logout(bool $clear_security_ctx = true): void // Flush all session data and regenerate the session ID to ensure no stale // data survives (OAuth2 memento, OpenID auth context, authorization responses, etc.) + // The flush also wipes the session-backed security context, so when the + // caller asked to keep it (clear_security_ctx = false - the prompt=login + // re-authentication path, which needs the requested-user id to show the + // login hint on the login screen) it is captured first and re-saved + // after the session ID is regenerated. + $preserved_security_ctx = $clear_security_ctx ? null : $this->security_context_service->get(); Session::flush(); Session::regenerate(); + if (!is_null($preserved_security_ctx)) + $this->security_context_service->save($preserved_security_ctx); } public function invalidateSession(): void diff --git a/app/libs/Auth/MFAConstants.php b/app/libs/Auth/MFAConstants.php new file mode 100644 index 00000000..aeb3eee4 --- /dev/null +++ b/app/libs/Auth/MFAConstants.php @@ -0,0 +1,44 @@ +./tests/ ./tests/OpenTelemetry/ ./tests/TestCase.php + + ./tests/OAuth2ProtocolTestCase.php + ./tests/OIDCProtocolTestCase.php + ./tests/OIDCPasswordlessTestCase.php + ./tests/OpenIdProtocolTestCase.php ./tests/OpenTelemetry/ diff --git a/tests/OAuth2ProtocolTestCase.php b/tests/OAuth2ProtocolTestCase.php index d0a72b9f..2e9604c2 100644 --- a/tests/OAuth2ProtocolTestCase.php +++ b/tests/OAuth2ProtocolTestCase.php @@ -459,6 +459,13 @@ public function testResourceServerIntrospectionNotValidIP() { $access_token = $this->testValidateToken(); + // The resource-server IP check became opt-in in #98 + // (oauth2.validate_resource_server_ip, default off) - this test is + // about the rejection itself, so turn the flag on. Set AFTER + // testValidateToken(): that helper introspects from resource server 1, + // whose registered IPs do include the test-request IP. + Config::set('oauth2.validate_resource_server_ip', true); + $client_id = 'resource.server.2.openstack.client'; $client_secret = '123456789123456789123456789123456789123456789'; //do token validation .... diff --git a/tests/OIDCProtocolTestCase.php b/tests/OIDCProtocolTestCase.php index fd95a2c7..eb0e64e8 100644 --- a/tests/OIDCProtocolTestCase.php +++ b/tests/OIDCProtocolTestCase.php @@ -45,6 +45,9 @@ */ final class OIDCProtocolTestCase extends OpenStackIDBaseTestCase { + // Seeded login user's password (database/seeds/TestSeeder.php). + private const SEED_PASSWORD = '1Qaz2wsx!'; + /** * @var string */ @@ -55,6 +58,12 @@ protected function prepareForTests():void parent::prepareForTests(); App::singleton(UtilsServiceCatalog::ServerConfigurationService, StubServerConfigurationService::class); $this->current_realm = Config::get('app.url'); + // This class exercises the OIDC/OAuth2 protocol, not the MFA gate: the + // seeded login user belongs to SuperAdminGroup (enforced by default), + // and every password login leg here would otherwise stop at the 2FA + // challenge. The gate itself is covered by TwoFactorLoginFlowTest, + // including the full authorize -> MFA -> consent -> code circuit. + Config::set('two_factor.enforced_groups', []); Session::start(); } @@ -126,7 +135,7 @@ public function testLoginWithTrailingSpace() $response = $this->action('POST', "UserController@postLogin", [ 'username' => ' sebastian@tipit.net ', - 'password' => ' 1qaz2wsx ', + 'password' => ' ' . self::SEED_PASSWORD . ' ', '_token' => Session::token(), 'flow' => 'password', ] @@ -172,7 +181,7 @@ public function testConsentPrompt() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -259,7 +268,7 @@ public function testConsentLogin() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -341,7 +350,7 @@ public function testAuthCode() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -426,7 +435,7 @@ public function testAuthCodeIDN() array ( 'username' => 'hei@やる.ca', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -539,7 +548,7 @@ public function testAuthCodeOpenIdScopeOnly() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -623,7 +632,7 @@ public function testMaxAge1AndWait2() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -687,7 +696,7 @@ public function testToken array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -849,7 +858,7 @@ public function testTokenSeveralScopes array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -997,7 +1006,7 @@ public function testGetRefreshTokenWithPromptSetToConsentLogin() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -1140,7 +1149,7 @@ public function testFlowNativeDisplay() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => $json_response['required_params_valid_values']["_token"] ) @@ -1259,7 +1268,7 @@ public function testGetRefreshTokenFromNativeAppNTimes($n = 5) array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -1431,7 +1440,11 @@ public function testTokenResponseModePost() OAuth2Protocol::OfflineAccess_Scope), OAuth2Protocol::OAuth2Protocol_LoginHint => 'sebastian@tipit.net', OAuth2Protocol::OAuth2Protocol_Prompt => OAuth2Protocol::OAuth2Protocol_Prompt_Consent, - OAuth2Protocol::OAuth2Protocol_MaxAge => 1, + // 3200 like the sibling circuits: this 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 forces a re-login instead of the form post. + OAuth2Protocol::OAuth2Protocol_MaxAge => 3200, OAuth2Protocol::OAuth2Protocol_ResponseMode => OAuth2Protocol::OAuth2Protocol_ResponseMode_FormPost ); @@ -1457,7 +1470,7 @@ public function testTokenResponseModePost() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -1604,7 +1617,7 @@ public function testNativeClientBasicAuth() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -1743,7 +1756,7 @@ public function testClientAuthenticationClientSecretJwt() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -1922,7 +1935,7 @@ public function testClientAuthenticationPrivateKeyJwt() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -2074,7 +2087,7 @@ public function testImplicitFlowTokenIdToken() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -2155,7 +2168,7 @@ public function testImplicitFlowIdToken() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -2240,7 +2253,7 @@ public function testImplicitFlowIdTokenMaxAge1000() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -2350,7 +2363,7 @@ public function testImplicitFlowAccessToken() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -2487,7 +2500,7 @@ public function testImplicitFlowResponseModePost() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -2657,7 +2670,7 @@ public function testHybridFlowCodeIdToken() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -2764,7 +2777,7 @@ public function testHybridFlowCodeIdTokenIdTokenHint() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -2985,7 +2998,7 @@ public function testHybridFlowCodeAccessToken() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -3103,7 +3116,7 @@ public function testHybridFlowCodeAccessTokenIdToken() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) @@ -3210,7 +3223,7 @@ public function testTryingAuthCodeTwice() array ( 'username' => 'sebastian@tipit.net', - 'password' => '1qaz2wsx', + 'password' => self::SEED_PASSWORD, 'flow' => 'password', '_token' => Session::token() ) diff --git a/tests/TwoFactorLoginFlowTest.php b/tests/TwoFactorLoginFlowTest.php index cff0ff0f..aa0abd56 100644 --- a/tests/TwoFactorLoginFlowTest.php +++ b/tests/TwoFactorLoginFlowTest.php @@ -19,7 +19,9 @@ use App\Mail\OAuth2PasswordlessOTPMail; use App\Services\Auth\IDeviceTrustService; use App\Services\Auth\ITwoFactorAuditService; +use App\Services\Auth\ITwoFactorRateLimitService; use Auth\AuthHelper; +use Auth\MFAConstants; use Auth\User; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Auth; @@ -33,10 +35,15 @@ use Auth\Repositories\IUserRecoveryCodeRepository; use LaravelDoctrine\ORM\Facades\EntityManager; use Models\OAuth2\Client; +use OAuth2\OAuth2Protocol; +use OAuth2\Requests\OAuth2RequestMemento; +use OAuth2\Services\IMementoOAuth2SerializerService; use Services\OAuth2\PrincipalService; use Strategies\ILoginStrategy; use Strategies\MFA\IMFAChallengeStrategy; use Strategies\MFA\MFAChallengeStrategyFactory; +use Strategies\MFA\MFAPendingState; +use Illuminate\Support\Facades\URL; use Utils\Services\IAuthService; /** @@ -68,21 +75,23 @@ private function flushRateLimitCounters(): void $admin = EntityManager::getRepository(User::class)->getByEmailOrName(self::ADMIN_EMAIL); if ($admin) { $userId = $admin->getId(); + $prefix = ITwoFactorRateLimitService::RATE_LIMIT_CACHE_KEY_PREFIX; foreach (['verify', 'recovery', 'resend'] as $action) { - Cache::forget("2fa_rate:{$action}:{$userId}"); + Cache::forget("{$prefix}{$action}:{$userId}"); // RateLimiter::hit() also writes a companion ":timer" key holding // the window's reset timestamp - must be cleared too, or a stale // timer from an earlier test leaks into a later one for this // same fixed subject (self::ADMIN_EMAIL's user id). - Cache::forget("2fa_rate:{$action}:{$userId}:timer"); + Cache::forget("{$prefix}{$action}:{$userId}:timer"); } } // otp is keyed by the (lowercased) submitted email, not a user id - // clear every literal email this test class submits to that action. + $otpPrefix = ITwoFactorRateLimitService::RATE_LIMIT_CACHE_KEY_PREFIX . ITwoFactorRateLimitService::ActionOtp . ':'; foreach ([self::ADMIN_EMAIL, 'someone-else@example.com'] as $email) { - Cache::forget('2fa_rate:otp:' . strtolower($email)); - Cache::forget('2fa_rate:otp:' . strtolower($email) . ':timer'); + Cache::forget($otpPrefix . strtolower($email)); + Cache::forget($otpPrefix . strtolower($email) . ':timer'); } } @@ -338,7 +347,7 @@ public function testCancelClearsUIStateAndPendingChallenge(): void $response = $this->verify($code); $this->assertResponseStatus(401); $payload = json_decode($response->getContent(), true); - $this->assertSame('mfa_session_expired', $payload['error_code']); + $this->assertSame(MFAConstants::ERROR_CODE_SESSION_EXPIRED, $payload['error_code']); $this->assertFalse(Auth::check(), 'a cancelled challenge must never establish a session'); } @@ -426,7 +435,7 @@ public function testFailedOTPVerificationReturnsErrorAndIncrementsCounter(): voi $this->assertResponseStatus(401); $payload = json_decode($response->getContent(), true); - $this->assertSame('mfa_verification_failed', $payload['error_code']); + $this->assertSame(MFAConstants::ERROR_CODE_VERIFICATION_FAILED, $payload['error_code']); $this->assertFalse(Auth::check()); $this->assertSame(1, (int) Cache::get('2fa_rate:verify:' . $userId, 0), 'verify counter must increment on failure'); @@ -455,7 +464,7 @@ public function testOTPVerificationRejectsWrongCode(): void $this->assertResponseStatus(401); $payload = json_decode($response->getContent(), true); - $this->assertSame('mfa_verification_failed', $payload['error_code'], + $this->assertSame(MFAConstants::ERROR_CODE_VERIFICATION_FAILED, $payload['error_code'], 'verifyChallenge must load the stored OTP and reject a non-matching value'); $this->assertFalse(Auth::check()); } @@ -475,7 +484,7 @@ public function testOTPCodeRejectsReuseAfterSuccessfulVerification(): void $this->assertResponseStatus(401); $payload = json_decode($response->getContent(), true); - $this->assertSame('mfa_verification_failed', $payload['error_code'], + $this->assertSame(MFAConstants::ERROR_CODE_VERIFICATION_FAILED, $payload['error_code'], 'a reused OTP must be rejected because the redemption was committed by the AuthService transaction'); } @@ -501,7 +510,7 @@ public function testRecoveryCodeRejectsReuseAfterTransactionCommit(): void $this->assertResponseStatus(401); $payload = json_decode($response->getContent(), true); - $this->assertSame('mfa_invalid_recovery', $payload['error_code'], + $this->assertSame(MFAConstants::ERROR_CODE_INVALID_RECOVERY, $payload['error_code'], 'recovery code reuse must be rejected because used_at was committed via the AuthService transaction'); } @@ -538,7 +547,7 @@ public function resendChallenge(User $user, ?Client $client, bool $remember): ar return $this->inner->resendChallenge($user, $client, $remember); } - public function getPendingState(): ?array + public function getPendingState(): ?MFAPendingState { return $this->inner->getPendingState(); } @@ -683,7 +692,7 @@ public function testExpiredMFASessionFails(): void $this->assertResponseStatus(401); $payload = json_decode($response->getContent(), true); - $this->assertSame('mfa_session_expired', $payload['error_code']); + $this->assertSame(MFAConstants::ERROR_CODE_SESSION_EXPIRED, $payload['error_code']); } // ------------------------------------------------------------------------- @@ -831,7 +840,7 @@ public function testDeviceTrustFailureDoesNotBlockLogin(): void $this->assertEquals(200, $response->getStatusCode(), 'a best-effort device-trust failure must not fail the login'); $this->assertTrue(Auth::check(), 'session must be established despite the device-trust failure'); - $this->assertNull(Session::get('2fa_pending_user_id'), 'pending MFA state must be cleared even when device-trust enrollment fails'); + $this->assertNull(Session::get(MFAConstants::SESSION_KEY_PENDING_USER_ID), 'pending MFA state must be cleared even when device-trust enrollment fails'); } // ------------------------------------------------------------------------- @@ -852,6 +861,13 @@ public function testRecoveryCodeLoginSucceeds(): void $this->assertIsString($payload['redirect_url'] ?? null); $this->assertTrue(Auth::check()); + // Wire contract consumed by login.js's low-recovery-codes warning + // (RecoveryCodesStatus::toArray()) - expected values come from config, + // not from re-deriving the service's own math. + $this->assertIsInt($payload['recovery_codes_remaining'] ?? null); + $this->assertSame((int) Config::get('auth.recovery_codes.count'), $payload['recovery_codes_total'] ?? null); + $this->assertSame((int) Config::get('auth.recovery_codes.low_threshold'), $payload['recovery_codes_low_threshold'] ?? null); + EntityManager::clear(); $code = EntityManager::find(UserRecoveryCode::class, $codeId); $this->assertTrue($code->isUsed(), 'the recovery code must be marked used'); @@ -869,10 +885,458 @@ public function testUsedRecoveryCodeFails(): void $this->assertResponseStatus(401); $payload = json_decode($response->getContent(), true); - $this->assertSame('mfa_invalid_recovery', $payload['error_code']); + $this->assertSame(MFAConstants::ERROR_CODE_INVALID_RECOVERY, $payload['error_code']); + $this->assertFalse(Auth::check()); + } + + public function testRecoveryWithStaleOAuth2ClientFailsBeforeBurningCode(): void + { + $admin = $this->user(self::ADMIN_EMAIL); + $plain = 'RECOVERYSTALE789'; + $codeId = $this->createRecoveryCode($admin, $plain, false); + + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + // A pending OAuth2 authorization request whose client no longer exists + // (e.g. deleted mid-login). verify2FA() fails this via + // resolveClientFromMemento() BEFORE redeeming the OTP; recovery must + // apply the same guard instead of burning the single-use code and + // establishing a session for a doomed authorization request. + App::make(IMementoOAuth2SerializerService::class)->serialize( + OAuth2RequestMemento::buildFromState([ + OAuth2Protocol::OAuth2Protocol_ResponseType => OAuth2Protocol::OAuth2Protocol_ResponseType_Code, + OAuth2Protocol::OAuth2Protocol_ClientId => 'stale-client-' . uniqid(), + OAuth2Protocol::OAuth2Protocol_RedirectUri => 'https://client.invalid/callback', + ]) + ); + + $response = $this->recovery($plain); + + $this->assertResponseStatus(412); + $this->assertFalse(Auth::check(), 'no session must be established when the pending OAuth2 client cannot be resolved'); + + EntityManager::clear(); + $code = EntityManager::find(UserRecoveryCode::class, $codeId); + $this->assertFalse($code->isUsed(), 'the recovery code must NOT be burned when the pending OAuth2 request points at a non-existent client'); + } + + // ------------------------------------------------------------------------- + // branch coverage: validator 412s, vanished pending user, expired session, + // stale OAuth2 client on verify2FA, audit failure on the FAILED-verify path + // ------------------------------------------------------------------------- + + public function testVerifyValidatorRejectsMalformedRequest(): void + { + $response = $this->action('POST', 'UserController@verify2FA', [ + 'method' => 'bogus-method', + '_token' => Session::token(), + ]); + + $this->assertResponseStatus(412); + $this->assertFalse(Auth::check()); + } + + public function testRecoveryValidatorRejectsMalformedRequest(): void + { + $response = $this->action('POST', 'UserController@verify2FARecovery', [ + '_token' => Session::token(), + ]); + + $this->assertResponseStatus(412); + $this->assertFalse(Auth::check()); + } + + public function testVerifyWithVanishedPendingUserFailsAsExpiredSession(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + // The pending user disappeared between challenge and verification + // (e.g. deleted account) - must clear the pending state, not 500. + Session::put(MFAConstants::SESSION_KEY_PENDING_USER_ID, PHP_INT_MAX); + + $response = $this->verify('123456'); + + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame(MFAConstants::ERROR_CODE_SESSION_EXPIRED, $payload['error_code']); + $this->assertFalse(Auth::check()); + $this->assertNull(Session::get(MFAConstants::SESSION_KEY_PENDING_USER_ID), 'the orphaned pending state must be cleared'); + } + + public function testRecoveryWithoutPendingChallengeFailsAsExpiredSession(): void + { + // No prior postLogin -> no pending state. + $response = $this->recovery('ANYCODE123'); + + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame(MFAConstants::ERROR_CODE_SESSION_EXPIRED, $payload['error_code']); + $this->assertFalse(Auth::check()); + } + + public function testRecoveryWithVanishedPendingUserFailsAsExpiredSession(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + Session::put(MFAConstants::SESSION_KEY_PENDING_USER_ID, PHP_INT_MAX); + + $response = $this->recovery('ANYCODE123'); + + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame(MFAConstants::ERROR_CODE_SESSION_EXPIRED, $payload['error_code']); + $this->assertFalse(Auth::check()); + $this->assertNull(Session::get(MFAConstants::SESSION_KEY_PENDING_USER_ID), 'the orphaned pending state must be cleared'); + } + + public function testVerifyWithStaleOAuth2ClientFailsBeforeRedeemingOTP(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + // Same guard already proven for recovery: a pending OAuth2 request + // whose client no longer exists must fail BEFORE the OTP is redeemed. + App::make(IMementoOAuth2SerializerService::class)->serialize( + OAuth2RequestMemento::buildFromState([ + OAuth2Protocol::OAuth2Protocol_ResponseType => OAuth2Protocol::OAuth2Protocol_ResponseType_Code, + OAuth2Protocol::OAuth2Protocol_ClientId => 'stale-client-' . uniqid(), + OAuth2Protocol::OAuth2Protocol_RedirectUri => 'https://client.invalid/callback', + ]) + ); + + $response = $this->verify($code); + + $this->assertResponseStatus(412); + $this->assertFalse(Auth::check(), 'no session must be established when the pending OAuth2 client cannot be resolved'); + + /** @var IOAuth2OTPRepository $otpRepo */ + $otpRepo = App::make(IOAuth2OTPRepository::class); + EntityManager::clear(); + $otp = $otpRepo->getByValue($code); + $this->assertNotNull($otp); + $this->assertFalse($otp->isRedeemed(), 'the OTP must NOT be redeemed when the pending OAuth2 request points at a non-existent client'); + } + + public function testFailedVerifyAuditFailureStillReturnsClean401(): void + { + // Audit is best-effort on the FAILED path too: a failure emitting + // challenge_failed must not turn the clean 401 (whose error_code the + // rate-limit middleware keys on) into a 500. + $auditMock = \Mockery::mock(ITwoFactorAuditService::class); + $auditMock->shouldReceive('log') + ->andReturnUsing(function (User $user, string $eventType) { + if ($eventType === TwoFactorAuditLog::EventChallengeFailed) { + throw new \Exception('audit sink unavailable'); + } + }); + $this->app->instance(ITwoFactorAuditService::class, $auditMock); + + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + $response = $this->verify('000000'); + + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame(MFAConstants::ERROR_CODE_VERIFICATION_FAILED, $payload['error_code']); + $this->assertFalse(Auth::check()); + } + + public function testFailedRecoveryAuditFailureStillReturnsClean401(): void + { + $auditMock = \Mockery::mock(ITwoFactorAuditService::class); + $auditMock->shouldReceive('log') + ->andReturnUsing(function (User $user, string $eventType) { + if ($eventType === TwoFactorAuditLog::EventChallengeFailed) { + throw new \Exception('audit sink unavailable'); + } + }); + $this->app->instance(ITwoFactorAuditService::class, $auditMock); + + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + $response = $this->recovery('WRONGCODE999'); + + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame(MFAConstants::ERROR_CODE_INVALID_RECOVERY, $payload['error_code']); $this->assertFalse(Auth::check()); } + // ------------------------------------------------------------------------- + // full OIDC circuit: authorize -> login -> MFA -> consent -> auth code + // ------------------------------------------------------------------------- + + private const OIDC_CLIENT_ID = '.-_~87D8/Vcvr6fvQbH4HyNgwTlfSyQ3x.openstack.client'; + private const OIDC_REDIRECT_URI = 'https://www.test.com/oauth2'; + + public function testFullOIDCFlowWithMFAChallengeAndConsentDeliversAuthCode(): void + { + $this->startOIDCFlowUpToChallenge(); + + $response = $this->verify($this->latestOtpCode(self::ADMIN_EMAIL)); + + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertTrue(Auth::check(), 'second factor verified - session must be established'); + $this->assertSame( + URL::action('OAuth2\OAuth2ProviderController@auth'), + $payload['redirect_url'] ?? null, + 'the XHR must be told to navigate back to the authorization endpoint so the pending OIDC request resumes' + ); + + $this->completeConsentAndGetAuthCode(); + } + + public function testFullOIDCFlowWithRecoveryCodeAndConsentDeliversAuthCode(): void + { + $admin = $this->user(self::ADMIN_EMAIL); + $plain = 'RECOVERYOIDC321'; + $this->createRecoveryCode($admin, $plain, false); + + $this->startOIDCFlowUpToChallenge(); + + $response = $this->recovery($plain); + + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertTrue(Auth::check(), 'recovery code verified - session must be established'); + $this->assertSame( + URL::action('OAuth2\OAuth2ProviderController@auth'), + $payload['redirect_url'] ?? null, + 'the XHR must be told to navigate back to the authorization endpoint so the pending OIDC request resumes' + ); + + $this->completeConsentAndGetAuthCode(); + } + + public function testOIDCFlowWrongRecoveryCodeThenCorrectCompletesCircuit(): void + { + $admin = $this->user(self::ADMIN_EMAIL); + $plain = 'RECOVERYRETRY111'; + $this->createRecoveryCode($admin, $plain, false); + + $this->startOIDCFlowUpToChallenge(); + + // Wrong code: rejected without killing the pending challenge or the + // OAuth2 memento - the user must be able to retry within the same flow. + $response = $this->recovery('WRONGCODE000'); + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame(MFAConstants::ERROR_CODE_INVALID_RECOVERY, $payload['error_code']); + $this->assertFalse(Auth::check(), 'a rejected recovery code must not establish a session'); + + // Correct code on the retry: the same OIDC flow completes end to end. + $response = $this->recovery($plain); + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertTrue(Auth::check()); + $this->assertSame(URL::action('OAuth2\OAuth2ProviderController@auth'), $payload['redirect_url'] ?? null); + + $this->completeConsentAndGetAuthCode(); + } + + public function testOIDCFlowConsecutiveWrongRecoveryCodesHitRateLimit(): void + { + $admin = $this->user(self::ADMIN_EMAIL); + $plain = 'RECOVERYLIMIT222'; + $this->createRecoveryCode($admin, $plain, false); + + $this->startOIDCFlowUpToChallenge(); + + $max = (int) Config::get('two_factor.rate_limit.max_attempts'); + for ($i = 0; $i < $max; $i++) { + $response = $this->recovery('WRONGCODE' . $i); + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame(MFAConstants::ERROR_CODE_INVALID_RECOVERY, $payload['error_code']); + $this->assertFalse(Auth::check()); + } + + // Threshold reached: even the CORRECT code is rejected while the + // window lasts, still without a session - brute-forcing recovery codes + // inside a pending OIDC flow cannot buy extra attempts. + $response = $this->recovery($plain); + $this->assertResponseStatus(429); + $payload = json_decode($response->getContent(), true); + $this->assertSame(MFAConstants::ERROR_CODE_RATE_LIMIT, $payload['error_code']); + $this->assertFalse(Auth::check(), 'a rate-limited attempt must not establish a session even with a valid code'); + } + + public function testOIDCFlowBurnedRecoveryCodeFailsThenFreshCodeCompletesCircuit(): void + { + $admin = $this->user(self::ADMIN_EMAIL); + $burned = 'RECOVERYBURNED33'; + $fresh = 'RECOVERYFRESH444'; + $this->createRecoveryCode($admin, $burned, true); // already used + $freshId = $this->createRecoveryCode($admin, $fresh, false); + + $this->startOIDCFlowUpToChallenge(); + + // A burned (single-use, already redeemed) code is rejected like any + // other invalid code. + $response = $this->recovery($burned); + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame(MFAConstants::ERROR_CODE_INVALID_RECOVERY, $payload['error_code']); + $this->assertFalse(Auth::check(), 'a burned recovery code must not establish a session'); + + // A fresh code still completes the same OIDC flow afterwards. + $response = $this->recovery($fresh); + $this->assertResponseStatus(200); + $this->assertTrue(Auth::check()); + + EntityManager::clear(); + $code = EntityManager::find(UserRecoveryCode::class, $freshId); + $this->assertTrue($code->isUsed(), 'the fresh recovery code must be marked used'); + + $this->completeConsentAndGetAuthCode(); + } + + public function testOIDCFlowWrongOTPThenCorrectCompletesCircuit(): void + { + $this->startOIDCFlowUpToChallenge(); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + // Wrong OTP: rejected without killing the pending challenge or the + // OAuth2 memento - the user must be able to retry within the same flow. + $response = $this->verify('000000'); + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame(MFAConstants::ERROR_CODE_VERIFICATION_FAILED, $payload['error_code']); + $this->assertFalse(Auth::check(), 'a rejected OTP must not establish a session'); + + // Correct OTP on the retry: the same OIDC flow completes end to end. + $response = $this->verify($code); + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertTrue(Auth::check()); + $this->assertSame(URL::action('OAuth2\OAuth2ProviderController@auth'), $payload['redirect_url'] ?? null); + + $this->completeConsentAndGetAuthCode(); + } + + public function testOIDCFlowConsecutiveWrongOTPsHitRateLimit(): void + { + $this->startOIDCFlowUpToChallenge(); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + $max = (int) Config::get('two_factor.rate_limit.max_attempts'); + for ($i = 0; $i < $max; $i++) { + $response = $this->verify('00000' . $i); + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame(MFAConstants::ERROR_CODE_VERIFICATION_FAILED, $payload['error_code']); + $this->assertFalse(Auth::check()); + } + + // Threshold reached: even the CORRECT code is rejected while the + // window lasts, still without a session - brute-forcing the OTP inside + // a pending OIDC flow cannot buy extra attempts. + $response = $this->verify($code); + $this->assertResponseStatus(429); + $payload = json_decode($response->getContent(), true); + $this->assertSame(MFAConstants::ERROR_CODE_RATE_LIMIT, $payload['error_code']); + $this->assertFalse(Auth::check(), 'a rate-limited attempt must not establish a session even with a valid code'); + } + + public function testOIDCFlowRedeemedOTPFailsThenResendCompletesCircuit(): void + { + $this->startOIDCFlowUpToChallenge(); + $burned = $this->latestOtpCode(self::ADMIN_EMAIL); + + // Burn the issued OTP directly (single-use, already redeemed) - the + // OTP analog of an already-used recovery code. + /** @var IOAuth2OTPRepository $otpRepo */ + $otpRepo = App::make(IOAuth2OTPRepository::class); + $otp = $otpRepo->getByValue($burned); + $this->assertNotNull($otp); + $otp->redeem(); + EntityManager::persist($otp); + EntityManager::flush(); + + $response = $this->verify($burned); + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame(MFAConstants::ERROR_CODE_VERIFICATION_FAILED, $payload['error_code']); + $this->assertFalse(Auth::check(), 'a redeemed OTP must not establish a session'); + + // Resend issues a fresh code; the same OIDC flow completes with it. + $this->resend(); + $this->assertResponseStatus(200); + $fresh = $this->latestOtpCode(self::ADMIN_EMAIL); + $this->assertNotSame($burned, $fresh); + + $response = $this->verify($fresh); + $this->assertResponseStatus(200); + $this->assertTrue(Auth::check()); + + $this->completeConsentAndGetAuthCode(); + } + + /** + * Starts an OIDC authorization-code request and walks it up to the MFA + * challenge: authorize -> redirected to login -> password accepted -> + * challenge issued, no session yet. The OAuth2 memento is serialized by + * the authorize endpoint, so the whole login leg runs under the + * OAuth2LoginStrategy, client-scoped OTP included. + */ + private function startOIDCFlowUpToChallenge(): void + { + $response = $this->action('POST', 'OAuth2\OAuth2ProviderController@auth', [ + 'client_id' => self::OIDC_CLIENT_ID, + 'redirect_uri' => self::OIDC_REDIRECT_URI, + 'response_type' => 'code', + 'scope' => 'openid profile email', + ]); + $this->assertResponseStatus(302); + $this->assertTrue( + str_contains($response->getTargetUrl(), '/login'), + 'an unauthenticated OIDC request must bounce to the login screen' + ); + + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $this->assertResponseStatus(302); + $this->assertFalse(Auth::check(), 'password alone must not establish a session while MFA is pending'); + } + + /** + * Walks the consent leg (first authorization for this client, so consent is + * required) and asserts the authorization code is delivered to the client's + * redirect_uri. + */ + private function completeConsentAndGetAuthCode(): void + { + // The top-level navigation the SPA performs with redirect_url: the auth + // endpoint rebuilds the authorization request from the session memento. + $response = $this->action('GET', 'OAuth2\OAuth2ProviderController@auth'); + $this->assertResponseStatus(302); + $this->assertSame( + URL::action('UserController@getConsent'), + $response->getTargetUrl(), + 'first authorization for this client must land on the consent screen' + ); + + $this->action('GET', 'UserController@getConsent'); + $this->assertResponseStatus(200); + + $this->action('POST', 'UserController@postConsent', [ + 'trust' => IAuthService::AuthorizationResponse_AllowOnce, + '_token' => Session::token(), + ]); + $this->assertResponseStatus(302); + + $response = $this->action('GET', 'OAuth2\OAuth2ProviderController@auth'); + $this->assertResponseStatus(302); + + $url = $response->getTargetUrl(); + $this->assertTrue( + str_starts_with($url, self::OIDC_REDIRECT_URI), + "the final hop must deliver to the client redirect_uri, got: {$url}" + ); + parse_str(parse_url($url, PHP_URL_QUERY) ?? '', $query); + $this->assertNotEmpty($query['code'] ?? null, 'an authorization code must be delivered to the client'); + } + // ------------------------------------------------------------------------- // resend // ------------------------------------------------------------------------- @@ -905,7 +1369,7 @@ public function testVerifyRateLimitBlocksAfterThreshold(): void $response = $this->verify('bad-code-final'); $this->assertResponseStatus(429); $payload = json_decode($response->getContent(), true); - $this->assertSame('mfa_rate_limit', $payload['error_code']); + $this->assertSame(MFAConstants::ERROR_CODE_RATE_LIMIT, $payload['error_code']); $this->assertSame((string) $max, $response->headers->get('X-RateLimit-Limit')); $this->assertSame('0', $response->headers->get('X-RateLimit-Remaining')); $this->assertGreaterThan(0, (int) $response->headers->get('Retry-After')); @@ -923,7 +1387,7 @@ public function testRecoveryRateLimitBlocksAfterThreshold(): void $response = $this->recovery('bad-recovery-final'); $this->assertResponseStatus(429); $payload = json_decode($response->getContent(), true); - $this->assertSame('mfa_rate_limit', $payload['error_code']); + $this->assertSame(MFAConstants::ERROR_CODE_RATE_LIMIT, $payload['error_code']); $this->assertSame((string) $max, $response->headers->get('X-RateLimit-Limit')); $this->assertSame('0', $response->headers->get('X-RateLimit-Remaining')); $this->assertGreaterThan(0, (int) $response->headers->get('Retry-After')); @@ -941,7 +1405,7 @@ public function testResendRateLimitBlocksAfterThreshold(): void $response = $this->resend(); $this->assertResponseStatus(429); $payload = json_decode($response->getContent(), true); - $this->assertSame('mfa_rate_limit', $payload['error_code']); + $this->assertSame(MFAConstants::ERROR_CODE_RATE_LIMIT, $payload['error_code']); $this->assertSame((string) $max, $response->headers->get('X-RateLimit-Limit')); $this->assertSame('0', $response->headers->get('X-RateLimit-Remaining')); $this->assertGreaterThan(0, (int) $response->headers->get('Retry-After')); @@ -979,7 +1443,7 @@ public function testOtpEmailRateLimitBlocksAfterThreshold(): void $response = $this->emitOTP(self::ADMIN_EMAIL); $this->assertResponseStatus(429); $payload = json_decode($response->getContent(), true); - $this->assertSame('mfa_rate_limit', $payload['error_code']); + $this->assertSame(MFAConstants::ERROR_CODE_RATE_LIMIT, $payload['error_code']); // A 429 must give the client a standard, machine-readable retry signal - // without these, callers have no way to know how long to back off. @@ -1013,7 +1477,7 @@ public function testOtpEmailRateLimitIsCaseInsensitive(): void $response = $this->emitOTP('SEBASTIAN@TIPIT.NET'); $this->assertResponseStatus(429); $payload = json_decode($response->getContent(), true); - $this->assertSame('mfa_rate_limit', $payload['error_code']); + $this->assertSame(MFAConstants::ERROR_CODE_RATE_LIMIT, $payload['error_code']); } // ------------------------------------------------------------------------- diff --git a/tests/unit/AuthServiceLogoutTest.php b/tests/unit/AuthServiceLogoutTest.php new file mode 100644 index 00000000..25366224 --- /dev/null +++ b/tests/unit/AuthServiceLogoutTest.php @@ -0,0 +1,79 @@ +save( + (new SecurityContext) + ->setRequestedUserId(self::REQUESTED_USER_ID) + ->setAuthTimeRequired(true) + ); + } + + public function testLogoutPreservingSecurityContext_survivesSessionFlush(): void + { + $this->saveSecurityContext(); + Session::put('unrelated_key', 'value'); + + App::make(IAuthService::class)->logout(false); + + $ctx = App::make(ISecurityContextService::class)->get(); + $this->assertSame( + self::REQUESTED_USER_ID, + $ctx->getRequestedUserId(), + 'logout(clear_security_ctx: false) must preserve the security context across the session flush' + ); + $this->assertTrue($ctx->isAuthTimeRequired()); + // The flush hardening itself must still hold for everything else. + $this->assertNull(Session::get('unrelated_key'), 'all other session data must still be flushed on logout'); + } + + public function testLogoutClearingSecurityContext_removesIt(): void + { + $this->saveSecurityContext(); + + App::make(IAuthService::class)->logout(true); + + $ctx = App::make(ISecurityContextService::class)->get(); + $this->assertNull($ctx->getRequestedUserId(), 'logout(clear_security_ctx: true) must clear the security context'); + } +} diff --git a/tests/unit/MFA/AbstractMFAChallengeStrategyTest.php b/tests/unit/MFA/AbstractMFAChallengeStrategyTest.php index afa2432b..f9340fac 100644 --- a/tests/unit/MFA/AbstractMFAChallengeStrategyTest.php +++ b/tests/unit/MFA/AbstractMFAChallengeStrategyTest.php @@ -14,6 +14,7 @@ **/ use Auth\Exceptions\AuthenticationException; +use Auth\MFAConstants; use Auth\Repositories\IUserRecoveryCodeRepository; use Auth\User; use Illuminate\Support\Facades\Hash; @@ -53,21 +54,21 @@ public function testGetPendingState_withValidSession_returnsState(): void $state = $this->strategy->getPendingState(); $this->assertNotNull($state); - $this->assertSame(42, $state['user_id']); - $this->assertTrue($state['remember']); - $this->assertArrayHasKey('pending_at', $state); + $this->assertSame(42, $state->getUserId()); + $this->assertTrue($state->shouldRemember()); + $this->assertGreaterThan(0, $state->getPendingAt()); } public function testGetPendingState_withExpiredSession_returnsNull(): void { - Session::put('2fa_pending_user_id', 99); - Session::put('2fa_pending_at', time() - 301); - Session::put('2fa_remember', false); + Session::put(MFAConstants::SESSION_KEY_PENDING_USER_ID, 99); + Session::put(MFAConstants::SESSION_KEY_PENDING_AT, time() - 301); + Session::put(MFAConstants::SESSION_KEY_REMEMBER, false); $state = $this->strategy->getPendingState(); $this->assertNull($state); - $this->assertNull(Session::get('2fa_pending_user_id')); + $this->assertNull(Session::get(MFAConstants::SESSION_KEY_PENDING_USER_ID)); } public function testGetPendingState_withMissingSession_returnsNull(): void @@ -79,17 +80,17 @@ public function testGetPendingState_withMissingSession_returnsNull(): void public function testClearPendingState_removesAllSessionKeys(): void { - Session::put('2fa_pending_user_id', 7); - Session::put('2fa_pending_at', time()); - Session::put('2fa_remember', true); - Session::put('2fa_recovery_attempts', 1); + Session::put(MFAConstants::SESSION_KEY_PENDING_USER_ID, 7); + Session::put(MFAConstants::SESSION_KEY_PENDING_AT, time()); + Session::put(MFAConstants::SESSION_KEY_REMEMBER, true); + Session::put(MFAConstants::SESSION_KEY_RECOVERY_ATTEMPTS, 1); $this->strategy->clearPendingState(); - $this->assertNull(Session::get('2fa_pending_user_id')); - $this->assertNull(Session::get('2fa_pending_at')); - $this->assertNull(Session::get('2fa_remember')); - $this->assertNull(Session::get('2fa_recovery_attempts')); + $this->assertNull(Session::get(MFAConstants::SESSION_KEY_PENDING_USER_ID)); + $this->assertNull(Session::get(MFAConstants::SESSION_KEY_PENDING_AT)); + $this->assertNull(Session::get(MFAConstants::SESSION_KEY_REMEMBER)); + $this->assertNull(Session::get(MFAConstants::SESSION_KEY_RECOVERY_ATTEMPTS)); } public function testVerifyRecoveryCode_withMatchingCode_marksAsUsed(): void diff --git a/tests/unit/MFA/EmailOTPMFAChallengeStrategyTest.php b/tests/unit/MFA/EmailOTPMFAChallengeStrategyTest.php index 3fefe025..bfbd664a 100644 --- a/tests/unit/MFA/EmailOTPMFAChallengeStrategyTest.php +++ b/tests/unit/MFA/EmailOTPMFAChallengeStrategyTest.php @@ -13,6 +13,7 @@ * limitations under the License. **/ +use Auth\MFAConstants; use App\libs\OAuth2\Repositories\IOAuth2OTPRepository; use Auth\Repositories\IUserRecoveryCodeRepository; use Auth\User; @@ -87,8 +88,8 @@ public function testIssueChallenge_storesPendingStateAndReturnsOtpInfo(): void ['otp_length' => 6, 'otp_lifetime' => 120, 'otp_issued_at' => $issuedAt->getTimestamp()], $result ); - $this->assertSame(42, Session::get('2fa_pending_user_id')); - $this->assertTrue(Session::get('2fa_remember')); + $this->assertSame(42, Session::get(MFAConstants::SESSION_KEY_PENDING_USER_ID)); + $this->assertTrue(Session::get(MFAConstants::SESSION_KEY_REMEMBER)); } // ---------- resendChallenge ---------- @@ -115,7 +116,7 @@ public function testResendChallenge_delegatesToIssueChallenge(): void ['otp_length' => 6, 'otp_lifetime' => 120, 'otp_issued_at' => $issuedAt->getTimestamp()], $result ); - $this->assertSame(7, Session::get('2fa_pending_user_id')); + $this->assertSame(7, Session::get(MFAConstants::SESSION_KEY_PENDING_USER_ID)); } // ---------- verifyChallenge ----------