diff --git a/config/users.php b/config/users.php index bfd749b8ffa..da2d293b3e2 100644 --- a/config/users.php +++ b/config/users.php @@ -211,6 +211,22 @@ 'two_factor_enforced_roles' => [], + /* + |-------------------------------------------------------------------------- + | Two-Factor Authentication URLs + |-------------------------------------------------------------------------- + | + | When users log in to the frontend and need to verify a two-factor code + | or set up two-factor authentication, they will be redirected to these + | URLs. Leave null to use the built-in pages. Control panel flows are + | unaffected and always use their own pages. + | + */ + + 'two_factor_challenge_url' => null, + + 'two_factor_setup_url' => null, + /* |-------------------------------------------------------------------------- | Default Sorting diff --git a/resources/js/components/two-factor/Setup.vue b/resources/js/components/two-factor/Setup.vue index ff745b5b329..0526ad82559 100644 --- a/resources/js/components/two-factor/Setup.vue +++ b/resources/js/components/two-factor/Setup.vue @@ -25,7 +25,7 @@ onMounted(() => getSetupCode()); function getSetupCode() { loading.value = true; - axios.get(props.enableUrl).then((response) => { + axios.post(props.enableUrl).then((response) => { qrCode.value = response.data.qr; secretKey.value = response.data.secret_key; confirmUrl.value = response.data.confirm_url; diff --git a/routes/cp.php b/routes/cp.php index 6b23b6b9873..4c08207c7d3 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -350,7 +350,7 @@ Route::patch('users/{user}/password', [PasswordController::class, 'update'])->name('users.password.update'); if (TwoFactor::enabled()) { Route::withoutMiddleware(RedirectIfTwoFactorSetupIncomplete::class)->middleware(RequireElevatedSession::class)->group(function () { - Route::get('two-factor/enable', [TwoFactorAuthenticationController::class, 'enable'])->name('users.two-factor.enable'); + Route::post('two-factor/enable', [TwoFactorAuthenticationController::class, 'enable'])->name('users.two-factor.enable'); Route::delete('two-factor', [TwoFactorAuthenticationController::class, 'disable'])->name('users.two-factor.disable'); Route::post('two-factor/confirm', [TwoFactorAuthenticationController::class, 'confirm'])->name('users.two-factor.confirm'); Route::get('two-factor/recovery-codes', [TwoFactorRecoveryCodesController::class, 'show'])->name('users.two-factor.recovery-codes.show'); diff --git a/routes/web.php b/routes/web.php index 4eab84115c2..92352e57dfe 100755 --- a/routes/web.php +++ b/routes/web.php @@ -28,6 +28,7 @@ use Statamic\Http\Middleware\CP\AuthGuard as CPAuthGuard; use Statamic\Http\Middleware\CP\HandleInertiaRequests; use Statamic\Http\Middleware\RedirectIfTwoFactorSetupIncomplete; +use Statamic\Http\Middleware\RequireElevatedSession; use Statamic\Statamic; use Statamic\StaticCaching\NoCache\CsrfTokenController; use Statamic\StaticCaching\NoCache\NoCacheController; @@ -81,9 +82,10 @@ Route::get('two-factor-challenge', [TwoFactorChallengeController::class, 'index'])->name('two-factor-challenge'); Route::post('two-factor-challenge', [TwoFactorChallengeController::class, 'store']); - Route::withoutMiddleware(RedirectIfTwoFactorSetupIncomplete::class)->group(function () { - Route::get('two-factor/enable', [TwoFactorAuthenticationController::class, 'enable'])->name('users.two-factor.enable'); + Route::middleware(['auth', RequireElevatedSession::class])->withoutMiddleware(RedirectIfTwoFactorSetupIncomplete::class)->group(function () { + Route::post('two-factor/enable', [TwoFactorAuthenticationController::class, 'enable'])->name('users.two-factor.enable'); Route::post('two-factor/confirm', [TwoFactorAuthenticationController::class, 'confirm'])->name('users.two-factor.confirm'); + Route::delete('two-factor/disable', [TwoFactorAuthenticationController::class, 'disable'])->name('users.two-factor.disable'); Route::get('two-factor/recovery-codes', [TwoFactorRecoveryCodesController::class, 'show'])->name('users.two-factor.recovery-codes.show'); Route::post('two-factor/recovery-codes', [TwoFactorRecoveryCodesController::class, 'store'])->name('users.two-factor.recovery-codes.generate'); Route::get('two-factor/recovery-codes/download', [TwoFactorRecoveryCodesController::class, 'download'])->name('users.two-factor.recovery-codes.download'); diff --git a/src/Auth/UserTags.php b/src/Auth/UserTags.php index 3d8cda46c1e..6dfa895f90d 100644 --- a/src/Auth/UserTags.php +++ b/src/Auth/UserTags.php @@ -4,6 +4,7 @@ use Illuminate\Support\Collection; use Statamic\Contracts\Auth\Role; +use Statamic\Facades\TwoFactor; use Statamic\Facades\URL; use Statamic\Facades\User; use Statamic\Fields\Field; @@ -795,6 +796,318 @@ public function eventUrl($url, $relative = false) ); } + /** + * Output a boolean of whether two-factor auth is enabled for the user. + * + * Maps to {{ user:two_factor_enabled }} + */ + public function twoFactorEnabled(): bool + { + return (bool) User::current()?->hasEnabledTwoFactorAuthentication(); + } + + /** + * Output a two-factor challenge form for login verification. + * + * Maps to {{ user:two_factor_challenge_form }} + * + * @return string|array + */ + public function twoFactorChallengeForm() + { + if ( + ! TwoFactor::enabled() + || session()->missing('login.id') + ) { + return; + } + + $params = []; + + $data = $this->getFormSession(); + + $knownParams = ['redirect', 'error_redirect', 'allow_request_redirect']; + + $method = 'POST'; + $action = route('statamic.two-factor-challenge'); + + if ($redirect = $this->getRedirectUrl()) { + $params['redirect'] = $this->parseRedirect($redirect); + } + + if ($errorRedirect = $this->getErrorRedirectUrl()) { + $params['error_redirect'] = $this->parseRedirect($errorRedirect); + } + + if (! $this->canParseContents()) { + return array_merge([ + 'attrs' => $this->formAttrs($action, $method, $knownParams), + 'params' => $this->formMetaPrefix($this->formParams($method, $params)), + ], $data); + } + + $html = $this->formOpen($action, $method, $knownParams); + + $html .= $this->formMetaFields($params); + + $html .= $this->parse($data); + + $html .= $this->formClose(); + + return $html; + } + + /** + * Output a two-factor enable form. + * + * Maps to {{ user:two_factor_enable_form }} + * + * @return string|array + */ + public function twoFactorEnableForm() + { + $user = User::current(); + + if ( + ! TwoFactor::enabled() + || ! $user + || $user->hasEnabledTwoFactorAuthentication() + ) { + return; + } + + $params = []; + + $data = $this->getFormSession('user.two_factor_enable'); + + $knownParams = ['redirect', 'allow_request_redirect']; + + $method = 'POST'; + $action = route('statamic.users.two-factor.enable'); + + if ($redirect = $this->getRedirectUrl()) { + $params['redirect'] = $this->parseRedirect($redirect); + } + + if (! $this->canParseContents()) { + return array_merge([ + 'attrs' => $this->formAttrs($action, $method, $knownParams), + 'params' => $this->formMetaPrefix($this->formParams($method, $params)), + ], $data); + } + + $html = $this->formOpen($action, $method, $knownParams); + + $html .= $this->formMetaFields($params); + + $html .= $this->parse($data); + + $html .= $this->formClose(); + + return $html; + } + + /** + * Output a two-factor setup form. + * + * Maps to {{ user:two_factor_setup_form }} + * + * @return string|array + */ + public function twoFactorSetupForm() + { + $user = User::current(); + + if ( + ! TwoFactor::enabled() + || ! $user + || $user->hasEnabledTwoFactorAuthentication() + || empty($user->two_factor_secret) + ) { + return; + } + + $params = []; + + $data = $this->getFormSession('user.two_factor_setup'); + + $data['qr_code'] = $user->twoFactorQrCodeSvg(); + $data['qr_code_url'] = 'data:image/svg+xml;base64,'.base64_encode($user->twoFactorQrCodeSvg()); + $data['secret_key'] = $user->twoFactorSecretKey(); + + $knownParams = ['redirect', 'error_redirect', 'allow_request_redirect']; + + $method = 'POST'; + $action = route('statamic.users.two-factor.confirm'); + + if ($redirect = $this->getRedirectUrl()) { + $params['redirect'] = $this->parseRedirect($redirect); + } + + if ($errorRedirect = $this->getErrorRedirectUrl()) { + $params['error_redirect'] = $this->parseRedirect($errorRedirect); + } + + if (! $this->canParseContents()) { + return array_merge([ + 'attrs' => $this->formAttrs($action, $method, $knownParams), + 'params' => $this->formMetaPrefix($this->formParams($method, $params)), + ], $data); + } + + $html = $this->formOpen($action, $method, $knownParams); + + $html .= $this->formMetaFields($params); + + $html .= $this->parse($data); + + $html .= $this->formClose(); + + return $html; + } + + /** + * Output the user's two-factor recovery codes. + * + * Maps to {{ user:two_factor_recovery_codes }} + * + * @return array|string + */ + public function twoFactorRecoveryCodes() + { + $user = User::current(); + + if ( + ! TwoFactor::enabled() + || ! $user?->hasEnabledTwoFactorAuthentication() + ) { + return $this->parser ? null : []; + } + + $codes = collect($user->twoFactorRecoveryCodes())->map(fn ($code) => ['code' => $code]); + + return $this->parser ? $this->parseLoop($codes) : $codes->all(); + } + + /** + * Outputs a URL to download two-factor recovery codes. + * + * Maps to {{ user:two_factor_recovery_codes_download_url }} + * + * @return string + */ + public function twoFactorRecoveryCodesDownloadUrl() + { + $user = User::current(); + + if ( + ! TwoFactor::enabled() + || ! $user?->hasEnabledTwoFactorAuthentication() + ) { + return; + } + + return route('statamic.users.two-factor.recovery-codes.download'); + } + + /** + * Output a form to regenerate two-factor recovery codes. + * + * Maps to {{ user:reset_two_factor_recovery_codes_form }} + * + * @return string|array + */ + public function resetTwoFactorRecoveryCodesForm() + { + $user = User::current(); + + if ( + ! TwoFactor::enabled() + || ! $user?->hasEnabledTwoFactorAuthentication() + ) { + return; + } + + $params = []; + + $data = $this->getFormSession('user.two_factor_reset_recovery_codes'); + + $knownParams = ['redirect', 'allow_request_redirect']; + + $method = 'POST'; + $action = route('statamic.users.two-factor.recovery-codes.generate'); + + if ($redirect = $this->getRedirectUrl()) { + $params['redirect'] = $this->parseRedirect($redirect); + } + + if (! $this->canParseContents()) { + return array_merge([ + 'attrs' => $this->formAttrs($action, $method, $knownParams), + 'params' => $this->formMetaPrefix($this->formParams($method, $params)), + ], $data); + } + + $html = $this->formOpen($action, $method, $knownParams); + + $html .= $this->formMetaFields($params); + + $html .= $this->parse($data); + + $html .= $this->formClose(); + + return $html; + } + + /** + * Output a form to disable two-factor authentication. + * + * Maps to {{ user:disable_two_factor_form }} + * + * @return string|array + */ + public function disableTwoFactorForm() + { + $user = User::current(); + + if ( + ! TwoFactor::enabled() + || ! $user?->hasEnabledTwoFactorAuthentication() + ) { + return; + } + + $params = []; + + $data = $this->getFormSession('user.two_factor_disable'); + + $knownParams = ['redirect', 'allow_request_redirect']; + + $method = 'DELETE'; + $action = route('statamic.users.two-factor.disable'); + + if ($redirect = $this->getRedirectUrl()) { + $params['redirect'] = $this->parseRedirect($redirect); + } + + if (! $this->canParseContents()) { + return array_merge([ + 'attrs' => $this->formAttrs($action, $method, $knownParams), + 'params' => $this->formMetaPrefix($this->formParams($method, $params)), + ], $data); + } + + $html = $this->formOpen($action, $method, $knownParams); + + $html .= $this->formMetaFields($params); + + $html .= $this->parse($data); + + $html .= $this->formClose(); + + return $html; + } + /** * Get the redirect URL. * diff --git a/src/Http/Controllers/CP/Auth/TwoFactorChallengeController.php b/src/Http/Controllers/CP/Auth/TwoFactorChallengeController.php index b5b71aee92d..e24e80ee468 100644 --- a/src/Http/Controllers/CP/Auth/TwoFactorChallengeController.php +++ b/src/Http/Controllers/CP/Auth/TwoFactorChallengeController.php @@ -2,7 +2,9 @@ namespace Statamic\Http\Controllers\CP\Auth; +use Illuminate\Http\Request; use Statamic\Http\Controllers\TwoFactorChallengeController as Controller; +use Statamic\Http\Middleware\CP\HandleInertiaRequests; use Statamic\Http\Middleware\CP\RedirectIfAuthorized; use Statamic\Support\Str; @@ -11,6 +13,7 @@ class TwoFactorChallengeController extends Controller public function __construct() { $this->middleware('throttle:two-factor'); + $this->middleware(HandleInertiaRequests::class)->except('store'); $this->middleware(RedirectIfAuthorized::class); } @@ -19,12 +22,17 @@ protected function formAction() return cp_route('two-factor-challenge'); } - protected function redirectPath() + protected function redirectPath(Request $request) { $cp = cp_route('index'); - $referer = request('referer'); + $referer = $request->input('referer'); $referredFromCp = Str::startsWith($referer, $cp) && ! Str::startsWith($referer, $cp.'/auth/'); return $referredFromCp ? $referer : $cp; } + + protected function failedRedirectPath() + { + return cp_route('two-factor-challenge'); + } } diff --git a/src/Http/Controllers/Concerns/HandlesLogins.php b/src/Http/Controllers/Concerns/HandlesLogins.php index 9132393e051..28a1ad75836 100644 --- a/src/Http/Controllers/Concerns/HandlesLogins.php +++ b/src/Http/Controllers/Concerns/HandlesLogins.php @@ -9,6 +9,7 @@ use Statamic\Auth\ThrottlesLogins; use Statamic\Contracts\Auth\User; use Statamic\Events\TwoFactorAuthenticationChallenged; +use Statamic\Facades\URL; trait HandlesLogins { @@ -59,10 +60,20 @@ protected function throwFailedAuthenticationException(Request $request) protected function twoFactorChallengeResponse(Request $request, User $user) { - $request->session()->put([ + $request->session()->forget('login.redirect'); + + $session = [ 'login.id' => $user->getKey(), 'login.remember' => $request->boolean('remember'), - ]); + ]; + + if ($redirect = $request->input('_redirect')) { + if (! URL::isExternalToApplication($redirect)) { + $session['login.redirect'] = $redirect; + } + } + + $request->session()->put($session); TwoFactorAuthenticationChallenged::dispatch($user); diff --git a/src/Http/Controllers/TwoFactorChallengeController.php b/src/Http/Controllers/TwoFactorChallengeController.php index 37487189f69..a40f44c9e09 100644 --- a/src/Http/Controllers/TwoFactorChallengeController.php +++ b/src/Http/Controllers/TwoFactorChallengeController.php @@ -18,7 +18,7 @@ class TwoFactorChallengeController extends Controller public function __construct(Request $request) { $this->middleware('throttle:two-factor'); - $this->middleware(HandleInertiaRequests::class); + $this->middleware(HandleInertiaRequests::class)->except('store'); $this->middleware(RedirectIfAuthenticated::class); } @@ -45,20 +45,37 @@ public function store(TwoFactorChallengeRequest $request) } elseif (! $request->hasValidCode()) { TwoFactorAuthenticationFailed::dispatch($user); - return $request->sendFailedTwoFactorChallengeResponse(); + return $this->sendFailedResponse($request); } ValidTwoFactorAuthenticationCodeProvided::dispatch($user); Auth::guard()->login($user, $request->remember()); + $request->session()->forget(['login.id', 'login.remember']); + $request->session()->elevate(); $request->session()->regenerate(); - return $request->expectsJson() - ? response('Authenticated') - : redirect()->intended($this->redirectPath()); + if ($request->inertia() || $request->expectsJson()) { + return $request->inertia() + ? Inertia::location($this->redirectPath($request)) + : response('Authenticated'); + } + + return redirect()->intended($this->redirectPath($request)); + } + + protected function sendFailedResponse(TwoFactorChallengeRequest $request) + { + if ($errorRedirect = $request->input('_error_redirect')) { + if (! URL::isExternalToApplication($errorRedirect)) { + return $request->sendFailedTwoFactorChallengeResponse($errorRedirect); + } + } + + return $request->sendFailedTwoFactorChallengeResponse($this->failedRedirectPath()); } protected function formAction() @@ -66,12 +83,25 @@ protected function formAction() return route('statamic.two-factor-challenge'); } - protected function redirectPath() + protected function redirectPath(Request $request) { - $redirect = request('redirect'); + if ($redirect = $request->input('_redirect')) { + if (! URL::isExternalToApplication($redirect)) { + return $redirect; + } + } + + if ($redirect = $request->session()->pull('login.redirect')) { + if (! URL::isExternalToApplication($redirect)) { + return $redirect; + } + } - return $redirect && ! URL::isExternalToApplication($redirect) - ? $redirect - : route('statamic.site'); + return route('statamic.site'); + } + + protected function failedRedirectPath() + { + return config('statamic.users.two_factor_challenge_url') ?? route('statamic.two-factor-challenge'); } } diff --git a/src/Http/Controllers/TwoFactorSetupController.php b/src/Http/Controllers/TwoFactorSetupController.php index 6e26410457d..97ab1bec4ba 100644 --- a/src/Http/Controllers/TwoFactorSetupController.php +++ b/src/Http/Controllers/TwoFactorSetupController.php @@ -19,24 +19,33 @@ public function __construct(Request $request) public function __invoke(Request $request) { $user = User::fromUser($request->user()); + $redirect = $this->redirectPath(); if ($user->hasEnabledTwoFactorAuthentication()) { - return redirect($this->redirectPath()); + return redirect($redirect); } return Inertia::render('auth/two-factor/Setup', [ 'routes' => $this->routes($user), - 'redirect' => $this->redirectPath(), + 'redirect' => $redirect, ]); } protected function redirectPath() { - $redirect = request('redirect'); + if ($redirect = request('redirect')) { + if (! URL::isExternalToApplication($redirect)) { + return $redirect; + } + } + + if ($redirect = session()->get('login.redirect')) { + if (! URL::isExternalToApplication($redirect)) { + return $redirect; + } + } - return $redirect && ! URL::isExternalToApplication($redirect) - ? $redirect - : route('statamic.site'); + return route('statamic.site'); } protected function routes($user): array diff --git a/src/Http/Controllers/User/LoginController.php b/src/Http/Controllers/User/LoginController.php index a0e1394dfe4..910e0706b2e 100644 --- a/src/Http/Controllers/User/LoginController.php +++ b/src/Http/Controllers/User/LoginController.php @@ -29,11 +29,21 @@ public function login(UserLoginRequest $request) return $this->twoFactorChallengeResponse($request, $user); } - $this->authenticate($request, $user); + $redirect = $request->input('_redirect'); + $redirect = $redirect && ! URL::isExternalToApplication($redirect) ? $redirect : null; + + // If 2FA setup is required, stash the redirect so the setup flow can use it after completion. + if (TwoFactor::enabled() && $user->isTwoFactorAuthenticationRequired() && ! $user->hasEnabledTwoFactorAuthentication()) { + $request->session()->forget('login.redirect'); + + if ($redirect) { + $request->session()->put('login.redirect', $redirect); + } + } - $redirect = $request->input('_redirect', '/'); + $this->authenticate($request, $user); - return redirect(URL::isExternalToApplication($redirect) ? '/' : $redirect)->withSuccess(__('Login successful.')); + return redirect($redirect ?? route('statamic.site'))->withSuccess(__('Login successful.')); } private function checkPasskeyEnforcement(Request $request) @@ -63,7 +73,7 @@ private function checkPasskeyEnforcement(Request $request) protected function twoFactorChallengeRedirect(): string { - return route('statamic.two-factor-challenge'); + return config('statamic.users.two_factor_challenge_url') ?? route('statamic.two-factor-challenge'); } /** @@ -103,9 +113,13 @@ public function logout() { Auth::logout(); - $redirect = request()->get('redirect', '/'); + $redirect = request()->get('redirect'); + + $url = $redirect && ! URL::isExternalToApplication($redirect) + ? $redirect + : route('statamic.site'); - return redirect(URL::isExternalToApplication($redirect) ? '/' : $redirect); + return redirect($url); } protected function username() diff --git a/src/Http/Controllers/User/TwoFactorAuthenticationController.php b/src/Http/Controllers/User/TwoFactorAuthenticationController.php index 25a69b8ab8a..e50d442b122 100644 --- a/src/Http/Controllers/User/TwoFactorAuthenticationController.php +++ b/src/Http/Controllers/User/TwoFactorAuthenticationController.php @@ -3,9 +3,11 @@ namespace Statamic\Http\Controllers\User; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; use Statamic\Auth\TwoFactor\ConfirmTwoFactorAuthentication; use Statamic\Auth\TwoFactor\DisableTwoFactorAuthentication; use Statamic\Auth\TwoFactor\EnableTwoFactorAuthentication; +use Statamic\Facades\URL; use Statamic\Facades\User; use Statamic\Http\Controllers\CP\CpController; @@ -19,25 +21,48 @@ public function enable(Request $request, EnableTwoFactorAuthentication $enable) abort(403); } - // We don't want to regenerate the QR code when there's an error in the session. - if (! session()->get('errors')?->has('code')) { + if (empty($user->two_factor_secret)) { $enable($user); } - return [ - 'qr' => $user->twoFactorQrCodeSvg(), - 'secret_key' => $user->twoFactorSecretKey(), - 'confirm_url' => $this->confirmUrl(), - ]; + if ($request->expectsJson()) { + return [ + 'qr' => $user->twoFactorQrCodeSvg(), + 'secret_key' => $user->twoFactorSecretKey(), + 'confirm_url' => $this->confirmUrl(), + ]; + } + + if (($redirect = $request->input('_redirect')) && ! URL::isExternalToApplication($redirect)) { + return redirect($redirect); + } + + if ($setupUrl = config('statamic.users.two_factor_setup_url')) { + return redirect($setupUrl); + } + + return back(); } public function confirm(Request $request, ConfirmTwoFactorAuthentication $confirm) { $user = User::current(); - $confirm($user, $request->input('code')); + try { + $confirm($user, $request->input('code')); + } catch (ValidationException $e) { + if ($request->expectsJson()) { + throw $e; + } - return []; + return $this->handleFormValidationError($request, $e, 'user.two_factor_setup'); + } + + if ($request->expectsJson()) { + return []; + } + + return $this->formSuccessRedirect($request, __('Two-factor authentication enabled.'), 'user.two_factor_setup'); } public function disable(Request $request, DisableTwoFactorAuthentication $disable) @@ -46,11 +71,50 @@ public function disable(Request $request, DisableTwoFactorAuthentication $disabl $disable($user); + if ($request->expectsJson()) { + if ($user->isTwoFactorAuthenticationRequired()) { + return ['redirect' => $this->setupUrlRedirect()]; + } + + return ['redirect' => null]; + } + if ($user->isTwoFactorAuthenticationRequired()) { - return ['redirect' => $this->setupUrlRedirect()]; + return redirect($this->setupUrlRedirect()) + ->with('user.two_factor_disable.success', __('Two-factor authentication disabled.')); + } + + return $this->formSuccessRedirect($request, __('Two-factor authentication disabled.'), 'user.two_factor_disable'); + } + + private function handleFormValidationError(Request $request, ValidationException $e, string $formName) + { + $errorRedirect = $request->input('_error_redirect'); + + $redirect = $errorRedirect && ! URL::isExternalToApplication($errorRedirect) + ? redirect($errorRedirect) + : back(); + + return $redirect->withInput()->withErrors($e->errors(), $formName); + } + + private function formSuccessRedirect(Request $request, string $message, string $formName) + { + $successKey = "{$formName}.success"; + + if ($redirect = $request->input('_redirect')) { + if (! URL::isExternalToApplication($redirect)) { + return redirect($redirect)->with($successKey, $message); + } + } + + if ($loginRedirect = $request->session()->pull('login.redirect')) { + if (! URL::isExternalToApplication($loginRedirect)) { + return redirect($loginRedirect)->with($successKey, $message); + } } - return ['redirect' => null]; + return back()->with($successKey, $message); } protected function confirmUrl() @@ -60,6 +124,6 @@ protected function confirmUrl() protected function setupUrlRedirect() { - return route('statamic.two-factor-setup'); + return config('statamic.users.two_factor_setup_url') ?? route('statamic.two-factor-setup'); } } diff --git a/src/Http/Controllers/User/TwoFactorRecoveryCodesController.php b/src/Http/Controllers/User/TwoFactorRecoveryCodesController.php index 359c093abf1..67fb2553da4 100644 --- a/src/Http/Controllers/User/TwoFactorRecoveryCodesController.php +++ b/src/Http/Controllers/User/TwoFactorRecoveryCodesController.php @@ -5,6 +5,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Str; use Statamic\Auth\TwoFactor\GenerateNewRecoveryCodes; +use Statamic\Facades\URL; use Statamic\Facades\User; use Statamic\Http\Controllers\CP\CpController; @@ -21,7 +22,11 @@ public function store(Request $request, GenerateNewRecoveryCodes $generateRecove $generateRecoveryCodes($user); - return ['recovery_codes' => $user->twoFactorRecoveryCodes()]; + if ($request->expectsJson()) { + return ['recovery_codes' => $user->twoFactorRecoveryCodes()]; + } + + return $this->formSuccessRedirect($request, __('Recovery codes regenerated.'), 'user.two_factor_reset_recovery_codes'); } public function download(Request $request) @@ -37,4 +42,15 @@ public function download(Request $request) 'Content-Disposition' => 'attachment; filename="'.$filename.'"', ]); } + + private function formSuccessRedirect(Request $request, string $message, string $formName) + { + $redirect = $request->input('_redirect'); + + $url = $redirect && ! URL::isExternalToApplication($redirect) + ? $redirect + : back()->getTargetUrl(); + + return redirect($url)->with("{$formName}.success", $message); + } } diff --git a/src/Http/Middleware/CP/RedirectIfTwoFactorSetupIncomplete.php b/src/Http/Middleware/CP/RedirectIfTwoFactorSetupIncomplete.php index c1cd853fa54..0d0bb96dc63 100644 --- a/src/Http/Middleware/CP/RedirectIfTwoFactorSetupIncomplete.php +++ b/src/Http/Middleware/CP/RedirectIfTwoFactorSetupIncomplete.php @@ -2,6 +2,7 @@ namespace Statamic\Http\Middleware\CP; +use Illuminate\Http\Request; use Statamic\Http\Middleware\RedirectIfTwoFactorSetupIncomplete as Middleware; class RedirectIfTwoFactorSetupIncomplete extends Middleware @@ -10,4 +11,11 @@ protected function redirectRoute(): string { return 'statamic.cp.two-factor-setup'; } + + protected function redirectUrl(Request $request): string + { + return route($this->redirectRoute(), [ + 'referer' => $request->fullUrl(), + ]); + } } diff --git a/src/Http/Middleware/RedirectIfTwoFactorSetupIncomplete.php b/src/Http/Middleware/RedirectIfTwoFactorSetupIncomplete.php index 873edcfa0c1..e4990099320 100644 --- a/src/Http/Middleware/RedirectIfTwoFactorSetupIncomplete.php +++ b/src/Http/Middleware/RedirectIfTwoFactorSetupIncomplete.php @@ -4,6 +4,7 @@ use Closure; use Illuminate\Http\Request; +use Statamic\Auth\TwoFactor\EnableTwoFactorAuthentication; use Statamic\Facades\TwoFactor; use Statamic\Facades\User; @@ -16,15 +17,41 @@ public function handle(Request $request, Closure $next) && ($user = User::fromUser($request->user())) && $user->isTwoFactorAuthenticationRequired() && ! $user->hasEnabledTwoFactorAuthentication() + && ! $this->isSetupUrl($request) ) { - return redirect()->route($this->redirectRoute(), [ - 'referer' => $request->fullUrl(), - ]); + if (empty($user->two_factor_secret)) { + app(EnableTwoFactorAuthentication::class)($user); + } + + return redirect($this->redirectUrl($request)); } return $next($request); } + protected function isSetupUrl(Request $request): bool + { + if (! $customUrl = config('statamic.users.two_factor_setup_url')) { + return false; + } + + $currentPath = '/'.ltrim($request->path(), '/'); + $customPath = '/'.ltrim(parse_url($customUrl, PHP_URL_PATH) ?? '', '/'); + + return $currentPath === $customPath; + } + + protected function redirectUrl(Request $request): string + { + if ($url = config('statamic.users.two_factor_setup_url')) { + return $url; + } + + return route($this->redirectRoute(), [ + 'referer' => $request->fullUrl(), + ]); + } + protected function redirectRoute(): string { return 'statamic.two-factor-setup'; diff --git a/src/Http/Requests/TwoFactorChallengeRequest.php b/src/Http/Requests/TwoFactorChallengeRequest.php index ebb5d74c7b9..373ce03804a 100644 --- a/src/Http/Requests/TwoFactorChallengeRequest.php +++ b/src/Http/Requests/TwoFactorChallengeRequest.php @@ -135,7 +135,7 @@ public function remember() return $this->remember; } - public function sendFailedTwoFactorChallengeResponse() + public function sendFailedTwoFactorChallengeResponse(?string $redirectUrl = null) { [$key, $message] = $this->filled('recovery_code') ? ['recovery_code', __('statamic::validation.invalid_two_factor_recovery_code')] @@ -147,6 +147,8 @@ public function sendFailedTwoFactorChallengeResponse() ]); } - return redirect()->route('statamic.cp.two-factor-challenge')->withErrors([$key => $message]); + $redirect = $redirectUrl ?? route('statamic.cp.two-factor-challenge'); + + return redirect($redirect)->withErrors([$key => $message]); } } diff --git a/tests/Actions/DisableTwoFactorTest.php b/tests/Actions/DisableTwoFactorTest.php index f4e9331b614..b92a046dcba 100644 --- a/tests/Actions/DisableTwoFactorTest.php +++ b/tests/Actions/DisableTwoFactorTest.php @@ -3,6 +3,7 @@ namespace Tests\Actions; use Mockery; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\Test; use Statamic\Actions\DisableTwoFactorAuthentication as Action; use Statamic\Auth\TwoFactor\DisableTwoFactorAuthentication; @@ -11,6 +12,7 @@ use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; +#[Group('2fa')] class DisableTwoFactorTest extends TestCase { use FakesRoles; diff --git a/tests/Feature/Users/DisableTwoFactorTest.php b/tests/Feature/Users/DisableTwoFactorTest.php index 271143d5185..58ca93b1c52 100644 --- a/tests/Feature/Users/DisableTwoFactorTest.php +++ b/tests/Feature/Users/DisableTwoFactorTest.php @@ -29,7 +29,7 @@ public function it_disables_two_factor_authentication() $this ->actingAs($user) ->withActiveElevatedSession() - ->delete(cp_route('users.two-factor.disable')) + ->deleteJson(cp_route('users.two-factor.disable')) ->assertOk() ->assertJson(['redirect' => null]); @@ -55,7 +55,7 @@ public function it_disables_two_factor_authentication_when_two_factor_is_enforce $this ->actingAs($user) ->withActiveElevatedSession() - ->delete(cp_route('users.two-factor.disable')) + ->deleteJson(cp_route('users.two-factor.disable')) ->assertOk() ->assertJson(['redirect' => cp_route('two-factor-setup')]); diff --git a/tests/Feature/Users/EnableTwoFactorTest.php b/tests/Feature/Users/EnableTwoFactorTest.php index a2d9f9561d5..f046ffcc467 100644 --- a/tests/Feature/Users/EnableTwoFactorTest.php +++ b/tests/Feature/Users/EnableTwoFactorTest.php @@ -51,7 +51,7 @@ public function it_enables_two_factor_authentication($url) $this ->actingAs($user) ->withActiveElevatedSession() - ->get($url()) + ->postJson($url()) ->assertOk() ->assertJsonStructure(['qr', 'secret_key', 'confirm_url']); @@ -75,7 +75,7 @@ public function it_cant_enable_two_factor_authentication_without_elevated_sessio $this ->actingAs($user) - ->get(cp_route('users.two-factor.enable')) + ->post(cp_route('users.two-factor.enable')) ->assertRedirect('/cp/auth/confirm-password'); $this->assertNull($user->two_factor_secret); @@ -95,7 +95,7 @@ public function it_cant_enable_two_factor_authentication_when_it_is_already_enab $this ->actingAs($user) ->withActiveElevatedSession() - ->get($url()) + ->postJson($url()) ->assertForbidden(); Event::assertNotDispatched(TwoFactorAuthenticationEnabled::class, fn ($event) => $event->user->id === $user->id); @@ -103,7 +103,7 @@ public function it_cant_enable_two_factor_authentication_when_it_is_already_enab #[Test] #[DataProvider('enableProvider')] - public function it_doesnt_regenerate_secret_when_validation_error_is_present($url) + public function it_is_idempotent_when_secret_already_exists($url) { Event::fake(); @@ -112,19 +112,15 @@ public function it_doesnt_regenerate_secret_when_validation_error_is_present($ur $user->set('two_factor_recovery_codes', $originalRecoveryCodes = encrypt(['abc', 'def', 'ghi', 'jkl', 'mno', 'pqr', 'stu', 'vwx'])); $user->save(); - $errors = new \Illuminate\Support\ViewErrorBag; - $errors->put('default', new \Illuminate\Support\MessageBag(['code' => 'The provided two factor authentication code was invalid.'])); - $this ->actingAs($user) ->withActiveElevatedSession() - ->withSession(['errors' => $errors]) - ->get($url()) + ->postJson($url()) ->assertOk() ->assertJsonStructure(['qr', 'secret_key', 'confirm_url']); - $this->assertEquals($originalSecret, $user->two_factor_secret); - $this->assertEquals($originalRecoveryCodes, $user->two_factor_recovery_codes); + $this->assertEquals($originalSecret, $user->fresh()->two_factor_secret); + $this->assertEquals($originalRecoveryCodes, $user->fresh()->two_factor_recovery_codes); Event::assertNotDispatched(TwoFactorAuthenticationEnabled::class, fn ($event) => $event->user->id === $user->id); } @@ -143,7 +139,7 @@ public function it_confirms_two_factor_authentication($url) $this ->actingAs($user) ->withActiveElevatedSession() - ->post($url(), [ + ->postJson($url(), [ 'code' => $this->getOneTimeCode($user), ]) ->assertOk(); @@ -168,7 +164,7 @@ public function it_cant_confirm_two_factor_authentication_without_valid_code($ur ->post($url(), [ 'code' => '123456', ]) - ->assertSessionHasErrors('code'); + ->assertSessionHasErrors('code', null, 'user.two_factor_setup'); $this->assertNull($user->two_factor_confirmed_at); } diff --git a/tests/Feature/Users/TwoFactorRecoveryCodesTest.php b/tests/Feature/Users/TwoFactorRecoveryCodesTest.php index 5184f376466..ddeac34bd21 100644 --- a/tests/Feature/Users/TwoFactorRecoveryCodesTest.php +++ b/tests/Feature/Users/TwoFactorRecoveryCodesTest.php @@ -76,7 +76,7 @@ public function it_generates_recovery_codes($url) $this ->actingAs($user) ->withActiveElevatedSession() - ->post($url()) + ->postJson($url()) ->assertOk() ->assertJsonStructure(['recovery_codes']); } diff --git a/tests/Feature/Users/TwoFactorRoutesTest.php b/tests/Feature/Users/TwoFactorRoutesTest.php index 1968e0c062e..20287a783ed 100644 --- a/tests/Feature/Users/TwoFactorRoutesTest.php +++ b/tests/Feature/Users/TwoFactorRoutesTest.php @@ -23,6 +23,12 @@ protected function resolveApplicationConfiguration($app) Route::get('/test-frontend-route', function () { return 'ok'; })->middleware('statamic.web'); + + Route::get('/custom-setup', function () { + return 'setup page'; + })->middleware('statamic.web'); + + Route::get('/login', fn () => 'login page')->name('login'); }); } @@ -74,6 +80,22 @@ public function cp_two_factor_setup_middleware_does_not_redirect_when_two_factor ->assertOk(); } + #[Test] + public function cp_two_factor_setup_middleware_ignores_frontend_setup_url_config() + { + config([ + 'statamic.users.two_factor_enforced_roles' => ['*'], + 'statamic.users.two_factor_setup_url' => '/custom-setup', + ]); + + $user = tap(User::make()->makeSuper()->email('admin@domain.com'))->save(); + + $this + ->actingAs($user) + ->get(cp_route('dashboard')) + ->assertRedirect(cp_route('two-factor-setup', ['referer' => cp_route('dashboard')])); + } + #[Test] public function frontend_two_factor_setup_middleware_redirects_when_two_factor_is_enforced() { @@ -87,6 +109,90 @@ public function frontend_two_factor_setup_middleware_redirects_when_two_factor_i ->assertRedirect(route('statamic.two-factor-setup', ['referer' => url('/test-frontend-route')])); } + #[Test] + public function frontend_two_factor_setup_middleware_generates_secret_when_none_exists() + { + config()->set('statamic.users.two_factor_enforced_roles', ['*']); + + $user = tap(User::make()->makeSuper()->email('admin@domain.com'))->save(); + + $this->assertNull($user->two_factor_secret); + + $this + ->actingAs($user) + ->get('/test-frontend-route') + ->assertRedirect(route('statamic.two-factor-setup', ['referer' => url('/test-frontend-route')])); + + $this->assertNotNull($user->fresh()->two_factor_secret); + } + + #[Test] + public function frontend_two_factor_setup_middleware_does_not_regenerate_existing_secret() + { + config()->set('statamic.users.two_factor_enforced_roles', ['*']); + + $user = tap(User::make()->makeSuper()->email('admin@domain.com')->data([ + 'two_factor_secret' => $existing = encrypt(app(\Statamic\Contracts\Auth\TwoFactor\TwoFactorAuthenticationProvider::class)->generateSecretKey()), + ]))->save(); + + $this + ->actingAs($user) + ->get('/test-frontend-route') + ->assertRedirect(route('statamic.two-factor-setup', ['referer' => url('/test-frontend-route')])); + + $this->assertEquals($existing, $user->fresh()->two_factor_secret); + } + + #[Test] + public function frontend_two_factor_setup_middleware_redirects_to_configured_url() + { + config([ + 'statamic.users.two_factor_enforced_roles' => ['*'], + 'statamic.users.two_factor_setup_url' => '/custom-setup', + ]); + + $user = tap(User::make()->makeSuper()->email('admin@domain.com'))->save(); + + $this + ->actingAs($user) + ->get('/test-frontend-route') + ->assertRedirect('/custom-setup'); + } + + #[Test] + public function frontend_two_factor_setup_middleware_allows_configured_url_through() + { + config([ + 'statamic.users.two_factor_enforced_roles' => ['*'], + 'statamic.users.two_factor_setup_url' => '/custom-setup', + ]); + + $user = tap(User::make()->makeSuper()->email('admin@domain.com'))->save(); + + $this + ->actingAs($user) + ->get('/custom-setup') + ->assertOk() + ->assertSee('setup page'); + } + + #[Test] + public function frontend_two_factor_action_routes_require_authentication() + { + $routes = [ + ['post', route('statamic.users.two-factor.enable')], + ['post', route('statamic.users.two-factor.confirm')], + ['delete', route('statamic.users.two-factor.disable')], + ['get', route('statamic.users.two-factor.recovery-codes.show')], + ['post', route('statamic.users.two-factor.recovery-codes.generate')], + ['get', route('statamic.users.two-factor.recovery-codes.download')], + ]; + + foreach ($routes as [$method, $url]) { + $this->{$method}($url)->assertRedirect('/login'); + } + } + #[Test] #[DefineEnvironment('disableTwoFactor')] public function frontend_two_factor_setup_middleware_does_not_redirect_when_two_factor_is_disabled() diff --git a/tests/Feature/Users/TwoFactorSetupTest.php b/tests/Feature/Users/TwoFactorSetupTest.php index f4c00429075..0b8fde08717 100644 --- a/tests/Feature/Users/TwoFactorSetupTest.php +++ b/tests/Feature/Users/TwoFactorSetupTest.php @@ -45,6 +45,23 @@ public function redirect_url_is_referer() ->assertInertia(fn ($page) => $page->where('redirect', 'http://localhost/cp/collections')); } + #[Test] + public function redirect_url_is_preserved_across_refreshes_of_the_frontend_setup_page() + { + $user = $this->user(); + + $this + ->actingAs($user) + ->withSession(['login.redirect' => '/dashboard']) + ->get(route('statamic.two-factor-setup')) + ->assertInertia(fn ($page) => $page->where('redirect', '/dashboard')); + + $this + ->actingAs($user) + ->get(route('statamic.two-factor-setup')) + ->assertInertia(fn ($page) => $page->where('redirect', '/dashboard')); + } + #[Test] public function it_does_not_redirect_to_external_url_on_frontend_route() { diff --git a/tests/Tags/User/DisableTwoFactorFormTest.php b/tests/Tags/User/DisableTwoFactorFormTest.php new file mode 100644 index 00000000000..249564be2e2 --- /dev/null +++ b/tests/Tags/User/DisableTwoFactorFormTest.php @@ -0,0 +1,210 @@ +userWithTwoFactorEnabled(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:disable_two_factor_form }}{{ /user:disable_two_factor_form }}'); + + $this->assertStringStartsWith('
', $output); + $this->assertStringContainsString('assertStringContainsString(csrf_field(), $output); + } + + #[Test] + public function it_does_not_render_for_user_without_2fa() + { + $user = $this->user(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:disable_two_factor_form }}

inside

{{ /user:disable_two_factor_form }}'); + + $this->assertStringNotContainsString('assertStringNotContainsString('

inside

', $output); + } + + #[Test] + public function it_does_not_render_for_guests() + { + $output = $this->tag('{{ user:disable_two_factor_form }}

inside

{{ /user:disable_two_factor_form }}'); + + $this->assertStringNotContainsString('assertStringNotContainsString('

inside

', $output); + } + + #[Test] + public function it_renders_with_redirect_param() + { + $user = $this->userWithTwoFactorEnabled(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:disable_two_factor_form redirect="/account" }}{{ /user:disable_two_factor_form }}'); + + $this->assertStringContainsString('', $output); + } + + #[Test] + public function it_disables_2fa_and_redirects() + { + $user = $this->userWithTwoFactorEnabled(); + + $this + ->actingAs($user) + ->session(['statamic_elevated_session' => now()->timestamp]) + ->delete(route('statamic.users.two-factor.disable'), [ + '_redirect' => '/account', + ]) + ->assertRedirect('/account') + ->assertSessionHas('user.two_factor_disable.success'); + + $this->assertNull($user->fresh()->two_factor_confirmed_at); + } + + #[Test] + public function it_requires_elevated_session_to_disable() + { + $user = $this->userWithTwoFactorEnabled(); + + $this + ->actingAs($user) + ->delete(route('statamic.users.two-factor.disable'), [ + '_redirect' => '/account', + ]) + ->assertRedirect(route('statamic.elevated-session')); + } + + #[Test] + public function it_redirects_to_configured_setup_url_when_2fa_is_enforced() + { + config([ + 'statamic.users.two_factor_enforced_roles' => ['*'], + 'statamic.users.two_factor_setup_url' => '/auth/setup-2fa', + ]); + + $user = $this->userWithTwoFactorEnabled(); + + $this + ->actingAs($user) + ->session(['statamic_elevated_session' => now()->timestamp]) + ->delete(route('statamic.users.two-factor.disable'), [ + '_redirect' => '/account', + ]) + ->assertRedirect('/auth/setup-2fa') + ->assertSessionHas('user.two_factor_disable.success'); + } + + #[Test] + public function it_falls_back_to_default_setup_route_when_no_config_and_2fa_is_enforced() + { + config(['statamic.users.two_factor_enforced_roles' => ['*']]); + + $user = $this->userWithTwoFactorEnabled(); + + $this + ->actingAs($user) + ->session(['statamic_elevated_session' => now()->timestamp]) + ->delete(route('statamic.users.two-factor.disable'), [ + '_redirect' => '/account', + ]) + ->assertRedirect(route('statamic.two-factor-setup')); + } + + #[Test] + public function it_prefers_redirect_param_over_login_redirect_in_session() + { + $user = $this->userWithTwoFactorEnabled(); + + $this + ->actingAs($user) + ->session([ + 'statamic_elevated_session' => now()->timestamp, + 'login.redirect' => '/dashboard', + ]) + ->delete(route('statamic.users.two-factor.disable'), [ + '_redirect' => '/account', + ]) + ->assertRedirect('/account'); + + $this->assertNull($user->fresh()->two_factor_confirmed_at); + } + + #[Test] + public function it_redirects_back_without_redirect_param() + { + $user = $this->userWithTwoFactorEnabled(); + + $this + ->actingAs($user) + ->session(['statamic_elevated_session' => now()->timestamp]) + ->from('/account') + ->delete(route('statamic.users.two-factor.disable')) + ->assertRedirect('/account') + ->assertSessionHas('user.two_factor_disable.success'); + + $this->assertNull($user->fresh()->two_factor_confirmed_at); + } + + #[Test] + public function it_returns_json_for_xhr_requests() + { + $user = $this->userWithTwoFactorEnabled(); + + $this + ->actingAs($user) + ->session(['statamic_elevated_session' => now()->timestamp]) + ->deleteJson(route('statamic.users.two-factor.disable')) + ->assertOk() + ->assertJson(['redirect' => null]); + + $this->assertNull($user->fresh()->two_factor_confirmed_at); + } + + private function user() + { + return tap(User::make()->makeSuper()->email('test@example.com'))->save(); + } + + private function userWithTwoFactorEnabled() + { + $user = $this->user(); + + $user->merge([ + 'two_factor_confirmed_at' => now()->timestamp, + 'two_factor_secret' => encrypt(app(TwoFactorAuthenticationProvider::class)->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode(Collection::times(8, function () { + return RecoveryCode::generate(); + })->all())), + ]); + + $user->save(); + + return $user; + } +} diff --git a/tests/Tags/User/LoginFormTest.php b/tests/Tags/User/LoginFormTest.php index cd1f328ccf7..5a972dda4c9 100644 --- a/tests/Tags/User/LoginFormTest.php +++ b/tests/Tags/User/LoginFormTest.php @@ -328,6 +328,151 @@ public function it_redirects_to_the_two_factor_challenge_page() Event::assertDispatched(TwoFactorAuthenticationChallenged::class, fn ($event) => $event->user->id === 1); } + #[Test] + public function it_redirects_to_configured_two_factor_challenge_url() + { + Event::fake(); + + config(['statamic.users.two_factor_challenge_url' => '/custom-2fa-challenge']); + + User::make() + ->id(1) + ->email('san@holo.com') + ->password('chewy') + ->data([ + 'two_factor_confirmed_at' => now()->timestamp, + 'two_factor_secret' => encrypt(app(TwoFactorAuthenticationProvider::class)->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode(Collection::times(8, function () { + return RecoveryCode::generate(); + })->all())), + ]) + ->save(); + + $this + ->assertGuest() + ->post('/!/auth/login', [ + 'token' => 'test-token', + 'email' => 'san@holo.com', + 'password' => 'chewy', + ]) + ->assertRedirect('/custom-2fa-challenge') + ->assertSessionHas('login.id', 1); + + Event::assertDispatched(TwoFactorAuthenticationChallenged::class); + } + + #[Test] + public function it_stores_redirect_in_session_for_two_factor_challenge() + { + User::make() + ->id(1) + ->email('san@holo.com') + ->password('chewy') + ->data([ + 'two_factor_confirmed_at' => now()->timestamp, + 'two_factor_secret' => encrypt(app(TwoFactorAuthenticationProvider::class)->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode(Collection::times(8, function () { + return RecoveryCode::generate(); + })->all())), + ]) + ->save(); + + $this + ->post('/!/auth/login', [ + 'token' => 'test-token', + 'email' => 'san@holo.com', + 'password' => 'chewy', + '_redirect' => '/dashboard', + ]) + ->assertSessionHas('login.redirect', '/dashboard'); + } + + #[Test] + public function it_does_not_stash_login_redirect_when_two_factor_is_not_enforced() + { + User::make() + ->id(1) + ->email('san@holo.com') + ->password('chewy') + ->save(); + + $this + ->post('/!/auth/login', [ + 'token' => 'test-token', + 'email' => 'san@holo.com', + 'password' => 'chewy', + '_redirect' => '/dashboard', + ]) + ->assertRedirect('/dashboard') + ->assertSessionMissing('login.redirect'); + } + + #[Test] + public function it_stashes_login_redirect_when_two_factor_setup_is_required() + { + config()->set('statamic.users.two_factor_enforced_roles', ['*']); + + User::make() + ->id(1) + ->email('san@holo.com') + ->password('chewy') + ->save(); + + $this + ->post('/!/auth/login', [ + 'token' => 'test-token', + 'email' => 'san@holo.com', + 'password' => 'chewy', + '_redirect' => '/dashboard', + ]) + ->assertSessionHas('login.redirect', '/dashboard'); + } + + #[Test] + public function it_clears_stale_login_redirect_on_two_factor_challenge() + { + User::make() + ->id(1) + ->email('san@holo.com') + ->password('chewy') + ->data([ + 'two_factor_confirmed_at' => now()->timestamp, + 'two_factor_secret' => encrypt(app(TwoFactorAuthenticationProvider::class)->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode(Collection::times(8, fn () => RecoveryCode::generate())->all())), + ]) + ->save(); + + $this + ->withSession(['login.redirect' => '/stale']) + ->post('/!/auth/login', [ + 'token' => 'test-token', + 'email' => 'san@holo.com', + 'password' => 'chewy', + ]) + ->assertSessionMissing('login.redirect'); + } + + #[Test] + public function it_clears_stale_login_redirect_when_two_factor_setup_is_required() + { + config()->set('statamic.users.two_factor_enforced_roles', ['*']); + + User::make() + ->id(1) + ->email('san@holo.com') + ->password('chewy') + ->save(); + + $this + ->withSession(['login.redirect' => '/stale']) + ->post('/!/auth/login', [ + 'token' => 'test-token', + 'email' => 'san@holo.com', + 'password' => 'chewy', + ]) + ->assertSessionMissing('login.redirect'); + } + #[Test] #[DefineEnvironment('disableTwoFactor')] public function it_skips_two_factor_challenge_when_two_factor_is_disabled() diff --git a/tests/Tags/User/ResetTwoFactorRecoveryCodesFormTest.php b/tests/Tags/User/ResetTwoFactorRecoveryCodesFormTest.php new file mode 100644 index 00000000000..c8361de9961 --- /dev/null +++ b/tests/Tags/User/ResetTwoFactorRecoveryCodesFormTest.php @@ -0,0 +1,153 @@ +userWithTwoFactorEnabled(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:reset_two_factor_recovery_codes_form }}{{ /user:reset_two_factor_recovery_codes_form }}'); + + $this->assertStringStartsWith('', $output); + $this->assertStringContainsString(csrf_field(), $output); + } + + #[Test] + public function it_does_not_render_for_user_without_2fa() + { + $user = $this->user(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:reset_two_factor_recovery_codes_form }}

inside

{{ /user:reset_two_factor_recovery_codes_form }}'); + + $this->assertStringNotContainsString('assertStringNotContainsString('

inside

', $output); + } + + #[Test] + public function it_does_not_render_for_guests() + { + $output = $this->tag('{{ user:reset_two_factor_recovery_codes_form }}

inside

{{ /user:reset_two_factor_recovery_codes_form }}'); + + $this->assertStringNotContainsString('assertStringNotContainsString('

inside

', $output); + } + + #[Test] + public function it_renders_with_redirect_param() + { + $user = $this->userWithTwoFactorEnabled(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:reset_two_factor_recovery_codes_form redirect="/recovery-codes" }}{{ /user:reset_two_factor_recovery_codes_form }}'); + + $this->assertStringContainsString('', $output); + } + + #[Test] + public function it_regenerates_codes_and_redirects() + { + $user = $this->userWithTwoFactorEnabled(); + $originalCodes = $user->twoFactorRecoveryCodes(); + + $this + ->actingAs($user) + ->session(['statamic_elevated_session' => now()->timestamp]) + ->post(route('statamic.users.two-factor.recovery-codes.generate'), [ + '_redirect' => '/recovery-codes', + ]) + ->assertRedirect('/recovery-codes') + ->assertSessionHas('user.two_factor_reset_recovery_codes.success'); + + $newCodes = $user->fresh()->twoFactorRecoveryCodes(); + $this->assertNotEquals($originalCodes, $newCodes); + $this->assertCount(8, $newCodes); + } + + #[Test] + public function it_requires_elevated_session_to_regenerate() + { + $user = $this->userWithTwoFactorEnabled(); + + $this + ->actingAs($user) + ->post(route('statamic.users.two-factor.recovery-codes.generate'), [ + '_redirect' => '/recovery-codes', + ]) + ->assertRedirect(route('statamic.elevated-session')); + } + + #[Test] + public function it_redirects_back_without_redirect_param() + { + $user = $this->userWithTwoFactorEnabled(); + + $this + ->actingAs($user) + ->session(['statamic_elevated_session' => now()->timestamp]) + ->from('/account/security') + ->post(route('statamic.users.two-factor.recovery-codes.generate')) + ->assertRedirect('/account/security') + ->assertSessionHas('user.two_factor_reset_recovery_codes.success'); + } + + #[Test] + public function it_returns_json_for_xhr_requests() + { + $user = $this->userWithTwoFactorEnabled(); + + $this + ->actingAs($user) + ->session(['statamic_elevated_session' => now()->timestamp]) + ->postJson(route('statamic.users.two-factor.recovery-codes.generate')) + ->assertOk() + ->assertJsonStructure(['recovery_codes']); + } + + private function user() + { + return tap(User::make()->makeSuper()->email('test@example.com'))->save(); + } + + private function userWithTwoFactorEnabled() + { + $user = $this->user(); + + $user->merge([ + 'two_factor_confirmed_at' => now()->timestamp, + 'two_factor_secret' => encrypt(app(TwoFactorAuthenticationProvider::class)->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode(Collection::times(8, function () { + return RecoveryCode::generate(); + })->all())), + ]); + + $user->save(); + + return $user; + } +} diff --git a/tests/Tags/User/TwoFactorChallengeFormTest.php b/tests/Tags/User/TwoFactorChallengeFormTest.php new file mode 100644 index 00000000000..047200e3cbe --- /dev/null +++ b/tests/Tags/User/TwoFactorChallengeFormTest.php @@ -0,0 +1,239 @@ +userWithTwoFactorEnabled(); + + $this->session(['login.id' => $user->id()]); + + $output = $this->tag('{{ user:two_factor_challenge_form }}{{ /user:two_factor_challenge_form }}'); + + $this->assertStringStartsWith('', $output); + $this->assertStringContainsString(csrf_field(), $output); + $this->assertStringEndsWith('', $output); + } + + #[Test] + public function it_does_not_render_without_login_id_in_session() + { + $output = $this->tag('{{ user:two_factor_challenge_form }}

inside

{{ /user:two_factor_challenge_form }}'); + + $this->assertStringNotContainsString('assertStringNotContainsString('

inside

', $output); + } + + #[Test] + public function it_renders_with_redirect_params() + { + $user = $this->userWithTwoFactorEnabled(); + + $this->session(['login.id' => $user->id()]); + + $output = $this->tag('{{ user:two_factor_challenge_form redirect="/dashboard" error_redirect="/error" }}{{ /user:two_factor_challenge_form }}'); + + $this->assertStringContainsString('', $output); + $this->assertStringContainsString('', $output); + } + + #[Test] + public function it_fetches_form_data() + { + $user = $this->userWithTwoFactorEnabled(); + + $this->session(['login.id' => $user->id()]); + + $form = Statamic::tag('user:two_factor_challenge_form')->fetch(); + + $this->assertEquals('http://localhost/!/auth/two-factor-challenge', $form['attrs']['action']); + $this->assertEquals('POST', $form['attrs']['method']); + $this->assertArrayHasKey('_token', $form['params']); + } + + #[Test] + public function it_completes_challenge_and_redirects() + { + $user = $this->userWithTwoFactorEnabled(); + + $this + ->session(['login.id' => $user->id()]) + ->post(route('statamic.two-factor-challenge'), [ + 'code' => $this->getOneTimeCode($user), + '_redirect' => '/dashboard', + ]) + ->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user); + } + + #[Test] + public function it_clears_login_session_keys_after_successful_challenge() + { + $user = $this->userWithTwoFactorEnabled(); + + $this + ->session(['login.id' => $user->id(), 'login.remember' => true]) + ->post(route('statamic.two-factor-challenge'), [ + 'code' => $this->getOneTimeCode($user), + '_redirect' => '/dashboard', + ]) + ->assertRedirect('/dashboard') + ->assertSessionMissing('login.id') + ->assertSessionMissing('login.remember'); + } + + #[Test] + public function it_completes_challenge_with_recovery_code_and_redirects() + { + $user = $this->userWithTwoFactorEnabled(); + $codes = $user->twoFactorRecoveryCodes(); + + $this + ->session(['login.id' => $user->id()]) + ->post(route('statamic.two-factor-challenge'), [ + 'recovery_code' => $codes[0], + '_redirect' => '/dashboard', + ]) + ->assertRedirect('/dashboard'); + + $this->assertAuthenticatedAs($user); + $this->assertNotContains($codes[0], $user->fresh()->twoFactorRecoveryCodes()); + } + + #[Test] + public function it_redirects_to_home_without_redirect_param() + { + $user = $this->userWithTwoFactorEnabled(); + + $this + ->session(['login.id' => $user->id()]) + ->post(route('statamic.two-factor-challenge'), [ + 'code' => $this->getOneTimeCode($user), + ]) + ->assertRedirect(route('statamic.site')); + + $this->assertAuthenticatedAs($user); + } + + #[Test] + public function it_redirects_to_configured_challenge_url_on_invalid_code() + { + config(['statamic.users.two_factor_challenge_url' => '/two-factor-challenge']); + + $user = $this->userWithTwoFactorEnabled(); + + $this + ->session(['login.id' => $user->id()]) + ->post(route('statamic.two-factor-challenge'), [ + 'code' => '123456', + '_redirect' => '/dashboard', + ]) + ->assertRedirect('/two-factor-challenge') + ->assertSessionHasErrors('code'); + + $this->assertGuest(); + } + + #[Test] + public function it_redirects_to_default_challenge_route_on_invalid_code_without_config() + { + $user = $this->userWithTwoFactorEnabled(); + + $this + ->session(['login.id' => $user->id()]) + ->post(route('statamic.two-factor-challenge'), [ + 'code' => '123456', + ]) + ->assertRedirect(route('statamic.two-factor-challenge')) + ->assertSessionHasErrors('code'); + + $this->assertGuest(); + } + + #[Test] + public function it_redirects_to_error_redirect_on_invalid_code() + { + $user = $this->userWithTwoFactorEnabled(); + + $this + ->session(['login.id' => $user->id()]) + ->post(route('statamic.two-factor-challenge'), [ + 'code' => '123456', + '_redirect' => '/dashboard', + '_error_redirect' => '/challenge-error', + ]) + ->assertRedirect('/challenge-error') + ->assertSessionHasErrors('code'); + + $this->assertGuest(); + } + + #[Test] + public function it_uses_login_redirect_from_session_when_no_redirect_param() + { + $user = $this->userWithTwoFactorEnabled(); + + $this + ->session([ + 'login.id' => $user->id(), + 'login.redirect' => '/account', + ]) + ->post(route('statamic.two-factor-challenge'), [ + 'code' => $this->getOneTimeCode($user), + ]) + ->assertRedirect('/account'); + + $this->assertAuthenticatedAs($user); + } + + private function user() + { + return tap(User::make()->makeSuper()->email('test@example.com'))->save(); + } + + private function userWithTwoFactorEnabled() + { + $user = $this->user(); + + $user->merge([ + 'two_factor_confirmed_at' => now()->timestamp, + 'two_factor_secret' => encrypt(app(TwoFactorAuthenticationProvider::class)->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode(Collection::times(8, function () { + return RecoveryCode::generate(); + })->all())), + ]); + + $user->save(); + + return $user; + } + + private function getOneTimeCode($user): string + { + return app(Google2FA::class)->getCurrentOtp($user->twoFactorSecretKey()); + } +} diff --git a/tests/Tags/User/TwoFactorEnableFormTest.php b/tests/Tags/User/TwoFactorEnableFormTest.php new file mode 100644 index 00000000000..796275e2eea --- /dev/null +++ b/tests/Tags/User/TwoFactorEnableFormTest.php @@ -0,0 +1,251 @@ +user(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:two_factor_enable_form }}{{ /user:two_factor_enable_form }}'); + + $this->assertStringStartsWith('
', $output); + $this->assertStringContainsString('', $output); + } + + #[Test] + public function it_renders_for_user_with_pending_setup() + { + $user = $this->userWithTwoFactorPending(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:two_factor_enable_form }}

inside

{{ /user:two_factor_enable_form }}'); + + $this->assertStringStartsWith('', $output); + $this->assertStringContainsString('

inside

', $output); + } + + #[Test] + public function it_does_not_render_for_user_with_2fa_enabled() + { + $user = $this->userWithTwoFactorEnabled(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:two_factor_enable_form }}

inside

{{ /user:two_factor_enable_form }}'); + + $this->assertStringNotContainsString('assertStringNotContainsString('

inside

', $output); + } + + #[Test] + public function it_does_not_render_for_guests() + { + $output = $this->tag('{{ user:two_factor_enable_form }}

inside

{{ /user:two_factor_enable_form }}'); + + $this->assertStringNotContainsString('assertStringNotContainsString('

inside

', $output); + } + + #[Test] + public function it_renders_with_redirect_param() + { + $user = $this->user(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:two_factor_enable_form redirect="/dashboard" }}{{ /user:two_factor_enable_form }}'); + + $this->assertStringContainsString('', $output); + } + + #[Test] + public function it_enables_2fa_and_redirects_back() + { + Event::fake(); + + $user = $this->user(); + + $this->assertNull($user->two_factor_secret); + + $this + ->actingAs($user) + ->withActiveElevatedSession() + ->from('/profile') + ->post(route('statamic.users.two-factor.enable')) + ->assertRedirect('/profile'); + + $this->assertNotNull($user->fresh()->two_factor_secret); + + Event::assertDispatched(TwoFactorAuthenticationEnabled::class, fn ($event) => $event->user->id === $user->id); + } + + #[Test] + public function it_requires_elevated_session_to_enable() + { + $user = $this->user(); + + $this + ->actingAs($user) + ->post(route('statamic.users.two-factor.enable')) + ->assertRedirect(route('statamic.elevated-session')); + + $this->assertNull($user->fresh()->two_factor_secret); + } + + #[Test] + public function it_redirects_to_redirect_param() + { + $user = $this->user(); + + $this + ->actingAs($user) + ->withActiveElevatedSession() + ->from('/profile') + ->post(route('statamic.users.two-factor.enable'), [ + '_redirect' => '/dashboard', + ]) + ->assertRedirect('/dashboard'); + } + + #[Test] + public function it_ignores_external_redirect_param() + { + $user = $this->user(); + + $this + ->actingAs($user) + ->withActiveElevatedSession() + ->from('/profile') + ->post(route('statamic.users.two-factor.enable'), [ + '_redirect' => 'https://evil.example.com', + ]) + ->assertRedirect('/profile'); + } + + #[Test] + public function it_falls_back_to_configured_setup_url_without_redirect_param() + { + config(['statamic.users.two_factor_setup_url' => '/setup-2fa']); + + $user = $this->user(); + + $this + ->actingAs($user) + ->withActiveElevatedSession() + ->from('/profile') + ->post(route('statamic.users.two-factor.enable')) + ->assertRedirect('/setup-2fa'); + } + + #[Test] + public function it_prefers_redirect_param_over_configured_setup_url() + { + config(['statamic.users.two_factor_setup_url' => '/setup-2fa']); + + $user = $this->user(); + + $this + ->actingAs($user) + ->withActiveElevatedSession() + ->from('/profile') + ->post(route('statamic.users.two-factor.enable'), [ + '_redirect' => '/dashboard', + ]) + ->assertRedirect('/dashboard'); + } + + #[Test] + public function it_is_idempotent_when_secret_already_exists() + { + Event::fake(); + + $user = $this->user(); + + $this + ->actingAs($user) + ->withActiveElevatedSession() + ->from('/profile') + ->post(route('statamic.users.two-factor.enable')) + ->assertRedirect('/profile'); + + $firstSecret = $user->fresh()->two_factor_secret; + $this->assertNotNull($firstSecret); + + $this + ->actingAs($user) + ->withActiveElevatedSession() + ->from('/profile') + ->post(route('statamic.users.two-factor.enable')) + ->assertRedirect('/profile'); + + $this->assertEquals($firstSecret, $user->fresh()->two_factor_secret); + + Event::assertDispatchedTimes(TwoFactorAuthenticationEnabled::class, 1); + } + + private function withActiveElevatedSession() + { + return $this->session(['statamic_elevated_session' => now()->timestamp]); + } + + private function user() + { + return tap(User::make()->makeSuper()->email('test@example.com'))->save(); + } + + private function userWithTwoFactorPending() + { + $user = $this->user(); + + $user->merge([ + 'two_factor_secret' => encrypt(app(TwoFactorAuthenticationProvider::class)->generateSecretKey()), + ]); + + $user->save(); + + return $user; + } + + private function userWithTwoFactorEnabled() + { + $user = $this->user(); + + $user->merge([ + 'two_factor_confirmed_at' => now()->timestamp, + 'two_factor_secret' => encrypt(app(TwoFactorAuthenticationProvider::class)->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode(Collection::times(8, function () { + return RecoveryCode::generate(); + })->all())), + ]); + + $user->save(); + + return $user; + } +} diff --git a/tests/Tags/User/TwoFactorRecoveryCodesTagTest.php b/tests/Tags/User/TwoFactorRecoveryCodesTagTest.php new file mode 100644 index 00000000000..901f2754980 --- /dev/null +++ b/tests/Tags/User/TwoFactorRecoveryCodesTagTest.php @@ -0,0 +1,82 @@ +userWithTwoFactorEnabled(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:two_factor_recovery_codes }}
  • {{ code }}
  • {{ /user:two_factor_recovery_codes }}'); + + $codes = $user->twoFactorRecoveryCodes(); + + foreach ($codes as $code) { + $this->assertStringContainsString("
  • {$code}
  • ", $output); + } + } + + #[Test] + public function it_does_not_render_for_user_without_2fa() + { + $user = $this->user(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:two_factor_recovery_codes }}
  • {{ code }}
  • {{ /user:two_factor_recovery_codes }}'); + + $this->assertStringNotContainsString('
  • ', $output); + } + + #[Test] + public function it_does_not_render_for_guests() + { + $output = $this->tag('{{ user:two_factor_recovery_codes }}
  • {{ code }}
  • {{ /user:two_factor_recovery_codes }}'); + + $this->assertStringNotContainsString('
  • ', $output); + } + + private function user() + { + return tap(User::make()->makeSuper()->email('test@example.com'))->save(); + } + + private function userWithTwoFactorEnabled() + { + $user = $this->user(); + + $user->merge([ + 'two_factor_confirmed_at' => now()->timestamp, + 'two_factor_secret' => encrypt(app(TwoFactorAuthenticationProvider::class)->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode(Collection::times(8, function () { + return RecoveryCode::generate(); + })->all())), + ]); + + $user->save(); + + return $user; + } +} diff --git a/tests/Tags/User/TwoFactorSetupFormTest.php b/tests/Tags/User/TwoFactorSetupFormTest.php new file mode 100644 index 00000000000..5a733e105d5 --- /dev/null +++ b/tests/Tags/User/TwoFactorSetupFormTest.php @@ -0,0 +1,287 @@ +userWithTwoFactorPending(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:two_factor_setup_form }}{{ qr_code }}{{ secret_key }}{{ /user:two_factor_setup_form }}'); + + $this->assertStringStartsWith('', $output); + $this->assertStringContainsString('assertStringContainsString('', $output); + } + + #[Test] + public function it_does_not_render_without_pending_setup() + { + $user = $this->user(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:two_factor_setup_form }}

    inside

    {{ /user:two_factor_setup_form }}'); + + $this->assertStringNotContainsString('assertStringNotContainsString('

    inside

    ', $output); + } + + #[Test] + public function it_provides_qr_code_url() + { + $user = $this->userWithTwoFactorPending(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:two_factor_setup_form }}{{ qr_code_url }}{{ /user:two_factor_setup_form }}'); + + $this->assertStringContainsString('data:image/svg+xml;base64,', $output); + } + + #[Test] + public function it_does_not_render_for_user_with_2fa_enabled() + { + $user = $this->userWithTwoFactorEnabled(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:two_factor_setup_form }}

    inside

    {{ /user:two_factor_setup_form }}'); + + $this->assertStringNotContainsString('assertStringNotContainsString('

    inside

    ', $output); + } + + #[Test] + public function it_does_not_render_for_guests() + { + $output = $this->tag('{{ user:two_factor_setup_form }}

    inside

    {{ /user:two_factor_setup_form }}'); + + $this->assertStringNotContainsString('assertStringNotContainsString('

    inside

    ', $output); + } + + #[Test] + public function it_fetches_form_data() + { + $user = $this->userWithTwoFactorPending(); + + $this->actingAs($user); + + $form = Statamic::tag('user:two_factor_setup_form')->fetch(); + + $this->assertEquals('http://localhost/!/auth/two-factor/confirm', $form['attrs']['action']); + $this->assertEquals('POST', $form['attrs']['method']); + $this->assertArrayHasKey('qr_code', $form); + $this->assertArrayHasKey('qr_code_url', $form); + $this->assertArrayHasKey('secret_key', $form); + } + + #[Test] + public function it_renders_with_redirect_params() + { + $user = $this->userWithTwoFactorPending(); + + $this->actingAs($user); + + $output = $this->tag('{{ user:two_factor_setup_form redirect="/dashboard" error_redirect="/error" }}{{ /user:two_factor_setup_form }}'); + + $this->assertStringContainsString('', $output); + $this->assertStringContainsString('', $output); + } + + #[Test] + public function it_confirms_setup_and_redirects() + { + $user = $this->userWithTwoFactorPending(); + + $this + ->actingAs($user) + ->withActiveElevatedSession() + ->post(route('statamic.users.two-factor.confirm'), [ + 'code' => $this->getOneTimeCode($user), + '_redirect' => '/dashboard', + ]) + ->assertRedirect('/dashboard') + ->assertSessionHas('user.two_factor_setup.success'); + + $this->assertNotNull($user->fresh()->two_factor_confirmed_at); + } + + #[Test] + public function it_requires_elevated_session_to_confirm() + { + $user = $this->userWithTwoFactorPending(); + + $this + ->actingAs($user) + ->post(route('statamic.users.two-factor.confirm'), [ + 'code' => $this->getOneTimeCode($user), + ]) + ->assertRedirect(route('statamic.elevated-session')); + + $this->assertNull($user->fresh()->two_factor_confirmed_at); + } + + #[Test] + public function it_redirects_back_without_redirect_param() + { + $user = $this->userWithTwoFactorPending(); + + $this + ->actingAs($user) + ->withActiveElevatedSession() + ->from('/setup-2fa') + ->post(route('statamic.users.two-factor.confirm'), [ + 'code' => $this->getOneTimeCode($user), + ]) + ->assertRedirect('/setup-2fa') + ->assertSessionHas('user.two_factor_setup.success'); + + $this->assertNotNull($user->fresh()->two_factor_confirmed_at); + } + + #[Test] + public function it_returns_json_for_xhr_requests() + { + $user = $this->userWithTwoFactorPending(); + + $this + ->actingAs($user) + ->withActiveElevatedSession() + ->postJson(route('statamic.users.two-factor.confirm'), [ + 'code' => $this->getOneTimeCode($user), + ]) + ->assertOk() + ->assertJson([]); + + $this->assertNotNull($user->fresh()->two_factor_confirmed_at); + } + + #[Test] + public function it_redirects_back_with_errors_on_invalid_code() + { + $user = $this->userWithTwoFactorPending(); + + $this + ->actingAs($user) + ->withActiveElevatedSession() + ->from('/setup-2fa') + ->post(route('statamic.users.two-factor.confirm'), [ + 'code' => '123456', + '_redirect' => '/dashboard', + ]) + ->assertRedirect('/setup-2fa') + ->assertSessionHasErrors('code', null, 'user.two_factor_setup'); + + $this->assertNull($user->fresh()->two_factor_confirmed_at); + } + + #[Test] + public function it_redirects_to_error_redirect_on_invalid_code() + { + $user = $this->userWithTwoFactorPending(); + + $this + ->actingAs($user) + ->withActiveElevatedSession() + ->post(route('statamic.users.two-factor.confirm'), [ + 'code' => '123456', + '_redirect' => '/dashboard', + '_error_redirect' => '/setup-error', + ]) + ->assertRedirect('/setup-error') + ->assertSessionHasErrors('code', null, 'user.two_factor_setup'); + + $this->assertNull($user->fresh()->two_factor_confirmed_at); + } + + #[Test] + public function it_uses_login_redirect_from_session_when_redirect_param_is_empty() + { + $user = $this->userWithTwoFactorPending(); + + $this + ->actingAs($user) + ->session([ + 'statamic_elevated_session' => now()->timestamp, + 'login.redirect' => '/account', + ]) + ->post(route('statamic.users.two-factor.confirm'), [ + 'code' => $this->getOneTimeCode($user), + '_redirect' => '', + ]) + ->assertRedirect('/account'); + + $this->assertNotNull($user->fresh()->two_factor_confirmed_at); + } + + private function withActiveElevatedSession() + { + return $this->session(['statamic_elevated_session' => now()->timestamp]); + } + + private function user() + { + return tap(User::make()->makeSuper()->email('test@example.com'))->save(); + } + + private function userWithTwoFactorEnabled() + { + $user = $this->user(); + + $user->merge([ + 'two_factor_confirmed_at' => now()->timestamp, + 'two_factor_secret' => encrypt(app(TwoFactorAuthenticationProvider::class)->generateSecretKey()), + 'two_factor_recovery_codes' => encrypt(json_encode(Collection::times(8, function () { + return RecoveryCode::generate(); + })->all())), + ]); + + $user->save(); + + return $user; + } + + private function userWithTwoFactorPending() + { + $user = $this->user(); + + $user->merge([ + 'two_factor_secret' => encrypt(app(TwoFactorAuthenticationProvider::class)->generateSecretKey()), + ]); + + $user->save(); + + return $user; + } + + private function getOneTimeCode($user): string + { + return app(Google2FA::class)->getCurrentOtp($user->twoFactorSecretKey()); + } +}