Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
8 changes: 8 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 @@ -53,6 +54,13 @@
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/passkey-options', [ElevatedSessionController::class, 'options'])->name('elevated-session.passkey-options');
Route::get('elevated-session/resend-code', [ElevatedSessionController::class, 'resendCode'])->name('elevated-session.resend-code')->middleware('throttle:send-elevated-session-code');
});

Route::group(['prefix' => 'passkeys'], function () {
Route::middleware(ThrottleRequests::class.':30,1')->group(function () {
Route::get('options', [PasskeyLoginController::class, 'options'])->name('passkeys.options');
Expand Down
47 changes: 47 additions & 0 deletions src/Auth/UserTags.php
Original file line number Diff line number Diff line change
Expand Up @@ -738,6 +738,53 @@ 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 === 'verification_code') {
session()->sendElevatedSessionVerificationCodeIfRequired();
}

$data = [
...$this->getFormSession('user.elevated_session'),
'method' => $method,
'allow_passkey' => $method !== 'verification_code' && $user->passkeys()->isNotEmpty(),
'resend_code_url' => route('statamic.elevated-session.resend-code'),
'passkey_options_url' => route('statamic.elevated-session.passkey-options'),
'submit_url' => route('statamic.elevated-session.confirm'),
];

$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);
}
}
153 changes: 153 additions & 0 deletions src/Http/Controllers/Auth/ElevatedSessionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?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()->to($customUrl);
}

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

return Inertia::render('auth/ConfirmPassword', [
'outside' => true,
'method' => $method,
'allowPasskey' => $method !== 'verification_code' && $user->passkeys()->isNotEmpty(),
'status' => session('status'),
'submitUrl' => route('statamic.elevated-session.confirm'),
'resendUrl' => route('statamic.elevated-session.resend-code'),
'passkeyOptionsUrl' => route('statamic.elevated-session.passkey-options'),
]);
}

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');

$redirect = redirect()->intended(route('statamic.site'));

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