Skip to content

Commit 8a2269b

Browse files
duncanmccleanclaudejasonvarga
authored
[6.x] Frontend Two-Factor Authentication (#14525)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Jason Varga <jason@pixelfear.com>
1 parent acae0b3 commit 8a2269b

28 files changed

Lines changed: 2072 additions & 64 deletions

config/users.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,22 @@
211211

212212
'two_factor_enforced_roles' => [],
213213

214+
/*
215+
|--------------------------------------------------------------------------
216+
| Two-Factor Authentication URLs
217+
|--------------------------------------------------------------------------
218+
|
219+
| When users log in to the frontend and need to verify a two-factor code
220+
| or set up two-factor authentication, they will be redirected to these
221+
| URLs. Leave null to use the built-in pages. Control panel flows are
222+
| unaffected and always use their own pages.
223+
|
224+
*/
225+
226+
'two_factor_challenge_url' => null,
227+
228+
'two_factor_setup_url' => null,
229+
214230
/*
215231
|--------------------------------------------------------------------------
216232
| Default Sorting

resources/js/components/two-factor/Setup.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ onMounted(() => getSetupCode());
2525
function getSetupCode() {
2626
loading.value = true;
2727
28-
axios.get(props.enableUrl).then((response) => {
28+
axios.post(props.enableUrl).then((response) => {
2929
qrCode.value = response.data.qr;
3030
secretKey.value = response.data.secret_key;
3131
confirmUrl.value = response.data.confirm_url;

routes/cp.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@
350350
Route::patch('users/{user}/password', [PasswordController::class, 'update'])->name('users.password.update');
351351
if (TwoFactor::enabled()) {
352352
Route::withoutMiddleware(RedirectIfTwoFactorSetupIncomplete::class)->middleware(RequireElevatedSession::class)->group(function () {
353-
Route::get('two-factor/enable', [TwoFactorAuthenticationController::class, 'enable'])->name('users.two-factor.enable');
353+
Route::post('two-factor/enable', [TwoFactorAuthenticationController::class, 'enable'])->name('users.two-factor.enable');
354354
Route::delete('two-factor', [TwoFactorAuthenticationController::class, 'disable'])->name('users.two-factor.disable');
355355
Route::post('two-factor/confirm', [TwoFactorAuthenticationController::class, 'confirm'])->name('users.two-factor.confirm');
356356
Route::get('two-factor/recovery-codes', [TwoFactorRecoveryCodesController::class, 'show'])->name('users.two-factor.recovery-codes.show');

routes/web.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use Statamic\Http\Middleware\CP\AuthGuard as CPAuthGuard;
2929
use Statamic\Http\Middleware\CP\HandleInertiaRequests;
3030
use Statamic\Http\Middleware\RedirectIfTwoFactorSetupIncomplete;
31+
use Statamic\Http\Middleware\RequireElevatedSession;
3132
use Statamic\Statamic;
3233
use Statamic\StaticCaching\NoCache\CsrfTokenController;
3334
use Statamic\StaticCaching\NoCache\NoCacheController;
@@ -81,9 +82,10 @@
8182
Route::get('two-factor-challenge', [TwoFactorChallengeController::class, 'index'])->name('two-factor-challenge');
8283
Route::post('two-factor-challenge', [TwoFactorChallengeController::class, 'store']);
8384

84-
Route::withoutMiddleware(RedirectIfTwoFactorSetupIncomplete::class)->group(function () {
85-
Route::get('two-factor/enable', [TwoFactorAuthenticationController::class, 'enable'])->name('users.two-factor.enable');
85+
Route::middleware(['auth', RequireElevatedSession::class])->withoutMiddleware(RedirectIfTwoFactorSetupIncomplete::class)->group(function () {
86+
Route::post('two-factor/enable', [TwoFactorAuthenticationController::class, 'enable'])->name('users.two-factor.enable');
8687
Route::post('two-factor/confirm', [TwoFactorAuthenticationController::class, 'confirm'])->name('users.two-factor.confirm');
88+
Route::delete('two-factor/disable', [TwoFactorAuthenticationController::class, 'disable'])->name('users.two-factor.disable');
8789
Route::get('two-factor/recovery-codes', [TwoFactorRecoveryCodesController::class, 'show'])->name('users.two-factor.recovery-codes.show');
8890
Route::post('two-factor/recovery-codes', [TwoFactorRecoveryCodesController::class, 'store'])->name('users.two-factor.recovery-codes.generate');
8991
Route::get('two-factor/recovery-codes/download', [TwoFactorRecoveryCodesController::class, 'download'])->name('users.two-factor.recovery-codes.download');

src/Auth/UserTags.php

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Illuminate\Support\Collection;
66
use Statamic\Contracts\Auth\Role;
7+
use Statamic\Facades\TwoFactor;
78
use Statamic\Facades\URL;
89
use Statamic\Facades\User;
910
use Statamic\Fields\Field;
@@ -795,6 +796,318 @@ public function eventUrl($url, $relative = false)
795796
);
796797
}
797798

799+
/**
800+
* Output a boolean of whether two-factor auth is enabled for the user.
801+
*
802+
* Maps to {{ user:two_factor_enabled }}
803+
*/
804+
public function twoFactorEnabled(): bool
805+
{
806+
return (bool) User::current()?->hasEnabledTwoFactorAuthentication();
807+
}
808+
809+
/**
810+
* Output a two-factor challenge form for login verification.
811+
*
812+
* Maps to {{ user:two_factor_challenge_form }}
813+
*
814+
* @return string|array
815+
*/
816+
public function twoFactorChallengeForm()
817+
{
818+
if (
819+
! TwoFactor::enabled()
820+
|| session()->missing('login.id')
821+
) {
822+
return;
823+
}
824+
825+
$params = [];
826+
827+
$data = $this->getFormSession();
828+
829+
$knownParams = ['redirect', 'error_redirect', 'allow_request_redirect'];
830+
831+
$method = 'POST';
832+
$action = route('statamic.two-factor-challenge');
833+
834+
if ($redirect = $this->getRedirectUrl()) {
835+
$params['redirect'] = $this->parseRedirect($redirect);
836+
}
837+
838+
if ($errorRedirect = $this->getErrorRedirectUrl()) {
839+
$params['error_redirect'] = $this->parseRedirect($errorRedirect);
840+
}
841+
842+
if (! $this->canParseContents()) {
843+
return array_merge([
844+
'attrs' => $this->formAttrs($action, $method, $knownParams),
845+
'params' => $this->formMetaPrefix($this->formParams($method, $params)),
846+
], $data);
847+
}
848+
849+
$html = $this->formOpen($action, $method, $knownParams);
850+
851+
$html .= $this->formMetaFields($params);
852+
853+
$html .= $this->parse($data);
854+
855+
$html .= $this->formClose();
856+
857+
return $html;
858+
}
859+
860+
/**
861+
* Output a two-factor enable form.
862+
*
863+
* Maps to {{ user:two_factor_enable_form }}
864+
*
865+
* @return string|array
866+
*/
867+
public function twoFactorEnableForm()
868+
{
869+
$user = User::current();
870+
871+
if (
872+
! TwoFactor::enabled()
873+
|| ! $user
874+
|| $user->hasEnabledTwoFactorAuthentication()
875+
) {
876+
return;
877+
}
878+
879+
$params = [];
880+
881+
$data = $this->getFormSession('user.two_factor_enable');
882+
883+
$knownParams = ['redirect', 'allow_request_redirect'];
884+
885+
$method = 'POST';
886+
$action = route('statamic.users.two-factor.enable');
887+
888+
if ($redirect = $this->getRedirectUrl()) {
889+
$params['redirect'] = $this->parseRedirect($redirect);
890+
}
891+
892+
if (! $this->canParseContents()) {
893+
return array_merge([
894+
'attrs' => $this->formAttrs($action, $method, $knownParams),
895+
'params' => $this->formMetaPrefix($this->formParams($method, $params)),
896+
], $data);
897+
}
898+
899+
$html = $this->formOpen($action, $method, $knownParams);
900+
901+
$html .= $this->formMetaFields($params);
902+
903+
$html .= $this->parse($data);
904+
905+
$html .= $this->formClose();
906+
907+
return $html;
908+
}
909+
910+
/**
911+
* Output a two-factor setup form.
912+
*
913+
* Maps to {{ user:two_factor_setup_form }}
914+
*
915+
* @return string|array
916+
*/
917+
public function twoFactorSetupForm()
918+
{
919+
$user = User::current();
920+
921+
if (
922+
! TwoFactor::enabled()
923+
|| ! $user
924+
|| $user->hasEnabledTwoFactorAuthentication()
925+
|| empty($user->two_factor_secret)
926+
) {
927+
return;
928+
}
929+
930+
$params = [];
931+
932+
$data = $this->getFormSession('user.two_factor_setup');
933+
934+
$data['qr_code'] = $user->twoFactorQrCodeSvg();
935+
$data['qr_code_url'] = 'data:image/svg+xml;base64,'.base64_encode($user->twoFactorQrCodeSvg());
936+
$data['secret_key'] = $user->twoFactorSecretKey();
937+
938+
$knownParams = ['redirect', 'error_redirect', 'allow_request_redirect'];
939+
940+
$method = 'POST';
941+
$action = route('statamic.users.two-factor.confirm');
942+
943+
if ($redirect = $this->getRedirectUrl()) {
944+
$params['redirect'] = $this->parseRedirect($redirect);
945+
}
946+
947+
if ($errorRedirect = $this->getErrorRedirectUrl()) {
948+
$params['error_redirect'] = $this->parseRedirect($errorRedirect);
949+
}
950+
951+
if (! $this->canParseContents()) {
952+
return array_merge([
953+
'attrs' => $this->formAttrs($action, $method, $knownParams),
954+
'params' => $this->formMetaPrefix($this->formParams($method, $params)),
955+
], $data);
956+
}
957+
958+
$html = $this->formOpen($action, $method, $knownParams);
959+
960+
$html .= $this->formMetaFields($params);
961+
962+
$html .= $this->parse($data);
963+
964+
$html .= $this->formClose();
965+
966+
return $html;
967+
}
968+
969+
/**
970+
* Output the user's two-factor recovery codes.
971+
*
972+
* Maps to {{ user:two_factor_recovery_codes }}
973+
*
974+
* @return array|string
975+
*/
976+
public function twoFactorRecoveryCodes()
977+
{
978+
$user = User::current();
979+
980+
if (
981+
! TwoFactor::enabled()
982+
|| ! $user?->hasEnabledTwoFactorAuthentication()
983+
) {
984+
return $this->parser ? null : [];
985+
}
986+
987+
$codes = collect($user->twoFactorRecoveryCodes())->map(fn ($code) => ['code' => $code]);
988+
989+
return $this->parser ? $this->parseLoop($codes) : $codes->all();
990+
}
991+
992+
/**
993+
* Outputs a URL to download two-factor recovery codes.
994+
*
995+
* Maps to {{ user:two_factor_recovery_codes_download_url }}
996+
*
997+
* @return string
998+
*/
999+
public function twoFactorRecoveryCodesDownloadUrl()
1000+
{
1001+
$user = User::current();
1002+
1003+
if (
1004+
! TwoFactor::enabled()
1005+
|| ! $user?->hasEnabledTwoFactorAuthentication()
1006+
) {
1007+
return;
1008+
}
1009+
1010+
return route('statamic.users.two-factor.recovery-codes.download');
1011+
}
1012+
1013+
/**
1014+
* Output a form to regenerate two-factor recovery codes.
1015+
*
1016+
* Maps to {{ user:reset_two_factor_recovery_codes_form }}
1017+
*
1018+
* @return string|array
1019+
*/
1020+
public function resetTwoFactorRecoveryCodesForm()
1021+
{
1022+
$user = User::current();
1023+
1024+
if (
1025+
! TwoFactor::enabled()
1026+
|| ! $user?->hasEnabledTwoFactorAuthentication()
1027+
) {
1028+
return;
1029+
}
1030+
1031+
$params = [];
1032+
1033+
$data = $this->getFormSession('user.two_factor_reset_recovery_codes');
1034+
1035+
$knownParams = ['redirect', 'allow_request_redirect'];
1036+
1037+
$method = 'POST';
1038+
$action = route('statamic.users.two-factor.recovery-codes.generate');
1039+
1040+
if ($redirect = $this->getRedirectUrl()) {
1041+
$params['redirect'] = $this->parseRedirect($redirect);
1042+
}
1043+
1044+
if (! $this->canParseContents()) {
1045+
return array_merge([
1046+
'attrs' => $this->formAttrs($action, $method, $knownParams),
1047+
'params' => $this->formMetaPrefix($this->formParams($method, $params)),
1048+
], $data);
1049+
}
1050+
1051+
$html = $this->formOpen($action, $method, $knownParams);
1052+
1053+
$html .= $this->formMetaFields($params);
1054+
1055+
$html .= $this->parse($data);
1056+
1057+
$html .= $this->formClose();
1058+
1059+
return $html;
1060+
}
1061+
1062+
/**
1063+
* Output a form to disable two-factor authentication.
1064+
*
1065+
* Maps to {{ user:disable_two_factor_form }}
1066+
*
1067+
* @return string|array
1068+
*/
1069+
public function disableTwoFactorForm()
1070+
{
1071+
$user = User::current();
1072+
1073+
if (
1074+
! TwoFactor::enabled()
1075+
|| ! $user?->hasEnabledTwoFactorAuthentication()
1076+
) {
1077+
return;
1078+
}
1079+
1080+
$params = [];
1081+
1082+
$data = $this->getFormSession('user.two_factor_disable');
1083+
1084+
$knownParams = ['redirect', 'allow_request_redirect'];
1085+
1086+
$method = 'DELETE';
1087+
$action = route('statamic.users.two-factor.disable');
1088+
1089+
if ($redirect = $this->getRedirectUrl()) {
1090+
$params['redirect'] = $this->parseRedirect($redirect);
1091+
}
1092+
1093+
if (! $this->canParseContents()) {
1094+
return array_merge([
1095+
'attrs' => $this->formAttrs($action, $method, $knownParams),
1096+
'params' => $this->formMetaPrefix($this->formParams($method, $params)),
1097+
], $data);
1098+
}
1099+
1100+
$html = $this->formOpen($action, $method, $knownParams);
1101+
1102+
$html .= $this->formMetaFields($params);
1103+
1104+
$html .= $this->parse($data);
1105+
1106+
$html .= $this->formClose();
1107+
1108+
return $html;
1109+
}
1110+
7981111
/**
7991112
* Get the redirect URL.
8001113
*

0 commit comments

Comments
 (0)