feat: add two-factor authentication support and related configurations
This commit is contained in:
@@ -67,6 +67,8 @@ AUTH_ENABLE_REGISTRATION=true
|
|||||||
AUTH_ENABLE_PASSWORD_RESET=true
|
AUTH_ENABLE_PASSWORD_RESET=true
|
||||||
AUTH_ENABLE_REMEMBER_ME=true
|
AUTH_ENABLE_REMEMBER_ME=true
|
||||||
AUTH_ENABLE_EMAIL_VERIFICATION=false
|
AUTH_ENABLE_EMAIL_VERIFICATION=false
|
||||||
|
AUTH_ENABLE_TWO_FACTOR=false
|
||||||
|
AUTH_REQUIRE_TWO_FACTOR=false
|
||||||
|
|
||||||
# Auth Redirects
|
# Auth Redirects
|
||||||
# AUTH_REDIRECT_LOGIN=/dashboard
|
# AUTH_REDIRECT_LOGIN=/dashboard
|
||||||
|
|||||||
13
README.md
13
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
|
- **Authentication** — Login (email or username), register, forgot/reset password
|
||||||
- **Social Login** — OAuth via Laravel Socialite with provider tracking (`social_accounts` table)
|
- **Social Login** — OAuth via Laravel Socialite with provider tracking (`social_accounts` table)
|
||||||
- **Email Verification** — Optional, toggle via `AUTH_ENABLE_EMAIL_VERIFICATION`
|
- **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
|
- **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
|
- **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
|
- **Code Quality** — ESLint, Laravel Pint, shared Valibot validation schemas
|
||||||
@@ -73,6 +74,8 @@ AUTH_ENABLE_REGISTRATION=true
|
|||||||
AUTH_ENABLE_PASSWORD_RESET=true
|
AUTH_ENABLE_PASSWORD_RESET=true
|
||||||
AUTH_ENABLE_REMEMBER_ME=true
|
AUTH_ENABLE_REMEMBER_ME=true
|
||||||
AUTH_ENABLE_EMAIL_VERIFICATION=false
|
AUTH_ENABLE_EMAIL_VERIFICATION=false
|
||||||
|
AUTH_ENABLE_TWO_FACTOR=false
|
||||||
|
AUTH_REQUIRE_TWO_FACTOR=false
|
||||||
|
|
||||||
AUTH_REDIRECT_LOGIN=/dashboard
|
AUTH_REDIRECT_LOGIN=/dashboard
|
||||||
AUTH_REDIRECT_LOGOUT=/
|
AUTH_REDIRECT_LOGOUT=/
|
||||||
@@ -85,6 +88,16 @@ When `AUTH_ENABLE_EMAIL_VERIFICATION=true`:
|
|||||||
- Social login users are auto-verified (trusted from provider)
|
- Social login users are auto-verified (trusted from provider)
|
||||||
- Social accounts can only be linked to users with verified emails
|
- 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.
|
See `config/auth-ui.php` for all available options including page titles, icons, legal links, and social provider configuration.
|
||||||
|
|
||||||
## Social Login Setup
|
## Social Login Setup
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Auth;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\Auth\LoginRequest;
|
use App\Http\Requests\Auth\LoginRequest;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\TwoFactorChallenge;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
@@ -25,26 +26,42 @@ class LoginController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Handle an incoming authentication request.
|
* Handle an incoming authentication request.
|
||||||
*/
|
*/
|
||||||
public function store(LoginRequest $request): RedirectResponse
|
public function store(LoginRequest $request, TwoFactorChallenge $twoFactor): RedirectResponse
|
||||||
{
|
{
|
||||||
$login = $request->validated('login');
|
$login = $request->validated('login');
|
||||||
$password = $request->validated('password');
|
$password = $request->validated('password');
|
||||||
|
|
||||||
$isEmail = filter_var($login, FILTER_VALIDATE_EMAIL);
|
$isEmail = filter_var($login, FILTER_VALIDATE_EMAIL);
|
||||||
$credentials = $isEmail
|
$email = $isEmail
|
||||||
? ['email' => $login, 'password' => $password]
|
? $login
|
||||||
: ['email' => User::whereRaw('LOWER(username) = ?', [strtolower($login)])->value('email'), 'password' => $password];
|
: User::whereRaw('LOWER(username) = ?', [strtolower($login)])->value('email');
|
||||||
|
$credentials = ['email' => $email, 'password' => $password];
|
||||||
|
|
||||||
$remember = config('auth-ui.features.remember_me')
|
$remember = config('auth-ui.features.remember_me')
|
||||||
? $request->boolean('remember')
|
? $request->boolean('remember')
|
||||||
: false;
|
: 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([
|
throw ValidationException::withMessages([
|
||||||
'login' => __('auth.failed'),
|
'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();
|
$request->session()->regenerate();
|
||||||
|
|
||||||
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ namespace App\Http\Controllers\Auth;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\SocialAccount;
|
use App\Models\SocialAccount;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Services\Auth\TwoFactorChallenge;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Socialite\Facades\Socialite;
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
@@ -49,8 +51,11 @@ class SocialiteController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Obtain the user information from provider.
|
* 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())) {
|
if (! in_array($provider, $this->getEnabledProviders())) {
|
||||||
abort(404, 'Provider not enabled');
|
abort(404, 'Provider not enabled');
|
||||||
}
|
}
|
||||||
@@ -69,10 +74,7 @@ class SocialiteController extends Controller
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($socialAccount) {
|
if ($socialAccount) {
|
||||||
Auth::login($socialAccount->user, remember: true);
|
return $this->finishAuthentication($request, $socialAccount->user, $twoFactor);
|
||||||
request()->session()->regenerate();
|
|
||||||
|
|
||||||
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a user with this email already exists
|
// Check if a user with this email already exists
|
||||||
@@ -90,10 +92,7 @@ class SocialiteController extends Controller
|
|||||||
'provider_id' => $socialUser->getId(),
|
'provider_id' => $socialUser->getId(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
Auth::login($existingUser, remember: true);
|
return $this->finishAuthentication($request, $existingUser, $twoFactor);
|
||||||
request()->session()->regenerate();
|
|
||||||
|
|
||||||
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New user — check if registration is enabled
|
// New user — check if registration is enabled
|
||||||
@@ -135,8 +134,20 @@ class SocialiteController extends Controller
|
|||||||
'provider_id' => $socialUser->getId(),
|
'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);
|
Auth::login($user, remember: true);
|
||||||
request()->session()->regenerate();
|
$request->session()->regenerate();
|
||||||
|
|
||||||
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
||||||
}
|
}
|
||||||
|
|||||||
72
app/Http/Controllers/Auth/TwoFactorChallengeController.php
Normal file
72
app/Http/Controllers/Auth/TwoFactorChallengeController.php
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
use Laravel\Fortify\Events\TwoFactorAuthenticationFailed;
|
||||||
|
use Laravel\Fortify\Events\ValidTwoFactorAuthenticationCodeProvided;
|
||||||
|
use Laravel\Fortify\Http\Requests\TwoFactorLoginRequest;
|
||||||
|
|
||||||
|
class TwoFactorChallengeController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the two-factor challenge.
|
||||||
|
*/
|
||||||
|
public function create(TwoFactorLoginRequest $request): Response|RedirectResponse
|
||||||
|
{
|
||||||
|
if (! config('auth-ui.features.two_factor') || ! $request->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
168
app/Http/Controllers/Auth/TwoFactorSettingsController.php
Normal file
168
app/Http/Controllers/Auth/TwoFactorSettingsController.php
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Auth;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
|
||||||
|
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
|
||||||
|
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||||
|
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
|
||||||
|
use Laravel\Fortify\Contracts\TwoFactorAuthenticationProvider;
|
||||||
|
use Laravel\Fortify\Fortify;
|
||||||
|
|
||||||
|
class TwoFactorSettingsController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Begin two-factor enrollment.
|
||||||
|
*/
|
||||||
|
public function enable(Request $request, EnableTwoFactorAuthentication $enable): RedirectResponse
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
app/Http/Controllers/ProfileController.php
Normal file
40
app/Http/Controllers/ProfileController.php
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
use Laravel\Fortify\Fortify;
|
||||||
|
|
||||||
|
class ProfileController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Display the authenticated user's profile and security settings.
|
||||||
|
*/
|
||||||
|
public function __invoke(Request $request): Response
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $request->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,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class EnsureTwoFactorAuthenticationIsEnabled
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Require authenticated users to finish two-factor enrollment when configured.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
if (! config('auth-ui.features.two_factor')
|
||||||
|
|| ! config('auth-ui.features.two_factor_required')
|
||||||
|
|| ! $request->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.*',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ class HandleInertiaRequests extends Middleware
|
|||||||
'error' => fn () => $request->session()->get('error'),
|
'error' => fn () => $request->session()->get('error'),
|
||||||
'message' => fn () => $request->session()->get('message'),
|
'message' => fn () => $request->session()->get('message'),
|
||||||
'status' => fn () => $request->session()->get('status'),
|
'status' => fn () => $request->session()->get('status'),
|
||||||
|
'recoveryCodes' => fn () => $request->session()->get('recoveryCodes'),
|
||||||
],
|
],
|
||||||
'authConfig' => fn () => $this->getAuthConfig(),
|
'authConfig' => fn () => $this->getAuthConfig(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
|
|
||||||
class User extends Authenticatable implements MustVerifyEmail
|
class User extends Authenticatable implements MustVerifyEmail
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasFactory, Notifiable;
|
use HasFactory, Notifiable, TwoFactorAuthenticatable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
@@ -36,6 +37,8 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
protected $hidden = [
|
protected $hidden = [
|
||||||
'password',
|
'password',
|
||||||
'remember_token',
|
'remember_token',
|
||||||
|
'two_factor_secret',
|
||||||
|
'two_factor_recovery_codes',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,6 +60,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
return [
|
return [
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
|
'two_factor_confirmed_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
use Laravel\Fortify\Fortify;
|
||||||
|
|
||||||
class AppServiceProvider extends ServiceProvider
|
class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -11,7 +12,9 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function register(): void
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
33
app/Services/Auth/TwoFactorChallenge.php
Normal file
33
app/Services/Auth/TwoFactorChallenge.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Auth;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class TwoFactorChallenge
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine whether the user must complete a two-factor challenge.
|
||||||
|
*/
|
||||||
|
public function requiredFor(User $user): bool
|
||||||
|
{
|
||||||
|
return (bool) config('auth-ui.features.two_factor')
|
||||||
|
&& $user->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsureTwoFactorAuthenticationIsEnabled;
|
||||||
use App\Http\Middleware\HandleInertiaRequests;
|
use App\Http\Middleware\HandleInertiaRequests;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
@@ -20,6 +21,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->web(append: [
|
$middleware->web(append: [
|
||||||
HandleInertiaRequests::class,
|
HandleInertiaRequests::class,
|
||||||
|
EnsureTwoFactorAuthenticationIsEnabled::class,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$middleware->statefulApi();
|
$middleware->statefulApi();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"require": {
|
"require": {
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"inertiajs/inertia-laravel": "^3.0.0-beta",
|
"inertiajs/inertia-laravel": "^3.0.0-beta",
|
||||||
|
"laravel/fortify": "^1.37",
|
||||||
"laravel/framework": "^13.0",
|
"laravel/framework": "^13.0",
|
||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/socialite": "^5.24",
|
"laravel/socialite": "^5.24",
|
||||||
|
|||||||
1650
composer.lock
generated
1650
composer.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,8 @@ return [
|
|||||||
'password_reset' => env('AUTH_ENABLE_PASSWORD_RESET', true),
|
'password_reset' => env('AUTH_ENABLE_PASSWORD_RESET', true),
|
||||||
'remember_me' => env('AUTH_ENABLE_REMEMBER_ME', true),
|
'remember_me' => env('AUTH_ENABLE_REMEMBER_ME', true),
|
||||||
'email_verification' => env('AUTH_ENABLE_EMAIL_VERIFICATION', false),
|
'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),
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
17
config/fortify.php
Normal file
17
config/fortify.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Laravel\Fortify\Features;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'guard' => 'web',
|
||||||
|
'username' => 'email',
|
||||||
|
'views' => false,
|
||||||
|
'home' => config('auth-ui.redirects.login', '/dashboard'),
|
||||||
|
'features' => config('auth-ui.features.two_factor')
|
||||||
|
? [
|
||||||
|
Features::twoFactorAuthentication([
|
||||||
|
'confirm' => true,
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
];
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table) {
|
||||||
|
$table->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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -18,7 +18,9 @@ class DatabaseSeeder extends Seeder
|
|||||||
// User::factory(10)->create();
|
// User::factory(10)->create();
|
||||||
|
|
||||||
User::factory()->create([
|
User::factory()->create([
|
||||||
'name' => 'Test User',
|
'username' => 'testuser',
|
||||||
|
'first_name' => 'Test',
|
||||||
|
'last_name' => 'User',
|
||||||
'email' => 'test@example.com',
|
'email' => 'test@example.com',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
96
resources/js/Pages/Auth/TwoFactorChallenge.vue
Normal file
96
resources/js/Pages/Auth/TwoFactorChallenge.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import AuthLayout from '@/layouts/AuthLayout.vue'
|
||||||
|
|
||||||
|
const usingRecoveryCode = ref(false)
|
||||||
|
const form = useForm({
|
||||||
|
code: '',
|
||||||
|
recovery_code: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const error = computed(() => form.errors.code || form.errors.recovery_code)
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
form
|
||||||
|
.transform(data => usingRecoveryCode.value
|
||||||
|
? { code: '', recovery_code: data.recovery_code }
|
||||||
|
: { code: data.code, recovery_code: '' })
|
||||||
|
.post('/two-factor-challenge', {
|
||||||
|
onFinish: () => form.reset(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AuthLayout>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<UIcon name="i-lucide-shield-check" class="text-primary size-10" />
|
||||||
|
<h1 class="text-xl font-semibold">
|
||||||
|
Two-factor authentication
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted text-sm">
|
||||||
|
{{ usingRecoveryCode
|
||||||
|
? 'Enter one of your recovery codes to finish signing in.'
|
||||||
|
: 'Enter the six-digit code from your authenticator app.' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="space-y-4" @submit.prevent="submit">
|
||||||
|
<UFormField
|
||||||
|
v-if="!usingRecoveryCode"
|
||||||
|
label="Authentication code"
|
||||||
|
name="code"
|
||||||
|
:error="form.errors.code"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="form.code"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
maxlength="6"
|
||||||
|
placeholder="123456"
|
||||||
|
autofocus
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField
|
||||||
|
v-else
|
||||||
|
label="Recovery code"
|
||||||
|
name="recovery_code"
|
||||||
|
:error="form.errors.recovery_code"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="form.recovery_code"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
placeholder="xxxxx-xxxxx"
|
||||||
|
autofocus
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="error"
|
||||||
|
color="error"
|
||||||
|
icon="i-lucide-alert-circle"
|
||||||
|
:title="error"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UButton type="submit" block :loading="form.processing">
|
||||||
|
Verify and sign in
|
||||||
|
</UButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<UButton
|
||||||
|
variant="link"
|
||||||
|
size="sm"
|
||||||
|
@click="usingRecoveryCode = !usingRecoveryCode; form.clearErrors(); form.reset()"
|
||||||
|
>
|
||||||
|
{{ usingRecoveryCode ? 'Use an authentication code' : 'Use a recovery code' }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
|
</template>
|
||||||
332
resources/js/Pages/Profile/Show.vue
Normal file
332
resources/js/Pages/Profile/Show.vue
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@inertiajs/vue3'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useAuth } from '@/composables/useAuth'
|
||||||
|
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||||
|
|
||||||
|
interface TwoFactorState {
|
||||||
|
available: boolean
|
||||||
|
enabled: boolean
|
||||||
|
pending: boolean
|
||||||
|
requiresPassword: boolean
|
||||||
|
qrCodeDataUri: string | null
|
||||||
|
secretKey: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
layout: DashboardLayout,
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
twoFactor: TwoFactorState
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { config, flash, user } = useAuth()
|
||||||
|
|
||||||
|
const enableForm = useForm({ password: '' })
|
||||||
|
const confirmForm = useForm({ code: '' })
|
||||||
|
const disableForm = useForm({ password: '', code: '' })
|
||||||
|
const recoveryForm = useForm({ password: '', code: '', regenerate: false })
|
||||||
|
|
||||||
|
const recoveryCodes = computed(() => flash.value.recoveryCodes ?? [])
|
||||||
|
const twoFactorRequired = computed(() =>
|
||||||
|
config.value.features.two_factor && config.value.features.two_factor_required,
|
||||||
|
)
|
||||||
|
|
||||||
|
function enable() {
|
||||||
|
enableForm.post('/profile/two-factor', {
|
||||||
|
preserveScroll: true,
|
||||||
|
onFinish: () => enableForm.reset(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
confirmForm.post('/profile/two-factor/confirm', {
|
||||||
|
preserveScroll: true,
|
||||||
|
onFinish: () => confirmForm.reset(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function disable() {
|
||||||
|
disableForm.delete('/profile/two-factor', {
|
||||||
|
preserveScroll: true,
|
||||||
|
onFinish: () => disableForm.reset(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function recoveryCodesAction(regenerate: boolean) {
|
||||||
|
recoveryForm.regenerate = regenerate
|
||||||
|
recoveryForm.post('/profile/two-factor/recovery-codes', {
|
||||||
|
preserveScroll: true,
|
||||||
|
onFinish: () => recoveryForm.reset(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UDashboardPanel>
|
||||||
|
<template #header>
|
||||||
|
<UDashboardNavbar title="Profile" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<div class="mx-auto w-full max-w-3xl space-y-6">
|
||||||
|
<UAlert
|
||||||
|
v-if="flash.success"
|
||||||
|
color="success"
|
||||||
|
icon="i-lucide-check-circle"
|
||||||
|
:title="flash.success"
|
||||||
|
/>
|
||||||
|
<UAlert
|
||||||
|
v-if="twoFactorRequired && !twoFactor.enabled"
|
||||||
|
color="warning"
|
||||||
|
icon="i-lucide-shield-alert"
|
||||||
|
title="Two-factor authentication is required"
|
||||||
|
description="Finish setup before accessing the rest of the application."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<div class="flex flex-col gap-5 sm:flex-row sm:items-center">
|
||||||
|
<UAvatar :alt="user?.full_name" size="3xl" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h1 class="truncate text-xl font-semibold">
|
||||||
|
{{ user?.full_name }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-muted truncate text-sm">
|
||||||
|
@{{ user?.username }}
|
||||||
|
</p>
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
<UBadge color="neutral" variant="subtle" icon="i-lucide-mail">
|
||||||
|
{{ user?.email }}
|
||||||
|
</UBadge>
|
||||||
|
<UBadge
|
||||||
|
:color="user?.email_verified_at ? 'success' : 'warning'"
|
||||||
|
variant="subtle"
|
||||||
|
:icon="user?.email_verified_at ? 'i-lucide-badge-check' : 'i-lucide-circle-alert'"
|
||||||
|
>
|
||||||
|
{{ user?.email_verified_at ? 'Email verified' : 'Email not verified' }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold">
|
||||||
|
Two-factor authentication
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted mt-1 text-sm">
|
||||||
|
Protect your account with a time-based code from an authenticator app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UBadge :color="twoFactor.enabled ? 'success' : twoFactor.pending ? 'warning' : 'neutral'">
|
||||||
|
{{ twoFactor.enabled ? 'Enabled' : twoFactor.pending ? 'Setup pending' : 'Disabled' }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<UAlert
|
||||||
|
v-if="!twoFactor.available"
|
||||||
|
color="neutral"
|
||||||
|
icon="i-lucide-shield-off"
|
||||||
|
title="Two-factor authentication is not available"
|
||||||
|
description="An administrator can enable this feature through the authentication configuration."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form v-else-if="!twoFactor.enabled && !twoFactor.pending" class="space-y-4" @submit.prevent="enable">
|
||||||
|
<UAlert
|
||||||
|
v-if="!twoFactor.requiresPassword"
|
||||||
|
color="info"
|
||||||
|
icon="i-lucide-info"
|
||||||
|
title="Your social login session will authorize enrollment."
|
||||||
|
/>
|
||||||
|
<UFormField
|
||||||
|
v-if="twoFactor.requiresPassword"
|
||||||
|
label="Current password"
|
||||||
|
name="password"
|
||||||
|
:error="enableForm.errors.password"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="enableForm.password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UButton type="submit" :loading="enableForm.processing">
|
||||||
|
Enable two-factor authentication
|
||||||
|
</UButton>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-else-if="twoFactor.pending" class="space-y-6">
|
||||||
|
<div class="grid gap-6 md:grid-cols-[auto_1fr]">
|
||||||
|
<img
|
||||||
|
v-if="twoFactor.qrCodeDataUri"
|
||||||
|
:src="twoFactor.qrCodeDataUri"
|
||||||
|
alt="Two-factor authenticator QR code"
|
||||||
|
class="size-48 rounded-lg border bg-white p-2"
|
||||||
|
>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<h3 class="font-medium">
|
||||||
|
Scan this QR code
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted text-sm">
|
||||||
|
Scan it with any TOTP-compatible authenticator app, then enter the generated code below.
|
||||||
|
</p>
|
||||||
|
<div>
|
||||||
|
<p class="text-muted mb-1 text-xs">
|
||||||
|
Manual setup key
|
||||||
|
</p>
|
||||||
|
<code class="block break-all rounded bg-elevated p-2 text-sm">{{ twoFactor.secretKey }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="space-y-4" @submit.prevent="confirm">
|
||||||
|
<UFormField
|
||||||
|
label="Six-digit authentication code"
|
||||||
|
name="code"
|
||||||
|
:error="confirmForm.errors.code"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-model="confirmForm.code"
|
||||||
|
inputmode="numeric"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
maxlength="6"
|
||||||
|
placeholder="123456"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<UButton type="submit" :loading="confirmForm.processing">
|
||||||
|
Confirm setup
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
type="button"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
:loading="disableForm.processing"
|
||||||
|
@click="disable"
|
||||||
|
>
|
||||||
|
Cancel setup
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<UAlert
|
||||||
|
color="success"
|
||||||
|
variant="subtle"
|
||||||
|
icon="i-lucide-shield-check"
|
||||||
|
title="Your account requires a second factor when signing in."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="space-y-4 border-t pt-6">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium">
|
||||||
|
Recovery codes
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted text-sm">
|
||||||
|
Re-authenticate to reveal your codes or replace them with a new set.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UFormField
|
||||||
|
:label="twoFactor.requiresPassword ? 'Current password' : 'Authentication or recovery code'"
|
||||||
|
:name="twoFactor.requiresPassword ? 'password' : 'code'"
|
||||||
|
:error="recoveryForm.errors.password || recoveryForm.errors.code"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-if="twoFactor.requiresPassword"
|
||||||
|
v-model="recoveryForm.password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<UInput
|
||||||
|
v-else
|
||||||
|
v-model="recoveryForm.code"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<UButton
|
||||||
|
variant="outline"
|
||||||
|
:loading="recoveryForm.processing && !recoveryForm.regenerate"
|
||||||
|
@click="recoveryCodesAction(false)"
|
||||||
|
>
|
||||||
|
Reveal recovery codes
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
color="warning"
|
||||||
|
variant="outline"
|
||||||
|
:loading="recoveryForm.processing && recoveryForm.regenerate"
|
||||||
|
@click="recoveryCodesAction(true)"
|
||||||
|
>
|
||||||
|
Generate new codes
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="space-y-4 border-t pt-6" @submit.prevent="disable">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-medium text-error">
|
||||||
|
Disable two-factor authentication
|
||||||
|
</h3>
|
||||||
|
<p class="text-muted text-sm">
|
||||||
|
This removes the additional protection from your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<UFormField
|
||||||
|
:label="twoFactor.requiresPassword ? 'Current password' : 'Authentication or recovery code'"
|
||||||
|
:name="twoFactor.requiresPassword ? 'password' : 'code'"
|
||||||
|
:error="disableForm.errors.password || disableForm.errors.code"
|
||||||
|
>
|
||||||
|
<UInput
|
||||||
|
v-if="twoFactor.requiresPassword"
|
||||||
|
v-model="disableForm.password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
<UInput
|
||||||
|
v-else
|
||||||
|
v-model="disableForm.code"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
<UButton type="submit" color="error" :loading="disableForm.processing">
|
||||||
|
Disable two-factor authentication
|
||||||
|
</UButton>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<UCard v-if="recoveryCodes.length">
|
||||||
|
<template #header>
|
||||||
|
<div>
|
||||||
|
<h2 class="font-semibold">
|
||||||
|
Save your recovery codes
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted mt-1 text-sm">
|
||||||
|
Store these codes somewhere safe. Each code can be used once.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<code
|
||||||
|
v-for="code in recoveryCodes"
|
||||||
|
:key="code"
|
||||||
|
class="rounded bg-elevated px-3 py-2 text-sm"
|
||||||
|
>{{ code }}</code>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UDashboardPanel>
|
||||||
|
</template>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { DropdownMenuItem } from '@nuxt/ui'
|
||||||
import { useForm } from '@inertiajs/vue3'
|
import { useForm } from '@inertiajs/vue3'
|
||||||
import Logo from '@/components/common/Logo.vue'
|
import Logo from '@/components/common/Logo.vue'
|
||||||
import { useAuth } from '@/composables/useAuth'
|
import { useAuth } from '@/composables/useAuth'
|
||||||
@@ -18,6 +19,24 @@ const sidebarLinks = [
|
|||||||
to: '/dashboard',
|
to: '/dashboard',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const userMenuItems: DropdownMenuItem[][] = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: 'Profile',
|
||||||
|
icon: 'i-lucide-user-round',
|
||||||
|
to: '/profile',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
label: 'Sign out',
|
||||||
|
icon: 'i-lucide-log-out',
|
||||||
|
color: 'error',
|
||||||
|
onSelect: logout,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -28,27 +47,36 @@ const sidebarLinks = [
|
|||||||
<Logo :link-to-home="false" size="sm" />
|
<Logo :link-to-home="false" size="sm" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<UNavigationMenu :items="sidebarLinks" />
|
<UNavigationMenu :items="sidebarLinks" orientation="vertical" />
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex items-center gap-2 px-2">
|
<UDropdownMenu
|
||||||
<UAvatar :alt="user?.full_name" size="sm" />
|
:items="userMenuItems"
|
||||||
<div class="flex-1 truncate text-sm">
|
:content="{ align: 'start', side: 'top', sideOffset: 8 }"
|
||||||
<p class="font-medium truncate">
|
:ui="{ content: 'w-64' }"
|
||||||
{{ user?.full_name }}
|
>
|
||||||
</p>
|
|
||||||
<p class="text-muted truncate text-xs">
|
|
||||||
{{ user?.email }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<UButton
|
<UButton
|
||||||
icon="i-lucide-log-out"
|
color="neutral"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
class="w-full justify-start px-2 py-2"
|
||||||
|
aria-label="Open user menu"
|
||||||
:loading="logoutForm.processing"
|
:loading="logoutForm.processing"
|
||||||
@click="logout"
|
>
|
||||||
/>
|
<UAvatar :alt="user?.full_name" size="sm" />
|
||||||
</div>
|
<span class="min-w-0 flex-1 text-left">
|
||||||
|
<span class="block truncate text-sm font-medium text-highlighted">
|
||||||
|
{{ user?.full_name }}
|
||||||
|
</span>
|
||||||
|
<span class="block truncate text-xs font-normal text-muted">
|
||||||
|
{{ user?.email }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<UIcon
|
||||||
|
name="i-lucide-chevrons-up-down"
|
||||||
|
class="size-4 shrink-0 text-muted"
|
||||||
|
/>
|
||||||
|
</UButton>
|
||||||
|
</UDropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
</UDashboardSidebar>
|
</UDashboardSidebar>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ export interface AuthFeatures {
|
|||||||
password_reset: boolean
|
password_reset: boolean
|
||||||
remember_me: boolean
|
remember_me: boolean
|
||||||
email_verification: boolean
|
email_verification: boolean
|
||||||
|
two_factor: boolean
|
||||||
|
two_factor_required: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthPageConfig {
|
export interface AuthPageConfig {
|
||||||
|
|||||||
@@ -3,4 +3,5 @@ export interface Flash {
|
|||||||
error: string | null
|
error: string | null
|
||||||
message: string | null
|
message: string | null
|
||||||
status: string | null
|
status: string | null
|
||||||
|
recoveryCodes: string[] | null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\EnsureTwoFactorAuthenticationIsEnabled;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/user', function (Request $request) {
|
Route::get('/user', function (Request $request) {
|
||||||
return $request->user();
|
return $request->user();
|
||||||
})->middleware('auth:sanctum');
|
})->middleware(['auth:sanctum', EnsureTwoFactorAuthenticationIsEnabled::class]);
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ use App\Http\Controllers\Auth\LoginController;
|
|||||||
use App\Http\Controllers\Auth\RegisterController;
|
use App\Http\Controllers\Auth\RegisterController;
|
||||||
use App\Http\Controllers\Auth\ResetPasswordController;
|
use App\Http\Controllers\Auth\ResetPasswordController;
|
||||||
use App\Http\Controllers\Auth\SocialiteController;
|
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;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::middleware('guest')->group(function () {
|
Route::middleware('guest')->group(function () {
|
||||||
@@ -29,6 +32,12 @@ Route::middleware('guest')->group(function () {
|
|||||||
// Complete profile after social login (when username is taken)
|
// Complete profile after social login (when username is taken)
|
||||||
Route::get('complete-profile', [CompleteProfileController::class, 'create'])->name('auth.complete-profile');
|
Route::get('complete-profile', [CompleteProfileController::class, 'create'])->name('auth.complete-profile');
|
||||||
Route::post('complete-profile', [CompleteProfileController::class, 'store']);
|
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 () {
|
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', [EmailVerificationController::class, 'notice'])->name('verification.notice');
|
||||||
Route::get('email/verify/{id}/{hash}', [EmailVerificationController::class, 'verify'])->middleware('signed')->name('verification.verify');
|
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::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');
|
||||||
});
|
});
|
||||||
|
|||||||
311
tests/Feature/Auth/TwoFactorAuthenticationTest.php
Normal file
311
tests/Feature/Auth/TwoFactorAuthenticationTest.php
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Database\Seeders\DatabaseSeeder;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
||||||
|
use Laravel\Fortify\Features;
|
||||||
|
use Laravel\Fortify\Fortify;
|
||||||
|
use Laravel\Socialite\Facades\Socialite;
|
||||||
|
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||||
|
use PragmaRX\Google2FA\Google2FA;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
config([
|
||||||
|
'auth-ui.features.two_factor' => 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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user