Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"psr/http-message": "^1.0",
"psr/log": "^1.0",
"seld/jsonlint": "^1.9",
"slim/csrf": "^0.8.3",
"slim/slim": "^3.7",
"studio-42/elfinder": "^2.1.70",
"symfony/translation": "^3.4",
Expand Down
356 changes: 235 additions & 121 deletions composer.lock

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion packages/admin/src/Charcoal/Admin/AdminModule.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,16 @@ class AdminModule extends AbstractModule
*/
public function setUp()
{
// Hack: skip if the request does not start with '/admin'
$container = $this->app()->getContainer();

// Registered unconditionally, unlike the rest of AdminServiceProvider
// below: App::setupMiddlewares() requires every "active" middleware
// ident in config to be registered on the container for *every*
// request, regardless of path, so this can't wait behind the
// is-this-an-admin-request check the heavier services below do.
(new AdminServiceProvider())->registerMiddlewareServices($container);

// Hack: skip if the request does not start with '/admin'
if ($this->isPathAdmin($container['request']->getUri()->getPath()) !== true) {
return $this;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
// From PSR-7
use Psr\Http\Message\UriInterface;
// From Slim
use Slim\Csrf\Guard;
use Slim\Http\Uri;
// From 'charcoal-app'
use Charcoal\App\Middleware\CsrfMiddleware;
// From Mustache
use Mustache\LambdaHelper;
// From 'charcoal-config'
Expand Down Expand Up @@ -81,6 +84,7 @@ public function register(Container $container)
$this->registerSelectizeServices($container);
$this->registerMetadataExtensions($container);
$this->registerAuthExtensions($container);
$this->registerMiddlewareServices($container);
$this->registerViewExtensions($container);
$this->registerAssetsManager($container);

Expand Down Expand Up @@ -351,6 +355,88 @@ protected function registerAuthExtensions(Container $container)
};
}

/**
* Registers admin-scoped middlewares.
*
* @param Container $container The Pimple DI container.
* @return void
*/
public function registerMiddlewareServices(Container $container)
{
// Ensure a default configset exists (active, covering the auth
// templates) so every app gets this protection without having to
// configure it — while still fully overridable, since an app's own
// `charcoal/admin/middleware/csrf` config entry, if present, is left
// untouched.
$middlewares = ($container['config']['middlewares'] ?: []);
if (!isset($middlewares['charcoal/admin/middleware/csrf'])) {
$middlewares['charcoal/admin/middleware/csrf'] = $this->defaultCsrfMiddlewareConfig();
$container['config']['middlewares'] = $middlewares;
}

/**
* Slim-CSRF guard for the admin area, independent of `charcoal/app`'s
* own `csrf/guard` (used for the public-facing CSRF middleware, if
* any) — `Charcoal\App\Middleware\CsrfMiddleware` mutates its guard's
* failure callable on construction, so two differently-configured
* middleware instances must not share one guard.
*
* @param Container $container The Pimple DI Container.
* @return Guard
*/
$container['admin/csrf/guard'] = function (Container $container) {
return new Guard();
};

/**
* @param Container $container The Pimple DI Container.
* @return CsrfMiddleware
*/
$container['middlewares/charcoal/admin/middleware/csrf'] = function (Container $container) {
$wareConfig = $container['config']['middlewares']['charcoal/admin/middleware/csrf'];
$wareConfig['guard'] = $container['admin/csrf/guard'];
return new CsrfMiddleware($wareConfig);
};
}

/**
* The default configset for `charcoal/admin/middleware/csrf`, used
* whenever a consuming app hasn't defined its own — covers the admin
* area's plain-form auth pages (login, lost-password, reset-password),
* responding with the same `{success, next_url, feedbacks}` shape as any
* other admin action, since that's what the bundled admin JS expects.
*
* Assumes the default `admin` base path (`admin.config.default.json`'s
* `base_path`). This method is called unconditionally, for every
* request, before `admin/config` is necessarily registered (it's only
* registered for requests under the admin path — see
* {@see \Charcoal\Admin\AdminModule::setUp()}), so it can't reliably
* read a customized base path here. An app that renames its admin path
* should define this configset itself, same as it already must adjust
* other admin-path-dependent integrations.
*
* @return array
*/
private function defaultCsrfMiddlewareConfig(): array
{
return [
'active' => true,
'included_path' => [
'^/admin/login$',
'^/admin/account/lost-password$',
'^/admin/account/reset-password(/.*)?$',
],
'failure_message' => 'Your session has expired. Please try logging in again.',
'failure_body' => [
'success' => false,
'next_url' => null,
'feedbacks' => [
[ 'level' => 'error', 'message' => '{{message}}' ],
],
],
];
}

