From 235d5f646c2b144165e7841a9dc94f7be2718447 Mon Sep 17 00:00:00 2001 From: Flycro Date: Fri, 24 Jul 2026 12:59:10 +0200 Subject: [PATCH] feat: add two-factor authentication support and related configurations --- .env.example | 2 + README.md | 13 + app/Http/Controllers/Auth/LoginController.php | 27 +- .../Controllers/Auth/SocialiteController.php | 33 +- .../Auth/TwoFactorChallengeController.php | 72 + .../Auth/TwoFactorSettingsController.php | 168 ++ app/Http/Controllers/ProfileController.php | 40 + ...EnsureTwoFactorAuthenticationIsEnabled.php | 43 + app/Http/Middleware/HandleInertiaRequests.php | 1 + app/Models/User.php | 6 +- app/Providers/AppServiceProvider.php | 5 +- app/Services/Auth/TwoFactorChallenge.php | 33 + bootstrap/app.php | 2 + composer.json | 1 + composer.lock | 1650 +++++++++++++---- config/auth-ui.php | 2 + config/fortify.php | 17 + ..._add_two_factor_columns_to_users_table.php | 34 + database/seeders/DatabaseSeeder.php | 4 +- .../js/Pages/Auth/TwoFactorChallenge.vue | 96 + resources/js/Pages/Profile/Show.vue | 332 ++++ resources/js/layouts/DashboardLayout.vue | 60 +- resources/js/types/auth/config.ts | 2 + resources/js/types/inertia/flash.ts | 1 + routes/api.php | 3 +- routes/auth.php | 24 + .../Auth/TwoFactorAuthenticationTest.php | 311 ++++ 27 files changed, 2608 insertions(+), 374 deletions(-) create mode 100644 app/Http/Controllers/Auth/TwoFactorChallengeController.php create mode 100644 app/Http/Controllers/Auth/TwoFactorSettingsController.php create mode 100644 app/Http/Controllers/ProfileController.php create mode 100644 app/Http/Middleware/EnsureTwoFactorAuthenticationIsEnabled.php create mode 100644 app/Services/Auth/TwoFactorChallenge.php create mode 100644 config/fortify.php create mode 100644 database/migrations/2026_07_24_000000_add_two_factor_columns_to_users_table.php create mode 100644 resources/js/Pages/Auth/TwoFactorChallenge.vue create mode 100644 resources/js/Pages/Profile/Show.vue create mode 100644 tests/Feature/Auth/TwoFactorAuthenticationTest.php diff --git a/.env.example b/.env.example index 8bd56be..8f84714 100644 --- a/.env.example +++ b/.env.example @@ -67,6 +67,8 @@ AUTH_ENABLE_REGISTRATION=true AUTH_ENABLE_PASSWORD_RESET=true AUTH_ENABLE_REMEMBER_ME=true AUTH_ENABLE_EMAIL_VERIFICATION=false +AUTH_ENABLE_TWO_FACTOR=false +AUTH_REQUIRE_TWO_FACTOR=false # Auth Redirects # AUTH_REDIRECT_LOGIN=/dashboard diff --git a/README.md b/README.md index 873e0f5..c32826d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A starter template built with Laravel 13, Inertia.js v3, Vue 3, Nuxt UI, and Tai - **Authentication** — Login (email or username), register, forgot/reset password - **Social Login** — OAuth via Laravel Socialite with provider tracking (`social_accounts` table) - **Email Verification** — Optional, toggle via `AUTH_ENABLE_EMAIL_VERIFICATION` +- **Two-Factor Authentication** — Optional TOTP and recovery-code protection, toggle via `AUTH_ENABLE_TWO_FACTOR` - **Dashboard** — Protected page using Nuxt UI dashboard components with `auth` + `verified` middleware - **Security** — Nullable passwords for social-only users, verified-email requirement for provider linking, rate limiting on all auth endpoints - **Code Quality** — ESLint, Laravel Pint, shared Valibot validation schemas @@ -73,6 +74,8 @@ AUTH_ENABLE_REGISTRATION=true AUTH_ENABLE_PASSWORD_RESET=true AUTH_ENABLE_REMEMBER_ME=true AUTH_ENABLE_EMAIL_VERIFICATION=false +AUTH_ENABLE_TWO_FACTOR=false +AUTH_REQUIRE_TWO_FACTOR=false AUTH_REDIRECT_LOGIN=/dashboard AUTH_REDIRECT_LOGOUT=/ @@ -85,6 +88,16 @@ When `AUTH_ENABLE_EMAIL_VERIFICATION=true`: - Social login users are auto-verified (trusted from provider) - Social accounts can only be linked to users with verified emails +When `AUTH_ENABLE_TWO_FACTOR=true`, authenticated users can enroll an authenticator +and manage recovery codes from `/profile`. Existing password and social logins are +both held at the two-factor challenge until a valid TOTP or one-time recovery code is +provided. + +Set both `AUTH_ENABLE_TWO_FACTOR=true` and `AUTH_REQUIRE_TWO_FACTOR=true` to +require every authenticated user to complete enrollment before accessing +protected web pages or authenticated API endpoints. Enrollment, email +verification, and logout routes remain available while setup is pending. + See `config/auth-ui.php` for all available options including page titles, icons, legal links, and social provider configuration. ## Social Login Setup diff --git a/app/Http/Controllers/Auth/LoginController.php b/app/Http/Controllers/Auth/LoginController.php index 0f2aebb..c589c87 100644 --- a/app/Http/Controllers/Auth/LoginController.php +++ b/app/Http/Controllers/Auth/LoginController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; use App\Http\Requests\Auth\LoginRequest; use App\Models\User; +use App\Services\Auth\TwoFactorChallenge; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -25,26 +26,42 @@ class LoginController extends Controller /** * Handle an incoming authentication request. */ - public function store(LoginRequest $request): RedirectResponse + public function store(LoginRequest $request, TwoFactorChallenge $twoFactor): RedirectResponse { $login = $request->validated('login'); $password = $request->validated('password'); $isEmail = filter_var($login, FILTER_VALIDATE_EMAIL); - $credentials = $isEmail - ? ['email' => $login, 'password' => $password] - : ['email' => User::whereRaw('LOWER(username) = ?', [strtolower($login)])->value('email'), 'password' => $password]; + $email = $isEmail + ? $login + : User::whereRaw('LOWER(username) = ?', [strtolower($login)])->value('email'); + $credentials = ['email' => $email, 'password' => $password]; $remember = config('auth-ui.features.remember_me') ? $request->boolean('remember') : false; - if (! $credentials['email'] || ! Auth::attempt($credentials, $remember)) { + $provider = Auth::getProvider(); + $user = $credentials['email'] + ? $provider->retrieveByCredentials($credentials) + : null; + + if (! $user || ! $provider->validateCredentials($user, $credentials)) { throw ValidationException::withMessages([ 'login' => __('auth.failed'), ]); } + if (config('hashing.rehash_on_login', true) + && method_exists($provider, 'rehashPasswordIfRequired')) { + $provider->rehashPasswordIfRequired($user, $credentials); + } + + if ($twoFactor->requiredFor($user)) { + return $twoFactor->begin($request, $user, $remember); + } + + Auth::login($user, $remember); $request->session()->regenerate(); return redirect()->intended(config('auth-ui.redirects.login', '/')); diff --git a/app/Http/Controllers/Auth/SocialiteController.php b/app/Http/Controllers/Auth/SocialiteController.php index 673c2c2..12344dc 100644 --- a/app/Http/Controllers/Auth/SocialiteController.php +++ b/app/Http/Controllers/Auth/SocialiteController.php @@ -5,7 +5,9 @@ namespace App\Http\Controllers\Auth; use App\Http\Controllers\Controller; use App\Models\SocialAccount; use App\Models\User; +use App\Services\Auth\TwoFactorChallenge; use Illuminate\Http\RedirectResponse; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Str; use Laravel\Socialite\Facades\Socialite; @@ -49,8 +51,11 @@ class SocialiteController extends Controller /** * Obtain the user information from provider. */ - public function callback(string $provider): RedirectResponse - { + public function callback( + Request $request, + string $provider, + TwoFactorChallenge $twoFactor + ): RedirectResponse { if (! in_array($provider, $this->getEnabledProviders())) { abort(404, 'Provider not enabled'); } @@ -69,10 +74,7 @@ class SocialiteController extends Controller ->first(); if ($socialAccount) { - Auth::login($socialAccount->user, remember: true); - request()->session()->regenerate(); - - return redirect()->intended(config('auth-ui.redirects.login', '/')); + return $this->finishAuthentication($request, $socialAccount->user, $twoFactor); } // Check if a user with this email already exists @@ -90,10 +92,7 @@ class SocialiteController extends Controller 'provider_id' => $socialUser->getId(), ]); - Auth::login($existingUser, remember: true); - request()->session()->regenerate(); - - return redirect()->intended(config('auth-ui.redirects.login', '/')); + return $this->finishAuthentication($request, $existingUser, $twoFactor); } // New user — check if registration is enabled @@ -135,8 +134,20 @@ class SocialiteController extends Controller 'provider_id' => $socialUser->getId(), ]); + return $this->finishAuthentication($request, $user, $twoFactor); + } + + private function finishAuthentication( + Request $request, + User $user, + TwoFactorChallenge $twoFactor + ): RedirectResponse { + if ($twoFactor->requiredFor($user)) { + return $twoFactor->begin($request, $user, remember: true); + } + Auth::login($user, remember: true); - request()->session()->regenerate(); + $request->session()->regenerate(); return redirect()->intended(config('auth-ui.redirects.login', '/')); } diff --git a/app/Http/Controllers/Auth/TwoFactorChallengeController.php b/app/Http/Controllers/Auth/TwoFactorChallengeController.php new file mode 100644 index 0000000..e8bb9b6 --- /dev/null +++ b/app/Http/Controllers/Auth/TwoFactorChallengeController.php @@ -0,0 +1,72 @@ +hasChallengedUser()) { + $this->clearChallenge($request); + + return redirect()->route('login'); + } + + return Inertia::render('Auth/TwoFactorChallenge'); + } + + /** + * Complete the pending login using a TOTP or recovery code. + */ + public function store(TwoFactorLoginRequest $request): RedirectResponse + { + if (! config('auth-ui.features.two_factor') || ! $request->hasChallengedUser()) { + $this->clearChallenge($request); + + return redirect()->route('login'); + } + + $user = $request->challengedUser(); + + if (! $user->hasEnabledTwoFactorAuthentication()) { + $this->clearChallenge($request); + + return redirect()->route('login'); + } + + if ($recoveryCode = $request->validRecoveryCode()) { + $user->replaceRecoveryCode($recoveryCode); + } elseif (! $request->hasValidCode()) { + event(new TwoFactorAuthenticationFailed($user)); + + throw ValidationException::withMessages([ + $request->filled('recovery_code') ? 'recovery_code' : 'code' => 'The provided authentication code was invalid.', + ]); + } + + event(new ValidTwoFactorAuthenticationCodeProvided($user)); + + Auth::login($user, $request->remember()); + $request->session()->regenerate(); + + return redirect()->intended(config('auth-ui.redirects.login', '/dashboard')); + } + + private function clearChallenge(TwoFactorLoginRequest $request): void + { + $request->session()->forget(['login.id', 'login.remember']); + } +} diff --git a/app/Http/Controllers/Auth/TwoFactorSettingsController.php b/app/Http/Controllers/Auth/TwoFactorSettingsController.php new file mode 100644 index 0000000..03504e5 --- /dev/null +++ b/app/Http/Controllers/Auth/TwoFactorSettingsController.php @@ -0,0 +1,168 @@ +ensureFeatureIsEnabled(); + + /** @var User $user */ + $user = $request->user(); + + if ($user->hasEnabledTwoFactorAuthentication()) { + return redirect()->route('profile.show'); + } + + $this->validatePasswordForPasswordUser($request, $user); + $enable($user, force: true); + + return redirect()->route('profile.show') + ->with('success', 'Scan the QR code, then enter a code to finish enabling two-factor authentication.'); + } + + /** + * Confirm a newly configured authenticator. + */ + public function confirm( + Request $request, + ConfirmTwoFactorAuthentication $confirm + ): RedirectResponse { + $this->ensureFeatureIsEnabled(); + + $validated = $request->validate([ + 'code' => ['required', 'string', 'regex:/^\d{6}$/'], + ]); + + /** @var User $user */ + $user = $request->user(); + $confirm($user, $validated['code']); + + return redirect()->route('profile.show') + ->with('success', 'Two-factor authentication is now enabled.') + ->with('recoveryCodes', $user->fresh()->recoveryCodes()); + } + + /** + * Disable two-factor authentication. + */ + public function disable( + Request $request, + DisableTwoFactorAuthentication $disable + ): RedirectResponse { + $this->ensureFeatureIsEnabled(); + + /** @var User $user */ + $user = $request->user(); + + if ($user->hasEnabledTwoFactorAuthentication()) { + $this->validateSensitiveAction($request, $user); + } + + $disable($user); + + return redirect()->route('profile.show') + ->with('success', 'Two-factor authentication has been disabled.'); + } + + /** + * Reveal or regenerate recovery codes after re-authentication. + */ + public function recoveryCodes( + Request $request, + GenerateNewRecoveryCodes $generate + ): RedirectResponse { + $this->ensureFeatureIsEnabled(); + + /** @var User $user */ + $user = $request->user(); + + if (! $user->hasEnabledTwoFactorAuthentication()) { + abort(409, 'Two-factor authentication is not enabled.'); + } + + $this->validateSensitiveAction($request, $user); + + if ($request->boolean('regenerate')) { + $generate($user); + $user->refresh(); + } + + return redirect()->route('profile.show') + ->with('success', $request->boolean('regenerate') + ? 'New recovery codes have been generated. Previous codes no longer work.' + : 'Recovery codes revealed.') + ->with('recoveryCodes', $user->recoveryCodes()); + } + + private function ensureFeatureIsEnabled(): void + { + abort_unless(config('auth-ui.features.two_factor'), 404); + } + + private function validatePasswordForPasswordUser(Request $request, User $user): void + { + if (! $user->hasPassword()) { + return; + } + + $request->validate([ + 'password' => ['required', 'string', 'current_password:web'], + ]); + } + + private function validateSensitiveAction(Request $request, User $user): void + { + if ($user->hasPassword()) { + $this->validatePasswordForPasswordUser($request, $user); + + return; + } + + $validated = $request->validate([ + 'code' => ['required', 'string'], + ]); + + if (! $this->hasValidTwoFactorCredential($user, $validated['code'])) { + throw ValidationException::withMessages([ + 'code' => 'The provided authentication or recovery code was invalid.', + ]); + } + } + + private function hasValidTwoFactorCredential(User $user, string $code): bool + { + $secret = Fortify::currentEncrypter()->decrypt($user->two_factor_secret); + + if (app(TwoFactorAuthenticationProvider::class)->verify($secret, $code)) { + return true; + } + + $recoveryCode = collect($user->recoveryCodes()) + ->first(fn (string $recoveryCode) => hash_equals($recoveryCode, $code)); + + if (! $recoveryCode) { + return false; + } + + $user->replaceRecoveryCode($recoveryCode); + + return true; + } +} diff --git a/app/Http/Controllers/ProfileController.php b/app/Http/Controllers/ProfileController.php new file mode 100644 index 0000000..4be2b85 --- /dev/null +++ b/app/Http/Controllers/ProfileController.php @@ -0,0 +1,40 @@ +user(); + $available = (bool) config('auth-ui.features.two_factor'); + $pending = $available + && filled($user->two_factor_secret) + && ! $user->hasEnabledTwoFactorAuthentication(); + + return Inertia::render('Profile/Show', [ + 'twoFactor' => [ + 'available' => $available, + 'enabled' => $available && $user->hasEnabledTwoFactorAuthentication(), + 'pending' => $pending, + 'requiresPassword' => $user->hasPassword(), + 'qrCodeDataUri' => $pending + ? 'data:image/svg+xml;base64,'.base64_encode($user->twoFactorQrCodeSvg()) + : null, + 'secretKey' => $pending + ? Fortify::currentEncrypter()->decrypt($user->two_factor_secret) + : null, + ], + ]); + } +} diff --git a/app/Http/Middleware/EnsureTwoFactorAuthenticationIsEnabled.php b/app/Http/Middleware/EnsureTwoFactorAuthenticationIsEnabled.php new file mode 100644 index 0000000..50b517a --- /dev/null +++ b/app/Http/Middleware/EnsureTwoFactorAuthenticationIsEnabled.php @@ -0,0 +1,43 @@ +user() + || $request->user()->hasEnabledTwoFactorAuthentication() + || $this->isEnrollmentRoute($request)) { + return $next($request); + } + + if ($request->expectsJson()) { + return response()->json([ + 'message' => 'Two-factor authentication setup is required.', + 'setup_url' => route('profile.show'), + ], 403); + } + + return redirect()->route('profile.show') + ->with('error', 'Two-factor authentication is required before you can continue.'); + } + + private function isEnrollmentRoute(Request $request): bool + { + return $request->routeIs([ + 'profile.*', + 'logout', + 'verification.*', + ]); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 4bc5e15..84f7219 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -45,6 +45,7 @@ class HandleInertiaRequests extends Middleware 'error' => fn () => $request->session()->get('error'), 'message' => fn () => $request->session()->get('message'), 'status' => fn () => $request->session()->get('status'), + 'recoveryCodes' => fn () => $request->session()->get('recoveryCodes'), ], 'authConfig' => fn () => $this->getAuthConfig(), ]; diff --git a/app/Models/User.php b/app/Models/User.php index 62b56ee..8368616 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -9,11 +9,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; +use Laravel\Fortify\TwoFactorAuthenticatable; class User extends Authenticatable implements MustVerifyEmail { /** @use HasFactory */ - use HasFactory, Notifiable; + use HasFactory, Notifiable, TwoFactorAuthenticatable; /** * The attributes that are mass assignable. @@ -36,6 +37,8 @@ class User extends Authenticatable implements MustVerifyEmail protected $hidden = [ 'password', 'remember_token', + 'two_factor_secret', + 'two_factor_recovery_codes', ]; /** @@ -57,6 +60,7 @@ class User extends Authenticatable implements MustVerifyEmail return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'two_factor_confirmed_at' => 'datetime', ]; } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 452e6b6..2ca7351 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use Illuminate\Support\ServiceProvider; +use Laravel\Fortify\Fortify; class AppServiceProvider extends ServiceProvider { @@ -11,7 +12,9 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + // This application owns its authentication routes and UI. We use + // Fortify's two-factor primitives without its full route scaffold. + Fortify::ignoreRoutes(); } /** diff --git a/app/Services/Auth/TwoFactorChallenge.php b/app/Services/Auth/TwoFactorChallenge.php new file mode 100644 index 0000000..d20d8cb --- /dev/null +++ b/app/Services/Auth/TwoFactorChallenge.php @@ -0,0 +1,33 @@ +hasEnabledTwoFactorAuthentication(); + } + + /** + * Begin a two-factor challenge without authenticating the user. + */ + public function begin(Request $request, User $user, bool $remember = false): RedirectResponse + { + $request->session()->put([ + 'login.id' => $user->getKey(), + 'login.remember' => $remember, + ]); + $request->session()->regenerate(); + + return redirect()->route('two-factor.login'); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 0e62756..e1597f3 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,5 +1,6 @@ withMiddleware(function (Middleware $middleware): void { $middleware->web(append: [ HandleInertiaRequests::class, + EnsureTwoFactorAuthenticationIsEnabled::class, ]); $middleware->statefulApi(); diff --git a/composer.json b/composer.json index ffa14c2..80041c3 100644 --- a/composer.json +++ b/composer.json @@ -11,6 +11,7 @@ "require": { "php": "^8.2", "inertiajs/inertia-laravel": "^3.0.0-beta", + "laravel/fortify": "^1.37", "laravel/framework": "^13.0", "laravel/sanctum": "^4.0", "laravel/socialite": "^5.24", diff --git a/composer.lock b/composer.lock index 1746d14..7add2a2 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,63 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "797bf73cb95e144b07eaba8af6825cb3", + "content-hash": "5c582098ef0a251fe7c722068783093a", "packages": [ + { + "name": "bacon/bacon-qr-code", + "version": "v3.1.1", + "source": { + "type": "git", + "url": "https://github.com/Bacon/BaconQrCode.git", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "reference": "4da2233e72eeecd9be3b62e0dc2cc9ed8e2e31c2", + "shasum": "" + }, + "require": { + "dasprid/enum": "^1.0.3", + "ext-iconv": "*", + "php": "^8.1" + }, + "require-dev": { + "phly/keep-a-changelog": "^2.12", + "phpunit/phpunit": "^10.5.11 || ^11.0.4", + "spatie/phpunit-snapshot-assertions": "^5.1.5", + "spatie/pixelmatch-php": "^1.2.0", + "squizlabs/php_codesniffer": "^3.9" + }, + "suggest": { + "ext-imagick": "to generate QR code images" + }, + "type": "library", + "autoload": { + "psr-4": { + "BaconQrCode\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "BaconQrCode is a QR code generator for PHP.", + "homepage": "https://github.com/Bacon/BaconQrCode", + "support": { + "issues": "https://github.com/Bacon/BaconQrCode/issues", + "source": "https://github.com/Bacon/BaconQrCode/tree/v3.1.1" + }, + "time": "2026-04-05T21:06:35+00:00" + }, { "name": "brick/math", "version": "0.18.0", @@ -134,6 +189,56 @@ ], "time": "2024-02-09T16:56:22+00:00" }, + { + "name": "dasprid/enum", + "version": "1.0.7", + "source": { + "type": "git", + "url": "https://github.com/DASPRiD/Enum.git", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce", + "shasum": "" + }, + "require": { + "php": ">=7.1 <9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "DASPRiD\\Enum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Ben Scholzen 'DASPRiD'", + "email": "mail@dasprids.de", + "homepage": "https://dasprids.de/", + "role": "Developer" + } + ], + "description": "PHP 7.1 enum implementation", + "keywords": [ + "enum", + "map" + ], + "support": { + "issues": "https://github.com/DASPRiD/Enum/issues", + "source": "https://github.com/DASPRiD/Enum/tree/1.0.7" + }, + "time": "2025-09-16T12:23:56+00:00" + }, { "name": "dflydev/dot-access-data", "version": "v3.0.3", @@ -209,6 +314,54 @@ }, "time": "2024-07-08T12:26:09+00:00" }, + { + "name": "doctrine/deprecations", + "version": "1.1.6", + "source": { + "type": "git", + "url": "https://github.com/doctrine/deprecations.git", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "conflict": { + "phpunit/phpunit": "<=7.5 || >=14" + }, + "require-dev": { + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", + "phpstan/phpstan-phpunit": "^1.0 || ^2", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", + "psr/log": "^1 || ^2 || ^3" + }, + "suggest": { + "psr/log": "Allows logging deprecations via PSR-3 logger implementation" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Deprecations\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", + "homepage": "https://www.doctrine-project.org/", + "support": { + "issues": "https://github.com/doctrine/deprecations/issues", + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" + }, + "time": "2026-02-07T07:09:04+00:00" + }, { "name": "doctrine/inflector", "version": "2.1.0", @@ -1195,6 +1348,70 @@ }, "time": "2026-07-02T12:45:54+00:00" }, + { + "name": "laravel/fortify", + "version": "v1.37.3", + "source": { + "type": "git", + "url": "https://github.com/laravel/fortify.git", + "reference": "66b9503330e7c18b3edebb9c3b4087037826573b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/fortify/zipball/66b9503330e7c18b3edebb9c3b4087037826573b", + "reference": "66b9503330e7c18b3edebb9c3b4087037826573b", + "shasum": "" + }, + "require": { + "bacon/bacon-qr-code": "^3.0", + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "laravel/passkeys": "^0.2.0", + "php": "^8.2", + "pragmarx/google2fa": "^9.0" + }, + "require-dev": { + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Fortify\\FortifyServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Fortify\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Backend controllers and scaffolding for Laravel authentication.", + "keywords": [ + "auth", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/fortify/issues", + "source": "https://github.com/laravel/fortify" + }, + "time": "2026-06-29T16:22:02+00:00" + }, { "name": "laravel/framework", "version": "v13.21.1", @@ -1422,6 +1639,74 @@ }, "time": "2026-07-21T14:27:35+00:00" }, + { + "name": "laravel/passkeys", + "version": "v0.2.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/passkeys-server.git", + "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/passkeys-server/zipball/a76656ada41b2b4a591f075eddae5ddc67e8ab9c", + "reference": "a76656ada41b2b4a591f075eddae5ddc67e8ab9c", + "shasum": "" + }, + "require": { + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/http": "^11.0|^12.0|^13.0", + "illuminate/routing": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "web-auth/webauthn-lib": "5.3.x" + }, + "require-dev": { + "laravel/pint": "^1.28.0", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.3" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Passkeys\\PasskeysServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Passkeys\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Passwordless authentication using WebAuthn/passkeys for Laravel", + "homepage": "https://github.com/laravel/passkeys-server", + "keywords": [ + "Authentication", + "Passwordless", + "laravel", + "passkeys", + "webauthn" + ], + "support": { + "issues": "https://github.com/laravel/passkeys-server/issues", + "source": "https://github.com/laravel/passkeys-server" + }, + "time": "2026-05-18T16:26:00+00:00" + }, { "name": "laravel/prompts", "version": "v0.3.21", @@ -3010,6 +3295,182 @@ }, "time": "2020-10-15T08:29:30+00:00" }, + { + "name": "phpdocumentor/reflection-common", + "version": "2.2.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionCommon.git", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "Common reflection classes used by phpdocumentor to reflect the code structure", + "homepage": "http://www.phpdoc.org", + "keywords": [ + "FQSEN", + "phpDocumentor", + "phpdoc", + "reflection", + "static analysis" + ], + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", + "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" + }, + "time": "2020-06-27T09:03:43+00:00" + }, + { + "name": "phpdocumentor/reflection-docblock", + "version": "6.0.3", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", + "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.1", + "ext-filter": "*", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/type-resolver": "^2.0", + "phpstan/phpdoc-parser": "^2.0", + "webmozart/assert": "^1.9.1 || ^2" + }, + "require-dev": { + "mockery/mockery": "~1.3.5 || ~1.6.0", + "phpstan/extension-installer": "^1.1", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-mockery": "^1.1", + "phpstan/phpstan-webmozart-assert": "^1.2", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^5.26", + "shipmonk/dead-code-detector": "^0.5.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + }, + { + "name": "Jaap van Otterdijk", + "email": "opensource@ijaap.nl" + } + ], + "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", + "support": { + "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", + "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" + }, + "time": "2026-03-18T20:49:53+00:00" + }, + { + "name": "phpdocumentor/type-resolver", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/phpDocumentor/TypeResolver.git", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", + "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", + "shasum": "" + }, + "require": { + "doctrine/deprecations": "^1.0", + "php": "^7.4 || ^8.0", + "phpdocumentor/reflection-common": "^2.0", + "phpstan/phpdoc-parser": "^2.0" + }, + "require-dev": { + "ext-tokenizer": "*", + "phpbench/phpbench": "^1.2", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.5", + "psalm/phar": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-1.x": "1.x-dev", + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "phpDocumentor\\Reflection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mike van Riel", + "email": "me@mikevanriel.com" + } + ], + "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", + "support": { + "issues": "https://github.com/phpDocumentor/TypeResolver/issues", + "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" + }, + "time": "2026-01-06T21:53:42+00:00" + }, { "name": "phpoption/phpoption", "version": "1.9.5", @@ -3195,6 +3656,105 @@ ], "time": "2026-06-14T23:24:10+00:00" }, + { + "name": "phpstan/phpdoc-parser", + "version": "2.3.3", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpdoc-parser.git", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^2.0", + "nikic/php-parser": "^5.3.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", + "symfony/process": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "PHPStan\\PhpDocParser\\": [ + "src/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPDoc parser with support for nullable, intersection and generic types", + "support": { + "issues": "https://github.com/phpstan/phpdoc-parser/issues", + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" + }, + "time": "2026-07-08T07:01:06+00:00" + }, + { + "name": "pragmarx/google2fa", + "version": "v9.0.0", + "source": { + "type": "git", + "url": "https://github.com/antonioribeiro/google2fa.git", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/antonioribeiro/google2fa/zipball/e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "reference": "e6bc62dd6ae83acc475f57912e27466019a1f2cf", + "shasum": "" + }, + "require": { + "paragonie/constant_time_encoding": "^1.0|^2.0|^3.0", + "php": "^7.1|^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.9", + "phpunit/phpunit": "^7.5.15|^8.5|^9.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "PragmaRX\\Google2FA\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Antonio Carlos Ribeiro", + "email": "acr@antoniocarlosribeiro.com", + "role": "Creator & Designer" + } + ], + "description": "A One Time Password Authentication package, compatible with Google Authenticator.", + "keywords": [ + "2fa", + "Authentication", + "Two Factor Authentication", + "google2fa" + ], + "support": { + "issues": "https://github.com/antonioribeiro/google2fa/issues", + "source": "https://github.com/antonioribeiro/google2fa/tree/v9.0.0" + }, + "time": "2025-09-19T22:51:08+00:00" + }, { "name": "psr/clock", "version": "1.0.0", @@ -3884,6 +4444,186 @@ }, "time": "2026-06-18T03:57:49+00:00" }, + { + "name": "spomky-labs/cbor-php", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/cbor-php.git", + "reference": "013d13da69cf28b1ae501887daceccc850ca1c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/cbor-php/zipball/013d13da69cf28b1ae501887daceccc850ca1c76", + "reference": "013d13da69cf28b1ae501887daceccc850ca1c76", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", + "ext-mbstring": "*", + "php": ">=8.0" + }, + "require-dev": { + "ext-json": "*", + "roave/security-advisories": "dev-latest", + "symfony/error-handler": "^6.4|^7.1|^8.0", + "symfony/var-dumper": "^6.4|^7.1|^8.0" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath extensions will drastically improve the library performance. BCMath extension needed to handle the Big Float and Decimal Fraction Tags", + "ext-gmp": "GMP or BCMath extensions will drastically improve the library performance" + }, + "type": "library", + "autoload": { + "psr-4": { + "CBOR\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/cbor-php/contributors" + } + ], + "description": "CBOR Encoder/Decoder for PHP", + "keywords": [ + "Concise Binary Object Representation", + "RFC7049", + "cbor" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/cbor-php/issues", + "source": "https://github.com/Spomky-Labs/cbor-php/tree/3.3.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-07-15T18:56:27+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.5.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "e0d61661962560c1cedfef02b51b431e720aae78" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/e0d61661962560c1cedfef02b51b431e720aae78", + "reference": "e0d61661962560c1cedfef02b51b431e720aae78", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0 || ^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.5.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-07-16T10:28:45+00:00" + }, { "name": "symfony/clock", "version": "v8.1.0", @@ -5766,6 +6506,173 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/property-access", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-access.git", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-access/zipball/9261ef060f26cc7b728f67f141ba19b98a6209a9", + "reference": "9261ef060f26cc7b728f67f141ba19b98a6209a9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/property-info": "^7.4.4|^8.0.4" + }, + "require-dev": { + "symfony/cache": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyAccess\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides functions to read and write from/to an object or array using a simple string notation", + "homepage": "https://symfony.com", + "keywords": [ + "access", + "array", + "extraction", + "index", + "injection", + "object", + "property", + "property-path", + "reflection" + ], + "support": { + "source": "https://github.com/symfony/property-access/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/property-info", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/property-info.git", + "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/property-info/zipball/4721e8c56d0cd2378e0ef9a9899f810008b859f7", + "reference": "4721e8c56d0cd2378e0ef9a9899f810008b859f7", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/string": "^7.4|^8.0", + "symfony/type-info": "^7.4.7|^8.0.7" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\PropertyInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts information about PHP class' properties using metadata of popular sources", + "homepage": "https://symfony.com", + "keywords": [ + "doctrine", + "phpdoc", + "property", + "symfony", + "type", + "validator" + ], + "support": { + "source": "https://github.com/symfony/property-info/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "symfony/routing", "version": "v8.1.0", @@ -5846,6 +6753,105 @@ ], "time": "2026-05-29T05:06:50+00:00" }, + { + "name": "symfony/serializer", + "version": "v8.1.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/serializer.git", + "reference": "f911b744bc24658f435ea30439cfe536f0173a3a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/serializer/zipball/f911b744bc24658f435ea30439cfe536f0173a3a", + "reference": "f911b744bc24658f435ea30439cfe536f0173a3a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/property-access": "<8.1", + "symfony/property-info": "<7.4", + "symfony/type-info": "<7.4" + }, + "require-dev": { + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/form": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/property-access": "^8.1", + "symfony/property-info": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Serializer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/serializer/tree/v8.1.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-27T09:05:56+00:00" + }, { "name": "symfony/service-contracts", "version": "v3.7.1", @@ -6198,6 +7204,88 @@ ], "time": "2026-06-05T06:23:12+00:00" }, + { + "name": "symfony/type-info", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/type-info.git", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/type-info/zipball/9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "reference": "9f24df8a79781b9b9f030fea7dfd2f3bd1e7e7e7", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/container": "^1.1|^2.0" + }, + "conflict": { + "phpstan/phpdoc-parser": "<1.30" + }, + "require-dev": { + "phpstan/phpdoc-parser": "^1.30|^2.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\TypeInfo\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Baptiste LEDUC", + "email": "baptiste.leduc@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Extracts PHP types information.", + "homepage": "https://symfony.com", + "keywords": [ + "PHPStan", + "phpdoc", + "symfony", + "type" + ], + "support": { + "source": "https://github.com/symfony/type-info/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, { "name": "symfony/uid", "version": "v8.1.0", @@ -6575,6 +7663,229 @@ } ], "time": "2026-04-26T05:33:54+00:00" + }, + { + "name": "web-auth/cose-lib", + "version": "4.6.0", + "source": { + "type": "git", + "url": "https://github.com/web-auth/cose-lib.git", + "reference": "3afe04df137baf97c5c3e28c5ee6f05536405148" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/cose-lib/zipball/3afe04df137baf97c5c3e28c5ee6f05536405148", + "reference": "3afe04df137baf97c5c3e28c5ee6f05536405148", + "shasum": "" + }, + "require": { + "brick/math": "^0.9|^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18", + "ext-json": "*", + "ext-openssl": "*", + "php": ">=8.1", + "spomky-labs/pki-framework": "^1.0" + }, + "require-dev": { + "spomky-labs/cbor-php": "^3.2.2" + }, + "suggest": { + "ext-bcmath": "For better performance, please install either GMP (recommended) or BCMath extension", + "ext-gmp": "For better performance, please install either GMP (recommended) or BCMath extension", + "spomky-labs/cbor-php": "For COSE Signature support" + }, + "type": "library", + "autoload": { + "psr-4": { + "Cose\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/cose/contributors" + } + ], + "description": "CBOR Object Signing and Encryption (COSE) For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "COSE", + "RFC8152" + ], + "support": { + "issues": "https://github.com/web-auth/cose-lib/issues", + "source": "https://github.com/web-auth/cose-lib/tree/4.6.0" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-07-16T10:19:49+00:00" + }, + { + "name": "web-auth/webauthn-lib", + "version": "5.3.5", + "source": { + "type": "git", + "url": "https://github.com/web-auth/webauthn-lib.git", + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "reference": "9e0986d999f4102e24ac8a598d3a80d98b56c19f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-openssl": "*", + "paragonie/constant_time_encoding": "^2.6|^3.0", + "php": ">=8.2", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", + "psr/clock": "^1.0", + "psr/event-dispatcher": "^1.0", + "psr/log": "^1.0|^2.0|^3.0", + "spomky-labs/cbor-php": "^3.0", + "spomky-labs/pki-framework": "^1.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/deprecation-contracts": "^3.2", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "web-auth/cose-lib": "^4.2.3" + }, + "suggest": { + "psr/log-implementation": "Recommended to receive logs from the library", + "symfony/event-dispatcher": "Recommended to use dispatched events", + "web-token/jwt-library": "Mandatory for fetching Metadata Statement from distant sources" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/web-auth/webauthn-framework", + "name": "web-auth/webauthn-framework" + } + }, + "autoload": { + "psr-4": { + "Webauthn\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-auth/webauthn-library/contributors" + } + ], + "description": "FIDO2/Webauthn Support For PHP", + "homepage": "https://github.com/web-auth", + "keywords": [ + "FIDO2", + "fido", + "webauthn" + ], + "support": { + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.5" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-05-31T15:00:08+00:00" + }, + { + "name": "webmozart/assert", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/webmozarts/assert.git", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-date": "*", + "ext-filter": "*", + "php": "^8.2" + }, + "suggest": { + "ext-intl": "", + "ext-simplexml": "", + "ext-spl": "" + }, + "type": "library", + "extra": { + "psalm": { + "pluginClass": "Webmozart\\Assert\\PsalmPlugin" + }, + "branch-alias": { + "dev-master": "2.0-dev", + "dev-feature/2-0": "2.0-dev" + } + }, + "autoload": { + "psr-4": { + "Webmozart\\Assert\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" + } + ], + "description": "Assertions to validate method input/output with nice error messages.", + "keywords": [ + "assert", + "check", + "validate" + ], + "support": { + "issues": "https://github.com/webmozarts/assert/issues", + "source": "https://github.com/webmozarts/assert/tree/2.4.1" + }, + "time": "2026-06-15T15:31:57+00:00" } ], "packages-dev": [ @@ -6813,54 +8124,6 @@ ], "time": "2024-05-06T16:37:16+00:00" }, - { - "name": "doctrine/deprecations", - "version": "1.1.6", - "source": { - "type": "git", - "url": "https://github.com/doctrine/deprecations.git", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", - "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", - "shasum": "" - }, - "require": { - "php": "^7.1 || ^8.0" - }, - "conflict": { - "phpunit/phpunit": "<=7.5 || >=14" - }, - "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^14", - "phpstan/phpstan": "1.4.10 || 2.1.30", - "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", - "psr/log": "^1 || ^2 || ^3" - }, - "suggest": { - "psr/log": "Allows logging deprecations via PSR-3 logger implementation" - }, - "type": "library", - "autoload": { - "psr-4": { - "Doctrine\\Deprecations\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.", - "homepage": "https://www.doctrine-project.org/", - "support": { - "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.6" - }, - "time": "2026-02-07T07:09:04+00:00" - }, { "name": "fakerphp/faker", "version": "v1.24.1", @@ -8327,229 +9590,6 @@ }, "time": "2022-02-21T01:04:05+00:00" }, - { - "name": "phpdocumentor/reflection-common", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-2.x": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" - } - ], - "description": "Common reflection classes used by phpdocumentor to reflect the code structure", - "homepage": "http://www.phpdoc.org", - "keywords": [ - "FQSEN", - "phpDocumentor", - "phpdoc", - "reflection", - "static analysis" - ], - "support": { - "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", - "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" - }, - "time": "2020-06-27T09:03:43+00:00" - }, - { - "name": "phpdocumentor/reflection-docblock", - "version": "6.0.3", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/7bae67520aa9f5ecc506d646810bd40d9da54582", - "reference": "7bae67520aa9f5ecc506d646810bd40d9da54582", - "shasum": "" - }, - "require": { - "doctrine/deprecations": "^1.1", - "ext-filter": "*", - "php": "^7.4 || ^8.0", - "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^2.0", - "phpstan/phpdoc-parser": "^2.0", - "webmozart/assert": "^1.9.1 || ^2" - }, - "require-dev": { - "mockery/mockery": "~1.3.5 || ~1.6.0", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-mockery": "^1.1", - "phpstan/phpstan-webmozart-assert": "^1.2", - "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26", - "shipmonk/dead-code-detector": "^0.5.1" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" - }, - { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" - } - ], - "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", - "support": { - "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/6.0.3" - }, - "time": "2026-03-18T20:49:53+00:00" - }, - { - "name": "phpdocumentor/type-resolver", - "version": "2.0.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/327a05bbee54120d4786a0dc67aad30226ad4cf9", - "reference": "327a05bbee54120d4786a0dc67aad30226ad4cf9", - "shasum": "" - }, - "require": { - "doctrine/deprecations": "^1.0", - "php": "^7.4 || ^8.0", - "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^2.0" - }, - "require-dev": { - "ext-tokenizer": "*", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.4", - "phpstan/phpstan": "^2.1", - "phpstan/phpstan-phpunit": "^2.0", - "phpunit/phpunit": "^9.5", - "psalm/phar": "^4" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-1.x": "1.x-dev", - "dev-2.x": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" - } - ], - "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", - "support": { - "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/2.0.0" - }, - "time": "2026-01-06T21:53:42+00:00" - }, - { - "name": "phpstan/phpdoc-parser", - "version": "2.3.3", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", - "reference": "fb19eedd2bb67ff8cf7a5502ad329e701d6398a3", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "doctrine/annotations": "^2.0", - "nikic/php-parser": "^5.3.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6", - "symfony/process": "^5.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", - "support": { - "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.3" - }, - "time": "2026-07-08T07:01:06+00:00" - }, { "name": "phpunit/php-code-coverage", "version": "12.5.7", @@ -10130,72 +11170,6 @@ } ], "time": "2025-12-08T11:19:18+00:00" - }, - { - "name": "webmozart/assert", - "version": "2.4.1", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70", - "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", - "php": "^8.2" - }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" - }, - "type": "library", - "extra": { - "psalm": { - "pluginClass": "Webmozart\\Assert\\PsalmPlugin" - }, - "branch-alias": { - "dev-master": "2.0-dev", - "dev-feature/2-0": "2.0-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - }, - { - "name": "Woody Gilk", - "email": "woody.gilk@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.4.1" - }, - "time": "2026-06-15T15:31:57+00:00" } ], "aliases": [], diff --git a/config/auth-ui.php b/config/auth-ui.php index dea7db7..53a50f2 100644 --- a/config/auth-ui.php +++ b/config/auth-ui.php @@ -27,6 +27,8 @@ return [ 'password_reset' => env('AUTH_ENABLE_PASSWORD_RESET', true), 'remember_me' => env('AUTH_ENABLE_REMEMBER_ME', true), 'email_verification' => env('AUTH_ENABLE_EMAIL_VERIFICATION', false), + 'two_factor' => env('AUTH_ENABLE_TWO_FACTOR', false), + 'two_factor_required' => env('AUTH_REQUIRE_TWO_FACTOR', false), ], /* diff --git a/config/fortify.php b/config/fortify.php new file mode 100644 index 0000000..9d3e8f6 --- /dev/null +++ b/config/fortify.php @@ -0,0 +1,17 @@ + 'web', + 'username' => 'email', + 'views' => false, + 'home' => config('auth-ui.redirects.login', '/dashboard'), + 'features' => config('auth-ui.features.two_factor') + ? [ + Features::twoFactorAuthentication([ + 'confirm' => true, + ]), + ] + : [], +]; diff --git a/database/migrations/2026_07_24_000000_add_two_factor_columns_to_users_table.php b/database/migrations/2026_07_24_000000_add_two_factor_columns_to_users_table.php new file mode 100644 index 0000000..dfc075e --- /dev/null +++ b/database/migrations/2026_07_24_000000_add_two_factor_columns_to_users_table.php @@ -0,0 +1,34 @@ +text('two_factor_secret')->nullable()->after('password'); + $table->text('two_factor_recovery_codes')->nullable()->after('two_factor_secret'); + $table->timestamp('two_factor_confirmed_at')->nullable()->after('two_factor_recovery_codes'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn([ + 'two_factor_secret', + 'two_factor_recovery_codes', + 'two_factor_confirmed_at', + ]); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..32b6bb4 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -18,7 +18,9 @@ class DatabaseSeeder extends Seeder // User::factory(10)->create(); User::factory()->create([ - 'name' => 'Test User', + 'username' => 'testuser', + 'first_name' => 'Test', + 'last_name' => 'User', 'email' => 'test@example.com', ]); } diff --git a/resources/js/Pages/Auth/TwoFactorChallenge.vue b/resources/js/Pages/Auth/TwoFactorChallenge.vue new file mode 100644 index 0000000..78d538d --- /dev/null +++ b/resources/js/Pages/Auth/TwoFactorChallenge.vue @@ -0,0 +1,96 @@ + + + diff --git a/resources/js/Pages/Profile/Show.vue b/resources/js/Pages/Profile/Show.vue new file mode 100644 index 0000000..5e1cf6e --- /dev/null +++ b/resources/js/Pages/Profile/Show.vue @@ -0,0 +1,332 @@ + + + diff --git a/resources/js/layouts/DashboardLayout.vue b/resources/js/layouts/DashboardLayout.vue index 05bb5a3..8159b22 100644 --- a/resources/js/layouts/DashboardLayout.vue +++ b/resources/js/layouts/DashboardLayout.vue @@ -1,4 +1,5 @@ - + diff --git a/resources/js/types/auth/config.ts b/resources/js/types/auth/config.ts index a0ea4c0..358ad1a 100644 --- a/resources/js/types/auth/config.ts +++ b/resources/js/types/auth/config.ts @@ -3,6 +3,8 @@ export interface AuthFeatures { password_reset: boolean remember_me: boolean email_verification: boolean + two_factor: boolean + two_factor_required: boolean } export interface AuthPageConfig { diff --git a/resources/js/types/inertia/flash.ts b/resources/js/types/inertia/flash.ts index 20207df..192d141 100644 --- a/resources/js/types/inertia/flash.ts +++ b/resources/js/types/inertia/flash.ts @@ -3,4 +3,5 @@ export interface Flash { error: string | null message: string | null status: string | null + recoveryCodes: string[] | null } diff --git a/routes/api.php b/routes/api.php index ccc387f..d3dc7b8 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,8 +1,9 @@ user(); -})->middleware('auth:sanctum'); +})->middleware(['auth:sanctum', EnsureTwoFactorAuthenticationIsEnabled::class]); diff --git a/routes/auth.php b/routes/auth.php index 38c27d1..6882d23 100644 --- a/routes/auth.php +++ b/routes/auth.php @@ -7,6 +7,9 @@ use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\Auth\RegisterController; use App\Http\Controllers\Auth\ResetPasswordController; use App\Http\Controllers\Auth\SocialiteController; +use App\Http\Controllers\Auth\TwoFactorChallengeController; +use App\Http\Controllers\Auth\TwoFactorSettingsController; +use App\Http\Controllers\ProfileController; use Illuminate\Support\Facades\Route; Route::middleware('guest')->group(function () { @@ -29,6 +32,12 @@ Route::middleware('guest')->group(function () { // Complete profile after social login (when username is taken) Route::get('complete-profile', [CompleteProfileController::class, 'create'])->name('auth.complete-profile'); Route::post('complete-profile', [CompleteProfileController::class, 'store']); + + Route::get('two-factor-challenge', [TwoFactorChallengeController::class, 'create']) + ->name('two-factor.login'); + Route::post('two-factor-challenge', [TwoFactorChallengeController::class, 'store']) + ->middleware('throttle:5,1') + ->name('two-factor.login.store'); }); Route::middleware('auth')->group(function () { @@ -38,4 +47,19 @@ Route::middleware('auth')->group(function () { Route::get('email/verify', [EmailVerificationController::class, 'notice'])->name('verification.notice'); Route::get('email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware('signed')->name('verification.verify'); Route::post('email/verification-notification', [EmailVerificationController::class, 'resend'])->middleware('throttle:6,1')->name('verification.send'); + + Route::get('profile', ProfileController::class)->name('profile.show'); + Route::redirect('security', '/profile')->name('profile.security-redirect'); + Route::post('profile/two-factor', [TwoFactorSettingsController::class, 'enable']) + ->middleware('throttle:5,1') + ->name('profile.two-factor.enable'); + Route::post('profile/two-factor/confirm', [TwoFactorSettingsController::class, 'confirm']) + ->middleware('throttle:5,1') + ->name('profile.two-factor.confirm'); + Route::delete('profile/two-factor', [TwoFactorSettingsController::class, 'disable']) + ->middleware('throttle:5,1') + ->name('profile.two-factor.disable'); + Route::post('profile/two-factor/recovery-codes', [TwoFactorSettingsController::class, 'recoveryCodes']) + ->middleware('throttle:5,1') + ->name('profile.two-factor.recovery-codes'); }); diff --git a/tests/Feature/Auth/TwoFactorAuthenticationTest.php b/tests/Feature/Auth/TwoFactorAuthenticationTest.php new file mode 100644 index 0000000..26a1dbe --- /dev/null +++ b/tests/Feature/Auth/TwoFactorAuthenticationTest.php @@ -0,0 +1,311 @@ + true, + 'auth-ui.features.two_factor_required' => false, + 'fortify.features' => [Features::twoFactorAuthentication()], + 'fortify-options.two-factor-authentication' => ['confirm' => true], + 'auth-ui.providers.github' => [ + 'label' => 'GitHub', + 'icon' => 'i-simple-icons-github', + 'enabled' => true, + ], + ]); +}); + +function enableTwoFactorForTest(User $user): array +{ + app(EnableTwoFactorAuthentication::class)($user); + $user->forceFill(['two_factor_confirmed_at' => now()])->save(); + $user->refresh(); + + $secret = Fortify::currentEncrypter()->decrypt($user->two_factor_secret); + + return [ + 'code' => app(Google2FA::class)->getCurrentOtp($secret), + 'recovery_codes' => $user->recoveryCodes(), + ]; +} + +it('keeps the profile available while two-factor settings are disabled', function () { + config(['auth-ui.features.two_factor' => false]); + + $this->actingAs(User::factory()->create()) + ->get('/profile') + ->assertInertia(fn (Assert $page) => $page + ->component('Profile/Show') + ->where('twoFactor.available', false) + ->where('twoFactor.enabled', false) + ->where('twoFactor.pending', false)); +}); + +it('redirects the former security page to the profile', function () { + $this->actingAs(User::factory()->create()) + ->get('/security') + ->assertRedirect('/profile'); +}); + +it('bypasses stored two-factor configuration when the feature is disabled', function () { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => 'password123', + ]); + enableTwoFactorForTest($user); + config(['auth-ui.features.two_factor' => false]); + + $this->post('/login', [ + 'login' => 'test@example.com', + 'password' => 'password123', + ])->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user); +}); + +it('redirects authenticated users to enrollment when two-factor setup is mandatory', function () { + config(['auth-ui.features.two_factor_required' => true]); + $user = User::factory()->create(); + + $this->actingAs($user) + ->get('/dashboard') + ->assertRedirect('/profile') + ->assertSessionHas('error', 'Two-factor authentication is required before you can continue.'); + + $this->actingAs($user) + ->get('/profile') + ->assertOk(); + + $this->actingAs($user) + ->post('/logout') + ->assertRedirect('/'); + $this->assertGuest(); +}); + +it('keeps application access restricted while mandatory enrollment is pending', function () { + config(['auth-ui.features.two_factor_required' => true]); + $user = User::factory()->create(['password' => 'password123']); + + $this->actingAs($user) + ->post('/profile/two-factor', ['password' => 'password123']) + ->assertRedirect('/profile'); + + expect($user->fresh()->two_factor_secret)->not->toBeNull() + ->and($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse(); + + $this->actingAs($user) + ->get('/dashboard') + ->assertRedirect('/profile'); +}); + +it('allows normal access after mandatory enrollment is complete', function () { + config(['auth-ui.features.two_factor_required' => true]); + $user = User::factory()->create(); + enableTwoFactorForTest($user); + + $this->actingAs($user) + ->get('/dashboard') + ->assertOk(); +}); + +it('does not enforce the mandatory flag while the main two-factor feature is disabled', function () { + config([ + 'auth-ui.features.two_factor' => false, + 'auth-ui.features.two_factor_required' => true, + ]); + + $this->actingAs(User::factory()->create()) + ->get('/dashboard') + ->assertOk(); +}); + +it('blocks authenticated API requests until mandatory enrollment is complete', function () { + config(['auth-ui.features.two_factor_required' => true]); + $user = User::factory()->create(); + + $this->actingAs($user) + ->getJson('/api/user') + ->assertForbidden() + ->assertJson([ + 'message' => 'Two-factor authentication setup is required.', + 'setup_url' => route('profile.show'), + ]); + + enableTwoFactorForTest($user); + + $this->actingAs($user->fresh()) + ->getJson('/api/user') + ->assertOk() + ->assertJsonPath('id', $user->id); +}); + +it('requires the current password before a password user can enroll', function () { + $user = User::factory()->create(['password' => 'password123']); + + $this->actingAs($user) + ->post('/profile/two-factor', ['password' => 'wrong-password']) + ->assertSessionHasErrors('password'); + + expect($user->fresh()->two_factor_secret)->toBeNull(); + + $this->actingAs($user) + ->post('/profile/two-factor', ['password' => 'password123']) + ->assertRedirect('/profile'); + + expect($user->fresh()->two_factor_secret)->not->toBeNull() + ->and($user->fresh()->two_factor_confirmed_at)->toBeNull(); + + $this->actingAs($user) + ->get('/profile') + ->assertInertia(fn (Assert $page) => $page + ->component('Profile/Show') + ->where('twoFactor.pending', true) + ->where('twoFactor.enabled', false) + ->where('twoFactor.requiresPassword', true) + ->where('twoFactor.qrCodeDataUri', fn ($value) => str_starts_with($value, 'data:image/svg+xml;base64,')) + ->where('twoFactor.secretKey', fn ($value) => filled($value))); +}); + +it('confirms enrollment and returns recovery codes once', function () { + $user = User::factory()->create(); + app(EnableTwoFactorAuthentication::class)($user); + $user->refresh(); + + $secret = Fortify::currentEncrypter()->decrypt($user->two_factor_secret); + $code = app(Google2FA::class)->getCurrentOtp($secret); + + $this->actingAs($user) + ->post('/profile/two-factor/confirm', ['code' => $code]) + ->assertRedirect('/profile') + ->assertSessionHas('recoveryCodes', fn (array $codes) => count($codes) === 8); + + expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeTrue(); +}); + +it('holds password login until a valid authenticator code is supplied', function () { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => 'password123', + ]); + $twoFactor = enableTwoFactorForTest($user); + + $loginResponse = $this->post('/login', [ + 'login' => 'test@example.com', + 'password' => 'password123', + 'remember' => true, + ]); + $loginResponse->assertRedirect('/two-factor-challenge'); + + $this->assertGuest(); + $loginResponse->assertSessionHas('login.id', $user->id); + $this->get('/two-factor-challenge') + ->assertInertia(fn (Assert $page) => $page->component('Auth/TwoFactorChallenge')); + + $this->post('/two-factor-challenge', ['code' => '000000']) + ->assertSessionHasErrors('code'); + $this->assertGuest(); + + $challengeResponse = $this->post('/two-factor-challenge', ['code' => $twoFactor['code']]); + $challengeResponse->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user); + $challengeResponse->assertSessionMissing('login.id'); +}); + +it('accepts a recovery code once and replaces it after login', function () { + $user = User::factory()->create([ + 'email' => 'test@example.com', + 'password' => 'password123', + ]); + $twoFactor = enableTwoFactorForTest($user); + $recoveryCode = $twoFactor['recovery_codes'][0]; + + $this->post('/login', [ + 'login' => 'test@example.com', + 'password' => 'password123', + ])->assertRedirect('/two-factor-challenge'); + + $this->post('/two-factor-challenge', ['recovery_code' => $recoveryCode]) + ->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user); + expect($user->fresh()->recoveryCodes())->not->toContain($recoveryCode); +}); + +it('holds an existing social login at the same two-factor challenge', function () { + $user = User::factory()->social()->create(); + $user->socialAccounts()->create([ + 'provider' => 'github', + 'provider_id' => 'github-2fa', + ]); + enableTwoFactorForTest($user); + + Socialite::fake('github', (new SocialiteUser)->map([ + 'id' => 'github-2fa', + 'name' => 'Social User', + 'email' => $user->email, + 'nickname' => $user->username, + ])); + + $response = $this->get('/auth/github/callback'); + $response->assertRedirect('/two-factor-challenge'); + + $this->assertGuest(); + $response->assertSessionHas('login.id', $user->id); +}); + +it('requires a valid second factor for social-only users to disable protection', function () { + $user = User::factory()->social()->create(); + $twoFactor = enableTwoFactorForTest($user); + + $this->actingAs($user) + ->delete('/profile/two-factor', ['code' => 'invalid']) + ->assertSessionHasErrors('code'); + + expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeTrue(); + + $this->actingAs($user) + ->delete('/profile/two-factor', ['code' => $twoFactor['code']]) + ->assertRedirect('/profile'); + + expect($user->fresh()->hasEnabledTwoFactorAuthentication())->toBeFalse(); +}); + +it('consumes recovery codes used to authorize a sensitive settings action', function () { + $user = User::factory()->social()->create(); + $twoFactor = enableTwoFactorForTest($user); + $recoveryCode = $twoFactor['recovery_codes'][0]; + + $this->actingAs($user) + ->post('/profile/two-factor/recovery-codes', [ + 'code' => $recoveryCode, + 'regenerate' => false, + ]) + ->assertRedirect('/profile') + ->assertSessionHas('recoveryCodes'); + + expect($user->fresh()->recoveryCodes())->not->toContain($recoveryCode); +}); + +it('seeds a user matching the current user table structure', function () { + $this->seed(DatabaseSeeder::class); + + $this->assertDatabaseHas('users', [ + 'username' => 'testuser', + 'first_name' => 'Test', + 'last_name' => 'User', + 'email' => 'test@example.com', + ]); +});