Greatly improve login related actions for Admin

* Better isolate admin to prevent session related vulnerabilities
* Removed support for custom login redirects for improved security
* Shorten forgot password link lifetime from 7 days to 1 hour
* Fixed login related pages being accessible from admin when user has logged in
* Fixed admin user creation and password reset allowing unsafe passwords
* Fixed missing validation when registering the first admin user
* Fixed reset password email not to have session specific token in it
This commit is contained in:
Matias Griese
2021-03-26 14:39:37 +02:00
parent e14e72958f
commit aa4f80eec1
22 changed files with 1930 additions and 663 deletions

View File

@@ -1,12 +1,22 @@
# v1.10.8 # v1.10.8
## 03/19/2021 ## 03/19/2021
1. [](#new)
* Requires **Grav 1.7.10**
1. [](#improved) 1. [](#improved)
* Include alt text and title for images added to the editor [#2098](https://github.com/getgrav/grav-plugin-admin/issues/2098) * Include alt text and title for images added to the editor [#2098](https://github.com/getgrav/grav-plugin-admin/issues/2098)
1. [](#bugfix) 1. [](#bugfix)
* Fixed issue replacing `wildcard` field names in flex collections [#2092](https://github.com/getgrav/grav-plugin-admin/pull/2092) * Fixed issue replacing `wildcard` field names in flex collections [#2092](https://github.com/getgrav/grav-plugin-admin/pull/2092)
* Fixed legacy Pages having old `modular` reference instead of `module` [#2093](https://github.com/getgrav/grav-plugin-admin/issues/2093) * Fixed legacy Pages having old `modular` reference instead of `module` [#2093](https://github.com/getgrav/grav-plugin-admin/issues/2093)
* Fixed issue where Add New modal would close if selecting an item outside of the modal window. It is now necessary go through the Cancel button and clicking the overlay won't trigger the closing of the modal [#2089](https://github.com/getgrav/grav-plugin-admin/issues/2089), [#2065](https://github.com/getgrav/grav-plugin-admin/issues/2065) * Fixed issue where Add New modal would close if selecting an item outside of the modal window. It is now necessary go through the Cancel button and clicking the overlay won't trigger the closing of the modal [#2089](https://github.com/getgrav/grav-plugin-admin/issues/2089), [#2065](https://github.com/getgrav/grav-plugin-admin/issues/2065)
1. [](#branch)
* Better isolate admin to prevent session related vulnerabilities
* Removed support for custom login redirects for improved security
* Shorten forgot password link lifetime from 7 days to 1 hour
* Fixed login related pages being accessible from admin when user has logged in
* Fixed admin user creation and password reset allowing unsafe passwords
* Fixed missing validation when registering the first admin user
* Fixed reset password email not to have session specific token in it
# v1.10.7 # v1.10.7
## 03/17/2021 ## 03/17/2021

560
admin.php
View File

@@ -14,10 +14,12 @@ use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Page; use Grav\Common\Page\Page;
use Grav\Common\Page\Pages; use Grav\Common\Page\Pages;
use Grav\Common\Plugin; use Grav\Common\Plugin;
use Grav\Common\Plugins;
use Grav\Common\Processors\Events\RequestHandlerEvent; use Grav\Common\Processors\Events\RequestHandlerEvent;
use Grav\Common\Session; use Grav\Common\Session;
use Grav\Common\Twig\Twig;
use Grav\Common\Uri; use Grav\Common\Uri;
use Grav\Common\User\Interfaces\UserCollectionInterface; use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\Utils; use Grav\Common\Utils;
use Grav\Common\Yaml; use Grav\Common\Yaml;
use Grav\Events\PermissionsRegisterEvent; use Grav\Events\PermissionsRegisterEvent;
@@ -25,17 +27,26 @@ use Grav\Framework\Acl\PermissionsReader;
use Grav\Framework\Psr7\Response; use Grav\Framework\Psr7\Response;
use Grav\Framework\Session\Exceptions\SessionException; use Grav\Framework\Session\Exceptions\SessionException;
use Grav\Plugin\Admin\Admin; use Grav\Plugin\Admin\Admin;
use Grav\Plugin\Admin\AdminFormFactory;
use Grav\Plugin\Admin\Popularity; use Grav\Plugin\Admin\Popularity;
use Grav\Plugin\Admin\Router; use Grav\Plugin\Admin\Router;
use Grav\Plugin\Admin\Themes; use Grav\Plugin\Admin\Themes;
use Grav\Plugin\Admin\AdminController; use Grav\Plugin\Admin\AdminController;
use Grav\Plugin\Admin\Twig\AdminTwigExtension; use Grav\Plugin\Admin\Twig\AdminTwigExtension;
use Grav\Plugin\Admin\WhiteLabel; use Grav\Plugin\Admin\WhiteLabel;
use Grav\Plugin\FlexObjects\FlexFormFactory;
use Grav\Plugin\Form\Form; use Grav\Plugin\Form\Form;
use Grav\Plugin\Form\Forms;
use Grav\Plugin\Login\Login; use Grav\Plugin\Login\Login;
use Pimple\Container; use Pimple\Container;
use Psr\Http\Message\ResponseInterface;
use RocketTheme\Toolbox\Event\Event; use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
/**
* Class AdminPlugin
* @package Grav\Plugin\Admin
*/
class AdminPlugin extends Plugin class AdminPlugin extends Plugin
{ {
public $features = [ public $features = [
@@ -44,34 +55,24 @@ class AdminPlugin extends Plugin
/** @var bool */ /** @var bool */
protected $active = false; protected $active = false;
/** @var string */ /** @var string */
protected $template; protected $template;
/** @var string */ /** @var string */
protected $theme; protected $theme;
/** @var string */ /** @var string */
protected $route; protected $route;
/** @var string */ /** @var string */
protected $admin_route; protected $admin_route;
/** @var Uri */ /** @var Uri */
protected $uri; protected $uri;
/** @var Admin */ /** @var Admin */
protected $admin; protected $admin;
/** @var Session */ /** @var Session */
protected $session; protected $session;
/** @var Popularity */ /** @var Popularity */
protected $popularity; protected $popularity;
/** @var string */ /** @var string */
protected $base; protected $base;
/** @var string */ /** @var string */
protected $version; protected $version;
@@ -89,12 +90,9 @@ class AdminPlugin extends Plugin
'onRequestHandlerInit' => [ 'onRequestHandlerInit' => [
['onRequestHandlerInit', 100000] ['onRequestHandlerInit', 100000]
], ],
'onFormRegisterTypes' => ['onFormRegisterTypes', 0],
'onPageInitialized' => ['onPageInitialized', 0], 'onPageInitialized' => ['onPageInitialized', 0],
'onFormProcessed' => ['onFormProcessed', 0],
'onShutdown' => ['onShutdown', 1000], 'onShutdown' => ['onShutdown', 1000],
'onAdminDashboard' => ['onAdminDashboard', 0],
'onAdminTools' => ['onAdminTools', 0],
'onAdminSave' => ['onAdminSave', 0],
PermissionsRegisterEvent::class => ['onRegisterPermissions', 1000], PermissionsRegisterEvent::class => ['onRegisterPermissions', 1000],
]; ];
} }
@@ -177,65 +175,54 @@ class AdminPlugin extends Plugin
* *
* @return ClassLoader * @return ClassLoader
*/ */
public function autoload() public function autoload(): ClassLoader
{ {
return require __DIR__ . '/vendor/autoload.php'; return require __DIR__ . '/vendor/autoload.php';
} }
/**
* @param Event $event
* @return void
*/
public function onFormRegisterTypes(Event $event): void
{
/** @var Forms $forms */
$forms = $event['forms'];
$forms->registerType('admin', new AdminFormFactory());
}
/** /**
* [onPluginsInitialized:100000] * [onPluginsInitialized:100000]
* *
* If the admin path matches, initialize the Login plugin configuration and set the admin * If the admin path matches, initialize the Login plugin configuration and set the admin
* as active. * as active.
*
* @return void
*/ */
public function setup() public function setup()
{ {
// Only enable admin if it has a route.
$route = $this->config->get('plugins.admin.route'); $route = $this->config->get('plugins.admin.route');
if (!$route) { if (!$route) {
return; return;
} }
$this->base = '/' . trim($route, '/'); /** @var Uri uri */
$this->admin_route = rtrim($this->grav['pages']->base(), '/') . $this->base;
$this->uri = $this->grav['uri']; $this->uri = $this->grav['uri'];
$users_exist = Admin::doAnyUsersExist(); $this->base = '/' . trim($route, '/');
$this->admin_route = rtrim($this->grav['pages']->base(), '/') . $this->base;
// If no users found, go to register $inAdmin = $this->isAdminPath();
if (!$users_exist) {
if (!$this->isAdminPath()) { // If no users found, go to register.
if (!$inAdmin && !Admin::doAnyUsersExist()) {
$this->grav->redirect($this->admin_route); $this->grav->redirect($this->admin_route);
} }
$this->template = 'register';
}
// Only activate admin if we're inside the admin path. // Only setup admin if we're inside the admin path.
if ($this->isAdminPath()) { if ($inAdmin) {
$pages = $this->grav['pages']; $this->setupAdmin();
if (method_exists($pages, 'disablePages')) {
$pages->disablePages();
}
try {
$this->grav['session']->init();
} catch (SessionException $e) {
$this->grav['session']->init();
$message = 'Session corruption detected, restarting session...';
/** @var Debugger $debugger */
$debugger = $this->grav['debugger'];
$debugger->addMessage($message);
$this->grav['messages']->add($message, 'error');
}
$this->active = true;
// Set cache based on admin_cache option
$this->grav['cache']->setEnabled($this->config->get('plugins.admin.cache_enabled'));
$pages = $this->grav['pages'];
if (method_exists($pages, 'setCheckMethod')) {
// Force file hash checks to fix caching on moved and deleted pages.
$pages->setCheckMethod('hash');
}
} }
} }
@@ -243,78 +230,68 @@ class AdminPlugin extends Plugin
* [onPluginsInitialized:1001] * [onPluginsInitialized:1001]
* *
* If the admin plugin is set as active, initialize the admin * If the admin plugin is set as active, initialize the admin
*
* @return void
*/ */
public function onPluginsInitialized() public function onPluginsInitialized()
{ {
// Only activate admin if we're inside the admin path. // Only activate admin if we're inside the admin path.
if ($this->active) { if ($this->active) {
// Have a unique Admin-only Cache key
if (method_exists($this->grav['cache'], 'setKey')) {
/** @var Cache $cache */
$cache = $this->grav['cache'];
$cache_key = $cache->getKey();
$cache->setKey($cache_key . '$');
}
// Turn on Twig autoescaping
if ($this->grav['uri']->param('task') !== 'processmarkdown') {
$this->grav['twig']->setAutoescape(true);
}
$this->initializeAdmin(); $this->initializeAdmin();
// Disable Asset pipelining (old method - remove this after Grav is updated)
if (!method_exists($this->grav['assets'], 'setJsPipeline')) {
$this->config->set('system.assets.css_pipeline', false);
$this->config->set('system.assets.js_pipeline', false);
} }
// Replace themes service with admin. // Always initialize popularity.
$this->grav['themes'] = function () {
return new Themes($this->grav);
};
// Initialize white label functionality
$this->grav['admin-whitelabel'] = new WhiteLabel();
}
// We need popularity no matter what
$this->popularity = new Popularity(); $this->popularity = new Popularity();
// Fire even to register permissions from other plugins
$this->grav->fireEvent('onAdminRegisterPermissions', new Event(['admin' => $this->admin]));
} }
/** /**
* [onRequestHandlerInit:100000] * [onRequestHandlerInit:100000]
* *
* @param RequestHandlerEvent $event * @param RequestHandlerEvent $event
* @return void
*/ */
public function onRequestHandlerInit(RequestHandlerEvent $event) public function onRequestHandlerInit(RequestHandlerEvent $event)
{ {
// Store this version. // Store this version.
$this->version = $this->getBlueprint()->get('version'); $this->version = $this->getBlueprint()->get('version');
$this->grav['debugger']->addMessage('Admin v' . $this->version);
$route = $event->getRoute(); $route = $event->getRoute();
$base = $route->getRoute(0, 1); $base = $route->getRoute(0, 1);
if ($base === $this->base) { if ($base === $this->base) {
$event->addMiddleware('admin_router', new Router($this->grav)); /** @var Debugger $debugger */
$debugger = $this->grav['debugger'];
$debugger->addMessage('Admin v' . $this->version);
$event->addMiddleware('admin_router', new Router($this->grav, $this->admin));
} }
} }
/**
* @param Event $event
* @return void
*/
public function onAdminControllerInit(Event $event): void
{
$eventController = $event['controller'];
// Blacklist login related views.
$loginViews = ['login', 'forgot', 'register', 'reset'];
$eventController->blacklist_views = array_merge($eventController->blacklist_views, $loginViews);
}
/** /**
* Force compile during save if admin plugin save * Force compile during save if admin plugin save
* *
* @param Event $event * @param Event $event
* @return void
*/ */
public function onAdminSave(Event $event) public function onAdminSave(Event $event)
{ {
$obj = $event['object']; $obj = $event['object'];
if ($obj instanceof Data && $obj->blueprints()->getFilename() === 'admin/blueprints') { if ($obj instanceof Data
&& ($blueprint = $obj->blueprints()) && $blueprint && $blueprint->getFilename() === 'admin/blueprints') {
[$status, $msg] = $this->grav['admin-whitelabel']->compilePresetScss($obj); [$status, $msg] = $this->grav['admin-whitelabel']->compilePresetScss($obj);
if (!$status) { if (!$status) {
$this->grav['messages']->add($msg, 'error'); $this->grav['messages']->add($msg, 'error');
@@ -322,112 +299,27 @@ class AdminPlugin extends Plugin
} }
} }
/** /**
* [onPageInitialized:0] * [onPageInitialized:0]
*
* @return void
*/ */
public function onPageInitialized() public function onPageInitialized()
{ {
$page = $this->grav['page']; $template = $this->uri->param('tmpl');
$template = $this->grav['uri']->param('tmpl');
if ($template) { if ($template) {
/** @var PageInterface $page */
$page = $this->grav['page'];
$page->template($template); $page->template($template);
} }
} }
/**
* [onFormProcessed:0]
*
* Process the admin registration form.
*
* @param Event $event
*/
public function onFormProcessed(Event $event)
{
$form = $event['form'];
$action = $event['action'];
Admin::DEBUG && Admin::addDebugMessage('Admin Form: ' . $action);
switch ($action) {
case 'register_admin_user':
if (Admin::doAnyUsersExist()) {
throw new \RuntimeException('A user account already exists, please create an admin account manually.');
}
if (!$this->config->get('plugins.login.enabled')) {
throw new \RuntimeException($this->grav['language']->translate('PLUGIN_LOGIN.PLUGIN_LOGIN_DISABLED'));
}
$data = [];
$username = $form->value('username');
if ($form->value('password1') !== $form->value('password2')) {
$this->grav->fireEvent('onFormValidationError', new Event([
'form' => $form,
'message' => $this->grav['language']->translate('PLUGIN_LOGIN.PASSWORDS_DO_NOT_MATCH')
]));
$event->stopPropagation();
return;
}
$data['password'] = $form->value('password1');
$fields = [
'email',
'fullname',
'title'
];
foreach ($fields as $field) {
// Process value of field if set in the page process.register_user
if (!isset($data[$field]) && $form->value($field)) {
$data[$field] = $form->value($field);
}
}
// Don't store plain text password or username (part of the filename).
unset($data['password1'], $data['password2'], $data['username']);
// Extra lowercase to ensure file is saved lowercase
$username = strtolower($username);
$inflector = new Inflector();
$data['fullname'] = $data['fullname'] ?? $inflector->titleize($username);
$data['title'] = $data['title'] ?? 'Administrator';
$data['state'] = 'enabled';
/** @var UserCollectionInterface $users */
$users = $this->grav['accounts'];
// Create user object and save it
$user = $users->load($username);
$user->update($data);
$user->set('access', ['admin' => ['login' => true, 'super' => true], 'site' => ['login' => true]]);
$user->save();
// Login user
$this->grav['session']->user = $user;
unset($this->grav['user']);
$this->grav['user'] = $user;
$user->authenticated = true;
$user->authorized = $user->authorize('admin.login') ?? false;
$messages = $this->grav['messages'];
$messages->add($this->grav['language']->translate('PLUGIN_ADMIN.LOGIN_LOGGED_IN'), 'info');
$this->grav->redirect($this->admin_route);
break;
}
}
/** /**
* [onShutdown:1000] * [onShutdown:1000]
* *
* Handles the shutdown * Handles the shutdown
*
* @return void
*/ */
public function onShutdown() public function onShutdown()
{ {
@@ -436,16 +328,16 @@ class AdminPlugin extends Plugin
if ($this->admin->shouldLoadAdditionalFilesInBackground()) { if ($this->admin->shouldLoadAdditionalFilesInBackground()) {
$this->admin->loadAdditionalFilesInBackground(); $this->admin->loadAdditionalFilesInBackground();
} }
} else { } elseif ($this->popularity && $this->config->get('plugins.admin.popularity.enabled')) {
//if popularity is enabled, track non-admin hits //if popularity is enabled, track non-admin hits
if ($this->popularity && $this->config->get('plugins.admin.popularity.enabled')) {
$this->popularity->trackHit(); $this->popularity->trackHit();
} }
} }
}
/** /**
* [onAdminDashboard:0] * [onAdminDashboard:0]
*
* @return void
*/ */
public function onAdminDashboard() public function onAdminDashboard()
{ {
@@ -478,7 +370,7 @@ class AdminPlugin extends Plugin
* *
* Provide the tools for the Tools page, currently only direct install * Provide the tools for the Tools page, currently only direct install
* *
* @return Event * @return void
*/ */
public function onAdminTools(Event $event) public function onAdminTools(Event $event)
{ {
@@ -489,12 +381,12 @@ class AdminPlugin extends Plugin
'reports' => [['admin.super'], 'PLUGIN_ADMIN.REPORTS'], 'reports' => [['admin.super'], 'PLUGIN_ADMIN.REPORTS'],
'direct-install' => [['admin.super'], 'PLUGIN_ADMIN.DIRECT_INSTALL'], 'direct-install' => [['admin.super'], 'PLUGIN_ADMIN.DIRECT_INSTALL'],
]); ]);
return $event;
} }
/** /**
* Sets longer path to the home page allowing us to have list of pages when we enter to pages section. * Sets longer path to the home page allowing us to have list of pages when we enter to pages section.
*
* @return void
*/ */
public function onPagesInitialized() public function onPagesInitialized()
{ {
@@ -512,22 +404,60 @@ class AdminPlugin extends Plugin
$this->session = $this->grav['session']; $this->session = $this->grav['session'];
// set session variable if it's passed via the url // set session variable if it's passed via the url
if ($this->uri->param('mode') === 'expert') { $mode = $this->uri->param('mode');
if ($mode === 'expert') {
$this->session->expert = true; $this->session->expert = true;
} elseif ($this->uri->param('mode') === 'normal') { } elseif ($mode === 'normal') {
$this->session->expert = false; $this->session->expert = false;
} else { } else {
// set the default if not set before // set the default if not set before
$this->session->expert = $this->session->expert ?? false; $this->session->expert = $this->session->expert ?? false;
} }
// Make local copy of POST. // make sure page is not frozen!
$post = $this->grav['uri']->post(); unset($this->grav['page']);
// Call the controller if it has been set.
$adminParams = $this->admin->request->getAttribute('admin');
$page = null;
if (isset($adminParams['controller'])) {
$controllerParams = $adminParams['controller'];
$class = $controllerParams['class'];
if (!class_exists($class)) {
throw new \RuntimeException(sprintf('Admin controller %s does not exist', $class));
}
/** @var \Grav\Plugin\Admin\Controllers\AdminController $controller */
$controller = new $class($this->grav);
$method = $controllerParams['method'];
$params = $controllerParams['params'] ?? [];
if (!is_callable([$controller, $method])) {
throw new \RuntimeException(sprintf('Admin controller method %s() does not exist', $method));
}
/** @var ResponseInterface $response */
$response = $controller->{$method}(...$params);
if ($response->getStatusCode() !== 418) {
$this->grav->close($response);
}
$page = $controller->getPage();
if (!$page) {
throw new \RuntimeException('Not Found', 404);
}
$this->grav['page'] = $page;
$this->admin->form = $controller->getActiveForm();
} else {
// Handle tasks. // Handle tasks.
$this->admin->task = $task = $this->grav['task'] ?? $this->grav['action']; $this->admin->task = $task = $this->grav['task'] ?? $this->grav['action'];
if ($task) { if ($task) {
Admin::DEBUG && Admin::addDebugMessage("Admin task: {$task}"); Admin::DEBUG && Admin::addDebugMessage("Admin task: {$task}");
// Make local copy of POST.
$post = $this->grav['uri']->post();
$this->initializeController($task, $post); $this->initializeController($task, $post);
} elseif ($this->template === 'logs' && $this->route) { } elseif ($this->template === 'logs' && $this->route) {
// Display RAW error message. // Display RAW error message.
@@ -535,24 +465,25 @@ class AdminPlugin extends Plugin
$this->grav->close($response); $this->grav->close($response);
} }
}
$self = $this;
// make sure page is not frozen!
unset($this->grav['page']);
// Replace page service with admin. // Replace page service with admin.
$this->grav['page'] = function () use ($self) { if (empty($this->grav['page'])) {
/** @var UserInterface $user */
$user = $this->grav['user'];
$this->grav['page'] = function () use ($user) {
$page = new Page(); $page = new Page();
// Plugins may not have the correct Cache-Control header set, force no-store for the proxies. // Plugins may not have the correct Cache-Control header set, force no-store for the proxies.
$page->expires(0); $page->expires(0);
if ($this->grav['user']->authorize('admin.login')) { if ($user->authorize('admin.login')) {
$event = new Event(['page' => $page]); $event = new Event(['page' => $page]);
$event = $this->grav->fireEvent('onAdminPage', $event); $event = $this->grav->fireEvent('onAdminPage', $event);
$page = $event['page'];
/** @var PageInterface $page */
$page = $event['page'];
if ($page->slug()) { if ($page->slug()) {
Admin::DEBUG && Admin::addDebugMessage('Admin page: from event'); Admin::DEBUG && Admin::addDebugMessage('Admin page: from event');
return $page; return $page;
@@ -560,31 +491,32 @@ class AdminPlugin extends Plugin
} }
// Look in the pages provided by the Admin plugin itself // Look in the pages provided by the Admin plugin itself
if (file_exists(__DIR__ . "/pages/admin/{$self->template}.md")) { if (file_exists(__DIR__ . "/pages/admin/{$this->template}.md")) {
Admin::DEBUG && Admin::addDebugMessage("Admin page: {$self->template}"); Admin::DEBUG && Admin::addDebugMessage("Admin page: {$this->template}");
$page->init(new \SplFileInfo(__DIR__ . "/pages/admin/{$self->template}.md")); $page->init(new \SplFileInfo(__DIR__ . "/pages/admin/{$this->template}.md"));
$page->slug(basename($self->template)); $page->slug(basename($this->template));
return $page; return $page;
} }
// If not provided by Admin, lookup pages added by other plugins /** @var UniformResourceLocator $locator */
$plugins = $this->grav['plugins'];
$locator = $this->grav['locator']; $locator = $this->grav['locator'];
// If not provided by Admin, lookup pages added by other plugins
/** @var Plugins $plugins */
$plugins = $this->grav['plugins'];
foreach ($plugins as $plugin) { foreach ($plugins as $plugin) {
if ($this->config->get("plugins.{$plugin->name}.enabled") !== true) { if ($this->config->get("plugins.{$plugin->name}.enabled") !== true) {
continue; continue;
} }
$path = $locator->findResource("plugins://{$plugin->name}/admin/pages/{$self->template}.md"); $path = $locator->findResource("plugins://{$plugin->name}/admin/pages/{$this->template}.md");
if ($path) { if ($path) {
Admin::DEBUG && Admin::addDebugMessage("Admin page: plugin {$plugin->name}/{$self->template}"); Admin::DEBUG && Admin::addDebugMessage("Admin page: plugin {$plugin->name}/{$this->template}");
$page->init(new \SplFileInfo($path)); $page->init(new \SplFileInfo($path));
$page->slug(basename($self->template)); $page->slug(basename($this->template));
return $page; return $page;
} }
@@ -592,9 +524,10 @@ class AdminPlugin extends Plugin
return null; return null;
}; };
}
if (empty($this->grav['page'])) { if (empty($this->grav['page'])) {
if ($this->grav['user']->authenticated) { if ($user->authenticated) {
Admin::DEBUG && Admin::addDebugMessage('Admin page: fire onPageNotFound event'); Admin::DEBUG && Admin::addDebugMessage('Admin page: fire onPageNotFound event');
$event = new Event(['page' => null]); $event = new Event(['page' => null]);
$event->page = null; $event->page = null;
@@ -631,6 +564,8 @@ class AdminPlugin extends Plugin
/** /**
* Handles initializing the assets * Handles initializing the assets
*
* @return void
*/ */
public function onAssetsInitialized() public function onAssetsInitialized()
{ {
@@ -650,6 +585,8 @@ class AdminPlugin extends Plugin
/** /**
* Add twig paths to plugin templates. * Add twig paths to plugin templates.
*
* @return void
*/ */
public function onTwigTemplatePaths() public function onTwigTemplatePaths()
{ {
@@ -663,10 +600,14 @@ class AdminPlugin extends Plugin
/** /**
* Set all twig variables for generating output. * Set all twig variables for generating output.
*
* @return void
*/ */
public function onTwigSiteVariables() public function onTwigSiteVariables()
{ {
/** @var Twig $twig */
$twig = $this->grav['twig']; $twig = $this->grav['twig'];
/** @var PageInterface $page */
$page = $this->grav['page']; $page = $this->grav['page'];
$twig->twig_vars['location'] = $this->template; $twig->twig_vars['location'] = $this->template;
@@ -739,7 +680,9 @@ class AdminPlugin extends Plugin
// preserve form validation // preserve form validation
if (!isset($twig->twig_vars['form'])) { if (!isset($twig->twig_vars['form'])) {
if (isset($header->form)) { if ($this->admin->form) {
$twig->twig_vars['form'] = $this->admin->form;
} elseif (isset($header->form)) {
$twig->twig_vars['form'] = new Form($page); $twig->twig_vars['form'] = new Form($page);
} elseif (isset($header->forms)) { } elseif (isset($header->forms)) {
$twig->twig_vars['form'] = new Form($page, null, reset($header->forms)); $twig->twig_vars['form'] = new Form($page, null, reset($header->forms));
@@ -772,23 +715,41 @@ class AdminPlugin extends Plugin
} }
} }
// Add images to twig template paths to allow inclusion of SVG files /**
* Add images to twig template paths to allow inclusion of SVG files
*
* @return void
*/
public function onTwigLoader() public function onTwigLoader()
{ {
$theme_paths = Grav::instance()['locator']->findResources('plugins://admin/themes/' . $this->theme . '/images'); /** @var Twig $twig */
$twig = $this->grav['twig'];
/** @var UniformResourceLocator $locator */
$locator = Grav::instance()['locator'];
$theme_paths = $locator->findResources('plugins://admin/themes/' . $this->theme . '/images');
foreach($theme_paths as $images_path) { foreach($theme_paths as $images_path) {
$this->grav['twig']->addPath($images_path, 'admin-images'); $twig->addPath($images_path, 'admin-images');
} }
} }
/** /**
* Add the Admin Twig Extensions * Add the Admin Twig Extensions
*
* @return void
*/ */
public function onTwigExtensions() public function onTwigExtensions()
{ {
$this->grav['twig']->twig->addExtension(new AdminTwigExtension); /** @var Twig $twig */
$twig = $this->grav['twig'];
$twig->twig->addExtension(new AdminTwigExtension);
} }
/**
* @param Event $event
* @return void
*/
public function onAdminAfterSave(Event $event) public function onAdminAfterSave(Event $event)
{ {
// Special case to redirect after changing the admin route to avoid 'breaking' // Special case to redirect after changing the admin route to avoid 'breaking'
@@ -808,6 +769,7 @@ class AdminPlugin extends Plugin
* Convert some types where we want to process out of the standard config path * Convert some types where we want to process out of the standard config path
* *
* @param Event $e * @param Event $e
* @return void
*/ */
public function onAdminData(Event $e) public function onAdminData(Event $e)
{ {
@@ -826,6 +788,9 @@ class AdminPlugin extends Plugin
} }
} }
/**
* @return void
*/
public function onOutputGenerated() public function onOutputGenerated()
{ {
// Clear flash objects for previously uploaded files whenever the user switches page or reloads // Clear flash objects for previously uploaded files whenever the user switches page or reloads
@@ -848,6 +813,7 @@ class AdminPlugin extends Plugin
* Initial stab at registering permissions (WIP) * Initial stab at registering permissions (WIP)
* *
* @param PermissionsRegisterEvent $event * @param PermissionsRegisterEvent $event
* @return void
*/ */
public function onRegisterPermissions(PermissionsRegisterEvent $event): void public function onRegisterPermissions(PermissionsRegisterEvent $event): void
{ {
@@ -857,10 +823,16 @@ class AdminPlugin extends Plugin
$permissions->addActions($actions); $permissions->addActions($actions);
} }
/**
* @return void
*/
public function onAdminMenu() public function onAdminMenu()
{ {
/** @var Twig $twig */
$twig = $this->grav['twig'];
// Dashboard // Dashboard
$this->grav['twig']->plugins_hooked_nav['PLUGIN_ADMIN.DASHBOARD'] = [ $twig->plugins_hooked_nav['PLUGIN_ADMIN.DASHBOARD'] = [
'route' => 'dashboard', 'route' => 'dashboard',
'icon' => 'fa-th', 'icon' => 'fa-th',
'authorize' => ['admin.login', 'admin.super'], 'authorize' => ['admin.login', 'admin.super'],
@@ -868,7 +840,7 @@ class AdminPlugin extends Plugin
]; ];
// Configuration // Configuration
$this->grav['twig']->plugins_hooked_nav['PLUGIN_ADMIN.CONFIGURATION'] = [ $twig->plugins_hooked_nav['PLUGIN_ADMIN.CONFIGURATION'] = [
'route' => 'config', 'route' => 'config',
'icon' => 'fa-wrench', 'icon' => 'fa-wrench',
'authorize' => [ 'authorize' => [
@@ -883,7 +855,7 @@ class AdminPlugin extends Plugin
// Pages // Pages
$count = new Container(['count' => function () { return $this->admin->pagesCount(); }]); $count = new Container(['count' => function () { return $this->admin->pagesCount(); }]);
$this->grav['twig']->plugins_hooked_nav['PLUGIN_ADMIN.PAGES'] = [ $twig->plugins_hooked_nav['PLUGIN_ADMIN.PAGES'] = [
'route' => 'pages', 'route' => 'pages',
'icon' => 'fa-file-text-o', 'icon' => 'fa-file-text-o',
'authorize' => ['admin.pages', 'admin.super'], 'authorize' => ['admin.pages', 'admin.super'],
@@ -893,7 +865,7 @@ class AdminPlugin extends Plugin
// Plugins // Plugins
$count = new Container(['updates' => 0, 'count' => function () { return count($this->admin->plugins()); }]); $count = new Container(['updates' => 0, 'count' => function () { return count($this->admin->plugins()); }]);
$this->grav['twig']->plugins_hooked_nav['PLUGIN_ADMIN.PLUGINS'] = [ $twig->plugins_hooked_nav['PLUGIN_ADMIN.PLUGINS'] = [
'route' => 'plugins', 'route' => 'plugins',
'icon' => 'fa-plug', 'icon' => 'fa-plug',
'authorize' => ['admin.plugins', 'admin.super'], 'authorize' => ['admin.plugins', 'admin.super'],
@@ -903,7 +875,7 @@ class AdminPlugin extends Plugin
// Themes // Themes
$count = new Container(['updates' => 0, 'count' => function () { return count($this->admin->themes()); }]); $count = new Container(['updates' => 0, 'count' => function () { return count($this->admin->themes()); }]);
$this->grav['twig']->plugins_hooked_nav['PLUGIN_ADMIN.THEMES'] = [ $twig->plugins_hooked_nav['PLUGIN_ADMIN.THEMES'] = [
'route' => 'themes', 'route' => 'themes',
'icon' => 'fa-tint', 'icon' => 'fa-tint',
'authorize' => ['admin.themes', 'admin.super'], 'authorize' => ['admin.themes', 'admin.super'],
@@ -912,7 +884,7 @@ class AdminPlugin extends Plugin
]; ];
// Tools // Tools
$this->grav['twig']->plugins_hooked_nav['PLUGIN_ADMIN.TOOLS'] = [ $twig->plugins_hooked_nav['PLUGIN_ADMIN.TOOLS'] = [
'route' => 'tools', 'route' => 'tools',
'icon' => 'fa-briefcase', 'icon' => 'fa-briefcase',
'authorize' => $this->admin::toolsPermissions(), 'authorize' => $this->admin::toolsPermissions(),
@@ -945,8 +917,8 @@ class AdminPlugin extends Plugin
$types = Pages::types(); $types = Pages::types();
// First filter by configuration // First filter by configuration
$hideTypes = Grav::instance()['config']->get('plugins.admin.hide_page_types', []); $hideTypes = (array)Grav::instance()['config']->get('plugins.admin.hide_page_types');
foreach ((array) $hideTypes as $hide) { foreach ($hideTypes as $hide) {
if (isset($types[$hide])) { if (isset($types[$hide])) {
unset($types[$hide]); unset($types[$hide]);
} else { } else {
@@ -979,8 +951,8 @@ class AdminPlugin extends Plugin
$types = Pages::modularTypes(); $types = Pages::modularTypes();
// First filter by configuration // First filter by configuration
$hideTypes = (array) Grav::instance()['config']->get('plugins.admin.hide_modular_page_types', []); $hideTypes = (array)Grav::instance()['config']->get('plugins.admin.hide_modular_page_types');
foreach ((array) $hideTypes as $hide) { foreach ($hideTypes as $hide) {
if (isset($types[$hide])) { if (isset($types[$hide])) {
unset($types[$hide]); unset($types[$hide]);
} else { } else {
@@ -1021,7 +993,12 @@ class AdminPlugin extends Plugin
return $login->validateField($type, $value, $extra); return $login->validateField($type, $value, $extra);
} }
protected function initializeController($task, $post) /**
* @param string $task
* @param array|null $post
* @return void
*/
protected function initializeController($task, $post = null): void
{ {
Admin::DEBUG && Admin::addDebugMessage('Admin controller: execute'); Admin::DEBUG && Admin::addDebugMessage('Admin controller: execute');
@@ -1031,36 +1008,80 @@ class AdminPlugin extends Plugin
$controller->redirect(); $controller->redirect();
} }
/**
* @return void
*/
protected function setupAdmin()
{
// Set cache based on admin_cache option.
/** @var Cache $cache */
$cache = $this->grav['cache'];
$cache->setEnabled($this->config->get('plugins.admin.cache_enabled'));
/** @var Pages $pages */
$pages = $this->grav['pages'];
// Disable frontend pages in admin.
$pages->disablePages();
// Force file hash checks to fix caching on moved and deleted pages.
$pages->setCheckMethod('hash');
/** @var Session $session */
$session = $this->grav['session'];
// Make sure that the session has been initialized.
try {
$session->init();
} catch (SessionException $e) {
$session->init();
$message = 'Session corruption detected, restarting session...';
/** @var Debugger $debugger */
$debugger = $this->grav['debugger'];
$debugger->addMessage($message);
$this->grav['messages']->add($message, 'error');
}
$this->active = true;
}
/** /**
* Initialize the admin. * Initialize the admin.
* *
* @return void
* @throws \RuntimeException * @throws \RuntimeException
*/ */
protected function initializeAdmin() protected function initializeAdmin()
{ {
$this->enable([
'onTwigExtensions' => ['onTwigExtensions', 1000],
'onPagesInitialized' => ['onPagesInitialized', 1000],
'onTwigLoader' => ['onTwigLoader', 1000],
'onTwigTemplatePaths' => ['onTwigTemplatePaths', 1000],
'onTwigSiteVariables' => ['onTwigSiteVariables', 1000],
'onAssetsInitialized' => ['onAssetsInitialized', 1000],
'onOutputGenerated' => ['onOutputGenerated', 0],
'onAdminAfterSave' => ['onAdminAfterSave', 0],
'onAdminData' => ['onAdminData', 0],
'onAdminMenu' => ['onAdminMenu', 1000],
]);
// Check for required plugins // Check for required plugins
if (!$this->grav['config']->get('plugins.login.enabled') || !$this->grav['config']->get('plugins.form.enabled') || !$this->grav['config']->get('plugins.email.enabled')) { if (!$this->grav['config']->get('plugins.login.enabled') || !$this->grav['config']->get('plugins.form.enabled') || !$this->grav['config']->get('plugins.email.enabled')) {
throw new \RuntimeException('One of the required plugins is missing or not enabled'); throw new \RuntimeException('One of the required plugins is missing or not enabled');
} }
// Initialize Admin Language if needed /** @var Cache $cache */
$cache = $this->grav['cache'];
// Have a unique Admin-only Cache key
$cache_key = $cache->getKey();
$cache->setKey($cache_key . '$');
/** @var Session $session */
$session = $this->grav['session'];
/** @var Language $language */ /** @var Language $language */
$language = $this->grav['language']; $language = $this->grav['language'];
if ($language->enabled() && empty($this->grav['session']->admin_lang)) {
$this->grav['session']->admin_lang = $language->getLanguage(); /** @var UniformResourceLocator $locator */
$locator = $this->grav['locator'];
// Turn on Twig autoescaping
if ($this->uri->param('task') !== 'processmarkdown') {
$this->grav['twig']->setAutoescape(true);
}
// Initialize Admin Language if needed
if ($language->enabled() && empty($session->admin_lang)) {
$session->admin_lang = $language->getLanguage();
} }
// Decide admin template and route. // Decide admin template and route.
@@ -1080,8 +1101,46 @@ class AdminPlugin extends Plugin
// Initialize admin class (also registers it to Grav services). // Initialize admin class (also registers it to Grav services).
$this->admin = new Admin($this->grav, $this->admin_route, $this->template, $this->route); $this->admin = new Admin($this->grav, $this->admin_route, $this->template, $this->route);
// Get theme for admin
$this->theme = $this->config->get('plugins.admin.theme', 'grav');
// Replace themes service with admin.
$this->grav['themes'] = function () {
return new Themes($this->grav);
};
// Initialize white label functionality
$this->grav['admin-whitelabel'] = new WhiteLabel();
// These events are needed for login.
$this->enable([
'onTwigExtensions' => ['onTwigExtensions', 1000],
'onPagesInitialized' => ['onPagesInitialized', 1000],
'onTwigLoader' => ['onTwigLoader', 1000],
'onTwigTemplatePaths' => ['onTwigTemplatePaths', 1000],
'onTwigSiteVariables' => ['onTwigSiteVariables', 1000],
'onAssetsInitialized' => ['onAssetsInitialized', 1000],
]);
// Do not do more if user isn't logged in.
if (!$this->admin->user->authorize('admin.login')) {
return;
}
// These events are not needed during login.
$this->enable([
'onAdminControllerInit' => ['onAdminControllerInit', 1001],
'onAdminDashboard' => ['onAdminDashboard', 0],
'onAdminMenu' => ['onAdminMenu', 1000],
'onAdminTools' => ['onAdminTools', 0],
'onAdminSave' => ['onAdminSave', 0],
'onAdminAfterSave' => ['onAdminAfterSave', 0],
'onAdminData' => ['onAdminData', 0],
'onOutputGenerated' => ['onOutputGenerated', 0],
]);
// Double check we have system.yaml, site.yaml etc // Double check we have system.yaml, site.yaml etc
$config_path = $this->grav['locator']->findResource('user://config'); $config_path = $locator->findResource('user://config');
foreach ($this->admin::configurations() as $config_file) { foreach ($this->admin::configurations() as $config_file) {
if ($config_file === 'info') { if ($config_file === 'info') {
continue; continue;
@@ -1092,9 +1151,6 @@ class AdminPlugin extends Plugin
} }
} }
// Get theme for admin
$this->theme = $this->config->get('plugins.admin.theme', 'grav');
$assets = $this->grav['assets']; $assets = $this->grav['assets'];
$translations = 'this.GravAdmin = this.GravAdmin || {}; if (!this.GravAdmin.translations) this.GravAdmin.translations = {}; ' . PHP_EOL . 'this.GravAdmin.translations.PLUGIN_ADMIN = {'; $translations = 'this.GravAdmin = this.GravAdmin || {}; if (!this.GravAdmin.translations) this.GravAdmin.translations = {}; ' . PHP_EOL . 'this.GravAdmin.translations.PLUGIN_ADMIN = {';
@@ -1227,13 +1283,20 @@ class AdminPlugin extends Plugin
$this->config->set('system.languages.translations', $translations_actual_state); $this->config->set('system.languages.translations', $translations_actual_state);
$assets->addInlineJs($translations); $assets->addInlineJs($translations);
// Fire even to register permissions from other plugins
$this->grav->fireEvent('onAdminRegisterPermissions', new Event(['admin' => $this->admin]));
} }
/**
* @return array
*/
public static function themeOptions() public static function themeOptions()
{ {
static $options = []; static $options;
if (empty($options)) { if (null === $options) {
$options = [];
$theme_files = glob(__dir__ . '/themes/grav/css/codemirror/themes/*.css'); $theme_files = glob(__dir__ . '/themes/grav/css/codemirror/themes/*.css');
foreach ($theme_files as $theme_file) { foreach ($theme_files as $theme_file) {
$theme = basename(basename($theme_file, '.css')); $theme = basename(basename($theme_file, '.css'));
@@ -1244,6 +1307,9 @@ class AdminPlugin extends Plugin
return $options; return $options;
} }
/**
* @return array
*/
public function getPresets() public function getPresets()
{ {
$filename = $this->grav['locator']->findResource('plugin://admin/presets.yaml', false); $filename = $this->grav['locator']->findResource('plugin://admin/presets.yaml', false);
@@ -1257,12 +1323,12 @@ class AdminPlugin extends Plugin
$custom_presets = Yaml::parse($custom_presets); $custom_presets = Yaml::parse($custom_presets);
if (is_array($custom_presets)) { if (is_array($custom_presets)) {
if (isset($custom_presets['name']) && isset($custom_presets['colors']) && isset($custom_presets['accents'])) { if (isset($custom_presets['name'], $custom_presets['colors'], $custom_presets['accents'])) {
$preset = [Inflector::hyphenize($custom_presets['name']) => $custom_presets]; $preset = [Inflector::hyphenize($custom_presets['name']) => $custom_presets];
$presets = $preset + $presets; $presets = $preset + $presets;
} else { } else {
foreach ($custom_presets as $value) { foreach ($custom_presets as $value) {
if (isset($value['name']) && isset($value['colors']) && isset($value['accents'])) { if (isset($value['name'], $value['colors'], $value['accents'])) {
$preset = [Inflector::hyphenize($value['name']) => $value]; $preset = [Inflector::hyphenize($value['name']) => $value];
$presets = $preset + $presets; $presets = $preset + $presets;
} }

View File

@@ -15,7 +15,7 @@ docs: https://github.com/getgrav/grav-plugin-admin/blob/develop/README.md
license: MIT license: MIT
dependencies: dependencies:
- { name: grav, version: '>=1.7.4' } - { name: grav, version: '>=1.7.10' }
- { name: form, version: '>=4.1.0' } - { name: form, version: '>=4.1.0' }
- { name: login, version: '>=3.3.5' } - { name: login, version: '>=3.3.5' }
- { name: email, version: '>=3.0.9' } - { name: email, version: '>=3.0.9' }

View File

@@ -26,7 +26,6 @@ use Grav\Common\Themes;
use Grav\Common\Uri; use Grav\Common\Uri;
use Grav\Common\User\Interfaces\UserCollectionInterface; use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\Interfaces\UserInterface; use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\User\User;
use Grav\Common\Utils; use Grav\Common\Utils;
use Grav\Framework\Acl\Action; use Grav\Framework\Acl\Action;
use Grav\Framework\Acl\Permissions; use Grav\Framework\Acl\Permissions;
@@ -40,6 +39,7 @@ use Grav\Plugin\AdminPlugin;
use Grav\Plugin\Login\Login; use Grav\Plugin\Login\Login;
use Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth; use Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth;
use PicoFeed\Parser\MalformedXmlException; use PicoFeed\Parser\MalformedXmlException;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event; use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\File\File; use RocketTheme\Toolbox\File\File;
use RocketTheme\Toolbox\File\JsonFile; use RocketTheme\Toolbox\File\JsonFile;
@@ -52,72 +52,63 @@ use PicoFeed\Reader\Reader;
define('LOGIN_REDIRECT_COOKIE', 'grav-login-redirect'); define('LOGIN_REDIRECT_COOKIE', 'grav-login-redirect');
/**
* Class Admin
* @package Grav\Plugin\Admin
*/
class Admin class Admin
{ {
/** @var int */
public const DEBUG = 1; public const DEBUG = 1;
/** @var int */
public const MEDIA_PAGINATION_INTERVAL = 20; public const MEDIA_PAGINATION_INTERVAL = 20;
/** @var string */
public const TMP_COOKIE_NAME = 'tmp-admin-message'; public const TMP_COOKIE_NAME = 'tmp-admin-message';
/** @var Grav */ /** @var Grav */
public $grav; public $grav;
/** @var ServerRequestInterface|null */
public $request;
/** @var AdminForm */
public $form;
/** @var string */ /** @var string */
public $base; public $base;
/** @var string */ /** @var string */
public $location; public $location;
/** @var string */ /** @var string */
public $route; public $route;
/** @var UserInterface */
/** @var User */
public $user; public $user;
/** @var array */ /** @var array */
public $forgot; public $forgot;
/** @var string */ /** @var string */
public $task; public $task;
/** @var array */ /** @var array */
public $json_response; public $json_response;
/** @var Collection */ /** @var Collection */
public $collection; public $collection;
/** @var bool */ /** @var bool */
public $multilang; public $multilang;
/** @var string */ /** @var string */
public $language; public $language;
/** @var array */ /** @var array */
public $languages_enabled = []; public $languages_enabled = [];
/** @var Uri $uri */ /** @var Uri $uri */
protected $uri; protected $uri;
/** @var array */ /** @var array */
protected $pages = []; protected $pages = [];
/** @var Session */ /** @var Session */
protected $session; protected $session;
/** @var Data\Blueprints */ /** @var Data\Blueprints */
protected $blueprints; protected $blueprints;
/** @var GPM */ /** @var GPM */
protected $gpm; protected $gpm;
/** @var int */ /** @var int */
protected $pages_count; protected $pages_count;
/** @var bool */ /** @var bool */
protected $load_additional_files_in_background = false; protected $load_additional_files_in_background = false;
/** @var bool */ /** @var bool */
protected $loading_additional_files_in_background = false; protected $loading_additional_files_in_background = false;
/** @var array */ /** @var array */
protected $temp_messages = []; protected $temp_messages = [];
@@ -127,7 +118,7 @@ class Admin
* @param Grav $grav * @param Grav $grav
* @param string $base * @param string $base
* @param string $location * @param string $location
* @param string $route * @param string|null $route
*/ */
public function __construct(Grav $grav, $base, $location, $route) public function __construct(Grav $grav, $base, $location, $route)
{ {
@@ -137,7 +128,7 @@ class Admin
$this->grav = $grav; $this->grav = $grav;
$this->base = $base; $this->base = $base;
$this->location = $location; $this->location = $location;
$this->route = $route; $this->route = $route ?? '';
$this->uri = $grav['uri']; $this->uri = $grav['uri'];
$this->session = $grav['session']; $this->session = $grav['session'];
@@ -176,7 +167,7 @@ class Admin
$this->languages_enabled = (array)$this->grav['config']->get('system.languages.supported', []); $this->languages_enabled = (array)$this->grav['config']->get('system.languages.supported', []);
//Set the currently active language for the admin //Set the currently active language for the admin
$languageCode = $this->grav['uri']->param('lang'); $languageCode = $this->uri->param('lang');
if (null === $languageCode && !$this->session->admin_lang) { if (null === $languageCode && !$this->session->admin_lang) {
$this->session->admin_lang = $language->getActive() ?? ''; $this->session->admin_lang = $language->getActive() ?? '';
} }
@@ -190,7 +181,8 @@ class Admin
/** /**
* @param string $message * @param string $message
* @param array $data * @param array|object $data
* @return void
*/ */
public static function addDebugMessage(string $message, $data = []) public static function addDebugMessage(string $message, $data = [])
{ {
@@ -199,6 +191,9 @@ class Admin
$debugger->addMessage($message, 'debug', $data); $debugger->addMessage($message, 'debug', $data);
} }
/**
* @return string[]
*/
public static function contentEditor() public static function contentEditor()
{ {
$options = [ $options = [
@@ -238,6 +233,9 @@ class Admin
return $languages; return $languages;
} }
/**
* @return string
*/
public function getLanguage(): string public function getLanguage(): string
{ {
return $this->language ?: $this->grav['language']->getLanguage() ?: 'en'; return $this->language ?: $this->grav['language']->getLanguage() ?: 'en';
@@ -317,6 +315,9 @@ class Admin
return $tools; return $tools;
} }
/**
* @return array
*/
public static function toolsPermissions() public static function toolsPermissions()
{ {
$tools = static::tools(); $tools = static::tools();
@@ -349,12 +350,11 @@ class Admin
/** /**
* Static helper method to return the admin form nonce * Static helper method to return the admin form nonce
* *
* @param string $action
* @return string * @return string
*/ */
public static function getNonce() public static function getNonce(string $action = 'admin-form')
{ {
$action = 'admin-form';
return Utils::getNonce($action); return Utils::getNonce($action);
} }
@@ -388,11 +388,16 @@ class Admin
return $admin->getCurrentRoute(); return $admin->getCurrentRoute();
} }
/**
* @param string $path
* @param string|null $languageCode
* @return Route
*/
public function getAdminRoute(string $path = '', $languageCode = null): Route public function getAdminRoute(string $path = '', $languageCode = null): Route
{ {
/** @var Language $language */ /** @var Language $language */
$language = $this->grav['language']; $language = $this->grav['language'];
$languageCode = $languageCode ?? $language->getActive(); $languageCode = $languageCode ?? ($language->getActive() ?: null);
$languagePrefix = $languageCode ? '/' . $languageCode : ''; $languagePrefix = $languageCode ? '/' . $languageCode : '';
$root = $this->grav['uri']->rootUrl(); $root = $this->grav['uri']->rootUrl();
@@ -415,6 +420,11 @@ class Admin
return RouteFactory::createFromParts($parts); return RouteFactory::createFromParts($parts);
} }
/**
* @param string $route
* @param string|null $languageCode
* @return string
*/
public function adminUrl(string $route = '', $languageCode = null) public function adminUrl(string $route = '', $languageCode = null)
{ {
return $this->getAdminRoute($route, $languageCode)->toString(true); return $this->getAdminRoute($route, $languageCode)->toString(true);
@@ -435,6 +445,9 @@ class Admin
return $admin->getCurrentRoute(); return $admin->getCurrentRoute();
} }
/**
* @return string|null
*/
public function getCurrentRoute() public function getCurrentRoute()
{ {
$pages = static::enablePages(); $pages = static::enablePages();
@@ -460,6 +473,7 @@ class Admin
* *
* @param string $redirect * @param string $redirect
* @param int $redirectCode * @param int $redirectCode
* @return void
*/ */
public function redirect($redirect, $redirectCode = 303) public function redirect($redirect, $redirectCode = 303)
{ {
@@ -520,6 +534,9 @@ class Admin
return count($this->grav['config']->get('system.languages.supported', [])) > 1; return count($this->grav['config']->get('system.languages.supported', [])) > 1;
} }
/**
* @return string
*/
public static function getTempDir() public static function getTempDir()
{ {
try { try {
@@ -531,6 +548,9 @@ class Admin
return $tmp_dir; return $tmp_dir;
} }
/**
* @return array
*/
public static function getPageMedia() public static function getPageMedia()
{ {
$files = []; $files = [];
@@ -564,8 +584,7 @@ class Admin
/** /**
* Fetch and delete messages from the session queue. * Fetch and delete messages from the session queue.
* *
* @param string $type * @param string|null $type
*
* @return array * @return array
*/ */
public function messages($type = null) public function messages($type = null)
@@ -580,6 +599,8 @@ class Admin
* Authenticate user. * Authenticate user.
* *
* @param array $credentials User credentials. * @param array $credentials User credentials.
* @param array $post
* @return never-return
*/ */
public function authenticate($credentials, $post) public function authenticate($credentials, $post)
{ {
@@ -658,6 +679,10 @@ class Admin
/** /**
* Check Two-Factor Authentication. * Check Two-Factor Authentication.
*
* @param array $data
* @param array $post
* @return never-return
*/ */
public function twoFa($data, $post) public function twoFa($data, $post)
{ {
@@ -695,6 +720,10 @@ class Admin
/** /**
* Logout from admin. * Logout from admin.
*
* @param array $data
* @param array $post
* @return never-return
*/ */
public function logout($data, $post) public function logout($data, $post)
{ {
@@ -718,15 +747,8 @@ class Admin
public static function doAnyUsersExist() public static function doAnyUsersExist()
{ {
$accounts = Grav::instance()['accounts'] ?? null; $accounts = Grav::instance()['accounts'] ?? null;
if ($accounts instanceof \Countable) {
return $accounts->count() > 0;
}
// TODO: remove old way to check for existence of a user account (Grav < v1.6.9) return $accounts && $accounts->count() > 0;
$account_dir = $file_path = Grav::instance()['locator']->findResource('account://');
$user_check = glob($account_dir . '/*.yaml');
return $user_check;
} }
/** /**
@@ -734,6 +756,7 @@ class Admin
* *
* @param string $msg * @param string $msg
* @param string $type * @param string $type
* @return void
*/ */
public function setMessage($msg, $type = 'info') public function setMessage($msg, $type = 'info')
{ {
@@ -742,11 +765,19 @@ class Admin
$messages->add($msg, $type); $messages->add($msg, $type);
} }
/**
* @param string $msg
* @param string $type
* @return void
*/
public function addTempMessage($msg, $type) public function addTempMessage($msg, $type)
{ {
$this->temp_messages[] = ['message' => $msg, 'scope' => $type]; $this->temp_messages[] = ['message' => $msg, 'scope' => $type];
} }
/**
* @return array
*/
public function getTempMessages() public function getTempMessages()
{ {
return $this->temp_messages; return $this->temp_messages;
@@ -755,11 +786,9 @@ class Admin
/** /**
* Translate a string to the user-defined language * Translate a string to the user-defined language
* *
* @param array|mixed $args * @param array|string $args
* * @param array|null $languages
* @param mixed $languages * @return string|string[]|null
*
* @return string
*/ */
public static function translate($args, $languages = null) public static function translate($args, $languages = null)
{ {
@@ -812,7 +841,6 @@ class Admin
* Checks user authorisation to the action. * Checks user authorisation to the action.
* *
* @param string|string[] $action * @param string|string[] $action
*
* @return bool * @return bool
*/ */
public function authorize($action = 'admin.login') public function authorize($action = 'admin.login')
@@ -839,7 +867,6 @@ class Admin
* *
* @param string $type * @param string $type
* @param array $post * @param array $post
*
* @return mixed * @return mixed
* @throws \RuntimeException * @throws \RuntimeException
*/ */
@@ -958,7 +985,6 @@ class Admin
* *
* @param string $type * @param string $type
* @param array|null $post * @param array|null $post
*
* @return object * @return object
* @throws \RuntimeException * @throws \RuntimeException
*/ */
@@ -990,7 +1016,7 @@ class Admin
if (preg_match('|plugins/|', $type)) { if (preg_match('|plugins/|', $type)) {
$obj = Plugins::get(preg_replace('|plugins/|', '', $type)); $obj = Plugins::get(preg_replace('|plugins/|', '', $type));
if (null === $obj) { if (null === $obj) {
return []; return new \stdClass();
} }
if ($post) { if ($post) {
@@ -1005,7 +1031,7 @@ class Admin
$themes = $this->grav['themes']; $themes = $this->grav['themes'];
$obj = $themes->get(preg_replace('|themes/|', '', $type)); $obj = $themes->get(preg_replace('|themes/|', '', $type));
if (null === $obj) { if (null === $obj) {
return []; return new \stdClass();
} }
if ($post) { if ($post) {
@@ -1070,6 +1096,11 @@ class Admin
return $data[$type]; return $data[$type];
} }
/**
* @param Data\Data $object
* @param array $post
* @return Data\Data
*/
protected function mergePost(Data\Data $object, array $post) protected function mergePost(Data\Data $object, array $post)
{ {
$object->merge($post); $object->merge($post);
@@ -1105,6 +1136,9 @@ class Admin
return $post; return $post;
} }
/**
* @return bool
*/
protected function hasErrorMessage() protected function hasErrorMessage()
{ {
$msgs = $this->grav['messages']->all(); $msgs = $this->grav['messages']->all();
@@ -1120,7 +1154,6 @@ class Admin
* Returns blueprints for the given type. * Returns blueprints for the given type.
* *
* @param string $type * @param string $type
*
* @return Data\Blueprint * @return Data\Blueprint
*/ */
public function blueprints($type) public function blueprints($type)
@@ -1136,7 +1169,6 @@ class Admin
* Converts dot notation to array notation. * Converts dot notation to array notation.
* *
* @param string $name * @param string $name
*
* @return string * @return string
*/ */
public function field($name) public function field($name)
@@ -1150,7 +1182,6 @@ class Admin
* Get all routes. * Get all routes.
* *
* @param bool $unique * @param bool $unique
*
* @return array * @return array
*/ */
public function routes($unique = false) public function routes($unique = false)
@@ -1231,6 +1262,10 @@ class Admin
return []; return [];
} }
/**
* @param string|null $package_slug
* @return string[]|string
*/
public function license($package_slug) public function license($package_slug)
{ {
return Licenses::get($package_slug); return Licenses::get($package_slug);
@@ -1241,7 +1276,6 @@ class Admin
* packages that can be removed when removing a package. * packages that can be removed when removing a package.
* *
* @param string $slug The package slug * @param string $slug The package slug
*
* @return array|bool * @return array|bool
*/ */
public function dependenciesThatCanBeRemovedWhenRemoving($slug) public function dependenciesThatCanBeRemovedWhenRemoving($slug)
@@ -1255,8 +1289,7 @@ class Admin
$package = $this->getPackageFromGPM($slug); $package = $this->getPackageFromGPM($slug);
if ($package) { if ($package && $package->dependencies) {
if ($package->dependencies) {
foreach ($package->dependencies as $dependency) { foreach ($package->dependencies as $dependency) {
// if (count($gpm->getPackagesThatDependOnPackage($dependency)) > 1) { // if (count($gpm->getPackagesThatDependOnPackage($dependency)) > 1) {
// continue; // continue;
@@ -1265,14 +1298,11 @@ class Admin
$dependency = $dependency['name']; $dependency = $dependency['name'];
} }
if (!in_array($dependency, $dependencies, true)) { if (!in_array($dependency, $dependencies, true) && !in_array($dependency, ['admin', 'form', 'login', 'email', 'php'])) {
if (!in_array($dependency, ['admin', 'form', 'login', 'email', 'php'])) {
$dependencies[] = $dependency; $dependencies[] = $dependency;
} }
} }
} }
}
}
return $dependencies; return $dependencies;
} }
@@ -1295,6 +1325,10 @@ class Admin
return $this->gpm; return $this->gpm;
} }
/**
* @param string $package_slug
* @return mixed
*/
public function getPackageFromGPM($package_slug) public function getPackageFromGPM($package_slug)
{ {
$package = $this->plugins(true)[$package_slug]; $package = $this->plugins(true)[$package_slug];
@@ -1309,7 +1343,6 @@ class Admin
* Get all plugins. * Get all plugins.
* *
* @param bool $local * @param bool $local
*
* @return mixed * @return mixed
*/ */
public function plugins($local = true) public function plugins($local = true)
@@ -1338,7 +1371,6 @@ class Admin
* Get all themes. * Get all themes.
* *
* @param bool $local * @param bool $local
*
* @return mixed * @return mixed
*/ */
public function themes($local = true) public function themes($local = true)
@@ -1384,9 +1416,8 @@ class Admin
* Check the passed packages list can be updated * Check the passed packages list can be updated
* *
* @param array $packages * @param array $packages
*
* @throws \Exception
* @return bool * @return bool
* @throws \Exception
*/ */
public function checkPackagesCanBeInstalled($packages) public function checkPackagesCanBeInstalled($packages)
{ {
@@ -1405,7 +1436,6 @@ class Admin
* to be installed. * to be installed.
* *
* @param array $packages The packages slugs * @param array $packages The packages slugs
*
* @return array|bool * @return array|bool
*/ */
public function getDependenciesNeededToInstall($packages) public function getDependenciesNeededToInstall($packages)
@@ -1422,8 +1452,7 @@ class Admin
* Used by the Dashboard in the admin to display the X latest pages * Used by the Dashboard in the admin to display the X latest pages
* that have been modified * that have been modified
* *
* @param integer $count number of pages to pull back * @param int $count number of pages to pull back
*
* @return array|null * @return array|null
*/ */
public function latestPages($count = 10) public function latestPages($count = 10)
@@ -1517,7 +1546,6 @@ class Admin
* Determine if the plugin or theme info passed is from Team Grav * Determine if the plugin or theme info passed is from Team Grav
* *
* @param object $info Plugin or Theme info object * @param object $info Plugin or Theme info object
*
* @return bool * @return bool
*/ */
public function isTeamGrav($info) public function isTeamGrav($info)
@@ -1529,7 +1557,6 @@ class Admin
* Determine if the plugin or theme info passed is premium * Determine if the plugin or theme info passed is premium
* *
* @param object $info Plugin or Theme info object * @param object $info Plugin or Theme info object
*
* @return bool * @return bool
*/ */
public function isPremiumProduct($info) public function isPremiumProduct($info)
@@ -1542,13 +1569,12 @@ class Admin
* *
* @return string The phpinfo() output * @return string The phpinfo() output
*/ */
function phpinfo() public function phpinfo()
{ {
if (function_exists('phpinfo')) { if (function_exists('phpinfo')) {
ob_start(); ob_start();
phpinfo(); phpinfo();
$pinfo = ob_get_clean(); $pinfo = ob_get_clean();
$pinfo = preg_replace('%^.*<body>(.*)</body>.*$%ms', '$1', $pinfo); $pinfo = preg_replace('%^.*<body>(.*)</body>.*$%ms', '$1', $pinfo);
return $pinfo; return $pinfo;
@@ -1560,8 +1586,7 @@ class Admin
/** /**
* Guest date format based on euro/US * Guest date format based on euro/US
* *
* @param string $date * @param string|null $date
*
* @return string * @return string
*/ */
public function guessDateFormat($date) public function guessDateFormat($date)
@@ -1584,6 +1609,7 @@ class Admin
'g:ia' 'g:ia'
]; ];
$date = (string)$date;
if (!isset($guess[$date])) { if (!isset($guess[$date])) {
$guess[$date] = 'd-m-Y H:i'; $guess[$date] = 'd-m-Y H:i';
foreach ($date_formats as $date_format) { foreach ($date_formats as $date_format) {
@@ -1605,6 +1631,11 @@ class Admin
return $guess[$date]; return $guess[$date];
} }
/**
* @param string $date
* @param string $format
* @return bool
*/
public function validateDate($date, $format) public function validateDate($date, $format)
{ {
$d = DateTime::createFromFormat($format, $date); $d = DateTime::createFromFormat($format, $date);
@@ -1614,7 +1645,6 @@ class Admin
/** /**
* @param string $php_format * @param string $php_format
*
* @return string * @return string
*/ */
public function dateformatToMomentJS($php_format) public function dateformatToMomentJS($php_format)

View File

@@ -24,7 +24,7 @@ use Grav\Common\Page\Pages;
use Grav\Common\Page\Collection; use Grav\Common\Page\Collection;
use Grav\Common\Security; use Grav\Common\Security;
use Grav\Common\User\Interfaces\UserCollectionInterface; use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\User; use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\Utils; use Grav\Common\Utils;
use Grav\Framework\Psr7\Response; use Grav\Framework\Psr7\Response;
use Grav\Framework\RequestHandler\Exception\RequestException; use Grav\Framework\RequestHandler\Exception\RequestException;
@@ -195,29 +195,7 @@ class AdminController extends AdminBaseController
return true; return true;
} }
// LOGIN & USER TASKS // USER TASKS
/**
* Handle login.
*
* @return bool True if the action was performed.
*/
protected function taskLogin()
{
$this->admin->authenticate($this->data, $this->post);
return true;
}
/**
* @return bool True if the action was performed.
*/
protected function taskTwofa()
{
$this->admin->twoFa($this->data, $this->post);
return true;
}
/** /**
* Handle logout. * Handle logout.
@@ -226,6 +204,10 @@ class AdminController extends AdminBaseController
*/ */
protected function taskLogout() protected function taskLogout()
{ {
if (!$this->authorizeTask('logout', ['admin.login'])) {
return false;
}
$this->admin->logout($this->data, $this->post); $this->admin->logout($this->data, $this->post);
return true; return true;
@@ -241,7 +223,7 @@ class AdminController extends AdminBaseController
} }
try { try {
/** @var User $user */ /** @var UserInterface $user */
$user = $this->grav['user']; $user = $this->grav['user'];
/** @var TwoFactorAuth $twoFa */ /** @var TwoFactorAuth $twoFa */
@@ -278,172 +260,6 @@ class AdminController extends AdminBaseController
return true; return true;
} }
/**
* Handle the reset password action.
*
* @return bool True if the action was performed.
*/
public function taskReset()
{
$data = $this->data;
if (isset($data['password'])) {
/** @var UserCollectionInterface $users */
$users = $this->grav['accounts'];
$username = isset($data['username']) ? strip_tags(strtolower($data['username'])) : null;
$user = $username ? $users->load($username) : null;
$password = $data['password'] ?? null;
$token = $data['token'] ?? null;
if ($user && $user->exists() && !empty($user->get('reset'))) {
list($good_token, $expire) = explode('::', $user->get('reset'));
if ($good_token === $token) {
if (time() > $expire) {
$this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.RESET_LINK_EXPIRED'), 'error');
$this->setRedirect('/forgot');
return true;
}
$user->undef('hashed_password');
$user->undef('reset');
$user->set('password', $password);
$user->save();
$this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.RESET_PASSWORD_RESET'), 'info');
$this->setRedirect('/');
return true;
}
}
$this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.RESET_INVALID_LINK'), 'error');
$this->setRedirect('/forgot');
return true;
}
$user = $this->grav['uri']->param('user');
$token = $this->grav['uri']->param('token');
if (empty($user) || empty($token)) {
$this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.RESET_INVALID_LINK'), 'error');
$this->setRedirect('/forgot');
return true;
}
$this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.RESET_NEW_PASSWORD'), 'info');
$this->admin->forgot = ['username' => $user, 'token' => $token];
return true;
}
/**
* Handle the email password recovery procedure.
*
* @return bool True if the action was performed.
*/
protected function taskForgot()
{
$param_sep = $this->grav['config']->get('system.param_sep', ':');
$post = $this->post;
$data = $this->data;
$login = $this->grav['login'];
/** @var UserCollectionInterface $users */
$users = $this->grav['accounts'];
$username = isset($data['username']) ? strip_tags(strtolower($data['username'])) : '';
$user = !empty($username) ? $users->load($username) : null;
if (!isset($this->grav['Email'])) {
$this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.FORGOT_EMAIL_NOT_CONFIGURED'), 'error');
$this->setRedirect($post['redirect']);
return true;
}
if (!$user || !$user->exists()) {
$this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL'),
'info');
$this->setRedirect($post['redirect']);
return true;
}
if (empty($user->email)) {
$this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL'),
'info');
$this->setRedirect($post['redirect']);
return true;
}
$count = $this->grav['config']->get('plugins.login.max_pw_resets_count', 0);
$interval =$this->grav['config']->get('plugins.login.max_pw_resets_interval', 2);
if ($login->isUserRateLimited($user, 'pw_resets', $count, $interval)) {
$this->admin->setMessage($this->admin::translate(['PLUGIN_LOGIN.FORGOT_CANNOT_RESET_IT_IS_BLOCKED', $user->email, $interval]), 'error');
$this->setRedirect($post['redirect']);
return true;
}
$token = md5(uniqid(mt_rand(), true));
$expire = time() + 604800; // next week
$user->set('reset', $token . '::' . $expire);
$user->save();
$author = $this->grav['config']->get('site.author.name', '');
$fullname = $user->fullname ?: $username;
$reset_link = rtrim($this->grav['uri']->rootUrl(true), '/') . '/' . trim($this->admin->base,
'/') . '/reset/task' . $param_sep . 'reset/user' . $param_sep . $username . '/token' . $param_sep . $token . '/admin-nonce' . $param_sep . Utils::getNonce('admin-form');
$sitename = $this->grav['config']->get('site.title', 'Website');
$from = $this->grav['config']->get('plugins.email.from');
if (empty($from)) {
$this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.FORGOT_EMAIL_NOT_CONFIGURED'), 'error');
$this->setRedirect($post['redirect']);
return true;
}
$to = $user->email;
$subject = $this->admin::translate(['PLUGIN_ADMIN.FORGOT_EMAIL_SUBJECT', $sitename]);
$content = $this->admin::translate([
'PLUGIN_ADMIN.FORGOT_EMAIL_BODY',
$fullname,
$reset_link,
$author,
$sitename
]);
$body = $this->grav['twig']->processTemplate('email/base.html.twig', ['content' => $content]);
$message = $this->grav['Email']->message($subject, $body, 'text/html')->setFrom($from)->setTo($to);
$sent = $this->grav['Email']->send($message);
if ($sent < 1) {
$this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.FORGOT_FAILED_TO_EMAIL'), 'error');
} else {
$this->admin->setMessage($this->admin::translate('PLUGIN_ADMIN.FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL'),
'info');
}
$this->setRedirect('/');
return true;
}
/** /**
* Save user account. * Save user account.
* *

View File

@@ -0,0 +1,182 @@
<?php
/**
* @package Grav\Plugin\Admin
*
* @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Plugin\Admin;
use ArrayAccess;
use Exception;
use Grav\Common\Data\Blueprint;
use Grav\Common\Data\Data;
use Grav\Framework\Form\Interfaces\FormFlashInterface;
use Grav\Framework\Form\Interfaces\FormInterface;
use Grav\Framework\Form\Traits\FormTrait;
use InvalidArgumentException;
use JsonSerializable;
/**
* Class AdminForm
* @package Grav\Plugin\Admin
*/
class AdminForm implements FormInterface, JsonSerializable
{
use FormTrait;
/** @var string */
protected $nonce_name;
/** @var string */
protected $nonce_action;
/** @var callable */
protected $submitMethod;
/**
* AdminForm constructor.
*
* @param string $name
* @param array $options
*/
public function __construct(string $name, array $options)
{
$this->name = $name;
$this->nonce_name = $options['nonce_name'] ?? 'admin-nonce';
$this->nonce_action = $options['nonce_action'] ?? 'admin-form';
$this->setId($options['id'] ?? $this->getName());
$this->setUniqueId($options['unique_id'] ?? $this->getName());
$this->setBlueprint($options['blueprint']);
$this->setSubmitMethod($options['submit_method'] ?? null);
$this->setFlashLookupFolder('tmp://admin/forms/[SESSIONID]');
if (!empty($options['reset'])) {
$this->getFlash()->delete();
}
$this->initialize();
}
/**
* @return $this
*/
public function initialize(): AdminForm
{
$this->messages = [];
$this->submitted = false;
$this->unsetFlash();
/** @var FormFlashInterface $flash */
$flash = $this->getFlash();
if ($flash->exists()) {
$data = $flash->getData();
if (null !== $data) {
$data = new Data($data, $this->getBlueprint());
$data->setKeepEmptyValues(true);
$data->setMissingValuesAsNull(true);
}
$this->data = $data;
$this->files = $flash->getFilesByFields(false);
} else {
$this->data = new Data([], $this->getBlueprint());
$this->files = [];
}
return $this;
}
/**
* @return string
*/
public function getNonceName(): string
{
return $this->nonce_name;
}
/**
* @return string
*/
public function getNonceAction(): string
{
return $this->nonce_action;
}
/**
* @return string
*/
public function getScope(): string
{
return 'data.';
}
/**
* @param Blueprint $blueprint
*/
public function setBlueprint(Blueprint $blueprint): void
{
if (null === $blueprint) {
throw new InvalidArgumentException('Blueprint is required');
}
$this->blueprint = $blueprint;
}
/**
* @param string $field
* @param mixed $value
*/
public function setData(string $field, $value): void
{
$this->getData()->set($field, $value);
}
/**
* @return Blueprint
*/
public function getBlueprint(): Blueprint
{
return $this->blueprint;
}
/**
* @param callable|null $submitMethod
*/
public function setSubmitMethod(?callable $submitMethod): void
{
if (null === $submitMethod) {
throw new InvalidArgumentException('Submit method is required');
}
$this->submitMethod = $submitMethod;
}
/**
* @param array $data
* @param array $files
* @return void
* @throws Exception
*/
protected function doSubmit(array $data, array $files): void
{
$method = $this->submitMethod;
$method($data, $files);
$this->reset();
}
/**
* Filter validated data.
*
* @param ArrayAccess|Data|null $data
* @return void
*/
protected function filterData($data = null): void
{
if ($data instanceof Data) {
$data->filter(true, true);
}
}
}

View File

@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Grav\Plugin\Admin;
use Grav\Common\Grav;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Page;
use Grav\Framework\Form\Interfaces\FormFactoryInterface;
use Grav\Framework\Form\Interfaces\FormInterface;
/**
* Class FlexFormFactory
* @package Grav\Plugin\FlexObjects
*/
class AdminFormFactory implements FormFactoryInterface
{
/**
* @param Page $page
* @param string $name
* @param array $form
* @return FormInterface|null
*/
public function createPageForm(Page $page, string $name, array $form): ?FormInterface
{
return $this->createFormForPage($page, $name, $form);
}
/**
* @param PageInterface $page
* @param string $name
* @param array $form
* @return FormInterface|null
*/
public function createFormForPage(PageInterface $page, string $name, array $form): ?FormInterface
{
/** @var Admin|null $admin */
$admin = Grav::instance()['admin'] ?? null;
$object = $admin->form ?? null;
return $object && $object->getName() === $name ? $object : null;
}
}

View File

@@ -0,0 +1,359 @@
<?php
/**
* @package Grav\Plugin\Admin
*
* @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
declare(strict_types=1);
namespace Grav\Plugin\Admin\Controllers;
use Grav\Common\Config\Config;
use Grav\Common\Data\Blueprint;
use Grav\Common\Grav;
use Grav\Common\Language\Language;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Page;
use Grav\Common\Page\Pages;
use Grav\Common\Uri;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\Utils;
use Grav\Framework\Controller\Traits\ControllerResponseTrait;
use Grav\Framework\RequestHandler\Exception\PageExpiredException;
use Grav\Framework\Session\SessionInterface;
use Grav\Plugin\Admin\Admin;
use Grav\Plugin\Admin\AdminForm;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Session\Message;
abstract class AdminController
{
use ControllerResponseTrait {
createRedirectResponse as traitCreateRedirectResponse;
getErrorJson as traitGetErrorJson;
}
/** @var string */
protected $nonce_action = 'admin-form';
/** @var string */
protected $nonce_name = 'admin-nonce';
/** @var Grav */
protected $grav;
/** @var PageInterface */
protected $page;
/** @var AdminForm|null */
protected $form;
public function __construct(Grav $grav)
{
$this->grav = $grav;
}
/**
* @return PageInterface|null
*/
public function getPage(): ?PageInterface
{
return $this->page;
}
/**
* Get currently active form.
*
* @return AdminForm|null
*/
public function getActiveForm(): ?AdminForm
{
if (null === $this->form) {
$post = $this->getPost();
$active = $post['__form-name__'] ?? null;
$this->form = $active ? $this->getForm($active) : null;
}
return $this->form;
}
/**
* Get a form.
*
* @param string $name
* @param array $options
* @return AdminForm|null
*/
public function getForm(string $name, array $options = []): ?AdminForm
{
$post = $this->getPost();
$page = $this->getPage();
$forms = $page ? $page->forms() : [];
$blueprint = $forms[$name] ?? null;
if (null === $blueprint) {
return null;
}
$active = $post['__form-name__'] ?? null;
$unique_id = $active && $active === $name ? ($post['__unique_form_id__'] ?? null) : null;
$options += [
'unique_id' => $unique_id,
'blueprint' => new Blueprint(null, ['form' => $blueprint]),
'submit_method' => $this->getFormSubmitMethod($name),
'nonce_name' => $this->nonce_name,
'nonce_action' => $this->nonce_action,
];
return new AdminForm($name, $options);
}
abstract protected function getFormSubmitMethod(string $name): callable;
/**
* @param string $route
* @param string|null $lang
* @return string
*/
public function getAdminUrl(string $route, string $lang = null): string
{
/** @var Pages $pages */
$pages = $this->grav['pages'];
$admin = $this->getAdmin();
return $pages->baseUrl($lang) . $admin->base . trim($route, '/');
}
/**
* @param string $route
* @param string|null $lang
* @return string
*/
public function getAbsoluteAdminUrl(string $route, string $lang = null): string
{
/** @var Pages $pages */
$pages = $this->grav['pages'];
$admin = $this->getAdmin();
return $pages->baseUrl($lang, true) . $admin->base . trim($route, '/');
}
/**
* Get session.
*
* @return SessionInterface
*/
public function getSession(): SessionInterface
{
return $this->grav['session'];
}
/**
* @return Admin
*/
protected function getAdmin(): Admin
{
return $this->grav['admin'];
}
/**
* @return UserInterface
*/
protected function getUser(): UserInterface
{
return $this->getAdmin()->user;
}
/**
* @return ServerRequestInterface
*/
public function getRequest(): ServerRequestInterface
{
return $this->getAdmin()->request;
}
/**
* @return array
*/
public function getPost(): array
{
return (array)($this->getRequest()->getParsedBody() ?? []);
}
/**
* Translate a string.
*
* @param string $string
* @param mixed ...$args
* @return string
*/
public function translate(string $string, ...$args): string
{
/** @var Language $language */
$language = $this->grav['language'];
array_unshift($args, $string);
return $language->translate($args);
}
/**
* Set message to be shown in the admin.
*
* @param string $message
* @param string $type
* @return $this
*/
public function setMessage(string $message, string $type = 'info'): AdminController
{
/** @var Message $messages */
$messages = $this->grav['messages'];
$messages->add($message, $type);
return $this;
}
/**
* @return Config
*/
protected function getConfig(): Config
{
return $this->grav['config'];
}
/**
* Check if request nonce is valid.
*
* @return void
* @throws PageExpiredException If nonce is not valid.
*/
protected function checkNonce(): void
{
$nonce = null;
$nonce_name = $this->form ? $this->form->getNonceName() : $this->nonce_name;
$nonce_action = $this->form ? $this->form->getNonceAction() : $this->nonce_action;
if (\in_array(strtoupper($this->getRequest()->getMethod()), ['POST', 'PUT', 'PATCH', 'DELETE'])) {
$post = $this->getPost();
$nonce = $post[$nonce_name] ?? null;
}
/** @var Uri $uri */
$uri = $this->grav['uri'];
if (!$nonce) {
$nonce = $uri->param($nonce_name);
}
if (!$nonce) {
$nonce = $uri->query($nonce_name);
}
if (!$nonce || !Utils::verifyNonce($nonce, $nonce_action)) {
throw new PageExpiredException($this->getRequest());
}
}
/**
* Return the best matching mime type for the request.
*
* @param string[] $compare
* @return string|null
*/
protected function getAccept(array $compare): ?string
{
$accepted = [];
foreach ($this->getRequest()->getHeader('Accept') as $accept) {
foreach (explode(',', $accept) as $item) {
if (!$item) {
continue;
}
$split = explode(';q=', $item);
$mime = array_shift($split);
$priority = array_shift($split) ?? 1.0;
$accepted[$mime] = $priority;
}
}
arsort($accepted);
// TODO: add support for image/* etc
$list = array_intersect($compare, array_keys($accepted));
if (!$list && (isset($accepted['*/*']) || isset($accepted['*']))) {
return reset($compare) ?: null;
}
return reset($list) ?: null;
}
/**
* @param string $template
* @return PageInterface
*/
protected function createPage(string $template): PageInterface
{
$page = new Page();
// Plugins may not have the correct Cache-Control header set, force no-store for the proxies.
$page->expires(0);
$filename = "plugin://admin/pages/admin/{$template}.md";
if (!file_exists($filename)) {
throw new \RuntimeException(sprintf('Creating admin page %s failed: not found', $template));
}
Admin::DEBUG && Admin::addDebugMessage("Admin page: {$template}");
$page->init(new \SplFileInfo($filename));
$page->slug($template);
return $page;
}
/**
* @param string|null $url
* @param int|null $code
* @return ResponseInterface
*/
protected function createRedirectResponse(string $url = null, int $code = null): ResponseInterface
{
$request = $this->getRequest();
if (null === $url || '' === $url) {
$url = (string)$request->getUri();
} elseif (mb_strpos($url, '/') === 0) {
$url = $this->getAbsoluteAdminUrl($url);
}
if (null === $code) {
if (in_array($request->getMethod(), ['GET', 'HEAD'])) {
$code = 302;
} else {
$code = 303;
}
}
return $this->traitCreateRedirectResponse($url, $code);
}
/**
* @param \Throwable $e
* @return array
*/
protected function getErrorJson(\Throwable $e): array
{
$json = $this->traitGetErrorJson($e);
$code = $e->getCode();
if ($code === 401) {
$json['redirect'] = $this->getAbsoluteAdminUrl('/');
}
return $json;
}
}

View File

@@ -0,0 +1,630 @@
<?php
/**
* @package Grav\Plugin\Admin
*
* @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Plugin\Admin\Controllers\Login;
use Grav\Common\Debugger;
use Grav\Common\Page\Pages;
use Grav\Common\Uri;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Framework\RequestHandler\Exception\PageExpiredException;
use Grav\Framework\RequestHandler\Exception\RequestException;
use Grav\Plugin\Admin\Admin;
use Grav\Plugin\Admin\Controllers\AdminController;
use Grav\Plugin\Email\Email;
use Grav\Plugin\Login\Login;
use Psr\Http\Message\ResponseInterface;
use RobThree\Auth\TwoFactorAuthException;
/**
* Class LoginController
* @package Grav\Plugin\Admin\Controllers\Login
*/
class LoginController extends AdminController
{
/** @var string */
protected $nonce_action = 'admin-login';
/** @var string */
protected $nonce_name = 'login-nonce';
/**
* @return ResponseInterface
*/
public function displayLogin(): ResponseInterface
{
$this->page = $this->createPage('login');
$user = $this->getUser();
if ($this->is2FA($user)) {
$this->form = $this->getForm('login-twofa', ['reset' => true]);
} else {
$this->form = $this->getForm('login', ['reset' => true]);
}
return $this->createDisplayResponse();
}
/**
* @return ResponseInterface
*/
public function displayForgot(): ResponseInterface
{
$this->page = $this->createPage('forgot');
$this->form = $this->getForm('admin-login-forgot', ['reset' => true]);
return $this->createDisplayResponse();
}
/**
* Handle the reset password action.
*
* @param string|null $username
* @param string|null $token
* @return ResponseInterface
*/
public function displayReset(string $username = null, string $token = null): ResponseInterface
{
if ('' === (string)$username || '' === (string)$token) {
$this->setMessage($this->translate('PLUGIN_ADMIN.RESET_INVALID_LINK'), 'error');
return $this->createRedirectResponse('/forgot');
}
$this->page = $this->createPage('reset');
$this->form = $this->getForm('admin-login-reset', ['reset' => true]);
$this->form->setData('username', $username);
$this->form->setData('token', $token);
$this->setMessage($this->translate('PLUGIN_ADMIN.RESET_NEW_PASSWORD'));
return $this->createDisplayResponse();
}
/**
* @return ResponseInterface
*/
public function displayRegister(): ResponseInterface
{
$route = $this->getRequest()->getAttribute('admin')['route'] ?? '';
if ('' !== $route) {
return $this->createRedirectResponse('/');
}
$this->page = $this->createPage('register');
$this->form = $this->getForm('admin-login-register');
return $this->createDisplayResponse();
}
/**
* @return ResponseInterface
*/
public function displayUnauthorized(): ResponseInterface
{
$uri = (string)$this->getRequest()->getUri();
$ext = pathinfo($uri, PATHINFO_EXTENSION);
$accept = $this->getAccept(['application/json', 'text/html']);
if ($ext === 'json' || $accept === 'application/json') {
return $this->createErrorResponse(new RequestException($this->getRequest(), $this->translate('PLUGIN_ADMIN.LOGGED_OUT'), 401));
}
$this->setMessage($this->translate('PLUGIN_ADMIN.LOGGED_OUT'), 'warning');
return $this->createRedirectResponse('/');
}
/**
* Handle login.
*
* @return ResponseInterface
*/
public function taskLogin(): ResponseInterface
{
$this->page = $this->createPage('login');
$this->form = $this->getActiveForm() ?? $this->getForm('login');
try {
$this->checkNonce();
} catch (PageExpiredException $e) {
$this->setMessage($e->getMessage(), 'error');
return $this->createDisplayResponse();
}
$post = $this->getPost();
$credentials = $post['data'] ?? [];
$login = $this->getLogin();
$config = $this->getConfig();
$userKey = (string)($credentials['username'] ?? '');
// Pseudonymization of the IP.
$ipKey = sha1(Uri::ip() . $config->get('security.salt'));
$rateLimiter = $login->getRateLimiter('login_attempts');
// Check if the current IP has been used in failed login attempts.
$attempts = count($rateLimiter->getAttempts($ipKey, 'ip'));
$rateLimiter->registerRateLimitedAction($ipKey, 'ip')->registerRateLimitedAction($userKey);
// Check rate limit for both IP and user, but allow each IP a single try even if user is already rate limited.
if ($rateLimiter->isRateLimited($ipKey, 'ip') || ($attempts && $rateLimiter->isRateLimited($userKey))) {
Admin::DEBUG && Admin::addDebugMessage('Admin login: rate limit, redirecting', $credentials);
$this->setMessage($this->translate('PLUGIN_LOGIN.TOO_MANY_LOGIN_ATTEMPTS', $rateLimiter->getInterval()), 'error');
$this->form->reset();
/** @var Pages $pages */
$pages = $this->grav['pages'];
// Redirect to the home page of the site.
return $this->createRedirectResponse($pages->homeUrl(null, true));
}
Admin::DEBUG && Admin::addDebugMessage('Admin login', $credentials);
// Fire Login process.
$event = $login->login(
$credentials,
['admin' => true, 'twofa' => $config->get('plugins.admin.twofa_enabled', false)],
['authorize' => 'admin.login', 'return_event' => true]
);
$user = $event->getUser();
Admin::DEBUG && Admin::addDebugMessage('Admin login: user', $user);
$redirect = (string)$this->getRequest()->getUri();
if ($user->authenticated) {
$rateLimiter->resetRateLimit($ipKey, 'ip')->resetRateLimit($userKey);
if ($user->authorized) {
$event->defMessage('PLUGIN_ADMIN.LOGIN_LOGGED_IN', 'info');
}
$event->defRedirect($redirect);
} elseif ($user->authorized) {
$event->defMessage('PLUGIN_LOGIN.ACCESS_DENIED', 'error');
} else {
$event->defMessage('PLUGIN_LOGIN.LOGIN_FAILED', 'error');
}
$event->defRedirect($redirect);
$message = $event->getMessage();
if ($message) {
$this->setMessage($this->translate($message), $event->getMessageType());
}
$this->form->reset();
return $this->createRedirectResponse($event->getRedirect());
}
/**
* Handle logout when user isn't fully logged in.
*
* @return ResponseInterface
*/
public function taskLogout(): ResponseInterface
{
try {
$this->checkNonce();
} catch (PageExpiredException $e) {
$this->setMessage($e->getMessage(), 'error');
return $this->createDisplayResponse();
}
$login = $this->getLogin();
$event = $login->logout(['admin' => true], ['return_event' => true]);
$event->defMessage('PLUGIN_ADMIN.LOGGED_OUT', 'info');
$message = $event->getMessage();
if ($message) {
$this->getSession()->setFlashCookieObject(Admin::TMP_COOKIE_NAME, ['message' => $this->translate($message), 'status' => $event->getMessageType()]);
}
return $this->createRedirectResponse('/');
}
/**
* Handle 2FA verification.
*
* @return ResponseInterface
*/
public function taskTwofa(): ResponseInterface
{
$user = $this->getUser();
if (!$this->is2FA($user)) {
Admin::DEBUG && Admin::addDebugMessage('Admin login: user is not logged in or does not have 2FA enabled', $user);
// Task is visible only for users who have enabled 2FA.
return $this->createRedirectResponse('/');
}
$this->page = $this->createPage('login');
$this->form = $this->getForm('admin-login-twofa');
try {
$this->checkNonce();
} catch (PageExpiredException $e) {
$this->setMessage($e->getMessage(), 'error');
return $this->createDisplayResponse();
}
$post = $this->getPost();
$data = $post['data'] ?? [];
$login = $this->getLogin();
try {
$twoFa = $login->twoFactorAuth();
} catch (TwoFactorAuthException $e) {
/** @var Debugger $debugger */
$debugger = $this->grav['debugger'];
$debugger->addException($e);
$twoFa = null;
}
$code = $data['2fa_code'] ?? null;
$secret = $user->twofa_secret ?? null;
$redirect = (string)$this->getRequest()->getUri();
if (null === $twoFa || !$user->authenticated || !$code || !$secret || !$twoFa->verifyCode($secret, $code)) {
Admin::DEBUG && Admin::addDebugMessage('Admin login: 2FA check failed, log out!');
// Failed 2FA auth, logout and redirect to the current page.
$login->logout(['admin' => true]);
$this->grav['session']->setFlashCookieObject(Admin::TMP_COOKIE_NAME, ['message' => $this->translate('PLUGIN_ADMIN.2FA_FAILED'), 'status' => 'error']);
$this->form->reset();
return $this->createRedirectResponse($redirect);
}
// Successful 2FA, authorize user and redirect.
$user->authorized = true;
Admin::DEBUG && Admin::addDebugMessage('Admin login: 2FA check succeeded, authorize user and redirect');
$this->setMessage($this->translate('PLUGIN_ADMIN.LOGIN_LOGGED_IN'));
$this->form->reset();
return $this->createRedirectResponse($redirect);
}
/**
* Handle the reset password action.
*
* @param string|null $username
* @param string|null $token
* @return ResponseInterface
*/
public function taskReset(string $username = null, string $token = null): ResponseInterface
{
$this->page = $this->createPage('reset');
$this->form = $this->getForm('admin-login-reset');
try {
$this->checkNonce();
} catch (PageExpiredException $e) {
$this->setMessage($e->getMessage(), 'error');
return $this->createDisplayResponse();
}
$post = $this->getPost();
$data = $post['data'] ?? [];
$users = $this->getAccounts();
$username = $username ?? $data['username'] ?? null;
$token = $token ?? $data['token'] ?? null;
$user = $username ? $users->load($username) : null;
$password = $data['password'];
if ($user && $user->exists() && !empty($user->get('reset'))) {
[$good_token, $expire] = explode('::', $user->get('reset'));
if ($good_token === $token) {
if (time() > $expire) {
$this->setMessage($this->translate('PLUGIN_ADMIN.RESET_LINK_EXPIRED'), 'error');
$this->form->reset();
return $this->createRedirectResponse('/forgot');
}
// Set new password.
$login = $this->getLogin();
try {
$login->validateField('password1', $password);
} catch (\RuntimeException $e) {
$this->setMessage($this->translate($e->getMessage()), 'error');
return $this->createRedirectResponse("/reset/u/{$username}/{$token}");
}
$user->undef('hashed_password');
$user->undef('reset');
$user->update(['password' => $password]);
$user->save();
$this->form->reset();
$this->setMessage($this->translate('PLUGIN_ADMIN.RESET_PASSWORD_RESET'));
return $this->createRedirectResponse('/login');
}
Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed to reset password: Token %s is not good', $token));
} else {
Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed to reset password: User %s does not exist or has not requested reset', $username));
}
$this->setMessage($this->translate('PLUGIN_ADMIN.RESET_INVALID_LINK'), 'error');
$this->form->reset();
return $this->createRedirectResponse('/forgot');
}
/**
* Handle the email password recovery procedure.
*
* Sends email to the user.
*
* @return ResponseInterface
*/
public function taskForgot(): ResponseInterface
{
$this->page = $this->createPage('forgot');
$this->form = $this->getForm('admin-login-forgot');
try {
$this->checkNonce();
} catch (PageExpiredException $e) {
$this->setMessage($e->getMessage(), 'error');
return $this->createDisplayResponse();
}
$post = $this->getPost();
$data = $post['data'] ?? [];
$login = $this->getLogin();
$users = $this->getAccounts();
$email = $this->getEmail();
$current = (string)$this->getRequest()->getUri();
$search = isset($data['username']) ? strip_tags($data['username']) : '';
$user = !empty($search) ? $users->load($search) : null;
$username = $user->username ?? null;
$to = $user->email ?? null;
// Only send email to users which are enabled and have an email address.
if (null === $user || $user->state !== 'enabled' || !$to) {
Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed sending email: %s <%s> was not found or is blocked', $search, $to ?? 'N/A'));
$this->form->reset();
$this->setMessage($this->translate('PLUGIN_ADMIN.FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL'));
return $this->createRedirectResponse($current);
}
$config = $this->getConfig();
// Check rate limit for the user.
$rateLimiter = $login->getRateLimiter('pw_resets');
$rateLimiter->registerRateLimitedAction($username);
if ($rateLimiter->isRateLimited($username)) {
Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed sending email: user %s <%s> is rate limited', $search, $to));
$this->form->reset();
$interval = $config->get('plugins.login.max_pw_resets_interval', 2);
$this->setMessage($this->translate('PLUGIN_LOGIN.FORGOT_CANNOT_RESET_IT_IS_BLOCKED', $to, $interval), 'error');
return $this->createRedirectResponse($current);
}
$token = md5(uniqid(mt_rand(), true));
$expire = time() + 3600; // 1 hour
$user->set('reset', $token . '::' . $expire);
$user->save();
$from = $config->get('plugins.email.from');
if (empty($from)) {
Admin::DEBUG && Admin::addDebugMessage('Failed sending email: from address is not configured in email plugin');
$this->form->reset();
$this->setMessage($this->translate('PLUGIN_ADMIN.FORGOT_EMAIL_NOT_CONFIGURED'), 'error');
return $this->createRedirectResponse($current);
}
// Do not trust username from the request.
$fullname = $user->fullname ?: $username;
$author = $config->get('site.author.name', '');
$sitename = $config->get('site.title', 'Website');
$reset_link = $this->getAbsoluteAdminUrl("/reset/u/{$username}/{$token}");
// For testing only!
//Admin::DEBUG && Admin::addDebugMessage(sprintf('Reset link: %s', $reset_link));
$subject = $this->translate('PLUGIN_ADMIN.FORGOT_EMAIL_SUBJECT', $sitename);
$content = $this->translate('PLUGIN_ADMIN.FORGOT_EMAIL_BODY', $fullname, $reset_link, $author, $sitename);
$this->grav['twig']->init();
$body = $this->grav['twig']->processTemplate('email/base.html.twig', ['content' => $content]);
try {
$message = $email->message($subject, $body, 'text/html')->setFrom($from)->setTo($to);
$sent = $email->send($message);
if ($sent < 1) {
throw new \RuntimeException('Sending email failed');
}
// For testing only!
//Admin::DEBUG && Admin::addDebugMessage(sprintf('Email sent to %s', $to), $body);
$this->setMessage($this->translate('PLUGIN_ADMIN.FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL'));
} catch (\RuntimeException|\Swift_SwiftException $e) {
$rateLimiter->resetRateLimit($username);
/** @var Debugger $debugger */
$debugger = $this->grav['debugger'];
$debugger->addException($e);
$this->form->reset();
$this->setMessage($this->translate('PLUGIN_ADMIN.FORGOT_FAILED_TO_EMAIL'), 'error');
return $this->createRedirectResponse('/forgot');
}
$this->form->reset();
return $this->createRedirectResponse('/login');
}
/**
* @return ResponseInterface
*/
public function taskRegister(): ResponseInterface
{
$this->page = $this->createPage('register');
$this->form = $form = $this->getForm('admin-login-register');
try {
$this->checkNonce();
} catch (PageExpiredException $e) {
$this->setMessage($e->getMessage(), 'error');
return $this->createDisplayResponse();
}
// Note: Calls $this->doRegistration() to perform the user registration.
$form->handleRequest($this->getRequest());
$error = $form->getError();
$errors = $form->getErrors();
if ($error || $errors) {
foreach ($errors as $field => $list) {
foreach ((array)$list as $message) {
if ($message !== $error) {
$this->setMessage($message, 'error');
}
}
}
return $this->createDisplayResponse();
}
$this->setMessage($this->translate('PLUGIN_ADMIN.LOGIN_LOGGED_IN'));
return $this->createRedirectResponse('/');
}
/**
* @param UserInterface $user
* @return bool
*/
protected function is2FA(UserInterface $user): bool
{
return $user && $user->authenticated && !$user->authorized && $user->get('twofa_enabled');
}
/**
* @param string $name
* @return callable
*/
protected function getFormSubmitMethod(string $name): callable
{
switch ($name) {
case 'login':
case 'login-twofa':
case 'admin-login-forgot':
case 'admin-login-reset':
return static function(array $data, array $files) {};
case 'admin-login-register':
return function(array $data, array $files) {
$this->doRegistration($data, $files);
};
}
throw new \RuntimeException('Unknown form');
}
/**
* Called by registration form when calling handleRequest().
*
* @param array $data
* @param array $files
*/
private function doRegistration(array $data, array $files): void
{
if (Admin::doAnyUsersExist()) {
throw new \RuntimeException('A user account already exists, please create an admin account manually.', 400);
}
$login = $this->getLogin();
if (!$login) {
throw new \RuntimeException($this->grav['language']->translate('PLUGIN_LOGIN.PLUGIN_LOGIN_DISABLED', 500));
}
$data['title'] = $data['title'] ?? 'Administrator';
// Do not allow form to set the following fields (make super user):
$data['state'] = 'enabled';
$data['access'] = ['admin' => ['login' => true, 'super' => true], 'site' => ['login' => true]];
unset($data['groups']);
// Create user.
$user = $login->register($data, $files);
// Log in the new super admin user.
unset($this->grav['user']);
$this->grav['user'] = $user;
$this->grav['session']->user = $user;
$user->authenticated = true;
$user->authorized = $user->authorize('admin.login') ?? false;
}
/**
* @return Login
*/
private function getLogin(): Login
{
return $this->grav['login'];
}
/**
* @return Email
*/
private function getEmail(): Email
{
return $this->grav['Email'];
}
/**
* @return UserCollectionInterface
*/
private function getAccounts(): UserCollectionInterface
{
return $this->grav['accounts'];
}
}

View File

@@ -2,8 +2,10 @@
namespace Grav\Plugin\Admin; namespace Grav\Plugin\Admin;
use Grav\Common\Grav;
use Grav\Common\Processors\ProcessorBase; use Grav\Common\Processors\ProcessorBase;
use Grav\Framework\Route\Route; use Grav\Framework\Route\Route;
use Grav\Plugin\Admin\Routers\LoginRouter;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
@@ -13,6 +15,16 @@ class Router extends ProcessorBase
public $id = 'admin_router'; public $id = 'admin_router';
public $title = 'Admin Panel'; public $title = 'Admin Panel';
/** @var Admin */
protected $admin;
public function __construct(Grav $container, Admin $admin)
{
parent::__construct($container);
$this->admin = $admin;
}
/** /**
* Handle routing to the dashboard, group and build objects. * Handle routing to the dashboard, group and build objects.
* *
@@ -30,26 +42,26 @@ class Router extends ProcessorBase
$route = $context['route']; $route = $context['route'];
$normalized = mb_strtolower(trim($route->getRoute(), '/')); $normalized = mb_strtolower(trim($route->getRoute(), '/'));
$parts = explode('/', $normalized); $parts = explode('/', $normalized);
array_shift($parts); array_shift($parts); // Admin path
$key = array_shift($parts); $routeStr = implode('/', $parts);
$view = array_shift($parts);
$path = implode('/', $parts); $path = implode('/', $parts);
$task = $this->container['task'] ?? null;
$action = $this->container['action'] ?? null;
$request = $request->withAttribute('admin', ['path' => $path, 'parts' => $parts]); $params = ['view' => $view, 'route' => $routeStr, 'path' => $path, 'parts' => $parts, 'task' => $task, 'action' => $action];
$request = $request->withAttribute('admin', $params);
$response = null; // Run login controller if user isn't fully logged in or asks to logout.
/* $user = $this->admin->user;
if ($key === '__TODO__') { if (!$user->authorized || !$user->authorize('admin.login')) {
$params = (new LoginRouter())->matchServerRequest($request);
$controller = new TodoController(); $request = $request->withAttribute('admin', $params + $request->getAttribute('admin'));
$response = $controller->handle($request);
} }
*/
if (!$response) { $this->admin->request = $request;
// Fallback to the old admin behavior.
$response = $handler->handle($request); $response = $handler->handle($request);
}
$this->stopTimer(); $this->stopTimer();

View File

@@ -0,0 +1,93 @@
<?php
/**
* @package Grav\Plugin\Admin
*
* @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Plugin\Admin\Routers;
use Grav\Plugin\Admin\Admin;
use Grav\Plugin\Admin\Controllers\Login\LoginController;
use Psr\Http\Message\ServerRequestInterface;
class LoginRouter
{
/** @var string[] */
private $taskTemplates = [
'logout' => 'login',
'twofa' => 'login',
'forgot' => 'forgot',
'reset' => 'reset'
];
/**
* @param ServerRequestInterface $request
* @return array
*/
public function matchServerRequest(ServerRequestInterface $request): array
{
$adminInfo = $request->getAttribute('admin');
$task = $adminInfo['task'];
$class = LoginController::class;
// Special controller for the new sites.
if (!Admin::doAnyUsersExist()) {
$method = $task === 'register' ? 'taskRegister' : 'displayRegister';
return [
'controller' => [
'class' => $class,
'method' => $method,
'params' => []
],
'template' => 'register',
];
}
$httpMethod = $request->getMethod();
$template = $this->taskTemplates[$task] ?? $adminInfo['view'];
$params = [];
switch ($template) {
case 'forgot':
break;
case 'reset':
$path = $adminInfo['path'];
if (str_starts_with($path, 'u/')) {
// Path is 'u/username/token'
$parts = explode('/', $path, 4);
$user = $parts[1] ?? null;
$token = $parts[2] ?? null;
} else {
// Old path used to be 'task:reset/user:username/token:token'
if ($httpMethod === 'GET' || $httpMethod === 'HEAD') {
$task = null;
}
$route = $request->getAttribute('route');
$user = $route->getGravParam('user');
$token = $route->getGravParam('token');
}
$params = [$user, $token];
break;
default:
$template = 'login';
}
$method = ($task ? 'task' : 'display') . ucfirst($task ?? $template);
if (!method_exists($class, $method)) {
$method = 'displayUnauthorized';
}
return [
'controller' => [
'class' => $class,
'method' => $method,
'params' => $params
],
'template' => $template,
];
}
}

View File

@@ -1,10 +1,16 @@
--- ---
title: Forgot password title: Forgot password
expires: 0 expires: 0
access:
admin.login: false
forms:
admin-login-forgot:
type: admin
method: post
form:
fields: fields:
- name: username username:
type: text type: text
placeholder: PLUGIN_ADMIN.USERNAME placeholder: PLUGIN_ADMIN.USERNAME
autofocus: true autofocus: true

View File

@@ -1,10 +1,12 @@
--- ---
title: Admin Login title: Admin Login
expires: 0 expires: 0
access:
admin.login: false
forms: forms:
login: login:
action: type: admin
method: post method: post
fields: fields:
@@ -22,7 +24,7 @@ forms:
required: true required: true
login-twofa: login-twofa:
action: type: admin
method: post method: post
fields: fields:

View File

@@ -1,9 +1,16 @@
--- ---
title: Register Admin User
expires: 0 expires: 0
access:
admin.login: false
forms:
admin-login-register:
type: admin
method: post
form:
fields: fields:
- name: username username:
type: text type: text
label: PLUGIN_ADMIN.USERNAME label: PLUGIN_ADMIN.USERNAME
autofocus: true autofocus: true
@@ -13,7 +20,7 @@ form:
message: PLUGIN_LOGIN.USERNAME_NOT_VALID message: PLUGIN_LOGIN.USERNAME_NOT_VALID
config-pattern@: system.username_regex config-pattern@: system.username_regex
- name: email email:
type: email type: email
label: PLUGIN_ADMIN.EMAIL label: PLUGIN_ADMIN.EMAIL
placeholder: "valid email address" placeholder: "valid email address"
@@ -22,7 +29,7 @@ form:
message: PLUGIN_ADMIN.EMAIL_VALIDATION_MESSAGE message: PLUGIN_ADMIN.EMAIL_VALIDATION_MESSAGE
required: true required: true
- name: password1 password1:
type: password type: password
label: PLUGIN_ADMIN.PASSWORD label: PLUGIN_ADMIN.PASSWORD
placeholder: PLUGIN_ADMIN.PWD_PLACEHOLDER placeholder: PLUGIN_ADMIN.PWD_PLACEHOLDER
@@ -31,7 +38,7 @@ form:
message: PLUGIN_ADMIN.PASSWORD_VALIDATION_MESSAGE message: PLUGIN_ADMIN.PASSWORD_VALIDATION_MESSAGE
config-pattern@: system.pwd_regex config-pattern@: system.pwd_regex
- name: password2 password2:
type: password type: password
label: PLUGIN_ADMIN.PASSWORD_CONFIRM label: PLUGIN_ADMIN.PASSWORD_CONFIRM
placeholder: PLUGIN_ADMIN.PWD_PLACEHOLDER placeholder: PLUGIN_ADMIN.PWD_PLACEHOLDER
@@ -40,19 +47,17 @@ form:
message: PLUGIN_ADMIN.PASSWORD_VALIDATION_MESSAGE message: PLUGIN_ADMIN.PASSWORD_VALIDATION_MESSAGE
config-pattern@: system.pwd_regex config-pattern@: system.pwd_regex
- name: fullname fullname:
type: text type: text
placeholder: "e.g. 'Joe Schmoe'" placeholder: "e.g. 'Joe Schmoe'"
label: PLUGIN_ADMIN.FULL_NAME label: PLUGIN_ADMIN.FULL_NAME
validate:
required: true
- name: title title:
type: text type: text
placeholder: "e.g. 'Administrator'" placeholder: "e.g. 'Administrator'"
label: PLUGIN_ADMIN.TITLE label: PLUGIN_ADMIN.TITLE
process:
register_admin_user: true
--- ---
The Admin plugin has been installed, but no **admin accounts** could be found. Please create an admin account to ensure your Grav install is secure... The Admin plugin has been installed, but no **admin accounts** could be found. Please create an admin account to ensure your Grav install is secure...

View File

@@ -1,17 +1,24 @@
--- ---
title: Reset password title: Reset password
expires: 0 expires: 0
access:
admin.login: false
forms:
admin-login-reset:
type: admin
method: post
form:
fields: fields:
- name: username username:
type: text type: text
placeholder: PLUGIN_ADMIN.USERNAME placeholder: PLUGIN_ADMIN.USERNAME
readonly: true readonly: true
- name: password password:
type: password type: password
placeholder: PLUGIN_ADMIN.PASSWORD placeholder: PLUGIN_ADMIN.PASSWORD
autofocus: true autofocus: true
- name: token token:
type: hidden type: hidden
--- ---

View File

@@ -1,8 +1,10 @@
{% embed 'partials/login.html.twig' with {title:'Grav Forgot Password'} %} {% embed 'partials/login.html.twig' with {title:'Grav Forgot Password'} %}
{% block form %} {% block form %}
{% for field in form.fields %} {% for field_name,field in form.fields %}
{% if field.type %} {% if field.type %}
{% set field = field|merge({ name: field.name ?? field_name }) %}
{% set value = form.value(field.name) %}
<div> <div>
{% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %} {% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %}
</div> </div>

View File

@@ -12,9 +12,10 @@
{% block form %} {% block form %}
{% set form = forms['login'] %} {% set form = forms['login'] %}
{% for field in form.fields %} {% for field_name,field in form.fields %}
{% set value = field.name == 'username' ? username : '' %}
{% if field.type %} {% if field.type %}
{% set field = field|merge({ name: field.name ?? field_name }) %}
{% set value = form.value(field.name) %}
<div> <div>
{% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %} {% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %}
</div> </div>

View File

@@ -7,8 +7,10 @@
{% set form = forms['login-twofa'] %} {% set form = forms['login-twofa'] %}
{% for field in form.fields %} {% for field_name, field in form.fields %}
{% if field.type %} {% if field.type %}
{% set field = field|merge({ name: field.name ?? field_name }) %}
{% set value = form.value(field.name) %}
<div> <div>
{% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %} {% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %}
</div> </div>

View File

@@ -1,5 +1,6 @@
{% extends 'partials/base.html.twig' %} {% extends 'partials/base.html.twig' %}
{% set scope = scope ?: 'data.' %} {% set scope = form.scope %}
{% block messages %}{% endblock %} {% block messages %}{% endblock %}
{% block body %} {% block body %}
@@ -14,13 +15,10 @@
{% block integration %}{% endblock %} {% block integration %}{% endblock %}
{% set redirect = redirect ?: uri.path ~ uri.params ~ (uri.query ? '?' ~ uri.query : '') %} <form method="post" action="">
<form method="post" action="{{ admin_route('/')|trim('/', 'right') }}">
<div class="padding"> <div class="padding">
{% block form %}{% endblock %} {% block form %}{% endblock %}
<input type="hidden" name="redirect" value="{{ redirect }}" /> {{ nonce_field(form.getNonceAction(), form.getNonceName())|raw }}
{{ nonce_field('admin-form', 'admin-nonce')|raw }}
</div> </div>
</form> </form>

View File

@@ -1,5 +1,5 @@
{% extends 'partials/base.html.twig' %} {% extends 'partials/base.html.twig' %}
{% set scope = scope ?: 'data.' %} {% set scope = form.scope %}
{% block body %} {% block body %}
<body id="admin-login-wrapper"> <body id="admin-login-wrapper">
@@ -10,11 +10,12 @@
{% block instructions %}{% endblock %} {% block instructions %}{% endblock %}
<form method="post" action="{{ admin_route('/') }}"> <form method="post" action="">
<div class="padding"> <div class="padding">
{% block form %}{% endblock %} {% block form %}{% endblock %}
{% include "forms/fields/formname/formname.html.twig" %}
{{ nonce_field('form', 'form-nonce')|raw }} {% include 'forms/fields/uniqueid/uniqueid.html.twig' %}
{{ nonce_field(form.getNonceAction() ?? 'form', form.getNonceName() ?? 'form-nonce')|raw }}
</div> </div>
</form> </form>
</section> </section>

View File

@@ -7,8 +7,9 @@
{% endblock %} {% endblock %}
{% block form %} {% block form %}
{% for field in form.fields %} {% for field_name,field in form.fields %}
{% if field.type %} {% if field.type %}
{% set field = field|merge({ name: field.name ?? field_name }) %}
{% set value = form.value(field.name) %} {% set value = form.value(field.name) %}
<div class="wrapper-{{ field.name }}"> <div class="wrapper-{{ field.name }}">
{% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %} {% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %}
@@ -19,7 +20,7 @@
<div class="form-actions primary-accent"> <div class="form-actions primary-accent">
<button type="reset" class="button secondary"><i class="fa fa-exclamation-circle"></i> {{ 'PLUGIN_ADMIN.LOGIN_BTN_CLEAR'|tu }}</button> <button type="reset" class="button secondary"><i class="fa fa-exclamation-circle"></i> {{ 'PLUGIN_ADMIN.LOGIN_BTN_CLEAR'|tu }}</button>
<button type="submit" class="button primary"><i class="fa fa-sign-in"></i> {{ 'PLUGIN_ADMIN.LOGIN_BTN_CREATE_USER'|tu }}</button> <button type="submit" class="button primary" name="task" value="register"><i class="fa fa-sign-in"></i> {{ 'PLUGIN_ADMIN.LOGIN_BTN_CREATE_USER'|tu }}</button>
</div> </div>

View File

@@ -1,10 +1,10 @@
{% embed 'partials/login.html.twig' with {title:'Grav Reset Password'} %} {% embed 'partials/login.html.twig' with {title:'Grav Reset Password'} %}
{% block form %} {% block form %}
{% for field in form.fields %} {% for field_name,field in form.fields %}
{% set value = attribute(admin.forgot, field.name) is defined ? attribute(admin.forgot, field.name) : null %}
{% if field.type %} {% if field.type %}
{% set field = field|merge({ name: field.name ?? field_name }) %}
{% set value = form.value(field.name) %}
<div> <div>
{% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %} {% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %}
</div> </div>