diff --git a/.github/workflows/pull_request_frontend_tests.yml b/.github/workflows/pull_request_frontend_tests.yml new file mode 100644 index 00000000..f2f8df58 --- /dev/null +++ b/.github/workflows/pull_request_frontend_tests.yml @@ -0,0 +1,138 @@ +name: Front End Tests On Pull Request + +on: + pull_request: + types: [opened, reopened, edited, synchronize] + branches: ["main"] + +jobs: + + js-unit-tests: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'yarn' + - name: Install JS dependencies + run: yarn install --frozen-lockfile + - name: Run Jest unit tests + run: yarn test:unit:ci + - name: Upload Jest coverage + uses: actions/upload-artifact@v4 + if: always() + with: + name: jest-coverage + path: tests/js/coverage + retention-days: 5 + + e2e-tests: + runs-on: ubuntu-latest + env: + APP_ENV: testing + APP_DEBUG: true + APP_KEY: base64:4vh0op/S1dAsXKQ2bbdCfWRyCI9r8NNIdPXyZWt9PX4= + APP_URL: http://localhost:8001 + DEV_EMAIL_TO: smarcet@gmail.com + DB_CONNECTION: mysql + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: idp_test + DB_USERNAME: root + DB_PASSWORD: 1qaz2wsx + REDIS_HOST: 127.0.0.1 + REDIS_PORT: 6379 + REDIS_DB: 0 + REDIS_PASSWORD: 1qaz2wsx + REDIS_DATABASES: 16 + SSL_ENABLED: false + SESSION_DRIVER: redis + SESSION_COOKIE_SECURE: false + PHP_VERSION: 8.3 + OTEL_SDK_DISABLED: true + OTEL_SERVICE_ENABLED: false + TURNSTILE_SITE_KEY: ${{ secrets.TURNSTILE_SITE_KEY }} + TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: 1qaz2wsx + MYSQL_DATABASE: idp_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + steps: + - name: Create Redis + uses: supercharge/redis-github-action@1.8.1 + with: + redis-port: 6379 + redis-password: 1qaz2wsx + - name: Check out repository code + uses: actions/checkout@v4 + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + extensions: pdo_mysql, mbstring, exif, pcntl, bcmath, sockets, gettext, apcu + - name: Install PHP dependencies + uses: ramsey/composer-install@v3 + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.PAT }}"} }' + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'yarn' + - name: Install JS dependencies + run: yarn install --frozen-lockfile + - name: Build frontend assets + run: yarn build + - name: Prepare application + run: | + ./update_doctrine.sh + php artisan doctrine:migrations:migrate --no-interaction + php artisan db:seed --force + php artisan idp:create-super-admin test@test.com '1Qaz2wsx!' + php artisan idp:create-raw-user e2e@test.com '1Qaz2wsx!' + for i in 001 002 003 004 005 006 007 008; do + php artisan idp:create-super-admin "mfa-ts-$i@test.com" '1Qaz2wsx!' + done + php artisan idp:create-super-admin mfa-oauth2@test.com '1Qaz2wsx!' + php artisan idp:create-super-admin mfa-oauth2-consent@test.com '1Qaz2wsx!' + php artisan idp:create-super-admin mfa-oauth2-trust@test.com '1Qaz2wsx!' + php artisan idp:create-oauth2-test-client + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + - name: Start web server + run: php artisan serve --host=127.0.0.1 --port=8001 & + - name: Wait for server to be ready + run: | + for i in $(seq 1 20); do + curl -sf http://localhost:8001 > /dev/null 2>&1 && echo "Server ready" && exit 0 + sleep 2 + done + echo "Server did not start in time" && exit 1 + - name: Run E2E tests + run: yarn test:e2e --reporter=list + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: tests/e2e/report + retention-days: 7 + - name: Upload Playwright traces + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-traces + path: test-results/ + retention-days: 7 diff --git a/.github/workflows/pull_request_unit_tests.yml b/.github/workflows/pull_request_unit_tests.yml index 462317c3..45df32f3 100644 --- a/.github/workflows/pull_request_unit_tests.yml +++ b/.github/workflows/pull_request_unit_tests.yml @@ -37,6 +37,8 @@ jobs: PHP_VERSION: 8.3 OTEL_SDK_DISABLED: true OTEL_SERVICE_ENABLED: false + TURNSTILE_SITE_KEY: ${{ secrets.TURNSTILE_SITE_KEY }} + TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} services: mysql: image: mysql:8.0 diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index ad2ede65..ec993b27 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -33,6 +33,8 @@ jobs: PHP_VERSION: 8.3 OTEL_SDK_DISABLED: true OTEL_SERVICE_ENABLED: false + TURNSTILE_SITE_KEY: ${{ secrets.TURNSTILE_SITE_KEY }} + TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} services: mysql: image: mysql:8.0 diff --git a/.github/workflows/push_frontend_tests.yml b/.github/workflows/push_frontend_tests.yml new file mode 100644 index 00000000..5376f6f0 --- /dev/null +++ b/.github/workflows/push_frontend_tests.yml @@ -0,0 +1,139 @@ +name: Front End Tests On Push + +on: push + +jobs: + + js-unit-tests: + runs-on: ubuntu-latest + steps: + - name: Check out repository code + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'yarn' + - name: Install JS dependencies + run: yarn install --frozen-lockfile + - name: Run Jest unit tests + run: yarn test:unit:ci + - name: Upload Jest coverage + uses: actions/upload-artifact@v4 + if: always() + with: + name: jest-coverage + path: tests/js/coverage + retention-days: 5 + + e2e-tests: + runs-on: ubuntu-latest + env: + APP_ENV: testing + APP_DEBUG: true + APP_KEY: base64:4vh0op/S1dAsXKQ2bbdCfWRyCI9r8NNIdPXyZWt9PX4= + APP_URL: http://localhost:8001 + DEV_EMAIL_TO: smarcet@gmail.com + DB_CONNECTION: mysql + DB_HOST: 127.0.0.1 + DB_PORT: 3306 + DB_DATABASE: idp_test + DB_USERNAME: root + DB_PASSWORD: 1qaz2wsx + REDIS_HOST: 127.0.0.1 + REDIS_PORT: 6379 + REDIS_DB: 0 + REDIS_PASSWORD: 1qaz2wsx + REDIS_DATABASES: 16 + SSL_ENABLED: false + SESSION_DRIVER: redis + SESSION_COOKIE_SECURE: false + PHP_VERSION: 8.3 + OTEL_SDK_DISABLED: true + OTEL_SERVICE_ENABLED: false + TURNSTILE_SITE_KEY: ${{ secrets.TURNSTILE_SITE_KEY }} + TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} + # `php artisan serve` (below) is PHP's built-in single-threaded dev server — + # it can only handle one request at a time, so Playwright workers must stay + # at 1 here or concurrent page loads queue up and blow the 30s test timeout. + PLAYWRIGHT_WORKERS: 1 + services: + mysql: + image: mysql:8.0 + env: + MYSQL_ROOT_PASSWORD: 1qaz2wsx + MYSQL_DATABASE: idp_test + ports: + - 3306:3306 + options: >- + --health-cmd="mysqladmin ping" + --health-interval=10s + --health-timeout=5s + --health-retries=3 + steps: + - name: Create Redis + uses: supercharge/redis-github-action@1.8.1 + with: + redis-port: 6379 + redis-password: 1qaz2wsx + - name: Check out repository code + uses: actions/checkout@v4 + - name: Install PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ env.PHP_VERSION }} + extensions: pdo_mysql, mbstring, exif, pcntl, bcmath, sockets, gettext, apcu + - name: Install PHP dependencies + uses: ramsey/composer-install@v3 + env: + COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ secrets.PAT }}"} }' + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'yarn' + - name: Install JS dependencies + run: yarn install --frozen-lockfile + - name: Build frontend assets + run: yarn build + - name: Prepare application + run: | + ./update_doctrine.sh + php artisan doctrine:migrations:migrate --no-interaction + php artisan db:seed --force + php artisan idp:create-super-admin test@test.com '1Qaz2wsx!' + php artisan idp:create-raw-user e2e@test.com '1Qaz2wsx!' + for i in 001 002 003 004 005 006 007 008; do + php artisan idp:create-super-admin "mfa-ts-$i@test.com" '1Qaz2wsx!' + done + php artisan idp:create-super-admin mfa-oauth2@test.com '1Qaz2wsx!' + php artisan idp:create-super-admin mfa-oauth2-consent@test.com '1Qaz2wsx!' + php artisan idp:create-super-admin mfa-oauth2-trust@test.com '1Qaz2wsx!' + php artisan idp:create-oauth2-test-client + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + - name: Start web server + run: php artisan serve --host=127.0.0.1 --port=8001 & + - name: Wait for server to be ready + run: | + for i in $(seq 1 20); do + curl -sf http://localhost:8001 > /dev/null 2>&1 && echo "Server ready" && exit 0 + sleep 2 + done + echo "Server did not start in time" && exit 1 + - name: Run E2E tests + run: yarn test:e2e --reporter=list + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: tests/e2e/report + retention-days: 7 + - name: Upload Playwright traces + uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-traces + path: test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 2b975b7c..74d9df00 100644 --- a/.gitignore +++ b/.gitignore @@ -52,4 +52,15 @@ model.sql /.phpunit.cache/ docker-compose/mysql/model/*.sql public/assets/*.map -public/assets/css/*.map \ No newline at end of file +public/assets/css/*.map +.codegraph +docs/plans + +# Playwright +/tests/e2e/report/ +/test-results/ + +# Jest +/tests/js/coverage/ +/playwright-report/ +/.playwright-out/ diff --git a/app/Console/Commands/CreateOAuth2TestClient.php b/app/Console/Commands/CreateOAuth2TestClient.php new file mode 100644 index 00000000..8fdaa402 --- /dev/null +++ b/app/Console/Commands/CreateOAuth2TestClient.php @@ -0,0 +1,106 @@ +findOneBy(['client_id' => self::CLIENT_ID]); + + if (is_null($client)) { + // The consent screen's getDeveloperEmail() dereferences the + // client's owner unconditionally - a client without one 500s + // as soon as a real login reaches /accounts/user/consent. + $owner = EntityManager::getRepository(User::class)->findOneBy(['email' => self::OWNER_EMAIL]); + if (is_null($owner)) { + $owner = new User(); + $owner->setEmail(self::OWNER_EMAIL); + $owner->verifyEmail(); + $owner->setPassword('1Qaz2wsx!'); + $owner->setFirstName(self::OWNER_EMAIL); + $owner->setLastName(self::OWNER_EMAIL); + $owner->setIdentifier(self::OWNER_EMAIL); + EntityManager::persist($owner); + EntityManager::flush(); + } + + $client = ClientFactory::build([ + 'app_name' => 'oauth2_test_app', + 'app_description' => 'oauth2_test_app', + 'client_id' => self::CLIENT_ID, + 'client_secret' => self::CLIENT_SECRET, + 'client_type' => IClient::ClientType_Confidential, + 'application_type' => IClient::ApplicationType_Web_App, + 'token_endpoint_auth_method' => OAuth2Protocol::TokenEndpoint_AuthMethod_ClientSecretBasic, + 'owner' => $owner, + 'rotate_refresh_token' => true, + 'use_refresh_token' => true, + 'redirect_uris' => self::REDIRECT_URI, + ]); + EntityManager::persist($client); + EntityManager::flush(); + $this->info('Created client: ' . self::CLIENT_ID); + } else { + $this->info('Client already exists: ' . self::CLIENT_ID); + } + + $scope = EntityManager::getRepository(ApiScope::class)->findOneBy(['name' => 'profile']); + if (is_null($scope)) { + $this->error("api scope 'profile' not found - run php artisan db:seed first"); + return 1; + } + + $client->addScope($scope); + EntityManager::persist($client); + EntityManager::flush(); + + return 0; + } +} diff --git a/app/Console/Commands/CreateRawUser.php b/app/Console/Commands/CreateRawUser.php new file mode 100644 index 00000000..b25450b8 --- /dev/null +++ b/app/Console/Commands/CreateRawUser.php @@ -0,0 +1,57 @@ +argument('email')); + $password = trim($this->argument('password')); + + $user = EntityManager::getRepository(User::class)->findOneBy(['email' => $email]); + if (is_null($user)) { + $user = new User(); + $user->setEmail($email); + $user->verifyEmail(); + $user->setPassword($password); + $user->setFirstName($email); + $user->setLastName($email); + $user->setIdentifier($email); + EntityManager::persist($user); + EntityManager::flush(); + $this->info("Created user: {$email}"); + } else { + $this->info("User already exists: {$email}"); + } + } +} diff --git a/app/Console/Commands/GetLatestOtp.php b/app/Console/Commands/GetLatestOtp.php new file mode 100644 index 00000000..c51a6ff4 --- /dev/null +++ b/app/Console/Commands/GetLatestOtp.php @@ -0,0 +1,52 @@ +argument('email')); + + // DoctrineOAuth2OTPRepository::getByUserNameNotRedeemed() orders by + // id DESC, so the newest not-yet-redeemed OTP is the FIRST result, + // not the last - an account with more than one pending OTP (e.g. a + // prior attempt that was never redeemed) would otherwise return a + // stale code. + $otps = $repository->getByUserNameNotRedeemed($email); + if (empty($otps)) { + $this->error("no pending otp for {$email}"); + return 1; + } + + $this->line(reset($otps)->getValue()); + return 0; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 89bf376a..03857599 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -29,6 +29,9 @@ class Kernel extends ConsoleKernel Commands\CleanOAuth2StaleData::class, Commands\CleanOpenIdStaleData::class, Commands\CreateSuperAdmin::class, + Commands\CreateRawUser::class, + Commands\CreateOAuth2TestClient::class, + Commands\GetLatestOtp::class, Commands\SpammerProcess\RebuildUserSpammerEstimator::class, Commands\SpammerProcess\UserSpammerProcessor::class, ]; diff --git a/app/Http/Controllers/Api/UserApiController.php b/app/Http/Controllers/Api/UserApiController.php index b32c7307..b334959b 100644 --- a/app/Http/Controllers/Api/UserApiController.php +++ b/app/Http/Controllers/Api/UserApiController.php @@ -16,6 +16,7 @@ use App\Http\Controllers\Traits\RequestProcessor; use App\Http\Controllers\UserValidationRulesFactory; use App\ModelSerializers\SerializerRegistry; +use App\Services\Auth\IRecoveryCodeService; use Auth\Repositories\IUserRepository; use Auth\User; use Exception; @@ -23,6 +24,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Request; +use Illuminate\Support\Facades\Validator; use models\exceptions\EntityNotFoundException; use models\exceptions\ValidationException; use OAuth2\Services\ITokenService; @@ -43,23 +45,31 @@ final class UserApiController extends APICRUDController */ private $token_service; + /** + * @var IRecoveryCodeService + */ + private $recovery_code_service; + /** * UserApiController constructor. * @param IUserRepository $user_repository * @param ILogService $log_service * @param IUserService $user_service * @param ITokenService $token_service + * @param IRecoveryCodeService $recovery_code_service */ public function __construct ( IUserRepository $user_repository, ILogService $log_service, IUserService $user_service, - ITokenService $token_service + ITokenService $token_service, + IRecoveryCodeService $recovery_code_service ) { parent::__construct($user_repository, $user_service, $log_service); $this->token_service = $token_service; + $this->recovery_code_service = $recovery_code_service; } /** @@ -247,6 +257,68 @@ public function updateMe() return $this->update(Auth::user()->getId()); } + /** + * Enables a 2FA method for the current user and generates the first batch of + * recovery codes for them. Plaintext codes are returned once in the response + * and never persisted. + * + * @return \Illuminate\Http\JsonResponse|mixed + */ + public function enableTwoFactor() + { + if (!Auth::check()) + return $this->error403(); + + return $this->processRequest(function () { + $data = Request::all(); + $validator = Validator::make($data, [ + 'method' => 'required|string|in:' . implode(',', User::ValidMFAMethods), + ]); + + if (!$validator->passes()) { + return $this->error412($validator->getMessageBag()->getMessages()); + } + + $user = Auth::user(); + $method = $data['method']; + + if ($user->isTwoFactorEnabled()) { + return $this->error412(['method' => ['Two-factor authentication is already enabled. Use the regenerate recovery codes endpoint to rotate your codes.']]); + } + + $codes = $this->recovery_code_service->enableTwoFactorAndGenerateCodes($user, $method); + + return $this->ok(['recovery_codes' => $codes]); + }); + } + + /** + * Invalidates the current user's recovery codes and generates a fresh batch. + * Plaintext codes are returned once in the response and never persisted. + * + * @return \Illuminate\Http\JsonResponse|mixed + */ + public function regenerateRecoveryCodes() + { + if (!Auth::check()) + return $this->error403(); + + return $this->processRequest(function () { + $data = Request::all(); + $validator = Validator::make($data, [ + 'current_password' => 'required|string', + ]); + + if (!$validator->passes()) { + return $this->error412($validator->getMessageBag()->getMessages()); + } + + $codes = $this->recovery_code_service->regenerateRecoveryCodes(Auth::user(), $data['current_password']); + + return $this->ok(['recovery_codes' => $codes]); + }); + } + public function revokeAllMyTokens() { if (!Auth::check()) diff --git a/app/Http/Controllers/Auth/RegisterController.php b/app/Http/Controllers/Auth/RegisterController.php index 4ec12ec0..f1bf24aa 100644 --- a/app/Http/Controllers/Auth/RegisterController.php +++ b/app/Http/Controllers/Auth/RegisterController.php @@ -173,15 +173,18 @@ public function showRegistrationForm(LaravelRequest $request) protected function validator(array $data) { $rules = [ - 'first_name' => 'required|string|max:100', - 'last_name' => 'required|string|max:100', - 'country_iso_code' => 'required|string|country_iso_alpha2_code', - 'email' => 'required|string|email|max:255', - 'password' => 'required|string|confirmed|password_policy', - 'cf-turnstile-response' => ['required', new Turnstile()], + 'first_name' => 'required|string|max:100', + 'last_name' => 'required|string|max:100', + 'country_iso_code' => 'required|string|country_iso_alpha2_code', + 'email' => 'required|string|email|max:255', + 'password' => 'required|string|confirmed|password_policy', ]; - if(!empty(Config::get("app.code_of_conduct_link", null))){ + if (!empty(Config::get("services.turnstile.secret", null))) { + $rules['cf-turnstile-response'] = ['required', new Turnstile()]; + } + + if (!empty(Config::get("app.code_of_conduct_link", null))) { $rules['agree_code_of_conduct'] = 'required|string|in:true'; } diff --git a/app/Http/Controllers/Traits/JsonResponses.php b/app/Http/Controllers/Traits/JsonResponses.php index e71726d1..b0d9fff7 100644 --- a/app/Http/Controllers/Traits/JsonResponses.php +++ b/app/Http/Controllers/Traits/JsonResponses.php @@ -15,6 +15,7 @@ use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Response; use Exception; +use Symfony\Component\HttpFoundation\Response as HttpResponse; /** * Trait JsonResponses * @package App\Http\Controllers\Traits @@ -23,11 +24,11 @@ trait JsonResponses { protected function error500(Exception $ex){ Log::error($ex); - return Response::json(array( 'error' => 'server error'), 500); + return Response::json(array( 'error' => 'server error'), HttpResponse::HTTP_INTERNAL_SERVER_ERROR); } protected function created($data='ok'){ - $res = Response::json($data, 201); + $res = Response::json($data, HttpResponse::HTTP_CREATED ); //jsonp if(Request::has('callback')) $res->setCallback(Request::input('callback')); @@ -36,7 +37,7 @@ protected function created($data='ok'){ protected function updated($data = 'ok', $has_content = true) { - $res = Response::json($data, $has_content ? 201 : 204); + $res = Response::json($data, $has_content ? HttpResponse::HTTP_CREATED : HttpResponse::HTTP_NO_CONTENT); //jsonp if (Request::has('callback')) { $res->setCallback(Request::input('callback')); @@ -45,7 +46,7 @@ protected function updated($data = 'ok', $has_content = true) } protected function deleted($data='ok'){ - $res = Response::json($data, 204); + $res = Response::json($data, HttpResponse::HTTP_NO_CONTENT); //jsonp if(Request::has('callback')) $res->setCallback(Request::input('callback')); @@ -61,19 +62,24 @@ protected function ok($data = 'ok'){ } protected function error400($data = ['message' => 'Bad Request']){ - return Response::json($data, 400); + return Response::json($data, HttpResponse::HTTP_BAD_REQUEST); } protected function error404($data = array('message' => 'Entity Not Found')){ if(!is_array($data)){ $data = ['message' => $data]; } - return Response::json($data, 404); + return Response::json($data, HttpResponse::HTTP_NOT_FOUND); } protected function error403($data = array('message' => 'Forbidden')) { - return Response::json($data, 403); + return Response::json($data, HttpResponse::HTTP_FORBIDDEN); + } + + protected function unauthorized($data = array('message' => 'UnAuthorized')) + { + return Response::json($data, HttpResponse::HTTP_UNAUTHORIZED); } /** @@ -94,6 +100,6 @@ protected function error412($messages){ if(!is_array($messages)){ $messages = [$messages]; } - return Response::json(array('message' => 'Validation Failed', 'errors' => $messages), 412); + return Response::json(array('message' => 'Validation Failed', 'errors' => $messages), HttpResponse::HTTP_PRECONDITION_FAILED); } } \ No newline at end of file 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 3d7c1213..fdd3b482 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -13,49 +13,59 @@ **/ use App\Http\Controllers\OpenId\DiscoveryController; -use RyanChandler\LaravelCloudflareTurnstile\Rules\Turnstile; -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\libs\OAuth2\Strategies\LoginHintProcessStrategy; use App\ModelSerializers\SerializerRegistry; +use App\Services\Auth\IDeviceTrustService; +use App\Services\Auth\IRecoveryCodeService; +use App\Services\Auth\ITwoFactorAuditService; +use App\Services\Auth\ITwoFactorGateService; +use App\Services\Auth\ITwoFactorRateLimitService; +use App\Services\Auth\IUserService as AuthUserService; use Auth\Exceptions\AuthenticationException; use Auth\Exceptions\UnverifiedEmailMemberException; -use App\Services\Auth\IUserService as AuthUserService; +use Auth\MFAConstants; +use Auth\User; use Exception; use Illuminate\Http\Request as LaravelRequest; -use Illuminate\Support\Facades\Config; -use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Redirect; +use Illuminate\Support\Facades\Request; use Illuminate\Support\Facades\Response; use Illuminate\Support\Facades\Session; use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\View; use models\exceptions\EntityNotFoundException; use models\exceptions\ValidationException; +use Models\OAuth2\Client; use Models\OAuth2\OAuth2OTP; use OAuth2\Factories\OAuth2AuthorizationRequestFactory; use OAuth2\OAuth2Message; use OAuth2\OAuth2Protocol; use OAuth2\Repositories\IApiScopeRepository; use OAuth2\Repositories\IClientRepository; -use OpenId\Services\IUserService; use OAuth2\Services\IMementoOAuth2SerializerService; use OAuth2\Services\IResourceServerService; use OAuth2\Services\ISecurityContextService; use OAuth2\Services\ITokenService; use OpenId\Services\IMementoOpenIdSerializerService; use OpenId\Services\ITrustedSitesService; +use OpenId\Services\IUserService; +use RyanChandler\LaravelCloudflareTurnstile\Rules\Turnstile; use Services\IUserActionService; use Sokil\IsoCodes\IsoCodesFactory; use Strategies\DefaultLoginStrategy; use Strategies\IConsentStrategy; +use Strategies\MFA\MFAChallengeStrategyFactory; use Strategies\OAuth2ConsentStrategy; use Strategies\OAuth2LoginStrategy; use Strategies\OpenIdConsentStrategy; use Strategies\OpenIdLoginStrategy; +use Utils\IPHelper; use Utils\Services\IAuthService; use Utils\Services\IServerConfigurationService; use Utils\Services\IServerConfigurationService as IUtilsServerConfigurationService; @@ -132,6 +142,31 @@ 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; + + /** + * @var ITwoFactorRateLimitService + */ + private $two_factor_rate_limit_service; + + /** + * @var IRecoveryCodeService + */ + private $recovery_code_service; + /** * @param IMementoOpenIdSerializerService $openid_memento_service * @param IMementoOAuth2SerializerService $oauth2_memento_service @@ -167,7 +202,12 @@ 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, + ITwoFactorRateLimitService $two_factor_rate_limit_service, + IRecoveryCodeService $recovery_code_service, ) { $this->openid_memento_service = $openid_memento_service; @@ -185,6 +225,11 @@ 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->two_factor_rate_limit_service = $two_factor_rate_limit_service; + $this->recovery_code_service = $recovery_code_service; $this->middleware(function ($request, $next) use($login_hint_process_strategy){ @@ -249,11 +294,22 @@ public function getLogin() public function cancelLogin() { + // A cancelled login must invalidate any pending MFA challenge server-side, + // not just reset the client's view of things - otherwise an OTP issued + // before cancel can still complete a login the user explicitly abandoned. + $method = Session::get('mfa_method'); + if (!is_null($method)) { + MFAChallengeStrategyFactory::create($method)->clearPendingState(); + } + $this->clearMFAUISessionState(); + return $this->login_strategy->cancelLogin(); } use JsonResponses; + use MFACookieManager; + /** * @return \Illuminate\Http\JsonResponse|mixed */ @@ -345,6 +401,32 @@ public function emitOTP() OAuth2Protocol::OAuth2PasswordlessPhoneNumber => ($connection == OAuth2Protocol::OAuth2PasswordlessConnectionSMS) ? $username : null ], $client); + // Restore-on-refresh: a subsequent GET /login can rehydrate the OTP + // screen from session instead of dropping back to the email form - + // same mechanism postLogin()'s MFA challengeRequired() branch already + // uses. user_verified is set unconditionally (not inside the + // existing-user lookup below) because loginWithOTP() auto-registers + // brand-new emails at redemption time; gating it on an existing user + // would silently break refresh-restoration for first-time passwordless + // users. + $existing_user = $this->auth_service->getUserByUsername($username); + Session::put('flow', IAuthService::AuthenticationFlowPasswordless); + Session::put('username', $username); + Session::put('user_verified', true); + // Mirrors login.js's emitOtpAction(), which falls back to the + // submitted email as the chip's display name when there's no real + // full name yet - persisting the same fallback here keeps the + // identity chip (visible right after opting into OTP) from + // vanishing on a refresh for a not-yet-registered email. + Session::put('user_fullname', !is_null($existing_user) ? $existing_user->getFullName() : $username); + Session::put('otp_length', $otp->getLength()); + Session::put('otp_lifetime', $otp->getLifetime()); + Session::put('otp_issued_at', $otp->getCreatedAt()?->getTimestamp() ?? time()); + if (!is_null($existing_user)) { + Session::put('user_pic', $existing_user->getPic()); + Session::put('user_is_active', $existing_user->isActive() ? 1 : 0); + } + return $this->created([ 'otp_length' => $otp->getLength(), 'otp_lifetime' => $otp->getLifetime(), @@ -436,38 +518,97 @@ 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 == 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)) { + // Initial issuance shares the resend rate-limit window + // (SDS idp-mfa.md §4.12) - without this, this route + // would be an unthrottled way to mail-bomb the account + // owner with OTP codes. + if ($this->two_factor_rate_limit_service->isRateLimited( + ITwoFactorRateLimitService::ActionResend, + $user->getId() + )) { + throw new AuthenticationException(ITwoFactorRateLimitService::RATE_LIMIT_MESSAGE); + } - if ($flow == "otp") { + // 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_rate_limit_service->increment(ITwoFactorRateLimitService::ActionResend, $user->getId()); + + // Best-effort: the challenge was already issued and the OTP + // sent, so an audit-logging failure must not 500 the user + // out of the mfa_required response they need to proceed. + try { + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventChallengeIssued, + $method, + IPHelper::getUserIp() + ); + } catch (\Throwable $ex) { + Log::warning($ex); + } - $client = null; + // Restore-on-refresh: a subsequent GET /login can rehydrate + // the 2FA screen from session instead of dropping back to the + // password form. otp_length/otp_lifetime (part of $payload) + // are flashed by challengeRequired() itself; flow/mfa_method + // aren't part of the challenge payload, so they're set here. + Session::put('flow', IAuthService::AuthenticationFlowMFA); + Session::put('mfa_method', $method); + + // The password step now submits as a native form POST, so this + // response is a fresh page load, not a client-side transition - + // without these, the React app remounts with no identity state + // at all (no chip, and Cancel/session-expiry can't return to the + // password screen because it looks like the user was never + // verified). Same fields/getters as the AuthenticationException + // errorLogin() branch below. + $payload = array_merge($payload, [ + 'username' => $username, + 'user_fullname' => $user->getFullName(), + 'user_pic' => $user->getPic(), + 'user_verified' => true, + 'user_is_active' => $user->isActive() ? 1 : 0, + ]); + + return $this->login_strategy->challengeRequired($payload); + } - // check if we have a former oauth2 request - if ($this->oauth2_memento_service->exists()) { + // No challenge required: establish the session and continue. + $this->auth_service->loginUser($user, $remember); + return $this->login_strategy->postLogin(); + } - Log::debug("UserController::postLogin exist a oauth auth request on session"); + if ($flow == IAuthService::AuthenticationFlowPasswordless) { - $oauth_auth_request = OAuth2AuthorizationRequestFactory::getInstance()->build - ( - OAuth2Message::buildFromMemento($this->oauth2_memento_service->load()) + // Passwordless login is single-factor (email access only) and + // must not be usable to satisfy MFA enforcement (SDS idp-mfa.md + // §7.4 / Open Question #3). + $existing_user = $this->auth_service->getUserByUsername($username); + if (!is_null($existing_user) && $existing_user->shouldRequire2FA()) { + throw new AuthenticationException( + "This account requires password and two-factor authentication. Please use the password login option." ); - - if ($oauth_auth_request->isValid()) { - - $client_id = $oauth_auth_request->getClientId(); - - $client = $this->client_repository->getClientById($client_id); - if (is_null($client)) - throw new ValidationException("client does not exists"); - - $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); + // A completed login must not leave the OTP screen restorable + // on a later refresh - same identity-leakage concern already + // fixed for the MFA flow's verify2FA()/verify2FARecovery(). + $this->clearMFAUISessionState(); return $this->login_strategy->postLogin(); } } catch (AuthenticationException $ex) { @@ -558,6 +699,366 @@ 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; + } + + /** + * 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($pending->getUserId()); + if (is_null($user) || !$user->isTwoFactorMethodEnabled($method)) { + $strategy->clearPendingState(); + return $this->mfaSessionExpired(); + } + + // Scope verification to the client the challenge was issued for. + $client = $this->resolveClientFromMemento(); + + 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 = $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 + // the rate-limit middleware keys its failure count on). + try { + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventChallengeFailed, + $method, + IPHelper::getUserIp() + ); + } catch (\Throwable $auditEx) { + Log::warning($auditEx); + } + return $this->unauthorized(['error_code' => MFAConstants::ERROR_CODE_VERIFICATION_FAILED]); + } + + // Second factor verified: establish the session. + $this->auth_service->loginUser($user, $pending->shouldRemember()); + + 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 (\Throwable $ex) { + Log::warning($ex); + } + } + + $strategy->clearPendingState(); + $this->clearMFAUISessionState(); + + try { + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventChallengeSucceeded, + $method, + IPHelper::getUserIp() + ); + } catch (\Throwable $ex) { + Log::warning($ex); + } + + // Return the same-origin post-login destination as data instead of a raw + // redirect for this XHR to follow: postLogin() can chain into a cross-origin + // hop (authorization code delivery to an already-consented OAuth2 client), + // which no XHR/fetch can read past - and per InteractiveGrantType::handle()'s + // consent-bypass branch, that hop also consumes the OAuth2 memento as a side + // effect, so a silently-failed XHR follow-through burns the authorization + // code with no way to recover it client-side. A real top-level navigation to + // this URL lets the browser complete that chain natively instead - CORS never + // applies to page navigations, only to XHR/fetch. + $redirect = $this->login_strategy->postLogin(); + return $this->ok(['redirect_url' => $redirect->getTargetUrl()]); + } 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($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 = $pending->getUserId(); + $user = $this->auth_service->getUserById($userId) ?? $user; + // Best-effort: see verify2FA() for rationale. + try { + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventChallengeFailed, + TwoFactorAuditLog::MethodRecovery, + IPHelper::getUserIp() + ); + } catch (\Throwable $auditEx) { + Log::warning($auditEx); + } + return $this->unauthorized(['error_code' => MFAConstants::ERROR_CODE_INVALID_RECOVERY]); + } + + $this->auth_service->loginUser($user, $pending->shouldRemember()); + $strategy->clearPendingState(); + $this->clearMFAUISessionState(); + + // Best-effort: the recovery code is already redeemed and the session + // established, so an audit-logging failure must not 500 the user + // (which would strand them after burning their last-resort code). + try { + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventRecoveryUsed, + TwoFactorAuditLog::MethodRecovery, + IPHelper::getUserIp() + ); + } catch (\Throwable $ex) { + Log::warning($ex); + } + + // See verify2FA() for rationale: return the destination as data so a real + // 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(array_merge( + ['redirect_url' => $redirect->getTargetUrl()], + $this->recovery_code_service->getStatus($user)->toArray() + )); + } 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($pending->getUserId()); + if (is_null($user) || !$user->isTwoFactorMethodEnabled($method)) { + $strategy->clearPendingState(); + return $this->mfaSessionExpired(); + } + + $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, + // mfa_method changes if this resend is actually a method switch). + Session::put('mfa_method', $method); + if (isset($payload['otp_length'])) { + Session::put('otp_length', $payload['otp_length']); + } + if (isset($payload['otp_lifetime'])) { + Session::put('otp_lifetime', $payload['otp_lifetime']); + } + if (isset($payload['otp_issued_at'])) { + Session::put('otp_issued_at', $payload['otp_issued_at']); + } + + // Best-effort: the challenge was already re-issued and the OTP + // sent, so an audit-logging failure must not 500 the user out of + // the payload they need to complete verification. + try { + $this->two_factor_audit_service->log( + $user, + TwoFactorAuditLog::EventChallengeIssued, + $method, + IPHelper::getUserIp() + ); + } catch (\Throwable $ex) { + Log::warning($ex); + } + + 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() + { + $this->clearMFAUISessionState(); + return $this->unauthorized(['error_code' => MFAConstants::ERROR_CODE_SESSION_EXPIRED]); + } + + /** + * Clears the UI-restoration session keys written when a challenge is + * issued (see postLogin()'s mfa_required branch). Companion to + * IMFAChallengeStrategy::clearPendingState(), which only owns the + * 2fa_* pending-state keys. + * + * @return void + */ + private function clearMFAUISessionState(): void + { + Session::forget('flow'); + Session::forget('mfa_method'); + Session::forget('otp_length'); + Session::forget('otp_lifetime'); + Session::forget('otp_issued_at'); + Session::forget('error_code'); + // Identity/display fields written by postLogin()'s challengeRequired() + // payload (needed only to hydrate the React app on the initial + // post-redirect GET /login mount) - must not survive cancel/verify + // success/session-expiry, or a later visitor on the same browser + // session inherits the previous attempt's identity. + Session::forget('username'); + Session::forget('user_fullname'); + Session::forget('user_pic'); + Session::forget('user_verified'); + Session::forget('user_is_active'); + } + /** * @return \Illuminate\Http\Response|mixed */ @@ -697,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()), @@ -705,7 +1206,8 @@ public function getProfile() 'actions' => $actions, 'countries' => CountryList::getCountries(), 'languages' => $lang2Code, - ]); + 'two_factor_enabled' => $user->shouldRequire2FA(), + ], $this->recovery_code_service->getStatus($user)->toArray())); } public function deleteTrustedSite($id) 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..26e8d7e9 --- /dev/null +++ b/app/Http/Middleware/TwoFactorRateLimitMiddleware.php @@ -0,0 +1,114 @@ +key; + + if ($this->rate_limit_service->isRateLimited($action, $subject)) { + Log::debug(sprintf("TwoFactorRateLimitMiddleware: action %s subject %s rate limited", $action, $subject)); + + $responseCallback = $limit->responseCallback; + return $responseCallback($request, [ + 'Retry-After' => $this->rate_limit_service->getRetryAfterSeconds($action, $subject), + 'X-RateLimit-Limit' => $this->rate_limit_service->getLimit($action), + 'X-RateLimit-Remaining' => 0, + ]); + } + + $response = $next($request); + + if ($action === ITwoFactorRateLimitService::ActionResend || $action === ITwoFactorRateLimitService::ActionOtp) { + $this->rate_limit_service->increment($action, $subject); + } else if ($this->isFailure($response)) { + $this->rate_limit_service->increment($action, $subject); + } + + return $response; + } + + /** + * @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/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 09ed90d4..e25e83f7 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -40,7 +40,7 @@ class AppServiceProvider extends ServiceProvider */ public function boot() { - if (!App::isLocal()) + if (Config::get('server.ssl_enabled', false)) URL::forceScheme('https'); $logger = Log::getLogger(); diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index cc31990c..7d9b56f3 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -57,10 +57,6 @@ protected function configureRateLimiting() return Limit::perMinute(5)->by(optional($request->user())->id ?: $request->ip()); }); - RateLimiter::for('otp', function (Request $request) { - return Limit::perMinute(10)->by(optional($request->user())->id ?: $request->ip()); - }); - RateLimiter::for('oauth2', function (Request $request) { $maxAttempts = App::environment() == "testing" ? PHP_INT_MAX : 50; return Limit::perMinute($maxAttempts)->by(optional($request->user())->id ?: $request->ip()); diff --git a/app/Repositories/DoctrineTwoFactorAuditLogRepository.php b/app/Repositories/DoctrineTwoFactorAuditLogRepository.php new file mode 100644 index 00000000..e87779f6 --- /dev/null +++ b/app/Repositories/DoctrineTwoFactorAuditLogRepository.php @@ -0,0 +1,34 @@ +findBy( + ['user' => $user], + ['created_at' => 'DESC'], + $limit + ); + } +} diff --git a/app/Repositories/DoctrineUserRecoveryCodeRepository.php b/app/Repositories/DoctrineUserRecoveryCodeRepository.php new file mode 100644 index 00000000..202e48b6 --- /dev/null +++ b/app/Repositories/DoctrineUserRecoveryCodeRepository.php @@ -0,0 +1,49 @@ +findBy([ + 'user' => $user, + 'used_at' => null, + ]); + } + + 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(); + $qb = $em->createQueryBuilder() + ->delete(UserRecoveryCode::class, 'c') + ->where('c.user = :user') + ->setParameter('user', $user); + return (int) $qb->getQuery()->execute(); + } +} diff --git a/app/Repositories/DoctrineUserTrustedDeviceRepository.php b/app/Repositories/DoctrineUserTrustedDeviceRepository.php new file mode 100644 index 00000000..37267066 --- /dev/null +++ b/app/Repositories/DoctrineUserTrustedDeviceRepository.php @@ -0,0 +1,80 @@ +gt('expires_at', $now); + } + + public function getByUserAndDeviceIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('user', $user)) + ->andWhere(Criteria::expr()->eq('device_identifier', $deviceIdentifier)) + ->setMaxResults(1); + + $result = $this->matching($criteria)->first(); + return $result instanceof UserTrustedDevice ? $result : null; + } + + public function revokeAllForUser(User $user): void + { + $this->getEntityManager() + ->createQueryBuilder() + ->update($this->getBaseEntity(), 'd') + ->set('d.is_revoked', ':revoked') + ->where('d.user = :user') + ->setParameter('revoked', true) + ->setParameter('user', $user) + ->getQuery() + ->execute(); + } + + public function getActiveByUserAndIdentifier(User $user, string $deviceIdentifier): ?UserTrustedDevice + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('user', $user)) + ->andWhere(Criteria::expr()->eq('device_identifier', $deviceIdentifier)) + ->andWhere(Criteria::expr()->eq('is_revoked', false)) + ->andWhere($this->buildActiveExpiryExpr()) + ->setMaxResults(1); + + $result = $this->matching($criteria)->first(); + return $result instanceof UserTrustedDevice ? $result : null; + } + + public function getActiveByUser(User $user): array + { + $criteria = Criteria::create() + ->where(Criteria::expr()->eq('user', $user)) + ->andWhere(Criteria::expr()->eq('is_revoked', false)) + ->andWhere($this->buildActiveExpiryExpr()); + + return $this->matching($criteria)->toArray(); + } +} diff --git a/app/Repositories/RepositoriesProvider.php b/app/Repositories/RepositoriesProvider.php index 61e01b2d..15939b37 100644 --- a/app/Repositories/RepositoriesProvider.php +++ b/app/Repositories/RepositoriesProvider.php @@ -13,7 +13,10 @@ **/ use App\libs\Auth\Models\SpamEstimatorFeed; +use App\libs\Auth\Models\TwoFactorAuditLog; +use App\libs\Auth\Models\UserRecoveryCode; use App\libs\Auth\Models\UserRegistrationRequest; +use App\libs\Auth\Models\UserTrustedDevice; use App\libs\Auth\Repositories\IBannedIPRepository; use App\libs\Auth\Repositories\IGroupRepository; use App\libs\Auth\Repositories\ISpamEstimatorFeedRepository; @@ -32,7 +35,10 @@ use App\Repositories\IServerConfigurationRepository; use App\Repositories\IServerExtensionRepository; use Auth\Group; +use Auth\Repositories\ITwoFactorAuditLogRepository; use Auth\Repositories\IUserActionRepository; +use Auth\Repositories\IUserRecoveryCodeRepository; +use Auth\Repositories\IUserTrustedDeviceRepository; use Auth\User; use Auth\UserPasswordResetRequest; use Illuminate\Contracts\Support\DeferrableProvider; @@ -271,6 +277,27 @@ function () { } ); + App::singleton( + IUserTrustedDeviceRepository::class, + function () { + return EntityManager::getRepository(UserTrustedDevice::class); + } + ); + + App::singleton( + ITwoFactorAuditLogRepository::class, + function () { + return EntityManager::getRepository(TwoFactorAuditLog::class); + } + ); + + App::singleton( + IUserRecoveryCodeRepository::class, + function () { + return EntityManager::getRepository(UserRecoveryCode::class); + } + ); + } public function provides() @@ -304,6 +331,9 @@ public function provides() IStreamChatSSOProfileRepository::class, IOAuth2OTPRepository::class, IUserActionRepository::class, + IUserTrustedDeviceRepository::class, + ITwoFactorAuditLogRepository::class, + IUserRecoveryCodeRepository::class, ]; } } \ No newline at end of file diff --git a/app/Services/Auth/DeviceTrustService.php b/app/Services/Auth/DeviceTrustService.php new file mode 100644 index 00000000..d4242c2f --- /dev/null +++ b/app/Services/Auth/DeviceTrustService.php @@ -0,0 +1,110 @@ +add(new DateInterval("P{$lifetimeDays}D")); + + $device = new UserTrustedDevice(); + $device->setUser($user); + $device->setDeviceIdentifier($this->generateDeviceIdentifier($rawToken)); + $device->setDeviceName(substr($userAgent, 0, 255)); + $device->setIpAddress($ipAddress); + $device->setUserAgent($userAgent); + $device->setTrustedAt($now); + $device->setExpiresAt($expiresAt); + $device->setLastSeenAt(clone $now); + $device->setIsRevoked(false); + + $this->tx_service->transaction(function () use ($device) { + $this->repository->add($device, false); + }); + + $this->audit_service->log( + $user, + TwoFactorAuditLog::EventDeviceTrusted, + $user->getTwoFactorMethod(), + $ipAddress + ); + + return $rawToken; + } + + public function isDeviceTrusted(User $user, ?string $cookieToken): bool + { + if (empty($cookieToken)) { + return false; + } + + $identifier = $this->generateDeviceIdentifier($cookieToken); + $device = $this->repository->getByUserAndDeviceIdentifier($user, $identifier); + + if (!$device instanceof UserTrustedDevice || $device->isRevoked() || $device->isExpired()) { + return false; + } + + $device->setLastSeenAt(new DateTime('now', new DateTimeZone('UTC'))); + $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/IDeviceTrustService.php b/app/Services/Auth/IDeviceTrustService.php new file mode 100644 index 00000000..2750335c --- /dev/null +++ b/app/Services/Auth/IDeviceTrustService.php @@ -0,0 +1,45 @@ +shouldRequire2FA()) { + return false; + } + return !$this->deviceTrustService->isDeviceTrusted($user, $cookieToken); + } +} diff --git a/app/Services/Auth/RecoveryCodeService.php b/app/Services/Auth/RecoveryCodeService.php new file mode 100644 index 00000000..a626dd82 --- /dev/null +++ b/app/Services/Auth/RecoveryCodeService.php @@ -0,0 +1,171 @@ +checkPassword(trim($currentPassword))) { + throw new ValidationException('current_password is not correct.'); + } + + return $this->generateRecoveryCodes($user); + } + + /** + * @inheritDoc + */ + public function generateRecoveryCodes(User $user): array + { + $codes = $this->tx_service->transaction(fn() => $this->regenerateCodesForUser($user)); + + // Best-effort: the codes are already committed and about to be shown to + // the user, so an audit-logging failure must not 500 this response - a + // client retry on a 500 would regenerate and invalidate the codes it + // just received. + try { + $this->audit_service->log( + $user, + TwoFactorAuditLog::EventRecoveryCodesGenerated, + TwoFactorAuditLog::MethodRecovery, + IPHelper::getUserIp() + ); + } catch (\Throwable $ex) { + Log::warning($ex); + } + + return $codes; + } + + /** + * @inheritDoc + */ + public function enableTwoFactorAndGenerateCodes(User $user, string $method): array + { + // Everything must live in a single transaction() call: it opens/commits + // its own connection-level transaction and closes the entity manager on + // failure (see DoctrineTransactionService::transaction()), so nesting a + // second call inside it (e.g. by calling generateRecoveryCodes() here) + // would let an inner failure tear down the EM out from under this + // still-running outer transaction. + $codes = $this->tx_service->transaction(function () use ($user, $method) { + $user->enable2FA($method); + $this->user_repository->add($user, false); + + return $this->regenerateCodesForUser($user); + }); + + // Best-effort: 2FA is already enabled and the codes are already + // committed, so an audit-logging failure must not 500 this response - a + // client retry on a 500 would regenerate and invalidate the codes it + // just received. + try { + $this->audit_service->log( + $user, + TwoFactorAuditLog::EventRecoveryCodesGenerated, + TwoFactorAuditLog::MethodRecovery, + IPHelper::getUserIp() + ); + + $this->audit_service->log( + $user, + TwoFactorAuditLog::EventEnrollmentChanged, + $method, + IPHelper::getUserIp() + ); + } catch (\Throwable $ex) { + Log::warning($ex); + } + + return $codes; + } + + /** + * Invalidates every existing recovery code for the user and generates a + * fresh batch, within the caller's already-open transaction. + * + * @return string[] plaintext codes formatted as XXXX-XXXX + */ + private function regenerateCodesForUser(User $user): array + { + $count = (int)config('auth.recovery_codes.count', 10); + $length = (int)config('auth.recovery_codes.length', 8); + + $plaintext_codes = []; + + $this->repository->deleteAllForUser($user); + + for ($i = 0; $i < $count; $i++) { + $plain = Rand::getString($length, self::CODE_CHARSET, true); + $plaintext_codes[] = $plain; + + $code = new UserRecoveryCode(); + $code->setUser($user); + $code->setCodeHash(Hash::make($plain)); + $this->repository->add($code, false); + } + + return array_map(static fn(string $code) => implode('-', str_split($code, 4)), $plaintext_codes); + } + + /** + * @inheritDoc + */ + 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/TwoFactorAuditService.php b/app/Services/Auth/TwoFactorAuditService.php new file mode 100644 index 00000000..a1dfd9f8 --- /dev/null +++ b/app/Services/Auth/TwoFactorAuditService.php @@ -0,0 +1,84 @@ + $user->getId(), + 'event_type' => $eventType, + 'method' => $method, + 'ip_address' => $ipAddress, + ]); + + $auditLog = new TwoFactorAuditLog(); + $auditLog->setUser($user); + $auditLog->setEventType($eventType); // throws InvalidArgumentException on unknown type + $auditLog->setMethod($method); // throws InvalidArgumentException on unknown method + $auditLog->setIpAddress($ipAddress); + // user_agent is captured from the current HTTP request context; falls back to empty + // string in CLI / queue contexts. A future signature change may accept $userAgent + // explicitly if project conventions require it (see ticket CU-86ba2z5gz). + $auditLog->setUserAgent(request()?->userAgent() ?? ''); + $auditLog->setMetadata($metadata); + + $this->tx_service->transaction(function () use ($auditLog) { + $this->repository->add($auditLog, false); + }); + + if (config('opentelemetry.enabled', false)) { + EmitAuditLogJob::dispatch('two_factor.audit', [ + 'two_factor.event_type' => $eventType, + 'two_factor.method' => $method, + 'two_factor.user_id' => $user->getId(), + 'two_factor.ip_address' => $ipAddress, + 'two_factor.success' => $this->resolveSuccess($eventType), + 'two_factor.device_trusted' => $eventType === TwoFactorAuditLog::EventDeviceTrusted, + 'elasticsearch.index' => config('opentelemetry.logs.elasticsearch_index', 'logs-audit'), + ]); + } + } + + /** + * Derive whether the 2FA event represents a successful outcome. + * Only challenge_failed is treated as a failure; all other event types + * represent informational or successful operations. + */ + private function resolveSuccess(string $eventType): bool + { + return $eventType !== TwoFactorAuditLog::EventChallengeFailed; + } +} diff --git a/app/Services/Auth/TwoFactorRateLimitService.php b/app/Services/Auth/TwoFactorRateLimitService.php new file mode 100644 index 00000000..df461192 --- /dev/null +++ b/app/Services/Auth/TwoFactorRateLimitService.php @@ -0,0 +1,99 @@ +limitsFor($action); + return RateLimiter::tooManyAttempts($this->cacheKey($action, $subject), $maxAttempts); + } + + public function increment(string $action, string|int $subject): void + { + [, $windowSeconds] = $this->limitsFor($action); + + // Fixed window: RateLimiter::hit() sets a companion "resets at" timer + // key once (only if absent) and bumps the counter while preserving + // that timer, so the window starts at the first hit and does not + // slide - same semantics the previous hand-rolled Cache::add()+ + // increment() had, but this also gives us availableIn() for + // Retry-After without a driver-specific TTL query. + RateLimiter::hit($this->cacheKey($action, $subject), $windowSeconds); + } + + public function getLimit(string $action): int + { + [$maxAttempts, ] = $this->limitsFor($action); + return $maxAttempts; + } + + public function getWindowSeconds(string $action): int + { + [, $windowSeconds] = $this->limitsFor($action); + return $windowSeconds; + } + + public function getRetryAfterSeconds(string $action, string|int $subject): int + { + return RateLimiter::availableIn($this->cacheKey($action, $subject)); + } + + /** + * @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, + ]; + } + + if ($action === self::ActionOtp) { + return [ + (int) Config::get('two_factor.rate_limit.max_otp_email_requests', 5), + (int) Config::get('two_factor.rate_limit.otp_email_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), + ]; + } + + /** + * @param string $action + * @param string|int $subject a user id for session-keyed actions, or a raw + * (already-canonicalized) subject string for ActionOtp + * @return string + */ + private function cacheKey(string $action, string|int $subject): string + { + return sprintf('%s%s:%s', self::RATE_LIMIT_CACHE_KEY_PREFIX, $action, $subject); + } +} diff --git a/app/Services/Auth/TwoFactorServiceProvider.php b/app/Services/Auth/TwoFactorServiceProvider.php new file mode 100644 index 00000000..b82bdd10 --- /dev/null +++ b/app/Services/Auth/TwoFactorServiceProvider.php @@ -0,0 +1,117 @@ +registerRateLimiters(); + } + + public function register(): void + { + $this->app->singleton(IDeviceTrustService::class, DeviceTrustService::class); + $this->app->singleton(ITwoFactorAuditService::class, TwoFactorAuditService::class); + $this->app->singleton(ITwoFactorGateService::class, MFAGateService::class); + $this->app->singleton(ITwoFactorRateLimitService::class, TwoFactorRateLimitService::class); + $this->app->singleton(IRecoveryCodeService::class, RecoveryCodeService::class); + } + + /** + * Named RateLimiter::for() limiters for the 2FA actions - own the two + * things that vanilla ->middleware('throttle:...') can declare: the + * throttled subject (Limit::by()) and the 429 response shape + * (Limit::response()). TwoFactorRateLimitMiddleware still enforces the + * limit itself and decides *when* to count a hit, because the stock + * throttle pipeline always increments before the request reaches the + * controller and has no hook for "only count on failure" - required for + * verify/recovery per SDS idp-mfa.md §4.12. Returning Limit::none() + * signals "no resolvable subject yet" so the middleware lets the request + * through and the controller resolves the (missing) state itself. + */ + private function registerRateLimiters(): void + { + $rateLimitService = $this->app->make(ITwoFactorRateLimitService::class); + + $respondRateLimited = fn ($request, array $headers) => Response::json( + [ + 'error_code' => ITwoFactorRateLimitService::RATE_LIMIT_ERROR_CODE, + 'error_message' => ITwoFactorRateLimitService::RATE_LIMIT_MESSAGE, + ], + HttpResponse::HTTP_TOO_MANY_REQUESTS + )->withHeaders($headers); + + $bySessionPendingUser = function (string $action) use ($rateLimitService, $respondRateLimited) { + $userId = Session::get(ITwoFactorRateLimitService::PENDING_USER_SESSION_KEY); + + if (is_null($userId)) { + return Limit::none(); + } + + return (new Limit( + (int) $userId, + $rateLimitService->getLimit($action), + $rateLimitService->getWindowSeconds($action) + ))->response($respondRateLimited); + }; + + $limiterName = fn (string $action) => ITwoFactorRateLimitService::RATE_LIMITER_NAME_PREFIX . $action; + + RateLimiter::for($limiterName(ITwoFactorRateLimitService::ActionVerify), fn () => $bySessionPendingUser(ITwoFactorRateLimitService::ActionVerify)); + RateLimiter::for($limiterName(ITwoFactorRateLimitService::ActionRecovery), fn () => $bySessionPendingUser(ITwoFactorRateLimitService::ActionRecovery)); + RateLimiter::for($limiterName(ITwoFactorRateLimitService::ActionResend), fn () => $bySessionPendingUser(ITwoFactorRateLimitService::ActionResend)); + + RateLimiter::for($limiterName(ITwoFactorRateLimitService::ActionOtp), function (Request $request) use ($rateLimitService, $respondRateLimited) { + $username = strtolower(trim($request->input('username', ''))); + + if ($username === '') { + return Limit::none(); + } + + return (new Limit( + $username, + $rateLimitService->getLimit(ITwoFactorRateLimitService::ActionOtp), + $rateLimitService->getWindowSeconds(ITwoFactorRateLimitService::ActionOtp) + ))->response($respondRateLimited); + }); + } + + public function provides(): array + { + return [ + IDeviceTrustService::class, + ITwoFactorAuditService::class, + ITwoFactorGateService::class, + ITwoFactorRateLimitService::class, + IRecoveryCodeService::class, + ]; + } +} diff --git a/app/Strategies/DefaultLoginStrategy.php b/app/Strategies/DefaultLoginStrategy.php index 1a90c770..693ee417 100644 --- a/app/Strategies/DefaultLoginStrategy.php +++ b/app/Strategies/DefaultLoginStrategy.php @@ -113,4 +113,22 @@ public function errorLogin(array $params) $response = $response->with($key, $val); return $response; } + + /** + * @param array $params + * @return mixed + */ + public function challengeRequired(array $params) + { + // The login form submits as a native form POST, so this must redirect + // like every other outcome (errorLogin()) rather than return JSON. + // Persistent (not one-shot flash) so the state survives repeated + // refreshes while the challenge is still pending. error_code mirrors + // what DisplayResponseJsonStrategy sends native clients in JSON. + Session::put('error_code', ILoginStrategy::MFA_REQUIRED); + foreach ($params as $key => $val) { + Session::put($key, $val); + } + return Redirect::action('UserController@getLogin'); + } } \ No newline at end of file diff --git a/app/Strategies/DisplayResponseJsonStrategy.php b/app/Strategies/DisplayResponseJsonStrategy.php index 3f30a325..150e1596 100644 --- a/app/Strategies/DisplayResponseJsonStrategy.php +++ b/app/Strategies/DisplayResponseJsonStrategy.php @@ -96,4 +96,13 @@ public function getLoginErrorResponse(array $data = []) } return Response::json($data, 412); } + + /** + * @param array $data + * @return SymfonyResponse + */ + public function getChallengeRequiredResponse(array $data = []) + { + return Response::json(array_merge(['error_code' => ILoginStrategy::MFA_REQUIRED], $data), 412); + } } \ No newline at end of file diff --git a/app/Strategies/DisplayResponseUserAgentStrategy.php b/app/Strategies/DisplayResponseUserAgentStrategy.php index 832d5bcb..ea8358ba 100644 --- a/app/Strategies/DisplayResponseUserAgentStrategy.php +++ b/app/Strategies/DisplayResponseUserAgentStrategy.php @@ -17,6 +17,7 @@ use Symfony\Component\HttpFoundation\Response as SymfonyResponse; use Illuminate\Support\Facades\Response; use Illuminate\Support\Facades\Redirect; +use Illuminate\Support\Facades\Session; /** * Class DisplayResponseUserAgentStrategy @@ -72,4 +73,25 @@ public function getLoginErrorResponse(array $data = []) return $response; } + + /** + * Same redirect+session-flash contract as getLoginErrorResponse(): OAuth2 + * page/popup/touch flows render the same login.js SPA via a native form + * POST, so the MFA challenge is delivered the same way every other login + * outcome is. Persistent (not one-shot flash) so the state survives + * repeated refreshes while the challenge is still pending. + * + * @param array $data + * @return SymfonyResponse + */ + public function getChallengeRequiredResponse(array $data = []) + { + // error_code mirrors what DisplayResponseJsonStrategy sends native + // clients in JSON. + Session::put('error_code', ILoginStrategy::MFA_REQUIRED); + foreach ($data as $key => $val) { + Session::put($key, $val); + } + return Redirect::action('UserController@getLogin'); + } } \ No newline at end of file diff --git a/app/Strategies/IDisplayResponseStrategy.php b/app/Strategies/IDisplayResponseStrategy.php index 019615a8..21e8a8a8 100644 --- a/app/Strategies/IDisplayResponseStrategy.php +++ b/app/Strategies/IDisplayResponseStrategy.php @@ -34,4 +34,13 @@ public function getLoginResponse(array $data = []); * @return SymfonyResponse */ public function getLoginErrorResponse(array $data = []); + + /** + * Factor 1 (password) passed but a 2FA challenge must be completed before + * a session is established. + * + * @param array $data + * @return SymfonyResponse + */ + public function getChallengeRequiredResponse(array $data = []); } \ No newline at end of file diff --git a/app/Strategies/ILoginStrategy.php b/app/Strategies/ILoginStrategy.php index 73120130..7d895329 100644 --- a/app/Strategies/ILoginStrategy.php +++ b/app/Strategies/ILoginStrategy.php @@ -1,10 +1,19 @@ self::SESSION_TTL) { + $this->clearPendingState(); + return null; + } + + return new MFAPendingState( + (int) $user_id, + (int) $pending_at, + (bool) Session::get(self::KEY_REMEMBER, false) + ); + } + + 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_RECOVERY_ATTEMPTS); + } + + public function verifyRecoveryCode(User $user, string $code): void + { + // Recovery codes are hashed without the "-" separator; it is added only + // for on-screen readability (XXXX-XXXX). Normalize here so a code typed + // or pasted exactly as displayed still matches the stored hash. + $code = strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $code)); + + 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(); + return; + } + } + throw new AuthenticationException("Invalid recovery code."); + } + + protected function storePendingState(int $userId, bool $remember): void + { + Session::put(self::KEY_USER_ID, $userId); + Session::put(self::KEY_PENDING_AT, time()); + Session::put(self::KEY_REMEMBER, $remember); + } + + public function verifyChallenge(User $user, string $code, ?Client $client = null): void + { + } + + public function issueChallenge(User $user, ?Client $client, bool $remember): array + { + return []; + } +} diff --git a/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php new file mode 100644 index 00000000..d5d24f8f --- /dev/null +++ b/app/Strategies/MFA/EmailOTPMFAChallengeStrategy.php @@ -0,0 +1,93 @@ +storePendingState($user->getId(), $remember); + + $otp = $this->token_service->createOTPFromPayload([ + OAuth2Protocol::OAuth2PasswordlessConnection => OAuth2Protocol::OAuth2PasswordlessConnectionEmail, + OAuth2Protocol::OAuth2PasswordlessSend => OAuth2Protocol::OAuth2PasswordlessSendCode, + OAuth2Protocol::OAuth2PasswordlessEmail => $user->getEmail(), + ], $client); + + return [ + 'otp_length' => $otp->getLength(), + 'otp_lifetime' => $otp->getLifetime(), + // Same source isAlive()/getRemainingLifetime() use server-side, so a + // UI countdown seeded from it can never drift from the actual expiry check. + 'otp_issued_at' => $otp->getCreatedAt()?->getTimestamp() ?? time(), + ]; + } + + public function verifyChallenge(User $user, string $code, ?Client $client = null): void + { + // 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."); + } + + $otp->logRedeemAttempt(); + + if (!$otp->isAlive()) { + throw new AuthenticationException("Verification code is expired."); + } + + if (!$otp->isValid()) { + 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(); + + // 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(); + } + } + } + + public function resendChallenge(User $user, ?Client $client, bool $remember): array + { + return $this->issueChallenge($user, $client, $remember); + } +} diff --git a/app/Strategies/MFA/IMFAChallengeStrategy.php b/app/Strategies/MFA/IMFAChallengeStrategy.php new file mode 100644 index 00000000..ddc34dc7 --- /dev/null +++ b/app/Strategies/MFA/IMFAChallengeStrategy.php @@ -0,0 +1,14 @@ + app()->make(EmailOTPMFAChallengeStrategy::class), + default => throw new \InvalidArgumentException("Unknown MFA method: {$method}"), + }; + } +} 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/Strategies/OAuth2LoginStrategy.php b/app/Strategies/OAuth2LoginStrategy.php index bcbd5123..8161062c 100644 --- a/app/Strategies/OAuth2LoginStrategy.php +++ b/app/Strategies/OAuth2LoginStrategy.php @@ -127,4 +127,21 @@ public function errorLogin(array $params) return $response_strategy->getLoginErrorResponse($params); } + + /** + * @param array $params + * @return mixed + */ + public function challengeRequired(array $params) + { + $auth_request = OAuth2AuthorizationRequestFactory::getInstance()->build( + OAuth2Message::buildFromMemento( + $this->memento_service->load() + ) + ); + + $response_strategy = DisplayResponseStrategyFactory::build($auth_request->getDisplay()); + + return $response_strategy->getChallengeRequiredResponse($params); + } } \ No newline at end of file diff --git a/app/libs/Auth/AuthService.php b/app/libs/Auth/AuthService.php index 928c5af8..f90af6cd 100644 --- a/app/libs/Auth/AuthService.php +++ b/app/libs/Auth/AuthService.php @@ -1,4 +1,5 @@ -user_repository = $user_repository; $this->principal_service = $principal_service; @@ -131,6 +131,14 @@ public function isUserLogged() return Auth::check(); } + /** + * @return User|null + */ + public function getCurrentUser(): ?User + { + return Auth::user(); + } + /** * Finds the OTP by value/connection/username, logs the redeem attempt (TX-A), * then validates lifecycle / value / scope / audience (TX-B). @@ -140,13 +148,12 @@ public function isUserLogged() * @throws InvalidOTPException */ private function findAndValidateOTP( - string $otp_value, - string $user_name, - string $otp_conn, + string $otp_value, + string $user_name, + string $otp_conn, ?string $otp_required_scopes, ?Client $client - ): OAuth2OTP - { + ): OAuth2OTP { // TX-A: find + log attempt (committed before any validation can throw) $otp = $this->tx_service->transaction(function () use ($otp_value, $otp_conn, $user_name, $client) { @@ -189,8 +196,10 @@ private function findAndValidateOTP( throw new InvalidOTPException("Single-use code requested scopes escalates former scopes."); } - if (($otp->hasClient() && is_null($client)) || - ($otp->hasClient() && !is_null($client) && $client->getClientId() != $otp->getClient()->getClientId())) { + if ( + ($otp->hasClient() && is_null($client)) || + ($otp->hasClient() && !is_null($client) && $client->getClientId() != $otp->getClient()->getClientId()) + ) { throw new AuthenticationException("Single-use code audience mismatch."); } @@ -319,8 +328,7 @@ public function verifyOTPChallenge( OAuth2OTP $otpClaim, User $sessionUser, ?Client $client = null - ): OAuth2OTP - { + ): OAuth2OTP { Log::debug(sprintf( "AuthService::verifyOTPChallenge otp %s session user %s", $otpClaim->getValue(), @@ -384,7 +392,6 @@ public function login(string $username, string $password, bool $remember_me): bo { Log::debug("AuthService::login"); - $this->last_login_error = ""; if (!Auth::attempt(['username' => $username, 'password' => $password], $remember_me)) { throw new AuthenticationException ( @@ -409,11 +416,50 @@ public function login(string $username, string $password, bool $remember_me): bo } /** + * @param string $username + * @param string $password * @return User|null + * @throws AuthenticationException */ - public function getCurrentUser(): ?User + public function validateCredentials(string $username, string $password): User { - return Auth::user(); + Log::debug("AuthService::validateCredentials"); + + /** + * @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."); + } + return $user; + } + + /** + * @param User $user + * @param bool $remember + * @return void + */ + 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."); + + // Auth::login() first: Laravel's SessionGuard::login() already + // regenerates the session ID internally (session->migrate(true)), + // closing the pre-auth session-fixation window. Principal bookkeeping + // runs AFTER so register()'s op_browser_state hash (used for OIDC + // Session Management) is derived from the FINAL id, not one that + // Auth::login() is about to invalidate. + Auth::login($user, $remember); + + $this->principal_service->clear(); + $this->principal_service->register + ( + $user->getId(), + time() + ); } /** @@ -458,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 @@ -618,7 +672,8 @@ public function registerRPLogin(string $client_id): void $rps = $zlib->uncompress($rps); $rps .= '|'; } - if (is_null($rps)) $rps = ""; + if (is_null($rps)) + $rps = ""; if (!str_contains($rps, $client_id)) $rps .= $client_id; @@ -720,12 +775,15 @@ public function postLoginUserActions(int $user_id): void Log::debug(sprintf("AuthService::postLoginUserActions user %s", $user_id)); $this->tx_service->transaction(function () use ($user_id) { $user = $this->user_repository->getById($user_id); - if (!$user instanceof User) return; + if (!$user instanceof User) + return; if (!$user->isActive()) { Log::warning(sprintf("AuthService::postLoginUserActions user %s is not active.", $user_id)); - throw new AuthenticationLockedUserLoginAttempt($user->getEmail(), - sprintf("User %s is locked.", $user->getEmail())); + throw new AuthenticationLockedUserLoginAttempt( + $user->getEmail(), + sprintf("User %s is locked.", $user->getEmail()) + ); } //update user fields @@ -736,4 +794,47 @@ 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 { + $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/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 @@ +metadata = null; + } + + public function getUser(): User + { + return $this->user; + } + + public function setUser(User $user): void + { + $this->user = $user; + } + + public function getEventType(): string + { + return $this->event_type; + } + + public function setEventType(string $value): void + { + if (!in_array($value, self::ALLOWED_EVENT_TYPES, true)) { + throw new \InvalidArgumentException('Unsupported 2FA audit event type.'); + } + $this->event_type = $value; + } + + public function getMethod(): string + { + return $this->method; + } + + public function setMethod(string $value): void + { + if (!in_array($value, self::ALLOWED_METHODS, true)) { + throw new \InvalidArgumentException('Unsupported 2FA audit method.'); + } + $this->method = $value; + } + + public function getIpAddress(): string + { + return $this->ip_address; + } + + public function setIpAddress(string $value): void + { + $this->ip_address = $value; + } + + public function getUserAgent(): string + { + return $this->user_agent; + } + + public function setUserAgent(string $value): void + { + $this->user_agent = $value; + } + + public function getMetadata(): ?array + { + return $this->metadata; + } + + public function setMetadata(?array $value): void + { + $this->metadata = $value; + } +} \ No newline at end of file diff --git a/app/libs/Auth/Models/User.php b/app/libs/Auth/Models/User.php index c1d70d96..33f4ba50 100644 --- a/app/libs/Auth/Models/User.php +++ b/app/libs/Auth/Models/User.php @@ -76,6 +76,15 @@ class User extends BaseEntity self::SpamTypeHam ]; + public const MFAMethod_OTP = 'email_otp'; + public const MFAMethod_SMS = 'sms_otp'; + public const MFAMethod_TOTP = 'totp'; + public const MFAMethod_PASSKEY = 'passkey'; + + public const ValidMFAMethods = [ + self::MFAMethod_OTP + ]; + /** * @var string */ @@ -303,6 +312,25 @@ class User extends BaseEntity */ #[ORM\Column(name: 'email_verified_date', nullable: true, type: 'datetime')] private $email_verified_date; + + /** + * @var bool + */ + #[ORM\Column(name: 'two_factor_enabled', type: 'boolean', options: ['default' => false])] + private $two_factor_enabled; + + /** + * @var string + */ + #[ORM\Column(name: 'two_factor_method', type: 'string', length: 32, options: ['default' => self::MFAMethod_OTP])] + private $two_factor_method; + + /** + * @var \DateTime|null + */ + #[ORM\Column(name: 'two_factor_enforced_at', nullable: true, type: 'datetime')] + private $two_factor_enforced_at; + /** * @var string */ @@ -457,6 +485,9 @@ public function __construct() parent::__construct(); $this->active = true; $this->email_verified = false; + $this->two_factor_enabled = false; + $this->two_factor_method = self::MFAMethod_OTP; + $this->two_factor_enforced_at = null; // user profile settings $this->public_profile_show_photo = false; $this->public_profile_show_email = false; @@ -2359,4 +2390,148 @@ public function getAuthPasswordName() return 'password'; } + // --- Two-factor authentication --------------------------------------- + + public function isTwoFactorEnabled(): bool + { + return (bool) $this->two_factor_enabled; + } + + public function setTwoFactorEnabled(bool $enabled): void + { + $this->two_factor_enabled = $enabled; + } + + public function getTwoFactorMethod(): string + { + return $this->two_factor_method; + } + + /** + * @throws ValidationException + */ + protected function setTwoFactorMethod(string $method): void + { + $this->two_factor_method = $method; + } + + public function getTwoFactorEnforcedAt(): ?\DateTime + { + return $this->two_factor_enforced_at; + } + + public function setTwoFactorEnforcedAt(?\DateTime $at): void + { + $this->two_factor_enforced_at = $at; + } + + /** + * Whether this user is required to complete 2FA to sign in. + * + * The global kill-switch is honored first: when config('two_factor.enabled') + * is false the whole 2FA gate is inactive (SDS idp-mfa.md §10.1), so no user + * is required regardless of role or preference. Otherwise a user is required + * when they belong to any of the groups listed in + * config('two_factor.enforced_groups'); failing that, the stored flag applies. + */ + public function shouldRequire2FA(): bool + { + if (!config('two_factor.enabled', true)) { + return false; + } + $enforcedGroups = config('two_factor.enforced_groups', []); + foreach ($enforcedGroups as $slug) { + if($this->belongToGroup($slug)) { + return true; + } + } + return (bool) $this->two_factor_enabled; + } + + /** + * @throws ValidationException + */ + public function enable2FA(string $method): void + { + $availableMethods = $this->getAvailableTwoFactorMethods(); + if(!in_array($method, self::ValidMFAMethods, true)) { + throw new ValidationException( + sprintf( + "Invalid 2FA method '%s'. Allowed methods: %s. Enabled methods: %s", + $method, + implode(', ', self::ValidMFAMethods), + implode(', ', $availableMethods) + ) + ); + } + + if(!in_array($method, $availableMethods, true)) { + throw new ValidationException( + sprintf( + "Disabled 2FA method '%s'. Enabled methods: %s", + $method, + implode(', ', $availableMethods) + ) + ); + } + + $this->setTwoFactorMethod($method); + $this->setTwoFactorEnabled(true); + $this->setTwoFactorEnforcedAt(new \DateTime('now', new \DateTimeZone('UTC'))); + } + + public function disable2FA(): void + { + $this->setTwoFactorEnabled(false); + $this->setTwoFactorEnforcedAt(null); + } + + /** + * Returns the set of 2FA methods currently available to this user. + * Phase I only supports email_otp; other methods are stubs that will + * light up in Phase II/III once the backing verifications exist. + * + * @return string[] + */ + public function getAvailableTwoFactorMethods(): array + { + $methods = []; + if($this->isEmailVerified() && in_array(self::MFAMethod_OTP, self::ValidMFAMethods, true)) { + $methods[] = self::MFAMethod_OTP; + } + if($this->isPhoneNumberVerified() && in_array(self::MFAMethod_SMS, self::ValidMFAMethods, true)) { + $methods[] = self::MFAMethod_SMS; + } + if($this->isTOTPConfirmed() && in_array(self::MFAMethod_TOTP, self::ValidMFAMethods, true)) { + $methods[] = self::MFAMethod_TOTP; + } + if($this->isPassKeyEnabled() && in_array(self::MFAMethod_PASSKEY, self::ValidMFAMethods, true)) { + $methods[] = self::MFAMethod_PASSKEY; + } + return $methods; + } + + public function isTwoFactorMethodEnabled(string $method): bool + { + return in_array($method, $this->getAvailableTwoFactorMethods(), true); + } + + // Phase II stub + public function isPhoneNumberVerified(): bool + { + return false; + } + + // Phase III stub + public function isTOTPConfirmed(): bool + { + return false; + } + + // Phase III stub + public function isPassKeyEnabled(): bool + { + return false; + } + } \ No newline at end of file diff --git a/app/libs/Auth/Models/UserRecoveryCode.php b/app/libs/Auth/Models/UserRecoveryCode.php new file mode 100644 index 00000000..7f324b16 --- /dev/null +++ b/app/libs/Auth/Models/UserRecoveryCode.php @@ -0,0 +1,86 @@ +used_at = null; + } + + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(User $user): void + { + $this->user = $user; + } + + public function getCodeHash(): string + { + return $this->code_hash; + } + + public function setCodeHash(string $value): void + { + $info = password_get_info($value); + if (($info['algo'] ?? null) !== PASSWORD_BCRYPT) { + throw new \InvalidArgumentException('code_hash must be a bcrypt hash'); + } + $this->code_hash = $value; + } + + public function getUsedAt(): ?\DateTime + { + return $this->used_at; + } + + + public function isUsed(): bool + { + return !is_null($this->used_at); + } + + public function markUsed(): void + { + if ($this->used_at !== null) { + throw new ValidationException('Recovery code already used at ' . $this->used_at->format(\DateTime::ATOM)); + } + $this->used_at = new \DateTime('now', new \DateTimeZone('UTC')); + } + +} \ No newline at end of file diff --git a/app/libs/Auth/Models/UserTrustedDevice.php b/app/libs/Auth/Models/UserTrustedDevice.php new file mode 100644 index 00000000..16b836a8 --- /dev/null +++ b/app/libs/Auth/Models/UserTrustedDevice.php @@ -0,0 +1,147 @@ + 0])] + private $is_revoked; + + public function __construct() + { + parent::__construct(); + $this->last_seen_at = new \DateTime('now', new \DateTimeZone('UTC')); + $this->is_revoked = false; + } + + public function getUser(): User + { + return $this->user; + } + public function setUser(User $user): void + { + $this->user = $user; + } + + public function getDeviceIdentifier(): string + { + return $this->device_identifier; + } + public function setDeviceIdentifier(string $value): void + { + $this->device_identifier = $value; + } + + public function getDeviceName(): string + { + return $this->device_name; + } + public function setDeviceName(string $value): void + { + $this->device_name = $value; + } + + public function getIpAddress(): string + { + return $this->ip_address; + } + public function setIpAddress(string $value): void + { + $this->ip_address = $value; + } + + public function getUserAgent(): string + { + return $this->user_agent; + } + public function setUserAgent(string $value): void + { + $this->user_agent = $value; + } + + public function getTrustedAt(): \DateTime + { + return $this->trusted_at; + } + public function setTrustedAt(\DateTime $value): void + { + $this->trusted_at = $value; + } + + public function getExpiresAt(): \DateTime + { + return $this->expires_at; + } + public function setExpiresAt(\DateTime $value): void + { + $this->expires_at = $value; + } + + public function getLastSeenAt(): \DateTime + { + return $this->last_seen_at; + } + public function setLastSeenAt(\DateTime $value): void + { + $this->last_seen_at = $value; + } + + public function isRevoked(): bool + { + return (bool) $this->is_revoked; + } + public function setIsRevoked(bool $value): void + { + $this->is_revoked = $value; + } + + public function isExpired(): bool + { + $now = new \DateTime('now', new \DateTimeZone('UTC')); + return $this->expires_at < $now; + } +} \ No newline at end of file diff --git a/app/libs/Auth/Repositories/ITwoFactorAuditLogRepository.php b/app/libs/Auth/Repositories/ITwoFactorAuditLogRepository.php new file mode 100644 index 00000000..4948149b --- /dev/null +++ b/app/libs/Auth/Repositories/ITwoFactorAuditLogRepository.php @@ -0,0 +1,24 @@ +markUsed double-spend race. + */ + public function refreshExclusiveLock(UserRecoveryCode $code): void; + + /** + * Delete every recovery code for a user (used when regenerating). + */ + public function deleteAllForUser(User $user): int; +} diff --git a/app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php b/app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php new file mode 100644 index 00000000..04e86edf --- /dev/null +++ b/app/libs/Auth/Repositories/IUserTrustedDeviceRepository.php @@ -0,0 +1,39 @@ + env('AUTH_PASSWORD_SHAPE_WARNING', 'Password must include at least one uppercase letter, one lowercase letter, one number, and one special character (#?!@$%^&*+-).'), 'verification_email_lifetime' => env("AUTH_VERIFICATION_EMAIL_LIFETIME", 600), 'allows_native_auth' => env('AUTH_ALLOWS_NATIVE_AUTH', 1), + + 'recovery_codes' => [ + 'count' => env('MFA_RECOVERY_CODES_COUNT', 10), + 'length' => env('MFA_RECOVERY_CODE_LENGTH', 8), + 'low_threshold' => env('MFA_RECOVERY_CODES_LOW_THRESHOLD', 3), + ], 'allows_native_on_config' => env('AUTH_ALLOWS_NATIVE_AUTH_CONFIG', 1), 'allows_opt_auth' => env('AUTH_ALLOWS_OTP_AUTH', 1), ]; diff --git a/config/session.php b/config/session.php index 39306e12..6e18cbbe 100644 --- a/config/session.php +++ b/config/session.php @@ -148,7 +148,7 @@ | */ - 'secure' => true, + 'secure' => env('SESSION_SECURE_COOKIE', false), /* |-------------------------------------------------------------------------- @@ -176,6 +176,6 @@ | */ - 'same_site' => 'none', + 'same_site' => env('SESSION_COOKIE_SAME_SITE', 'lax'), ]; diff --git a/config/two_factor.php b/config/two_factor.php new file mode 100644 index 00000000..48491d80 --- /dev/null +++ b/config/two_factor.php @@ -0,0 +1,79 @@ + env('TWO_FACTOR_ENABLED', true), + + /* + |-------------------------------------------------------------------------- + | Enforced Groups + |-------------------------------------------------------------------------- + | + | Users that belong to any of these groups are required to complete 2FA + | regardless of the value of their `two_factor_enabled` flag. + | + */ + 'enforced_groups' => [ + IGroupSlugs::SuperAdminGroup, + IGroupSlugs::AdminGroup, + IGroupSlugs::OAuth2ServerAdminGroup, + IGroupSlugs::OpenIdServerAdminsGroup, + ], + + /* + |-------------------------------------------------------------------------- + | Device Trust + |-------------------------------------------------------------------------- + */ + '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), + + // Passwordless OTP issuance (POST /auth/login/otp) - anonymous, pre-auth + // endpoint, keyed by the submitted email rather than a session user id. + // Kept independent from the MFA resend keys above so ops can tune this + // budget separately. + 'max_otp_email_requests' => env('TWO_FACTOR_MAX_OTP_EMAIL_REQUESTS', 5), + 'otp_email_window_minutes' => env('TWO_FACTOR_OTP_EMAIL_WINDOW_MINUTES', 15), + ], +]; diff --git a/database/migrations/Version20260416194357.php b/database/migrations/Version20260416194357.php new file mode 100644 index 00000000..d86375b0 --- /dev/null +++ b/database/migrations/Version20260416194357.php @@ -0,0 +1,119 @@ +hasTable("users") && !$builder->hasColumn("users", "two_factor_enabled")) { + $builder->table('users', function (Table $table) { + $table->boolean('two_factor_enabled')->setNotnull(true)->setDefault(false); + $table->string('two_factor_method', 32)->setNotnull(true)->setDefault('email_otp'); + $table->dateTime('two_factor_enforced_at')->setNotnull(false)->setDefault(null); + }); + } + + // 2) Create user_trusted_devices + if (!$builder->hasTable("user_trusted_devices")) { + $builder->create('user_trusted_devices', function (Table $table) { + $table->increments('id'); + $table->dateTime('created_at'); + $table->dateTime('updated_at')->setNotnull(false); + $table->bigInteger("user_id")->setUnsigned(true); + $table->string('device_identifier', 255); + $table->string('device_name', 255); + $table->string('ip_address', 45); + $table->text('user_agent'); + $table->dateTime('trusted_at'); + $table->dateTime('expires_at'); + $table->dateTime('last_seen_at'); + $table->boolean('is_revoked')->setNotnull(true)->setDefault(false); + $table->unique(["user_id", "device_identifier"], "utd_user_device_uniq"); + $table->index(["user_id", "is_revoked"], "utd_user_revoked_idx"); + $table->index(["expires_at"], "utd_expires_idx"); + $table->foreign("users", "user_id", "id", ["onDelete" => "CASCADE"]); + }); + } + + // 3) Create two_factor_audit_log + if (!$builder->hasTable("two_factor_audit_log")) { + $builder->create('two_factor_audit_log', function (Table $table) { + $table->increments('id'); + $table->dateTime('created_at'); + $table->dateTime('updated_at')->setNotnull(false); + $table->bigInteger("user_id")->setUnsigned(true); + $table->string('event_type', 64); + $table->string('method', 32); + $table->string('ip_address', 45); + $table->text('user_agent'); + $table->json('metadata')->setNotnull(false)->setDefault(null); + $table->index(["user_id", "event_type", "created_at"], "tfa_user_event_created_idx"); + $table->index(["created_at"], "tfa_created_idx"); + $table->foreign("users", "user_id", "id", ["onDelete" => "CASCADE"]); + }); + } + + // 4) Create user_recovery_codes + if (!$builder->hasTable("user_recovery_codes")) { + $builder->create('user_recovery_codes', function (Table $table) { + $table->increments('id'); + $table->dateTime('created_at'); + $table->dateTime('updated_at')->setNotnull(false); + $table->bigInteger("user_id")->setUnsigned(true); + $table->string('code_hash', 72)->setNotnull(true); + $table->dateTime('used_at')->setNotnull(false)->setDefault(null); + $table->index(["user_id", "used_at"], "urc_user_used_idx"); + $table->unique(["user_id", "code_hash"], "urc_user_codehash_uniq"); + $table->foreign("users", "user_id", "id", ["onDelete" => "CASCADE"]); + }); + } + } + + public function down(Schema $schema): void + { + $builder = new Builder($schema); + + if ($builder->hasTable("user_recovery_codes")) { + $builder->drop('user_recovery_codes'); + } + if ($builder->hasTable("two_factor_audit_log")) { + $builder->drop('two_factor_audit_log'); + } + if ($builder->hasTable("user_trusted_devices")) { + $builder->drop('user_trusted_devices'); + } + if ($schema->hasTable("users") && $builder->hasColumn("users", "two_factor_enabled")) { + $builder->table('users', function (Table $table) { + $table->dropColumn('two_factor_enforced_at'); + $table->dropColumn('two_factor_method'); + $table->dropColumn('two_factor_enabled'); + }); + } + } +} \ No newline at end of file diff --git a/doc/adrs/0001-recovery-code-generation-not-triggered-by-group-enforcement.md b/doc/adrs/0001-recovery-code-generation-not-triggered-by-group-enforcement.md new file mode 100644 index 00000000..31ed8bbd --- /dev/null +++ b/doc/adrs/0001-recovery-code-generation-not-triggered-by-group-enforcement.md @@ -0,0 +1,91 @@ +# 0001. Recovery code generation is not automatically triggered by group-based 2FA enforcement + +## Status + +Accepted — 2026-07-08 + +## Context + +Ticket [CU-86ba2zp66](https://app.clickup.com/t/86ba2zp66) ("Recovery Code Management UI and Endpoints") requires: + +> Create endpoint to generate recovery codes **when 2FA is enabled or admin enforcement requires code creation**. + +Two-factor authentication in this project can become mandatory for a user in two distinct ways: + +1. **Explicit self-enrollment** — the user calls the new `UserApiController::enableTwoFactor()` endpoint + (`app/Http/Controllers/Api/UserApiController.php`), which invokes `User::enable2FA($method)` and, in the + same transaction, calls `RecoveryCodeService::generateRecoveryCodes()` to mint the user's first batch of + recovery codes. +2. **Group-based enforcement** — `User::shouldRequire2FA()` (`app/libs/Auth/Models/User.php`) returns `true` + for any user belonging to one of the groups listed in `config('two_factor.enforced_groups')` + (`config/two_factor.php`: SuperAdmin, Admin, OAuth2ServerAdmin, OpenIdServerAdmins), **independently** of + whether that user ever called `enableTwoFactor()` or has the `two_factor_enabled` column set. + +Only path (1) generates recovery codes automatically. Path (2) has no corresponding hook: there is no +listener on group-membership assignment (e.g. when a user is added to the `Admin` group via +`GroupApiController` or any other admin-management flow) that generates a recovery-code batch for that user. + +Practical consequence: a user who is force-enrolled into MFA purely by being added to an enforced group — +and who never separately visits their profile to enable 2FA or regenerate codes — has **zero recovery codes** +until they proactively open their profile's "Two-Factor Authentication" section and click "Regenerate Codes" +(`resources/js/components/recovery_codes_panel.js`). That manual path works correctly and is not gated on +prior enrollment, but it is not automatic, so the literal wording of the ticket ("or admin enforcement +requires code creation") is not satisfied for this path. + +Implementing the automatic path would require identifying every place group membership can be granted +(direct admin action, bulk import, programmatic group assignment, etc.) and wiring a listener/hook into each +one to call `RecoveryCodeService::generateRecoveryCodes()` exactly once per user, without duplicating codes +on repeated grants or interfering with a user who already regenerated codes themselves. No single +well-defined integration point for "group membership changed" was identified during implementation without +a dedicated exploration pass, and building one was judged to be a meaningfully larger change than the rest +of this ticket. + +## Decision + +We accept the gap as a documented scope limitation for this ticket. Recovery code generation for +group-enforced users remains **on-demand**: the user (or an admin acting on their behalf, e.g. via a support +flow) must visit the profile's Two-Factor Authentication section and use "Regenerate Codes" at least once +after being enrolled through group enforcement. + +No code changes accompany this decision; it documents the trade-off already present in the shipped +implementation (`feat/recovery-codes-management` branch). + +## Consequences + +**Positive** + +- No new event/listener infrastructure needed for group-membership changes, keeping the change surface of + this ticket limited to the profile self-service flow it was originally scoped around. +- The manual path is simple, already implemented, and requires no additional user-facing concept: a + group-enforced user sees the same "Two-Factor Authentication" section and the same "Regenerate Codes" + action as anyone who self-enrolled. +- Avoids the risk of generating recovery codes an admin never asked for or expects during unrelated + group-management operations (e.g. bulk group imports). + +**Negative** + +- A user who is force-enrolled by group membership and is challenged for MFA (e.g. at their next login) + before ever visiting their profile has **no recovery codes available** if they lose access to their normal + 2FA method (email, for Phase I) at that point. Their only recourse is out-of-band administrative + intervention (e.g. a server admin resetting their 2FA state directly), not a self-service recovery path. +- The literal acceptance criterion "generate recovery codes ... when admin enforcement requires code + creation" is not met for the group-enforcement path — only for explicit self-enrollment. + +**Follow-up (not scheduled)** + +If this gap needs to be closed later, the natural integration point is wherever group membership is granted +(`GroupApiController` and any other code path that adds a user to `Group`) — call +`RecoveryCodeService::generateRecoveryCodes($user)` immediately after granting membership to a group in +`config('two_factor.enforced_groups')`, guarded so it only fires when the user currently has zero unused +codes (to avoid clobbering codes on every re-grant). + +## Alternatives considered + +- **Hook into group-assignment code paths now.** Rejected for this ticket: requires auditing every place + group membership can change (there is more than one — see `GroupApiController` and related admin flows) + to guarantee the hook fires exactly once and doesn't silently invalidate codes a user already saved. Judged + to be new scope beyond "Recovery Code Management UI and Endpoints," better handled as its own ticket if + the org decides to close this gap. +- **Generate codes lazily on first MFA challenge instead of on group grant.** Rejected: the MFA challenge + screen itself has no natural place to show a one-time "here are your recovery codes" modal without + interrupting the login flow the ticket explicitly requires to remain "unaffected." diff --git a/doc/mfa-test-gap-report.md b/doc/mfa-test-gap-report.md new file mode 100644 index 00000000..5c6fd602 --- /dev/null +++ b/doc/mfa-test-gap-report.md @@ -0,0 +1,143 @@ +# MFA Test Gap Report — PR 142 + +**Branch:** `feat/mfa---login-ui-flow` +**Date:** 2026-06-30 +**Scope:** All files changed across the MFA feature branch (backend + frontend) + +--- + +## Summary + +PR 142 adds full MFA authentication support: 65 files changed, +6,900 lines. The PHP backend layer has strong coverage — 11 dedicated test files were added as part of the PR. The entire frontend refactor (15 JavaScript/JSX files, ~2,220 lines) has **zero test coverage**, and four specific PHP areas were identified as gaps in isolation-level coverage even though they are exercised indirectly by the integration suite. + +| Layer | Files Changed | Files with Tests | Coverage | +|---|---|---|---| +| PHP — services, strategies, repositories | 30 | 30 | ✅ Direct | +| PHP — HTTP / controller layer | 6 | 0 (integration only) | ⚠️ Partial | +| JavaScript — login UI | 15 | 0 | ❌ None | + +--- + +## What IS Covered — PHP Test Files Added in PR 142 + +The following 11 test files were added or substantially extended as part of this PR. They form the baseline any reviewer can rely on. + +### Integration / Feature Tests + +| File | Tests | What it covers | +|---|---|---| +| `tests/TwoFactorLoginFlowTest.php` | 19 | Full end-to-end MFA login flow via HTTP: admin/non-admin routing, OTP verify/fail/reuse, recovery codes, device trust cookie enrollment, trusted-device bypass, audit failure resilience, rate-limit enforcement on verify/recovery/resend endpoints | +| `tests/AuthServiceValidateCredentialsIntegrationTest.php` | 2 | `AuthService::validateCredentials` integration path including the MFA gate check | + +### Unit Tests + +| File | Tests | What it covers | +|-------------------------------------------------------|---|---| +| `tests/unit/AuthServiceValidateCredentialsTest.php` | 9 | Password validation, account state guards, `validateCredentials` under MFA gate (unit) | +| `tests/unit/UserTwoFactorTest.php` | 14 | User entity 2FA flag logic, enforcement rules, group-based enforcement, method availability | +| `tests/unit/MFAGateServiceTest.php` | 5 | `MFAGateService::requiresChallenge` decision tree for all trust/enforce/cookie combinations | +| `tests/unit/TwoFactorAuditServiceTest.php` | 7 | Audit event recording: challenge issued, verified, failed, device trusted | +| `tests/DeviceTrustServiceTest.php` | 15 | Full `DeviceTrustService` contract: trust/revoke/expire/validate, SHA-256 storage, audit wiring | +| `tests/unit/MFA/AbstractMFAChallengeStrategyTest.php` | 8 | Base strategy: OTP generation, expiry, session binding, reuse prevention | +| `tests/unit/MFA/EmailOTPMFAChallengeStrategyTest.php` | 5 | Email OTP dispatch, already-redeemed race, numeric-only validation | +| `tests/unit/MFA/MFAChallengeStrategyFactoryTest.php` | 2 | Factory resolves correct strategy for each `MFA_METHODS` value | + +### Repository / Model Tests + +| File | Tests | What it covers | +|---|---|---| +| `tests/TwoFactorRepositoriesTest.php` | 11 | Doctrine round-trips for `UserTrustedDevice`, `TwoFactorAuditLog`, `UserRecoveryCode`: persistence, expiry/revocation queries, uniqueness constraints, `setCodeHash` guards against plaintext | + +--- + +## Gaps — PHP Backend + +These four items lack isolated test coverage. They are exercised indirectly by `TwoFactorLoginFlowTest` but would be invisible to a unit test runner. + +### 1. `cancelLogin` Controller Endpoint (Critical) + +**File:** `app/Http/Controllers/UserController.php` — `cancelLogin()` action +**What it does:** Tears down the pending-MFA session state when the user cancels mid-challenge. If this is broken, users can get stuck in an unrecoverable MFA state or, worse, a session may retain stale auth context. +**Gap:** No unit or dedicated integration test for the `POST /auth/cancel-login` route. The flow test exercises the happy-path continuation but not cancellation edge cases (double-cancel, cancel with no pending session, cancel with concurrent session). + +### 2. `TwoFactorRateLimitMiddleware` Isolation (High) + +**File:** `app/Http/Middleware/TwoFactorRateLimitMiddleware.php` +**What it does:** Cache-backed, fixed-window rate limiting for verify/recovery/resend. Counters survive session cleanup. Verify/recovery increment only on failure; resend increments always. +**Gap:** The middleware is tested indirectly through `TwoFactorLoginFlowTest` (`testVerifyRateLimitBlocksAfterThreshold` etc.), but there are no isolated middleware unit tests covering: window expiry after TTL, per-action counter separation, resend counting regardless of response status, and behavior when no pending session key exists. + +### 3. `MFACookieManager` Trait Isolation (Medium) + +**File:** `app/Http/Controllers/Traits/MFACookieManager.php` +**What it does:** Reads the raw device-trust cookie from the request and queues the `Set-Cookie` header. Cookie name, lifetime, and security flags (Secure, HttpOnly, SameSite=lax) are configuration-driven. +**Gap:** No unit test verifies that `queueDeviceTrustCookie` passes the correct flags to `Cookie::queue`, that the lifetime calculation (`days × 24 × 60`) is right, or that `getCookieToken` returns `null` when no cookie is present. A misconfigured `$secure = true` hardcode already exists in the code and warrants explicit assertion. + +### 4. `EncryptCookies` Exclusion (Medium) + +**File:** `app/Http/Middleware/EncryptCookies.php` +**What it does:** Excludes the device-trust token from Laravel's cookie encryption layer so the raw token survives the round-trip. +**Gap:** No test asserts that `config('two_factor.cookie_name')` is in `$except`, so a future refactor that drops the constructor injection would silently encrypt the cookie and break device trust comparison in `DeviceTrustService` with no test failure. + +--- + +## Gaps — JavaScript Frontend + +All 15 frontend files introduced or substantially modified by this PR have no test coverage of any kind. + +### File Coverage Table + +| File | Lines | Category | Risk | Notes | +|---|---|---|---|---| +| `resources/js/login/login.js` | 1,000 | State machine / orchestrator | **Critical** | Core MFA flow controller: `handleAuthenticatePasswordOk` dispatches to `FLOW.MFA`; `handleMfaError` maps 401/412/429/0 to UI states; `resetToPasswordFlow`; `onVerify2FA`; `onVerifyRecovery`; `onResend2FA` | +| `resources/js/login/components/two_factor_form.js` | 149 | UI Component | **Critical** | Countdown timer with dual `useEffect` (expiry + cooldown), resend cooldown guard, expired-code state, trust-device checkbox | +| `resources/js/login/components/otp_input_form.js` | 117 | UI Component | **High** | OTP entry for email-verification flow; error display, submit guard | +| `resources/js/login/components/password_input_form.js` | 193 | UI Component | **High** | Password entry + show/hide; attempt-count error states; `data-testid` error label | +| `resources/js/login/components/recovery_code_form.js` | 84 | UI Component | **High** | Recovery code entry, empty-submit guard | +| `resources/js/login/actions.js` | 66 | API Layer | **High** | `verify2FA`, `resend2FA`, `verifyRecoveryCode`, `cancelLogin`, `authenticateWithPassword` — all XHR wrappers; URL sourced from `window.*_ENDPOINT` | +| `resources/js/base_actions.js` | 248 | API Layer | **High** | `postRawRequest` / `postRawRequestFull` — XHR transport, redirect-following, `responseURL` extraction; used by every action | +| `resources/js/login/components/email_input_form.js` | 61 | UI Component | **Medium** | Email entry step; `data-testid="error-label"` | +| `resources/js/login/components/email_error_actions.js` | 60 | UI Component | **Medium** | Unknown-email CTA display | +| `resources/js/login/components/existing_account_actions.js` | 47 | UI Component | **Medium** | Account-exists action set | +| `resources/js/login/components/help_links.js` | 78 | UI Component | **Medium** | Context-sensitive help links | +| `resources/js/login/constants.js` | 32 | Constants | **Low** | `FLOW`, `HTTP_CODES`, `MFA_ERROR_CODE` enum values | +| `resources/js/login/components/otp_help_links.js` | 20 | UI Component | **Low** | OTP-specific help link | +| `resources/js/login/components/third_party_identity_providers.js` | 36 | UI Component | **Low** | SSO provider list display | +| `resources/js/shared/HTMLRender.jsx` | 29 | Shared Utility | **Low** | DOMPurify wrapper; `...rest` prop forwarding | + +--- + +## Priority Recommendations + +| Priority | Item | Rationale | +|---|---|---| +| **Critical** | Unit tests for `login.js` state machine | `handleAuthenticatePasswordOk`, `handleMfaError`, `handleAuthenticateValidation`, and `resetToPasswordFlow` are pure state logic that can be tested without a DOM. These are the highest-value, lowest-effort tests — each branch covers a real user failure mode. | +| **Critical** | Jest component tests for `TwoFactorForm` | The countdown + cooldown dual-timer is the most complex UI logic in the PR. Timer behavior, expired-code state, and resend-button disabling are invisible in E2E tests but trivially verifiable with `@testing-library/react` + `jest.useFakeTimers`. | +| **Critical** | Dedicated integration test for `cancelLogin` | Covers the session-cleanup contract that is otherwise only exercised by the happy path. | +| **High** | Jest tests for `actions.js` and `base_actions.js` | Mock `window.*_ENDPOINT` and `superagent`; assert that `postRawRequestFull` extracts `responseURL` as `finalUrl`. These are the only XHR-level contracts between React and the PHP backend. | +| **High** | Playwright E2E: full MFA flow | `goes to 2FA step after password → enters code → logs in` and the expired-session regression. The scaffold (`tests/e2e/`) already exists. | +| **High** | `TwoFactorRateLimitMiddleware` unit tests | Isolated cache-mock tests for window expiry and per-action counter separation. | +| **Medium** | `MFACookieManager` unit tests | Assert cookie flag values. | +| **Medium** | Jest component tests: `RecoveryCodeForm`, `PasswordInputForm`, `OTPInputForm` | Error-display and empty-submit guard branches. | +| **Medium** | `EncryptCookies` exclusion assertion | One-line test: `assertContains(config('two_factor.cookie_name'), (new EncryptCookies(...))->getExcept())`. | +| **Low** | `constants.js` smoke test | Not worth dedicated tests; covered by any consumer test that imports the file. | + +--- + +## How to Run What Exists Today + +```bash +# PHP — all suites +./vendor/bin/phpunit + +# PHP — MFA suite only +./vendor/bin/phpunit --testsuite "Two Factor Authentication Test Suite" + +# PHP — integration suite only +./vendor/bin/phpunit tests/TwoFactorLoginFlowTest.php + +# JS — unit tests (Jest) +yarn test:unit:ci + +# E2E (requires Docker stack) +docker compose --profile e2e run --rm playwright npx playwright test tests/e2e/tests/auth/ +``` diff --git a/docker-compose.yml b/docker-compose.yml index a185686e..644780e1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -57,6 +57,28 @@ services: networks: - idp-local-net env_file: ./.env + playwright: + build: + context: ./docker-compose/playwright + container_name: idp-playwright + working_dir: /var/www + volumes: + - ./:/var/www + - playwright_cache:/root/.cache/ms-playwright + # Lets the e2e suite `docker exec idp-app php artisan idp:get-latest-otp + # ` to read a real OTP value (see tests/e2e/utils/otp.ts) - + # grants this container full control of the host's Docker daemon, not + # just idp-app, so keep this service dev/e2e-only (profiles: [e2e]). + - /var/run/docker.sock:/var/run/docker.sock + networks: + - idp-local-net + depends_on: + - nginx + profiles: + - e2e + environment: + - APP_URL=http://nginx + nginx: image: nginx:alpine container_name: nginx-idp @@ -119,3 +141,4 @@ networks: volumes: mysql_idp: elasticsearch_data: + playwright_cache: diff --git a/docker-compose/playwright/Dockerfile b/docker-compose/playwright/Dockerfile new file mode 100644 index 00000000..14394765 --- /dev/null +++ b/docker-compose/playwright/Dockerfile @@ -0,0 +1,12 @@ +FROM mcr.microsoft.com/playwright:v1.61.1-jammy + +# Docker CLI only (no daemon) - lets the e2e suite shell out to +# `docker exec idp-app php artisan idp:get-latest-otp ` to read a +# real OTP value without adding DB/mail dependencies to the test runner. +# Requires /var/run/docker.sock to be mounted at runtime (docker-compose.yml). +ARG DOCKER_CLI_VERSION=27.3.1 +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && curl -fsSL "https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_CLI_VERSION}.tgz" \ + | tar -xz --strip-components=1 -C /usr/local/bin docker/docker \ + && rm -rf /var/lib/apt/lists/* diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..2cb8907a --- /dev/null +++ b/jest.config.js @@ -0,0 +1,18 @@ +module.exports = { + testEnvironment: 'jsdom', + testMatch: ['/tests/js/**/*.test.js'], + // @marsidev/react-turnstile ships as pure ESM; allow Babel to transform it. + transformIgnorePatterns: ['/node_modules/(?!@marsidev/react-turnstile)'], + moduleNameMapper: { + '\\.(css|scss|sass|less)$': 'identity-obj-proxy', + '\\.(jpg|jpeg|png|gif|svg|ttf|woff|woff2|eot|otf|webp)$': + '/tests/js/__mocks__/fileMock.js', + }, + setupFilesAfterEnv: ['/tests/js/setup.js'], + transform: { + '^.+\\.[jt]sx?$': 'babel-jest', + }, + moduleDirectories: ['node_modules', 'resources/js'], + collectCoverageFrom: ['resources/js/**/*.{js,jsx}', '!resources/js/index.js'], + coverageDirectory: 'tests/js/coverage', +}; diff --git a/package.json b/package.json index ccf4660e..96e436f3 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,13 @@ "clean": "find . -name \"node_modules\" -type d -prune -exec rm -rf '{}' + && yarn", "build-dev": "./node_modules/.bin/webpack --config webpack.dev.js", "build": "./node_modules/.bin/webpack --config webpack.prod.js", - "serve": "webpack-dev-server --open --port=8888 --https --config webpack.dev.js", - "test": "jest --watch" + "serve": "webpack-dev-server --open --port=8888 --server-type https --config webpack.dev.js", + "test": "jest --watch", + "test:unit": "jest --testPathPattern=tests/js", + "test:unit:ci": "jest --testPathPattern=tests/js --ci --coverage", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:report": "playwright show-report tests/e2e/report" }, "devDependencies": { "@babel/core": "^7.17.8", @@ -21,6 +26,10 @@ "@babel/preset-flow": "^7.7.4", "@babel/preset-react": "^7.7.4", "@babel/runtime": "^7.20.7", + "@playwright/test": "^1.61.1", + "@testing-library/user-event": "^13", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^12.1.5", "babel-cli": "^6.26.0", "babel-jest": "^26.6.3", "babel-loader": "^8.2.4", @@ -81,6 +90,7 @@ "bootstrap-tagsinput": "^0.7.1", "chosen-js": "^1.8.7", "crypto-js": "^3.1.9-1", + "dompurify": "^3.4.11", "easymde": "^2.18.0", "font-awesome": "^4.7.0", "formik": "^2.2.9", @@ -95,6 +105,7 @@ "moment": "^2.29.4", "moment-timezone": "^0.5.21", "popper.js": "^1.14.3", + "prop-types": "^15.8.1", "pure": "^2.85.0", "pwstrength-bootstrap": "^3.0.10", "react-otp-input": "^3.1.1", diff --git a/phpunit.xml b/phpunit.xml index 7515f39f..04e8bd17 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -3,7 +3,7 @@ backupGlobals="false" colors="true" processIsolation="false" - stopOnFailure="true" + stopOnFailure="false" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.2/phpunit.xsd" cacheDirectory=".phpunit.cache" backupStaticProperties="false" @@ -13,6 +13,14 @@ ./tests/ ./tests/OpenTelemetry/ ./tests/TestCase.php + + ./tests/OAuth2ProtocolTestCase.php + ./tests/OIDCProtocolTestCase.php + ./tests/OIDCPasswordlessTestCase.php + ./tests/OpenIdProtocolTestCase.php ./tests/OpenTelemetry/ @@ -22,6 +30,16 @@ ./tests/OpenTelemetry/Formatters/ + + ./tests/TwoFactorRepositoriesTest.php + ./tests/unit/UserTwoFactorTest.php + ./tests/unit/MFA/AbstractMFAChallengeStrategyTest.php + ./tests/unit/MFA/EmailOTPMFAChallengeStrategyTest.php + ./tests/unit/MFA/MFAChallengeStrategyFactoryTest.php + ./tests/unit/TwoFactorAuditServiceTest.php + ./tests/unit/MFAGateServiceTest.php + ./tests/TwoFactorLoginFlowTest.php + diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..0df15725 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e/tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? parseInt(process.env.PLAYWRIGHT_WORKERS ?? '1') : undefined, + reporter: [['html', { outputFolder: 'tests/e2e/report' }]], + use: { + baseURL: process.env.APP_URL || 'http://localhost:8001', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); diff --git a/readme.md b/readme.md index 26b145d9..8df45030 100644 --- a/readme.md +++ b/readme.md @@ -79,10 +79,47 @@ nvm use # Tests +## Backend (PHPUnit) + +```bash php artisan view:clear php artisan cache:clear - ./vendor/bin/phpunit +``` + +## Frontend — Unit/Component (Jest) + +Run from inside the `idp-app` container: + +```bash +yarn test:unit # watch mode +yarn test:unit:ci # single run with coverage +``` + +## Frontend — E2E (Playwright) + +Run from the **host** (outside any container). The full stack must be running (`./start_local_server.sh`). + +```bash +# Run all E2E tests +docker compose --profile e2e run --rm playwright npx playwright test + +# Run a specific file +docker compose --profile e2e run --rm playwright npx playwright test tests/e2e/tests/auth/login.spec.ts +``` + +> E2E tests cannot be run from inside the `idp-app` container — it has no browser. +> The `playwright` service (`mcr.microsoft.com/playwright:v1.61.1-jammy`) includes Chromium and all required system dependencies. + +### Viewing the HTML report + +The report is written to `tests/e2e/report/` on the host. Serve it from the host (not from inside any container) so the browser can reach it: + +```bash +nvm use 22.2.0 +yarn test:e2e:report +# Open http://localhost:9323 +``` # install docker compose diff --git a/resources/js/base_actions.js b/resources/js/base_actions.js index 18cd3b49..e6c1ea06 100644 --- a/resources/js/base_actions.js +++ b/resources/js/base_actions.js @@ -92,6 +92,30 @@ export const postRawRequest = (endpoint) => (params, headers = {}) => { }) } +export const postRawRequestFull = (endpoint) => (params, headers = {}) => { + let url = URI(endpoint); + + let key = url.toString(); + + cancel(key); + + let req = http.post(url.toString()); + + schedule(key, req); + + return req.set(headers).send(params).timeout({ + response: 60000, + deadline: 60000, + }).then((res) => { + let json = res.body; + end(key); + return Promise.resolve({response: json}); + }).catch((error) => { + end(key); + return Promise.reject(error); + }) +} + export const putRawRequest = (endpoint) => (payload = null, params={}, headers = {}) => { let url = URI(endpoint); diff --git a/resources/js/components/recovery_code_display.js b/resources/js/components/recovery_code_display.js new file mode 100644 index 00000000..08f5192a --- /dev/null +++ b/resources/js/components/recovery_code_display.js @@ -0,0 +1,76 @@ +import React, {useState} from "react"; +import Box from "@material-ui/core/Box"; +import Button from "@material-ui/core/Button"; +import Grid from "@material-ui/core/Grid"; +import Typography from "@material-ui/core/Typography"; +import AssignmentIcon from "@material-ui/icons/Assignment"; +import CheckCircleIcon from "@material-ui/icons/CheckCircle"; +import {downloadTextFile} from "../utils"; + +import styles from "./recovery_codes.module.scss"; + +const DEFAULT_APP_NAME = "OpenStackID"; + +const buildFileContent = (codes, email, appName) => { + const date = new Date().toISOString().slice(0, 10); + return [ + `${appName} Recovery Codes`, + `Generated: ${date}`, + `Account: ${email}`, + "", + "Keep these codes somewhere safe. Each code can only be used once to sign in, and they will not be shown again.", + "", + ...codes, + ].join("\n"); +}; + +const RecoveryCodeDisplay = ({codes, email, appName = DEFAULT_APP_NAME}) => { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(codes.join("\n")).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + }; + + const handleDownload = () => { + const date = new Date().toISOString().slice(0, 10); + const appSlug = appName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, ""); + downloadTextFile(`${appSlug}-recovery-codes-${date}.txt`, buildFileContent(codes, email, appName)); + }; + + return ( + + + {codes.map((code, idx) => ( + + {code} + + ))} + + + +   + + + + ); +}; + +export default RecoveryCodeDisplay; diff --git a/resources/js/components/recovery_code_modal.js b/resources/js/components/recovery_code_modal.js new file mode 100644 index 00000000..f0a09c01 --- /dev/null +++ b/resources/js/components/recovery_code_modal.js @@ -0,0 +1,64 @@ +import React, {useEffect, useState} from "react"; +import Box from "@material-ui/core/Box"; +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import DialogActions from "@material-ui/core/DialogActions"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogTitle from "@material-ui/core/DialogTitle"; +import Typography from "@material-ui/core/Typography"; +import WarningRoundedIcon from "@material-ui/icons/WarningRounded"; +import RecoveryCodeDisplay from "./recovery_code_display"; + +import styles from "./recovery_codes.module.scss"; + +const ACK_DELAY_SECONDS = 5; + +const RecoveryCodeModal = ({open, codes, email, appName, onAcknowledge}) => { + const [secondsLeft, setSecondsLeft] = useState(ACK_DELAY_SECONDS); + + useEffect(() => { + if (!open) return undefined; + + setSecondsLeft(ACK_DELAY_SECONDS); + const interval = setInterval(() => { + setSecondsLeft((prev) => (prev > 0 ? prev - 1 : 0)); + }, 1000); + + return () => clearInterval(interval); + }, [open]); + + return ( + + Save Your Recovery Codes + + + + + These codes will not be shown again. Copy or download them now and store them somewhere safe. + + + {codes && } + + + + + + ); +}; + +export default RecoveryCodeModal; diff --git a/resources/js/components/recovery_codes.module.scss b/resources/js/components/recovery_codes.module.scss new file mode 100644 index 00000000..c4437a1e --- /dev/null +++ b/resources/js/components/recovery_codes.module.scss @@ -0,0 +1,43 @@ +.recovery_codes_panel { + margin-top: 15px; +} + +.codes_grid { + margin: 8px 0; + padding: 12px; + background-color: #f5f5f5; + border-radius: 4px; +} + +.code { + font-family: monospace; + font-size: 1rem; + letter-spacing: 1px; +} + +.warning_banner { + display: flex; + align-items: flex-start; + margin-bottom: 16px; + padding: 10px 14px; + background-color: #fdecea; + border-left: 4px solid #f44336; + border-radius: 4px; +} + +.warning_icon { + color: #f44336; + margin-right: 10px; + margin-top: 1px; + flex-shrink: 0; +} + +.low_code_warning { + display: flex; + align-items: center; + justify-content: space-between; + margin-top: 8px; + padding: 8px 12px; + background-color: #fff3e0; + border-radius: 4px; +} diff --git a/resources/js/components/recovery_codes_panel.js b/resources/js/components/recovery_codes_panel.js new file mode 100644 index 00000000..986c70f7 --- /dev/null +++ b/resources/js/components/recovery_codes_panel.js @@ -0,0 +1,144 @@ +import React, {useState} from "react"; +import Box from "@material-ui/core/Box"; +import Button from "@material-ui/core/Button"; +import Grid from "@material-ui/core/Grid"; +import IconButton from "@material-ui/core/IconButton"; +import Link from "@material-ui/core/Link"; +import TextField from "@material-ui/core/TextField"; +import Typography from "@material-ui/core/Typography"; +import CloseIcon from "@material-ui/icons/Close"; +import {regenerateRecoveryCodes} from "../profile/actions"; +import {handleErrorResponse} from "../utils"; +import RecoveryCodeModal from "./recovery_code_modal"; +import { + RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY, + DEFAULT_RECOVERY_CODES_LOW_THRESHOLD, +} from "../shared/recovery_codes"; + +import styles from "./recovery_codes.module.scss"; + +const RecoveryCodesPanel = ({ + recoveryCodesRemaining, + recoveryCodesTotal, + lowCodeThreshold = DEFAULT_RECOVERY_CODES_LOW_THRESHOLD, + email, + appName, + initialCodes = null + }) => { + const [regenerating, setRegenerating] = useState(false); + const [currentPassword, setCurrentPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [remaining, setRemaining] = useState(recoveryCodesRemaining); + const [total, setTotal] = useState(recoveryCodesTotal); + const [codes, setCodes] = useState(initialCodes); + const [warningDismissed, setWarningDismissed] = useState( + sessionStorage.getItem(RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY) === "1" + ); + + const handleRegenerate = () => { + setLoading(true); + regenerateRecoveryCodes(currentPassword).then(({response}) => { + setLoading(false); + setRegenerating(false); + setCurrentPassword(""); + setCodes(response.recovery_codes); + setRemaining(response.recovery_codes.length); + setTotal(response.recovery_codes.length); + }).catch((err) => { + setLoading(false); + handleErrorResponse(err); + }); + }; + + const handleAcknowledge = () => { + setCodes(null); + }; + + const dismissLowCodeWarning = () => { + sessionStorage.setItem(RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY, "1"); + setWarningDismissed(true); + }; + + const showLowCodeWarning = !warningDismissed && remaining < lowCodeThreshold; + + return ( + <> + + + Recovery Codes: {remaining} of {total} remaining + + { + showLowCodeWarning && + + + You're running low on recovery codes. Regenerate them to avoid getting locked out. + + + + + + } + { + !regenerating && + { + e.preventDefault(); + setRegenerating(true); + }}> + Regenerate Codes + + } + { + regenerating && + + setCurrentPassword(e.target.value)} + onKeyDown={(e) => { + // This panel lives inside the profile page's own
; + // it must not let Enter bubble up and submit that form too. + if (e.key === "Enter") { + e.preventDefault(); + if (currentPassword && !loading) handleRegenerate(); + } + }} + // Detaches this input from the ancestor (the profile page + // wraps everything in one big form) so the browser's native + // "Enter submits the enclosing form" / password-manager-driven + // auto-submit can never fire a GET on it, regardless of the + // onKeyDown handler above. + inputProps={{form: "recovery-codes-detached-form", autoComplete: "off"}} + data-testid="recovery-codes-current-password" + /> +   + +   + { + e.preventDefault(); + setRegenerating(false); + setCurrentPassword(""); + }}> + Cancel + + + } + + + + ); +}; + +export default RecoveryCodesPanel; diff --git a/resources/js/components/two_factor_section.js b/resources/js/components/two_factor_section.js new file mode 100644 index 00000000..ece17eff --- /dev/null +++ b/resources/js/components/two_factor_section.js @@ -0,0 +1,68 @@ +import React, {useState} from "react"; +import Button from "@material-ui/core/Button"; +import Typography from "@material-ui/core/Typography"; +import {enableTwoFactor} from "../profile/actions"; +import {handleErrorResponse} from "../utils"; +import RecoveryCodesPanel from "./recovery_codes_panel"; + +const DEFAULT_METHOD = "email_otp"; + +const TwoFactorSection = ({ + twoFactorEnabled, + recoveryCodesRemaining, + recoveryCodesTotal, + recoveryCodesLowThreshold, + email, + appName + }) => { + const [enabled, setEnabled] = useState(twoFactorEnabled); + const [loading, setLoading] = useState(false); + const [remaining, setRemaining] = useState(recoveryCodesRemaining); + const [total, setTotal] = useState(recoveryCodesTotal); + const [enrollmentCodes, setEnrollmentCodes] = useState(null); + + const handleEnable = () => { + setLoading(true); + enableTwoFactor(DEFAULT_METHOD).then(({response}) => { + setLoading(false); + setRemaining(response.recovery_codes.length); + setTotal(response.recovery_codes.length); + setEnrollmentCodes(response.recovery_codes); + setEnabled(true); + }).catch((err) => { + setLoading(false); + handleErrorResponse(err); + }); + }; + + if (!enabled) { + return ( + <> + + Two-factor authentication is not enabled for your account. + + + + ); + } + + return ( + + ); +}; + +export default TwoFactorSection; diff --git a/resources/js/login/actions.js b/resources/js/login/actions.js index d0d20ad9..ebda8a6a 100644 --- a/resources/js/login/actions.js +++ b/resources/js/login/actions.js @@ -27,3 +27,33 @@ export const resendVerificationEmail = (email, token) => { return postRawRequest(window.RESEND_VERIFICATION_EMAIL_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); } + +export const verify2FA = (otpValue, method, trustDevice, token) => { + const params = { + otp_value: otpValue, + method: method, + trust_device: trustDevice ? 1 : 0 + }; + + return postRawRequest(window.VERIFY_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); +} + +export const resend2FA = (method, token) => { + const params = { + method: method + }; + + return postRawRequest(window.RESEND_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); +} + +export const verifyRecoveryCode = (recoveryCode, token) => { + const params = { + recovery_code: recoveryCode + }; + + return postRawRequest(window.RECOVERY_2FA_ENDPOINT)(params, {'X-CSRF-TOKEN': token}); +} + +export const cancelLogin = (token) => { + return postRawRequest(window.CANCEL_LOGIN_ENDPOINT)({}, {'X-CSRF-TOKEN': token}); +} diff --git a/resources/js/login/components/email_error_actions.js b/resources/js/login/components/email_error_actions.js new file mode 100644 index 00000000..4e67ce3b --- /dev/null +++ b/resources/js/login/components/email_error_actions.js @@ -0,0 +1,60 @@ +import React from "react"; +import Grid from "@material-ui/core/Grid"; +import Button from "@material-ui/core/Button"; +import styles from "../login.module.scss"; + +const EmailErrorActions = ({ + emitOtpAction, + createAccountAction, + onValidateEmail, + disableInput, +}) => { + return ( + + + + + + + + + + + + + + ); +}; + +export default EmailErrorActions; diff --git a/resources/js/login/components/email_input_form.js b/resources/js/login/components/email_input_form.js new file mode 100644 index 00000000..d074bb23 --- /dev/null +++ b/resources/js/login/components/email_input_form.js @@ -0,0 +1,61 @@ +import React from "react"; +import Paper from "@material-ui/core/Paper"; +import TextField from "@material-ui/core/TextField"; +import Button from "@material-ui/core/Button"; +import styles from "../login.module.scss"; +import HTMLRender from "../../shared/HTMLRender"; + +const EmailInputForm = ({ + value, + onValidateEmail, + onHandleUserNameChange, + disableInput, + emailError, +}) => { + return ( + <> + + + {emailError == "" && ( + + )} + + {emailError != "" && ( + + {emailError} + + )} + + ); +}; + +export default EmailInputForm; diff --git a/resources/js/login/components/existing_account_actions.js b/resources/js/login/components/existing_account_actions.js new file mode 100644 index 00000000..649d31fd --- /dev/null +++ b/resources/js/login/components/existing_account_actions.js @@ -0,0 +1,47 @@ +import React from "react"; +import Grid from "@material-ui/core/Grid"; +import Button from "@material-ui/core/Button"; +import Link from "@material-ui/core/Link"; +import styles from "../login.module.scss"; + +const ExistingAccountActions = ({ + emitOtpAction, + forgotPasswordAction, + userName, + disableInput, +}) => { + let forgotPasswordActionHref = forgotPasswordAction; + + if (userName) { + forgotPasswordActionHref = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`; + } + + return ( + + + + + + e.preventDefault() : undefined} + href={forgotPasswordActionHref} + target="_self" + variant="body2" + > + Reset your password + + + + ); +}; + +export default ExistingAccountActions; diff --git a/resources/js/login/components/help_links.js b/resources/js/login/components/help_links.js new file mode 100644 index 00000000..c4668e00 --- /dev/null +++ b/resources/js/login/components/help_links.js @@ -0,0 +1,78 @@ +import React, { useMemo } from "react"; +import Link from "@material-ui/core/Link"; +import styles from "../login.module.scss"; + +const HelpLinks = ({ + userName, + showEmitOtpAction, + forgotPasswordAction, + showForgotPasswordAction, + showVerifyEmailAction, + verifyEmailAction, + showHelpAction, + helpAction, + appName, + emitOtpAction, +}) => { + const actions = useMemo(() => { + let forgotPasswordActionHref = forgotPasswordAction; + if (userName) { + const separator = forgotPasswordAction.includes("?") ? "&" : "?"; + forgotPasswordActionHref = `${forgotPasswordAction}${separator}email=${encodeURIComponent(userName)}`; + } + + return [ + { + show: showEmitOtpAction, + href: "#", + onClick: emitOtpAction, + label: "Get A Single-use Code emailed to you", + }, + { + show: showForgotPasswordAction, + href: forgotPasswordActionHref, + label: "Reset your password", + }, + { + show: showVerifyEmailAction, + href: verifyEmailAction, + label: `Verify ${appName}`, + }, + { + show: showHelpAction, + href: helpAction, + label: "Having trouble?", + }, + ].filter((action) => action.show); + }, [ + showEmitOtpAction, + showForgotPasswordAction, + showVerifyEmailAction, + showHelpAction, + userName, + forgotPasswordAction, + verifyEmailAction, + helpAction, + appName, + emitOtpAction, + ]); + + return ( + <> +
+ {actions.map((action, index) => ( + + {action.label} + + ))} + + ); +}; + +export default HelpLinks; diff --git a/resources/js/login/components/otp_code_input.js b/resources/js/login/components/otp_code_input.js new file mode 100644 index 00000000..cb5cbfc3 --- /dev/null +++ b/resources/js/login/components/otp_code_input.js @@ -0,0 +1,56 @@ +import React from 'react'; +import OtpInput from 'react-otp-input'; +import {formatTime} from '../../utils'; +import styles from '../login.module.scss'; +import HTMLRender from '../../shared/HTMLRender'; + +/** + * Shared single-use-code entry block: subtitle, code boxes, error message and + * optional expiry countdown. Used by both the passwordless OTP form and the + * MFA verification form; the owning form keeps the submit mechanics. + */ +const OtpCodeInput = ({ + id, + otpCode, + otpError, + otpLength, + onCodeChange, + countdownActive, + secondsLeft, + expired, + subtitle = 'Enter the single-use code sent to your email:' + }) => { + return ( + <> +
{subtitle}
+
+ } + shouldAutoFocus={true} + hasErrored={!!otpError} + errorStyle={{border: '1px solid #e5424d'}} + data-testid={id} + /> +
+ {otpError && + + {otpError} + + } + {countdownActive && +

+ {expired + ? 'Your verification code has expired. Please request a new one.' + : `Code expires in ${formatTime(secondsLeft)}.`} +

+ } + + ); +}; + +export default OtpCodeInput; diff --git a/resources/js/login/components/otp_help_links.js b/resources/js/login/components/otp_help_links.js new file mode 100644 index 00000000..8d85dea6 --- /dev/null +++ b/resources/js/login/components/otp_help_links.js @@ -0,0 +1,43 @@ +import React, {useState, useEffect} from "react"; +import Link from "@material-ui/core/Link"; +import styles from "../login.module.scss"; +import {RESEND_COOLDOWN_SECONDS} from "../constants"; + +const OTPHelpLinks = ({ emitOtpAction, disableInput }) => { + const [cooldown, setCooldown] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setCooldown((prev) => (prev > 0 ? prev - 1 : 0)); + }, 1000); + return () => clearInterval(timer); + }, []); + + const handleResend = (ev) => { + ev.preventDefault(); + if (cooldown > 0 || disableInput) return; + setCooldown(RESEND_COOLDOWN_SECONDS); + emitOtpAction(ev); + }; + + return ( + <> +
+

Didn't receive it ?

+

+ Check your spam folder or{" "} + 0 || disableInput) ? styles.disabled_link : ''} + > + {cooldown > 0 ? `resend email (${cooldown}s)` : 'resend email.'} + +

