Skip to content

Commit

Permalink
NEW Form sudo mode
Browse files Browse the repository at this point in the history
  • Loading branch information
emteknetnz committed Feb 17, 2025
1 parent 8a2a7cc commit d218c67
Show file tree
Hide file tree
Showing 15 changed files with 185 additions and 15 deletions.
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
},
"conflict": {
"silverstripe/behat-extension": "<5.5",
"silverstripe/mfa": "<5.4",
"egulias/email-validator": "^2",
"oscarotero/html-parser": "<0.1.7",
"symfony/process": "<5.3.7"
Expand Down
38 changes: 38 additions & 0 deletions src/Forms/Form.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace SilverStripe\Forms;

use BadMethodCallException;
use SilverStripe\Admin\LeftAndMain;
use SilverStripe\Control\Controller;
use SilverStripe\Control\HasRequestHandler;
use SilverStripe\Control\HTTPRequest;
Expand All @@ -21,6 +22,8 @@
use SilverStripe\View\SSViewer;
use SilverStripe\View\ViewableData;
use SilverStripe\Dev\Deprecation;
use SilverStripe\Security\SudoMode\SudoModeServiceInterface;
use SilverStripe\Forms\GridField\GridFieldDetailForm_ItemRequest;

/**
* Base class for all forms.
Expand Down Expand Up @@ -317,6 +320,41 @@ public function __construct(
$this->setupDefaultClasses();
}

/**
* Make the form require sudo mode, which will make the form readonly and add a sudo mode password field
* unless the current user previously activated sudo mode.
*/
public function requireSudoMode(): void
{
// Check that the current request handler for the form is one that's used
// in an admin context where sudo mode makes sense
$classes = [
LeftAndMain::class,
GridFieldDetailForm_ItemRequest::class,
];
$enableSudoMode = false;
foreach ($classes as $class) {
if (is_a($this->getController(), $class)) {
$enableSudoMode = true;
break;
}
}
if (!$enableSudoMode) {
return;
}
// Check if sudo mode is currently enabled
$service = Injector::inst()->get(SudoModeServiceInterface::class);
$session = $this->getRequest()->getSession();
if ($service->check($session)) {
return;
}
// If sudo mode is not active, make the form readonly and add a sudo mode password field
$this->makeReadonly();
$field = SudoModePasswordField::create(SudoModePasswordField::FIELD_NAME);
$field->setForm($this);
$this->Fields()->unshift($field);
}

/**
* @return bool
*/
Expand Down
24 changes: 24 additions & 0 deletions src/Forms/GridField/GridField.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
use SilverStripe\ORM\SS_List;
use SilverStripe\View\HTML;
use SilverStripe\View\ViewableData;
use SilverStripe\Security\SudoMode\SudoModeServiceInterface;
use SilverStripe\ORM\DataObject;

/**
* Displays a {@link SS_List} in a grid format.
Expand Down Expand Up @@ -521,6 +523,28 @@ public function FieldHolder($properties = [])
{
$this->extend('onBeforeRenderHolder', $this, $properties);

// Set GridField to read-only if sudo mode is required for the DataObject being managed
// and sudo mode is not active
$modelClass = null;
try {
$modelClass = $this->getModelClass();
} catch (LogicException) {
// noop
}
if (is_a($modelClass, DataObject::class, true)) {
/** @var DataObject $obj */
$obj = Injector::inst()->create($modelClass);
if ($obj->getRequireSudoMode()) {
$session = Controller::curr()?->getRequest()?->getSession();
if ($session) {
$service = Injector::inst()->get(SudoModeServiceInterface::class);
if (!$service->check($session)) {
$this->performReadonlyTransformation();
}
}
}
}

$columns = $this->getColumns();

$list = $this->getManipulatedList();
Expand Down
8 changes: 8 additions & 0 deletions src/Forms/GridField/GridFieldDetailForm_ItemRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,15 @@ public function ItemEditForm()
if ($cb) {
$cb($form, $this);
}

$this->extend("updateItemEditForm", $form);

// Check if the the record is a DataObject and if that DataObject requires sudo mode
// If so then require sudo mode for the item edit form
if (is_a($this->record, DataObject::class) && $this->record->getRequireSudoMode()) {
$form->requireSudoMode();
}

return $form;
}

Expand Down
22 changes: 22 additions & 0 deletions src/Forms/SudoModePasswordField.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace SilverStripe\Forms;

use SilverStripe\Forms\PasswordField;

class SudoModePasswordField extends PasswordField
{
public const FIELD_NAME = 'SudoModePasswordField';

protected $schemaComponent = 'SudoModePasswordField';

public function __construct()
{
// Name must be "SudoModePasswordField" as there's logic elsewhere expecting this
// $title and $value are set to null as the react component does not use these arguments
parent::__construct(SudoModePasswordField::FIELD_NAME);
// Set title to empty string to avoid rendering a label before the react component has loaded
$this->setTitle('');
$this->addExtraClass('SudoModePasswordField');
}
}
2 changes: 2 additions & 0 deletions src/Security/Group.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ class Group extends DataObject
'Code' => true,
'Sort' => true,
];

private static bool $require_sudo_mode = true;

public function getAllChildren()
{
Expand Down
2 changes: 2 additions & 0 deletions src/Security/Member.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ class Member extends DataObject
//'AutoLoginHash' => Array('type'=>'unique', 'value'=>'AutoLoginHash', 'ignoreNulls'=>true)
];

private static bool $require_sudo_mode = true;

/**
* @config
* @var boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,6 @@ public function logIn(Member $member, $persistent = false, HTTPRequest $request
if (Member::config()->get('login_marker_cookie')) {
Cookie::set(Member::config()->get('login_marker_cookie'), 1, 0);
}

// Activate sudo mode on login so the user doesn't have to reauthenticate for sudo
// actions until the sudo mode timeout expires
$service = Injector::inst()->get(SudoModeServiceInterface::class);
$service->activate($session);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/Security/Permission.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ class Permission extends DataObject implements TemplateGlobalProvider, Resettabl
'EDIT_PERMISSIONS'
];

private static bool $require_sudo_mode = true;

/**
* Check that the current member has the given permission.
*
Expand Down
2 changes: 2 additions & 0 deletions src/Security/PermissionRole.php
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ class PermissionRole extends DataObject

private static $plural_name = 'Roles';

private static bool $require_sudo_mode = true;

public function getCMSFields()
{
$fields = parent::getCMSFields();
Expand Down
2 changes: 2 additions & 0 deletions src/Security/PermissionRoleCode.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ class PermissionRoleCode extends DataObject
];

private static $table_name = "PermissionRoleCode";

private static bool $require_sudo_mode = true;

private static $indexes = [
"Code" => true,
Expand Down
23 changes: 16 additions & 7 deletions src/Security/SudoMode/SudoModeService.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@

use SilverStripe\Control\Session;
use SilverStripe\Core\Config\Configurable;
use SilverStripe\Core\Extensible;
use SilverStripe\ORM\FieldType\DBDatetime;

class SudoModeService implements SudoModeServiceInterface
{
use Configurable;
use Extensible;

/**
* The lifetime that sudo mode authorization lasts for, in minutes.
Expand All @@ -27,16 +29,18 @@ class SudoModeService implements SudoModeServiceInterface

public function check(Session $session): bool
{
$active = true;
$lastActivated = $session->get(SudoModeService::SUDO_MODE_SESSION_KEY);
// Not activated at all
if (!$lastActivated) {
return false;
// Not activated at all
$active = false;
} else {
// Activated within the last "lifetime" window
$nowTimestamp = DBDatetime::now()->getTimestamp();
$active = $lastActivated > ($nowTimestamp - $this->getLifetime() * 60);
}

// Activated within the last "lifetime" window
$nowTimestamp = DBDatetime::now()->getTimestamp();

return $lastActivated > ($nowTimestamp - $this->getLifetime() * 60);
$this->extend('updateCheck', $active, $session);
return $active;
}

public function activate(Session $session): bool
Expand All @@ -45,6 +49,11 @@ public function activate(Session $session): bool
return true;
}

public function deactivate(Session $session): void
{
$session->set(SudoModeService::SUDO_MODE_SESSION_KEY, null);
}

public function getLifetime(): int
{
return (int) static::config()->get('lifetime_minutes');
Expand Down
15 changes: 15 additions & 0 deletions src/View/ViewableData.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ class ViewableData implements IteratorAggregate
*/
private array $dynamicData = [];

/**
* Config of whether the model requires sudo mode to be active in order to be modified in admin
* Sudo mode is a security feature that requires the user to re-enter their password before
* making changes to the database.
*/
private static bool $require_sudo_mode = false;

// -----------------------------------------------------------------------------------------------------------------

/**
Expand Down Expand Up @@ -261,6 +268,14 @@ public function hasDynamicData(string $field): bool
return array_key_exists($field, $this->dynamicData);
}

/**
* Whether the model requires sudo mode to be active in order to be modified in admin
*/
public function getRequireSudoMode(): bool
{
return $this->config()->get('require_sudo_mode');
}

/**
* Returns true if a method exists for the current class which isn't private.
* Also returns true for private methods if $this is ViewableData (not a subclass)
Expand Down
48 changes: 48 additions & 0 deletions tests/php/Forms/FormTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@
use SilverStripe\Security\SecurityToken;
use SilverStripe\View\ArrayData;
use SilverStripe\View\SSViewer;
use SilverStripe\Forms\GridField\GridFieldDetailForm;
use SilverStripe\Forms\GridField\GridFieldDetailForm_ItemRequest;
use SilverStripe\Security\MemberAuthenticator\LostPasswordHandler;
use SilverStripe\Core\Injector\Injector;
use SilverStripe\Forms\SudoModePasswordField;
use SilverStripe\Security\SudoMode\SudoModeServiceInterface;
use SilverStripe\Forms\ReadonlyField;

class FormTest extends FunctionalTest
{
Expand Down Expand Up @@ -1263,6 +1270,47 @@ public function testRestoreFromState()
);
}

public static function provideRequireSudoMode(): array
{
return [
'sudo-protected' => [
'class' => GridFieldDetailForm_ItemRequest::class,
'expected' => true,
],
'not-sudo-protected' => [
'class' => LostPasswordHandler::class,
'expected' => false,
],
];
}

/**
* @dataProvider provideRequireSudoMode
*/
public function testRequireSudoMode(string $class, bool $expected): void
{
$request = Controller::curr()->getRequest();
if ($class === GridFieldDetailForm_ItemRequest::class) {
$handler = new GridFieldDetailForm_ItemRequest(null, null, null, null, null);
} elseif ($class === LostPasswordHandler::class) {
$handler = new LostPasswordHandler('');
}
$handler->setRequest($request);
$form = new Form($handler, 'MyForm', new FieldList(new TextField('MyTextField')));
$form->requireSudoMode();
$fieldList = $form->Fields();
$last = $fieldList->last();
$this->assertSame('MyTextField', $last->getName());
if ($expected) {
$this->assertSame(2, $fieldList->count());
$this->assertSame(SudoModePasswordField::class, get_class($fieldList->first()));
$this->assertSame(ReadonlyField::class, get_class($last));
} else {
$this->assertSame(1, $fieldList->count());
$this->assertSame(TextField::class, get_class($last));
}
}

protected function getStubForm()
{
return new Form(
Expand Down
6 changes: 3 additions & 3 deletions tests/php/Security/SudoMode/SudoModeServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public function testActivateAndCheckImmediately()
$this->assertTrue($this->service->check($this->session));
}

public function testSudoModeActivatesOnLogin()
public function testSudoModeNotActiveOnLogin()
{
// Sometimes being logged in carries over from other tests
$this->logOut();
Expand All @@ -73,9 +73,9 @@ public function testSudoModeActivatesOnLogin()
// Sudo mode should not be enabled automagically when nobody is logged in
$this->assertFalse($service->check($session));

// Ensure sudo mode is activated on login
// Ensure sudo mode is not automatically activated on login
$this->logInWithPermission();
$this->assertTrue($service->check($session));
$this->assertFalse($service->check($session));

// Ensure sudo mode is not active after logging out
$this->logOut();
Expand Down

0 comments on commit d218c67

Please sign in to comment.