Skip to content

[6.x] Elevated Sessions #11688

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 35 commits into from
Apr 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
480f357
Elevated Sessions
duncanmcclean Apr 10, 2025
5752f1c
Require elevated session before changing another user's password
duncanmcclean Apr 10, 2025
d2bb314
Add ability for actions to require elevated sessions
duncanmcclean Apr 10, 2025
6f050e2
Require elevated session before copying password reset link
duncanmcclean Apr 10, 2025
094a928
Require elevated session when updating permissions on a role
duncanmcclean Apr 10, 2025
7912a02
wip
duncanmcclean Apr 10, 2025
dc08871
Improve how we enforce elevated sessions on the backend
duncanmcclean Apr 10, 2025
d351866
Update tests
duncanmcclean Apr 10, 2025
9d2f48c
Include user ID in session key
duncanmcclean Apr 10, 2025
552556c
Rename session key
duncanmcclean Apr 10, 2025
b4be28d
Pint
duncanmcclean Apr 10, 2025
6d8ecad
Wordsmithing.
duncanmcclean Apr 10, 2025
d501c9b
Merge remote-tracking branch 'origin/master' into elevated-sessions
duncanmcclean Apr 22, 2025
de6fcda
Wire up elevated session modal
duncanmcclean Apr 24, 2025
8b33826
Catch promise returned by `this.requireElevatedSession`
duncanmcclean Apr 24, 2025
526d61c
Merge remote-tracking branch 'origin/master' into elevated-sessions
duncanmcclean Apr 24, 2025
2cb53ba
Prettier
duncanmcclean Apr 24, 2025
3cdd919
Make sure we're dealing with a Statamic user instance
duncanmcclean Apr 24, 2025
df56f84
nitpick
jasonvarga Apr 24, 2025
468298d
Don't require passing request in
jasonvarga Apr 24, 2025
8b3ef5b
nitpick
jasonvarga Apr 25, 2025
5608ddb
Only require elevated session if you're updating someone elses passwo…
jasonvarga Apr 25, 2025
ed62949
Use expiry instead of diff ...
jasonvarga Apr 25, 2025
9b77e29
Use macros to clean up and colocate logic
jasonvarga Apr 25, 2025
95a0c4b
Middleware tweaks and non-json path ...
jasonvarga Apr 25, 2025
b9eaf43
group tests so they can be run together
jasonvarga Apr 25, 2025
fbf7d21
Store the current timestamp in the session and calculate expiry when …
jasonvarga Apr 25, 2025
91e5def
Including user ids in session key is not necessary.
jasonvarga Apr 25, 2025
6f00df2
Nitpick
jasonvarga Apr 25, 2025
31b59e3
Require elevated session for impersonation
jasonvarga Apr 25, 2025
982926c
We now have a better dedicated exception. Test should be performing j…
jasonvarga Apr 25, 2025
9ad7c76
Show toast if elevated session was not completed when saving role
jasonvarga Apr 25, 2025
15aae20
Add a convenience method
jasonvarga Apr 25, 2025
5b34928
Move away from mixins so its usable in composition api
jasonvarga Apr 25, 2025
0642163
Expose
jasonvarga Apr 25, 2025
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
13 changes: 13 additions & 0 deletions config/users.php
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,19 @@
'redirect' => env('STATAMIC_IMPERSONATE_REDIRECT', null),
],

/*
|--------------------------------------------------------------------------
| Elevated Sessions
|--------------------------------------------------------------------------
|
| Users may be required to reauthorize before performing certain
| sensitive actions. This is called an elevated session. Here
| you may configure the duration of the session in minutes.
|
*/

'elevated_session_duration' => 15,

/*
|--------------------------------------------------------------------------
| Default Sorting
Expand Down
2 changes: 2 additions & 0 deletions resources/js/bootstrap/components.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import ConfirmationModal from '../components/modals/ConfirmationModal.vue';
import FavoriteCreator from '../components/FavoriteCreator.vue';
import KeyboardShortcutsModal from '../components/modals/KeyboardShortcutsModal.vue';
import FieldActionModal from '../components/field-actions/FieldActionModal.vue';
import ElevatedSessionModal from '../components/modals/ElevatedSessionModal.vue';
import ResourceDeleter from '../components/ResourceDeleter.vue';
import Stack from '../components/stacks/Stack.vue';
import StackTest from '../components/stacks/StackTest.vue';
Expand Down Expand Up @@ -147,6 +148,7 @@ export default function registerGlobalComponents(app) {
app.component('keyboard-shortcuts-modal', KeyboardShortcutsModal);
app.component('resource-deleter', ResourceDeleter);
app.component('field-action-modal', FieldActionModal);
app.component('elevated-session-modal', ElevatedSessionModal);

app.component('stack', Stack);
app.component('stack-test', StackTest);
Expand Down
14 changes: 10 additions & 4 deletions resources/js/components/data-list/Action.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@

<script>
import PublishFields from '../publish/Fields.vue';
import { requireElevatedSessionIf } from '@statamic/components/elevated-sessions';

export default {
components: {
Expand Down Expand Up @@ -129,13 +130,18 @@ export default {
return;
}

this.running = true;
this.$emit('selected', this.action, this.values, this.onDone);
this.performAction();
},

confirm() {
this.running = true;
this.$emit('selected', this.action, this.values, this.onDone);
this.performAction();
},

performAction() {
requireElevatedSessionIf(this.action.requiresElevatedSession).then(() => {
this.running = true;
this.$emit('selected', this.action, this.values, this.onDone);
});
},

reset() {
Expand Down
22 changes: 22 additions & 0 deletions resources/js/components/elevated-sessions/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import axios from 'axios';

export async function requireElevatedSession() {
const response = await axios.get(cp_url('elevated-session'));

if (response.data.elevated) return;

return new Promise((resolve, reject) => {
const component = Statamic.$components.append('elevated-session-modal', {
props: {},
});

component.on('closed', (shouldResolve) => {
shouldResolve ? resolve() : reject();
component.destroy();
});
});
}

export async function requireElevatedSessionIf(condition) {
return condition ? requireElevatedSession() : Promise.resolve();
}
63 changes: 63 additions & 0 deletions resources/js/components/modals/ElevatedSessionModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<template>
<modal name="elevated-session" height="auto" :width="500" @closed="modalClosed" v-slot="{ close }" click-to-close>
<div class="-max-h-screen-px">
<div
class="flex items-center justify-between rounded-t-lg border-b bg-gray-200 px-5 py-3 text-lg font-semibold dark:border-dark-900 dark:bg-dark-550"
>
{{ __('Confirm Your Password') }}
</div>

<div class="publish-fields p-2">
<div class="form-group w-full">
<label v-text="__('messages.elevated_session_enter_password')" />
<small class="help-block text-red-500" v-if="errors.password" v-text="errors.password[0]" />
<div class="flex items-center">
<input
type="password"
v-model="password"
ref="password"
class="input-text"
tabindex="1"
autofocus
@keydown.enter.prevent="submit"
/>
<button @click="submit(close)" class="btn-primary ltr:ml-2 rtl:mr-2" v-text="__('Confirm')" />
</div>
</div>
</div>
</div>
</modal>
</template>

<script>
export default {
data() {
return {
password: null,
errors: [],
shouldResolve: false,
};
},

methods: {
submit(close) {
this.$axios
.post(cp_url('elevated-session'), { password: this.password })
.then((response) => {
this.shouldResolve = true;
close();
})
.catch((error) => {
this.errors = error.response.data.errors;
if (error.response.status === 422) {
this.$refs.password.focus();
}
});
},

modalClosed() {
this.$emit('closed', this.shouldResolve);
},
},
};
</script>
8 changes: 8 additions & 0 deletions resources/js/components/roles/PublishForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@
</template>

<script>
import { requireElevatedSession } from '@statamic/components/elevated-sessions';

const checked = function (permissions) {
return permissions.reduce((carry, permission) => {
if (!permission.checked) return carry;
Expand Down Expand Up @@ -119,6 +121,12 @@ export default {
},

save() {
requireElevatedSession()
.then(() => this.performSaveAction())
.catch(() => this.$toast.error(__('Unable to save role')));
},

performSaveAction() {
this.clearErrors();

this.$axios[this.method](this.action, this.payload)
Expand Down
8 changes: 8 additions & 0 deletions resources/js/components/users/ChangePassword.vue
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
</template>

<script>
import { requireElevatedSessionIf } from '@statamic/components/elevated-sessions';

export default {
props: {
saveUrl: String,
Expand Down Expand Up @@ -76,6 +78,12 @@ export default {
},

save() {
requireElevatedSessionIf(!this.requiresCurrentPassword)
.then(() => this.performSaveRequest())
.catch(() => {});
},

performSaveRequest() {
this.clearErrors();
this.saving = true;

Expand Down
1 change: 1 addition & 0 deletions resources/js/exports.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { default as BardToolbarButton } from './components/fieldtypes/bard/Toolb
export { default as Listing } from './components/Listing.vue';
export * as FieldConditions from './components/field-conditions/FieldConditions';
export { default as ValidatesFieldConditions } from './components/field-conditions/ValidatorMixin';
export * from './components/elevated-sessions';
1 change: 1 addition & 0 deletions resources/lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@
'duplicate_action_localizations_confirmation' => 'Are you sure you want to run this action? Localizations will also be duplicated.',
'duplicate_action_warning_localization' => 'This entry is a localization. The origin entry will be duplicated.',
'duplicate_action_warning_localizations' => 'One or more selected entries are localizations. In those cases, the origin entry will be duplicated instead.',
'elevated_session_enter_password' => 'For security, please confirm your password to continue.',
'email_utility_configuration_description' => 'Mail settings are configured in <code>:path</code>',
'email_utility_description' => 'Check email configuration settings and send test emails.',
'entry_origin_instructions' => 'The new localization will inherit values from the entry in the selected site.',
Expand Down
44 changes: 44 additions & 0 deletions resources/views/auth/confirm-password.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
@php
use function Statamic\trans as __;
@endphp

@extends('statamic::outside')

@section('content')
@include('statamic::partials.outside-logo')

<div class="relative mx-auto flex max-w-xs items-center justify-center rounded shadow-lg">
<div class="outside-shadow absolute inset-0"></div>
<div class="card auth-card">
<div class="mb-4 pb-4 text-center">
<h1 class="mb-4 text-lg text-gray-800 dark:text-white/80">{{ __('Confirm Your Password') }}</h1>
<p class="text-sm text-gray dark:text-dark-175">
{{ __('statamic::messages.elevated_session_enter_password') }}
</p>
</div>

@if (session('status'))
<div class="alert alert-success mb-6">
{{ session('status') }}
</div>
@endif

<form method="POST" action="{{ cp_route('elevated-session.confirm') }}">
@csrf

<div class="mb-8">
<label for="password" class="mb-2">{{ __('Password') }}</label>
<input id="password" type="password" class="input-text" name="password" />

@error('password')
<div class="mt-2 text-xs text-red-500">{{ $message }}</div>
@enderror
</div>

<button type="submit" class="btn-primary">
{{ __('Submit') }}
</button>
</form>
</div>
</div>
@endsection
5 changes: 5 additions & 0 deletions routes/cp.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
use Statamic\Http\Controllers\CP\Assets\SvgController;
use Statamic\Http\Controllers\CP\Assets\ThumbnailController;
use Statamic\Http\Controllers\CP\Auth\CsrfTokenController;
use Statamic\Http\Controllers\CP\Auth\ElevatedSessionController;
use Statamic\Http\Controllers\CP\Auth\ExtendSessionController;
use Statamic\Http\Controllers\CP\Auth\ForgotPasswordController;
use Statamic\Http\Controllers\CP\Auth\ImpersonationController;
Expand Down Expand Up @@ -367,6 +368,10 @@
Route::post('slug', SlugController::class);
Route::get('session-timeout', SessionTimeoutController::class)->name('session.timeout');

Route::get('auth/confirm-password', [ElevatedSessionController::class, 'showForm'])->name('confirm-password');
Route::get('elevated-session', [ElevatedSessionController::class, 'status'])->name('elevated-session.status');
Route::post('elevated-session', [ElevatedSessionController::class, 'confirm'])->name('elevated-session.confirm');

Route::view('/playground', 'statamic::playground')->name('playground');

Route::get('edit/{id}', EditRedirectController::class);
Expand Down
6 changes: 6 additions & 0 deletions src/Actions/Action.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ public function bypassesDirtyWarning(): bool
return false;
}

public function requiresElevatedSession(): bool
{
return false;
}

public function toArray()
{
return [
Expand All @@ -130,6 +135,7 @@ public function toArray()
'warningText' => $this->warningText(),
'dirtyWarningText' => $this->dirtyWarningText(),
'bypassesDirtyWarning' => $this->bypassesDirtyWarning(),
'requiresElevatedSession' => $this->requiresElevatedSession(),
'dangerous' => $this->dangerous,
'fields' => $this->fields()->toPublishArray(),
'values' => $this->fields()->preProcess()->values(),
Expand Down
5 changes: 5 additions & 0 deletions src/Actions/CopyPasswordResetLink.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ public function authorize($authed, $user)
return $authed->can('sendPasswordReset', $user);
}

public function requiresElevatedSession(): bool
{
return true;
}

public function confirmationText()
{
/** @translation */
Expand Down
5 changes: 5 additions & 0 deletions src/Actions/Impersonate.php
Original file line number Diff line number Diff line change
Expand Up @@ -86,4 +86,9 @@ public function bypassesDirtyWarning(): bool
{
return true;
}

