diff --git a/app/Http/Controllers/Traits/MFACookieManager.php b/app/Http/Controllers/Traits/MFACookieManager.php
new file mode 100644
index 00000000..086553d5
--- /dev/null
+++ b/app/Http/Controllers/Traits/MFACookieManager.php
@@ -0,0 +1,88 @@
+device_trust_service.
+ *
+ * @package App\Http\Controllers\Traits
+ */
+trait MFACookieManager
+{
+ /**
+ * Reads the raw trusted-device token from the request cookie.
+ *
+ * @return string|null
+ */
+ protected function getCookieToken(): ?string
+ {
+ return Request::cookie(Config::get('two_factor.cookie_name', 'device_trust_token'));
+ }
+
+ /**
+ * Persists a trusted-device record (via IDeviceTrustService) and queues a
+ * secure, HttpOnly cookie carrying the raw token for the configured lifetime.
+ *
+ * @param User $user
+ * @return void
+ */
+ protected function queueDeviceTrustCookie(User $user): void
+ {
+ $rawToken = $this->device_trust_service->trustDevice
+ (
+ $user,
+ Request::header('User-Agent') ?? '',
+ IPHelper::getUserIp()
+ );
+
+ $name = Config::get('two_factor.cookie_name', 'device_trust_token');
+ $lifetimeMinutes = intval(Config::get('two_factor.device_trust_lifetime_days', 30)) * 24 * 60;
+ $path = Config::get('session.path');
+ $domain = Config::get('session.domain');
+ $secure = true;
+ $httpOnly = true;
+ $raw = false;
+ $sameSite = 'lax';
+
+ // Same order as \Illuminate\Cookie\CookieJar::make()
+ Cookie::queue
+ (
+ $name,
+ $rawToken, // value
+ $lifetimeMinutes,
+ $path,
+ $domain,
+ $secure,
+ $httpOnly,
+ $raw,
+ $sameSite
+
+ );
+ }
+}
diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php
index 5cb684df..6b1cf3dc 100644
--- a/app/Http/Controllers/UserController.php
+++ b/app/Http/Controllers/UserController.php
@@ -16,7 +16,17 @@
use App\Jobs\RevokeUserGrantsOnExplicitLogout;
use App\Http\Controllers\OpenId\OpenIdController;
use App\Http\Controllers\Traits\JsonResponses;
+use App\Http\Controllers\Traits\MFACookieManager;
use App\Http\Utils\CountryList;
+use App\libs\Auth\Models\TwoFactorAuditLog;
+use App\Services\Auth\IDeviceTrustService;
+use App\Services\Auth\ITwoFactorAuditService;
+use App\Services\Auth\ITwoFactorGateService;
+use Auth\User;
+use Models\OAuth2\Client;
+use Strategies\MFA\MFAChallengeStrategyFactory;
+use Symfony\Component\HttpFoundation\Response as HttpResponse;
+use Utils\IPHelper;
use App\libs\OAuth2\Strategies\LoginHintProcessStrategy;
use App\ModelSerializers\SerializerRegistry;
use Auth\Exceptions\AuthenticationException;
@@ -131,6 +141,21 @@ final class UserController extends OpenIdController
*/
private $security_context_service;
+ /**
+ * @var IDeviceTrustService
+ */
+ private $device_trust_service;
+
+ /**
+ * @var ITwoFactorAuditService
+ */
+ private $two_factor_audit_service;
+
+ /**
+ * @var ITwoFactorGateService
+ */
+ private $mfa_gate_service;
+
/**
* @param IMementoOpenIdSerializerService $openid_memento_service
* @param IMementoOAuth2SerializerService $oauth2_memento_service
@@ -166,7 +191,10 @@ public function __construct
IResourceServerService $resource_server_service,
IUtilsServerConfigurationService $utils_configuration_service,
ISecurityContextService $security_context_service,
- LoginHintProcessStrategy $login_hint_process_strategy
+ LoginHintProcessStrategy $login_hint_process_strategy,
+ IDeviceTrustService $device_trust_service,
+ ITwoFactorAuditService $two_factor_audit_service,
+ ITwoFactorGateService $mfa_gate_service,
)
{
$this->openid_memento_service = $openid_memento_service;
@@ -184,6 +212,9 @@ public function __construct
$this->resource_server_service = $resource_server_service;
$this->utils_configuration_service = $utils_configuration_service;
$this->security_context_service = $security_context_service;
+ $this->device_trust_service = $device_trust_service;
+ $this->two_factor_audit_service = $two_factor_audit_service;
+ $this->mfa_gate_service = $mfa_gate_service;
$this->middleware(function ($request, $next) use($login_hint_process_strategy){
@@ -253,6 +284,8 @@ public function cancelLogin()
use JsonResponses;
+ use MFACookieManager;
+
/**
* @return \Illuminate\Http\JsonResponse|mixed
*/
@@ -435,35 +468,41 @@ public function postLogin()
$connection = $data['connection'] ?? null;
try {
- if ($flow == "password" && $this->auth_service->login($username, $password, $remember)) {
- return $this->login_strategy->postLogin();
- }
-
- if ($flow == "otp") {
-
- $client = null;
-
- // check if we have a former oauth2 request
- if ($this->oauth2_memento_service->exists()) {
-
- Log::debug("UserController::postLogin exist a oauth auth request on session");
-
- $oauth_auth_request = OAuth2AuthorizationRequestFactory::getInstance()->build
- (
- OAuth2Message::buildFromMemento($this->oauth2_memento_service->load())
+ if ($flow == IAuthService::AuthenticationFlowPassword) {
+ // Validate credentials WITHOUT establishing a session, so the
+ // MFA gate can run before the user is authenticated.
+ $user = $this->auth_service->validateCredentials($username, $password);
+
+ $cookieToken = $this->getCookieToken();
+
+ if ($this->mfa_gate_service->requiresChallenge($user, $cookieToken)) {
+ // Issue a challenge and stop short of session creation.
+ $client = $this->resolveClientFromMemento();
+ $method = $user->getTwoFactorMethod();
+ $strategy = MFAChallengeStrategyFactory::create($method);
+ $payload = $this->auth_service->issueMFAChallenge($user, $strategy, $client, $remember);
+
+ $this->two_factor_audit_service->log(
+ $user,
+ TwoFactorAuditLog::EventChallengeIssued,
+ $method,
+ IPHelper::getUserIp()
);
- if ($oauth_auth_request->isValid()) {
+ return Response::json(
+ array_merge(['error_code' => 'mfa_required'], $payload),
+ HttpResponse::HTTP_OK
+ );
+ }
- $client_id = $oauth_auth_request->getClientId();
+ // No challenge required: establish the session and continue.
+ $this->auth_service->loginUser($user, $remember);
+ return $this->login_strategy->postLogin();
+ }
- $client = $this->client_repository->getClientById($client_id);
- if (is_null($client))
- throw new ValidationException("client does not exists");
+ if ($flow == IAuthService::AuthenticationFlowPasswordless) {
- $this->oauth2_memento_service->serialize($oauth_auth_request->getMessage()->createMemento());
- }
- }
+ $client = $this->resolveClientFromMemento();
$otpClaim = OAuth2OTP::fromParams($username, $connection, $password);
$this->auth_service->loginWithOTP($otpClaim, $client);
@@ -557,6 +596,287 @@ public function postLogin()
}
}
+ /**
+ * Resolves the OAuth2 client from a former authorization request stored in
+ * the session memento, if any. Returns null when there is no pending OAuth2
+ * request (e.g. plain IdP login).
+ *
+ * @return Client|null
+ * @throws ValidationException
+ */
+ private function resolveClientFromMemento(): ?Client
+ {
+ if (!$this->oauth2_memento_service->exists()) {
+ return null;
+ }
+
+ Log::debug("UserController::resolveClientFromMemento exist a oauth auth request on session");
+
+ $oauth_auth_request = OAuth2AuthorizationRequestFactory::getInstance()->build
+ (
+ OAuth2Message::buildFromMemento($this->oauth2_memento_service->load())
+ );
+
+ if (!$oauth_auth_request->isValid()) {
+ return null;
+ }
+
+ $client = $this->client_repository->getClientById($oauth_auth_request->getClientId());
+ if (is_null($client))
+ throw new ValidationException("client does not exists");
+
+ $this->oauth2_memento_service->serialize($oauth_auth_request->getMessage()->createMemento());
+
+ return $client;
+ }
+
+ /**
+ * Resolves the OAuth2 client carried in the pending MFA state (the client the
+ * challenge was issued for), so verification scopes the OTP lookup and
+ * sibling-revoke to the same client. Returns null when the challenge was not
+ * issued in a client context.
+ *
+ * @param array $pending
+ * @return Client|null
+ */
+ private function resolveClientFromPendingState(array $pending): ?Client
+ {
+ $clientId = $pending['client_id'] ?? null;
+ if (is_null($clientId)) {
+ return null;
+ }
+ return $this->client_repository->getClientById($clientId);
+ }
+
+ /**
+ * Verifies a 2FA OTP challenge and, on success, establishes the session.
+ *
+ * @return \Illuminate\Http\JsonResponse|mixed
+ */
+ public function verify2FA()
+ {
+ try {
+ $data = Request::all();
+ $validator = Validator::make($data, [
+ 'otp_value' => 'required|string',
+ 'method' => 'required|string|in:' . implode(',', User::ValidMFAMethods),
+ 'trust_device' => 'sometimes|boolean',
+ ]);
+
+ if (!$validator->passes()) {
+ return $this->error412($validator->getMessageBag()->getMessages());
+ }
+
+ $method = $data['method'];
+ $otp_value = $data['otp_value'];
+ $trust_device = Request::boolean('trust_device');
+
+ $strategy = MFAChallengeStrategyFactory::create($method);
+ $pending = $strategy->getPendingState();
+
+ if (is_null($pending)) {
+ return $this->mfaSessionExpired();
+ }
+
+ $user = $this->auth_service->getUserById((int) $pending['user_id']);
+ if (is_null($user) || !$user->isTwoFactorMethodEnabled($method)) {
+ $strategy->clearPendingState();
+ return $this->mfaSessionExpired();
+ }
+
+ // Scope verification to the client the challenge was issued for.
+ $client = $this->resolveClientFromPendingState($pending);
+
+ try {
+ // Commits the OTP redeem (+ sibling revoke) in its own tx. The
+ // session, trusted-device enrollment and audit are applied below
+ // as separate post-verification steps.
+ $this->auth_service->verifyMFAChallenge(
+ $user,
+ $strategy,
+ $otp_value,
+ $client
+ );
+ } 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'];
+ $user = $this->auth_service->getUserById($userId) ?? $user;
+ $this->two_factor_audit_service->log(
+ $user,
+ TwoFactorAuditLog::EventChallengeFailed,
+ $method,
+ IPHelper::getUserIp()
+ );
+ return Response::json(['error_code' => 'mfa_verification_failed'], HttpResponse::HTTP_UNAUTHORIZED);
+ }
+
+ // Second factor verified: establish the session.
+ $this->auth_service->loginUser($user, (bool) $pending['remember']);
+
+ if ($trust_device) {
+ // Best-effort: the OTP is already redeemed and the session
+ // established, so a trusted-device enrollment failure must not
+ // 500 the user (which would lock them out on retry against a
+ // burned OTP). Log and continue; the device just isn't remembered.
+ try {
+ $this->queueDeviceTrustCookie($user);
+ } catch (Exception $ex) {
+ Log::warning($ex);
+ }
+ }
+
+ $strategy->clearPendingState();
+
+ try {
+ $this->two_factor_audit_service->log(
+ $user,
+ TwoFactorAuditLog::EventChallengeSucceeded,
+ $method,
+ IPHelper::getUserIp()
+ );
+ } catch (Exception $ex) {
+ Log::warning($ex);
+ }
+
+ return $this->login_strategy->postLogin();
+ } catch (ValidationException $ex) {
+ Log::warning($ex);
+ return $this->error412($ex->getMessages());
+ } catch (Exception $ex) {
+ Log::error($ex);
+ return $this->error500($ex);
+ }
+ }
+
+ /**
+ * Verifies a 2FA recovery code and, on success, establishes the session.
+ *
+ * @return \Illuminate\Http\JsonResponse|mixed
+ */
+ public function verify2FARecovery()
+ {
+ try {
+ $data = Request::all();
+ $validator = Validator::make($data, [
+ 'recovery_code' => 'required|string',
+ ]);
+
+ if (!$validator->passes()) {
+ return $this->error412($validator->getMessageBag()->getMessages());
+ }
+
+ $recovery_code = $data['recovery_code'];
+
+ // Recovery-code handling lives in the base strategy; session keys are
+ // method-agnostic, so any concrete strategy can read the pending state.
+ $strategy = MFAChallengeStrategyFactory::create(User::MFAMethod_OTP);
+ $pending = $strategy->getPendingState();
+
+ if (is_null($pending)) {
+ return $this->mfaSessionExpired();
+ }
+
+ $user = $this->auth_service->getUserById((int) $pending['user_id']);
+ if (is_null($user)) {
+ $strategy->clearPendingState();
+ return $this->mfaSessionExpired();
+ }
+
+ 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'];
+ $user = $this->auth_service->getUserById($userId) ?? $user;
+ $this->two_factor_audit_service->log(
+ $user,
+ TwoFactorAuditLog::EventChallengeFailed,
+ TwoFactorAuditLog::MethodRecovery,
+ IPHelper::getUserIp()
+ );
+ return Response::json(['error_code' => 'mfa_invalid_recovery'], HttpResponse::HTTP_UNAUTHORIZED);
+ }
+
+ $this->auth_service->loginUser($user, (bool) $pending['remember']);
+ $strategy->clearPendingState();
+
+ $this->two_factor_audit_service->log(
+ $user,
+ TwoFactorAuditLog::EventRecoveryUsed,
+ TwoFactorAuditLog::MethodRecovery,
+ IPHelper::getUserIp()
+ );
+
+ return $this->login_strategy->postLogin();
+ } catch (ValidationException $ex) {
+ Log::warning($ex);
+ return $this->error412($ex->getMessages());
+ } catch (Exception $ex) {
+ Log::error($ex);
+ return $this->error500($ex);
+ }
+ }
+
+ /**
+ * Re-issues a 2FA challenge for the pending login and returns the challenge payload.
+ *
+ * @return \Illuminate\Http\JsonResponse|mixed
+ */
+ public function resend2FA()
+ {
+ try {
+ $data = Request::all();
+ $validator = Validator::make($data, [
+ 'method' => 'required|string|in:' . implode(',', User::ValidMFAMethods),
+ ]);
+
+ if (!$validator->passes()) {
+ return $this->error412($validator->getMessageBag()->getMessages());
+ }
+
+ $method = $data['method'];
+ $strategy = MFAChallengeStrategyFactory::create($method);
+ $pending = $strategy->getPendingState();
+
+ if (is_null($pending)) {
+ return $this->mfaSessionExpired();
+ }
+
+ $user = $this->auth_service->getUserById((int) $pending['user_id']);
+ if (is_null($user) || !$user->isTwoFactorMethodEnabled($method)) {
+ $strategy->clearPendingState();
+ return $this->mfaSessionExpired();
+ }
+
+ $payload = $this->auth_service->resendMFAChallenge($user, $strategy, $this->resolveClientFromMemento(), (bool) $pending['remember']);
+
+ $this->two_factor_audit_service->log(
+ $user,
+ TwoFactorAuditLog::EventChallengeIssued,
+ $method,
+ IPHelper::getUserIp()
+ );
+
+ return $this->ok($payload);
+ } catch (ValidationException $ex) {
+ Log::warning($ex);
+ return $this->error412($ex->getMessages());
+ } catch (Exception $ex) {
+ Log::error($ex);
+ return $this->error500($ex);
+ }
+ }
+
+ /**
+ * @return \Illuminate\Http\JsonResponse
+ */
+ private function mfaSessionExpired()
+ {
+ return Response::json(['error_code' => 'mfa_session_expired'], HttpResponse::HTTP_UNAUTHORIZED);
+ }
+
/**
* @return \Illuminate\Http\Response|mixed
*/
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index d81fc8df..6e3c84df 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -75,6 +75,7 @@ class Kernel extends HttpKernel
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequestsWithRedis::class,
'csrf' => \App\Http\Middleware\VerifyCsrfToken::class,
+ '2fa.rate' => \App\Http\Middleware\TwoFactorRateLimitMiddleware::class,
'oauth2.endpoint' => \App\Http\Middleware\OAuth2BearerAccessTokenRequestValidator::class,
'oauth2.currentuser.serveradmin' => \App\Http\Middleware\CurrentUserIsOAuth2ServerAdmin::class,
'oauth2.currentuser.serveradmin.json' => \App\Http\Middleware\CurrentUserIsOAuth2ServerAdminJson::class,
diff --git a/app/Http/Middleware/EncryptCookies.php b/app/Http/Middleware/EncryptCookies.php
index a613dc08..c682095f 100644
--- a/app/Http/Middleware/EncryptCookies.php
+++ b/app/Http/Middleware/EncryptCookies.php
@@ -11,6 +11,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
**/
+use Illuminate\Contracts\Encryption\Encrypter;
use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;
use OAuth2\Services\IPrincipalService;
/**
@@ -22,10 +23,20 @@ class EncryptCookies extends Middleware
/**
* The names of the cookies that should not be encrypted.
*
+ * The trusted-device token is a high-entropy random secret only ever compared
+ * against a server-side SHA-256 hash, so cookie-layer encryption adds no
+ * meaningful protection - exclude it so the value round-trips verbatim.
+ *
* @var array
*/
protected $except = [
- IPrincipalService::OP_BROWSER_STATE_COOKIE_NAME
+ IPrincipalService::OP_BROWSER_STATE_COOKIE_NAME,
];
+ public function __construct(Encrypter $encrypter)
+ {
+ parent::__construct($encrypter);
+ $this->except[] = config('two_factor.cookie_name', 'device_trust_token');
+ }
+
}
diff --git a/app/Http/Middleware/TwoFactorRateLimitMiddleware.php b/app/Http/Middleware/TwoFactorRateLimitMiddleware.php
new file mode 100644
index 00000000..a0bba142
--- /dev/null
+++ b/app/Http/Middleware/TwoFactorRateLimitMiddleware.php
@@ -0,0 +1,145 @@
+limitsFor($action);
+ $key = $this->cacheKey($action, $userId);
+
+ if ((int) Cache::get($key, 0) >= $maxAttempts) {
+ Log::debug(sprintf("TwoFactorRateLimitMiddleware: action %s user %s rate limited", $action, $userId));
+ return Response::json(
+ [
+ 'error_code' => 'mfa_rate_limit',
+ 'error_message' => 'Too many attempts. Please try again later.',
+ ],
+ HttpResponse::HTTP_TOO_MANY_REQUESTS
+ );
+ }
+
+ $response = $next($request);
+
+ if ($action === self::ActionResend) {
+ $this->increment($key, $windowSeconds);
+ } else if ($this->isFailure($response)) {
+ $this->increment($key, $windowSeconds);
+ }
+
+ return $response;
+ }
+
+ /**
+ * @param string $action
+ * @return array{0:int,1:int} [maxAttempts, windowSeconds]
+ */
+ private function limitsFor(string $action): array
+ {
+ if ($action === self::ActionResend) {
+ return [
+ (int) Config::get('two_factor.rate_limit.max_otp_requests', 5),
+ (int) Config::get('two_factor.rate_limit.otp_window_minutes', 15) * 60,
+ ];
+ }
+
+ return [
+ (int) Config::get('two_factor.rate_limit.max_attempts', 3),
+ (int) Config::get('two_factor.rate_limit.window_seconds', 900),
+ ];
+ }
+
+ private function cacheKey(string $action, $userId): string
+ {
+ return sprintf('2fa_rate:%s:%s', $action, $userId);
+ }
+
+ /**
+ * Increment within a fixed window: add() sets the TTL once (only if the key
+ * is absent), increment() bumps the value while preserving that TTL, so the
+ * window starts at the first hit and does not slide.
+ */
+ private function increment(string $key, int $windowSeconds): void
+ {
+ Cache::add($key, 0, $windowSeconds);
+ Cache::increment($key);
+ }
+
+ /**
+ * @param mixed $response
+ * @return bool
+ */
+ private function isFailure($response): bool
+ {
+ $content = method_exists($response, 'getContent') ? $response->getContent() : null;
+ if (empty($content)) {
+ return false;
+ }
+
+ $decoded = json_decode($content, true);
+ if (!is_array($decoded) || !isset($decoded['error_code'])) {
+ return false;
+ }
+
+ return in_array($decoded['error_code'], self::FAILURE_CODES, true);
+ }
+}
diff --git a/app/Repositories/DoctrineUserRecoveryCodeRepository.php b/app/Repositories/DoctrineUserRecoveryCodeRepository.php
index b492a0f6..202e48b6 100644
--- a/app/Repositories/DoctrineUserRecoveryCodeRepository.php
+++ b/app/Repositories/DoctrineUserRecoveryCodeRepository.php
@@ -31,6 +31,12 @@ public function getUnusedByUser(User $user): array
]);
}
+ public function refreshExclusiveLock(UserRecoveryCode $code): void
+ {
+ // Single round-trip: SELECT ... FOR UPDATE that also re-hydrates the entity.
+ $this->getEntityManager()->refresh($code, \Doctrine\DBAL\LockMode::PESSIMISTIC_WRITE);
+ }
+
public function deleteAllForUser(User $user): int
{
$em = $this->getEntityManager();
diff --git a/app/Services/Auth/DeviceTrustService.php b/app/Services/Auth/DeviceTrustService.php
index e6f317b6..d4242c2f 100644
--- a/app/Services/Auth/DeviceTrustService.php
+++ b/app/Services/Auth/DeviceTrustService.php
@@ -13,12 +13,15 @@
* limitations under the License.
**/
+use App\libs\Auth\Models\TwoFactorAuditLog;
use App\libs\Auth\Models\UserTrustedDevice;
use Auth\Repositories\IUserTrustedDeviceRepository;
use Auth\User;
use DateTime;
use DateInterval;
use DateTimeZone;
+use Utils\IPHelper;
+use Utils\Db\ITransactionService;
/**
* Class DeviceTrustService
@@ -26,7 +29,11 @@
*/
final class DeviceTrustService implements IDeviceTrustService
{
- public function __construct(private readonly IUserTrustedDeviceRepository $repository)
+ public function __construct(
+ private readonly IUserTrustedDeviceRepository $repository,
+ private readonly ITwoFactorAuditService $audit_service,
+ private readonly ITransactionService $tx_service
+ )
{
}
@@ -55,7 +62,16 @@ public function trustDevice(User $user, string $userAgent, string $ipAddress): s
$device->setLastSeenAt(clone $now);
$device->setIsRevoked(false);
- $this->repository->add($device, true);
+ $this->tx_service->transaction(function () use ($device) {
+ $this->repository->add($device, false);
+ });
+
+ $this->audit_service->log(
+ $user,
+ TwoFactorAuditLog::EventDeviceTrusted,
+ $user->getTwoFactorMethod(),
+ $ipAddress
+ );
return $rawToken;
}
@@ -74,12 +90,21 @@ public function isDeviceTrusted(User $user, ?string $cookieToken): bool
}
$device->setLastSeenAt(new DateTime('now', new DateTimeZone('UTC')));
- $this->repository->add($device, true);
+ $this->tx_service->transaction(function () use ($device) {
+ $this->repository->add($device, false);
+ });
return true;
}
public function removeTrustedDevices(User $user): void
{
$this->repository->revokeAllForUser($user);
+
+ $this->audit_service->log(
+ $user,
+ TwoFactorAuditLog::EventDeviceRevoked,
+ $user->getTwoFactorMethod(),
+ IPHelper::getUserIp()
+ );
}
}
diff --git a/app/Services/Auth/TwoFactorAuditService.php b/app/Services/Auth/TwoFactorAuditService.php
index 9a02586d..a1dfd9f8 100644
--- a/app/Services/Auth/TwoFactorAuditService.php
+++ b/app/Services/Auth/TwoFactorAuditService.php
@@ -18,6 +18,7 @@
use Auth\Repositories\ITwoFactorAuditLogRepository;
use Auth\User;
use Illuminate\Support\Facades\Log;
+use Utils\Db\ITransactionService;
/**
* Class TwoFactorAuditService
@@ -26,7 +27,8 @@
final class TwoFactorAuditService implements ITwoFactorAuditService
{
public function __construct(
- private readonly ITwoFactorAuditLogRepository $repository
+ private readonly ITwoFactorAuditLogRepository $repository,
+ private readonly ITransactionService $tx_service
) {
}
@@ -53,7 +55,9 @@ public function log(User $user, string $eventType, string $method, string $ipAdd
$auditLog->setUserAgent(request()?->userAgent() ?? '');
$auditLog->setMetadata($metadata);
- $this->repository->add($auditLog, true);
+ $this->tx_service->transaction(function () use ($auditLog) {
+ $this->repository->add($auditLog, false);
+ });
if (config('opentelemetry.enabled', false)) {
EmitAuditLogJob::dispatch('two_factor.audit', [
diff --git a/app/Strategies/MFA/AbstractMFAChallengeStrategy.php b/app/Strategies/MFA/AbstractMFAChallengeStrategy.php
index 172ab7e6..3bf3055c 100644
--- a/app/Strategies/MFA/AbstractMFAChallengeStrategy.php
+++ b/app/Strategies/MFA/AbstractMFAChallengeStrategy.php
@@ -13,6 +13,7 @@ abstract class AbstractMFAChallengeStrategy implements IMFAChallengeStrategy
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_CLIENT_ID = '2fa_pending_client_id';
private const KEY_RECOVERY_ATTEMPTS = '2fa_recovery_attempts';
public function __construct(protected IUserRecoveryCodeRepository $recovery_code_repository) {}
@@ -35,6 +36,7 @@ public function getPendingState(): ?array
'user_id' => $user_id,
'pending_at' => $pending_at,
'remember' => Session::get(self::KEY_REMEMBER, false),
+ 'client_id' => Session::get(self::KEY_CLIENT_ID),
];
}
@@ -43,6 +45,7 @@ public function clearPendingState(): void
Session::remove(self::KEY_USER_ID);
Session::remove(self::KEY_PENDING_AT);
Session::remove(self::KEY_REMEMBER);
+ Session::remove(self::KEY_CLIENT_ID);
Session::remove(self::KEY_RECOVERY_ATTEMPTS);
}
@@ -50,18 +53,32 @@ public function verifyRecoveryCode(User $user, string $code): void
{
foreach ($this->recovery_code_repository->getUnusedByUser($user) as $recoveryCode) {
if (Hash::check($code, $recoveryCode->getCodeHash())) {
+ // Concurrency: acquire a PESSIMISTIC_WRITE row lock and re-hydrate
+ // used_at before mutating. This closes the check->markUsed race
+ // window: a second concurrent submitter blocks on the lock and, on
+ // resume, sees the code already used instead of double-spending it.
+ $this->recovery_code_repository->refreshExclusiveLock($recoveryCode);
+ if ($recoveryCode->isUsed()) {
+ throw new AuthenticationException("Invalid recovery code.");
+ }
$recoveryCode->markUsed();
+ $this->recovery_code_repository->add($recoveryCode, false);
return;
}
}
throw new AuthenticationException("Invalid recovery code.");
}
- protected function storePendingState(int $userId, bool $remember): void
+ protected function storePendingState(int $userId, bool $remember, ?string $clientId = null): void
{
Session::put(self::KEY_USER_ID, $userId);
Session::put(self::KEY_PENDING_AT, time());
Session::put(self::KEY_REMEMBER, $remember);
+ if (is_null($clientId)) {
+ Session::remove(self::KEY_CLIENT_ID);
+ } else {
+ Session::put(self::KEY_CLIENT_ID, $clientId);
+ }
}
public function verifyChallenge(User $user, string $code, ?Client $client = null): void
diff --git a/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php
index 56a35da2..a07f366b 100644
--- a/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php
+++ b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php
@@ -5,7 +5,6 @@
use Auth\Repositories\IUserRecoveryCodeRepository;
use Auth\User;
use Models\OAuth2\Client;
-use Models\OAuth2\OAuth2OTP;
use OAuth2\OAuth2Protocol;
use OAuth2\Services\ITokenService;
@@ -21,7 +20,9 @@ public function __construct(
public function issueChallenge(User $user, ?Client $client, bool $remember): array
{
- $this->storePendingState($user->getId(), $remember);
+ // Carry the issuing client into the pending state so verification scopes
+ // the OTP lookup and sibling-revoke to the same client (see verifyChallenge).
+ $this->storePendingState($user->getId(), $remember, $client?->getClientId());
$otp = $this->token_service->createOTPFromPayload([
OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail,
@@ -37,7 +38,16 @@ public function issueChallenge(User $user, ?Client $client, bool $remember): arr
public function verifyChallenge(User $user, string $code, ?Client $client = null): void
{
- $otp = OAuth2OTP::fromParams($user->getEmail(), OAuth2Protocol::OAuth2PasswordlessConnectionEmail, $code);
+ // Look up the STORED single-use code so the submitted value is actually
+ // validated against what was issued (a non-matching code resolves to null).
+ // Scope the lookup to the issuing client so an MFA OTP is only matched
+ // against the client it was issued for.
+ $otp = $this->otp_repository->getByValueConnectionAndUserName(
+ $code,
+ OAuth2Protocol::OAuth2PasswordlessConnectionEmail,
+ $user->getEmail(),
+ $client
+ );
if (is_null($otp)) {
throw new AuthenticationException("Non existent single-use code.");
@@ -53,11 +63,26 @@ public function verifyChallenge(User $user, string $code, ?Client $client = null
throw new AuthenticationException("Verification code is not valid.");
}
+ // Concurrency: acquire a PESSIMISTIC_WRITE row lock and re-hydrate redemption
+ // state before redeeming, mirroring AuthService::finalizeRedemption(). This
+ // closes the validate->redeem race so two concurrent submissions of the same
+ // valid code cannot both succeed. Runs inside the verifyMFAChallenge tx.
+ if ($otp->getConnection() !== OAuth2Protocol::OAuth2PasswordlessConnectionInline) {
+ $this->otp_repository->refreshExclusiveLock($otp);
+ if ($otp->isRedeemed()) {
+ throw new AuthenticationException("Verification code is already redeemed.");
+ }
+ }
+
$otp->redeem();
+ $this->otp_repository->add($otp, false);
- foreach ($this->otp_repository->getByUserNameNotRedeemed($user->getEmail()) as $otpToRevoke) {
+ // Revoke other pending OTPs for this user, scoped to the same client so we
+ // never burn unrelated OTPs (e.g. passwordless-login codes for other clients).
+ foreach ($this->otp_repository->getByUserNameNotRedeemed($user->getEmail(), $client) as $otpToRevoke) {
if ($otpToRevoke->getValue() !== $otp->getValue()) {
$otpToRevoke->redeem();
+ $this->otp_repository->add($otpToRevoke, false);
}
}
}
diff --git a/app/libs/Auth/AuthService.php b/app/libs/Auth/AuthService.php
index ae71663d..ed1f6510 100644
--- a/app/libs/Auth/AuthService.php
+++ b/app/libs/Auth/AuthService.php
@@ -39,6 +39,7 @@
use OAuth2\Services\ISecurityContextService;
use OpenId\Services\IUserService;
use Services\IUserActionService;
+use Strategies\MFA\IMFAChallengeStrategy;
use utils\Base64UrlRepresentation;
use Utils\Db\ITransactionService;
use Utils\IPHelper;
@@ -426,15 +427,10 @@ public function validateCredentials(string $username, string $password): User
{
Log::debug("AuthService::validateCredentials");
- try {
- /**
- * @var User|null $user
- */
- $user = Auth::getProvider()->retrieveByCredentials(['username' => $username, 'password' => $password]);
- } catch (UnverifiedEmailMemberException $ex) {
- throw new AuthenticationException($ex->getMessage());
- }
-
+ /**
+ * @var User|null $user
+ */
+ $user = Auth::getProvider()->retrieveByCredentials(['username' => $username, 'password' => $password]);
if (is_null($user) || !$user instanceof User || !$user->canLogin()) {
throw new AuthenticationException("We are sorry, your username or password does not match an existing record.");
}
@@ -451,6 +447,13 @@ public function loginUser(User $user, bool $remember): void
Log::debug("AuthService::loginUser");
if (!$user->canLogin())
throw new AuthenticationException("User is not active or cannot login.");
+
+ $this->principal_service->clear();
+ $this->principal_service->register
+ (
+ $user->getId(),
+ time()
+ );
Auth::login($user, $remember);
}
@@ -778,4 +781,51 @@ public function postLoginUserActions(int $user_id): void
});
}
+
+ public function issueMFAChallenge(
+ User $user,
+ IMFAChallengeStrategy $strategy,
+ ?Client $client = null,
+ bool $remember = false
+ ): array {
+ return $this->tx_service->transaction(function () use ($user, $strategy, $client, $remember) {
+ return $strategy->issueChallenge($user, $client, $remember);
+ });
+ }
+
+ public function verifyMFAChallenge(
+ User $user,
+ IMFAChallengeStrategy $strategy,
+ string $value,
+ ?Client $client = null
+ ): void {
+ // Commits the OTP redeem (+ sibling revoke) as a single tx. Trusted-device
+ // enrollment and audit are applied by the caller as best-effort,
+ // non-blocking side effects after this commits, so a failure in either
+ // does not block (or roll back) an already-verified second factor.
+ $this->tx_service->transaction(function () use ($user, $strategy, $value, $client) {
+ $strategy->verifyChallenge($user, $value, $client);
+ });
+ }
+
+ public function verifyMFARecoveryCode(
+ User $user,
+ IMFAChallengeStrategy $strategy,
+ string $inputCode
+ ): void {
+ $this->tx_service->transaction(function () use ($user, $strategy, $inputCode) {
+ $strategy->verifyRecoveryCode($user, $inputCode);
+ });
+ }
+
+ public function resendMFAChallenge(
+ User $user,
+ IMFAChallengeStrategy $strategy,
+ ?Client $client = null,
+ bool $remember = false
+ ): array {
+ return $this->tx_service->transaction(function () use ($user, $strategy, $client, $remember) {
+ return $strategy->resendChallenge($user, $client, $remember);
+ });
+ }
}
\ No newline at end of file
diff --git a/app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php b/app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php
index f9733f87..0bfd0335 100644
--- a/app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php
+++ b/app/libs/Auth/Repositories/IUserRecoveryCodeRepository.php
@@ -22,6 +22,13 @@ interface IUserRecoveryCodeRepository extends IBaseRepository
*/
public function getUnusedByUser(User $user): array;
+ /**
+ * Acquires a PESSIMISTIC_WRITE row lock on the given recovery code and
+ * re-hydrates its used_at state in the same round-trip. Required before
+ * redeeming a recovery code to close the check->markUsed double-spend race.
+ */
+ public function refreshExclusiveLock(UserRecoveryCode $code): void;
+
/**
* Delete every recovery code for a user (used when regenerating).
*/
diff --git a/app/libs/Utils/Services/IAuthService.php b/app/libs/Utils/Services/IAuthService.php
index c3d26e62..b1b6abfc 100644
--- a/app/libs/Utils/Services/IAuthService.php
+++ b/app/libs/Utils/Services/IAuthService.php
@@ -18,6 +18,7 @@
use Models\OAuth2\OAuth2OTP;
use OAuth2\Models\IClient;
use OpenId\Models\IOpenIdUser;
+use Strategies\MFA\IMFAChallengeStrategy;
/**
* Interface IAuthService
*/
@@ -66,6 +67,7 @@ public function login(string $username, string $password, bool $remember_me): bo
* @param string $password
* @return User
* @throws AuthenticationException on invalid credentials, missing user, or locked account.
+ * @throws \Auth\Exceptions\UnverifiedEmailMemberException when the user's email is not verified
*/
public function validateCredentials(string $username, string $password): User;
@@ -193,4 +195,31 @@ public function verifyOTPChallenge(
?Client $client = null
): OAuth2OTP;
+ public function issueMFAChallenge(
+ User $user,
+ IMFAChallengeStrategy $strategy,
+ ?Client $client = null,
+ bool $remember = false
+ ): array;
+
+ public function verifyMFAChallenge(
+ User $user,
+ IMFAChallengeStrategy $strategy,
+ string $value,
+ ?Client $client = null
+ ): void;
+
+ public function verifyMFARecoveryCode(
+ User $user,
+ IMFAChallengeStrategy $strategy,
+ string $inputCode
+ ): void;
+
+ public function resendMFAChallenge(
+ User $user,
+ IMFAChallengeStrategy $strategy,
+ ?Client $client = null,
+ bool $remember = false
+ ): array;
+
}
\ No newline at end of file
diff --git a/config/two_factor.php b/config/two_factor.php
index 8a399806..78ac21a0 100644
--- a/config/two_factor.php
+++ b/config/two_factor.php
@@ -38,4 +38,23 @@
*/
'device_trust_lifetime_days' => env('DEVICE_TRUST_LIFETIME_DAYS', 30),
'cookie_name' => env('DEVICE_TRUST_COOKIE_NAME', 'device_trust_token'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Rate Limiting
+ |--------------------------------------------------------------------------
+ |
+ | Counters live in the cache (NOT the session) so they survive session
+ | cleanup and keep an independent, fixed TTL window.
+ |
+ | verify/recovery: max_attempts failed attempts per window_seconds.
+ | resend: max_otp_requests requests per otp_window_minutes.
+ |
+ */
+ 'rate_limit' => [
+ 'max_attempts' => env('TWO_FACTOR_MAX_ATTEMPTS', 3),
+ 'window_seconds' => env('TWO_FACTOR_RATE_WINDOW_SECONDS', 900),
+ 'max_otp_requests' => env('TWO_FACTOR_MAX_OTP_REQUESTS', 5),
+ 'otp_window_minutes' => env('TWO_FACTOR_OTP_WINDOW_MINUTES', 15),
+ ],
];
diff --git a/phpunit.xml b/phpunit.xml
index 4c4f8d12..5d09a0bb 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -30,6 +30,7 @@
./tests/Unit/MFA/MFAChallengeStrategyFactoryTest.php
./tests/Unit/TwoFactorAuditServiceTest.php
./tests/Unit/MFAGateServiceTest.php
+ ./tests/TwoFactorLoginFlowTest.php
diff --git a/routes/web.php b/routes/web.php
index b49a3547..5c0f0af0 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -49,6 +49,11 @@
Route::group(array('prefix' => 'verification'), function () {
Route::post('resend', ['middleware' => ['csrf'], 'uses' => 'UserController@resendVerificationEmail']);
});
+ Route::group(array('prefix' => '2fa'), function () {
+ Route::post('verify', ['middleware' => ['csrf', '2fa.rate:verify'], 'uses' => 'UserController@verify2FA']);
+ Route::post('recovery', ['middleware' => ['csrf', '2fa.rate:recovery'], 'uses' => 'UserController@verify2FARecovery']);
+ Route::post('resend', ['middleware' => ['csrf', '2fa.rate:resend'], 'uses' => 'UserController@resend2FA']);
+ });
Route::post('', ['middleware' => 'csrf', 'uses' => 'UserController@postLogin']);
Route::get('cancel', "UserController@cancelLogin");
Route::group(array('prefix' => '{provider}'), function () {
diff --git a/storage/framework/cache/data/.gitignore b/storage/framework/cache/data/.gitignore
deleted file mode 100755
index d6b7ef32..00000000
--- a/storage/framework/cache/data/.gitignore
+++ /dev/null
@@ -1,2 +0,0 @@
-*
-!.gitignore
diff --git a/tests/DeviceTrustServiceTest.php b/tests/DeviceTrustServiceTest.php
index 5eb2bdfe..4d8ae8fb 100644
--- a/tests/DeviceTrustServiceTest.php
+++ b/tests/DeviceTrustServiceTest.php
@@ -15,12 +15,14 @@
use App\libs\Auth\Models\UserTrustedDevice;
use App\Services\Auth\DeviceTrustService;
+use App\Services\Auth\ITwoFactorAuditService;
use Auth\Repositories\IUserTrustedDeviceRepository;
use Auth\User;
use DateTime;
use DateInterval;
use DateTimeZone;
use Mockery;
+use Utils\Db\ITransactionService;
/**
* Class DeviceTrustServiceTest
@@ -33,11 +35,21 @@ final class DeviceTrustServiceTest extends BrowserKitTestCase
/** @var \Mockery\MockInterface&IUserTrustedDeviceRepository */
private $repo;
+ /** @var \Mockery\MockInterface&ITwoFactorAuditService */
+ private $audit_service;
+
+ /** @var \Mockery\MockInterface&ITransactionService */
+ private $tx_service;
+
public function setUp(): void
{
parent::setUp();
$this->repo = Mockery::mock(IUserTrustedDeviceRepository::class);
- $this->service = new DeviceTrustService($this->repo);
+ $this->audit_service = Mockery::mock(ITwoFactorAuditService::class);
+ $this->audit_service->shouldReceive('log')->byDefault();
+ $this->tx_service = Mockery::mock(ITransactionService::class);
+ $this->tx_service->shouldReceive('transaction')->andReturnUsing(fn($cb) => $cb())->byDefault();
+ $this->service = new DeviceTrustService($this->repo, $this->audit_service, $this->tx_service);
}
public function tearDown(): void
@@ -53,6 +65,7 @@ public function tearDown(): void
public function testIsDeviceTrustedNullCookie(): void
{
$user = Mockery::mock(User::class);
+ $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP);
$this->repo->shouldNotReceive('getByUserAndDeviceIdentifier');
$this->assertFalse($this->service->isDeviceTrusted($user, null));
@@ -61,6 +74,7 @@ public function testIsDeviceTrustedNullCookie(): void
public function testIsDeviceTrustedEmptyCookie(): void
{
$user = Mockery::mock(User::class);
+ $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP);
$this->repo->shouldNotReceive('getByUserAndDeviceIdentifier');
$this->assertFalse($this->service->isDeviceTrusted($user, ''));
@@ -69,6 +83,7 @@ public function testIsDeviceTrustedEmptyCookie(): void
public function testIsDeviceTrustedWrongCookie(): void
{
$user = Mockery::mock(User::class);
+ $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP);
$this->repo
->shouldReceive('getByUserAndDeviceIdentifier')
->once()
@@ -80,6 +95,7 @@ public function testIsDeviceTrustedWrongCookie(): void
public function testIsDeviceTrustedRevokedDevice(): void
{
$user = Mockery::mock(User::class);
+ $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP);
$device = $this->makeDevice(expired: false, revoked: true);
@@ -94,6 +110,7 @@ public function testIsDeviceTrustedRevokedDevice(): void
public function testIsDeviceTrustedExpiredDevice(): void
{
$user = Mockery::mock(User::class);
+ $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP);
$device = $this->makeDevice(expired: true, revoked: false);
@@ -108,6 +125,7 @@ public function testIsDeviceTrustedExpiredDevice(): void
public function testIsDeviceTrustedValidDevice(): void
{
$user = Mockery::mock(User::class);
+ $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP);
$device = $this->makeDevice(expired: false, revoked: false);
@@ -123,6 +141,7 @@ public function testIsDeviceTrustedValidDevice(): void
public function testIsDeviceTrustedUpdatesLastSeenAt(): void
{
$user = Mockery::mock(User::class);
+ $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP);
$device = $this->makeDevice(expired: false, revoked: false);
// set last_seen_at to a known old value so the update is detectable
@@ -148,6 +167,7 @@ public function testIsDeviceTrustedUpdatesLastSeenAt(): void
public function testTrustDeviceReturnsToken(): void
{
$user = Mockery::mock(User::class);
+ $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP);
$this->repo->shouldReceive('add')->once();
@@ -160,6 +180,7 @@ public function testTrustDeviceReturnsToken(): void
public function testTrustDeviceStoresHash(): void
{
$user = Mockery::mock(User::class);
+ $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP);
/** @var UserTrustedDevice|null $persistedDevice */
$persistedDevice = null;
@@ -181,6 +202,7 @@ public function testTrustDeviceStoresHash(): void
public function testTrustDeviceRawTokenNotStored(): void
{
$user = Mockery::mock(User::class);
+ $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP);
/** @var UserTrustedDevice|null $persistedDevice */
$persistedDevice = null;
@@ -202,15 +224,32 @@ public function testTrustDeviceRawTokenNotStored(): void
public function testTrustDeviceCreatesExactlyOneRecord(): void
{
$user = Mockery::mock(User::class);
+ $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP);
+
+ $this->repo->shouldReceive('add')->once();
+
+ $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1');
+ }
+
+ public function testTrustDeviceEmitsDeviceTrustedAuditEvent(): void
+ {
+ $user = Mockery::mock(User::class);
+ $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP);
$this->repo->shouldReceive('add')->once();
+ $this->audit_service
+ ->shouldReceive('log')
+ ->once()
+ ->with($user, \App\libs\Auth\Models\TwoFactorAuditLog::EventDeviceTrusted, User::MFAMethod_OTP, '127.0.0.1');
+
$this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1');
}
public function testTrustDeviceSetsExpiresAtFromConfig(): void
{
$user = Mockery::mock(User::class);
+ $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP);
/** @var UserTrustedDevice|null $persistedDevice */
$persistedDevice = null;
@@ -239,12 +278,18 @@ public function testTrustDeviceSetsExpiresAtFromConfig(): void
public function testRemoveTrustedDevicesRevokesAll(): void
{
$user = Mockery::mock(User::class);
+ $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP);
$this->repo
->shouldReceive('revokeAllForUser')
->once()
->with($user);
+ $this->audit_service
+ ->shouldReceive('log')
+ ->once()
+ ->with($user, \App\libs\Auth\Models\TwoFactorAuditLog::EventDeviceRevoked, User::MFAMethod_OTP, Mockery::type('string'));
+
$this->service->removeTrustedDevices($user);
}
diff --git a/tests/TwoFactorLoginFlowTest.php b/tests/TwoFactorLoginFlowTest.php
new file mode 100644
index 00000000..0fc20bd6
--- /dev/null
+++ b/tests/TwoFactorLoginFlowTest.php
@@ -0,0 +1,494 @@
+flushRateLimitCounters();
+ }
+
+ protected function tearDown(): void
+ {
+ $this->flushRateLimitCounters();
+ parent::tearDown();
+ }
+
+ private function flushRateLimitCounters(): void
+ {
+ $admin = EntityManager::getRepository(User::class)->getByEmailOrName(self::ADMIN_EMAIL);
+ if (!$admin) return;
+ $userId = $admin->getId();
+ foreach (['verify', 'recovery', 'resend'] as $action) {
+ Cache::forget("2fa_rate:{$action}:{$userId}");
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // postLogin gate
+ // -------------------------------------------------------------------------
+
+ public function testAdminLoginTriggersMFAChallenge(): void
+ {
+ $response = $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+
+ $this->assertResponseStatus(200);
+ $payload = json_decode($response->getContent(), true);
+ $this->assertSame('mfa_required', $payload['error_code']);
+ $this->assertFalse(Auth::check(), 'no session must be established when a challenge is required');
+
+ $admin = $this->user(self::ADMIN_EMAIL);
+ $this->assertGreaterThan(0, $this->countAudit($admin->getId(), TwoFactorAuditLog::EventChallengeIssued));
+ }
+
+ public function testNonAdminWithoutMFALogsInNormally(): void
+ {
+ $email = $this->createPlainUser();
+
+ $response = $this->postLogin($email, self::SEED_PASSWORD);
+
+ $this->assertResponseStatus(302);
+ $this->assertTrue(Auth::check(), 'a non-MFA user must get an authenticated session');
+ }
+
+ // -------------------------------------------------------------------------
+ // verify2FA
+ // -------------------------------------------------------------------------
+
+ public function testSuccessfulOTPVerificationCompletesLogin(): void
+ {
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+ $code = $this->latestOtpCode(self::ADMIN_EMAIL);
+
+ $response = $this->verify($code);
+
+ $this->assertResponseStatus(302);
+ $this->assertTrue(Auth::check());
+
+ $admin = $this->user(self::ADMIN_EMAIL);
+ $this->assertGreaterThan(0, $this->countAudit($admin->getId(), TwoFactorAuditLog::EventChallengeSucceeded));
+ }
+
+ public function testFailedOTPVerificationReturnsErrorAndIncrementsCounter(): void
+ {
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+ $admin = $this->user(self::ADMIN_EMAIL);
+ $userId = $admin->getId();
+
+ $response = $this->verify('000000-wrong');
+
+ $this->assertResponseStatus(401);
+ $payload = json_decode($response->getContent(), true);
+ $this->assertSame('mfa_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');
+ $this->assertGreaterThan(0, $this->countAudit($userId, TwoFactorAuditLog::EventChallengeFailed));
+ }
+
+ public function testSuccessfulVerificationDoesNotIncrementCounter(): void
+ {
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+ $userId = $this->user(self::ADMIN_EMAIL)->getId();
+ $code = $this->latestOtpCode(self::ADMIN_EMAIL);
+
+ $this->verify($code);
+
+ $this->assertSame(0, (int) Cache::get('2fa_rate:verify:' . $userId, 0), 'success must NOT increment the verify counter');
+ }
+
+ public function testOTPVerificationRejectsWrongCode(): void
+ {
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+ // Confirm there is a real OTP issued, then send a wrong value.
+ $this->latestOtpCode(self::ADMIN_EMAIL); // asserts an OTP exists
+ $wrongCode = 'WRONG-CODE-THAT-DOES-NOT-EXIST';
+
+ $response = $this->verify($wrongCode);
+
+ $this->assertResponseStatus(401);
+ $payload = json_decode($response->getContent(), true);
+ $this->assertSame('mfa_verification_failed', $payload['error_code'],
+ 'verifyChallenge must load the stored OTP and reject a non-matching value');
+ $this->assertFalse(Auth::check());
+ }
+
+ public function testOTPCodeRejectsReuseAfterSuccessfulVerification(): void
+ {
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+ $code = $this->latestOtpCode(self::ADMIN_EMAIL);
+
+ // First use — must succeed.
+ $this->verify($code);
+ $this->assertTrue(Auth::check(), 'first OTP use must establish a session');
+
+ // Second use — OTP must be redeemed (committed by the AuthService tx).
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+ $response = $this->verify($code);
+
+ $this->assertResponseStatus(401);
+ $payload = json_decode($response->getContent(), true);
+ $this->assertSame('mfa_verification_failed', $payload['error_code'],
+ 'a reused OTP must be rejected because the redemption was committed by the AuthService transaction');
+ }
+
+ public function testRecoveryCodeRejectsReuseAfterTransactionCommit(): void
+ {
+ $admin = $this->user(self::ADMIN_EMAIL);
+ $plain = 'RECOVERY-REUSE-TX-' . uniqid();
+ $this->createRecoveryCode($admin, $plain, false);
+
+ // First use — must succeed.
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+ $this->recovery($plain);
+ $this->assertTrue(Auth::check(), 'first recovery-code use must establish a session');
+
+ // Second use — used_at marking must have been committed by the AuthService tx.
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+ $response = $this->recovery($plain);
+
+ $this->assertResponseStatus(401);
+ $payload = json_decode($response->getContent(), true);
+ $this->assertSame('mfa_invalid_recovery', $payload['error_code'],
+ 'recovery code reuse must be rejected because used_at was committed via the AuthService transaction');
+ }
+
+ public function testExpiredMFASessionFails(): void
+ {
+ // No prior postLogin -> no pending state.
+ $response = $this->verify('whatever');
+
+ $this->assertResponseStatus(401);
+ $payload = json_decode($response->getContent(), true);
+ $this->assertSame('mfa_session_expired', $payload['error_code']);
+ }
+
+ // -------------------------------------------------------------------------
+ // trusted device
+ // -------------------------------------------------------------------------
+
+ public function testTrustDeviceEnrollmentPersistsRecord(): void
+ {
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+ $admin = $this->user(self::ADMIN_EMAIL);
+ $code = $this->latestOtpCode(self::ADMIN_EMAIL);
+
+ $response = $this->verify($code, true);
+ $this->assertResponseStatus(302);
+
+ EntityManager::clear();
+ $devices = EntityManager::getRepository(UserTrustedDevice::class)->findBy(['user' => $admin->getId()]);
+ $this->assertNotEmpty($devices, 'a trusted-device record must be persisted');
+ $this->assertGreaterThan(0, $this->countAudit($admin->getId(), TwoFactorAuditLog::EventDeviceTrusted));
+ }
+
+ public function testTrustedDeviceCookieBypassesMFA(): void
+ {
+ $admin = $this->user(self::ADMIN_EMAIL);
+
+ /** @var IDeviceTrustService $deviceTrust */
+ $deviceTrust = App::make(IDeviceTrustService::class);
+ $rawToken = $deviceTrust->trustDevice($admin, 'Mozilla/5.0 (test)', '127.0.0.1');
+
+ // The device-trust cookie is excluded from encryption, so it is sent verbatim.
+ $response = $this->postLogin(
+ self::ADMIN_EMAIL,
+ self::SEED_PASSWORD,
+ [Config::get('two_factor.cookie_name') => $rawToken]
+ );
+
+ $this->assertResponseStatus(302);
+ $this->assertTrue(Auth::check(), 'a valid trusted-device cookie must bypass MFA');
+ }
+
+ // -------------------------------------------------------------------------
+ // post-verify transaction boundary (Task 5: device-trust atomic, audit best-effort)
+ // -------------------------------------------------------------------------
+
+ public function testAuditFailureDoesNotBlockLogin(): void
+ {
+ // Audit is best-effort: a failure emitting challenge_succeeded must NOT
+ // 500 a user whose OTP is already redeemed and session established.
+ $auditMock = \Mockery::mock(ITwoFactorAuditService::class);
+ $auditMock->shouldReceive('log')
+ ->andReturnUsing(function (User $user, string $eventType) {
+ // Allow challenge_issued (postLogin) so the challenge is created;
+ // blow up only on the post-success event.
+ if ($eventType === TwoFactorAuditLog::EventChallengeSucceeded) {
+ throw new \Exception('audit sink unavailable');
+ }
+ });
+ $this->app->instance(ITwoFactorAuditService::class, $auditMock);
+
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+ $code = $this->latestOtpCode(self::ADMIN_EMAIL);
+
+ $response = $this->verify($code);
+
+ $this->assertEquals(302, $response->getStatusCode(), 'a best-effort audit failure must not fail the login');
+ $this->assertTrue(Auth::check(), 'session must be established despite the audit failure');
+ }
+
+ public function testDeviceTrustFailureDoesNotBlockLogin(): void
+ {
+ // Device-trust enrollment is best-effort: by the time it runs the OTP is
+ // already redeemed and the session established, so a failure must NOT 500
+ // the user (which would lock them out on retry against a now-burned OTP),
+ // and the pending MFA state must still be cleared.
+ $deviceTrustMock = \Mockery::mock(IDeviceTrustService::class);
+ // Gate path: no cookie -> not trusted, so the challenge is still issued.
+ $deviceTrustMock->shouldReceive('isDeviceTrusted')->andReturn(false);
+ // Enrollment blows up AFTER the OTP has been redeemed and the session set.
+ $deviceTrustMock->shouldReceive('trustDevice')
+ ->andThrow(new \Exception('trusted-device store unavailable'));
+ $this->app->instance(IDeviceTrustService::class, $deviceTrustMock);
+
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+ $code = $this->latestOtpCode(self::ADMIN_EMAIL);
+
+ $response = $this->verify($code, true); // trust_device = true
+
+ $this->assertEquals(302, $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');
+ }
+
+ // -------------------------------------------------------------------------
+ // recovery codes
+ // -------------------------------------------------------------------------
+
+ public function testRecoveryCodeLoginSucceeds(): void
+ {
+ $admin = $this->user(self::ADMIN_EMAIL);
+ $plain = 'RECOVERY-PLAIN-123';
+ $codeId = $this->createRecoveryCode($admin, $plain, false);
+
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+ $response = $this->recovery($plain);
+
+ $this->assertResponseStatus(302);
+ $this->assertTrue(Auth::check());
+
+ EntityManager::clear();
+ $code = EntityManager::find(UserRecoveryCode::class, $codeId);
+ $this->assertTrue($code->isUsed(), 'the recovery code must be marked used');
+ $this->assertGreaterThan(0, $this->countAudit($admin->getId(), TwoFactorAuditLog::EventRecoveryUsed));
+ }
+
+ public function testUsedRecoveryCodeFails(): void
+ {
+ $admin = $this->user(self::ADMIN_EMAIL);
+ $plain = 'RECOVERY-USED-456';
+ $this->createRecoveryCode($admin, $plain, true); // already used
+
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+ $response = $this->recovery($plain);
+
+ $this->assertResponseStatus(401);
+ $payload = json_decode($response->getContent(), true);
+ $this->assertSame('mfa_invalid_recovery', $payload['error_code']);
+ $this->assertFalse(Auth::check());
+ }
+
+ // -------------------------------------------------------------------------
+ // resend
+ // -------------------------------------------------------------------------
+
+ public function testResendEndpointReturnsChallengePayload(): void
+ {
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+
+ $response = $this->resend();
+
+ $this->assertResponseStatus(200);
+ $payload = json_decode($response->getContent(), true);
+ $this->assertArrayHasKey('otp_length', $payload);
+ $this->assertArrayHasKey('otp_lifetime', $payload);
+ }
+
+ // -------------------------------------------------------------------------
+ // rate limiting
+ // -------------------------------------------------------------------------
+
+ public function testVerifyRateLimitBlocksAfterThreshold(): void
+ {
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+
+ $max = (int) Config::get('two_factor.rate_limit.max_attempts');
+ for ($i = 0; $i < $max; $i++) {
+ $this->verify('bad-code-' . $i);
+ }
+
+ $response = $this->verify('bad-code-final');
+ $this->assertResponseStatus(429);
+ $payload = json_decode($response->getContent(), true);
+ $this->assertSame('mfa_rate_limit', $payload['error_code']);
+ }
+
+ public function testRecoveryRateLimitBlocksAfterThreshold(): void
+ {
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+
+ $max = (int) Config::get('two_factor.rate_limit.max_attempts');
+ for ($i = 0; $i < $max; $i++) {
+ $this->recovery('bad-recovery-' . $i);
+ }
+
+ $response = $this->recovery('bad-recovery-final');
+ $this->assertResponseStatus(429);
+ $payload = json_decode($response->getContent(), true);
+ $this->assertSame('mfa_rate_limit', $payload['error_code']);
+ }
+
+ public function testResendRateLimitBlocksAfterThreshold(): void
+ {
+ $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD);
+
+ $max = (int) Config::get('two_factor.rate_limit.max_otp_requests');
+ for ($i = 0; $i < $max; $i++) {
+ $this->resend();
+ }
+
+ $response = $this->resend();
+ $this->assertResponseStatus(429);
+ $payload = json_decode($response->getContent(), true);
+ $this->assertSame('mfa_rate_limit', $payload['error_code']);
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private function postLogin(string $username, string $password, array $cookies = [])
+ {
+ return $this->action('POST', 'UserController@postLogin', [
+ 'username' => $username,
+ 'password' => $password,
+ 'flow' => 'password',
+ '_token' => Session::token(),
+ ], [], $cookies);
+ }
+
+ private function verify(string $otp, bool $trustDevice = false)
+ {
+ return $this->action('POST', 'UserController@verify2FA', [
+ 'otp_value' => $otp,
+ 'method' => User::MFAMethod_OTP,
+ 'trust_device' => $trustDevice ? '1' : '0',
+ '_token' => Session::token(),
+ ]);
+ }
+
+ private function recovery(string $code)
+ {
+ return $this->action('POST', 'UserController@verify2FARecovery', [
+ 'recovery_code' => $code,
+ '_token' => Session::token(),
+ ]);
+ }
+
+ private function resend()
+ {
+ return $this->action('POST', 'UserController@resend2FA', [
+ 'method' => User::MFAMethod_OTP,
+ '_token' => Session::token(),
+ ]);
+ }
+
+ private function user(string $email): User
+ {
+ $repo = EntityManager::getRepository(User::class);
+ $user = $repo->getByEmailOrName($email);
+ $this->assertInstanceOf(User::class, $user, "user {$email} not found");
+ return $user;
+ }
+
+ private function createPlainUser(): string
+ {
+ $email = 'plain.' . uniqid() . '@test.invalid';
+ $user = UserFactory::build([
+ 'first_name' => 'Plain',
+ 'last_name' => 'User',
+ 'email' => $email,
+ 'password' => self::SEED_PASSWORD,
+ 'password_enc' => AuthHelper::AlgSHA1_V2_4,
+ 'active' => true,
+ 'email_verified' => true,
+ 'identifier' => 'plain.' . uniqid(),
+ ]);
+ EntityManager::persist($user);
+ EntityManager::flush();
+ return $email;
+ }
+
+ private function createRecoveryCode(User $user, string $plain, bool $used): int
+ {
+ $code = new UserRecoveryCode();
+ $code->setUser($user);
+ $code->setCodeHash(Hash::make($plain));
+ if ($used) {
+ $code->markUsed();
+ }
+ EntityManager::persist($code);
+ EntityManager::flush();
+ return $code->getId();
+ }
+
+ private function latestOtpCode(string $email): string
+ {
+ EntityManager::clear();
+ /** @var IOAuth2OTPRepository $repo */
+ $repo = App::make(IOAuth2OTPRepository::class);
+ $otps = $repo->getByUserNameNotRedeemed($email);
+ $this->assertNotEmpty($otps, "no OTP issued for {$email}");
+ return end($otps)->getValue();
+ }
+
+ private function countAudit(int $userId, string $eventType): int
+ {
+ EntityManager::clear();
+ return (int) count(
+ EntityManager::getRepository(TwoFactorAuditLog::class)
+ ->findBy(['user' => $userId, 'event_type' => $eventType])
+ );
+ }
+}
diff --git a/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php b/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php
index 743cf6f5..cdf2fcfb 100644
--- a/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php
+++ b/tests/Unit/MFA/AbstractMFAChallengeStrategyTest.php
@@ -86,10 +86,14 @@ public function testVerifyRecoveryCode_withMatchingCode_marksAsUsed(): void
$recoveryCode = \Mockery::mock(\App\libs\Auth\Models\UserRecoveryCode::class);
$recoveryCode->shouldReceive('getCodeHash')->andReturn(Hash::make($code));
+ $recoveryCode->shouldReceive('isUsed')->andReturn(false);
$recoveryCode->shouldReceive('markUsed')->once();
$repo = \Mockery::mock(IUserRecoveryCodeRepository::class);
$repo->shouldReceive('getUnusedByUser')->with($user)->andReturn([$recoveryCode]);
+ // Lock is taken before mutating (regression guard for 3357348455).
+ $repo->shouldReceive('refreshExclusiveLock')->with($recoveryCode)->once();
+ $repo->shouldReceive('add')->with($recoveryCode, false)->once();
$strategy = new class($repo) extends AbstractMFAChallengeStrategy {
public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; }
@@ -101,6 +105,34 @@ public function resendChallenge(User $user, ?Client $client, bool $remember): ar
$this->addToAssertionCount(1); // markUsed()->once() verified by Mockery in tearDown
}
+ public function testVerifyRecoveryCode_locksAndRejects_whenUsedAfterLock(): void
+ {
+ $user = new User();
+ $code = 'VALID-CODE';
+
+ // Hash matches, but a concurrent winner marked it used; the lock+recheck
+ // must reject without double-spending (regression guard for 3357348455).
+ $recoveryCode = \Mockery::mock(\App\libs\Auth\Models\UserRecoveryCode::class);
+ $recoveryCode->shouldReceive('getCodeHash')->andReturn(Hash::make($code));
+ $recoveryCode->shouldReceive('isUsed')->andReturn(true);
+ $recoveryCode->shouldNotReceive('markUsed');
+
+ $repo = \Mockery::mock(IUserRecoveryCodeRepository::class);
+ $repo->shouldReceive('getUnusedByUser')->with($user)->andReturn([$recoveryCode]);
+ $repo->shouldReceive('refreshExclusiveLock')->with($recoveryCode)->once();
+ $repo->shouldNotReceive('add');
+
+ $strategy = new class($repo) extends AbstractMFAChallengeStrategy {
+ public function issueChallenge(User $user, ?Client $client, bool $remember): array { return []; }
+ public function verifyChallenge(User $user, string $code, ?Client $client = null): void {}
+ public function resendChallenge(User $user, ?Client $client, bool $remember): array { return []; }
+ };
+
+ $this->expectException(AuthenticationException::class);
+ $this->expectExceptionMessage("Invalid recovery code.");
+ $strategy->verifyRecoveryCode($user, $code);
+ }
+
public function testVerifyRecoveryCode_withNonMatchingCode_throwsException(): void
{
$user = new User();
diff --git a/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php b/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php
index f2c56a7b..7db3b1ba 100644
--- a/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php
+++ b/tests/Unit/MFA/EmailOTPMFAChallengeStrategyTest.php
@@ -4,6 +4,7 @@
use Auth\Repositories\IUserRecoveryCodeRepository;
use Auth\User;
use Illuminate\Support\Facades\Session;
+use Models\OAuth2\Client;
use Models\OAuth2\OAuth2OTP;
use OAuth2\Services\ITokenService;
use Strategies\MFA\EmailOTPMFAChallengeStrategy;
@@ -94,20 +95,89 @@ public function testResendChallenge_delegatesToIssueChallenge(): void
// ---------- verifyChallenge ----------
- public function testVerifyChallenge_withValidOtp_redeemsAndRevokesOthers(): void
+ public function testVerifyChallenge_withValidOtp_redeemsAndRevokesOthers_scopedToClient(): void
{
- $user = $this->buildUser(1, 'verify@example.com');
- $code = '123456';
+ $user = $this->buildUser(1, 'verify@example.com');
+ $code = '123456';
+ $client = \Mockery::mock(Client::class);
+
+ $storedOtp = \Mockery::mock(OAuth2OTP::class);
+ $storedOtp->shouldReceive('getValue')->andReturn($code);
+ $storedOtp->shouldReceive('logRedeemAttempt')->once();
+ $storedOtp->shouldReceive('isAlive')->andReturn(true);
+ $storedOtp->shouldReceive('isValid')->andReturn(true);
+ $storedOtp->shouldReceive('getConnection')->andReturn('email');
+ $storedOtp->shouldReceive('isRedeemed')->andReturn(false);
+ $storedOtp->shouldReceive('redeem')->once();
+
+ // Lookup MUST be scoped to the issuing client (regression guard for r3357348448).
+ $this->otpRepository
+ ->shouldReceive('getByValueConnectionAndUserName')
+ ->once()
+ ->with($code, 'email', 'verify@example.com', $client)
+ ->andReturn($storedOtp);
+
+ // A pessimistic row lock is taken before redeeming (regression guard for 3357348444).
+ $this->otpRepository->shouldReceive('refreshExclusiveLock')->with($storedOtp)->once();
$otherOtp = \Mockery::mock(OAuth2OTP::class);
$otherOtp->shouldReceive('getValue')->andReturn('654321');
$otherOtp->shouldReceive('redeem')->once();
+ // Sibling revoke MUST be scoped to the SAME client so unrelated OTPs survive.
$this->otpRepository
->shouldReceive('getByUserNameNotRedeemed')
+ ->once()
+ ->with('verify@example.com', $client)
->andReturn([$otherOtp]);
- $this->strategy->verifyChallenge($user, $code);
+ // The redeemed code and the revoked sibling are both persisted with deferred flush.
+ $this->otpRepository->shouldReceive('add')->with($storedOtp, false)->once();
+ $this->otpRepository->shouldReceive('add')->with($otherOtp, false)->once();
+
+ $this->strategy->verifyChallenge($user, $code, $client);
$this->addToAssertionCount(1);
}
+
+ public function testVerifyChallenge_acquiresLock_andRejectsAlreadyRedeemed(): void
+ {
+ $user = $this->buildUser(1, 'verify@example.com');
+ $code = '123456';
+
+ $storedOtp = \Mockery::mock(OAuth2OTP::class);
+ $storedOtp->shouldReceive('getValue')->andReturn($code);
+ $storedOtp->shouldReceive('logRedeemAttempt')->once();
+ $storedOtp->shouldReceive('isAlive')->andReturn(true);
+ $storedOtp->shouldReceive('isValid')->andReturn(true);
+ $storedOtp->shouldReceive('getConnection')->andReturn('email');
+ // Concurrent winner already redeemed the row under the lock.
+ $storedOtp->shouldReceive('isRedeemed')->andReturn(true);
+ // Must NOT redeem again nor sweep siblings.
+ $storedOtp->shouldReceive('redeem')->never();
+
+ $this->otpRepository
+ ->shouldReceive('getByValueConnectionAndUserName')
+ ->once()
+ ->andReturn($storedOtp);
+
+ $this->otpRepository->shouldReceive('refreshExclusiveLock')->with($storedOtp)->once();
+ $this->otpRepository->shouldReceive('getByUserNameNotRedeemed')->never();
+ $this->otpRepository->shouldReceive('add')->never();
+
+ $this->expectException(\Auth\Exceptions\AuthenticationException::class);
+ $this->strategy->verifyChallenge($user, $code);
+ }
+
+ public function testVerifyChallenge_withNonMatchingCode_throws(): void
+ {
+ $user = $this->buildUser(1, 'verify@example.com');
+
+ $this->otpRepository
+ ->shouldReceive('getByValueConnectionAndUserName')
+ ->once()
+ ->andReturn(null);
+
+ $this->expectException(\Auth\Exceptions\AuthenticationException::class);
+ $this->strategy->verifyChallenge($user, 'wrong-code');
+ }
}
diff --git a/tests/Unit/TwoFactorAuditServiceTest.php b/tests/Unit/TwoFactorAuditServiceTest.php
index e16c5679..23a5cf3f 100644
--- a/tests/Unit/TwoFactorAuditServiceTest.php
+++ b/tests/Unit/TwoFactorAuditServiceTest.php
@@ -22,6 +22,7 @@
use Illuminate\Support\Facades\Queue;
use Mockery;
use Tests\TestCase;
+use Utils\Db\ITransactionService;
/**
* Class TwoFactorAuditServiceTest
@@ -35,6 +36,9 @@ final class TwoFactorAuditServiceTest extends TestCase
/** @var \Mockery\MockInterface&ITwoFactorAuditLogRepository */
private $repository;
+ /** @var \Mockery\MockInterface&ITransactionService */
+ private $tx_service;
+
/** @var \Mockery\MockInterface&User */
private $user;
@@ -44,7 +48,8 @@ protected function setUp(): void
Queue::fake();
$this->repository = Mockery::mock(ITwoFactorAuditLogRepository::class);
- $this->service = new TwoFactorAuditService($this->repository);
+ $this->tx_service = Mockery::mock(ITransactionService::class);
+ $this->service = new TwoFactorAuditService($this->repository, $this->tx_service);
$this->user = Mockery::mock(User::class);
$this->user->shouldReceive('getId')->andReturn(42);
@@ -70,7 +75,14 @@ public function testLogPersistsTwoFactorAuditLogWithCorrectFields(): void
->once()
->withArgs(function (TwoFactorAuditLog $log, bool $sync) use (&$persisted) {
$persisted = $log;
- return $sync === true;
+ return $sync === false;
+ });
+
+ $this->tx_service
+ ->shouldReceive('transaction')
+ ->once()
+ ->andReturnUsing(function (callable $callback) {
+ return $callback();
});
$this->service->log(
@@ -99,6 +111,13 @@ public function testLogEmitsOtlpAttributes(): void
$this->repository->shouldReceive('add')->once();
+ $this->tx_service
+ ->shouldReceive('transaction')
+ ->once()
+ ->andReturnUsing(function (callable $callback) {
+ return $callback();
+ });
+
$this->service->log(
$this->user,
TwoFactorAuditLog::EventChallengeSucceeded,
@@ -127,6 +146,13 @@ public function testLogEmitsSuccessFalseForChallengeFailed(): void
$this->repository->shouldReceive('add')->once();
+ $this->tx_service
+ ->shouldReceive('transaction')
+ ->once()
+ ->andReturnUsing(function (callable $callback) {
+ return $callback();
+ });
+
$this->service->log(
$this->user,
TwoFactorAuditLog::EventChallengeFailed,
@@ -150,6 +176,13 @@ public function testLogDoesNotDispatchJobWhenOtlpDisabled(): void
$this->repository->shouldReceive('add')->once();
+ $this->tx_service
+ ->shouldReceive('transaction')
+ ->once()
+ ->andReturnUsing(function (callable $callback) {
+ return $callback();
+ });
+
$this->service->log(
$this->user,
TwoFactorAuditLog::EventChallengeSucceeded,
@@ -177,6 +210,13 @@ public function testLogAcceptsNullMetadata(): void
return true;
});
+ $this->tx_service
+ ->shouldReceive('transaction')
+ ->once()
+ ->andReturnUsing(function (callable $callback) {
+ return $callback();
+ });
+
$this->service->log(
$this->user,
TwoFactorAuditLog::EventChallengeIssued,
diff --git a/tests/unit/AuthServiceValidateCredentialsTest.php b/tests/unit/AuthServiceValidateCredentialsTest.php
index a504adc4..678cc4c7 100644
--- a/tests/unit/AuthServiceValidateCredentialsTest.php
+++ b/tests/unit/AuthServiceValidateCredentialsTest.php
@@ -32,8 +32,9 @@
/**
* Class AuthServiceValidateCredentialsTest
* Verifies that AuthService::validateCredentials() validates the password
- * WITHOUT establishing a session, and that AuthService::loginUser() calls
- * Auth::login() for the 2FA completion step.
+ * WITHOUT establishing a session, and that AuthService::loginUser() registers
+ * the OIDC principal (clear + register with auth time) and calls Auth::login()
+ * for the 2FA completion step.
*/
#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses]
#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)]
@@ -44,6 +45,7 @@ final class AuthServiceValidateCredentialsTest extends PHPUnitTestCase
private AuthService $service;
private $mock_user_repository;
+ private $mock_principal_service;
// Facade aliases
private $auth_mock;
@@ -55,7 +57,7 @@ protected function setUp(): void
$this->mock_user_repository = $this->createMock(IUserRepository::class);
$mock_otp_repository = $this->createMock(IOAuth2OTPRepository::class);
- $mock_principal_service = $this->createMock(IPrincipalService::class);
+ $this->mock_principal_service = $this->createMock(IPrincipalService::class);
$mock_user_service = $this->createMock(IUserService::class);
$mock_user_action_service = $this->createMock(IUserActionService::class);
$mock_cache_service = $this->createMock(ICacheService::class);
@@ -72,7 +74,7 @@ protected function setUp(): void
$this->service = new AuthService(
$this->mock_user_repository,
$mock_otp_repository,
- $mock_principal_service,
+ $this->mock_principal_service,
$mock_user_service,
$mock_user_action_service,
$mock_cache_service,
@@ -140,6 +142,7 @@ public function testLoginUser_callsAuthLogin_withRememberTrue(): void
{
$user = Mockery::mock('Auth\User');
$user->shouldReceive('canLogin')->andReturn(true);
+ $user->shouldReceive('getId')->andReturn(123);
$this->auth_mock
->shouldReceive('login')
@@ -156,6 +159,7 @@ public function testLoginUser_callsAuthLogin_withRememberFalse(): void
{
$user = Mockery::mock('Auth\User');
$user->shouldReceive('canLogin')->andReturn(true);
+ $user->shouldReceive('getId')->andReturn(123);
$this->auth_mock
->shouldReceive('login')
@@ -165,6 +169,50 @@ public function testLoginUser_callsAuthLogin_withRememberFalse(): void
$this->service->loginUser($user, false);
}
+ /**
+ * Regression: loginUser() MUST register the OIDC principal before establishing
+ * the session, mirroring AuthService::login(). Without this, the principal has
+ * no auth_time, so the id_token auth_time claim is wrong and
+ * InteractiveGrantType::shouldForceReLogin() silently skips max_age / prompt=login
+ * enforcement for every password / 2FA / recovery login.
+ */
+ public function testLoginUser_registersPrincipal_withAuthTime(): void
+ {
+ $user = Mockery::mock('Auth\User');
+ $user->shouldReceive('canLogin')->andReturn(true);
+ $user->shouldReceive('getId')->andReturn(123);
+
+ $this->mock_principal_service->expects($this->once())->method('clear');
+ $this->mock_principal_service->expects($this->once())
+ ->method('register')
+ ->with(123, $this->greaterThan(0));
+
+ $this->auth_mock
+ ->shouldReceive('login')
+ ->once()
+ ->with($user, true);
+
+ $this->service->loginUser($user, true);
+ }
+
+ /**
+ * Regression: when the user cannot log in, loginUser() must throw BEFORE
+ * touching the principal — no clear()/register() and no Auth::login().
+ */
+ public function testLoginUser_doesNotRegisterPrincipal_whenCannotLogin(): void
+ {
+ $user = Mockery::mock('Auth\User');
+ $user->shouldReceive('canLogin')->andReturn(false);
+
+ $this->mock_principal_service->expects($this->never())->method('clear');
+ $this->mock_principal_service->expects($this->never())->method('register');
+ $this->auth_mock->shouldNotReceive('login');
+
+ $this->expectException(AuthenticationException::class);
+
+ $this->service->loginUser($user, true);
+ }
+
/**
* loginUser(user, [true|false]) and isActive or canLogin false throws an Exception.
*/
@@ -182,10 +230,10 @@ public function testLoginUser_throwsException_whenIsNotActive(): void
}
/**
- * UnverifiedEmailMemberException from the provider must be caught and
- * re-thrown as AuthenticationException (contract: @throws AuthenticationException only).
+ * UnverifiedEmailMemberException from the provider propagates to the caller
+ * so UserController::postLogin() can handle it with a specific UI message.
*/
- public function testUnverifiedUser_throwsAuthenticationException(): void
+ public function testUnverifiedUser_throwsUnverifiedEmailMemberException(): void
{
$username = 'unverified@example.com';
$password = 'any';
@@ -199,7 +247,7 @@ public function testUnverifiedUser_throwsAuthenticationException(): void
$this->auth_mock->shouldReceive('getProvider')->once()->andReturn($provider_mock);
$this->auth_mock->shouldNotReceive('login');
- $this->expectException(AuthenticationException::class);
+ $this->expectException(UnverifiedEmailMemberException::class);
$this->expectExceptionMessage('Email not verified.');
$this->service->validateCredentials($username, $password);