Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
0b714df
wip
duncanmcclean Mar 31, 2026
d2ab03e
Merge branch '6.x' into frontend-two-factor
duncanmcclean Apr 21, 2026
49e615f
wip
duncanmcclean Apr 21, 2026
d67510e
require elevated session to regenerate recovery codes and disable 2fa
duncanmcclean Apr 21, 2026
05b7bef
encrypt `setup_url` value in login form
duncanmcclean Apr 21, 2026
44ccac5
add to test group
duncanmcclean Apr 21, 2026
a269ef8
wip
duncanmcclean Apr 21, 2026
18103b3
Merge remote-tracking branch 'origin/6.x' into frontend-two-factor
jasonvarga Apr 21, 2026
cedb77b
Move frontend 2FA URLs to config
jasonvarga Apr 21, 2026
9c8cc3a
Use expectsJson to distinguish CP vs frontend 2FA responses
jasonvarga Apr 21, 2026
315031b
Consume login.redirect session key in 2FA setup redirect
jasonvarga Apr 21, 2026
62ffcd8
Forget login session keys after successful 2FA challenge
jasonvarga Apr 22, 2026
b8952b6
Add coverage for configured 2FA setup URL and login redirect stash
jasonvarga Apr 22, 2026
5801c54
Protect frontend 2FA action routes with auth middleware
jasonvarga Apr 22, 2026
3d24f0f
expose has_enabled_two_factor_authentication
jasonvarga Apr 22, 2026
0f79065
Make 2FA enable an explicit POST action
jasonvarga Apr 22, 2026
2882082
Scope 2FA form tag session keys to prevent flash bleed
jasonvarga Apr 22, 2026
f91f856
user tag instead of adding new augmented data
jasonvarga Apr 23, 2026
5034a0c
Scope two_factor_setup_url config to frontend only
jasonvarga Apr 23, 2026
cb366de
Eagerly generate 2FA secret when middleware redirects to setup
jasonvarga Apr 23, 2026
2f9250d
Clear stale login.redirect before stashing on a new login
jasonvarga Apr 23, 2026
36aea03
Preserve login.redirect across setup page refreshes
jasonvarga Apr 23, 2026
681e274
Require elevated session for 2FA enable and confirm
jasonvarga Apr 23, 2026
3ad0008
Render 2FA enable form for users with pending setup
jasonvarga Apr 23, 2026
22364b8
Fall back to configured setup URL when enabling 2FA
jasonvarga Apr 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions config/users.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion resources/js/components/two-factor/Setup.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion routes/cp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 4 additions & 2 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down
313 changes: 313 additions & 0 deletions src/Auth/UserTags.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down
Loading
Loading