Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions config/users.php
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@

'elevated_session_duration' => 15,

'elevated_session_page' => null,

/*
|--------------------------------------------------------------------------
| Two-Factor Authentication
Expand Down
13 changes: 11 additions & 2 deletions resources/js/pages/auth/ConfirmPassword.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
<script>
import Outside from '@/pages/layout/Outside.vue';
import Layout from '@/pages/layout/Layout.vue';

export default {
layout: (h, page) => page.props.outside ? h(Outside, () => page) : h(Layout, () => page),
};
</script>

<script setup>
import Head from '@/pages/layout/Head.vue';
import { AuthCard, Input, Field, Button, Description, ErrorMessage, Separator } from '@ui';
import { computed } from 'vue';
import { Form, router } from '@inertiajs/vue3';
import { usePasskey } from '@/composables/passkey';

const props = defineProps(['method', 'allowPasskey', 'status', 'submitUrl', 'resendUrl', 'passkeyOptionsUrl']);
const props = defineProps(['method', 'allowPasskey', 'status', 'submitUrl', 'resendUrl', 'passkeyOptionsUrl', 'outside']);
const isConfirmingPassword = computed(() => props.method === 'password_confirmation');
const isUsingVerificationCode = computed(() => props.method === 'verification_code');
const isOnlyUsingPasskey = computed(() => props.method === 'passkey');
Expand Down Expand Up @@ -45,7 +54,7 @@ async function confirmWithPasskey() {

<Button
v-if="isUsingVerificationCode"
as="href"
as="a"
class="flex-1"
:href="resendUrl"
:text="__('Resend code')"
Expand Down
7 changes: 7 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Statamic\Facades\OAuth;
use Statamic\Facades\TwoFactor;
use Statamic\Http\Controllers\ActivateAccountController;
use Statamic\Http\Controllers\Auth\ElevatedSessionController;
use Statamic\Http\Controllers\ForgotPasswordController;
use Statamic\Http\Controllers\FormController;
use Statamic\Http\Controllers\FrontendController;
Expand Down Expand Up @@ -51,6 +52,12 @@
Route::get('password/reset/{token}', [ResetPasswordController::class, 'showResetForm'])->name('password.reset');
Route::post('password/reset', [ResetPasswordController::class, 'reset'])->name('password.reset.action');

Route::middleware('auth')->group(function () {
Route::get('confirm-password', [ElevatedSessionController::class, 'showForm'])->name('elevated-session')->middleware([HandleInertiaRequests::class]);
Route::post('elevated-session', [ElevatedSessionController::class, 'confirm'])->name('elevated-session.confirm');
Route::get('elevated-session/resend-code', [ElevatedSessionController::class, 'resendCode'])->name('elevated-session.resend-code')->middleware('throttle:send-elevated-session-code');
});

if (TwoFactor::enabled()) {
Route::get('two-factor-setup', TwoFactorSetupController::class)->name('two-factor-setup');
Route::get('two-factor-challenge', [TwoFactorChallengeController::class, 'index'])->name('two-factor-challenge');
Expand Down
49 changes: 49 additions & 0 deletions src/Auth/UserTags.php
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,55 @@ public function notIn()
return $in ? null : $this->parse();
}

/**
* Output an elevated session form.
*
* Maps to {{ user:elevated_session_form }}
*
* @return string
*/
public function elevatedSessionForm()
{
if (! ($user = User::current())) {
return;
}

$method = $user->getElevatedSessionMethod();

if ($method === 'passkey') {
// TODO: Implement frontend passkey support for elevated sessions
throw new \Exception('Passkey authentication for elevated sessions is not yet supported on the frontend.');
}

if ($method === 'verification_code') {
session()->sendElevatedSessionVerificationCodeIfRequired();
}

$data = [
...$this->getFormSession('user.elevated_session'),
'method' => $method,
'resend_code_url' => route('statamic.elevated-session.resend-code'),
];

$action = route('statamic.elevated-session.confirm');
$method = 'POST';

if (! $this->canParseContents()) {
return array_merge([
'attrs' => $this->formAttrs($action, $method),
'params' => $this->formMetaPrefix($this->formParams($method)),
], $data);
}

$html = $this->formOpen($action, $method);

$html .= $this->parse($data);

$html .= $this->formClose();

return $html;
}

/**
* {@inheritdoc}
*/
Expand Down
13 changes: 10 additions & 3 deletions src/Exceptions/ElevatedSessionAuthorizationException.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Statamic\Exceptions;

use Illuminate\Http\Request;
use Statamic\Statamic;

