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
## 03/19/2021
1. [](#new)
* Requires **Grav 1.7.10**
1. [](#improved)
* Include alt text and title for images added to the editor [#2098](https://github.com/getgrav/grav-plugin-admin/issues/2098)
1. [](#bugfix)
* 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 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
## 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\Pages;
use Grav\Common\Plugin;
use Grav\Common\Plugins;
use Grav\Common\Processors\Events\RequestHandlerEvent;
use Grav\Common\Session;
use Grav\Common\Twig\Twig;
use Grav\Common\Uri;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\Utils;
use Grav\Common\Yaml;
use Grav\Events\PermissionsRegisterEvent;
@@ -25,17 +27,26 @@ use Grav\Framework\Acl\PermissionsReader;
use Grav\Framework\Psr7\Response;
use Grav\Framework\Session\Exceptions\SessionException;
use Grav\Plugin\Admin\Admin;
use Grav\Plugin\Admin\AdminFormFactory;
use Grav\Plugin\Admin\Popularity;
use Grav\Plugin\Admin\Router;
use Grav\Plugin\Admin\Themes;
use Grav\Plugin\Admin\AdminController;
use Grav\Plugin\Admin\Twig\AdminTwigExtension;
use Grav\Plugin\Admin\WhiteLabel;
use Grav\Plugin\FlexObjects\FlexFormFactory;
use Grav\Plugin\Form\Form;
use Grav\Plugin\Form\Forms;
use Grav\Plugin\Login\Login;
use Pimple\Container;
use Psr\Http\Message\ResponseInterface;
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
/**
* Class AdminPlugin
* @package Grav\Plugin\Admin
*/
class AdminPlugin extends Plugin
{
public $features = [
@@ -44,34 +55,24 @@ class AdminPlugin extends Plugin
/** @var bool */
protected $active = false;
/** @var string */
protected $template;
/** @var string */
protected $theme;
/** @var string */
protected $route;
/** @var string */
protected $admin_route;
/** @var Uri */
protected $uri;
/** @var Admin */
protected $admin;
/** @var Session */
protected $session;
/** @var Popularity */
protected $popularity;
/** @var string */
protected $base;
/** @var string */
protected $version;
@@ -89,12 +90,9 @@ class AdminPlugin extends Plugin
'onRequestHandlerInit' => [
['onRequestHandlerInit', 100000]
],
'onFormRegisterTypes' => ['onFormRegisterTypes', 0],
'onPageInitialized' => ['onPageInitialized', 0],
'onFormProcessed' => ['onFormProcessed', 0],
'onShutdown' => ['onShutdown', 1000],
'onAdminDashboard' => ['onAdminDashboard', 0],
'onAdminTools' => ['onAdminTools', 0],
'onAdminSave' => ['onAdminSave', 0],
PermissionsRegisterEvent::class => ['onRegisterPermissions', 1000],
];
}
@@ -177,65 +175,54 @@ class AdminPlugin extends Plugin
*
* @return ClassLoader
*/
public function autoload()
public function autoload(): ClassLoader
{
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]
*
* If the admin path matches, initialize the Login plugin configuration and set the admin
* as active.
*
* @return void
*/
public function setup()
{
// Only enable admin if it has a route.
$route = $this->config->get('plugins.admin.route');
if (!$route) {
return;
}
$this->base = '/' . trim($route, '/');
$this->admin_route = rtrim($this->grav['pages']->base(), '/') . $this->base;
/** @var Uri 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
if (!$users_exist) {
if (!$this->isAdminPath()) {
$inAdmin = $this->isAdminPath();
// If no users found, go to register.
if (!$inAdmin && !Admin::doAnyUsersExist()) {
$this->grav->redirect($this->admin_route);
}
$this->template = 'register';
}
// Only activate admin if we're inside the admin path.
if ($this->isAdminPath()) {
$pages = $this->grav['pages'];
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');
}
// Only setup admin if we're inside the admin path.
if ($inAdmin) {
$this->setupAdmin();
}
}
@@ -243,78 +230,68 @@ class AdminPlugin extends Plugin
* [onPluginsInitialized:1001]
*
* If the admin plugin is set as active, initialize the admin
*
* @return void
*/
public function onPluginsInitialized()
{
// Only activate admin if we're inside the admin path.
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();
// 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.
$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
// Always initialize popularity.
$this->popularity = new Popularity();
// Fire even to register permissions from other plugins
$this->grav->fireEvent('onAdminRegisterPermissions', new Event(['admin' => $this->admin]));
}
/**
* [onRequestHandlerInit:100000]
*
* @param RequestHandlerEvent $event
* @return void
*/
public function onRequestHandlerInit(RequestHandlerEvent $event)
{
// Store this version.
$this->version = $this->getBlueprint()->get('version');
$this->grav['debugger']->addMessage('Admin v' . $this->version);
$route = $event->getRoute();
$base = $route->getRoute(0, 1);
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
*
* @param Event $event
* @return void
*/
public function onAdminSave(Event $event)
{
$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);
if (!$status) {
$this->grav['messages']->add($msg, 'error');
@@ -322,112 +299,27 @@ class AdminPlugin extends Plugin
}
}
/**
* [onPageInitialized:0]
*
* @return void
*/
public function onPageInitialized()
{
$page = $this->grav['page'];
$template = $this->grav['uri']->param('tmpl');
$template = $this->uri->param('tmpl');
if ($template) {
/** @var PageInterface $page */
$page = $this->grav['page'];
$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]
*
* Handles the shutdown
*
* @return void
*/
public function onShutdown()
{
@@ -436,16 +328,16 @@ class AdminPlugin extends Plugin
if ($this->admin->shouldLoadAdditionalFilesInBackground()) {
$this->admin->loadAdditionalFilesInBackground();
}
} else {
} elseif ($this->popularity && $this->config->get('plugins.admin.popularity.enabled')) {
//if popularity is enabled, track non-admin hits
if ($this->popularity && $this->config->get('plugins.admin.popularity.enabled')) {
$this->popularity->trackHit();
}
}
}
/**
* [onAdminDashboard:0]
*
* @return void
*/
public function onAdminDashboard()
{
@@ -478,7 +370,7 @@ class AdminPlugin extends Plugin
*
* Provide the tools for the Tools page, currently only direct install
*
* @return Event
* @return void
*/
public function onAdminTools(Event $event)
{
@@ -489,12 +381,12 @@ class AdminPlugin extends Plugin
'reports' => [['admin.super'], 'PLUGIN_ADMIN.REPORTS'],
'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.
*
* @return void
*/
public function onPagesInitialized()
{
@@ -512,22 +404,60 @@ class AdminPlugin extends Plugin
$this->session = $this->grav['session'];
// 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;
} elseif ($this->uri->param('mode') === 'normal') {
} elseif ($mode === 'normal') {
$this->session->expert = false;
} else {
// set the default if not set before
$this->session->expert = $this->session->expert ?? false;
}
// Make local copy of POST.
$post = $this->grav['uri']->post();
// make sure page is not frozen!
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.
$this->admin->task = $task = $this->grav['task'] ?? $this->grav['action'];
if ($task) {
Admin::DEBUG && Admin::addDebugMessage("Admin task: {$task}");
// Make local copy of POST.
$post = $this->grav['uri']->post();
$this->initializeController($task, $post);
} elseif ($this->template === 'logs' && $this->route) {
// Display RAW error message.
@@ -535,24 +465,25 @@ class AdminPlugin extends Plugin
$this->grav->close($response);
}
$self = $this;
// make sure page is not frozen!
unset($this->grav['page']);
}
// 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();
// Plugins may not have the correct Cache-Control header set, force no-store for the proxies.
$page->expires(0);
if ($this->grav['user']->authorize('admin.login')) {
if ($user->authorize('admin.login')) {
$event = new Event(['page' => $page]);
$event = $this->grav->fireEvent('onAdminPage', $event);
$page = $event['page'];
/** @var PageInterface $page */
$page = $event['page'];
if ($page->slug()) {
Admin::DEBUG && Admin::addDebugMessage('Admin page: from event');
return $page;
@@ -560,31 +491,32 @@ class AdminPlugin extends Plugin
}
// Look in the pages provided by the Admin plugin itself
if (file_exists(__DIR__ . "/pages/admin/{$self->template}.md")) {
Admin::DEBUG && Admin::addDebugMessage("Admin page: {$self->template}");
if (file_exists(__DIR__ . "/pages/admin/{$this->template}.md")) {
Admin::DEBUG && Admin::addDebugMessage("Admin page: {$this->template}");
$page->init(new \SplFileInfo(__DIR__ . "/pages/admin/{$self->template}.md"));
$page->slug(basename($self->template));
$page->init(new \SplFileInfo(__DIR__ . "/pages/admin/{$this->template}.md"));
$page->slug(basename($this->template));
return $page;
}
// If not provided by Admin, lookup pages added by other plugins
$plugins = $this->grav['plugins'];
/** @var UniformResourceLocator $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) {
if ($this->config->get("plugins.{$plugin->name}.enabled") !== true) {
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) {
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->slug(basename($self->template));
$page->slug(basename($this->template));
return $page;
}
@@ -592,9 +524,10 @@ class AdminPlugin extends Plugin
return null;
};
}
if (empty($this->grav['page'])) {
if ($this->grav['user']->authenticated) {
if ($user->authenticated) {
Admin::DEBUG && Admin::addDebugMessage('Admin page: fire onPageNotFound event');
$event = new Event(['page' => null]);
$event->page = null;
@@ -631,6 +564,8 @@ class AdminPlugin extends Plugin
/**
* Handles initializing the assets
*
* @return void
*/
public function onAssetsInitialized()
{
@@ -650,6 +585,8 @@ class AdminPlugin extends Plugin
/**
* Add twig paths to plugin templates.
*
* @return void
*/
public function onTwigTemplatePaths()
{
@@ -663,10 +600,14 @@ class AdminPlugin extends Plugin
/**
* Set all twig variables for generating output.
*
* @return void
*/
public function onTwigSiteVariables()
{
/** @var Twig $twig */
$twig = $this->grav['twig'];
/** @var PageInterface $page */
$page = $this->grav['page'];
$twig->twig_vars['location'] = $this->template;
@@ -739,7 +680,9 @@ class AdminPlugin extends Plugin
// preserve form validation
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);
} elseif (isset($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()
{
$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) {
$this->grav['twig']->addPath($images_path, 'admin-images');
$twig->addPath($images_path, 'admin-images');
}
}
/**
* Add the Admin Twig Extensions
*
* @return void
*/
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)
{
// 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
*
* @param Event $e
* @return void
*/
public function onAdminData(Event $e)
{
@@ -826,6 +788,9 @@ class AdminPlugin extends Plugin
}
}
/**
* @return void
*/
public function onOutputGenerated()
{
// 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)
*
* @param PermissionsRegisterEvent $event
* @return void
*/
public function onRegisterPermissions(PermissionsRegisterEvent $event): void
{
@@ -857,10 +823,16 @@ class AdminPlugin extends Plugin
$permissions->addActions($actions);
}
/**
* @return void
*/
public function onAdminMenu()
{
/** @var Twig $twig */
$twig = $this->grav['twig'];
// Dashboard
$this->grav['twig']->plugins_hooked_nav['PLUGIN_ADMIN.DASHBOARD'] = [
$twig->plugins_hooked_nav['PLUGIN_ADMIN.DASHBOARD'] = [
'route' => 'dashboard',
'icon' => 'fa-th',
'authorize' => ['admin.login', 'admin.super'],
@@ -868,7 +840,7 @@ class AdminPlugin extends Plugin
];
// Configuration
$this->grav['twig']->plugins_hooked_nav['PLUGIN_ADMIN.CONFIGURATION'] = [
$twig->plugins_hooked_nav['PLUGIN_ADMIN.CONFIGURATION'] = [
'route' => 'config',
'icon' => 'fa-wrench',
'authorize' => [
@@ -883,7 +855,7 @@ class AdminPlugin extends Plugin
// Pages
$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',
'icon' => 'fa-file-text-o',
'authorize' => ['admin.pages', 'admin.super'],
@@ -893,7 +865,7 @@ class AdminPlugin extends Plugin
// 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',
'icon' => 'fa-plug',
'authorize' => ['admin.plugins', 'admin.super'],
@@ -903,7 +875,7 @@ class AdminPlugin extends Plugin
// 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',
'icon' => 'fa-tint',
'authorize' => ['admin.themes', 'admin.super'],
@@ -912,7 +884,7 @@ class AdminPlugin extends Plugin
];
// Tools
$this->grav['twig']->plugins_hooked_nav['PLUGIN_ADMIN.TOOLS'] = [
$twig->plugins_hooked_nav['PLUGIN_ADMIN.TOOLS'] = [
'route' => 'tools',
'icon' => 'fa-briefcase',
'authorize' => $this->admin::toolsPermissions(),
@@ -945,8 +917,8 @@ class AdminPlugin extends Plugin
$types = Pages::types();
// First filter by configuration
$hideTypes = Grav::instance()['config']->get('plugins.admin.hide_page_types', []);
foreach ((array) $hideTypes as $hide) {
$hideTypes = (array)Grav::instance()['config']->get('plugins.admin.hide_page_types');
foreach ($hideTypes as $hide) {
if (isset($types[$hide])) {
unset($types[$hide]);
} else {
@@ -979,8 +951,8 @@ class AdminPlugin extends Plugin
$types = Pages::modularTypes();
// First filter by configuration
$hideTypes = (array) Grav::instance()['config']->get('plugins.admin.hide_modular_page_types', []);
foreach ((array) $hideTypes as $hide) {
$hideTypes = (array)Grav::instance()['config']->get('plugins.admin.hide_modular_page_types');
foreach ($hideTypes as $hide) {
if (isset($types[$hide])) {
unset($types[$hide]);
} else {
@@ -1021,7 +993,12 @@ class AdminPlugin extends Plugin
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');
@@ -1031,36 +1008,80 @@ class AdminPlugin extends Plugin
$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.
*
* @return void
* @throws \RuntimeException
*/
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
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');
}
// 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 */
$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.
@@ -1080,8 +1101,46 @@ class AdminPlugin extends Plugin
// Initialize admin class (also registers it to Grav services).
$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
$config_path = $this->grav['locator']->findResource('user://config');
$config_path = $locator->findResource('user://config');
foreach ($this->admin::configurations() as $config_file) {
if ($config_file === 'info') {
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'];
$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);
$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()
{
static $options = [];
static $options;
if (empty($options)) {
if (null === $options) {
$options = [];
$theme_files = glob(__dir__ . '/themes/grav/css/codemirror/themes/*.css');
foreach ($theme_files as $theme_file) {
$theme = basename(basename($theme_file, '.css'));
@@ -1244,6 +1307,9 @@ class AdminPlugin extends Plugin
return $options;
}
/**
* @return array
*/
public function getPresets()
{
$filename = $this->grav['locator']->findResource('plugin://admin/presets.yaml', false);
@@ -1257,12 +1323,12 @@ class AdminPlugin extends Plugin
$custom_presets = Yaml::parse($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];
$presets = $preset + $presets;
} else {
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];
$presets = $preset + $presets;
}

View File

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

View File

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

View File

@@ -24,7 +24,7 @@ use Grav\Common\Page\Pages;
use Grav\Common\Page\Collection;
use Grav\Common\Security;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\User;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\Utils;
use Grav\Framework\Psr7\Response;
use Grav\Framework\RequestHandler\Exception\RequestException;
@@ -195,29 +195,7 @@ class AdminController extends AdminBaseController
return true;
}
// LOGIN & 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;
}
// USER TASKS
/**
* Handle logout.
@@ -226,6 +204,10 @@ class AdminController extends AdminBaseController
*/
protected function taskLogout()
{
if (!$this->authorizeTask('logout', ['admin.login'])) {
return false;
}
$this->admin->logout($this->data, $this->post);
return true;
@@ -241,7 +223,7 @@ class AdminController extends AdminBaseController
}
try {
/** @var User $user */
/** @var UserInterface $user */
$user = $this->grav['user'];
/** @var TwoFactorAuth $twoFa */
@@ -278,172 +260,6 @@ class AdminController extends AdminBaseController
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.
*

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;
use Grav\Common\Grav;
use Grav\Common\Processors\ProcessorBase;
use Grav\Framework\Route\Route;
use Grav\Plugin\Admin\Routers\LoginRouter;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
@@ -13,6 +15,16 @@ class Router extends ProcessorBase
public $id = 'admin_router';
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.
*
@@ -30,26 +42,26 @@ class Router extends ProcessorBase
$route = $context['route'];
$normalized = mb_strtolower(trim($route->getRoute(), '/'));
$parts = explode('/', $normalized);
array_shift($parts);
$key = array_shift($parts);
array_shift($parts); // Admin path
$routeStr = implode('/', $parts);
$view = array_shift($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;
/*
if ($key === '__TODO__') {
$controller = new TodoController();
$response = $controller->handle($request);
// Run login controller if user isn't fully logged in or asks to logout.
$user = $this->admin->user;
if (!$user->authorized || !$user->authorize('admin.login')) {
$params = (new LoginRouter())->matchServerRequest($request);
$request = $request->withAttribute('admin', $params + $request->getAttribute('admin'));
}
*/
if (!$response) {
// Fallback to the old admin behavior.
$this->admin->request = $request;
$response = $handler->handle($request);
}
$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
expires: 0
access:
admin.login: false
forms:
admin-login-forgot:
type: admin
method: post
form:
fields:
- name: username
username:
type: text
placeholder: PLUGIN_ADMIN.USERNAME
autofocus: true

View File

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

View File

@@ -1,9 +1,16 @@
---
title: Register Admin User
expires: 0
access:
admin.login: false
forms:
admin-login-register:
type: admin
method: post
form:
fields:
- name: username
username:
type: text
label: PLUGIN_ADMIN.USERNAME
autofocus: true
@@ -13,7 +20,7 @@ form:
message: PLUGIN_LOGIN.USERNAME_NOT_VALID
config-pattern@: system.username_regex
- name: email
email:
type: email
label: PLUGIN_ADMIN.EMAIL
placeholder: "valid email address"
@@ -22,7 +29,7 @@ form:
message: PLUGIN_ADMIN.EMAIL_VALIDATION_MESSAGE
required: true
- name: password1
password1:
type: password
label: PLUGIN_ADMIN.PASSWORD
placeholder: PLUGIN_ADMIN.PWD_PLACEHOLDER
@@ -31,7 +38,7 @@ form:
message: PLUGIN_ADMIN.PASSWORD_VALIDATION_MESSAGE
config-pattern@: system.pwd_regex
- name: password2
password2:
type: password
label: PLUGIN_ADMIN.PASSWORD_CONFIRM
placeholder: PLUGIN_ADMIN.PWD_PLACEHOLDER
@@ -40,19 +47,17 @@ form:
message: PLUGIN_ADMIN.PASSWORD_VALIDATION_MESSAGE
config-pattern@: system.pwd_regex
- name: fullname
fullname:
type: text
placeholder: "e.g. 'Joe Schmoe'"
label: PLUGIN_ADMIN.FULL_NAME
validate:
required: true
- name: title
title:
type: text
placeholder: "e.g. 'Administrator'"
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...

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,8 +7,9 @@
{% endblock %}
{% block form %}
{% for field in form.fields %}
{% for field_name,field in form.fields %}
{% if field.type %}
{% set field = field|merge({ name: field.name ?? field_name }) %}
{% set value = form.value(field.name) %}
<div class="wrapper-{{ field.name }}">
{% 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">
<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>

View File

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