Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 29 additions & 25 deletions app/Http/Controllers/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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();

Expand All @@ -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());
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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]);
}

/**
Expand Down Expand Up @@ -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()),
Expand All @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions app/Http/Middleware/TwoFactorRateLimitMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
**/

use App\Services\Auth\ITwoFactorRateLimitService;
use Auth\MFAConstants;
use Closure;
use Illuminate\Cache\RateLimiting\Unlimited;
use Illuminate\Support\Facades\Log;
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions app/Services/Auth/IRecoveryCodeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
14 changes: 12 additions & 2 deletions app/Services/Auth/ITwoFactorRateLimitService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions app/Services/Auth/RecoveryCodeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
}
60 changes: 60 additions & 0 deletions app/Services/Auth/RecoveryCodesStatus.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php
namespace App\Services\Auth;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

/**
* Immutable snapshot of a user's recovery-codes standing, built by
* IRecoveryCodeService::getStatus(). toArray() owns the wire keys consumed by
* the login SPA (resources/js/login/login.js) and the profile page
* (resources/views/profile.blade.php) - CU-86ba2zp66 / sds/idp-mfa.md §4.10.3,
* §4.11 step 5: the UI must be able to warn the user when they've burned into
* their last few recovery codes, since those may be their only way back in.
*
* @package App\Services\Auth
*/
final class RecoveryCodesStatus
{
public function __construct(
private readonly int $remaining,
private readonly int $total,
private readonly int $low_threshold,
) {}

public function getRemaining(): int
{
return $this->remaining;
}

public function getTotal(): int
{
return $this->total;
}

public function getLowThreshold(): int
{
return $this->low_threshold;
}

/**
* @return array<string,int>
*/
public function toArray(): array
{
return [
'recovery_codes_remaining' => $this->remaining,
'recovery_codes_total' => $this->total,
'recovery_codes_low_threshold' => $this->low_threshold,
];
}
}
2 changes: 1 addition & 1 deletion app/Services/Auth/TwoFactorRateLimitService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
5 changes: 4 additions & 1 deletion app/Strategies/ILoginStrategy.php
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<?php namespace Strategies;

use Auth\MFAConstants;

/**
* Interface ILoginStrategy
* @package Strategies
Expand All @@ -9,7 +12,7 @@ interface ILoginStrategy
* error_code returned by challengeRequired() when factor 1 passed but a
* 2FA challenge is pending.
*/
const MFA_REQUIRED = 'mfa_required';
const MFA_REQUIRED = MFAConstants::ERROR_CODE_MFA_REQUIRED;

/**
* @return mixed
Expand Down
21 changes: 11 additions & 10 deletions app/Strategies/MFA/AbstractMFAChallengeStrategy.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php namespace Strategies\MFA;

use Auth\Exceptions\AuthenticationException;
use Auth\MFAConstants;
use Auth\Repositories\IUserRecoveryCodeRepository;
use Auth\User;
use Illuminate\Support\Facades\Hash;
Expand All @@ -10,14 +11,14 @@
abstract class AbstractMFAChallengeStrategy implements IMFAChallengeStrategy
{
private const SESSION_TTL = 300;
private const KEY_USER_ID = '2fa_pending_user_id';
private const KEY_PENDING_AT = '2fa_pending_at';
private const KEY_REMEMBER = '2fa_remember';
private const KEY_RECOVERY_ATTEMPTS = '2fa_recovery_attempts';
private const KEY_USER_ID = MFAConstants::SESSION_KEY_PENDING_USER_ID;
private const KEY_PENDING_AT = MFAConstants::SESSION_KEY_PENDING_AT;
private const KEY_REMEMBER = MFAConstants::SESSION_KEY_REMEMBER;
private const KEY_RECOVERY_ATTEMPTS = MFAConstants::SESSION_KEY_RECOVERY_ATTEMPTS;

public function __construct(protected IUserRecoveryCodeRepository $recovery_code_repository) {}

public function getPendingState(): ?array
public function getPendingState(): ?MFAPendingState
{
$user_id = Session::get(self::KEY_USER_ID);
$pending_at = Session::get(self::KEY_PENDING_AT);
Expand All @@ -31,11 +32,11 @@ public function getPendingState(): ?array
return null;
}

return [
'user_id' => $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
Expand Down
2 changes: 1 addition & 1 deletion app/Strategies/MFA/IMFAChallengeStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading