Sudo Mode with CakePHP Authorization Plugin

I’ve been working on content for my CakeFest workshop this year, and thought it would be interesting to see a commonly used authorization pattern implemented as an extension to CakePHP’s authorization system. The pattern I wanted to implement was ‘sudo mode’. Often this pattern is used in applications that have longer session duration. Applications like GitHub use sudo mode to allow you to use basic read/write privileges with long-lived sessions, but if you need to make a change that would impact the security of your account, you are required to re enter your password or MFA device; much like how one uses sudo to temporarily elevate privileges to make system configuration changes in *nix systems.

What follows is a summary of the pattern implemented in CakePHP. During my workshop, I’ll give more context on this solution and some future improvements that could be made.

Functional Requirements

My goal for this solution was to provide a flexible way to require and apply sudo requirements. I also wanted to show how sudo mode can be done in server rendered applications without disrupting user flows. For API driven applications, you would need different error handling logic that ensured you returned the correct JSON payload for your front-end applications.

I wanted to have:

  1. Time based sudo mode activation. This would allow me to change the timeout globally or perhaps have operations that require a more recent privilege escalation.
  2. Policy based API. One of the goals of this was to show this pattern with the Authorization plugin so we need to use it.
  3. Server rendered flows. I wanted to show how sudo mode can fit into server rendered applications as well as API based ones. The HTML flow is easier to demo but more complex to implement as you have to build the client and server state with HTML.

Authorization Policies

Because we only want to selectively apply sudo, I used a trait based API:

Show Plain Text
  1. namespace App\Policy;
  2.  
  3. trait RequireSudoTrait
  4. {
  5.     /**
  6.      * Requires the user to have an active sudo time window
  7.      */
  8.     protected function requireSudo(IdentityInterface $user): void
  9.     {
  10.         if (!$user->sudo_until || $user->sudo_until < DateTime::now()) {
  11.             throw new SudoRequiredException();
  12.         }
  13.     }
  14. }

This allows us to include the trait as required and have a simple API of $this->requireSudo() in the policy to activate our sudo mode. When a user doesn’t have sudo active and it is required, an application error will be found. I’ve not included the code for that class. It was trivial, just remember to extend the ForbiddenException from the Authorization plugin.

We’ll catch this error in our error controller in later on.

Schema changes

As you may have noticed in the trait logic that I’m using $user->sudo_until as a way to track when sudo was activated. You’ll need to add a datetime column with the name of your choice.

Handling Errors

Now that our schema has been updated and we have an action we want to protect, we need to handle the error. First we’ll need to add some custom logic. In our src/Controller/ErrorController.php lets add the following:

Show Plain Text
  1. /**
  2.  * Enable json error views.
  3.  */
  4. public function viewClasses(): array
  5. {
  6.     return [JsonView::class];
  7. }
  8.  
  9. /**
  10.  * beforeRender callback.
  11.  *
  12.  * @param \Cake\Event\EventInterface<\Cake\Controller\Controller> $event Event.
  13.  * @return \Cake\Http\Response|null|void
  14.  */
  15. public function beforeRender(EventInterface $event)
  16. {
  17.     parent::beforeRender($event);
  18.  
  19.     $builder = $this->viewBuilder();
  20.     $builder->setTemplatePath('Error');
  21.     $error = $builder->getVar('error');
  22.     if ($error instanceof SudoRequiredException) {
  23.         $builder->setTemplate('sudo_required');
  24.     }
  25. }

Now when error pages are rendered, if the error is our SudoRequiredException we can render a custom template instead of the default. Our template will look like:

Show Plain Text
  1. <?php
  2. declare(strict_types=1);
  3.  
  4. use Cake\Utility\Hash;
  5. ?>
  6. <div class="sudo-required form content">
  7.     <?= $this->Form->create() ?>
  8.     <?= $this->Form->hidden('op', ['value' => 'sudo_activate']) ?>
  9.     <?php foreach (Hash::flatten($this->request->getData()) as $key => $value): ?>
  10.         <?php
  11.         if ($key === 'op' || $key === 'password') {
  12.             continue;
  13.         }
  14.         ?>
  15.         <?= $this->Form->hidden($key, ['value' => $value]) ?>
  16.     <?php endforeach; ?>
  17.     <fieldset>
  18.         <legend><?= __('Please enter your password to continue') ?></legend>
  19.         <?= $this->Form->control('password') ?>
  20.     </fieldset>
  21.     <?= $this->Form->button(__('Proceed')); ?>
  22.     <?= $this->Form->end() ?>
  23. </div>

This form will capture all of the request body data and store it in a form alongside the password and op parameter that is used to signal that a sudo side-channel request is occurring.

Some ways to improve this would be to store request data in a cache. Instead of serializing the data back into a form, you could pass uuid style identifier of the request payload. When the credentials are provided the cache data can replace the request data.

Activating Sudo

Now we have a form that captures the previous request, and will allow it to be replayed again we need controller logic to handle this. I chose to implement this as middleware so that it could be self contained. This logic could also live on the AppController if that’s more your style.

Show Plain Text
  1. namespace App\Middleware;
  2.  
  3. use App\Model\Entity\User;
  4. use App\Policy\SudoRequiredException;
  5. use Cake\Http\ServerRequest;
  6. use Cake\Log\Log;
  7. use Cake\ORM\Locator\LocatorAwareTrait;
  8. use Psr\Http\Message\ResponseInterface;
  9. use Psr\Http\Message\ServerRequestInterface;
  10. use Psr\Http\Server\MiddlewareInterface;
  11. use Psr\Http\Server\RequestHandlerInterface;
  12.  
  13. class SudoRequiredMiddleware implements MiddlewareInterface
  14. {
  15.     use LocatorAwareTrait;
  16.  
  17.     public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
  18.     {
  19.         assert($request instanceof ServerRequest, 'Requires cake request');
  20.  
  21.         $password = $request->getData('password');
  22.         $identity = $request->getAttribute('identity');
  23.         if (!$identity || $request->getData('op') !== 'sudo_activate') {
  24.             return $handler->handle($request);
  25.         }
  26.         $user = $identity->getOriginalData();
  27.  
  28.         assert($user instanceof User, 'User is required');
  29.         if ($user->activateSudo($password)) {
  30.             $users = $this->fetchTable('Users');
  31.             $users->saveOrFail($user);
  32.  
  33.             $data = $request->getData();
  34.             unset($data['op'], $data['password']);
  35.             $request = $request->withParsedBody($data);
  36.  
  37.             Log::info('user.sudo_activate', ['id' => $user->id, 'scope' => 'sudo']);
  38.  
  39.             return $handler->handle($request);
  40.         }
  41.  
  42.         $request->getFlash()->error('Sudo failed, password was incorrect.');
  43.         Log::info('user.sudo_failed', ['id' => $user->id, 'scope' => 'sudo']);
  44.  
  45.         throw new SudoRequiredException();
  46.     }
  47. }

This logic checks each request for our op signal parameter. You could choose any name for this parameter however, I would avoid overlapping with a field you use in your schema anywhere. Should sudo fail again, we can raise another error (which is caught and rendered by our error controller) starting the authorization process again.

Entity Changes

Finally we have to update our entity with the logic required to set a timeout for sudo. If you’re going to have multiple timeouts. I would set this to the longest of those timeouts and have constraints on how fresh the timestamp needs to be.

Show Plain Text
  1. public function activateSudo(string $password): bool
  2. {
  3.     $hasher = new DefaultPasswordHasher();
  4.     if ($hasher->check($password, $this->password)) {
  5.         // The sudo activation timeout could be application configuration as well.
  6.         $this->sudo_until = DateTime::now()->modify('+15 minutes');
  7.  
  8.         return true;
  9.     }
  10.  
  11.     return false;
  12. }

That’s it

That’s all there is to building a ‘sudo mode’ with CakePHP’s authorization system. Let me know what you think of it, and if you’re interested on working on a plugin together for this.

Comments

There are no comments, be the first!

Have your say: