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

Draft
wants to merge 12 commits into
base: master
Choose a base branch
from
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
|--------------------------------------------------------------------------
|
| Before performing certain actions, users may be asked to confirm their
| password. This is called an elevated session. Here you can configure
| the duration of elevated sessions, in minutes.
|
*/

'elevated_session_duration' => 15,

/*
|--------------------------------------------------------------------------
| Default Sorting
Expand Down
20 changes: 18 additions & 2 deletions resources/js/components/data-list/Action.vue
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,11 @@

<script>
import PublishFields from '../publish/Fields.vue';
import HasElevatedSession from '@statamic/mixins/HasElevatedSession.js';

export default {
mixins: [HasElevatedSession],

components: {
PublishFields,
},
Expand Down Expand Up @@ -129,11 +132,24 @@ export default {
return;
}

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

this.performAction();
},

confirm() {
if (this.action.requiresElevatedSession) {
this.requireElevatedSession().then(() => this.performAction());
return;
}

this.performAction();
},

performAction() {
this.running = true;
this.$emit('selected', this.action, this.values, this.onDone);
},
Expand Down
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 HasElevatedSession from '@statamic/mixins/HasElevatedSession.js';

const checked = function (permissions) {
return permissions.reduce((carry, permission) => {
if (!permission.checked) return carry;
Expand All @@ -62,6 +64,8 @@ const checked = function (permissions) {
};

export default {
mixins: [HasElevatedSession],

props: {
initialTitle: String,
initialHandle: String,
Expand Down Expand Up @@ -119,6 +123,10 @@ export default {
},

save() {
this.requireElevatedSession().then(() => this.performSaveAction());
},

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,7 +41,11 @@
</template>

<script>
import HasElevatedSession from '@statamic/mixins/HasElevatedSession.js';

export default {
mixins: [HasElevatedSession],

props: {
saveUrl: String,
requiresCurrentPassword: Boolean,
Expand Down Expand Up @@ -76,6 +80,10 @@ export default {
},

save() {
this.requireElevatedSession().then(() => this.performSaveRequest());
},

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

Expand Down
29 changes: 29 additions & 0 deletions resources/js/mixins/HasElevatedSession.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
export default {
methods: {
async requireElevatedSession() {
const response = await this.$axios.get(cp_url('elevated-session'));

if (response.data.elevated) return;

const password = await this.askForPassword();

if (!password) throw new Error('User cancelled');

try {
await this.$axios.post(cp_url('elevated-session'), { password });
} catch (error) {
this.$toast.error(error.response.data.message);
throw error;
}
},

async askForPassword() {
// TODO: This should be an actual modal at some point.
return new Promise((resolve) => {
const password = prompt('You need to enter your password to continue.');

resolve(password);
});
},
},
};
4 changes: 4 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,9 @@
Route::post('slug', SlugController::class);
Route::get('session-timeout', SessionTimeoutController::class)->name('session.timeout');

Route::get('elevated-session', [ElevatedSessionController::class, 'index'])->name('elevated-session.status');
Route::post('elevated-session', [ElevatedSessionController::class, 'store'])->name('elevated-session.store');

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
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
39 changes: 39 additions & 0 deletions src/Http/Controllers/CP/Auth/ElevatedSessionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace Statamic\Http\Controllers\CP\Auth;

use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

class ElevatedSessionController
{
public function index(Request $request)
{
$isElevated = session()->get("statamic_elevated_session_{$request->user()->id}") > now()->timestamp;
$timeRemaining = $isElevated ? Carbon::parse(session()->get("statamic_elevated_session_{$request->user()->id}"))->diffInSeconds(absolute: true) : 0;

return ['elevated' => $isElevated, 'time_remaining' => $timeRemaining];
}

public function store(Request $request)
{
$validated = $request->validate([
'password' => 'required',
]);

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

session()->put(
"statamic_elevated_session_{$request->user()->id}",
now()->addMinutes(config('statamic.users.elevated_session_duration', 15))->timestamp
);

return $this->index($request);
}
}
9 changes: 9 additions & 0 deletions src/Http/Controllers/CP/CpController.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,4 +107,13 @@ public function authorizeProIf($condition)
return $this->authorizePro();
}
}

public function requireElevatedSession($request): void
{
abort_if(
boolean: session()->get("statamic_elevated_session_{$request->user()->id}") < now()->timestamp,
code: 403,
message: __('Requires an elevated session.')
);
}
}
8 changes: 8 additions & 0 deletions src/Http/Controllers/CP/Users/PasswordController.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,17 @@
use Statamic\Exceptions\NotFoundHttpException;
use Statamic\Facades\User;
use Statamic\Http\Controllers\CP\CpController;
use Statamic\Http\Middleware\CP\RequireElevatedSession;

class PasswordController extends CpController
{
public function __construct(Request $request)
{
parent::__construct($request);

$this->middleware(RequireElevatedSession::class);
}

public function update(Request $request, $user)
{
throw_unless($user = User::find($user), new NotFoundHttpException);
Expand Down
2 changes: 2 additions & 0 deletions src/Http/Controllers/CP/Users/RolesController.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ public function edit($role)

public function update(Request $request, $role)
{
$this->requireElevatedSession($request);

$this->authorize('edit roles');

if (! $role = Role::find($role)) {
Expand Down
19 changes: 19 additions & 0 deletions src/Http/Middleware/CP/RequireElevatedSession.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Statamic\Http\Middleware\CP;

use Closure;

class RequireElevatedSession
{
public function handle($request, Closure $next)
{
$elevatedSessionIsActive = session()->get("statamic_elevated_session_{$request->user()->id}") > now()->timestamp;

if (! $elevatedSessionIsActive) {
return response()->json(['error' => __('Requires an elevated session.')], 403);
}

return $next($request);
}
}
Loading