+ + ); +}; + +export default OTPHelpLinks; diff --git a/resources/js/login/components/otp_input_form.js b/resources/js/login/components/otp_input_form.js new file mode 100644 index 00000000..1634f815 --- /dev/null +++ b/resources/js/login/components/otp_input_form.js @@ -0,0 +1,115 @@ +import React from "react"; +import { Turnstile } from "@marsidev/react-turnstile"; +import Button from "@material-ui/core/Button"; +import Link from "@material-ui/core/Link"; +import OtpCodeInput from "./otp_code_input"; +import useOtpCountdown from "./use_otp_countdown"; +import styles from "../login.module.scss"; + +const OTPInputForm = ({ + disableInput, + formAction, + onAuthenticate, + otpCode, + otpError, + otpLength, + otpLifetime, + codeVersion, + onCodeChange, + userNameValue, + csrfToken, + shouldShowCaptcha, + captchaPublicKey, + onChangeCaptchaProvider, + onExpireCaptchaProvider, + onErrorCaptchaProvider, + onReset, + loginAttempts, +}) => { + const showCaptcha = shouldShowCaptcha(); + const { secondsLeft, expired } = useOtpCountdown(otpLifetime ?? 0, codeVersion); + // The countdown only renders when this page view knows when the code was + // issued (a fresh emitOTP in this session). After a failed-submit page + // reload the issuance time is unknown - showing a fresh full countdown + // would overstate the code's validity, so none is shown. + const countdownActive = otpLifetime != null && otpLifetime > 0; + const blockExpired = countdownActive && expired; + + const handleSubmit = (ev) => { + if (blockExpired || !onAuthenticate(ev.target)) + { + ev.preventDefault(); + } + } + + return ( + + +
+ +
+
+

+ + Sign in using a different e-mail + +

+
+
After you login you will be e-mailed a link to
+
set a password and complete your account.
+
+
+ + + + + + + {showCaptcha && captchaPublicKey && ( + + )} + + ); +}; + +export default OTPInputForm; diff --git a/resources/js/login/components/password_input_form.js b/resources/js/login/components/password_input_form.js new file mode 100644 index 00000000..96e5a1cc --- /dev/null +++ b/resources/js/login/components/password_input_form.js @@ -0,0 +1,189 @@ +import React from "react"; +import { Turnstile } from "@marsidev/react-turnstile"; +import TextField from "@material-ui/core/TextField"; +import Button from "@material-ui/core/Button"; +import Grid from "@material-ui/core/Grid"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Checkbox from "@material-ui/core/Checkbox"; +import Visibility from "@material-ui/icons/Visibility"; +import VisibilityOff from "@material-ui/icons/VisibilityOff"; +import InputAdornment from "@material-ui/core/InputAdornment"; +import IconButton from "@material-ui/core/IconButton"; +import ExistingAccountActions from "./existing_account_actions"; +import styles from "../login.module.scss"; +import HTMLRender from "../../shared/HTMLRender"; + +const PasswordInputForm = ({ + formAction, + onAuthenticate, + disableInput, + showPassword, + passwordValue, + passwordError, + onUserPasswordChange, + handleClickShowPassword, + handleMouseDownPassword, + userNameValue, + csrfToken, + shouldShowCaptcha, + captchaPublicKey, + onChangeCaptchaProvider, + onExpireCaptchaProvider, + onErrorCaptchaProvider, + handleEmitOtpAction, + forgotPasswordAction, + loginAttempts, + maxLoginFailedAttempts, + userIsActive, + helpAction, +}) => { + // Native form submission (same adapter as OTPInputForm): password managers key + // their save/update prompt off the browser's real submit event, and the backend + // login strategies respond with a redirect + flashed session state that only a + // top-level navigation consumes correctly. + const handleSubmit = (ev) => { + if (!onAuthenticate(ev.target)) { + ev.preventDefault(); + } + }; + + const ErrorMessage = () => { + const attempts = parseInt(loginAttempts, 10); + const maxAttempts = parseInt(maxLoginFailedAttempts, 10); + const attemptsLeft = maxAttempts - attempts; + + if (!passwordError) return null; + + if (attempts > 0 && attempts < maxAttempts && userIsActive) { + return ( +

+ Incorrect password. You have {attemptsLeft} more attempt + {attemptsLeft !== 1 ? "s" : ""} before your account is locked. +

+ ); + } + + if (attempts > 0 && attempts === maxAttempts && userIsActive) { + return ( +

+ Incorrect password. You have reached the maximum ({maxAttempts}) + login attempts. Your account will be locked after another failed + login. +

+ ); + } + + if (attempts > 0 && attempts === maxAttempts && !userIsActive) { + return ( +

+ Your account has been locked due to multiple failed login + attempts. Please contact support to + unlock it. +

+ ); + } + + return ( + + {passwordError} + + ); + }; + + return ( +
+ + + {showPassword ? : } + + + ), + }} + /> + + + + + + + + } + label="Remember me" + /> + + + + + + + + {shouldShowCaptcha() && captchaPublicKey && ( + + )} + + + ); +}; + +export default PasswordInputForm; diff --git a/resources/js/login/components/recovery_code_form.js b/resources/js/login/components/recovery_code_form.js new file mode 100644 index 00000000..50830e04 --- /dev/null +++ b/resources/js/login/components/recovery_code_form.js @@ -0,0 +1,85 @@ +import React from 'react'; +import TextField from '@material-ui/core/TextField'; +import Button from '@material-ui/core/Button'; +import Link from '@material-ui/core/Link'; +import styles from '../login.module.scss'; +import HTMLRender from '../../shared/HTMLRender'; + +const RecoveryCodeForm = ({ + recoveryCode, + recoveryError, + onRecoveryCodeChange, + onVerify, + onBackToOtp, + onCancel, + disableInput + }) => { + + const handleSubmit = (ev) => { + ev.preventDefault(); + onVerify(); + }; + + const handleBack = (ev) => { + ev.preventDefault(); + onBackToOtp(); + }; + + const handleCancel = (ev) => { + ev.preventDefault(); + onCancel(); + }; + + return ( +
+
Enter a recovery code
+

+ Enter one of the recovery codes you saved when you enabled two-step verification. +

+ + {recoveryError && ( + + {recoveryError} + + )} +
+ +
+
+
+
+ + Back to verification code + + {" · "} + + Cancel + +
+
+ + ); +} + +export default RecoveryCodeForm; diff --git a/resources/js/login/components/third_party_identity_providers.js b/resources/js/login/components/third_party_identity_providers.js new file mode 100644 index 00000000..ee37915c --- /dev/null +++ b/resources/js/login/components/third_party_identity_providers.js @@ -0,0 +1,36 @@ +import React from 'react'; +import DividerWithText from '../../components/divider_with_text'; +import Button from '@material-ui/core/Button'; +import {handleThirdPartyProvidersVerbiage} from '../../utils'; +import styles from '../login.module.scss'; +import '../third_party_identity_providers.scss'; + +const ThirdPartyIdentityProviders = ({ thirdPartyProviders, formAction, disableInput, allowNativeAuth }) => { + return ( + <> + {allowNativeAuth && or} + { + thirdPartyProviders.map((provider) => { + const verbiage = `${handleThirdPartyProvidersVerbiage(provider.name)} with ${provider.label}`; + return ( + + ); + }) + } +

If you have a login, you may still choose to use a social login with the same email address to + access your account.

+ + ); +} + +export default ThirdPartyIdentityProviders; diff --git a/resources/js/login/components/two_factor_form.js b/resources/js/login/components/two_factor_form.js new file mode 100644 index 00000000..c06ff08e --- /dev/null +++ b/resources/js/login/components/two_factor_form.js @@ -0,0 +1,127 @@ +import React, {useState, useEffect} from 'react'; +import Button from '@material-ui/core/Button'; +import Link from '@material-ui/core/Link'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Checkbox from '@material-ui/core/Checkbox'; +import OtpCodeInput from './otp_code_input'; +import useOtpCountdown from './use_otp_countdown'; +import styles from '../login.module.scss'; +import {RESEND_COOLDOWN_SECONDS} from '../constants'; + +const TwoFactorForm = ({ + otpCode, + otpError, + otpLength, + otpLifetime, + codeVersion, + onCodeChange, + onVerify, + trustDevice, + onTrustDeviceChange, + onResend, + onUseRecovery, + onCancel, + disableInput + }) => { + + const {secondsLeft, expired} = useOtpCountdown(otpLifetime, codeVersion); + const [cooldown, setCooldown] = useState(0); + + // 1s ticker for the resend cooldown (the expiry countdown lives in the hook). + useEffect(() => { + const timer = setInterval(() => { + setCooldown(prev => (prev > 0 ? prev - 1 : 0)); + }, 1000); + return () => clearInterval(timer); + }, []); + + const handleSubmit = (ev) => { + ev.preventDefault(); + if (expired) return; + onVerify(); + }; + + const handleResend = (ev) => { + ev.preventDefault(); + if (cooldown > 0 || disableInput) return; + setCooldown(RESEND_COOLDOWN_SECONDS); + // A successful resend resets the expiry countdown through the parent's + // codeVersion bump; failures are surfaced by the parent as well. + const result = onResend(); + if (result && typeof result.catch === 'function') { + result.catch(() => {}); + } + }; + + const handleRecovery = (ev) => { + ev.preventDefault(); + onUseRecovery(); + }; + + const handleCancel = (ev) => { + ev.preventDefault(); + onCancel(); + }; + + return ( +
+ +
+ + } + label="Trust this device for 30 days" + /> +
+
+ +
+
+