class ElevatedSessionAuthorizationException extends \Exception
{
Expand All @@ -13,8 +14,14 @@ public function __construct()

public function render(Request $request)
{
return $request->wantsJson()
? response()->json(['message' => $this->getMessage()], 403)
: redirect()->setIntendedUrl($request->fullUrl())->to(cp_route('confirm-password'));
if ($request->wantsJson()) {
return response()->json(['message' => $this->getMessage()], 403);
}

$redirectUrl = Statamic::isCpRoute()
? cp_route('confirm-password')
: route('statamic.elevated-session');

return redirect()->setIntendedUrl($request->fullUrl())->to($redirectUrl);
}
}
158 changes: 158 additions & 0 deletions src/Http/Controllers/Auth/ElevatedSessionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
<?php

namespace Statamic\Http\Controllers\Auth;

use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
use Inertia\Inertia;
use Statamic\Auth\WebAuthn\Serializer;
use Statamic\Facades\User;
use Statamic\Facades\WebAuthn;
use Statamic\Http\Controllers\Controller;
use Statamic\Http\Requests\Auth\ElevatedSessionConfirmationRequest;

class ElevatedSessionController extends Controller
{
public function showForm(Request $request)
{
$user = User::current();
$method = $user->getElevatedSessionMethod();

if ($customUrl = config('statamic.users.elevated_session_page')) {
return redirect()->setIntendedUrl($request->fullUrl())->to($customUrl);
}

if ($method === 'passkey') {
// TODO: Implement frontend passkey support for elevated sessions
throw new \Exception('Passkey authentication for elevated sessions is not yet supported on the frontend.');
}

if ($method === 'verification_code') {
session()->sendElevatedSessionVerificationCodeIfRequired();
}

return Inertia::render('auth/ConfirmPassword', [
'outside' => true,
'method' => $method,
'allowPasskey' => false,
'status' => session('status'),
'submitUrl' => route('statamic.elevated-session.confirm'),
'resendUrl' => route('statamic.elevated-session.resend-code'),
]);
}

public function confirm(ElevatedSessionConfirmationRequest $request)
{
$user = User::current();

$this->validatePasswordConfirmation($request, $user);
$this->validateVerificationCodeConfirmation($request);
$this->validatePasskeyConfirmation($request, $user);

session()->elevate();

return $this->buildConfirmResponse($request, $user);
}

protected function buildConfirmResponse(Request $request, $user)
{
$message = $user->getElevatedSessionMethod() === 'password_confirmation'
? __('Password confirmed')
: __('Code verified');

$default = $request->input('_redirect') ?? route('statamic.site');
$redirect = redirect()->intended($default);

if ($request->wantsJson()) {
return response()->json([
'elevated' => true,
'expiry' => $request->getElevatedSessionExpiry(),
'redirect' => $redirect->getTargetUrl(),
]);
}

return $request->inertia()
? Inertia::location($redirect->getTargetUrl())
: $redirect->with('success', $message);
}

public function options()
{
$options = WebAuthn::prepareAssertion();

return app(Serializer::class)->normalize($options);
}

public function resendCode()
{
if (User::current()->getElevatedSessionMethod() !== 'verification_code') {
throw ValidationException::withMessages([
'method' => 'Resend code is only available for verification code method',
]);
}

session()->sendElevatedSessionVerificationCode();

return back()->with('status', __('statamic::messages.elevated_session_verification_code_sent'));
}

private function validatePasswordConfirmation(Request $request, $user): void
{
if (! $request->filled('password')) {
return;
}

if (Hash::check($request->password, $user->password())) {
return;
}

$this->throwValidationException($request, [
'password' => [__('statamic::validation.current_password')],
]);
}

private function validateVerificationCodeConfirmation(Request $request): void
{
if (! $request->filled('verification_code')) {
return;
}

$verificationCode = $request->verification_code;
$storedVerificationCode = $request->getElevatedSessionVerificationCode();

if (
is_string($verificationCode)
&& is_string($storedVerificationCode)
&& hash_equals($storedVerificationCode, $verificationCode)
) {
return;
}

$this->throwValidationException($request, [
'verification_code' => [__('statamic::validation.elevated_session_verification_code')],
]);
}

protected function throwValidationException(Request $request, array $errors): never
{
if ($request->wantsJson()) {
throw ValidationException::withMessages($errors);
}

throw new HttpResponseException(
back()->withInput()->withErrors($errors, 'user.elevated_session')
);
}

private function validatePasskeyConfirmation(Request $request, $user): void
{
if (! $request->filled('id')) {
return;
}

$credentials = $request->only(['id', 'rawId', 'response', 'type']);
WebAuthn::validateAssertion($user, $credentials);
}
}
Loading
Loading