diff --git a/CHANGELOG.md b/CHANGELOG.md index 28b168ca..88e20e2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,22 @@ # v1.10.9 ## mm/dd/2021 +1. [](#new) + * Requires **Grav 1.7.10** +1. [](#improved) + * 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 1. [](#bugfix) * Fixed issue where Adding a new page and canceling from within Editing would alter the Parent location of the edited page [#2067](https://github.com/getgrav/grav-plugin-admin/issues/2067) * Fixed and enhanced Range field to be Lists compatible [#2062](https://github.com/getgrav/grav-plugin-admin/issues/2062) * Fixed ERR_TOO_MANY_REDIRECTS with HTTPS = 'On' [#2100](https://github.com/getgrav/grav-plugin-admin/issues/2100) * Prevent expert editing mode from anyone else than super users [#2094](https://github.com/getgrav/grav-plugin-admin/issues/2094) + * 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.8 ## 03/19/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) %}