+ Didn't receive it? Check your spam folder or{" "} + 0 || disableInput) ? styles.disabled_link : ''} + data-testid="resend-link"> + {cooldown > 0 ? `resend code (${cooldown}s)` : 'resend code'} + . +

+ {/* "Use a different method" is intentionally hidden in Phase I (email_otp only). */} +
+
+ + Cancel + + + Use a recovery code instead + +
+
+ + ); +} + +export default TwoFactorForm; diff --git a/resources/js/login/components/use_otp_countdown.js b/resources/js/login/components/use_otp_countdown.js new file mode 100644 index 00000000..350d04ed --- /dev/null +++ b/resources/js/login/components/use_otp_countdown.js @@ -0,0 +1,25 @@ +import {useState, useEffect} from 'react'; + +/** + * Drives a 1-second expiry countdown for a single-use code. + * Resets whenever a fresh code is issued (otpLifetime change or codeVersion bump). + */ +const useOtpCountdown = (otpLifetime, codeVersion) => { + const [secondsLeft, setSecondsLeft] = useState(otpLifetime || 0); + + useEffect(() => { + setSecondsLeft(otpLifetime || 0); + }, [otpLifetime, codeVersion]); + + useEffect(() => { + const timer = setInterval( + () => setSecondsLeft(prev => (prev > 0 ? prev - 1 : 0)), + 1000 + ); + return () => clearInterval(timer); + }, []); + + return {secondsLeft, expired: secondsLeft <= 0}; +}; + +export default useOtpCountdown; diff --git a/resources/js/login/constants.js b/resources/js/login/constants.js new file mode 100644 index 00000000..183b3784 --- /dev/null +++ b/resources/js/login/constants.js @@ -0,0 +1,40 @@ +export const HTTP_CODES = { + OK: 200, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + PRECONDITION_FAILED: 412, + TOO_MANY_REQUESTS: 429, + INTERNAL_SERVER_ERROR: 500, +}; + +export const MFA_METHODS = { + EMAIL_OTP: "email_otp", + TOTP: "totp", +}; + +export const FLOW = { + PASSWORD: "password", + MFA: "2fa", + RECOVERY: "recovery", + OTP: "otp", +}; + +export const OTP_LENGTH_DEFAULT = 6; +export const OTP_TTL_DEFAULT = 300; +export const MFA_METHOD_DEFAULT = MFA_METHODS.EMAIL_OTP; +export const CAPTCHA_FIELD = 'cf-turnstile-response'; + +// Cooldown applied to any "resend code" action (MFA and passwordless OTP) to +// avoid hammering the resend endpoint (the backend also rate-limits server-side). +export const RESEND_COOLDOWN_SECONDS = 30; + +// Success confirmation shown after a code is (re)sent - shared by MFA's +// onResend2FA() and passwordless's emitOtpAction() so the two flows can't +// silently diverge in wording. +export const CODE_RESENT_MESSAGE = "A new verification code has been sent to your email."; + +export const MFA_ERROR_CODE = { + MFA_SESSION_EXPIRED: "mfa_session_expired", +}; diff --git a/resources/js/login/login.js b/resources/js/login/login.js index ee061b9a..3aa2100c 100644 --- a/resources/js/login/login.js +++ b/resources/js/login/login.js @@ -1,937 +1,1070 @@ -import React from 'react'; +import React from "react"; import { Turnstile } from "@marsidev/react-turnstile"; -import ReactDOM from 'react-dom'; -import Avatar from '@material-ui/core/Avatar'; -import Button from '@material-ui/core/Button'; -import CssBaseline from '@material-ui/core/CssBaseline'; -import TextField from '@material-ui/core/TextField'; -import Link from '@material-ui/core/Link'; -import Typography from '@material-ui/core/Typography'; -import Paper from '@material-ui/core/Paper'; -import Container from '@material-ui/core/Container'; -import Chip from '@material-ui/core/Chip'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import Checkbox from '@material-ui/core/Checkbox'; -import {verifyAccount, emitOTP, resendVerificationEmail} from './actions'; -import {MuiThemeProvider, createTheme} from '@material-ui/core/styles'; -import DividerWithText from '../components/divider_with_text'; -import Visibility from '@material-ui/icons/Visibility'; -import VisibilityOff from '@material-ui/icons/VisibilityOff'; -import InputAdornment from '@material-ui/core/InputAdornment'; -import IconButton from '@material-ui/core/IconButton'; -import { emailValidator } from '../validator'; -import Grid from '@material-ui/core/Grid'; +import ReactDOM from "react-dom"; +import Avatar from "@material-ui/core/Avatar"; +import Button from "@material-ui/core/Button"; +import CssBaseline from "@material-ui/core/CssBaseline"; +import Typography from "@material-ui/core/Typography"; +import Container from "@material-ui/core/Container"; +import Chip from "@material-ui/core/Chip"; +import { MuiThemeProvider, createTheme } from "@material-ui/core/styles"; +import { + verifyAccount, + emitOTP, + resendVerificationEmail, + verify2FA, + resend2FA, + verifyRecoveryCode, + cancelLogin, +} from "./actions"; +import { emailValidator } from "../validator"; import CustomSnackbar from "../components/custom_snackbar"; -import Banner from '../components/banner/banner'; -import OtpInput from 'react-otp-input'; -import {handleErrorResponse, handleThirdPartyProvidersVerbiage} from '../utils'; +import Banner from "../components/banner/banner"; +import { handleErrorResponse } from "../utils"; -import styles from './login.module.scss' +import EmailInputForm from "./components/email_input_form"; +import PasswordInputForm from "./components/password_input_form"; +import OTPInputForm from "./components/otp_input_form"; +import HelpLinks from "./components/help_links"; +import OTPHelpLinks from "./components/otp_help_links"; +import EmailErrorActions from "./components/email_error_actions"; +import ThirdPartyIdentityProviders from "./components/third_party_identity_providers"; +import TwoFactorForm from "./components/two_factor_form"; +import RecoveryCodeForm from "./components/recovery_code_form"; +import { + RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY, + DEFAULT_RECOVERY_CODES_LOW_THRESHOLD, +} from "../shared/recovery_codes"; + +import styles from "./login.module.scss"; +import recoveryCodesStyles from "../components/recovery_codes.module.scss"; import "./third_party_identity_providers.scss"; +import { + FLOW, + HTTP_CODES, + MFA_ERROR_CODE, + OTP_LENGTH_DEFAULT, + OTP_TTL_DEFAULT, + MFA_METHOD_DEFAULT, + CODE_RESENT_MESSAGE, +} from "./constants"; -const EmailInputForm = ({ value, onValidateEmail, onHandleUserNameChange, disableInput, emailError }) => { +class LoginPage extends React.Component { + constructor(props) { + super(props); + this.state = { + user_name: props.userName, + user_password: "", + otpCode: "", + user_pic: props.user_pic ?? null, + user_fullname: props.user_fullname ?? null, + user_verified: props.user_verified ?? false, + user_active: props.user_active ?? null, + email_verified: props.email_verified ?? null, + errors: { + email: "", + otp: props.authError ?? "", + password: props.authError ?? "", + twofactor: "", + recovery: "", + }, + notification: { + message: null, + severity: "info", + }, + captcha_value: "", + showPassword: false, + disableInput: false, + authFlow: props.flow, + allowNativeAuth: props.allowNativeAuth, + showInfoBanner: props.showInfoBanner, + infoBannerContent: props.infoBannerContent, + // Two-factor state (populated from the flash redirect when a challenge is required). + otpLength: props.otpLength ?? OTP_LENGTH_DEFAULT, + otpLifetime: props.otpLifetime ?? OTP_TTL_DEFAULT, + mfaMethod: props.mfaMethod ?? MFA_METHOD_DEFAULT, + trustDevice: false, + twoFactorCode: "", + recoveryCode: "", + codeVersion: 0, + // Lifetime of the pending passwordless OTP. Seeded from props.otpLifetime + // on mount so a refresh restores the REMAINING countdown (props.otpLifetime + // is already the session-derived remaining time - see login.blade.php's + // otp_issued_at math, same mechanism the MFA challenge screen uses). + // emitOtpAction() overwrites this with the live response on a fresh + // send/resend. Unlike otpLifetime's OTP_TTL_DEFAULT fallback, null is the + // correct fallback here (not a stand-in default): passwordlessLifetime + // only has meaning while authFlow === FLOW.OTP, and Task 1's backend + // change writes otp_lifetime atomically with flow, so props.otpLifetime + // is only ever missing when there's no pending OTP to show a countdown for. + passwordlessLifetime: props.otpLifetime ?? null, + // Set once a recovery-code login succeeds with a low remaining count, so + // the redirect can be held until the user acknowledges the warning - + // this is the only point where the SPA still controls the page (see + // onVerifyRecovery()/onContinueAfterLowRecoveryCodes() below). + lowRecoveryCodesWarning: null, + }; - return ( - <> - - - {emailError == "" && - - } - - { emailError != "" && -