public function requiresElevatedSession(): bool
{
return true;
}
}
20 changes: 20 additions & 0 deletions src/Exceptions/ElevatedSessionAuthorizationException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

namespace Statamic\Exceptions;

use Illuminate\Http\Request;

class ElevatedSessionAuthorizationException extends \Exception
{
public function __construct()
{
parent::__construct(__('Requires an elevated session.'));
}

public function render(Request $request)
{
return $request->wantsJson()
? response()->json(['message' => $this->getMessage()], 403)
: redirect()->setIntendedUrl($request->fullUrl())->to('/cp/auth/confirm-password');
}
}
4 changes: 4 additions & 0 deletions src/Http/Controllers/CP/ActionController.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ public function run(Request $request)

abort_unless($unauthorized->isEmpty(), 403, __('You are not authorized to run this action.'));

if ($action->requiresElevatedSession()) {
$this->requireElevatedSession();
}

$values = $action->fields()->addValues($request->all())->process()->values()->all();
$successful = true;

Expand Down
45 changes: 45 additions & 0 deletions src/Http/Controllers/CP/Auth/ElevatedSessionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace Statamic\Http\Controllers\CP\Auth;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
use Statamic\Facades\User;

class ElevatedSessionController
{
public function status(Request $request)
{
return [
'elevated' => $request->hasElevatedSession(),
'expiry' => $request->getElevatedSessionExpiry(),
];
}

public function showForm()
{
return view('statamic::auth.confirm-password');
}

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

$validated = $request->validate([
'password' => 'required',
]);

if (! Hash::check($validated['password'], $user->password())) {
throw ValidationException::withMessages([
'password' => [__('statamic::validation.current_password')],
]);
}

session()->elevate();

return $request->wantsJson()
? $this->status($request)
: redirect()->intended(cp_route('index'))->with('success', __('Password confirmed'));
}
}
Loading