Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
700c01f
feat: Add MultiFactor Authentication
matiasperrone-exo Apr 16, 2026
e85d9d0
chore: Add PR's requested changes and additional AI comments
matiasperrone-exo Apr 24, 2026
6247b83
chore: Add PR's requested changes
matiasperrone-exo Apr 29, 2026
f48e246
chore: Add guards on setEventType and setMethod methods on TwoFactorA…
matiasperrone-exo Apr 29, 2026
717dcbd
chore: Guard the unique-index migration against existing duplicates.
matiasperrone-exo Apr 29, 2026
e1a6801
chore: Add UniqueConstraint annotation to mirror the database migration
matiasperrone-exo Apr 29, 2026
3501297
chore: Refactor UserRecoveryCode and TwoFactorAuditLog models; update…
matiasperrone-exo May 14, 2026
30b1fb2
chore: remove BaseEntity oveloaded props and methods. fix skipped test
matiasperrone-exo May 18, 2026
ed53348
chore: unify migrations
matiasperrone-exo May 21, 2026
2accedf
feat: Add MultiFactor Authentication
matiasperrone-exo Apr 30, 2026
a7efc09
feat: Add AuthService validateCredentials method
matiasperrone-exo Apr 29, 2026
09fde46
chore: lint file app/libs/Auth/AuthService.php
matiasperrone-exo Apr 29, 2026
2106d82
chore: Add PR's requested changes
matiasperrone-exo May 5, 2026
0937416
chore: Add PR's requested changes
matiasperrone-exo May 7, 2026
c72c209
chore: Fix issues created on rebase
matiasperrone-exo May 21, 2026
5ad6e11
feat: Implement Multi-Factor Authentication challenge strategies and …
matiasperrone-exo May 4, 2026
43c31bb
chore: Add PR's requested changes
matiasperrone-exo May 19, 2026
39a7e4a
feat: Add Device Trust Service
matiasperrone-exo May 21, 2026
ba18c4b
feat: Two-Factor Audit Service
matiasperrone-exo May 26, 2026
50f4ad0
feat: MFAGateService (Two-Factor Gate Decision Service)
matiasperrone-exo May 28, 2026
f493144
feat: UserController MFA Integration, Device Trust Cookie Management,…
matiasperrone-exo Jun 3, 2026
10eb2e0
chore: Add PR's requested changed
matiasperrone-exo Jun 5, 2026
5fc47a4
chore: Add PR's requested changes
matiasperrone-exo Jun 8, 2026
e0b8519
feat: Add Login UI MFA Flow
matiasperrone-exo Jun 24, 2026
f7b8a91
feat: first tests
romanetar Jun 26, 2026
7acd03d
feat: add testing infrastructure for login MFA flow and E2E suite
romanetar Jun 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions .github/workflows/pull_request_frontend_tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
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: ''
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: |
cp .env.example .env
./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!'
- 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
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,11 @@ model.sql
/.phpunit.cache/
docker-compose/mysql/model/*.sql
public/assets/*.map
public/assets/css/*.map
public/assets/css/*.map
# Playwright
/tests/e2e/report/

# Jest
/tests/js/coverage/
/playwright-report/
/.playwright-out/
57 changes: 57 additions & 0 deletions app/Console/Commands/CreateRawUser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php namespace App\Console\Commands;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use Auth\User;
use Illuminate\Console\Command;
use LaravelDoctrine\ORM\Facades\EntityManager;

/**
* Class CreateRawUser
*
* Creates a plain, verified user with no group memberships.
* Because this user belongs to none of the groups listed in
* two_factor.enforced_groups, MFA is never required — making it
* safe to use in automated E2E tests that must complete the full
* login → redirect flow without a 2FA challenge.
*
* @package App\Console\Commands
*/
class CreateRawUser extends Command
{
protected $signature = 'idp:create-raw-user {email} {password}';

protected $description = 'Create a plain verified user with no group memberships (useful for E2E tests)';

public function handle()
{
$email = trim($this->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}");
}
}
}
1 change: 1 addition & 0 deletions app/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class Kernel extends ConsoleKernel
Commands\CleanOAuth2StaleData::class,
Commands\CleanOpenIdStaleData::class,
Commands\CreateSuperAdmin::class,
Commands\CreateRawUser::class,
Commands\SpammerProcess\RebuildUserSpammerEstimator::class,
Commands\SpammerProcess\UserSpammerProcessor::class,
];
Expand Down
17 changes: 10 additions & 7 deletions app/Http/Controllers/Auth/RegisterController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
88 changes: 88 additions & 0 deletions app/Http/Controllers/Traits/MFACookieManager.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php namespace App\Http\Controllers\Traits;
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/

use Auth\User;
use Exception;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Cookie;
use Illuminate\Support\Facades\Request;
use Keepsuit\LaravelOpenTelemetry\Facades\Logger;
use Utils\IPHelper;

/**
* Trait MFACookieManager
*
* Reads and queues the trusted-device cookie. All trusted-device persistence
* and validation lives in IDeviceTrustService — this trait contains NO Doctrine,
* repository, or device-lookup logic; it only moves the raw token in and out of
* the HTTP cookie.
*
* The consuming controller MUST expose an IDeviceTrustService instance as
* $this->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

);
}
}
Loading
Loading