Skip to content

Commit a206f4c

Browse files
duncanmccleanclaudejasonvarga
authored
[6.x] Frontend Elevated Sessions (#14424)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Jason Varga <jason@pixelfear.com>
1 parent 94c36bc commit a206f4c

17 files changed

Lines changed: 849 additions & 158 deletions

config/users.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,8 @@
181181

182182
'elevated_session_duration' => 15,
183183

184+
'elevated_session_url' => null,
185+
184186
/*
185187
|--------------------------------------------------------------------------
186188
| Two-Factor Authentication

lang/en/validation.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@
169169
'date_fieldtype_time_required' => 'Time is required.',
170170
'duplicate_field_handle' => 'A field with a handle of :handle already exists.',
171171
'duplicate_uri' => 'Duplicate URI :value',
172+
'elevated_session_resend_code_unavailable' => 'Resend code is only available for verification code method.',
172173
'elevated_session_verification_code' => 'The verification code is incorrect.',
173174
'email_available' => 'A user with this email already exists.',
174175
'fieldset_imported_recursively' => 'Fieldset :handle is being imported recursively.',

resources/js/pages/auth/ConfirmPassword.vue

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1+
<script>
2+
import Outside from '@/pages/layout/Outside.vue';
3+
import Layout from '@/pages/layout/Layout.vue';
4+
5+
export default {
6+
layout: (h, page) => page.props.outside ? h(Outside, () => page) : h(Layout, () => page),
7+
};
8+
</script>
9+
110
<script setup>
211
import Head from '@/pages/layout/Head.vue';
312
import { AuthCard, Input, Field, Button, Description, ErrorMessage, Separator } from '@ui';
413
import { computed } from 'vue';
514
import { Form, router } from '@inertiajs/vue3';
615
import { usePasskey } from '@/composables/passkey';
716
8-
const props = defineProps(['method', 'allowPasskey', 'status', 'submitUrl', 'resendUrl', 'passkeyOptionsUrl']);
17+
const props = defineProps(['method', 'allowPasskey', 'status', 'submitUrl', 'resendUrl', 'passkeyOptionsUrl', 'outside']);
918
const isConfirmingPassword = computed(() => props.method === 'password_confirmation');
1019
const isUsingVerificationCode = computed(() => props.method === 'verification_code');
1120
const isOnlyUsingPasskey = computed(() => props.method === 'passkey');
@@ -45,7 +54,7 @@ async function confirmWithPasskey() {
4554

4655
<Button
4756
v-if="isUsingVerificationCode"
48-
as="href"
57+
as="a"
4958
class="flex-1"
5059
:href="resendUrl"
5160
:text="__('Resend code')"

routes/cp.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -445,8 +445,8 @@
445445

446446
Route::get('auth/confirm-password', [ElevatedSessionController::class, 'showForm'])->name('confirm-password');
447447
Route::get('elevated-session', [ElevatedSessionController::class, 'status'])->name('elevated-session.status');
448-
Route::get('elevated-session/passkey-options', [ElevatedSessionController::class, 'options'])->name('elevated-session.passkey-options');
449-
Route::post('elevated-session', [ElevatedSessionController::class, 'confirm'])->name('elevated-session.confirm');
448+
Route::get('elevated-session/passkey-options', [ElevatedSessionController::class, 'options'])->name('elevated-session.passkey-options')->middleware('throttle:statamic.cp.passkeys');
449+
Route::post('elevated-session', [ElevatedSessionController::class, 'confirm'])->name('elevated-session.confirm')->middleware('throttle:statamic.cp.auth');
450450
Route::get('elevated-session/resend-code', [ElevatedSessionController::class, 'resendCode'])->name('elevated-session.resend-code')->middleware('throttle:send-elevated-session-code');
451451

452452
Route::get('playground', PlaygroundController::class)->name('playground');

routes/web.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Statamic\Facades\OAuth;
88
use Statamic\Facades\TwoFactor;
99
use Statamic\Http\Controllers\ActivateAccountController;
10+
use Statamic\Http\Controllers\Auth\ElevatedSessionController;
1011
use Statamic\Http\Controllers\ForgotPasswordController;
1112
use Statamic\Http\Controllers\FormController;
1213
use Statamic\Http\Controllers\FrontendController;
@@ -53,6 +54,13 @@
5354
Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset');
5455
Route::post('password/reset', [ResetPasswordController::class, 'reset'])->middleware('throttle:statamic.auth')->name('password.reset.action');
5556

57+
Route::middleware('auth')->group(function () {
58+
Route::get('confirm-password', [ElevatedSessionController::class, 'showForm'])->name('elevated-session')->middleware([HandleInertiaRequests::class]);
59+
Route::post('elevated-session', [ElevatedSessionController::class, 'confirm'])->name('elevated-session.confirm')->middleware('throttle:statamic.auth');
60+
Route::get('elevated-session/passkey-options', [ElevatedSessionController::class, 'options'])->name('elevated-session.passkey-options')->middleware('throttle:statamic.passkeys');
61+
Route::get('elevated-session/resend-code', [ElevatedSessionController::class, 'resendCode'])->name('elevated-session.resend-code')->middleware('throttle:send-elevated-session-code');
62+
});
63+
5664
Route::group(['prefix' => 'passkeys'], function () {
5765
Route::middleware('throttle:statamic.passkeys')->group(function () {
5866
Route::get('options', [PasskeyLoginController::class, 'options'])->name('passkeys.options');

src/Auth/UserTags.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,53 @@ public function notIn()
738738
return $in ? null : $this->parse();
739739
}
740740

741+
/**
742+
* Output an elevated session form.
743+
*
744+
* Maps to {{ user:elevated_session_form }}
745+
*
746+
* @return string
747+
*/
748+
public function elevatedSessionForm()
749+
{
750+
if (! ($user = User::current())) {
751+
return;
752+
}
753+
754+
$method = $user->getElevatedSessionMethod();
755+
756+
if ($method === 'verification_code') {
757+
session()->sendElevatedSessionVerificationCodeIfRequired();
758+
}
759+
760+
$data = [
761+
...$this->getFormSession('user.elevated_session'),
762+
'method' => $method,
763+
'allow_passkey' => $method !== 'verification_code' && $user->passkeys()->isNotEmpty(),
764+
'resend_code_url' => route('statamic.elevated-session.resend-code'),
765+
'passkey_options_url' => route('statamic.elevated-session.passkey-options'),
766+
'submit_url' => route('statamic.elevated-session.confirm'),
767+
];
768+
769+
$action = route('statamic.elevated-session.confirm');
770+
$method = 'POST';
771+
772+
if (! $this->canParseContents()) {
773+
return array_merge([
774+
'attrs' => $this->formAttrs($action, $method),
775+
'params' => $this->formMetaPrefix($this->formParams($method)),
776+
], $data);
777+
}
778+
779+
$html = $this->formOpen($action, $method);
780+
781+
$html .= $this->parse($data);
782+
783+
$html .= $this->formClose();
784+
785+
return $html;
786+
}
787+
741788
/**
742789
* {@inheritdoc}
743790
*/

src/Exceptions/ElevatedSessionAuthorizationException.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace Statamic\Exceptions;
44

55
use Illuminate\Http\Request;
6+
use Statamic\Statamic;
67

78
class ElevatedSessionAuthorizationException extends \Exception
89
{
@@ -13,8 +14,14 @@ public function __construct()
1314

1415
public function render(Request $request)
1516
{
16-
return $request->wantsJson()
17-
? response()->json(['message' => $this->getMessage()], 403)
18-
: redirect()->setIntendedUrl($request->fullUrl())->to(cp_route('confirm-password'));
17+
if ($request->wantsJson()) {
18+
return response()->json(['message' => $this->getMessage()], 403);
19+
}
20+
21+
$redirectUrl = Statamic::isCpRoute()
22+
? cp_route('confirm-password')
23+
: route('statamic.elevated-session');
24+
25+
return redirect()->setIntendedUrl($request->fullUrl())->to($redirectUrl);
1926
}
2027
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
<?php
2+
3+
namespace Statamic\Http\Controllers\Auth;
4+
5+
use Illuminate\Http\Exceptions\HttpResponseException;
6+
use Illuminate\Http\Request;
7+
use Illuminate\Support\Facades\Hash;
8+
use Illuminate\Validation\ValidationException;
9+
use Inertia\Inertia;
10+
use Statamic\Auth\WebAuthn\Serializer;
11+
use Statamic\Facades\User;
12+
use Statamic\Facades\WebAuthn;
13+
use Statamic\Http\Controllers\Controller;
14+
use Statamic\Http\Requests\Auth\ElevatedSessionConfirmationRequest;
15+
16+
class ElevatedSessionController extends Controller
17+
{
18+
public function showForm(Request $request)
19+
{
20+
if ($customUrl = config('statamic.users.elevated_session_url')) {
21+
return redirect()->to($customUrl);
22+
}
23+
24+
$user = User::current();
25+
$method = $user->getElevatedSessionMethod();
26+
27+
if ($method === 'verification_code') {
28+
session()->sendElevatedSessionVerificationCodeIfRequired();
29+
}
30+
31+
return Inertia::render('auth/ConfirmPassword', [
32+
'outside' => true,
33+
'method' => $method,
34+
'allowPasskey' => $method !== 'verification_code' && $user->passkeys()->isNotEmpty(),
35+
'status' => session('status'),
36+
'submitUrl' => route('statamic.elevated-session.confirm'),
37+
'resendUrl' => route('statamic.elevated-session.resend-code'),
38+
'passkeyOptionsUrl' => route('statamic.elevated-session.passkey-options'),
39+
]);
40+
}
41+
42+
public function confirm(ElevatedSessionConfirmationRequest $request)
43+
{
44+
$user = User::current();
45+
46+
$this->validatePasswordConfirmation($request, $user);
47+
$this->validateVerificationCodeConfirmation($request);
48+
$this->validatePasskeyConfirmation($request, $user);
49+
50+
session()->elevate();
51+
52+
return $this->buildConfirmResponse($request, $user);
53+
}
54+
55+
protected function buildConfirmResponse(Request $request, $user)
56+
{
57+
$message = $user->getElevatedSessionMethod() === 'password_confirmation'
58+
? __('Password confirmed')
59+
: __('Code verified');
60+
61+
$redirect = redirect()->intended(route('statamic.site'));
62+
63+
if ($request->wantsJson()) {
64+
return response()->json([
65+
'elevated' => true,
66+
'expiry' => $request->getElevatedSessionExpiry(),
67+
'redirect' => $redirect->getTargetUrl(),
68+
]);
69+
}
70+
71+
return $request->inertia()
72+
? Inertia::location($redirect->getTargetUrl())
73+
: $redirect->with('success', $message);
74+
}
75+
76+
public function options()
77+
{
78+
$options = WebAuthn::prepareAssertion();
79+
80+
return app(Serializer::class)->normalize($options);
81+
}
82+
83+
public function resendCode()
84+
{
85+
if (User::current()->getElevatedSessionMethod() !== 'verification_code') {
86+
throw ValidationException::withMessages([
87+
'method' => __('statamic::validation.elevated_session_resend_code_unavailable'),
88+
]);
89+
}
90+
91+
session()->sendElevatedSessionVerificationCode();
92+
93+
return back()->with('status', __('statamic::messages.elevated_session_verification_code_sent'));
94+
}
95+
96+
private function validatePasswordConfirmation(Request $request, $user): void
97+
{
98+
if (! $request->filled('password')) {
99+
return;
100+
}
101+
102+
if (Hash::check($request->password, $user->password())) {
103+
return;
104+
}
105+
106+
$this->throwValidationException($request, [
107+
'password' => [__('statamic::validation.current_password')],
108+
]);
109+
}
110+
111+
private function validateVerificationCodeConfirmation(Request $request): void
112+
{
113+
if (! $request->filled('verification_code')) {
114+
return;
115+
}
116+
117+
$verificationCode = $request->verification_code;
118+
$storedVerificationCode = $request->getElevatedSessionVerificationCode();
119+
120+
if (
121+
is_string($verificationCode)
122+
&& is_string($storedVerificationCode)
123+
&& hash_equals($storedVerificationCode, $verificationCode)
124+
) {
125+
return;
126+
}
127+
128+
$this->throwValidationException($request, [
129+
'verification_code' => [__('statamic::validation.elevated_session_verification_code')],
130+
]);
131+
}
132+
133+
protected function throwValidationException(Request $request, array $errors): never
134+
{
135+
if ($request->wantsJson() || $request->inertia()) {
136+
throw ValidationException::withMessages($errors);
137+
}
138+
139+
throw new HttpResponseException(
140+
back()->withInput()->withErrors($errors, 'user.elevated_session')
141+
);
142+
}
143+
144+
private function validatePasskeyConfirmation(Request $request, $user): void
145+
{
146+
if (! $request->filled('id')) {
147+
return;
148+
}
149+
150+
$credentials = $request->only(['id', 'rawId', 'response', 'type']);
151+
WebAuthn::validateAssertion($user, $credentials);
152+
}
153+
}

0 commit comments

Comments
 (0)