/**
* Registers view extensions.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class LostPasswordTemplate extends AdminTemplate
*/
public function init(RequestInterface $request)
{
$this->setCsrfAttributesFromRequest($request);

$translator = $this->translator();

$notice = $request->getParam('notice');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class ResetPasswordTemplate extends AdminTemplate
*/
public function init(RequestInterface $request)
{
$this->setCsrfAttributesFromRequest($request);

// Undocumented Slim 3 feature: The route attributes are stored in routeInfo[2].
$routeInfo = $request->getAttribute('routeInfo');

Expand Down
48 changes: 48 additions & 0 deletions packages/admin/src/Charcoal/Admin/Template/AuthTemplateTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,59 @@

namespace Charcoal\Admin\Template;

// From PSR-7
use Psr\Http\Message\RequestInterface;

/**
*
*/
trait AuthTemplateTrait
{
/**
* @var string|null
*/
private $csrfName;

/**
* @var string|null
*/
private $csrfValue;

/**
* Reads the CSRF token pair attached to the request by the CSRF
* middleware (`Charcoal\App\Middleware\CsrfMiddleware`, wrapping
* {@see \Slim\Csrf\Guard}), for {@see self::csrfFields()} to render.
*
* Unlike a form whose action URL is arbitrary (a CMS page, say), an auth
* form's GET-rendered page and its POST target are the same fixed route,
* so the middleware itself both issues the token (on GET) and validates
* it (on POST) — no separate token-issuance call is needed here.
*
* @param RequestInterface $request The PSR-7 HTTP request.
* @return void
*/
protected function setCsrfAttributesFromRequest(RequestInterface $request): void
{
$this->csrfName = $request->getAttribute('csrf_name');
$this->csrfValue = $request->getAttribute('csrf_value');
}

/**
* @return string The hidden `<input>` markup carrying the CSRF token pair.
*/
public function csrfFields(): string
{
if (!$this->csrfName || !$this->csrfValue) {
return '';
}

return '<input type="hidden" name="csrf_name" value="' .
htmlspecialchars($this->csrfName, ENT_QUOTES) .
'"><input type="hidden" name="csrf_value" value="' .
htmlspecialchars($this->csrfValue, ENT_QUOTES) .
'">';
}

/**
* Retrieve the base URI of the application.
*
Expand Down
2 changes: 2 additions & 0 deletions packages/admin/src/Charcoal/Admin/Template/LoginTemplate.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ class LoginTemplate extends AdminTemplate
*/
public function init(RequestInterface $request)
{
$this->setCsrfAttributesFromRequest($request);

$translator = $this->translator();

$notice = $request->getParam('notice');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
<p>{{# _t }}You will receive an email containing a link to create a new password.{{/ _t }}</p>
</div>
<form id="lost-password-form" method="POST" action="{{ urlLostPasswordAction }}">
<input type="hidden" name="csrf_token" value="{{ csrfToken }}">
{{& csrfFields }}

<div class="form-group">
<label class="sr-only" for="email">{{# _t }}Email Address{{/ _t }}</label>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
<p>{{# _t }}Enter the reset token you have received via email, your email address, and your new password.{{/ _t }}</p>
</div>
<form id="reset-password-form" method="POST" action="{{ urlResetPasswordAction }}">
<input type="hidden" name="csrf_token" value="{{ csrfToken }}">
{{& csrfFields }}

<div class="form-group">
<label class="sr-only" for="token">{{# _t }}Password Reset Token{{/ _t }}</label>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ This template expects a `Charcoal\Admin\Template\LoginTemplate` model as context
<h2 class="sr-only">{{# _t }}auth.login.title{{/ _t }}</h2>
</div>
<form id="login-form" method="POST" action="{{ urlLoginAction }}">
<input type="hidden" name="csrf_token" value="{{ csrfToken }}">
{{& csrfFields }}

<div class="form-group">
<label class="sr-only" for="email">{{# _t }}Email{{/ _t }}</label>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace Charcoal\Tests\Admin\ServiceProvider;

use Pimple\Container;

use Charcoal\App\AppConfig;
use Charcoal\Admin\ServiceProvider\AdminServiceProvider;
use Charcoal\Tests\AbstractTestCase;

/**
* Exercises `registerMiddlewareServices()` directly rather than the full
* `register()`, which pulls in unrelated dependencies (view, email, etc.) a
* bare container can't satisfy — no existing admin test builds a container
* complete enough for a full `register()` call.
*/
class AdminServiceProviderTest extends AbstractTestCase
{
public function testRegisterMiddlewareServices()
{
$container = new Container([ 'config' => new AppConfig() ]);
$provider = new AdminServiceProvider();
$provider->registerMiddlewareServices($container);

$this->assertTrue(isset($container['admin/csrf/guard']));
$this->assertTrue(isset($container['middlewares/charcoal/admin/middleware/csrf']));
}

public function testDefaultCsrfMiddlewareConfigIsInjectedWhenMissing()
{
$container = new Container([ 'config' => new AppConfig() ]);
$provider = new AdminServiceProvider();
$provider->registerMiddlewareServices($container);

$config = $container['config']['middlewares']['charcoal/admin/middleware/csrf'];

$this->assertTrue($config['active']);
$this->assertContains('^/admin/login$', $config['included_path']);
$this->assertContains('^/admin/account/lost-password$', $config['included_path']);
$this->assertContains('^/admin/account/reset-password(/.*)?$', $config['included_path']);
}

public function testExplicitAppConfigIsNotOverridden()
{
$appConfig = new AppConfig([
'middlewares' => [
'charcoal/admin/middleware/csrf' => [
'active' => false,
'included_path' => [ '^/admin/login$' ],
'failure_message' => 'Custom message.',
],
],
]);
$container = new Container([ 'config' => $appConfig ]);
$provider = new AdminServiceProvider();
$provider->registerMiddlewareServices($container);

$config = $container['config']['middlewares']['charcoal/admin/middleware/csrf'];

$this->assertFalse($config['active']);
$this->assertEquals([ '^/admin/login$' ], $config['included_path']);
$this->assertEquals('Custom message.', $config['failure_message']);
}
}
1 change: 1 addition & 0 deletions packages/app/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
"monolog/monolog": "^1.17",
"psr/http-message": "^1.0",
"psr/log": "^1.0",
"slim/csrf": "^0.8.3",
"slim/slim": "^3.7",
"vlucas/phpdotenv": "^5.4"
},
Expand Down
28 changes: 25 additions & 3 deletions packages/app/docs/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -516,10 +516,31 @@ For example

There are 2 middlewares provided by default in the `app` module:

* `\Charcoal\App\Middleware\CacheMiddleware`
* `\Charcoal\App\Middleware\Cache\IpMiddleware`
* `\Charcoal\App\Middleware\IpMiddleware`
* `\Charcoal\App\Middleware\CsrfMiddleware`

`CsrfMiddleware` wraps [slim/csrf]'s `Guard` and validates a token pair on
state-changing requests, scoped by an `included_path` / `excluded_path` list
of regular expressions so it only touches the session (and cacheability) for
matching routes. An empty `included_path` means every route is protected —
the middleware fails closed by default rather than being silently inert
until configured. Token issuance for the page that renders a protected form
is a separate, pull-based concern: fetch the shared `csrf/guard` container
service and call `validateStorage()` then `generateToken()`.

Other Charcoal modules may provide more middlewares (for example, language detection in [charcoal/translator]).
```json
{
"middlewares": {
"charcoal/app/middleware/csrf": {
"active": true,
"included_path": ["^/api/v1/form/"]
}
}
}
```

Other Charcoal modules may provide more middlewares (for example, language
detection in [charcoal/translator], or caching in [charcoal/cache]).

## Charcoal Binary

Expand Down Expand Up @@ -584,3 +605,4 @@ Available methods are:
[climate]: https://packagist.org/packages/league/climate
[fastroute]: https://packagist.org/packages/nikic/fast-route
[slim]: https://packagist.org/packages/slim/slim
[slim/csrf]: https://packagist.org/packages/slim/csrf
Loading
Loading