feat(auth): harden authentication and add configurable two-factor support
This commit is contained in:
@@ -5,6 +5,8 @@ namespace App\Http\Controllers\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\CompleteProfileRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use App\Services\Auth\TwoFactorChallenge;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -33,8 +35,11 @@ class CompleteProfileController extends Controller
|
||||
/**
|
||||
* Handle the complete profile request.
|
||||
*/
|
||||
public function store(CompleteProfileRequest $request): RedirectResponse
|
||||
{
|
||||
public function store(
|
||||
CompleteProfileRequest $request,
|
||||
TwoFactorChallenge $twoFactor,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
$socialiteUser = session('socialite_user');
|
||||
$validated = $request->validated();
|
||||
|
||||
@@ -58,6 +63,16 @@ class CompleteProfileController extends Controller
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->regenerate();
|
||||
$securityEvents->record('login.social_succeeded', $user, $request);
|
||||
|
||||
if ($twoFactor->enrollmentRequiredFor($user)) {
|
||||
$request->session()->put(
|
||||
'url.intended',
|
||||
config('auth-ui.redirects.login', '/')
|
||||
);
|
||||
|
||||
return redirect()->route('two-factor.setup');
|
||||
}
|
||||
|
||||
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\ForgotPasswordRequest;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Inertia\Inertia;
|
||||
@@ -26,16 +27,20 @@ class ForgotPasswordController extends Controller
|
||||
/**
|
||||
* Handle an incoming password reset link request.
|
||||
*/
|
||||
public function store(ForgotPasswordRequest $request): RedirectResponse
|
||||
{
|
||||
$status = Password::sendResetLink(
|
||||
public function store(
|
||||
ForgotPasswordRequest $request,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
Password::sendResetLink(
|
||||
$request->validated()
|
||||
);
|
||||
$securityEvents->record('password_reset.requested', null, $request, [
|
||||
'email_hash' => hash('sha256', strtolower($request->validated('email'))),
|
||||
]);
|
||||
|
||||
if ($status === Password::RESET_LINK_SENT) {
|
||||
return back()->with('status', __($status));
|
||||
}
|
||||
|
||||
return back()->withErrors(['email' => __($status)]);
|
||||
return back()->with(
|
||||
'status',
|
||||
'If an account exists for that email address, a password reset link has been sent.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,17 @@ namespace App\Http\Controllers\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\LoginRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use App\Services\Auth\TwoFactorChallenge;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use RuntimeException;
|
||||
|
||||
class LoginController extends Controller
|
||||
{
|
||||
@@ -26,8 +30,11 @@ class LoginController extends Controller
|
||||
/**
|
||||
* Handle an incoming authentication request.
|
||||
*/
|
||||
public function store(LoginRequest $request, TwoFactorChallenge $twoFactor): RedirectResponse
|
||||
{
|
||||
public function store(
|
||||
LoginRequest $request,
|
||||
TwoFactorChallenge $twoFactor,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
$login = $request->validated('login');
|
||||
$password = $request->validated('password');
|
||||
|
||||
@@ -46,7 +53,19 @@ class LoginController extends Controller
|
||||
? $provider->retrieveByCredentials($credentials)
|
||||
: null;
|
||||
|
||||
if (! $user || ! $provider->validateCredentials($user, $credentials)) {
|
||||
try {
|
||||
$credentialsAreValid = $user
|
||||
? $provider->validateCredentials($user, $credentials)
|
||||
: Hash::check($password, $this->dummyPasswordHash());
|
||||
} catch (RuntimeException) {
|
||||
$credentialsAreValid = false;
|
||||
}
|
||||
|
||||
if (! $user || ! $credentialsAreValid) {
|
||||
$securityEvents->record('login.password_failed', $user, $request, [
|
||||
'login_hash' => hash('sha256', strtolower($login)),
|
||||
]);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
'login' => __('auth.failed'),
|
||||
]);
|
||||
@@ -58,11 +77,23 @@ class LoginController extends Controller
|
||||
}
|
||||
|
||||
if ($twoFactor->requiredFor($user)) {
|
||||
$securityEvents->record('login.password_succeeded_pending_two_factor', $user, $request);
|
||||
|
||||
return $twoFactor->begin($request, $user, $remember);
|
||||
}
|
||||
|
||||
Auth::login($user, $remember);
|
||||
$request->session()->regenerate();
|
||||
$securityEvents->record('login.succeeded', $user, $request);
|
||||
|
||||
if ($twoFactor->enrollmentRequiredFor($user)) {
|
||||
$request->session()->put(
|
||||
'url.intended',
|
||||
config('auth-ui.redirects.login', '/')
|
||||
);
|
||||
|
||||
return redirect()->route('two-factor.setup');
|
||||
}
|
||||
|
||||
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
||||
}
|
||||
@@ -72,6 +103,10 @@ class LoginController extends Controller
|
||||
*/
|
||||
public function destroy(Request $request): RedirectResponse
|
||||
{
|
||||
/** @var User|null $user */
|
||||
$user = $request->user();
|
||||
app(SecurityEventRecorder::class)->record('logout.succeeded', $user, $request);
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$request->session()->invalidate();
|
||||
@@ -79,4 +114,11 @@ class LoginController extends Controller
|
||||
|
||||
return redirect(config('auth-ui.redirects.logout', '/'));
|
||||
}
|
||||
|
||||
private function dummyPasswordHash(): string
|
||||
{
|
||||
static $hash;
|
||||
|
||||
return $hash ??= Hash::make(Str::random(64));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace App\Http\Controllers\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\RegisterRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use App\Services\Auth\TwoFactorChallenge;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@@ -28,8 +30,11 @@ class RegisterController extends Controller
|
||||
/**
|
||||
* Handle an incoming registration request.
|
||||
*/
|
||||
public function store(RegisterRequest $request): RedirectResponse
|
||||
{
|
||||
public function store(
|
||||
RegisterRequest $request,
|
||||
TwoFactorChallenge $twoFactor,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
$validated = $request->validated();
|
||||
|
||||
$user = User::create([
|
||||
@@ -43,11 +48,22 @@ class RegisterController extends Controller
|
||||
event(new Registered($user));
|
||||
|
||||
Auth::login($user);
|
||||
$request->session()->regenerate();
|
||||
$securityEvents->record('registration.completed', $user, $request);
|
||||
|
||||
if (config('auth-ui.features.email_verification')) {
|
||||
return redirect()->route('verification.notice');
|
||||
}
|
||||
|
||||
if ($twoFactor->enrollmentRequiredFor($user)) {
|
||||
$request->session()->put(
|
||||
'url.intended',
|
||||
config('auth-ui.redirects.register', '/')
|
||||
);
|
||||
|
||||
return redirect()->route('two-factor.setup');
|
||||
}
|
||||
|
||||
return redirect(config('auth-ui.redirects.register', '/'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,13 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Auth\ResetPasswordRequest;
|
||||
use App\Models\User;
|
||||
use App\Notifications\PasswordChangedNotification;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use Illuminate\Auth\Events\PasswordReset;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use Illuminate\Support\Str;
|
||||
use Inertia\Inertia;
|
||||
@@ -32,21 +36,39 @@ class ResetPasswordController extends Controller
|
||||
/**
|
||||
* Handle an incoming new password request.
|
||||
*/
|
||||
public function store(ResetPasswordRequest $request): RedirectResponse
|
||||
{
|
||||
public function store(
|
||||
ResetPasswordRequest $request,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
$resetUser = null;
|
||||
$status = Password::reset(
|
||||
$request->validated(),
|
||||
function ($user, string $password): void {
|
||||
function (User $user, string $password) use ($request, &$resetUser): void {
|
||||
$user->forceFill([
|
||||
'password' => $password,
|
||||
'remember_token' => Str::random(60),
|
||||
'auth_session_version' => $user->auth_session_version + 1,
|
||||
])->save();
|
||||
|
||||
if (config('session.driver') === 'database') {
|
||||
DB::table(config('session.table'))
|
||||
->where('user_id', $user->getKey())
|
||||
->delete();
|
||||
}
|
||||
|
||||
$user->notify(new PasswordChangedNotification(
|
||||
(string) $request->ip(),
|
||||
now()->toIso8601String()
|
||||
));
|
||||
$resetUser = $user;
|
||||
|
||||
event(new PasswordReset($user));
|
||||
}
|
||||
);
|
||||
|
||||
if ($status === Password::PASSWORD_RESET) {
|
||||
$securityEvents->record('password_reset.completed', $resetUser, $request);
|
||||
|
||||
return redirect()->route('login')->with('status', __($status));
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Auth;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use App\Services\Auth\TwoFactorChallenge;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -54,7 +55,8 @@ class SocialiteController extends Controller
|
||||
public function callback(
|
||||
Request $request,
|
||||
string $provider,
|
||||
TwoFactorChallenge $twoFactor
|
||||
TwoFactorChallenge $twoFactor,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
if (! in_array($provider, $this->getEnabledProviders())) {
|
||||
abort(404, 'Provider not enabled');
|
||||
@@ -74,7 +76,12 @@ class SocialiteController extends Controller
|
||||
->first();
|
||||
|
||||
if ($socialAccount) {
|
||||
return $this->finishAuthentication($request, $socialAccount->user, $twoFactor);
|
||||
return $this->finishAuthentication(
|
||||
$request,
|
||||
$socialAccount->user,
|
||||
$twoFactor,
|
||||
$securityEvents
|
||||
);
|
||||
}
|
||||
|
||||
// Check if a user with this email already exists
|
||||
@@ -92,7 +99,12 @@ class SocialiteController extends Controller
|
||||
'provider_id' => $socialUser->getId(),
|
||||
]);
|
||||
|
||||
return $this->finishAuthentication($request, $existingUser, $twoFactor);
|
||||
return $this->finishAuthentication(
|
||||
$request,
|
||||
$existingUser,
|
||||
$twoFactor,
|
||||
$securityEvents
|
||||
);
|
||||
}
|
||||
|
||||
// New user — check if registration is enabled
|
||||
@@ -134,20 +146,33 @@ class SocialiteController extends Controller
|
||||
'provider_id' => $socialUser->getId(),
|
||||
]);
|
||||
|
||||
return $this->finishAuthentication($request, $user, $twoFactor);
|
||||
return $this->finishAuthentication($request, $user, $twoFactor, $securityEvents);
|
||||
}
|
||||
|
||||
private function finishAuthentication(
|
||||
Request $request,
|
||||
User $user,
|
||||
TwoFactorChallenge $twoFactor
|
||||
TwoFactorChallenge $twoFactor,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
if ($twoFactor->requiredFor($user)) {
|
||||
$securityEvents->record('login.social_succeeded_pending_two_factor', $user, $request);
|
||||
|
||||
return $twoFactor->begin($request, $user, remember: true);
|
||||
}
|
||||
|
||||
Auth::login($user, remember: true);
|
||||
$request->session()->regenerate();
|
||||
$securityEvents->record('login.social_succeeded', $user, $request);
|
||||
|
||||
if ($twoFactor->enrollmentRequiredFor($user)) {
|
||||
$request->session()->put(
|
||||
'url.intended',
|
||||
config('auth-ui.redirects.login', '/')
|
||||
);
|
||||
|
||||
return redirect()->route('two-factor.setup');
|
||||
}
|
||||
|
||||
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Notifications\TwoFactorFailedNotification;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -19,7 +21,9 @@ class TwoFactorChallengeController extends Controller
|
||||
*/
|
||||
public function create(TwoFactorLoginRequest $request): Response|RedirectResponse
|
||||
{
|
||||
if (! config('auth-ui.features.two_factor') || ! $request->hasChallengedUser()) {
|
||||
if (! config('auth-ui.features.two_factor')
|
||||
|| ! $request->hasChallengedUser()
|
||||
|| $this->challengeHasExpired($request)) {
|
||||
$this->clearChallenge($request);
|
||||
|
||||
return redirect()->route('login');
|
||||
@@ -31,9 +35,13 @@ class TwoFactorChallengeController extends Controller
|
||||
/**
|
||||
* 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()) {
|
||||
public function store(
|
||||
TwoFactorLoginRequest $request,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
if (! config('auth-ui.features.two_factor')
|
||||
|| ! $request->hasChallengedUser()
|
||||
|| $this->challengeHasExpired($request)) {
|
||||
$this->clearChallenge($request);
|
||||
|
||||
return redirect()->route('login');
|
||||
@@ -51,6 +59,12 @@ class TwoFactorChallengeController extends Controller
|
||||
$user->replaceRecoveryCode($recoveryCode);
|
||||
} elseif (! $request->hasValidCode()) {
|
||||
event(new TwoFactorAuthenticationFailed($user));
|
||||
$user->notify(new TwoFactorFailedNotification(
|
||||
(string) $request->ip(),
|
||||
(string) $request->userAgent(),
|
||||
now()->toIso8601String()
|
||||
));
|
||||
$securityEvents->record('login.two_factor_failed', $user, $request);
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
$request->filled('recovery_code') ? 'recovery_code' : 'code' => 'The provided authentication code was invalid.',
|
||||
@@ -61,12 +75,24 @@ class TwoFactorChallengeController extends Controller
|
||||
|
||||
Auth::login($user, $request->remember());
|
||||
$request->session()->regenerate();
|
||||
$securityEvents->record('login.two_factor_succeeded', $user, $request, [
|
||||
'used_recovery_code' => (bool) $recoveryCode,
|
||||
]);
|
||||
|
||||
return redirect()->intended(config('auth-ui.redirects.login', '/dashboard'));
|
||||
}
|
||||
|
||||
private function clearChallenge(TwoFactorLoginRequest $request): void
|
||||
{
|
||||
$request->session()->forget(['login.id', 'login.remember']);
|
||||
$request->session()->forget(['login.id', 'login.remember', 'login.started_at']);
|
||||
}
|
||||
|
||||
private function challengeHasExpired(TwoFactorLoginRequest $request): bool
|
||||
{
|
||||
$startedAt = $request->session()->get('login.started_at');
|
||||
|
||||
return ! is_int($startedAt)
|
||||
|| now()->timestamp - $startedAt
|
||||
> config('auth-ui.security.two_factor_challenge_timeout');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Notifications\TwoFactorSecurityNotification;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -21,19 +23,19 @@ class TwoFactorSettingsController extends Controller
|
||||
*/
|
||||
public function enable(Request $request, EnableTwoFactorAuthentication $enable): RedirectResponse
|
||||
{
|
||||
$this->ensureFeatureIsEnabled();
|
||||
$this->ensureFeatureIsEnabled($request);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->hasEnabledTwoFactorAuthentication()) {
|
||||
return redirect()->route('profile.show');
|
||||
return $this->enrollmentRedirect($request);
|
||||
}
|
||||
|
||||
$this->validatePasswordForPasswordUser($request, $user);
|
||||
$enable($user, force: true);
|
||||
|
||||
return redirect()->route('profile.show')
|
||||
return $this->enrollmentRedirect($request)
|
||||
->with('success', 'Scan the QR code, then enter a code to finish enabling two-factor authentication.');
|
||||
}
|
||||
|
||||
@@ -42,9 +44,10 @@ class TwoFactorSettingsController extends Controller
|
||||
*/
|
||||
public function confirm(
|
||||
Request $request,
|
||||
ConfirmTwoFactorAuthentication $confirm
|
||||
ConfirmTwoFactorAuthentication $confirm,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
$this->ensureFeatureIsEnabled();
|
||||
$this->ensureFeatureIsEnabled($request);
|
||||
|
||||
$validated = $request->validate([
|
||||
'code' => ['required', 'string', 'regex:/^\d{6}$/'],
|
||||
@@ -53,8 +56,18 @@ class TwoFactorSettingsController extends Controller
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$confirm($user, $validated['code']);
|
||||
$user->notify(new TwoFactorSecurityNotification(
|
||||
'Two-factor authentication was enabled for your account.',
|
||||
(string) $request->ip(),
|
||||
now()->toIso8601String()
|
||||
));
|
||||
$securityEvents->record('two_factor.enabled', $user, $request);
|
||||
|
||||
return redirect()->route('profile.show')
|
||||
$redirect = $request->routeIs('two-factor.setup.confirm')
|
||||
? redirect()->route('two-factor.setup')
|
||||
: redirect()->route('profile.show');
|
||||
|
||||
return $redirect
|
||||
->with('success', 'Two-factor authentication is now enabled.')
|
||||
->with('recoveryCodes', $user->fresh()->recoveryCodes());
|
||||
}
|
||||
@@ -64,9 +77,15 @@ class TwoFactorSettingsController extends Controller
|
||||
*/
|
||||
public function disable(
|
||||
Request $request,
|
||||
DisableTwoFactorAuthentication $disable
|
||||
DisableTwoFactorAuthentication $disable,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
$this->ensureFeatureIsEnabled();
|
||||
$this->ensureFeatureIsEnabled($request);
|
||||
abort_if(
|
||||
config('auth-ui.features.two_factor_required'),
|
||||
403,
|
||||
'Two-factor authentication is required for this application.'
|
||||
);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
@@ -76,6 +95,12 @@ class TwoFactorSettingsController extends Controller
|
||||
}
|
||||
|
||||
$disable($user);
|
||||
$user->notify(new TwoFactorSecurityNotification(
|
||||
'Two-factor authentication was disabled for your account.',
|
||||
(string) $request->ip(),
|
||||
now()->toIso8601String()
|
||||
));
|
||||
$securityEvents->record('two_factor.disabled', $user, $request);
|
||||
|
||||
return redirect()->route('profile.show')
|
||||
->with('success', 'Two-factor authentication has been disabled.');
|
||||
@@ -86,9 +111,10 @@ class TwoFactorSettingsController extends Controller
|
||||
*/
|
||||
public function recoveryCodes(
|
||||
Request $request,
|
||||
GenerateNewRecoveryCodes $generate
|
||||
GenerateNewRecoveryCodes $generate,
|
||||
SecurityEventRecorder $securityEvents
|
||||
): RedirectResponse {
|
||||
$this->ensureFeatureIsEnabled();
|
||||
$this->ensureFeatureIsEnabled($request);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
@@ -102,6 +128,14 @@ class TwoFactorSettingsController extends Controller
|
||||
if ($request->boolean('regenerate')) {
|
||||
$generate($user);
|
||||
$user->refresh();
|
||||
$user->notify(new TwoFactorSecurityNotification(
|
||||
'New two-factor authentication recovery codes were generated for your account.',
|
||||
(string) $request->ip(),
|
||||
now()->toIso8601String()
|
||||
));
|
||||
$securityEvents->record('two_factor.recovery_codes_regenerated', $user, $request);
|
||||
} else {
|
||||
$securityEvents->record('two_factor.recovery_codes_revealed', $user, $request);
|
||||
}
|
||||
|
||||
return redirect()->route('profile.show')
|
||||
@@ -111,9 +145,20 @@ class TwoFactorSettingsController extends Controller
|
||||
->with('recoveryCodes', $user->recoveryCodes());
|
||||
}
|
||||
|
||||
private function ensureFeatureIsEnabled(): void
|
||||
private function ensureFeatureIsEnabled(Request $request): void
|
||||
{
|
||||
abort_unless(config('auth-ui.features.two_factor'), 404);
|
||||
|
||||
if ($request->routeIs('two-factor.setup.*')) {
|
||||
abort_unless(config('auth-ui.features.two_factor_required'), 404);
|
||||
}
|
||||
}
|
||||
|
||||
private function enrollmentRedirect(Request $request): RedirectResponse
|
||||
{
|
||||
return $request->routeIs('two-factor.setup.*')
|
||||
? redirect()->route('two-factor.setup')
|
||||
: redirect()->route('profile.show');
|
||||
}
|
||||
|
||||
private function validatePasswordForPasswordUser(Request $request, User $user): void
|
||||
@@ -131,8 +176,6 @@ class TwoFactorSettingsController extends Controller
|
||||
{
|
||||
if ($user->hasPassword()) {
|
||||
$this->validatePasswordForPasswordUser($request, $user);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
|
||||
54
app/Http/Controllers/Auth/TwoFactorSetupController.php
Normal file
54
app/Http/Controllers/Auth/TwoFactorSetupController.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\TwoFactorEnrollmentState;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class TwoFactorSetupController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display the mandatory two-factor enrollment flow.
|
||||
*/
|
||||
public function __invoke(
|
||||
Request $request,
|
||||
TwoFactorEnrollmentState $enrollmentState
|
||||
): Response|RedirectResponse {
|
||||
abort_unless(
|
||||
config('auth-ui.features.two_factor')
|
||||
&& config('auth-ui.features.two_factor_required'),
|
||||
404
|
||||
);
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
|
||||
if ($user->hasEnabledTwoFactorAuthentication()
|
||||
&& ! $request->session()->has('recoveryCodes')) {
|
||||
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
||||
}
|
||||
|
||||
return Inertia::render('Auth/TwoFactorSetup', [
|
||||
'twoFactor' => $enrollmentState->for($user),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Continue to the page that was requested before enrollment.
|
||||
*/
|
||||
public function complete(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless(
|
||||
config('auth-ui.features.two_factor')
|
||||
&& $request->user()?->hasEnabledTwoFactorAuthentication(),
|
||||
404
|
||||
);
|
||||
|
||||
return redirect()->intended(config('auth-ui.redirects.login', '/'));
|
||||
}
|
||||
}
|
||||
@@ -3,38 +3,25 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\TwoFactorEnrollmentState;
|
||||
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
|
||||
{
|
||||
public function __invoke(
|
||||
Request $request,
|
||||
TwoFactorEnrollmentState $enrollmentState
|
||||
): 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,
|
||||
],
|
||||
'twoFactor' => $enrollmentState->for($user),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
58
app/Http/Middleware/AddSecurityHeaders.php
Normal file
58
app/Http/Middleware/AddSecurityHeaders.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class AddSecurityHeaders
|
||||
{
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$response = $next($request);
|
||||
|
||||
$response->headers->set('X-Content-Type-Options', 'nosniff');
|
||||
$response->headers->set('X-Frame-Options', 'DENY');
|
||||
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
|
||||
$response->headers->set(
|
||||
'Permissions-Policy',
|
||||
'camera=(), geolocation=(), microphone=(), payment=(), usb=()'
|
||||
);
|
||||
|
||||
if (app()->isProduction()) {
|
||||
$response->headers->set(
|
||||
'Content-Security-Policy',
|
||||
"default-src 'self'; base-uri 'self'; connect-src 'self'; font-src 'self' data:; form-action 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'"
|
||||
);
|
||||
|
||||
if ($request->isSecure()) {
|
||||
$response->headers->set(
|
||||
'Strict-Transport-Security',
|
||||
'max-age=63072000; includeSubDomains; preload'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->containsAuthenticationSecrets($request)) {
|
||||
$response->headers->set(
|
||||
'Cache-Control',
|
||||
'no-store, no-cache, must-revalidate, private'
|
||||
);
|
||||
$response->headers->set('Pragma', 'no-cache');
|
||||
$response->headers->set('Expires', '0');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
private function containsAuthenticationSecrets(Request $request): bool
|
||||
{
|
||||
return $request->routeIs([
|
||||
'login',
|
||||
'password.*',
|
||||
'two-factor.*',
|
||||
'profile.show',
|
||||
]);
|
||||
}
|
||||
}
|
||||
82
app/Http/Middleware/EnforceAbsoluteSessionTimeout.php
Normal file
82
app/Http/Middleware/EnforceAbsoluteSessionTimeout.php
Normal file
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\SecurityEventRecorder;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnforceAbsoluteSessionTimeout
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SecurityEventRecorder $securityEvents
|
||||
) {}
|
||||
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if (! $request->user() || ! $request->hasSession()) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
/** @var User $user */
|
||||
$user = $request->user();
|
||||
$sessionVersion = $request->session()->get('auth.session_version');
|
||||
$userVersion = (int) $user->auth_session_version;
|
||||
|
||||
if (! is_int($sessionVersion) && $userVersion === 0) {
|
||||
$request->session()->put('auth.session_version', 0);
|
||||
} elseif ($sessionVersion !== $userVersion) {
|
||||
return $this->terminateSession(
|
||||
$request,
|
||||
$user,
|
||||
'session.revoked',
|
||||
'Your session is no longer valid. Please sign in again.'
|
||||
);
|
||||
}
|
||||
|
||||
$expiresAt = $request->session()->get('auth.absolute_expires_at');
|
||||
|
||||
if (! is_int($expiresAt)) {
|
||||
$request->session()->put(
|
||||
'auth.absolute_expires_at',
|
||||
now()->timestamp + config('auth-ui.security.absolute_session_lifetime')
|
||||
);
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (now()->timestamp < $expiresAt) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
return $this->terminateSession(
|
||||
$request,
|
||||
$user,
|
||||
'session.absolute_timeout',
|
||||
'Your session has expired. Please sign in again.'
|
||||
);
|
||||
}
|
||||
|
||||
private function terminateSession(
|
||||
Request $request,
|
||||
User $user,
|
||||
string $event,
|
||||
string $message
|
||||
): Response {
|
||||
$this->securityEvents->record($event, $user, $request);
|
||||
|
||||
Auth::logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json(['message' => $message], 401);
|
||||
}
|
||||
|
||||
return redirect()->route('login')
|
||||
->with('error', $message);
|
||||
}
|
||||
}
|
||||
@@ -24,18 +24,22 @@ class EnsureTwoFactorAuthenticationIsEnabled
|
||||
if ($request->expectsJson()) {
|
||||
return response()->json([
|
||||
'message' => 'Two-factor authentication setup is required.',
|
||||
'setup_url' => route('profile.show'),
|
||||
'setup_url' => route('two-factor.setup'),
|
||||
], 403);
|
||||
}
|
||||
|
||||
return redirect()->route('profile.show')
|
||||
if ($request->isMethod('GET')) {
|
||||
$request->session()->put('url.intended', $request->fullUrl());
|
||||
}
|
||||
|
||||
return redirect()->route('two-factor.setup')
|
||||
->with('error', 'Two-factor authentication is required before you can continue.');
|
||||
}
|
||||
|
||||
private function isEnrollmentRoute(Request $request): bool
|
||||
{
|
||||
return $request->routeIs([
|
||||
'profile.*',
|
||||
'two-factor.setup*',
|
||||
'logout',
|
||||
'verification.*',
|
||||
]);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Notifications\QueuedResetPasswordNotification;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
@@ -61,6 +62,7 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'two_factor_confirmed_at' => 'datetime',
|
||||
'auth_session_version' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -82,6 +84,15 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
return $this->password !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue password reset mail so the request does not reveal account
|
||||
* existence through mail-delivery timing.
|
||||
*/
|
||||
public function sendPasswordResetNotification($token): void
|
||||
{
|
||||
$this->notify(new QueuedResetPasswordNotification($token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's social accounts.
|
||||
*/
|
||||
|
||||
38
app/Notifications/PasswordChangedNotification.php
Normal file
38
app/Notifications/PasswordChangedNotification.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class PasswordChangedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $ipAddress,
|
||||
public readonly string $occurredAt
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('Your password was changed')
|
||||
->greeting('Hello '.$notifiable->first_name.',')
|
||||
->line('The password for your account was changed.')
|
||||
->line("Time: {$this->occurredAt}")
|
||||
->line("IP address: {$this->ipAddress}")
|
||||
->line('All other signed-in sessions have been ended.')
|
||||
->line('If you did not make this change, contact support immediately.');
|
||||
}
|
||||
}
|
||||
12
app/Notifications/QueuedResetPasswordNotification.php
Normal file
12
app/Notifications/QueuedResetPasswordNotification.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Auth\Notifications\ResetPassword;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
|
||||
class QueuedResetPasswordNotification extends ResetPassword implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
}
|
||||
40
app/Notifications/TwoFactorFailedNotification.php
Normal file
40
app/Notifications/TwoFactorFailedNotification.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class TwoFactorFailedNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $ipAddress,
|
||||
public readonly string $userAgent,
|
||||
public readonly string $occurredAt
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('Failed two-factor authentication attempt')
|
||||
->greeting('Hello '.$notifiable->first_name.',')
|
||||
->line('A correct password was followed by a failed two-factor authentication attempt.')
|
||||
->line("Time: {$this->occurredAt}")
|
||||
->line("IP address: {$this->ipAddress}")
|
||||
->line("Browser: {$this->userAgent}")
|
||||
->line('If this was not you, change your password immediately.')
|
||||
->action('Review account security', url('/profile'));
|
||||
}
|
||||
}
|
||||
39
app/Notifications/TwoFactorSecurityNotification.php
Normal file
39
app/Notifications/TwoFactorSecurityNotification.php
Normal file
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class TwoFactorSecurityNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(
|
||||
public readonly string $activity,
|
||||
public readonly string $ipAddress,
|
||||
public readonly string $occurredAt
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)
|
||||
->subject('Two-factor authentication security notice')
|
||||
->greeting('Hello '.$notifiable->first_name.',')
|
||||
->line($this->activity)
|
||||
->line("Time: {$this->occurredAt}")
|
||||
->line("IP address: {$this->ipAddress}")
|
||||
->line('If you did not perform this action, change your password and contact support immediately.')
|
||||
->action('Review account security', url('/profile'));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,14 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use Illuminate\Auth\Events\Login;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
@@ -22,6 +29,73 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
//
|
||||
Event::listen(Login::class, function (Login $event): void {
|
||||
if (request()->hasSession()) {
|
||||
request()->session()->put([
|
||||
'auth.session_version' => (int) $event->user->auth_session_version,
|
||||
'auth.absolute_expires_at' => now()->timestamp
|
||||
+ config('auth-ui.security.absolute_session_lifetime'),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
Password::defaults(function (): Password {
|
||||
return Password::min(15)->max(128);
|
||||
});
|
||||
|
||||
RateLimiter::for('auth.login', function (Request $request): array {
|
||||
$login = Str::lower((string) $request->input('login'));
|
||||
|
||||
return [
|
||||
Limit::perMinute(20)->by('login-ip:'.$request->ip()),
|
||||
Limit::perMinutes(10, 10)->by('login-account:'.hash('sha256', $login)),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('auth.two-factor', function (Request $request): array {
|
||||
$userKey = $request->user()?->getAuthIdentifier()
|
||||
?? $request->session()->get('login.id')
|
||||
?? 'guest';
|
||||
|
||||
return [
|
||||
Limit::perMinute(20)->by('two-factor-ip:'.$request->ip()),
|
||||
Limit::perMinutes(5, 5)->by('two-factor-account:'.$userKey),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('auth.password-email', function (Request $request): array {
|
||||
$email = Str::lower((string) $request->input('email'));
|
||||
|
||||
return [
|
||||
Limit::perMinutes(15, 20)->by('password-email-ip:'.$request->ip()),
|
||||
Limit::perMinutes(15, 3)->by('password-email-account:'.hash('sha256', $email)),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('auth.password-reset', function (Request $request): array {
|
||||
$email = Str::lower((string) $request->input('email'));
|
||||
|
||||
return [
|
||||
Limit::perMinutes(15, 20)->by('password-reset-ip:'.$request->ip()),
|
||||
Limit::perMinutes(15, 5)->by('password-reset-account:'.hash('sha256', $email)),
|
||||
];
|
||||
});
|
||||
|
||||
RateLimiter::for('auth.register', fn (Request $request): array => [
|
||||
Limit::perHour(20)->by('register-ip:'.$request->ip()),
|
||||
]);
|
||||
|
||||
RateLimiter::for('auth.social', fn (Request $request): array => [
|
||||
Limit::perMinute(20)->by('social-ip:'.$request->ip()),
|
||||
]);
|
||||
|
||||
RateLimiter::for('auth.account', function (Request $request): array {
|
||||
$userKey = $request->user()?->getAuthIdentifier() ?? 'guest';
|
||||
|
||||
return [
|
||||
Limit::perMinute(20)->by('account-action-ip:'.$request->ip()),
|
||||
Limit::perMinutes(5, 6)->by('account-action-user:'.$userKey),
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
34
app/Services/Auth/SecurityEventRecorder.php
Normal file
34
app/Services/Auth/SecurityEventRecorder.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SecurityEventRecorder
|
||||
{
|
||||
/**
|
||||
* Record a security event without credentials, tokens, or session IDs.
|
||||
*
|
||||
* @param array<string, bool|int|string|null> $context
|
||||
*/
|
||||
public function record(
|
||||
string $event,
|
||||
?User $user,
|
||||
Request $request,
|
||||
array $context = []
|
||||
): void {
|
||||
Log::channel(config('auth-ui.security.log_channel'))->notice(
|
||||
'Authentication security event',
|
||||
[
|
||||
'event' => $event,
|
||||
'user_id' => $user?->getKey(),
|
||||
'ip_address' => $request->ip(),
|
||||
'user_agent' => Str::limit((string) $request->userAgent(), 255, ''),
|
||||
...$context,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,16 @@ use Illuminate\Http\Request;
|
||||
|
||||
class TwoFactorChallenge
|
||||
{
|
||||
/**
|
||||
* Determine whether the user must enroll in two-factor authentication.
|
||||
*/
|
||||
public function enrollmentRequiredFor(User $user): bool
|
||||
{
|
||||
return (bool) config('auth-ui.features.two_factor')
|
||||
&& (bool) config('auth-ui.features.two_factor_required')
|
||||
&& ! $user->hasEnabledTwoFactorAuthentication();
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user must complete a two-factor challenge.
|
||||
*/
|
||||
@@ -25,6 +35,7 @@ class TwoFactorChallenge
|
||||
$request->session()->put([
|
||||
'login.id' => $user->getKey(),
|
||||
'login.remember' => $remember,
|
||||
'login.started_at' => now()->timestamp,
|
||||
]);
|
||||
$request->session()->regenerate();
|
||||
|
||||
|
||||
43
app/Services/Auth/TwoFactorEnrollmentState.php
Normal file
43
app/Services/Auth/TwoFactorEnrollmentState.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class TwoFactorEnrollmentState
|
||||
{
|
||||
/**
|
||||
* Build the two-factor state shared by setup and profile screens.
|
||||
*
|
||||
* @return array{
|
||||
* available: bool,
|
||||
* enabled: bool,
|
||||
* pending: bool,
|
||||
* requiresPassword: bool,
|
||||
* qrCodeDataUri: string|null,
|
||||
* secretKey: string|null
|
||||
* }
|
||||
*/
|
||||
public function for(User $user): array
|
||||
{
|
||||
$available = (bool) config('auth-ui.features.two_factor');
|
||||
$enabled = $available && $user->hasEnabledTwoFactorAuthentication();
|
||||
$pending = $available
|
||||
&& filled($user->two_factor_secret)
|
||||
&& ! $enabled;
|
||||
|
||||
return [
|
||||
'available' => $available,
|
||||
'enabled' => $enabled,
|
||||
'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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user