- } - - ); -} + if (props.authError != "" && !this.state.user_fullname) { + this.state.user_fullname = props.userName; + } -const PasswordInputForm = ({ - formAction, - onAuthenticate, - disableInput, - showPassword, - passwordValue, - passwordError, - onUserPasswordChange, - handleClickShowPassword, - handleMouseDownPassword, - userNameValue, - csrfToken, - shouldShowCaptcha, - captchaPublicKey, - onChangeCaptchaProvider, - onExpireCaptchaProvider, - onErrorCaptchaProvider, - handleEmitOtpAction, - forgotPasswordAction, - loginAttempts, - maxLoginFailedAttempts, - userIsActive, - helpAction - }) => { - return ( -
- - - {showPassword ? : } - - - ) - }} - /> - {(() => { - const attempts = parseInt(loginAttempts, 10); - const maxAttempts = parseInt(maxLoginFailedAttempts, 10); - const attemptsLeft = maxAttempts - attempts; - - if (!passwordError) return null; - - if (attempts > 0 && attempts < maxAttempts && userIsActive) { - return ( - <> -

- Incorrect password. You have {attemptsLeft} more attempt{attemptsLeft !== 1 ? 's' : ''} before your account is locked. -

- - ); - } + if ( + this.state.errors.password && + this.state.errors.password.includes("is not yet verified") + ) { + this.state.errors.password = + this.state.errors.password + + `Or have another verification email sent to you.`; + } - if (attempts > 0 && attempts === maxAttempts && userIsActive) { - return ( - <> -

- Incorrect password. You have reached the maximum ({maxAttempts}) login attempts. Your account will be locked after another failed login. -

- - ); - } + this.onHandleUserNameChange = this.onHandleUserNameChange.bind(this); + this.onValidateEmail = this.onValidateEmail.bind(this); + this.handleDelete = this.handleDelete.bind(this); + this.onAuthenticate = this.onAuthenticate.bind(this); + this.onChangeCaptchaProvider = this.onChangeCaptchaProvider.bind(this); + this.onExpireCaptchaProvider = this.onExpireCaptchaProvider.bind(this); + this.onErrorCaptchaProvider = this.onErrorCaptchaProvider.bind(this); + this.onUserPasswordChange = this.onUserPasswordChange.bind(this); + this.onOTPCodeChange = this.onOTPCodeChange.bind(this); + this.shouldShowCaptcha = this.shouldShowCaptcha.bind(this); + this.handleClickShowPassword = this.handleClickShowPassword.bind(this); + this.handleMouseDownPassword = this.handleMouseDownPassword.bind(this); + this.handleEmitOtpAction = this.handleEmitOtpAction.bind(this); + this.resendVerificationEmail = this.resendVerificationEmail.bind(this); + this.handleSnackbarClose = this.handleSnackbarClose.bind(this); + this.showAlert = this.showAlert.bind(this); + this.onTwoFactorCodeChange = this.onTwoFactorCodeChange.bind(this); + this.onRecoveryCodeChange = this.onRecoveryCodeChange.bind(this); + this.onTrustDeviceChange = this.onTrustDeviceChange.bind(this); + this.onVerify2FA = this.onVerify2FA.bind(this); + this.onResend2FA = this.onResend2FA.bind(this); + this.onVerifyRecovery = this.onVerifyRecovery.bind(this); + this.onContinueAfterLowRecoveryCodes = this.onContinueAfterLowRecoveryCodes.bind(this); + this.onUseRecovery = this.onUseRecovery.bind(this); + this.onBackToOtp = this.onBackToOtp.bind(this); + this.resetToPasswordFlow = this.resetToPasswordFlow.bind(this); + this.cancelPendingLogin = this.cancelPendingLogin.bind(this); + } - if (attempts > 0 && attempts === maxAttempts && !userIsActive) { - return ( - <> -

- Your account has been locked due to multiple failed login attempts. Please contact support to unlock it. -

- - ); - } + /** + * Best-effort server-side invalidation of the pending MFA challenge. + * The UI resets optimistically; if the request fails the user is told the + * pending verification will only die by its own TTL. + */ + cancelPendingLogin() { + cancelLogin(this.props.token).catch((error) => { + console.error("cancelLogin failed", error); + this.showAlert( + "We couldn't cancel the pending verification on the server. It will expire on its own in a few minutes.", + "warning", + ); + }); + } - return

; - })()} - - - - - - - } - label="Remember me" - /> - - - - - - - - {shouldShowCaptcha() && captchaPublicKey && - - } - - - ); -} + showAlert(message, severity) { + this.setState({ + ...this.state, + notification: { + message: message, + severity: severity, + }, + }); + } -const OTPInputForm = ({ - disableInput, - formAction, - onAuthenticate, - otpCode, - otpError, - otpLength, - onCodeChange, - userNameValue, - csrfToken, - shouldShowCaptcha, - captchaPublicKey, - onChangeCaptchaProvider, - onExpireCaptchaProvider, - onErrorCaptchaProvider, - onReset, - loginAttempts - }) => { - return ( - <> -
-
Enter the single-use code sent to your email:
-
- } - shouldAutoFocus={true} - hasErrored={!otpError} - errorStyle={{border: '1px solid #e5424d'}} - data-testid="otp_code" - /> -
- {otpError && -

- } -
- -
-
-

- - Sign in using a different e-mail - -

-
-
After you login you will be e-mailed a link to
-
set a password and complete your account.
-
-
- - - - - - - {shouldShowCaptcha() && captchaPublicKey && - - } - - + emitOtpAction() { + let user_fullname = this.state.user_fullname + ? this.state.user_fullname + : this.state.user_name; + + emitOTP(this.state.user_name, this.props.token).then( + (payload) => { + let { response } = payload; + this.setState({ + ...this.state, + authFlow: FLOW.OTP, + errors: { + email: "", + otp: "", + password: "", + }, + user_verified: true, + user_fullname: user_fullname, + // A fresh code was just issued: seed/reset its expiry countdown. + passwordlessLifetime: response?.otp_lifetime ?? null, + codeVersion: this.state.codeVersion + 1, + }); + this.showAlert( + CODE_RESENT_MESSAGE, + "success", + ); + }, + (error) => { + let { response, status, message } = error; + if (status == 412) { + const { message, errors } = response.body; + this.showAlert(errors[0], "error"); + return; + } + if (status === HTTP_CODES.TOO_MANY_REQUESTS) { + const msg = + response && response.body && response.body.error_message + ? response.body.error_message + : "Too many attempts. Please try again later."; + this.showAlert(msg, "warning"); + return; + } + this.showAlert("Oops... Something went wrong!", "error"); + }, ); -} + return false; + } -const HelpLinks = ({ - userName, - showEmitOtpAction, - forgotPasswordAction, - showForgotPasswordAction, - showVerifyEmailAction, - verifyEmailAction, - showHelpAction, - helpAction, - appName, - emitOtpAction - }) => { - if (userName) { - forgotPasswordAction = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`; - } + handleEmitOtpAction(ev) { + ev.preventDefault(); + return this.emitOtpAction(); + } + shouldShowCaptcha() { return ( - <> -
- { - showEmitOtpAction && - - Get A Single-use Code emailed to you - - } - { - showForgotPasswordAction && - - Reset your password - - } - { - showVerifyEmailAction && - - Verify {appName} - - } - {showHelpAction && - - Having trouble? - - } - + typeof this.props.maxLoginAttempts2ShowCaptcha !== "undefined" && + typeof this.props.loginAttempts !== "undefined" && + this.props.loginAttempts >= this.props.maxLoginAttempts2ShowCaptcha ); -} + } -const OTPHelpLinks = ({ emitOtpAction }) => { - return ( - <> -
-

Didn't receive it ?

-

Check your spam folder or resend email. -

- - ); -} + handleAuthenticateValidation() { -const EmailErrorActions = ({ emitOtpAction, createAccountAction, onValidateEmail, disableInput }) => { - return ( - - - - - - - - - - - - - - ); -} + switch (this.state.authFlow) { + case FLOW.OTP: + if (this.state.otpCode == "") { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, otp: "Single-use code is empty" }, + }); + return false; + } + break; + default: + if (this.state.user_password == "") { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, password: "Password is empty" }, + }); + return false; + } -const ExistingAccountActions = ({emitOtpAction, forgotPasswordAction, userName, disableInput}) => { - if (userName) { - forgotPasswordAction = `${forgotPasswordAction}?email=${encodeURIComponent(userName)}`; + if (this.state.captcha_value == "" && this.shouldShowCaptcha()) { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, password: "you must check CAPTCHA" }, + }); + return false; + } } - return ( - - - - - - - Reset your password - - - - ); -} + return true; + } -const ThirdPartyIdentityProviders = ({ thirdPartyProviders, formAction, disableInput, allowNativeAuth }) => { - return ( - <> - {allowNativeAuth && or} - { - thirdPartyProviders.map((provider) => { - const verbiage = `${handleThirdPartyProvidersVerbiage(provider.name)} with ${provider.label}`; - return ( - - ); - }) - } -

If you have a login, you may still choose to use a social login with the same email address to - access your account.

- - ); -} + // Password and OTP flows submit as a native form POST: the backend login + // strategies answer with a redirect plus flashed/persisted session state + // (auth errors, login_attempts, the mfa_required '2fa' flow), which only a + // top-level navigation renders correctly. The 2FA screen is rehydrated from + // session by the blade on the post-redirect GET. + onAuthenticate() { -const otp_flow = 'otp'; -const password_flow = 'password'; + if (!this.handleAuthenticateValidation()) { + return false; + } -class LoginPage extends React.Component { + this.setState({ ...this.state, disableInput: true }); - constructor(props) { - super(props); - this.state = { - user_name: props.userName, - user_password: '', - otpCode: '', - user_pic: props.hasOwnProperty('user_pic') ? props.user_pic : null, - user_fullname: props.hasOwnProperty('user_fullname') ? props.user_fullname : null, - user_verified: props.hasOwnProperty('user_verified') ? props.user_verified : false, - user_active: props.hasOwnProperty('user_active') ? props.user_active : null, - email_verified: props.hasOwnProperty('email_verified') ? props.email_verified : null, - errors: { - email: '', - otp: props.authError != '' ? props.authError : '', - password: props.authError != '' ? props.authError : '', - }, - notification: { - message: null, - severity: 'info' - }, - captcha_value: '', - showPassword: false, - disableInput: false, - authFlow: props.flow, - allowNativeAuth: props.allowNativeAuth, - showInfoBanner: props.showInfoBanner, - infoBannerContent: props.infoBannerContent, - } + return true; + } - if (props.authError != '' && !this.state.user_fullname) { - this.state.user_fullname = props.userName; - } + onChangeCaptchaProvider(value) { + this.setState({ ...this.state, captcha_value: value }); + } - if (this.state.errors.password && this.state.errors.password.includes("is not yet verified")) { - this.state.errors.password = this.state.errors.password + `Or have another verification email sent to you.`; - } + onExpireCaptchaProvider() { + this.setState({ ...this.state, captcha_value: "" }); + } - this.onHandleUserNameChange = this.onHandleUserNameChange.bind(this); - this.onValidateEmail = this.onValidateEmail.bind(this); - this.handleDelete = this.handleDelete.bind(this); - this.onAuthenticate = this.onAuthenticate.bind(this); - this.onChangeCaptchaProvider = this.onChangeCaptchaProvider.bind(this); - this.onExpireCaptchaProvider = this.onExpireCaptchaProvider.bind(this); - this.onErrorCaptchaProvider = this.onErrorCaptchaProvider.bind(this); - this.onUserPasswordChange = this.onUserPasswordChange.bind(this); - this.onOTPCodeChange = this.onOTPCodeChange.bind(this); - this.shouldShowCaptcha = this.shouldShowCaptcha.bind(this); - this.handleClickShowPassword = this.handleClickShowPassword.bind(this); - this.handleMouseDownPassword = this.handleMouseDownPassword.bind(this); - this.handleEmitOtpAction = this.handleEmitOtpAction.bind(this); - this.resendVerificationEmail = this.resendVerificationEmail.bind(this); - this.handleSnackbarClose = this.handleSnackbarClose.bind(this); - this.showAlert = this.showAlert.bind(this); - } - - showAlert(message, severity) { - this.setState({ - ...this.state, - notification: { - message: message, - severity: severity - } - }); - } + onErrorCaptchaProvider() { + this.setState({ ...this.state, captcha_value: "" }); + } - emitOtpAction() { - let user_fullname = this.state.user_fullname ? this.state.user_fullname : this.state.user_name; - - emitOTP(this.state.user_name, this.props.token).then((payload) => { - let {response} = payload; - this.setState({ - ...this.state, - authFlow: otp_flow, - errors: { - email: '', - otp: '', - password: '' - }, - user_verified: true, - user_fullname: user_fullname, - }); - }, (error) => { - let {response, status, message} = error; - if(status == 412){ - const {message, errors} = response.body; - this.showAlert(errors[0], 'error'); - return; - } - this.showAlert('Oops... Something went wrong!', 'error'); - }); - return false; - } + onHandleUserNameChange(ev) { + let { value, id } = ev.target; + this.setState({ ...this.state, user_name: value }); + } - handleEmitOtpAction(ev) { - ev.preventDefault(); - return this.emitOtpAction(); - } + onUserPasswordChange(ev) { + let { errors } = this.state; + let { value, id } = ev.target; + if (value == "") + // clean error + errors[id] = ""; + this.setState({ + ...this.state, + user_password: value, + errors: { ...errors }, + }); + } - shouldShowCaptcha() { - return ( - this.props.hasOwnProperty('maxLoginAttempts2ShowCaptcha') && - this.props.hasOwnProperty('loginAttempts') && - this.props.loginAttempts >= this.props.maxLoginAttempts2ShowCaptcha - ) - } + onOTPCodeChange(value) { + this.setState({ ...this.state, otpCode: value }); + } - onAuthenticate(ev) { - if (this.state.authFlow === otp_flow) { - if (this.state.otpCode == '') { - this.setState({...this.state, disableInput: false, errors: {...this.state.errors, otp: 'Single-use code is empty'}}); - ev.preventDefault(); - return false; - } - } else if (this.state.user_password == '') { - this.setState({...this.state, disableInput: false, errors: {...this.state.errors, password: 'Password is empty'}}); - ev.preventDefault(); - return false; - } + onTwoFactorCodeChange(value) { + this.setState({ + ...this.state, + twoFactorCode: value, + errors: { ...this.state.errors, twofactor: "" }, + }); + } - if (this.state.captcha_value == '' && this.shouldShowCaptcha()) { - this.setState({...this.state, disableInput: false, errors: {...this.state.errors, password: 'you must check CAPTCHA'}}); - ev.preventDefault(); - return false; - } - this.setState({ ...this.state, disableInput: true }); - return true; - } + onRecoveryCodeChange(ev) { + let { value } = ev.target; + // Recovery codes are generated and hashed without the "-" separator; it is + // added only for on-screen readability (XXXX-XXXX). Strip any non-alphanumeric + // characters here so a code typed or pasted exactly as displayed still matches. + const normalized = value.replace(/[^A-Za-z0-9]/g, "").toUpperCase(); + this.setState({ + ...this.state, + recoveryCode: normalized, + errors: { ...this.state.errors, recovery: "" }, + }); + } + + onTrustDeviceChange(ev) { + this.setState({ ...this.state, trustDevice: ev.target.checked }); + } + + /** + * Resets client-side MFA state and returns the user to the password screen. + */ + resetToPasswordFlow() { + this.setState({ + ...this.state, + authFlow: FLOW.PASSWORD, + disableInput: false, + twoFactorCode: "", + user_password: "", + recoveryCode: "", + trustDevice: false, + errors: { + ...this.state.errors, + twofactor: "", + recovery: "", + email: "", + otp: "", + password: "", + }, + }); + this.cancelPendingLogin(); + } - onChangeCaptchaProvider(value) { - this.setState({ ...this.state, captcha_value: value }); + /** + * Shared error handling for the 2FA verify / recovery AJAX calls. + * @param {*} error superagent error + * @param {string} field 'twofactor' | 'recovery' + */ + handleMfaError(error, field) { + const status = error ? error.status : undefined; + const body = error && error.response ? error.response.body : null; + const code = body ? body.error_code : null; + + if ( + status === HTTP_CODES.UNAUTHORIZED && + code === MFA_ERROR_CODE.MFA_SESSION_EXPIRED + ) { + this.resetToPasswordFlow(); + this.showAlert( + "Your verification session has expired. Please sign in again.", + "warning", + ); + return; } - onExpireCaptchaProvider() { - this.setState({ ...this.state, captcha_value: '' }); + if (status === HTTP_CODES.TOO_MANY_REQUESTS) { + const msg = + body && body.error_message + ? body.error_message + : "Too many attempts. Please try again later."; + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, [field]: msg }, + }); + return; } - onErrorCaptchaProvider() { - this.setState({ ...this.state, captcha_value: '' }); + if (status === HTTP_CODES.UNAUTHORIZED) { + const msg = + field === "recovery" + ? "Invalid recovery code. Please try again." + : "Invalid or expired verification code. Please try again."; + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, [field]: msg }, + }); + return; } - onHandleUserNameChange(ev) { - let { value, id } = ev.target; - this.setState({ ...this.state, user_name: value }); + if (status === HTTP_CODES.PRECONDITION_FAILED) { + this.setState({ + ...this.state, + disableInput: false, + errors: { ...this.state.errors, [field]: "Please enter a valid code." }, + }); + return; } - onUserPasswordChange(ev) { - let {errors} = this.state; - let {value, id} = ev.target; - if (value == "") // clean error - errors[id] = ''; - this.setState({...this.state, user_password: value, errors: {...errors}}); + /** + * No HTTP status: the XHR likely followed a (possibly cross-origin) success redirect + * it could not read. The IDP session may already be established, so reload and let + * the server route us to the right place; a genuine network error just re-shows login. + */ + if (typeof status === "undefined" || status === 0) { + window.location.reload(); + return; } - onOTPCodeChange(value) { - this.setState({...this.state, otpCode: value}); + this.setState({ ...this.state, disableInput: false }); + this.showAlert("Oops... Something went wrong!", "error"); + } + + onVerify2FA() { + if (this.state.disableInput) return; + const { twoFactorCode, trustDevice, mfaMethod } = this.state; + if (twoFactorCode === "") { + this.setState({ + ...this.state, + errors: { + ...this.state.errors, + twofactor: "Verification code is empty", + }, + }); + return; } + this.setState({ + ...this.state, + disableInput: true, + errors: { ...this.state.errors, twofactor: "" }, + }); + + verify2FA(twoFactorCode, mfaMethod, trustDevice, this.props.token).then( + (payload) => { + // Success: the backend returns the same-origin post-login destination as JSON + // data (never a redirect for this XHR to follow) - a real top-level navigation + // to it lets the browser complete any further hop natively, cross-origin + // included, which this XHR never could. + const { response } = payload; + window.location.href = + (response && response.redirect_url) || window.location.href; + }, + (error) => { + this.handleMfaError(error, "twofactor"); + }, + ); + } - onValidateEmail(ev) { + onResend2FA() { + const promise = resend2FA(this.state.mfaMethod, this.props.token); - ev.preventDefault(); - let {user_name} = this.state; - user_name = user_name?.trim(); + promise.then( + (payload) => { + const { response } = payload; + this.setState({ + ...this.state, + otpLength: + response && response.otp_length + ? response.otp_length + : this.state.otpLength, + otpLifetime: + response && response.otp_lifetime + ? response.otp_lifetime + : this.state.otpLifetime, + codeVersion: this.state.codeVersion + 1, + errors: { ...this.state.errors, twofactor: "" }, + }); + this.showAlert( + CODE_RESENT_MESSAGE, + "success", + ); + }, + (error) => { + const status = error ? error.status : undefined; + const body = error && error.response ? error.response.body : null; + const code = body ? body.error_code : null; - if (user_name == '') { - return false; + if ( + status === HTTP_CODES.UNAUTHORIZED && + code === MFA_ERROR_CODE.MFA_SESSION_EXPIRED + ) { + this.resetToPasswordFlow(); + this.showAlert( + "Your verification session has expired. Please sign in again.", + "warning", + ); + return; } - if (!emailValidator(user_name)) { - return false; + if (status === HTTP_CODES.TOO_MANY_REQUESTS) { + const msg = + body && body.error_message + ? body.error_message + : "Too many attempts. Please try again later."; + this.showAlert(msg, "warning"); + return; } - this.setState({ ...this.state, disableInput: true }); + this.showAlert( + "Oops... Something went wrong while resending the code.", + "error", + ); + }, + ); - verifyAccount(user_name, this.props.token).then((payload) => { - let { response } = payload; + // Returned so the form can reset its expiry countdown once the resend resolves. + return promise; + } - let error = ''; - if (response.is_active === false) { - error = `Your user account is currently locked. Please contact support for further assistance.`; - } else if (response.is_active === true && response.is_verified === false) { - error = 'Your email has not been verified. Please check your inbox or resend the verification email.'; - } + onVerifyRecovery() { + if (this.state.disableInput) return; + const { recoveryCode } = this.state; + if (recoveryCode === "") { + this.setState({ + ...this.state, + errors: { ...this.state.errors, recovery: "Recovery code is empty" }, + }); + return; + } + this.setState({ + ...this.state, + disableInput: true, + errors: { ...this.state.errors, recovery: "" }, + }); - this.setState({ - ...this.state, - user_pic: response.pic, - user_fullname: response.full_name, - user_verified: true, - user_active: response.is_active, - email_verified: response.is_verified, - authFlow: response.has_password_set ? password_flow : otp_flow, - errors: { - email: error, - otp: '', - password: '' - }, - disableInput: false - }, function () { - //Once the state is updated, it's now possible to trigger emitOtpAction. - //No need to wait for the component to update. - if (!response.has_password_set && response.is_verified !== false) { - this.emitOtpAction(); - } - }); - }, (error) => { + verifyRecoveryCode(recoveryCode, this.props.token).then( + (payload) => { + const { response } = payload; + const redirectUrl = + (response && response.redirect_url) || window.location.href; + const remaining = response && response.recovery_codes_remaining; + const threshold = + (response && response.recovery_codes_low_threshold) ?? + DEFAULT_RECOVERY_CODES_LOW_THRESHOLD; + const alreadyDismissed = + sessionStorage.getItem(RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY) === "1"; - let { response, status, message } = error; + if (typeof remaining === "number" && remaining < threshold && !alreadyDismissed) { + this.setState({ + ...this.state, + lowRecoveryCodesWarning: { remaining, redirectUrl }, + }); + return; + } - let newErrors = {}; + // See onVerify2FA() for rationale on using a real top-level navigation. + window.location.href = redirectUrl; + }, + (error) => { + this.handleMfaError(error, "recovery"); + }, + ); + } - newErrors['password'] = ''; - newErrors['email'] = " "; + onContinueAfterLowRecoveryCodes() { + sessionStorage.setItem(RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY, "1"); + window.location.href = this.state.lowRecoveryCodesWarning.redirectUrl; + } - if (status == 429) { - newErrors['email'] = "Too many requests. Try it later."; - } + onUseRecovery() { + this.setState({ + ...this.state, + authFlow: FLOW.RECOVERY, + errors: { ...this.state.errors, recovery: "" }, + }); + } - this.setState({ - ...this.state, - user_pic: null, - user_fullname: null, - user_verified: false, - errors: newErrors, - disableInput: false - }); - }); - return true; + onBackToOtp() { + this.setState({ + ...this.state, + authFlow: FLOW.MFA, + errors: { ...this.state.errors, twofactor: "" }, + }); + } + + onValidateEmail(ev) { + ev.preventDefault(); + let { user_name } = this.state; + user_name = user_name?.trim(); + + if (user_name == "") { + return false; + } + if (!emailValidator(user_name)) { + return false; } + this.setState({ ...this.state, disableInput: true }); - resendVerificationEmail(ev) { - ev.preventDefault(); - let {user_name} = this.state; - user_name = user_name?.trim(); + verifyAccount(user_name, this.props.token).then( + (payload) => { + let { response } = payload; - if (!user_name) { - this.showAlert( - 'Something went wrong while trying to resend the verification email. Please try again later.', - 'error'); - return; + let error = ""; + if (response.is_active === false) { + error = `Your user account is currently locked. Please contact support for further assistance.`; + } else if ( + response.is_active === true && + response.is_verified === false + ) { + error = + "Your email has not been verified. Please check your inbox or resend the verification email."; } - resendVerificationEmail(user_name, this.props.token).then((payload) => { - this.showAlert( - 'We\'ve sent you a verification email. Please check your inbox and click the link to verify your account.', - 'success'); - }, (error) => { - handleErrorResponse(error, (title, messageLines, type) => { - const message = (messageLines ?? []).join(', ') - this.showAlert(`${title}: ${message}`, type); - }); - }); - } - - handleDelete(ev) { - ev.preventDefault(); - this.setState({ + this.setState( + { ...this.state, - user_name: null, - user_pic: null, - user_fullname: null, - user_verified: false, - user_active: null, - email_verified: null, - authFlow: "password", + user_pic: response.pic, + user_fullname: response.full_name, + user_verified: true, + user_active: response.is_active, + email_verified: response.is_verified, + authFlow: response.has_password_set ? FLOW.PASSWORD : FLOW.OTP, errors: { - email: '', - otp: '', - password: '' + email: error, + otp: "", + password: "", + }, + disableInput: false, + }, + function () { + //Once the state is updated, it's now possible to trigger emitOtpAction. + //No need to wait for the component to update. + if (!response.has_password_set && response.is_verified !== false) { + this.emitOtpAction(); } + }, + ); + }, + (error) => { + let { response, status, message } = error; + + let newErrors = {}; + + newErrors["password"] = ""; + newErrors["email"] = " "; + + if (status == HTTP_CODES.TOO_MANY_REQUESTS) { + newErrors["email"] = "Too many requests. Try it later."; + } + + this.setState({ + ...this.state, + user_pic: null, + user_fullname: null, + user_verified: false, + errors: newErrors, + disableInput: false, }); - return false; - } + }, + ); + return true; + } - handleClickShowPassword(ev) { - ev.preventDefault(); - this.setState({ ...this.state, showPassword: !this.state.showPassword }) - } + resendVerificationEmail(ev) { + ev.preventDefault(); + let { user_name } = this.state; + user_name = user_name?.trim(); - handleMouseDownPassword(ev) { - ev.preventDefault(); + if (!user_name) { + this.showAlert( + "Something went wrong while trying to resend the verification email. Please try again later.", + "error", + ); + return; } - existingUserCanContinue() { - const { user_active, email_verified } = this.state; - return user_active !== false && email_verified !== false; + resendVerificationEmail(user_name, this.props.token).then( + (payload) => { + this.showAlert( + "We've sent you a verification email. Please check your inbox and click the link to verify your account.", + "success", + ); + }, + (error) => { + handleErrorResponse(error, (title, messageLines, type) => { + const message = (messageLines ?? []).join(", "); + this.showAlert(`${title}: ${message}`, type); + }); + }, + ); + } + + handleDelete(ev) { + ev.preventDefault(); + if (this.isMfaFlow() || this.isPasswordlessFlow()) { + // A pending 2FA/recovery challenge or passwordless OTP was issued + // server-side (session state + an unredeemed OTP); invalidate it the + // same way "Cancel" does instead of leaving it live/restorable until + // its TTL. + this.cancelPendingLogin(); } + this.setState({ + ...this.state, + user_name: null, + user_pic: null, + user_fullname: null, + user_verified: false, + user_active: null, + email_verified: null, + authFlow: "password", + errors: { + email: "", + otp: "", + password: "", + }, + }); + return false; + } + + handleClickShowPassword(ev) { + ev.preventDefault(); + this.setState({ ...this.state, showPassword: !this.state.showPassword }); + } + + handleMouseDownPassword(ev) { + ev.preventDefault(); + } + + existingUserCanContinue() { + const { user_active, email_verified } = this.state; + return user_active !== false && email_verified !== false; + } + + isMfaFlow() { + return ( + this.state.authFlow === FLOW.MFA || this.state.authFlow === FLOW.RECOVERY + ); + } + + isPasswordlessFlow() { + return this.state.authFlow === FLOW.OTP; + } - getSignUpSignInTitle() { - const { errors, user_active } = this.state; + getSignUpSignInTitle() { + const { errors, user_active } = this.state; - if (errors.email && this.existingUserCanContinue()) { - return 'Create an account for:'; - } - return 'Sign in'; + if (errors.email && this.existingUserCanContinue()) { + return "Create an account for:"; } + return "Sign in"; + } - handleSnackbarClose() { - this.setState({ - ...this.state, - notification: { - message: null, - severity: 'info' - } - }); - }; + handleSnackbarClose() { + this.setState({ + ...this.state, + notification: { + message: null, + severity: "info", + }, + }); + } - componentDidUpdate(prevProps, prevState) { - if (this.state.user_verified && this.existingUserCanContinue() && prevState.authFlow !== this.state.authFlow) { - this.setState({ - ...this.state, - captcha_value: '', - }); - } + componentDidUpdate(prevProps, prevState) { + if ( + this.state.user_verified && + this.existingUserCanContinue() && + prevState.authFlow !== this.state.authFlow + ) { + this.setState({ + ...this.state, + captcha_value: "", + }); } + } + + render() { + const showTwoFactorForm = this.state.authFlow === FLOW.MFA; + const showRecoveryForm = this.state.authFlow === FLOW.RECOVERY; + const isPasswordFlow = + !showTwoFactorForm && + !showRecoveryForm && + !this.isMfaFlow() && + this.state.user_verified && + this.existingUserCanContinue() && + this.state.authFlow === FLOW.PASSWORD; + const isOtpFlow = + !showTwoFactorForm && + !showRecoveryForm && + !this.isMfaFlow() && + this.state.user_verified && + this.existingUserCanContinue() && + this.state.authFlow === FLOW.OTP; + const showDefaultFlow = !showTwoFactorForm && !showRecoveryForm && !isPasswordFlow && !isOtpFlow; + const createAccountAction = this.props.createAccountAction + + (this.state.user_name ? `?email=${encodeURIComponent(this.state.user_name)}` : ""); - render() { - return ( - - - {this.state.showInfoBanner && } - -
- - {this.props.appName} - - - {this.getSignUpSignInTitle()} - {this.state.user_fullname && - } - variant="outlined" - className={styles.valid_user_name_chip} - label={this.state.user_name} - onDelete={this.handleDelete}/> - } - - {(!this.state.user_verified || !this.existingUserCanContinue()) && - <> - {this.state.allowNativeAuth && - - } - {this.state.errors.email === '' && - this.props.thirdPartyProviders.length > 0 && - - } - { - // we already had an interaction and got an user error... - this.state.errors.email !== '' && - <> - {this.existingUserCanContinue() && - - } - { - this.state.user_active === true && this.state.email_verified === false && - - } - - - } - - } - {this.state.user_verified && this.existingUserCanContinue() && this.state.authFlow === password_flow && - // proceed to ask for password ( 2nd step ) - <> - - - - } - {this.state.user_verified && this.existingUserCanContinue() && this.state.authFlow === otp_flow && - // proceed to ask for password ( 2nd step ) - <> - - - - } - + + {this.state.showInfoBanner && ( + + )} + +
+ + + {this.props.appName} + + + + {this.getSignUpSignInTitle()} + {this.state.user_fullname && ( + + } + variant="outlined" + className={styles.valid_user_name_chip} + label={this.state.user_name} + onDelete={this.handleDelete} + /> + )} + + {showTwoFactorForm && ( + + )} + {showRecoveryForm && !this.state.lowRecoveryCodesWarning && ( + + )} + {showRecoveryForm && this.state.lowRecoveryCodesWarning && ( +
+ + You have {this.state.lowRecoveryCodesWarning.remaining} recovery + code{this.state.lowRecoveryCodesWarning.remaining === 1 ? "" : "s"} left. + Regenerate them from your profile after signing in to avoid getting + locked out. + + +
+ )} + {isPasswordFlow && ( + // proceed to ask for password ( 2nd step ) +
+ + +
+ )} + {isOtpFlow && ( + // proceed to ask for password ( 2nd step ) + <> + + + + )} + {showDefaultFlow && ( + <> + {this.state.allowNativeAuth && ( + + )} + {this.state.errors.email === "" && + this.props.thirdPartyProviders.length > 0 && ( + + )} + { + // we already had an interaction and got an user error... + this.state.errors.email !== "" && ( + <> + {this.existingUserCanContinue() && ( + -
-
- - ); - } + )} + {this.state.user_active === true && + this.state.email_verified === false && ( + + )} + + + ) + } + + )} + +
+
+
+ ); + } } // Or Create your Own theme: const theme = createTheme({ - palette: { - primary: { - main: '#3fa2f7' - }, + palette: { + primary: { + main: "#3fa2f7", }, - overrides: { - MuiButton: { - containedPrimary: { - color: 'white', - textTransform: 'none' - } - } - } + }, + overrides: { + MuiButton: { + containedPrimary: { + color: "white", + textTransform: "none", + }, + }, + }, }); -ReactDOM.render( +export { LoginPage }; + +const root = document.querySelector("#root"); +if (root) { + ReactDOM.render( - + , - document.querySelector('#root') -); + root, + ); +} diff --git a/resources/js/login/login.module.scss b/resources/js/login/login.module.scss index fb0257d1..cbd0b254 100644 --- a/resources/js/login/login.module.scss +++ b/resources/js/login/login.module.scss @@ -88,6 +88,28 @@ p > a { margin-top: 20px; } } + + .info_message { + margin-top: 8px; + color: $text-color-dark; + } + + .countdown { + margin-top: 10px; + font-size: 0.85rem; + color: $hint-text-color; + } + + .trust_device_row { + margin-top: 10px; + margin-bottom: 10px; + text-align: left; + } + + .disabled_link { + pointer-events: none; + opacity: 0.5; + } } } @@ -133,4 +155,11 @@ p > a { .otp_p { margin: 0; padding: 0; +} + +.box { + display: flex; + justify-content: space-between; + margin-bottom: 10px; + flex-direction: row; } \ No newline at end of file diff --git a/resources/js/profile/actions.js b/resources/js/profile/actions.js index c1c6c2f0..0c9ea422 100644 --- a/resources/js/profile/actions.js +++ b/resources/js/profile/actions.js @@ -1,4 +1,4 @@ -import {deleteRawRequest, getRawRequest, putFile, putRawRequest} from "../base_actions"; +import {deleteRawRequest, getRawRequest, postRawRequestFull, putFile, putRawRequest} from "../base_actions"; import moment from "moment"; export const PAGE_SIZE = 10; @@ -87,6 +87,16 @@ export const revokeAllTokens = async () => { return deleteRawRequest(window.REVOKE_ALL_TOKENS_ENDPOINT)({'X-CSRF-TOKEN': window.CSFR_TOKEN}); } +export const regenerateRecoveryCodes = async (currentPassword) => { + const params = {current_password: currentPassword}; + return postRawRequestFull(window.REGENERATE_RECOVERY_CODES_ENDPOINT)(params, {'X-CSRF-TOKEN': window.CSFR_TOKEN}); +} + +export const enableTwoFactor = async (method) => { + const params = {method}; + return postRawRequestFull(window.ENABLE_TWO_FACTOR_ENDPOINT)(params, {'X-CSRF-TOKEN': window.CSFR_TOKEN}); +} + const normalizeEntity = (entity) => { entity.public_profile_show_photo = entity.public_profile_show_photo ? 1 : 0; entity.public_profile_show_fullname = entity.public_profile_show_fullname ? 1 : 0; diff --git a/resources/js/profile/profile.js b/resources/js/profile/profile.js index a530d04a..878f86a5 100644 --- a/resources/js/profile/profile.js +++ b/resources/js/profile/profile.js @@ -1,5 +1,6 @@ import React, {useState} from "react"; import ReactDOM from "react-dom"; +import Box from "@material-ui/core/Box"; import Button from "@material-ui/core/Button"; import Card from "@material-ui/core/Card"; import CardContent from "@material-ui/core/CardContent"; @@ -26,6 +27,7 @@ import Navbar from "../components/navbar/navbar"; import Divider from "@material-ui/core/Divider"; import Link from "@material-ui/core/Link"; import PasswordChangePanel from "../components/password_change_panel"; +import TwoFactorSection from "../components/two_factor_section"; import LoadingIndicator from "../components/loading_indicator"; import TopLogo from "../components/top_logo/top_logo"; import {handleErrorResponse} from "../utils"; @@ -35,13 +37,18 @@ import styles from "./profile.module.scss"; const ProfilePage = ({ appLogo, + appName, countries, csrfToken, initialValues, languages, menuConfig, passwordPolicy, - redirectUri + redirectUri, + twoFactorEnabled, + recoveryCodesRemaining, + recoveryCodesTotal, + recoveryCodesLowThreshold }) => { const [pic, setPic] = useState(null); const [loading, setLoading] = useState(false); @@ -754,11 +761,27 @@ const ProfilePage = ({ )}
- - + + + + + + + Two-Factor Authentication + + + + { + const html = DOMPurify.sanitize(children || ""); + const Component = component; + + return ( + + ); +}; + +HTMLRender.propTypes = { + children: PropTypes.string, + className: PropTypes.string, + style: PropTypes.shape({ + [PropTypes.string]: PropTypes.string + }), + component: PropTypes.elementType +}; + +export default HTMLRender; diff --git a/resources/js/shared/recovery_codes.js b/resources/js/shared/recovery_codes.js new file mode 100644 index 00000000..c144f52b --- /dev/null +++ b/resources/js/shared/recovery_codes.js @@ -0,0 +1,5 @@ +// Shared between the profile page's RecoveryCodesPanel and the login page's +// post-MFA-recovery-login warning, so dismissing the low-code warning in +// either place suppresses it everywhere else for the rest of the session. +export const RECOVERY_CODES_LOW_WARNING_DISMISSED_KEY = "recovery_codes_low_warning_dismissed"; +export const DEFAULT_RECOVERY_CODES_LOW_THRESHOLD = 3; diff --git a/resources/js/signup/signup.js b/resources/js/signup/signup.js index 1d595e2a..93a98b2f 100644 --- a/resources/js/signup/signup.js +++ b/resources/js/signup/signup.js @@ -84,10 +84,12 @@ const SignUpPage = ({ return errors; }, onSubmit: (values) => { - const turnstileResponse = captcha.current?.getResponse(); - if (!turnstileResponse) { - setCaptchaConfirmation("Remember to check the captcha"); - return; + if (captchaPublicKey) { + const turnstileResponse = captcha.current?.getResponse(); + if (!turnstileResponse) { + setCaptchaConfirmation("Remember to check the captcha"); + return; + } } doHtmlFormPost(); }, diff --git a/resources/js/utils.js b/resources/js/utils.js index 7b35a202..5d5d711c 100644 --- a/resources/js/utils.js +++ b/resources/js/utils.js @@ -82,6 +82,18 @@ export const formatTime = (timeInSeconds) => { return res; } +export const downloadTextFile = (filename, content) => { + const blob = new Blob([content], {type: 'text/plain'}); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +}; + export const decodeHtmlEntities = (text) => { const textarea = document.createElement('textarea'); textarea.innerHTML = text; diff --git a/resources/views/auth/login.blade.php b/resources/views/auth/login.blade.php index d2ca52ee..1c825b23 100644 --- a/resources/views/auth/login.blade.php +++ b/resources/views/auth/login.blade.php @@ -34,6 +34,11 @@ accountVerifyAction : '{{URL::action("UserController@getAccount")}}', emitOtpAction : '{{URL::action("UserController@emitOTP")}}', resendVerificationEmailAction: '{{ URL::action("UserController@resendVerificationEmail") }}', + verify2faAction: '{{ URL::action("UserController@verify2FA") }}', + resend2faAction: '{{ URL::action("UserController@resend2FA") }}', + cancelLogin: '{{ URL::action("UserController@cancelLogin") }}', + recovery2faAction: '{{ URL::action("UserController@verify2FARecovery") }}', + mfaMethod: '{{ Session::has("mfa_method") ? Session::get("mfa_method") : "email_otp" }}', authError: authError, captchaPublicKey: '{{ Config::get("services.turnstile.key") }}', flow: 'password', @@ -84,9 +89,23 @@ config.flow = '{{Session::get('flow')}}'; @endif + @if(Session::has('otp_length')) + config.otpLength = {{Session::get("otp_length")}}; + @endif + @if(Session::has('otp_lifetime')) + {{-- Seed the countdown with the REMAINING lifetime: on a mid-challenge + refresh the full TTL would overstate how long the code is valid and + let the user burn rate-limited attempts on a server-expired code. --}} + config.otpLifetime = {{ max(0, intval(Session::get("otp_lifetime")) - (Session::has("otp_issued_at") ? time() - intval(Session::get("otp_issued_at")) : 0)) }}; + @endif + window.VERIFY_ACCOUNT_ENDPOINT = config.accountVerifyAction; window.EMIT_OTP_ENDPOINT = config.emitOtpAction; window.RESEND_VERIFICATION_EMAIL_ENDPOINT = config.resendVerificationEmailAction; + window.VERIFY_2FA_ENDPOINT = config.verify2faAction; + window.RESEND_2FA_ENDPOINT = config.resend2faAction; + window.CANCEL_LOGIN_ENDPOINT = config.cancelLogin; + window.RECOVERY_2FA_ENDPOINT = config.recovery2faAction; {!! script_to('assets/login.js') !!} @append \ No newline at end of file diff --git a/resources/views/profile.blade.php b/resources/views/profile.blade.php index 335094e7..672081ba 100644 --- a/resources/views/profile.blade.php +++ b/resources/views/profile.blade.php @@ -103,7 +103,11 @@ initialValues: initialValues, languages: languages, menuConfig: menuConfig, - passwordPolicy: passwordPolicy + passwordPolicy: passwordPolicy, + twoFactorEnabled: {{ $two_factor_enabled ? 'true' : 'false' }}, + recoveryCodesRemaining: {{ (int) $recovery_codes_remaining }}, + recoveryCodesTotal: {{ (int) $recovery_codes_total }}, + recoveryCodesLowThreshold: {{ (int) $recovery_codes_low_threshold }} } window.GET_USER_ACTIONS_ENDPOINT = '{{URL::action("Api\UserActionApiController@getActionsByCurrentUser")}}'; @@ -112,6 +116,8 @@ window.REVOKE_ALL_TOKENS_ENDPOINT = '{{URL::action("Api\UserApiController@revokeAllMyTokens")}}'; window.SAVE_PROFILE_ENDPOINT = '{!!URL::action("Api\UserApiController@updateMe")!!}'; window.SAVE_PIC_ENDPOINT = '{!!URL::action("Api\UserApiController@updateMyPic")!!}'; + window.REGENERATE_RECOVERY_CODES_ENDPOINT = '{!!URL::action("Api\UserApiController@regenerateRecoveryCodes")!!}'; + window.ENABLE_TWO_FACTOR_ENDPOINT = '{!!URL::action("Api\UserApiController@enableTwoFactor")!!}'; window.CSFR_TOKEN = document.head.querySelector('meta[name="csrf-token"]').content; {!! script_to('assets/profile.js') !!} diff --git a/routes/web.php b/routes/web.php index b49a3547..61d97573 100644 --- a/routes/web.php +++ b/routes/web.php @@ -45,12 +45,17 @@ Route::group(array('prefix' => 'login'), function () { Route::get('', "UserController@getLogin"); Route::post('account-verify', [ 'middleware' => ['csrf'], 'uses' => 'UserController@getAccount']); - Route::post('otp', ['middleware' => ['csrf'], 'uses' => 'UserController@emitOTP']); + Route::post('otp', ['middleware' => ['csrf', '2fa.rate:otp'], 'uses' => 'UserController@emitOTP']); 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::post('cancel', ['middleware' => 'csrf', 'uses' => 'UserController@cancelLogin']); Route::group(array('prefix' => '{provider}'), function () { Route::get('', 'SocialLoginController@redirect')->name("social_login"); Route::any('callback','SocialLoginController@callback')->name("social_login_callback"); @@ -192,6 +197,8 @@ Route::put('', "UserApiController@updateMe"); Route::put('pic', "UserApiController@updateMyPic"); Route::get('actions', "UserActionApiController@getActionsByCurrentUser"); + Route::post('recovery-codes/regenerate', "UserApiController@regenerateRecoveryCodes"); + Route::post('2fa/enable', "UserApiController@enableTwoFactor"); }); Route::get('access-tokens', ['middleware' => ['openstackid.currentuser.serveradmin.json'], 'uses' => 'ClientApiController@getAllAccessTokens']); diff --git a/start_local_server.sh b/start_local_server.sh index 0535f4b8..62d66591 100755 --- a/start_local_server.sh +++ b/start_local_server.sh @@ -2,11 +2,31 @@ set -e export DOCKER_SCAN_SUGGEST=false -docker compose run --rm app composer install -docker compose run --rm app php artisan doctrine:migrations:migrate --no-interaction -docker compose run --rm app php artisan db:seed --force -docker compose run --rm app php artisan idp:create-super-admin test@test.com 1Qaz2wsx! +# Install PHP deps without running post-autoload scripts (package:discover +# boots Laravel which triggers the OTEL exporter flush — hangs if the +# collector isn't up yet). +docker compose run --rm app composer install --no-scripts + +# JS deps and build don't involve artisan, safe to run before the full stack. docker compose run --rm app yarn install docker compose run --rm app yarn build + +# Bring up the full stack so the OTEL collector is reachable before any +# artisan command runs. docker compose up -d -docker compose exec app /bin/bash \ No newline at end of file + +echo "Waiting for app container to be ready..." +until docker compose exec app true 2>/dev/null; do sleep 1; done + +# Now run artisan commands with every service available. +docker compose exec app php artisan package:discover --ansi +docker compose exec app php artisan doctrine:migrations:migrate --no-interaction +docker compose exec app php artisan db:seed --force +docker compose exec app php artisan idp:create-super-admin test@test.com 1Qaz2wsx! +docker compose exec app php artisan idp:create-raw-user e2e@test.com 1Qaz2wsx! + +# Install Playwright Chromium into the named volume (skipped automatically if +# already cached from a previous run). +docker compose --profile e2e run --rm playwright npx playwright install chromium + +docker compose exec app /bin/bash 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/AuthServiceLoginUserTest.php b/tests/AuthServiceLoginUserTest.php new file mode 100644 index 00000000..e5acba90 --- /dev/null +++ b/tests/AuthServiceLoginUserTest.php @@ -0,0 +1,120 @@ +migrate(true)), closing + * the pre-auth session-fixation window (SDS idp-mfa.md §9.3) - no explicit + * Session::regenerate() call is needed. What matters is ordering: + * register() hashes the CURRENT session ID into op_browser_state (used for + * OIDC Session Management). If it ran BEFORE Auth::login(), that hash would + * be computed from the id Auth::login() is about to invalidate, desyncing + * the check-session iframe contract for any relying party using it. + */ +#[\PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses] +#[\PHPUnit\Framework\Attributes\PreserveGlobalState(false)] +final class AuthServiceLoginUserTest extends PHPUnitTestCase +{ + use MockeryPHPUnitIntegration; + + private AuthService $service; + + private $mock_principal_service; + + // Facade aliases + private $auth_mock; + + protected function setUp(): void + { + parent::setUp(); + + $mock_user_repository = $this->createMock(IUserRepository::class); + $mock_otp_repository = $this->createMock(IOAuth2OTPRepository::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); + $mock_auth_user_service = $this->createMock(IAuthUserService::class); + $mock_security_context_service = $this->createMock(ISecurityContextService::class); + $mock_tx_service = $this->createMock(ITransactionService::class); + + $this->auth_mock = Mockery::mock('alias:Illuminate\Support\Facades\Auth'); + + $log_mock = Mockery::mock('alias:Illuminate\Support\Facades\Log'); + $log_mock->shouldReceive('debug')->zeroOrMoreTimes(); + + $this->service = new AuthService( + $mock_user_repository, + $mock_otp_repository, + $this->mock_principal_service, + $mock_user_service, + $mock_user_action_service, + $mock_cache_service, + $mock_auth_user_service, + $mock_security_context_service, + $mock_tx_service + ); + } + + private function mockLoggableUser(): Mockery\MockInterface + { + $user = Mockery::mock('Auth\User'); + $user->shouldReceive('canLogin')->andReturn(true); + $user->shouldReceive('getId')->andReturn(42); + return $user; + } + + public function testLoginUserCallsAuthLoginBeforeRegisteringPrincipal(): void + { + $user = $this->mockLoggableUser(); + $call_order = []; + + $this->auth_mock->shouldReceive('login')->once()->andReturnUsing(function () use (&$call_order) { + $call_order[] = 'auth_login'; + }); + + $this->mock_principal_service->expects($this->once())->method('clear'); + $this->mock_principal_service->expects($this->once())->method('register')->willReturnCallback( + function () use (&$call_order) { + $call_order[] = 'principal_register'; + } + ); + + $this->service->loginUser($user, false); + + $this->assertSame( + ['auth_login', 'principal_register'], + $call_order, + 'Auth::login() must run before register() computes op_browser_state from the session ID - ' . + 'Auth::login() regenerates the session ID internally, so register() must use the post-login id' + ); + } +} diff --git a/tests/AuthServiceValidateCredentialsIntegrationTest.php b/tests/AuthServiceValidateCredentialsIntegrationTest.php new file mode 100644 index 00000000..ab512a6b --- /dev/null +++ b/tests/AuthServiceValidateCredentialsIntegrationTest.php @@ -0,0 +1,106 @@ +auth_service = $this->app[UtilsServiceCatalog::AuthenticationService]; + } + + /** + * A failed validateCredentials() call must: + * - throw AuthenticationException, + * - NOT establish a session (Auth::check() stays false), + * - trigger LockUserCounterMeasure so the user's login_failed_attempt counter increments. + */ + public function testFailedAttempt_incrementsLoginFailedAttemptCounter(): void + { + $initial_attempts = $this->getLoginFailedAttempt(self::SEEDED_USERNAME); + $this->assertFalse(Auth::check(), 'precondition: no authenticated user'); + + $threw = false; + try { + $this->auth_service->validateCredentials(self::SEEDED_USERNAME, 'wrong-password'); + } catch (AuthenticationException $ex) { + $threw = true; + } + + $this->assertTrue($threw, 'Expected AuthenticationException on wrong password'); + $this->assertFalse(Auth::check(), 'No session should be established after a failed attempt'); + + $new_attempts = $this->getLoginFailedAttempt(self::SEEDED_USERNAME); + $this->assertSame( + $initial_attempts + 1, + $new_attempts, + 'login_failed_attempt counter must increment via LockUserCounterMeasure' + ); + } + + /** + * A successful validateCredentials() call must return the user without + * establishing a session — Auth::check() must remain false afterwards. + */ + public function testSuccessfulValidation_doesNotEstablishSession(): void + { + $this->assertFalse(Auth::check(), 'precondition: no authenticated user'); + + $user = $this->auth_service->validateCredentials( + self::SEEDED_USERNAME, + self::SEEDED_PASSWORD + ); + + $this->assertInstanceOf(User::class, $user); + $this->assertFalse( + Auth::check(), + 'validateCredentials() must NOT call Auth::login() on success' + ); + } + + private function getLoginFailedAttempt(string $username): int + { + // Clear Doctrine's identity map so we read fresh state from the DB, + // not a cached in-memory entity from a prior transaction. + EntityManager::clear(); + $repo = EntityManager::getRepository(User::class); + /** @var IUserRepository $repo */ + $user = $repo->getByEmailOrName($username); + $this->assertInstanceOf(User::class, $user, "Seeded user {$username} not found"); + return $user->getLoginFailedAttempt(); + } +} diff --git a/tests/DeviceTrustServiceTest.php b/tests/DeviceTrustServiceTest.php new file mode 100644 index 00000000..4d8ae8fb --- /dev/null +++ b/tests/DeviceTrustServiceTest.php @@ -0,0 +1,335 @@ +repo = Mockery::mock(IUserTrustedDeviceRepository::class); + $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 + { + parent::tearDown(); + Mockery::close(); + } + + // ------------------------------------------------------------------------- + // isDeviceTrusted + // ------------------------------------------------------------------------- + + 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)); + } + + 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, '')); + } + + public function testIsDeviceTrustedWrongCookie(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + $this->repo + ->shouldReceive('getByUserAndDeviceIdentifier') + ->once() + ->andReturn(null); + + $this->assertFalse($this->service->isDeviceTrusted($user, 'unknowntoken')); + } + + public function testIsDeviceTrustedRevokedDevice(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + + $device = $this->makeDevice(expired: false, revoked: true); + + $this->repo + ->shouldReceive('getByUserAndDeviceIdentifier') + ->once() + ->andReturn($device); + + $this->assertFalse($this->service->isDeviceTrusted($user, 'sometoken')); + } + + public function testIsDeviceTrustedExpiredDevice(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + + $device = $this->makeDevice(expired: true, revoked: false); + + $this->repo + ->shouldReceive('getByUserAndDeviceIdentifier') + ->once() + ->andReturn($device); + + $this->assertFalse($this->service->isDeviceTrusted($user, 'sometoken')); + } + + public function testIsDeviceTrustedValidDevice(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + + $device = $this->makeDevice(expired: false, revoked: false); + + $this->repo + ->shouldReceive('getByUserAndDeviceIdentifier') + ->once() + ->andReturn($device); + $this->repo->shouldReceive('add')->once(); + + $this->assertTrue($this->service->isDeviceTrusted($user, 'sometoken')); + } + + 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 + $oldDate = new DateTime('2000-01-01', new DateTimeZone('UTC')); + $device->setLastSeenAt($oldDate); + + $this->repo + ->shouldReceive('getByUserAndDeviceIdentifier') + ->once() + ->andReturn($device); + $this->repo->shouldReceive('add')->once(); + + $this->service->isDeviceTrusted($user, 'sometoken'); + + $this->assertNotNull($device); + $this->assertGreaterThan($oldDate, $device->getLastSeenAt()); + } + + // ------------------------------------------------------------------------- + // trustDevice + // ------------------------------------------------------------------------- + + public function testTrustDeviceReturnsToken(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + + $this->repo->shouldReceive('add')->once(); + + $token = $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1'); + + $this->assertSame(128, strlen($token)); + $this->assertMatchesRegularExpression('/^[0-9a-f]{128}$/', $token); + } + + public function testTrustDeviceStoresHash(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + + /** @var UserTrustedDevice|null $persistedDevice */ + $persistedDevice = null; + + $this->repo + ->shouldReceive('add') + ->once() + ->withArgs(function ($device) use (&$persistedDevice) { + $persistedDevice = $device; + return true; + }); + + $rawToken = $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1'); + + $this->assertNotNull($persistedDevice); + $this->assertSame(hash('sha256', $rawToken), $persistedDevice->getDeviceIdentifier()); + } + + public function testTrustDeviceRawTokenNotStored(): void + { + $user = Mockery::mock(User::class); + $user->shouldReceive('getTwoFactorMethod')->andReturn(User::MFAMethod_OTP); + + /** @var UserTrustedDevice|null $persistedDevice */ + $persistedDevice = null; + + $this->repo + ->shouldReceive('add') + ->once() + ->withArgs(function ($device) use (&$persistedDevice) { + $persistedDevice = $device; + return true; + }); + + $rawToken = $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1'); + + $this->assertNotNull($persistedDevice); + $this->assertNotSame($rawToken, $persistedDevice->getDeviceIdentifier()); + } + + 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; + + $this->repo + ->shouldReceive('add') + ->once() + ->withArgs(function ($device) use (&$persistedDevice) { + $persistedDevice = $device; + return true; + }); + + $this->service->trustDevice($user, 'Mozilla/5.0', '127.0.0.1'); + + $this->assertNotNull($persistedDevice); + + $lifetimeDays = (int) config('two_factor.device_trust_lifetime_days', 30); + $diff = $persistedDevice->getTrustedAt()->diff($persistedDevice->getExpiresAt()); + $this->assertSame($lifetimeDays, $diff->days); + } + + // ------------------------------------------------------------------------- + // removeTrustedDevices + // ------------------------------------------------------------------------- + + 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); + } + + // ------------------------------------------------------------------------- + // generateDeviceIdentifier + // ------------------------------------------------------------------------- + + public function testGenerateDeviceIdentifierReturnsSha256(): void + { + $token = 'test_token_value'; + $expected = hash('sha256', $token); + + $this->assertSame($expected, $this->service->generateDeviceIdentifier($token)); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private function makeDevice(bool $expired, bool $revoked): UserTrustedDevice + { + $device = new UserTrustedDevice(); + + $now = new DateTime('now', new DateTimeZone('UTC')); + + if ($expired) { + $expiresAt = clone $now; + $expiresAt->sub(new DateInterval('P1D')); // 1 day in the past + } else { + $expiresAt = clone $now; + $expiresAt->add(new DateInterval('P30D')); // 30 days in the future + } + + $device->setExpiresAt($expiresAt); + $device->setIsRevoked($revoked); + $device->setDeviceIdentifier($this->service->generateDeviceIdentifier('sometoken')); + $device->setIpAddress('127.0.0.1'); + $device->setTrustedAt($now); + $device->setLastSeenAt(clone $now); + + return $device; + } +} diff --git a/tests/OAuth2NativeMFALoginFlowTest.php b/tests/OAuth2NativeMFALoginFlowTest.php new file mode 100644 index 00000000..ea9ddb27 --- /dev/null +++ b/tests/OAuth2NativeMFALoginFlowTest.php @@ -0,0 +1,96 @@ +authorize('native'); + + $response = $this->postLoginPassword(); + + $this->assertResponseStatus(412); + $payload = json_decode($response->getContent(), true); + $this->assertSame('mfa_required', $payload['error_code']); + } + + public function testNonNativeClientReceives302RedirectOnMFARequired(): void + { + // No display param -> defaults to the non-native (page/popup/touch) + // display strategy. + $this->authorize(); + + $response = $this->postLoginPassword(); + + $this->assertResponseStatus(302, 'must redirect like every other login outcome, not return JSON'); + $this->assertSame('2fa', Session::get('flow'), 'the redirected page must be able to restore the 2FA screen'); + } + + /** + * Unauthenticated authorize request - the grant serializes the OAuth2 + * memento and hands off to the login flow. + */ + private function authorize(?string $display = null) + { + $params = [ + 'client_id' => self::CLIENT_ID, + 'redirect_uri' => 'https://www.test.com:443/oauth2?param=1&BackUrl=123344', + 'response_type' => 'code', + 'scope' => sprintf('%s/resource-server/read', Config::get('app.url')), + ]; + if (!is_null($display)) { + $params['display'] = $display; + } + + return $this->action('POST', 'OAuth2\OAuth2ProviderController@auth', $params); + } + + /** + * Submits the enforced-2FA admin's password within the same session - + * this is what postLogin() sees as an OAuth2-originated login attempt + * via the persisted memento. + */ + private function postLoginPassword() + { + return $this->action('POST', 'UserController@postLogin', [ + 'username' => self::ADMIN_EMAIL, + 'password' => self::SEED_PASSWORD, + 'flow' => 'password', + '_token' => Session::token(), + ]); + } +} 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/RecoveryCodeRegenerationTest.php b/tests/RecoveryCodeRegenerationTest.php new file mode 100644 index 00000000..cec67de8 --- /dev/null +++ b/tests/RecoveryCodeRegenerationTest.php @@ -0,0 +1,237 @@ +withoutMiddleware(); + $this->be($this->admin()); + Session::start(); + } + + public function testRegenerateWithCorrectPasswordInvalidatesOldCodesAndReturnsNewOnes(): void + { + $admin = $this->admin(); + $this->createRecoveryCode($admin, 'OLD-CODE-' . uniqid(), false); + + $response = $this->regenerate(self::SEED_PASSWORD); + + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertArrayHasKey('recovery_codes', $payload); + + $expectedCount = (int)config('auth.recovery_codes.count', 10); + $this->assertCount($expectedCount, $payload['recovery_codes']); + foreach ($payload['recovery_codes'] as $code) { + $this->assertMatchesRegularExpression('/^[A-Z0-9]+-[A-Z0-9]+$/', $code); + } + + EntityManager::clear(); + $remaining = EntityManager::getRepository(UserRecoveryCode::class) + ->findBy(['user' => $admin->getId(), 'used_at' => null]); + $this->assertCount( + $expectedCount, + $remaining, + 'old codes must be invalidated and replaced by exactly the configured count' + ); + } + + public function testRegenerateWithWrongPasswordFailsAndDoesNotTouchExistingCodes(): void + { + $admin = $this->admin(); + $plain = 'KEEP-ME-' . uniqid(); + $this->createRecoveryCode($admin, $plain, false); + + $response = $this->regenerate('this-is-not-the-password'); + + $this->assertResponseStatus(412); + + EntityManager::clear(); + $remaining = EntityManager::getRepository(UserRecoveryCode::class) + ->findBy(['user' => $admin->getId(), 'used_at' => null]); + $this->assertNotEmpty($remaining, 'existing codes must not be touched when the password confirmation fails'); + } + + public function testRegenerateRequiresCurrentPassword(): void + { + $response = $this->action('POST', 'Api\\UserApiController@regenerateRecoveryCodes', [], [], [], []); + + $this->assertResponseStatus(412); + } + + public function testRegenerateLogsAuditEvent(): void + { + $admin = $this->admin(); + + $this->regenerate(self::SEED_PASSWORD); + + EntityManager::clear(); + $entries = EntityManager::getRepository(TwoFactorAuditLog::class) + ->findBy(['user' => $admin->getId(), 'event_type' => TwoFactorAuditLog::EventRecoveryCodesGenerated]); + $this->assertNotEmpty($entries, 'a recovery_codes_generated audit entry must be recorded'); + } + + public function testEnableTwoFactorGeneratesRecoveryCodes(): void + { + $admin = $this->admin(); + + $response = $this->enableTwoFactor('email_otp'); + + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertArrayHasKey('recovery_codes', $payload); + + $expectedCount = (int)config('auth.recovery_codes.count', 10); + $this->assertCount($expectedCount, $payload['recovery_codes']); + + EntityManager::clear(); + $reloaded = EntityManager::getRepository(User::class)->find($admin->getId()); + $this->assertTrue($reloaded->isTwoFactorEnabled(), '2FA must be enabled on the user after enrollment'); + + $remaining = EntityManager::getRepository(UserRecoveryCode::class) + ->findBy(['user' => $admin->getId(), 'used_at' => null]); + $this->assertCount($expectedCount, $remaining); + } + + public function testDisplayedRecoveryCodeRedeemsThroughVerifyRecoveryCode(): void + { + $admin = $this->admin(); + + $response = $this->regenerate(self::SEED_PASSWORD); + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $displayedCode = $payload['recovery_codes'][0]; + $this->assertMatchesRegularExpression('/^[A-Z0-9]+-[A-Z0-9]+$/', $displayedCode); + + EntityManager::clear(); + $admin = EntityManager::getRepository(User::class)->find($admin->getId()); + $unusedBefore = EntityManager::getRepository(UserRecoveryCode::class) + ->findBy(['user' => $admin->getId(), 'used_at' => null]); + + // The hash was generated over the dash-less string; redeeming the code + // exactly as it was displayed (with its "-" separator) must still work. + // Goes through IAuthService, like the real login flow, because + // verifyRecoveryCode() takes a PESSIMISTIC_WRITE row lock that requires + // an open transaction. + $strategy = MFAChallengeStrategyFactory::create(User::MFAMethod_OTP); + app(IAuthService::class)->verifyMFARecoveryCode($admin, $strategy, $displayedCode); + + EntityManager::clear(); + $unusedAfter = EntityManager::getRepository(UserRecoveryCode::class) + ->findBy(['user' => $admin->getId(), 'used_at' => null]); + + $this->assertCount( + count($unusedBefore) - 1, + $unusedAfter, + 'the code redeemed exactly as displayed must be consumed exactly once' + ); + } + + public function testEnableTwoFactorRejectsWhenAlreadyEnabled(): void + { + $this->enableTwoFactor('email_otp'); + $this->assertResponseStatus(200); + + $response = $this->enableTwoFactor('email_otp'); + + $this->assertResponseStatus(412); + + EntityManager::clear(); + $admin = $this->admin(); + $this->assertTrue($admin->isTwoFactorEnabled(), '2FA must remain enabled after the rejected second call'); + } + + public function testEnableTwoFactorRejectsUnavailableMethod(): void + { + // sms_otp is a stub in Phase I (isPhoneNumberVerified() is hardcoded false), + // so enable2FA() must reject it regardless of the requesting user. + $response = $this->enableTwoFactor('sms_otp'); + + $this->assertResponseStatus(412); + } + + public function testEnableTwoFactorRequiresMethod(): void + { + $response = $this->action('POST', 'Api\\UserApiController@enableTwoFactor', [], [], [], []); + + $this->assertResponseStatus(412); + } + + public function testEnableTwoFactorLogsEnrollmentAuditEvent(): void + { + $admin = $this->admin(); + + $this->enableTwoFactor('email_otp'); + + EntityManager::clear(); + $entries = EntityManager::getRepository(TwoFactorAuditLog::class) + ->findBy(['user' => $admin->getId(), 'event_type' => TwoFactorAuditLog::EventEnrollmentChanged]); + $this->assertNotEmpty($entries, 'an enrollment_changed audit entry must be recorded'); + } + + private function enableTwoFactor(string $method) + { + return $this->action('POST', 'Api\\UserApiController@enableTwoFactor', [ + 'method' => $method, + ], [], [], []); + } + + private function admin(): User + { + $user = EntityManager::getRepository(User::class)->findOneBy(['identifier' => self::ADMIN_IDENTIFIER]); + $this->assertInstanceOf(User::class, $user, 'seeded admin user not found'); + return $user; + } + + private function regenerate(string $password) + { + return $this->action('POST', 'Api\\UserApiController@regenerateRecoveryCodes', [ + 'current_password' => $password, + ], [], [], []); + } + + 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(); + } +} diff --git a/tests/TurnstileProtectedControllersTest.php b/tests/TurnstileProtectedControllersTest.php index c6076f40..dffc0134 100644 --- a/tests/TurnstileProtectedControllersTest.php +++ b/tests/TurnstileProtectedControllersTest.php @@ -17,9 +17,12 @@ /** * Class TurnstileProtectedControllersTest * - * Smoke tests verifying that cf-turnstile-response is always required on the - * five auth endpoints that gate every submission behind Turnstile (unlike - * UserController::postLogin, which only activates the rule above a threshold). + * Smoke tests verifying that cf-turnstile-response is required on the five auth + * endpoints that gate every submission behind Turnstile. + * + * Requests MUST go over HTTPS (callSecure) because .env.testing sets + * SSL_ENABLED=true, which causes SSLMiddleware to redirect plain HTTP requests + * to HTTPS before reaching any controller. */ final class TurnstileProtectedControllersTest extends BrowserKitTestCase { @@ -37,8 +40,8 @@ private function sessionHasValidationError(string $field): bool private function postWithSession(string $url, array $data = []): void { - $this->call('GET', $url); - $this->call('POST', $url, array_merge(['_token' => Session::token()], $data)); + $this->callSecure('GET', $url); + $this->callSecure('POST', $url, array_merge(['_token' => Session::token()], $data)); } // ------------------------------------------------------------------------- diff --git a/tests/TwoFactorLoginFlowTest.php b/tests/TwoFactorLoginFlowTest.php new file mode 100644 index 00000000..aa0abd56 --- /dev/null +++ b/tests/TwoFactorLoginFlowTest.php @@ -0,0 +1,1608 @@ +flushRateLimitCounters(); + } + + protected function tearDown(): void + { + $this->flushRateLimitCounters(); + parent::tearDown(); + } + + 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("{$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("{$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($otpPrefix . strtolower($email)); + Cache::forget($otpPrefix . strtolower($email) . ':timer'); + } + } + + // ------------------------------------------------------------------------- + // postLogin gate + // ------------------------------------------------------------------------- + + public function testAdminLoginTriggersMFAChallenge(): void + { + $response = $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + // The password flow submits as a native form POST, so the challenge + // is delivered via the same redirect+session-flash mechanism as every + // other login outcome (errorLogin()), not a live JSON response - + // see testAdminLoginPersistsUIStateForRefreshResilience for the + // session-state assertions the redirected page relies on. + $this->assertResponseStatus(302, 'must redirect back to the login screen, same as errorLogin(), not return JSON'); + $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 testAdminLoginPersistsUIStateForRefreshResilience(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + $this->assertSame('2fa', Session::get('flow'), 'a refresh mid-challenge must restore the 2FA screen, not the password form'); + $this->assertNotNull(Session::get('otp_length')); + $this->assertNotNull(Session::get('otp_lifetime')); + $this->assertNotNull(Session::get('otp_issued_at'), 'the issuance timestamp must be restorable so a refresh can seed the countdown with the REMAINING lifetime'); + $this->assertSame(User::MFAMethod_OTP, Session::get('mfa_method'), 'a refresh must restore the screen for the method actually challenged, not a hardcoded default'); + $this->assertSame(ILoginStrategy::MFA_REQUIRED, Session::get('error_code'), 'must match what DisplayResponseJsonStrategy sends native clients in its JSON body'); + } + + public function testRefreshMidChallengeSeedsCountdownWithRemainingLifetime(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + $lifetime = intval(Session::get('otp_lifetime')); + $this->assertGreaterThan(0, $lifetime); + + // Simulate a refresh 100 seconds into the challenge. + Session::put('otp_issued_at', time() - 100); + + $response = $this->action('GET', 'UserController@getLogin'); + $this->assertResponseOk(); + + $this->assertSame( + 1, + preg_match('/config\.otpLifetime = (\d+);/', $response->getContent(), $matches), + 'the login page must seed the countdown from session state' + ); + $remaining = intval($matches[1]); + // The countdown must be seeded with the REMAINING lifetime (~lifetime - 100), + // not restart at the full TTL - otherwise the UI overstates how long the + // code is valid and lets the user burn rate-limited attempts on a + // server-expired code. +/-2s tolerance for clock ticks between requests. + $this->assertLessThanOrEqual($lifetime - 98, $remaining); + $this->assertGreaterThanOrEqual($lifetime - 102, $remaining); + } + + public function testSuccessfulVerificationClearsUIState(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + $this->verify($code); + + $this->assertNull(Session::get('flow'), 'completed challenge must not leave the 2FA screen re-derivable from a stale refresh'); + $this->assertNull(Session::get('otp_length')); + $this->assertNull(Session::get('otp_lifetime')); + $this->assertNull(Session::get('otp_issued_at')); + $this->assertNull(Session::get('mfa_method')); + $this->assertNull(Session::get('error_code')); + // Identity/display fields written by postLogin()'s challengeRequired() + // payload must not survive a completed login either - otherwise a + // later visitor on the same browser session inherits this identity. + $this->assertNull(Session::get('username')); + $this->assertNull(Session::get('user_fullname')); + $this->assertNull(Session::get('user_pic')); + $this->assertNull(Session::get('user_verified')); + $this->assertNull(Session::get('user_is_active')); + } + + 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'); + } + + // ------------------------------------------------------------------------- + // passwordless (flow=otp) login must not bypass the MFA gate + // ------------------------------------------------------------------------- + + public function testEnforcedUserCannotBypassMFAViaPasswordlessLogin(): void + { + $this->emitOTP(self::ADMIN_EMAIL); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + $response = $this->postLoginOTP(self::ADMIN_EMAIL, $code); + + $this->assertFalse(Auth::check(), 'passwordless login must not authenticate an enforced-2FA user'); + + // The OTP flow still submits as a native form POST (PR #142 only + // converted the password flow to AJAX), so the rejection must go + // through the pre-existing errorLogin() redirect+flash mechanism, + // not the JSON contract built for the password flow's MFA gate. + $this->assertResponseStatus(302, 'must reuse errorLogin(), not a JSON response'); + $this->assertStringContainsString( + 'two-factor authentication', + Session::get('flash_notice'), + 'the flashed message must explain why passwordless login was rejected' + ); + $this->assertSame('otp', Session::get('flow'), 'a reload must land back on the OTP screen, not silently fall back to password'); + } + + public function testNonEnforcedUserStillLogsInViaPasswordlessLogin(): void + { + $email = $this->createPlainUser(); + $this->emitOTP($email); + $code = $this->latestOtpCode($email); + + $this->postLoginOTP($email, $code); + + $this->assertTrue(Auth::check(), 'passwordless login must keep working unchanged for non-enforced users'); + } + + public function testEnforcedUserCanUsePasswordlessWhenTwoFactorGloballyDisabled(): void + { + // Kill-switch (SDS idp-mfa.md §10.1): with 2FA globally disabled, the + // passwordless-login enforcement block must NOT fire - an enforced admin + // can log in passwordless again, matching "revert to password-only login". + // Regression guard for the gap where the block called shouldRequire2FA() + // without honoring config('two_factor.enabled'). + Config::set('two_factor.enabled', false); + + $this->emitOTP(self::ADMIN_EMAIL); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + $this->postLoginOTP(self::ADMIN_EMAIL, $code); + + $this->assertTrue( + Auth::check(), + 'with the kill-switch off, passwordless login must not be blocked for an enforced admin' + ); + } + + // ------------------------------------------------------------------------- + // passwordless (flow=otp) refresh-resilience + // ------------------------------------------------------------------------- + + public function testEmitOtpPersistsSessionStateForRefreshResilience(): void + { + $this->emitOTP(self::ADMIN_EMAIL); + + $this->assertSame('otp', Session::get('flow'), 'a refresh mid-code-entry must restore the OTP screen, not the email form'); + $this->assertTrue(Session::get('user_verified')); + $this->assertNotNull(Session::get('otp_length')); + $this->assertNotNull(Session::get('otp_lifetime')); + $this->assertNotNull(Session::get('otp_issued_at'), 'the issuance timestamp must be restorable so a refresh can seed the countdown with the REMAINING lifetime'); + $this->assertSame(self::ADMIN_EMAIL, Session::get('username')); + + $admin = $this->user(self::ADMIN_EMAIL); + $this->assertSame($admin->getFullName(), Session::get('user_fullname')); + $this->assertSame($admin->getPic(), Session::get('user_pic')); + $this->assertSame(1, Session::get('user_is_active')); + } + + public function testEmitOtpForNewUserStillPersistsRefreshState(): void + { + // No createPlainUser() call - this email has no existing User row. + // AuthService::loginWithOTP() auto-registers new users at redemption + // time, so emitOTP() must not silently skip the refresh-restoration + // state just because the identity lookup comes up empty. + $email = 'never.seen.' . uniqid() . '@test.invalid'; + + $this->emitOTP($email); + + $this->assertSame('otp', Session::get('flow')); + $this->assertTrue(Session::get('user_verified'), 'user_verified must persist even when no User row exists yet'); + $this->assertNotNull(Session::get('otp_length')); + $this->assertNotNull(Session::get('otp_lifetime')); + $this->assertNotNull(Session::get('otp_issued_at')); + $this->assertSame($email, Session::get('username')); + + // login.js's emitOtpAction() falls back to the submitted email as the + // chip's display name when there's no real full name yet (login.js:165-167) - + // the persisted session state must match that same fallback, or the + // identity chip (visible right after opting into OTP) vanishes on refresh + // instead of being restored identically. + $this->assertSame($email, Session::get('user_fullname'), 'must fall back to the submitted email, matching emitOtpAction()\'s client-side fallback'); + $this->assertNull(Session::get('user_pic'), 'no picture to persist for a not-yet-registered user'); + $this->assertNull(Session::get('user_is_active'), 'no active-status to persist for a not-yet-registered user'); + } + + public function testSuccessfulPasswordlessLoginClearsOtpSessionState(): void + { + $email = $this->createPlainUser(); + $this->emitOTP($email); + $code = $this->latestOtpCode($email); + + $this->postLoginOTP($email, $code); + + $this->assertTrue(Auth::check(), 'sanity check: the login itself must have succeeded'); + + // A completed passwordless login must not leave the OTP screen + // restorable on a later refresh - otherwise a subsequent unrelated + // visitor on the same browser session inherits this identity. + $this->assertNull(Session::get('flow')); + $this->assertNull(Session::get('user_verified')); + $this->assertNull(Session::get('otp_length')); + $this->assertNull(Session::get('otp_lifetime')); + $this->assertNull(Session::get('otp_issued_at')); + $this->assertNull(Session::get('username')); + $this->assertNull(Session::get('user_fullname')); + $this->assertNull(Session::get('user_pic')); + $this->assertNull(Session::get('user_is_active')); + } + + // ------------------------------------------------------------------------- + // cancelLogin + // ------------------------------------------------------------------------- + + public function testCancelClearsUIStateAndPendingChallenge(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + $this->cancelLogin(); + + $this->assertNull(Session::get('flow'), 'cancel must not leave a stale 2FA screen restorable on refresh'); + $this->assertNull(Session::get('mfa_method')); + $this->assertNull(Session::get('otp_length')); + $this->assertNull(Session::get('otp_lifetime')); + $this->assertNull(Session::get('otp_issued_at')); + // Identity/display fields written by postLogin()'s challengeRequired() + // payload must not survive cancel either - otherwise a later visitor + // on the same browser session inherits the cancelled attempt's identity. + $this->assertNull(Session::get('username')); + $this->assertNull(Session::get('user_fullname')); + $this->assertNull(Session::get('user_pic')); + $this->assertNull(Session::get('user_verified')); + $this->assertNull(Session::get('user_is_active')); + + // The strongest proof: the OTP issued before cancel must no longer + // complete a login. If pending state survived cancel, this would + // succeed with a 302 despite the user having explicitly cancelled. + $response = $this->verify($code); + $this->assertResponseStatus(401); + $payload = json_decode($response->getContent(), true); + $this->assertSame(MFAConstants::ERROR_CODE_SESSION_EXPIRED, $payload['error_code']); + $this->assertFalse(Auth::check(), 'a cancelled challenge must never establish a session'); + } + + public function testCancelClearsPasswordlessOtpSessionState(): void + { + // Server-side proof for the client fix in login.js's handleDelete() + // (widened to call cancelLogin() for the OTP flow, not just MFA): + // cancelLogin() already unconditionally clears the same key set + // emitOTP() writes, so a refresh after "sign in using a different + // e-mail" must not resurrect the abandoned OTP screen. + $email = $this->createPlainUser(); + $this->emitOTP($email); + + $this->cancelLogin(); + + $this->assertNull(Session::get('flow'), 'cancel must not leave a stale OTP screen restorable on refresh'); + $this->assertNull(Session::get('user_verified')); + $this->assertNull(Session::get('otp_length')); + $this->assertNull(Session::get('otp_lifetime')); + $this->assertNull(Session::get('otp_issued_at')); + $this->assertNull(Session::get('username')); + $this->assertNull(Session::get('user_fullname')); + $this->assertNull(Session::get('user_pic')); + $this->assertNull(Session::get('user_is_active')); + } + + // ------------------------------------------------------------------------- + // email delivery + // ------------------------------------------------------------------------- + + public function testMFAChallengeQueuesEmailWithCorrectOTPCode(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + $dbCode = $this->latestOtpCode(self::ADMIN_EMAIL); + + Mail::assertQueued( + OAuth2PasswordlessOTPMail::class, + function (OAuth2PasswordlessOTPMail $mail) use ($dbCode): bool { + return $mail->email === self::ADMIN_EMAIL + && $mail->otp === $dbCode; + } + ); + } + + public function testResendMFAChallengeQueuesAdditionalEmail(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $this->resend(); + + Mail::assertQueued(OAuth2PasswordlessOTPMail::class, 2); + } + + // ------------------------------------------------------------------------- + // verify2FA + // ------------------------------------------------------------------------- + + public function testSuccessfulOTPVerificationCompletesLogin(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + $response = $this->verify($code); + + // verify2FA returns the post-login destination as JSON data (not a raw redirect) + // so the caller's XHR never has to follow it itself - a real top-level navigation + // to redirect_url is what actually completes any further hop, cross-origin included. + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $this->assertIsString($payload['redirect_url'] ?? null); + $this->assertStringStartsWith('http', $payload['redirect_url']); + $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(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'); + $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(MFAConstants::ERROR_CODE_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(MFAConstants::ERROR_CODE_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); + // No "-" and all-uppercase: verifyRecoveryCode() now strips separators + // and uppercases the submitted code before Hash::check() (real codes are + // hashed dash-less and all-uppercase; the dash/case are display-only), + // so a fixture hashed with lowercase uniqid() hex would never match its + // own (normalized) submission. + $plain = 'RECOVERYREUSETX' . strtoupper(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(MFAConstants::ERROR_CODE_INVALID_RECOVERY, $payload['error_code'], + 'recovery code reuse must be rejected because used_at was committed via the AuthService transaction'); + } + + public function testOTPRedeemRollsBackOnMidTransactionFailure(): void + { + // Ticket CU-86ba2zc6p TESTS list: "OTP redeem is persisted only on + // commit; a failure inside the verify transaction rolls back the + // redeem." Wraps the REAL strategy so the OTP genuinely gets redeemed + // mid-transaction, then injects a failure before the transaction + // (AuthService::verifyMFAChallenge) can commit. + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + $admin = $this->user(self::ADMIN_EMAIL); + + $realStrategy = MFAChallengeStrategyFactory::create(User::MFAMethod_OTP); + $faultyStrategy = new class($realStrategy) implements IMFAChallengeStrategy { + public function __construct(private IMFAChallengeStrategy $inner) + { + } + + public function issueChallenge(User $user, ?Client $client, bool $remember): array + { + return $this->inner->issueChallenge($user, $client, $remember); + } + + public function verifyChallenge(User $user, string $code, ?Client $client = null): void + { + $this->inner->verifyChallenge($user, $code, $client); + throw new \RuntimeException('Simulated mid-transaction failure after redeem'); + } + + public function resendChallenge(User $user, ?Client $client, bool $remember): array + { + return $this->inner->resendChallenge($user, $client, $remember); + } + + public function getPendingState(): ?MFAPendingState + { + return $this->inner->getPendingState(); + } + + public function clearPendingState(): void + { + $this->inner->clearPendingState(); + } + + public function verifyRecoveryCode(User $user, string $code): void + { + $this->inner->verifyRecoveryCode($user, $code); + } + }; + + /** @var IAuthService $authService */ + $authService = App::make(IAuthService::class); + + try { + $authService->verifyMFAChallenge($admin, $faultyStrategy, $code); + $this->fail('Expected the simulated mid-transaction failure to propagate'); + } catch (\RuntimeException $ex) { + $this->assertSame('Simulated mid-transaction failure after redeem', $ex->getMessage()); + } + + EntityManager::clear(); + /** @var IOAuth2OTPRepository $otpRepo */ + $otpRepo = App::make(IOAuth2OTPRepository::class); + $otp = $otpRepo->getByValue($code); + + $this->assertNotNull($otp, 'the OTP row itself must still exist - only the redeem must roll back'); + $this->assertFalse( + $otp->isRedeemed(), + 'a failure inside the verify transaction must roll back the OTP redeem, not persist it' + ); + } + + // ------------------------------------------------------------------------- + // concurrency: pessimistic-lock proof for OTP / recovery-code redemption + // + // refreshExclusiveLock() (EmailOTPMFAChallengeStrategy::verifyChallenge, + // AbstractMFAChallengeStrategy::verifyRecoveryCode) exists specifically to + // close a check-then-redeem TOCTOU race between two concurrent requests. + // testOTPCodeRejectsReuseAfterSuccessfulVerification / testRecoveryCodeRejects + // ReuseAfterTransactionCommit above only prove SEQUENTIAL reuse is rejected. + // These tests prove the row lock the production code acquires actually + // blocks a second, independent physical DB connection while held. + // ------------------------------------------------------------------------- + + public function testOTPRedeemRowLockBlocksConcurrentConnection(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + + /** @var IOAuth2OTPRepository $otpRepo */ + $otpRepo = App::make(IOAuth2OTPRepository::class); + $otp = $otpRepo->getByValue($code); + $this->assertNotNull($otp); + + $this->assertLockBlocksSecondConnection( + 'oauth2_otp', + $otp->getId(), + fn() => $otpRepo->refreshExclusiveLock($otp) + ); + } + + public function testRecoveryCodeRowLockBlocksConcurrentConnection(): void + { + $admin = $this->user(self::ADMIN_EMAIL); + $plain = 'RECOVERYLOCK' . strtoupper(uniqid()); + $codeId = $this->createRecoveryCode($admin, $plain, false); + + /** @var IUserRecoveryCodeRepository $recoveryRepo */ + $recoveryRepo = App::make(IUserRecoveryCodeRepository::class); + $recoveryCode = EntityManager::find(UserRecoveryCode::class, $codeId); + $this->assertNotNull($recoveryCode); + + $this->assertLockBlocksSecondConnection( + 'user_recovery_codes', + $codeId, + fn() => $recoveryRepo->refreshExclusiveLock($recoveryCode) + ); + } + + /** + * Proves that a PESSIMISTIC_WRITE lock acquired via $acquireLock (the same + * production method the MFA strategies call before redeeming) blocks a + * genuinely separate, concurrent physical DB connection from also locking + * that row - i.e. the fix actually closes the TOCTOU redemption race, not + * just rejects a sequential re-submission. + * + * $table is always an internal literal supplied by this test file, never + * external input, so interpolating it into the probe SQL below is safe. + */ + private function assertLockBlocksSecondConnection(string $table, int $id, \Closure $acquireLock): void + { + $primary = EntityManager::getConnection(); + $primary->beginTransaction(); + + try { + $acquireLock(); + + // Register a second connection under a distinct name so Laravel's + // DatabaseManager opens an independent physical connection instead + // of returning the already-cached primary one. + Config::set('database.connections.mfa_lock_test_secondary', Config::get('database.connections.openstackid')); + $secondary = DB::connection('mfa_lock_test_secondary'); + + $primaryConnId = (int) $primary->executeQuery('SELECT CONNECTION_ID()')->fetchOne(); + $secondaryConnId = (int) $secondary->selectOne('SELECT CONNECTION_ID() AS id')->id; + $this->assertNotSame( + $primaryConnId, + $secondaryConnId, + 'test requires two independent physical DB connections to prove real lock contention' + ); + + $secondary->statement('SET SESSION innodb_lock_wait_timeout = 1'); + $secondary->beginTransaction(); + + try { + $secondary->selectOne("SELECT id FROM {$table} WHERE id = ? FOR UPDATE", [$id]); + $this->fail('a second connection must not be able to lock a row already held by refreshExclusiveLock()'); + } catch (\Illuminate\Database\QueryException $ex) { + $this->assertStringContainsStringIgnoringCase( + 'lock wait timeout', + $ex->getMessage(), + 'the second connection must be blocked by the row lock, not fail for an unrelated reason' + ); + } finally { + $secondary->rollBack(); + DB::purge('mfa_lock_test_secondary'); + } + } finally { + $primary->rollBack(); + } + } + + 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(MFAConstants::ERROR_CODE_SESSION_EXPIRED, $payload['error_code']); + } + + // ------------------------------------------------------------------------- + // session security (SDS idp-mfa.md §9.3: session fixation) + // + // Session-fixation protection itself is already provided by Laravel's + // SessionGuard::login() (session->migrate(true), called via Auth::login() + // inside loginUser()) - not something this branch needs to add. What + // AuthServiceLoginUserTest and the test below actually cover is the + // ordering bug this investigation found: PrincipalService::register() + // must run AFTER Auth::login(), not before, or its op_browser_state hash + // is computed from a session ID Auth::login() is about to invalidate. + // ------------------------------------------------------------------------- + + public function testCompletedMFALoginKeepsOPBrowserStateConsistentWithSessionId(): void + { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $code = $this->latestOtpCode(self::ADMIN_EMAIL); + $this->verify($code); + + // PrincipalService::register() hashes the session ID into + // op_browser_state (OIDC Session Management). If it ran BEFORE + // Auth::login()'s internal session->migrate(true), this would be a + // hash of the id Auth::login() was about to invalidate instead. + $this->assertSame( + hash('sha256', Session::getId()), + Session::get(PrincipalService::OPBrowserState), + 'op_browser_state must be derived from the post-regeneration session ID' + ); + } + + // ------------------------------------------------------------------------- + // 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(200); + $payload = json_decode($response->getContent(), true); + $this->assertIsString($payload['redirect_url'] ?? null); + + 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(200, $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 testRecoveryAuditFailureDoesNotBlockLogin(): void + { + // Audit is best-effort: a failure emitting recovery_used must NOT 500 a + // user whose recovery code is already burned and session established. + $admin = $this->user(self::ADMIN_EMAIL); + $plain = 'RECOVERYAUDITFAIL' . strtoupper(uniqid()); + $this->createRecoveryCode($admin, $plain, false); + + $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::EventRecoveryUsed) { + throw new \Exception('audit sink unavailable'); + } + }); + $this->app->instance(ITwoFactorAuditService::class, $auditMock); + + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + $response = $this->recovery($plain); + + $this->assertEquals(200, $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(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(MFAConstants::SESSION_KEY_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 = 'RECOVERYPLAIN123'; + $codeId = $this->createRecoveryCode($admin, $plain, false); + + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + $response = $this->recovery($plain); + + $this->assertResponseStatus(200); + $payload = json_decode($response->getContent(), true); + $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'); + $this->assertGreaterThan(0, $this->countAudit($admin->getId(), TwoFactorAuditLog::EventRecoveryUsed)); + } + + public function testUsedRecoveryCodeFails(): void + { + $admin = $this->user(self::ADMIN_EMAIL); + $plain = 'RECOVERYUSED456'; + $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(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 + // ------------------------------------------------------------------------- + + 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(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')); + } + + 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(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')); + } + + 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(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')); + } + + public function testInitialChallengeIssuanceCountsAgainstResendRateLimitWindow(): void + { + // SDS idp-mfa.md §4.12: "The initial OTP issuance during postLogin() + // shares the 2fa_rate:resend:{user_id} cache key, ensuring the first + // challenge counts against the same 5-request issuance window as + // subsequent resends." Each postLogin() call re-issues a challenge and + // must count against that SAME window. + $max = (int) Config::get('two_factor.rate_limit.max_otp_requests'); + for ($i = 0; $i < $max; $i++) { + $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + } + + $response = $this->postLogin(self::ADMIN_EMAIL, self::SEED_PASSWORD); + + // Rate-limited postLogin() must reuse errorLogin(), not the JSON + // contract resend()/verify() use - the password form still submits + // as a native form POST. + $this->assertResponseStatus(302, 'must redirect like every other login outcome, not return JSON'); + $this->assertStringContainsString('Too many attempts', Session::get('flash_notice')); + $this->assertFalse(Auth::check()); + } + + public function testOtpEmailRateLimitBlocksAfterThreshold(): void + { + $max = (int) Config::get('two_factor.rate_limit.max_otp_email_requests'); + for ($i = 0; $i < $max; $i++) { + $this->emitOTP(self::ADMIN_EMAIL); + } + + $response = $this->emitOTP(self::ADMIN_EMAIL); + $this->assertResponseStatus(429); + $payload = json_decode($response->getContent(), true); + $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. + $this->assertSame((string) $max, $response->headers->get('X-RateLimit-Limit')); + $this->assertSame('0', $response->headers->get('X-RateLimit-Remaining')); + $retryAfter = $response->headers->get('Retry-After'); + $this->assertNotNull($retryAfter, 'Retry-After must be present on a 429'); + $this->assertGreaterThan(0, (int) $retryAfter); + + // A different email must be unaffected - the subject is per-email, not global. + // emitOTP() never looks up an existing user before creating the OTP, so a + // non-seeded literal email is a valid, distinct rate-limit subject here. + $otherResponse = $this->emitOTP('someone-else@example.com'); + $this->assertNotEquals(429, $otherResponse->getStatusCode()); + } + + public function testOtpEmailRateLimitIsCaseInsensitive(): void + { + // users.email has a case-insensitive collation (utf8mb3_unicode_ci) and every + // session-keyed 2FA action resolves through a case-insensitive DB lookup before + // ever touching the rate limiter. The otp action has no such lookup - the raw + // submitted string IS the cache key - so casing must be canonicalized here or + // an attacker can reset the budget every request by cycling the target email's + // letter casing, defeating the limit entirely. + $max = (int) Config::get('two_factor.rate_limit.max_otp_email_requests'); + $casings = ['sebastian@tipit.net', 'Sebastian@Tipit.net', 'SEBASTIAN@TIPIT.NET', 'sEbAsTiAn@tIpIt.NeT']; + for ($i = 0; $i < $max; $i++) { + $this->emitOTP($casings[$i % count($casings)]); + } + + $response = $this->emitOTP('SEBASTIAN@TIPIT.NET'); + $this->assertResponseStatus(429); + $payload = json_decode($response->getContent(), true); + $this->assertSame(MFAConstants::ERROR_CODE_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 cancelLogin() + { + return $this->action('POST', 'UserController@cancelLogin', [ + '_token' => Session::token(), + ]); + } + + private function emitOTP(string $username) + { + return $this->action('POST', 'UserController@emitOTP', [ + 'username' => $username, + 'connection' => 'email', + 'send' => 'code', + '_token' => Session::token(), + ]); + } + + private function postLoginOTP(string $username, string $code) + { + return $this->action('POST', 'UserController@postLogin', [ + 'username' => $username, + 'password' => $code, + 'connection' => 'email', + 'flow' => 'otp', + '_token' => Session::token(), + ]); + } + + 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/TwoFactorRepositoriesTest.php b/tests/TwoFactorRepositoriesTest.php new file mode 100644 index 00000000..dccec14d --- /dev/null +++ b/tests/TwoFactorRepositoriesTest.php @@ -0,0 +1,407 @@ +setFirstName('Test'); + $user->setLastName('TwoFactor'); + $user->setEmail('test.twofactor.' . uniqid() . '@test.invalid'); + $user->setAddress1('123 Test St'); + $user->setState('CA'); + $user->setCity('Testville'); + $user->setPostCode('00000'); + $user->setCountryIsoCode('US'); + $user->setPic(''); + $user->setLastLoginDate(new \DateTime('now', new \DateTimeZone('UTC'))); + EntityManager::persist($user); + EntityManager::flush(); + $this->user = $user; + } + + public function tearDown(): void + { + if ($this->user !== null) { + $managed = EntityManager::find(User::class, $this->user->getId()); + if ($managed !== null) { + EntityManager::remove($managed); + EntityManager::flush(); + } + } + parent::tearDown(); + } + + public function testTrustedDeviceRoundTrip(): void + { + $repo = App::make(IUserTrustedDeviceRepository::class); + + $now = new \DateTime('now', new \DateTimeZone('UTC')); + $expires = (clone $now)->modify('+30 days'); + $deviceId = hash('sha256', 'test-token-' . uniqid()); + + $device = new UserTrustedDevice(); + $device->setUser($this->user); + $device->setDeviceIdentifier($deviceId); + $device->setDeviceName('Chrome on MacOS'); + $device->setIpAddress('127.0.0.1'); + $device->setUserAgent('Mozilla/5.0 (test)'); + $device->setTrustedAt($now); + $device->setExpiresAt($expires); + $device->setLastSeenAt($now); + + EntityManager::persist($device); + EntityManager::flush(); + $id = $device->getId(); + $this->assertGreaterThan(0, $id); + + EntityManager::clear(); + + $found = $repo->getActiveByUserAndIdentifier($this->user, $deviceId); + $this->assertNotNull($found); + $this->assertEquals($deviceId, $found->getDeviceIdentifier()); + $this->assertFalse($found->isRevoked()); + + $active = $repo->getActiveByUser($this->user); + $this->assertNotEmpty($active); + + EntityManager::remove($found); + EntityManager::flush(); + } + + public function testAuditLogRoundTrip(): void + { + $repo = App::make(ITwoFactorAuditLogRepository::class); + + $entry = new TwoFactorAuditLog(); + $entry->setUser($this->user); + $entry->setEventType(TwoFactorAuditLog::EventChallengeIssued); + $entry->setMethod(TwoFactorAuditLog::MethodEmailOtp); + $entry->setIpAddress('10.0.0.1'); + $entry->setUserAgent('Mozilla/5.0 (test)'); + $entry->setMetadata(['challenge_id' => 'abc123']); + + EntityManager::persist($entry); + EntityManager::flush(); + $id = $entry->getId(); + $this->assertGreaterThan(0, $id); + + EntityManager::clear(); + + $recent = $repo->getRecentByUser($this->user, 10); + $this->assertNotEmpty($recent); + $found = null; + foreach ($recent as $row) { + if ($row->getId() === $id) { $found = $row; break; } + } + $this->assertNotNull($found); + $this->assertEquals(TwoFactorAuditLog::EventChallengeIssued, $found->getEventType()); + $this->assertEquals(['challenge_id' => 'abc123'], $found->getMetadata()); + + EntityManager::remove($found); + EntityManager::flush(); + } + + public function testRecoveryCodeRoundTrip(): void + { + $repo = App::make(IUserRecoveryCodeRepository::class); + + $code = new UserRecoveryCode(); + self::setProp($code, 'user', $this->user); + self::setProp($code, 'code_hash', password_hash('TESTCODE', PASSWORD_BCRYPT)); + + EntityManager::persist($code); + EntityManager::flush(); + $id = $code->getId(); + $this->assertGreaterThan(0, $id); + $this->assertFalse($code->isUsed()); + + EntityManager::clear(); + + $unused = $repo->getUnusedByUser($this->user); + $this->assertNotEmpty($unused); + + $reload = EntityManager::find(UserRecoveryCode::class, $id); + $reload->markUsed(); + EntityManager::flush(); + $this->assertTrue($reload->isUsed()); + + $deleted = $repo->deleteAllForUser($this->user); + $this->assertEquals(1, $deleted); + } + + // ------------------------------------------------------------------------- + // Targeted behaviour tests + // ------------------------------------------------------------------------- + + public function testExpiredTrustedDeviceIsExcluded(): void + { + $repo = App::make(IUserTrustedDeviceRepository::class); + $now = new \DateTime('now', new \DateTimeZone('UTC')); + $expired = (clone $now)->modify('-1 minute'); + $deviceId = hash('sha256', 'expired-device-' . uniqid()); + + $device = $this->buildDevice($deviceId, $now, $expired); + EntityManager::persist($device); + EntityManager::flush(); + $id = $device->getId(); + EntityManager::clear(); + + $this->assertNull( + $repo->getActiveByUserAndIdentifier($this->user, $deviceId), + 'getActiveByUserAndIdentifier must return null for an expired device.' + ); + + $ids = array_map( + fn(UserTrustedDevice $d) => $d->getDeviceIdentifier(), + $repo->getActiveByUser($this->user) + ); + $this->assertNotContains($deviceId, $ids, 'getActiveByUser must not include expired devices.'); + + $stale = EntityManager::find(UserTrustedDevice::class, $id); + if ($stale) { EntityManager::remove($stale); EntityManager::flush(); } + } + + public function testRevokedTrustedDeviceIsExcluded(): void + { + $repo = App::make(IUserTrustedDeviceRepository::class); + $now = new \DateTime('now', new \DateTimeZone('UTC')); + $expires = (clone $now)->modify('+30 days'); + $deviceId = hash('sha256', 'revoked-device-' . uniqid()); + + $device = $this->buildDevice($deviceId, $now, $expires); + $device->setIsRevoked(true); + EntityManager::persist($device); + EntityManager::flush(); + $id = $device->getId(); + EntityManager::clear(); + + $this->assertNull( + $repo->getActiveByUserAndIdentifier($this->user, $deviceId), + 'getActiveByUserAndIdentifier must return null for a revoked device.' + ); + + $ids = array_map( + fn(UserTrustedDevice $d) => $d->getDeviceIdentifier(), + $repo->getActiveByUser($this->user) + ); + $this->assertNotContains($deviceId, $ids, 'getActiveByUser must not include revoked devices.'); + + $stale = EntityManager::find(UserTrustedDevice::class, $id); + if ($stale) { EntityManager::remove($stale); EntityManager::flush(); } + } + + public function testDuplicateDeviceIdentifierCannotOccur(): void + { + $connection = EntityManager::getConnection(); + $indexes = $connection->createSchemaManager()->listTableIndexes('user_trusted_devices'); + + $hasUnique = false; + foreach ($indexes as $index) { + if ($index->isUnique()) { + $cols = $index->getColumns(); + if (in_array('user_id', $cols) && in_array('device_identifier', $cols)) { + $hasUnique = true; + break; + } + } + } + + $this->assertTrue( + $hasUnique, + 'user_trusted_devices must have a UNIQUE index on (user_id, device_identifier).' + ); + } + + public function testRecoveryCodeDeletionRemovesUsedAndUnusedCodes(): void + { + $repo = App::make(IUserRecoveryCodeRepository::class); + + $unused = new UserRecoveryCode(); + self::setProp($unused, 'user', $this->user); + self::setProp($unused, 'code_hash', password_hash('UNUSED_' . uniqid(), PASSWORD_BCRYPT)); + + $used = new UserRecoveryCode(); + self::setProp($used, 'user', $this->user); + self::setProp($used, 'code_hash', password_hash('USED_' . uniqid(), PASSWORD_BCRYPT)); + $used->markUsed(); + + EntityManager::persist($unused); + EntityManager::persist($used); + EntityManager::flush(); + $unusedId = $unused->getId(); + $usedId = $used->getId(); + + $deleted = $repo->deleteAllForUser($this->user); + $this->assertGreaterThanOrEqual(2, $deleted, 'deleteAllForUser must remove both used and unused codes.'); + + EntityManager::clear(); + $this->assertNull( + EntityManager::find(UserRecoveryCode::class, $unusedId), + 'Unused recovery code must be deleted.' + ); + $this->assertNull( + EntityManager::find(UserRecoveryCode::class, $usedId), + 'Used recovery code must also be deleted.' + ); + } + + public function testAuditLogsReturnedMostRecentFirst(): void + { + $repo = App::make(ITwoFactorAuditLogRepository::class); + $createdIds = []; + + $timestamps = [ + new \DateTime('2020-01-01 01:00:00', new \DateTimeZone('UTC')), + new \DateTime('2020-01-01 02:00:00', new \DateTimeZone('UTC')), + new \DateTime('2020-01-01 03:00:00', new \DateTimeZone('UTC')), + ]; + + $setCreatedAt = static function (TwoFactorAuditLog $log, \DateTime $dt): void { + $prop = new \ReflectionProperty(TwoFactorAuditLog::class, 'created_at'); + $prop->setAccessible(true); + $prop->setValue($log, $dt); + }; + + foreach ($timestamps as $ts) { + $entry = new TwoFactorAuditLog(); + $entry->setUser($this->user); + $entry->setEventType(TwoFactorAuditLog::EventChallengeIssued); + $entry->setMethod(TwoFactorAuditLog::MethodEmailOtp); + $entry->setIpAddress('127.0.0.1'); + $entry->setUserAgent('Mozilla/5.0 (test)'); + $setCreatedAt($entry, $ts); + EntityManager::persist($entry); + EntityManager::flush(); + $createdIds[] = $entry->getId(); + } + + EntityManager::clear(); + + $all = $repo->getRecentByUser($this->user, 200); + $ours = array_values(array_filter($all, fn(TwoFactorAuditLog $e) => in_array($e->getId(), $createdIds))); + + $this->assertCount(3, $ours, 'All three seeded audit entries must be returned.'); + + for ($i = 0; $i < count($ours) - 1; $i++) { + $this->assertGreaterThanOrEqual( + $ours[$i + 1]->getCreatedAt()->getTimestamp(), + $ours[$i]->getCreatedAt()->getTimestamp(), + 'Audit logs must be ordered most-recent first.' + ); + } + + // cleanup + foreach ($createdIds as $logId) { + $log = EntityManager::find(TwoFactorAuditLog::class, $logId); + if ($log) { EntityManager::remove($log); } + } + EntityManager::flush(); + } + + public function testGetUnusedByUserExcludesUsedCodes(): void + { + $repo = App::make(IUserRecoveryCodeRepository::class); + + $unused = new UserRecoveryCode(); + self::setProp($unused, 'user', $this->user); + self::setProp($unused, 'code_hash', password_hash('UNUSED_' . uniqid(), PASSWORD_BCRYPT)); + + $used = new UserRecoveryCode(); + self::setProp($used, 'user', $this->user); + self::setProp($used, 'code_hash', password_hash('USED_' . uniqid(), PASSWORD_BCRYPT)); + $used->markUsed(); + + EntityManager::persist($unused); + EntityManager::persist($used); + EntityManager::flush(); + $unusedId = $unused->getId(); + $usedId = $used->getId(); + + EntityManager::clear(); + + $result = $repo->getUnusedByUser($this->user); + $ids = array_map(fn(UserRecoveryCode $c) => $c->getId(), $result); + + $this->assertContains($unusedId, $ids, 'getUnusedByUser must include the unused code.'); + $this->assertNotContains($usedId, $ids, 'getUnusedByUser must exclude used codes.'); + + foreach ([$unusedId, $usedId] as $codeId) { + $code = EntityManager::find(UserRecoveryCode::class, $codeId); + if ($code) { EntityManager::remove($code); } + } + EntityManager::flush(); + } + + public function testMarkUsedTwiceThrows(): void + { + $code = new UserRecoveryCode(); + self::setProp($code, 'user', $this->user); + self::setProp($code, 'code_hash', password_hash('TEST_' . uniqid(), PASSWORD_BCRYPT)); + $code->markUsed(); + + $this->expectException(ValidationException::class); + $code->markUsed(); + } + + public function testSetCodeHashRejectsPlaintext(): void + { + $code = new UserRecoveryCode(); + $this->expectException(\InvalidArgumentException::class); + $code->setCodeHash('plaintext-not-a-hash'); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static function setProp(object $obj, string $prop, mixed $value): void + { + $p = new \ReflectionProperty($obj, $prop); + $p->setAccessible(true); + $p->setValue($obj, $value); + } + + private function buildDevice(string $deviceId, \DateTime $now, \DateTime $expires): UserTrustedDevice + { + $device = new UserTrustedDevice(); + $device->setUser($this->user); + $device->setDeviceIdentifier($deviceId); + $device->setDeviceName('Test Browser'); + $device->setIpAddress('127.0.0.1'); + $device->setUserAgent('Mozilla/5.0 (test)'); + $device->setTrustedAt($now); + $device->setExpiresAt($expires); + $device->setLastSeenAt($now); + return $device; + } +} diff --git a/tests/e2e/fixtures/index.ts b/tests/e2e/fixtures/index.ts new file mode 100644 index 00000000..f6d80a7c --- /dev/null +++ b/tests/e2e/fixtures/index.ts @@ -0,0 +1,87 @@ +import { test as base } from '@playwright/test'; +import { LoginPage } from '../pages/LoginPage'; +import { RegisterPage } from '../pages/RegisterPage'; + +type E2EFixtures = { + loginPage: LoginPage; + registerPage: RegisterPage; + authenticatedPage: LoginPage; +}; + +export const test = base.extend({ + page: async ({ page }, use) => { + // When running inside the Docker playwright container, APP_URL=http://nginx is + // injected by docker-compose. The PHP app bakes http://localhost:8001 into the + // page HTML (asset src attributes and window.*_ENDPOINT globals). Two problems: + // 1. Assets at http://localhost:8001/assets/** → unreachable from the container. + // 2. XHR to http://localhost:8001/** is cross-origin from http://nginx, so the + // browser omits session cookies → server returns 419 CSRF error. + // + // Fix A: route.continue rewrite for assets (scripts, images, CSS). + // Fix B: addInitScript intercepts window.*_ENDPOINT assignments before they are + // read by React, rewriting them to http://nginx so XHR is same-origin. + // + // From the host (APP_URL unset or http://localhost:*), no interception is needed. + const internalUrl = process.env.APP_URL; + if (internalUrl && !internalUrl.includes('localhost')) { + // Fix A: rewrite asset URLs. + await page.route(/^http:\/\/localhost(:\d+)?\//, (route) => { + const rewritten = route.request().url() + .replace(/^http:\/\/localhost(:\d+)?/, internalUrl); + route.continue({ url: rewritten }); + }); + + // Fix B: intercept window.*_ENDPOINT property assignments so every XHR + // made by React targets http://nginx (same origin), ensuring the session + // cookie is automatically included and CSRF validation succeeds. + const endpoints = [ + 'VERIFY_ACCOUNT_ENDPOINT', + 'EMIT_OTP_ENDPOINT', + 'RESEND_VERIFICATION_EMAIL_ENDPOINT', + 'VERIFY_2FA_ENDPOINT', + 'RESEND_2FA_ENDPOINT', + 'CANCEL_LOGIN_ENDPOINT', + 'RECOVERY_2FA_ENDPOINT', + 'FORM_ACTION_ENDPOINT', + ]; + await page.addInitScript(({ endpoints, internalUrl }) => { + for (const key of endpoints) { + let _val; + Object.defineProperty(window, key, { + configurable: true, + enumerable: true, + set(v) { + _val = typeof v === 'string' + ? v.replace(/http:\/\/localhost(:\d+)?/, internalUrl) + : v; + }, + get() { return _val; }, + }); + } + }, { endpoints, internalUrl }); + } + await use(page); + }, + + loginPage: async ({ page }, use) => { + await use(new LoginPage(page)); + }, + + registerPage: async ({ page }, use) => { + await use(new RegisterPage(page)); + }, + + // Pre-authenticated session using the raw E2E user (no group memberships, + // so MFA is never enforced and the login completes without a 2FA challenge). + // Override via TEST_USER_EMAIL / TEST_USER_PASSWORD env vars if needed. + authenticatedPage: async ({ page }, use) => { + const loginPage = new LoginPage(page); + await loginPage.login( + process.env.TEST_USER_EMAIL || 'e2e@test.com', + process.env.TEST_USER_PASSWORD || '1Qaz2wsx!' + ); + await use(loginPage); + }, +}); + +export { expect } from '@playwright/test'; diff --git a/tests/e2e/pages/LoginPage.ts b/tests/e2e/pages/LoginPage.ts new file mode 100644 index 00000000..d05b4be0 --- /dev/null +++ b/tests/e2e/pages/LoginPage.ts @@ -0,0 +1,67 @@ +import { type Page, type Locator } from '@playwright/test'; + +export class LoginPage { + readonly page: Page; + readonly emailInput: Locator; + readonly passwordInput: Locator; + // Email step: button has title="Continue" (text is ">") + readonly emailSubmitButton: Locator; + // Password step: button text is "Continue", type="button" (no title) + readonly passwordSubmitButton: Locator; + readonly rememberMeCheckbox: Locator; + readonly errorLabel: Locator; + readonly otpInput: Locator; + // Password step container + readonly passwordForm: Locator; + // Two-factor (MFA) step + readonly twoFactorForm: Locator; + readonly verifyButton: Locator; + readonly resendLink: Locator; + readonly cancelLink: Locator; + readonly useRecoveryLink: Locator; + // Recovery code step + readonly recoveryForm: Locator; + + constructor(page: Page) { + this.page = page; + this.emailInput = page.locator('#email'); + this.passwordInput = page.locator('#password'); + this.emailSubmitButton = page.locator('button[title="Continue"]'); + this.passwordSubmitButton = page.getByRole('button', { name: 'Continue' }); + this.rememberMeCheckbox = page.locator('#remember'); + this.errorLabel = page.locator('[data-testid="error-label"]'); + this.otpInput = page.locator('[data-testid="otp_code"]'); + this.passwordForm = page.locator('[data-testid="password-form"]'); + this.twoFactorForm = page.locator('[data-testid="two-factor-form"]'); + this.verifyButton = page.locator('[data-testid="verify-button"]'); + this.resendLink = page.locator('[data-testid="resend-link"]'); + this.cancelLink = page.locator('[data-testid="cancel-link"]'); + this.useRecoveryLink = page.locator('[data-testid="use-recovery-link"]'); + this.recoveryForm = page.locator('[data-testid="recovery-form"]'); + } + + async goto() { + await this.page.goto('/auth/login'); + } + + async fillEmail(email: string) { + await this.emailInput.fill(email); + await this.emailSubmitButton.click(); + } + + async fillPassword(password: string) { + await this.passwordInput.fill(password); + await this.passwordSubmitButton.click(); + } + + async login(email: string, password: string) { + await this.goto(); + await this.fillEmail(email); + await this.fillPassword(password); + } + + async fillOtp(code: string) { + await this.otpInput.fill(code); + await this.passwordSubmitButton.click(); + } +} diff --git a/tests/e2e/pages/RegisterPage.ts b/tests/e2e/pages/RegisterPage.ts new file mode 100644 index 00000000..1c03f16e --- /dev/null +++ b/tests/e2e/pages/RegisterPage.ts @@ -0,0 +1,57 @@ +import { type Page, type Locator } from '@playwright/test'; + +export class RegisterPage { + readonly page: Page; + readonly firstNameInput: Locator; + readonly lastNameInput: Locator; + readonly emailInput: Locator; + readonly passwordInput: Locator; + readonly passwordConfirmInput: Locator; + readonly codeOfConductCheckbox: Locator; + readonly submitButton: Locator; + // MUI FormHelperText error messages (not CSS-module classes, so not hashed) + readonly errorContainer: Locator; + // SweetAlert2 popup shown for server-side errors (e.g. duplicate email) + readonly swalPopup: Locator; + + constructor(page: Page) { + this.page = page; + this.firstNameInput = page.locator('[name="first_name"]'); + this.lastNameInput = page.locator('[name="last_name"]'); + this.emailInput = page.locator('[name="email"]'); + this.passwordInput = page.locator('[name="password"]'); + this.passwordConfirmInput = page.locator('[name="password_confirmation"]'); + this.codeOfConductCheckbox = page.locator('[name="agree_code_of_conduct"]'); + this.submitButton = page.locator('button[type="submit"]'); + this.errorContainer = page.locator('p.MuiFormHelperText-root.Mui-error').first(); + this.swalPopup = page.locator('.swal2-popup'); + } + + async goto() { + await this.page.goto('/auth/register'); + } + + // MUI Select does not render a native . +const getPasswordInput = () => screen.getByTestId('recovery-codes-current-password').querySelector('input'); + +describe('RecoveryCodesPanel', () => { + beforeEach(() => { + window.sessionStorage.clear(); + jest.clearAllMocks(); + }); + + it('shows the remaining/total count', () => { + render( + + ); + expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 7 of 10 remaining'); + }); + + it('shows a dismissable low-code warning when remaining is below the threshold', () => { + render( + + ); + expect(screen.getByTestId('low-code-warning')).toBeInTheDocument(); + + fireEvent.click(screen.getByLabelText('dismiss')); + expect(screen.queryByTestId('low-code-warning')).not.toBeInTheDocument(); + }); + + it('does not show the low-code warning when there are enough codes', () => { + render( + + ); + expect(screen.queryByTestId('low-code-warning')).not.toBeInTheDocument(); + }); + + it('respects a custom lowCodeThreshold instead of the default of 3', () => { + // 4 remaining would NOT trigger the default threshold (3), but does with a custom threshold of 5. + render( + + ); + expect(screen.getByTestId('low-code-warning')).toBeInTheDocument(); + }); + + it('respects a custom lower threshold (2 remaining is not below a threshold of 2)', () => { + // With the default threshold (3) this would show the warning; a custom + // threshold of 2 must be honored instead of the hardcoded default. + render( + + ); + expect(screen.queryByTestId('low-code-warning')).not.toBeInTheDocument(); + }); + + it('opens the modal immediately when initialCodes is provided', () => { + const codes = ['AAAA-1111', 'BBBB-2222']; + render( + + ); + expect(screen.getByText('AAAA-1111')).toBeInTheDocument(); + }); + + it('regenerates codes after confirming the current password and opens the modal', async () => { + const newCodes = ['NEW1-CODE', 'NEW2-CODE']; + regenerateRecoveryCodes.mockResolvedValue({response: {recovery_codes: newCodes}}); + + render( + + ); + + fireEvent.click(screen.getByText('Regenerate Codes')); + fireEvent.change(getPasswordInput(), {target: {value: 'my-password'}}); + fireEvent.click(screen.getByTestId('confirm-regenerate-button')); + + expect(regenerateRecoveryCodes).toHaveBeenCalledWith('my-password'); + expect(await screen.findByText('NEW1-CODE')).toBeInTheDocument(); + expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 2 of 2 remaining'); + }); + + it('shows an error and keeps the existing count when the password is wrong', async () => { + regenerateRecoveryCodes.mockRejectedValue({ + status: 412, + response: {body: {errors: ['current_password is not correct.']}}, + }); + + render( + + ); + + fireEvent.click(screen.getByText('Regenerate Codes')); + fireEvent.change(getPasswordInput(), {target: {value: 'wrong'}}); + fireEvent.click(screen.getByTestId('confirm-regenerate-button')); + + expect(regenerateRecoveryCodes).toHaveBeenCalledWith('wrong'); + await new Promise((resolve) => setImmediate(resolve)); + expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 7 of 10 remaining'); + }); +}); diff --git a/tests/js/components/two_factor_section.test.js b/tests/js/components/two_factor_section.test.js new file mode 100644 index 00000000..e5e9dfef --- /dev/null +++ b/tests/js/components/two_factor_section.test.js @@ -0,0 +1,48 @@ +import React from 'react'; +import {render, screen, fireEvent} from '@testing-library/react'; +import TwoFactorSection from '../../../resources/js/components/two_factor_section'; +import {enableTwoFactor} from '../../../resources/js/profile/actions'; + +jest.mock('../../../resources/js/profile/actions'); +jest.mock('sweetalert2', () => jest.fn()); + +describe('TwoFactorSection', () => { + beforeEach(() => { + window.sessionStorage.clear(); + jest.clearAllMocks(); + }); + + it('shows the enable button when 2FA is not enabled', () => { + render( + + ); + expect(screen.getByTestId('enable-two-factor-button')).toBeInTheDocument(); + expect(screen.queryByTestId('recovery-codes-count')).not.toBeInTheDocument(); + }); + + it('shows the recovery codes panel when 2FA is already enabled', () => { + render( + + ); + expect(screen.queryByTestId('enable-two-factor-button')).not.toBeInTheDocument(); + expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 7 of 10 remaining'); + }); + + it('enables 2FA and shows the enrollment codes in the modal', async () => { + const codes = ['AAAA-1111', 'BBBB-2222']; + enableTwoFactor.mockResolvedValue({response: {recovery_codes: codes}}); + + render( + + ); + + fireEvent.click(screen.getByTestId('enable-two-factor-button')); + + expect(enableTwoFactor).toHaveBeenCalledWith('email_otp'); + expect(await screen.findByText('AAAA-1111')).toBeInTheDocument(); + expect(screen.getByTestId('recovery-codes-count')).toHaveTextContent('Recovery Codes: 2 of 2 remaining'); + }); +}); diff --git a/tests/js/login/components/two-factor-form.test.js b/tests/js/login/components/two-factor-form.test.js new file mode 100644 index 00000000..d7278eec --- /dev/null +++ b/tests/js/login/components/two-factor-form.test.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import TwoFactorForm from '../../../../resources/js/login/components/two_factor_form'; + +// Suppress the 1-second interval so tests don't bleed real timers into each other. +beforeEach(() => jest.useFakeTimers()); +afterEach(() => jest.useRealTimers()); + +const baseProps = { + otpCode: '123456', + otpError: '', + otpLength: 6, + otpLifetime: 300, + codeVersion: 0, + disableInput: false, + trustDevice: false, + onCodeChange: jest.fn(), + onVerify: jest.fn(), + onTrustDeviceChange: jest.fn(), + onResend: jest.fn(), + onUseRecovery: jest.fn(), + onCancel: jest.fn(), +}; + +describe('TwoFactorForm', () => { + + it('renders countdown when otpLifetime > 0', () => { + render(); + // formatTime(300) → "5 minutes"; the paragraph reads "Code expires in 5 minutes." + expect(screen.getByText(/Code expires in 5 minutes\./)).toBeInTheDocument(); + expect(screen.queryByText(/has expired/)).not.toBeInTheDocument(); + }); + + it('renders expired state when otpLifetime is 0', () => { + render(); + expect( + screen.getByText(/Your verification code has expired\. Please request a new one\./) + ).toBeInTheDocument(); + expect(screen.queryByText(/Code expires in/)).not.toBeInTheDocument(); + }); + + it('VERIFY button is disabled when otpCode is empty', () => { + render(); + // MUI Button spreads unknown props to its root