diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b168ca..a39156a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,12 +10,23 @@ # 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 + * Fixed admin controller running before setting `$grav['page']` # v1.10.7 ## 03/17/2021 diff --git a/admin.php b/admin.php index 332e93c0..78260d25 100644 --- a/admin.php +++ b/admin.php @@ -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()) { - $this->grav->redirect($this->admin_route); - } - $this->template = 'register'; + $inAdmin = $this->isAdminPath(); + + // If no users found, go to register. + if (!$inAdmin && !Admin::doAnyUsersExist()) { + $this->grav->redirect($this->admin_route); } - // 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(); - } + $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() { @@ -521,80 +413,107 @@ class AdminPlugin extends Plugin $this->session->expert = $this->session->expert ?? false; } - // Make local copy of POST. - $post = $this->grav['uri']->post(); - - // Handle tasks. - $this->admin->task = $task = $this->grav['task'] ?? $this->grav['action']; - if ($task) { - Admin::DEBUG && Admin::addDebugMessage("Admin task: {$task}"); - $this->initializeController($task, $post); - } elseif ($this->template === 'logs' && $this->route) { - // Display RAW error message. - $response = new Response(200, [], $this->admin->logEntry()); - - $this->grav->close($response); - } - - $self = $this; - // 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(); + $legacyController = false; + } else { + $legacyController = true; + } + // Replace page service with admin. - $this->grav['page'] = function () use ($self) { - $page = new Page(); + if (empty($this->grav['page'])) { + /** @var UserInterface $user */ + $user = $this->grav['user']; - // Plugins may not have the correct Cache-Control header set, force no-store for the proxies. - $page->expires(0); + $this->grav['page'] = function () use ($user) { + $page = new Page(); - if ($this->grav['user']->authorize('admin.login')) { - $event = new Event(['page' => $page]); - $event = $this->grav->fireEvent('onAdminPage', $event); - $page = $event['page']; + // Plugins may not have the correct Cache-Control header set, force no-store for the proxies. + $page->expires(0); - if ($page->slug()) { - Admin::DEBUG && Admin::addDebugMessage('Admin page: from event'); - return $page; - } - } + if ($user->authorize('admin.login')) { + $event = new Event(['page' => $page]); + $event = $this->grav->fireEvent('onAdminPage', $event); - // 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}"); - - $page->init(new \SplFileInfo(__DIR__ . "/pages/admin/{$self->template}.md")); - $page->slug(basename($self->template)); - - return $page; - } - - // If not provided by Admin, lookup pages added by other plugins - $plugins = $this->grav['plugins']; - $locator = $this->grav['locator']; - - foreach ($plugins as $plugin) { - if ($this->config->get("plugins.{$plugin->name}.enabled") !== true) { - continue; + /** @var PageInterface $page */ + $page = $event['page']; + if ($page->slug()) { + Admin::DEBUG && Admin::addDebugMessage('Admin page: from event'); + return $page; + } } - $path = $locator->findResource("plugins://{$plugin->name}/admin/pages/{$self->template}.md"); + // Look in the pages provided by the Admin plugin itself + if (file_exists(__DIR__ . "/pages/admin/{$this->template}.md")) { + Admin::DEBUG && Admin::addDebugMessage("Admin page: {$this->template}"); - if ($path) { - Admin::DEBUG && Admin::addDebugMessage("Admin page: plugin {$plugin->name}/{$self->template}"); - - $page->init(new \SplFileInfo($path)); - $page->slug(basename($self->template)); + $page->init(new \SplFileInfo(__DIR__ . "/pages/admin/{$this->template}.md")); + $page->slug(basename($this->template)); return $page; } - } - return null; - }; + /** @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/{$this->template}.md"); + if ($path) { + Admin::DEBUG && Admin::addDebugMessage("Admin page: plugin {$plugin->name}/{$this->template}"); + + $page->init(new \SplFileInfo($path)); + $page->slug(basename($this->template)); + + return $page; + } + } + + 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; @@ -625,12 +544,33 @@ class AdminPlugin extends Plugin } } + if ($legacyController) { + // 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. + $response = new Response(200, [], $this->admin->logEntry()); + + $this->grav->close($response); + } + + } + // Explicitly set a timestamp on assets $this->grav['assets']->setTimestamp(substr(md5(GRAV_VERSION . $this->grav['config']->checksum()), 0, 10)); } /** * Handles initializing the assets + * + * @return void */ public function onAssetsInitialized() { @@ -650,6 +590,8 @@ class AdminPlugin extends Plugin /** * Add twig paths to plugin templates. + * + * @return void */ public function onTwigTemplatePaths() { @@ -663,10 +605,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 +685,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 +720,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 +774,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 +793,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 +818,7 @@ class AdminPlugin extends Plugin * Initial stab at registering permissions (WIP) * * @param PermissionsRegisterEvent $event + * @return void */ public function onRegisterPermissions(PermissionsRegisterEvent $event): void { @@ -857,10 +828,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 +845,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 +860,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 +870,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 +880,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 +889,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 +922,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 +956,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 +998,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 +1013,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 +1106,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 +1156,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 +1288,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 +1312,9 @@ class AdminPlugin extends Plugin return $options; } + /** + * @return array + */ public function getPresets() { $filename = $this->grav['locator']->findResource('plugin://admin/presets.yaml', false); @@ -1257,12 +1328,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; } diff --git a/blueprints.yaml b/blueprints.yaml index 851e3c62..69e4f2cf 100644 --- a/blueprints.yaml +++ b/blueprints.yaml @@ -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' } diff --git a/classes/plugin/Admin.php b/classes/plugin/Admin.php index 02bbacae..e40344df 100644 --- a/classes/plugin/Admin.php +++ b/classes/plugin/Admin.php @@ -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(); @@ -459,7 +472,8 @@ class Admin * Route may or may not be prefixed by /en or /admin or /en/admin. * * @param string $redirect - * @param int$redirectCode + * @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 = []; @@ -541,7 +561,7 @@ class Admin $route = '/' . ltrim($grav['admin']->route, '/'); /** @var PageInterface $page */ - $page = $pages->find($route); + $page = $pages->find($route); $parent_route = null; if ($page) { $media = $page->media()->all(); @@ -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) @@ -579,7 +598,9 @@ class Admin /** * Authenticate user. * - * @param array $credentials User credentials. + * @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,21 +1289,17 @@ class Admin $package = $this->getPackageFromGPM($slug); - if ($package) { - if ($package->dependencies) { - foreach ($package->dependencies as $dependency) { -// if (count($gpm->getPackagesThatDependOnPackage($dependency)) > 1) { -// continue; -// } - if (isset($dependency['name'])) { - $dependency = $dependency['name']; - } + if ($package && $package->dependencies) { + foreach ($package->dependencies as $dependency) { +// if (count($gpm->getPackagesThatDependOnPackage($dependency)) > 1) { +// continue; +// } + if (isset($dependency['name'])) { + $dependency = $dependency['name']; + } - if (!in_array($dependency, $dependencies, true)) { - if (!in_array($dependency, ['admin', 'form', 'login', 'email', 'php'])) { - $dependencies[] = $dependency; - } - } + if (!in_array($dependency, $dependencies, true) && !in_array($dependency, ['admin', 'form', 'login', 'email', 'php'])) { + $dependencies[] = $dependency; } } } @@ -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('%^.*(.*).*$%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) diff --git a/classes/plugin/AdminController.php b/classes/plugin/AdminController.php index 3590a0be..a2bebc0f 100644 --- a/classes/plugin/AdminController.php +++ b/classes/plugin/AdminController.php @@ -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. * diff --git a/classes/plugin/AdminForm.php b/classes/plugin/AdminForm.php new file mode 100644 index 00000000..668ddbd3 --- /dev/null +++ b/classes/plugin/AdminForm.php @@ -0,0 +1,182 @@ +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); + } + } +} diff --git a/classes/plugin/AdminFormFactory.php b/classes/plugin/AdminFormFactory.php new file mode 100644 index 00000000..15513b6f --- /dev/null +++ b/classes/plugin/AdminFormFactory.php @@ -0,0 +1,44 @@ +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; + } +} diff --git a/classes/plugin/Controllers/AdminController.php b/classes/plugin/Controllers/AdminController.php new file mode 100644 index 00000000..1a6db932 --- /dev/null +++ b/classes/plugin/Controllers/AdminController.php @@ -0,0 +1,359 @@ +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; + } + + +} diff --git a/classes/plugin/Controllers/Login/LoginController.php b/classes/plugin/Controllers/Login/LoginController.php new file mode 100644 index 00000000..c625f324 --- /dev/null +++ b/classes/plugin/Controllers/Login/LoginController.php @@ -0,0 +1,630 @@ +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($this->translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), '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($this->translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), '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($this->translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), '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($this->translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), '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($this->translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), '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($this->translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), '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']; + } +} diff --git a/classes/plugin/Router.php b/classes/plugin/Router.php index d1b5749c..2c866616 100644 --- a/classes/plugin/Router.php +++ b/classes/plugin/Router.php @@ -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. * @@ -25,31 +37,32 @@ class Router extends ProcessorBase $this->startTimer(); $context = $request->getAttributes(); + $query = $request->getQueryParams(); /** @var Route $route */ $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'] ?? $query['task'] ?? null; + $action = $this->container['action'] ?? $query['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. - $response = $handler->handle($request); - } + $this->admin->request = $request; + + $response = $handler->handle($request); $this->stopTimer(); diff --git a/classes/plugin/Routers/LoginRouter.php b/classes/plugin/Routers/LoginRouter.php new file mode 100644 index 00000000..df36339a --- /dev/null +++ b/classes/plugin/Routers/LoginRouter.php @@ -0,0 +1,93 @@ + '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, + ]; + } +} diff --git a/pages/admin/forgot.md b/pages/admin/forgot.md index 59e6fa63..e4c85a5d 100644 --- a/pages/admin/forgot.md +++ b/pages/admin/forgot.md @@ -1,13 +1,19 @@ --- title: Forgot password expires: 0 +access: + admin.login: false + +forms: + admin-login-forgot: + type: admin + method: post -form: fields: - - name: username - type: text - placeholder: PLUGIN_ADMIN.USERNAME - autofocus: true - validate: - required: true + username: + type: text + placeholder: PLUGIN_ADMIN.USERNAME + autofocus: true + validate: + required: true --- diff --git a/pages/admin/login.md b/pages/admin/login.md index f80fd0cc..7fa0b44c 100644 --- a/pages/admin/login.md +++ b/pages/admin/login.md @@ -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: diff --git a/pages/admin/register.md b/pages/admin/register.md index e357427c..563cedfc 100644 --- a/pages/admin/register.md +++ b/pages/admin/register.md @@ -1,58 +1,63 @@ --- +title: Register Admin User expires: 0 +access: + admin.login: false -form: - fields: - - name: username - type: text - label: PLUGIN_ADMIN.USERNAME - autofocus: true - placeholder: PLUGIN_ADMIN.USERNAME_PLACEHOLDER - validate: - required: true - message: PLUGIN_LOGIN.USERNAME_NOT_VALID - config-pattern@: system.username_regex +forms: + admin-login-register: + type: admin + method: post - - name: email - type: email - label: PLUGIN_ADMIN.EMAIL - placeholder: "valid email address" - validate: + fields: + username: + type: text + label: PLUGIN_ADMIN.USERNAME + autofocus: true + placeholder: PLUGIN_ADMIN.USERNAME_PLACEHOLDER + validate: + required: true + message: PLUGIN_LOGIN.USERNAME_NOT_VALID + config-pattern@: system.username_regex + + email: type: email - message: PLUGIN_ADMIN.EMAIL_VALIDATION_MESSAGE - required: true + label: PLUGIN_ADMIN.EMAIL + placeholder: "valid email address" + validate: + type: email + message: PLUGIN_ADMIN.EMAIL_VALIDATION_MESSAGE + required: true - - name: password1 - type: password - label: PLUGIN_ADMIN.PASSWORD - placeholder: PLUGIN_ADMIN.PWD_PLACEHOLDER - validate: - required: true - message: PLUGIN_ADMIN.PASSWORD_VALIDATION_MESSAGE - config-pattern@: system.pwd_regex + password1: + type: password + label: PLUGIN_ADMIN.PASSWORD + placeholder: PLUGIN_ADMIN.PWD_PLACEHOLDER + validate: + required: true + message: PLUGIN_ADMIN.PASSWORD_VALIDATION_MESSAGE + config-pattern@: system.pwd_regex - - name: password2 - type: password - label: PLUGIN_ADMIN.PASSWORD_CONFIRM - placeholder: PLUGIN_ADMIN.PWD_PLACEHOLDER - validate: - required: true - message: PLUGIN_ADMIN.PASSWORD_VALIDATION_MESSAGE - config-pattern@: system.pwd_regex + password2: + type: password + label: PLUGIN_ADMIN.PASSWORD_CONFIRM + placeholder: PLUGIN_ADMIN.PWD_PLACEHOLDER + validate: + required: true + message: PLUGIN_ADMIN.PASSWORD_VALIDATION_MESSAGE + config-pattern@: system.pwd_regex - - name: fullname - type: text - placeholder: "e.g. 'Joe Schmoe'" - label: PLUGIN_ADMIN.FULL_NAME - - - name: title - type: text - placeholder: "e.g. 'Administrator'" - label: PLUGIN_ADMIN.TITLE - - process: - register_admin_user: true + fullname: + type: text + placeholder: "e.g. 'Joe Schmoe'" + label: PLUGIN_ADMIN.FULL_NAME + validate: + required: true + title: + type: text + placeholder: "e.g. 'Administrator'" + label: PLUGIN_ADMIN.TITLE --- 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... diff --git a/pages/admin/reset.md b/pages/admin/reset.md index cd4cc58b..973f0a06 100644 --- a/pages/admin/reset.md +++ b/pages/admin/reset.md @@ -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 - type: text - placeholder: PLUGIN_ADMIN.USERNAME - readonly: true - - name: password - type: password - placeholder: PLUGIN_ADMIN.PASSWORD - autofocus: true - - name: token - type: hidden + username: + type: text + placeholder: PLUGIN_ADMIN.USERNAME + readonly: true + password: + type: password + placeholder: PLUGIN_ADMIN.PASSWORD + autofocus: true + token: + type: hidden --- diff --git a/themes/grav/templates/forgot.html.twig b/themes/grav/templates/forgot.html.twig index 81058729..e7d9ef0c 100644 --- a/themes/grav/templates/forgot.html.twig +++ b/themes/grav/templates/forgot.html.twig @@ -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) %}
{% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %}
diff --git a/themes/grav/templates/partials/login-form.html.twig b/themes/grav/templates/partials/login-form.html.twig index 5eb5ec6d..8010767a 100755 --- a/themes/grav/templates/partials/login-form.html.twig +++ b/themes/grav/templates/partials/login-form.html.twig @@ -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) %}
{% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %}
diff --git a/themes/grav/templates/partials/login-twofa.html.twig b/themes/grav/templates/partials/login-twofa.html.twig index 4dafee9c..ada829f6 100644 --- a/themes/grav/templates/partials/login-twofa.html.twig +++ b/themes/grav/templates/partials/login-twofa.html.twig @@ -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) %}
{% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %}
diff --git a/themes/grav/templates/partials/login.html.twig b/themes/grav/templates/partials/login.html.twig index ef945aca..e7520272 100644 --- a/themes/grav/templates/partials/login.html.twig +++ b/themes/grav/templates/partials/login.html.twig @@ -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 : '') %} - -
+
{% block form %}{% endblock %} - - {{ nonce_field('admin-form', 'admin-nonce')|raw }} + {{ nonce_field(form.getNonceAction(), form.getNonceName())|raw }}
diff --git a/themes/grav/templates/partials/register.html.twig b/themes/grav/templates/partials/register.html.twig index 66417cb3..b7848e7b 100644 --- a/themes/grav/templates/partials/register.html.twig +++ b/themes/grav/templates/partials/register.html.twig @@ -1,5 +1,5 @@ {% extends 'partials/base.html.twig' %} -{% set scope = scope ?: 'data.' %} +{% set scope = form.scope %} {% block body %} @@ -10,11 +10,12 @@ {% block instructions %}{% endblock %} -
+
- {% block form %}{% endblock %} - - {{ nonce_field('form', 'form-nonce')|raw }} + {% block form %}{% endblock %} + {% include "forms/fields/formname/formname.html.twig" %} + {% include 'forms/fields/uniqueid/uniqueid.html.twig' %} + {{ nonce_field(form.getNonceAction() ?? 'form', form.getNonceName() ?? 'form-nonce')|raw }}
diff --git a/themes/grav/templates/register.html.twig b/themes/grav/templates/register.html.twig index 1a3765a8..aadf765b 100644 --- a/themes/grav/templates/register.html.twig +++ b/themes/grav/templates/register.html.twig @@ -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) %}
{% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %} @@ -19,7 +20,7 @@
- +
diff --git a/themes/grav/templates/reset.html.twig b/themes/grav/templates/reset.html.twig index 5ca75e90..c5f28ecd 100644 --- a/themes/grav/templates/reset.html.twig +++ b/themes/grav/templates/reset.html.twig @@ -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) %}
{% include ["forms/fields/#{field.type}/#{field.type}.html.twig", 'forms/fields/text/text.html.twig'] %}