Merge branch 'develop' of github.com:getgrav/grav-plugin-admin into develop

This commit is contained in:
Andy Miller
2021-04-06 10:58:35 -06:00
22 changed files with 1935 additions and 661 deletions

View File

@@ -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

637
admin.php

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -26,7 +26,6 @@ use Grav\Common\Themes;
use Grav\Common\Uri;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\User\User;
use Grav\Common\Utils;
use Grav\Framework\Acl\Action;
use Grav\Framework\Acl\Permissions;
@@ -40,6 +39,7 @@ use Grav\Plugin\AdminPlugin;
use Grav\Plugin\Login\Login;
use Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth;
use PicoFeed\Parser\MalformedXmlException;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
use RocketTheme\Toolbox\File\File;
use RocketTheme\Toolbox\File\JsonFile;
@@ -52,72 +52,63 @@ use PicoFeed\Reader\Reader;
define('LOGIN_REDIRECT_COOKIE', 'grav-login-redirect');
/**
* Class Admin
* @package Grav\Plugin\Admin
*/
class Admin
{
/** @var int */
public const DEBUG = 1;
/** @var int */
public const MEDIA_PAGINATION_INTERVAL = 20;
/** @var string */
public const TMP_COOKIE_NAME = 'tmp-admin-message';
/** @var Grav */
public $grav;
/** @var ServerRequestInterface|null */
public $request;
/** @var AdminForm */
public $form;
/** @var string */
public $base;
/** @var string */
public $location;
/** @var string */
public $route;
/** @var User */
/** @var UserInterface */
public $user;
/** @var array */
public $forgot;
/** @var string */
public $task;
/** @var array */
public $json_response;
/** @var Collection */
public $collection;
/** @var bool */
public $multilang;
/** @var string */
public $language;
/** @var array */
public $languages_enabled = [];
/** @var Uri $uri */
protected $uri;
/** @var array */
protected $pages = [];
/** @var Session */
protected $session;
/** @var Data\Blueprints */
protected $blueprints;
/** @var GPM */
protected $gpm;
/** @var int */
protected $pages_count;
/** @var bool */
protected $load_additional_files_in_background = false;
/** @var bool */
protected $loading_additional_files_in_background = false;
/** @var array */
protected $temp_messages = [];
@@ -127,7 +118,7 @@ class Admin
* @param Grav $grav
* @param string $base
* @param string $location
* @param string $route
* @param string|null $route
*/
public function __construct(Grav $grav, $base, $location, $route)
{
@@ -137,7 +128,7 @@ class Admin
$this->grav = $grav;
$this->base = $base;
$this->location = $location;
$this->route = $route;
$this->route = $route ?? '';
$this->uri = $grav['uri'];
$this->session = $grav['session'];
@@ -176,7 +167,7 @@ class Admin
$this->languages_enabled = (array)$this->grav['config']->get('system.languages.supported', []);
//Set the currently active language for the admin
$languageCode = $this->grav['uri']->param('lang');
$languageCode = $this->uri->param('lang');
if (null === $languageCode && !$this->session->admin_lang) {
$this->session->admin_lang = $language->getActive() ?? '';
}
@@ -190,7 +181,8 @@ class Admin
/**
* @param string $message
* @param array $data
* @param array|object $data
* @return void
*/
public static function addDebugMessage(string $message, $data = [])
{
@@ -199,6 +191,9 @@ class Admin
$debugger->addMessage($message, 'debug', $data);
}
/**
* @return string[]
*/
public static function contentEditor()
{
$options = [
@@ -238,6 +233,9 @@ class Admin
return $languages;
}
/**
* @return string
*/
public function getLanguage(): string
{
return $this->language ?: $this->grav['language']->getLanguage() ?: 'en';
@@ -317,6 +315,9 @@ class Admin
return $tools;
}
/**
* @return array
*/
public static function toolsPermissions()
{
$tools = static::tools();
@@ -349,12 +350,11 @@ class Admin
/**
* Static helper method to return the admin form nonce
*
* @param string $action
* @return string
*/
public static function getNonce()
public static function getNonce(string $action = 'admin-form')
{
$action = 'admin-form';
return Utils::getNonce($action);
}
@@ -388,11 +388,16 @@ class Admin
return $admin->getCurrentRoute();
}
/**
* @param string $path
* @param string|null $languageCode
* @return Route
*/
public function getAdminRoute(string $path = '', $languageCode = null): Route
{
/** @var Language $language */
$language = $this->grav['language'];
$languageCode = $languageCode ?? $language->getActive();
$languageCode = $languageCode ?? ($language->getActive() ?: null);
$languagePrefix = $languageCode ? '/' . $languageCode : '';
$root = $this->grav['uri']->rootUrl();
@@ -415,6 +420,11 @@ class Admin
return RouteFactory::createFromParts($parts);
}
/**
* @param string $route
* @param string|null $languageCode
* @return string
*/
public function adminUrl(string $route = '', $languageCode = null)
{
return $this->getAdminRoute($route, $languageCode)->toString(true);
@@ -435,6 +445,9 @@ class Admin
return $admin->getCurrentRoute();
}
/**
* @return string|null
*/
public function getCurrentRoute()
{
$pages = static::enablePages();
@@ -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('%^.*<body>(.*)</body>.*$%ms', '$1', $pinfo);
return $pinfo;
@@ -1560,8 +1586,7 @@ class Admin
/**
* Guest date format based on euro/US
*
* @param string $date
*
* @param string|null $date
* @return string
*/
public function guessDateFormat($date)
@@ -1584,6 +1609,7 @@ class Admin
'g:ia'
];
$date = (string)$date;
if (!isset($guess[$date])) {
$guess[$date] = 'd-m-Y H:i';
foreach ($date_formats as $date_format) {
@@ -1605,6 +1631,11 @@ class Admin
return $guess[$date];
}
/**
* @param string $date
* @param string $format
* @return bool
*/
public function validateDate($date, $format)
{
$d = DateTime::createFromFormat($format, $date);
@@ -1614,7 +1645,6 @@ class Admin
/**
* @param string $php_format
*
* @return string
*/
public function dateformatToMomentJS($php_format)

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,630 @@
<?php
/**
* @package Grav\Plugin\Admin
*
* @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved.
* @license MIT License; see LICENSE file for details.
*/
namespace Grav\Plugin\Admin\Controllers\Login;
use Grav\Common\Debugger;
use Grav\Common\Page\Pages;
use Grav\Common\Uri;
use Grav\Common\User\Interfaces\UserCollectionInterface;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Framework\RequestHandler\Exception\PageExpiredException;
use Grav\Framework\RequestHandler\Exception\RequestException;
use Grav\Plugin\Admin\Admin;
use Grav\Plugin\Admin\Controllers\AdminController;
use Grav\Plugin\Email\Email;
use Grav\Plugin\Login\Login;
use Psr\Http\Message\ResponseInterface;
use RobThree\Auth\TwoFactorAuthException;
/**
* Class LoginController
* @package Grav\Plugin\Admin\Controllers\Login
*/
class LoginController extends AdminController
{
/** @var string */
protected $nonce_action = 'admin-login';
/** @var string */
protected $nonce_name = 'login-nonce';
/**
* @return ResponseInterface
*/
public function displayLogin(): ResponseInterface
{
$this->page = $this->createPage('login');
$user = $this->getUser();
if ($this->is2FA($user)) {
$this->form = $this->getForm('login-twofa', ['reset' => true]);
} else {
$this->form = $this->getForm('login', ['reset' => true]);
}
return $this->createDisplayResponse();
}
/**
* @return ResponseInterface
*/
public function displayForgot(): ResponseInterface
{
$this->page = $this->createPage('forgot');
$this->form = $this->getForm('admin-login-forgot', ['reset' => true]);
return $this->createDisplayResponse();
}
/**
* Handle the reset password action.
*
* @param string|null $username
* @param string|null $token
* @return ResponseInterface
*/
public function displayReset(string $username = null, string $token = null): ResponseInterface
{
if ('' === (string)$username || '' === (string)$token) {
$this->setMessage($this->translate('PLUGIN_ADMIN.RESET_INVALID_LINK'), 'error');
return $this->createRedirectResponse('/forgot');
}
$this->page = $this->createPage('reset');
$this->form = $this->getForm('admin-login-reset', ['reset' => true]);
$this->form->setData('username', $username);
$this->form->setData('token', $token);
$this->setMessage($this->translate('PLUGIN_ADMIN.RESET_NEW_PASSWORD'));
return $this->createDisplayResponse();
}
/**
* @return ResponseInterface
*/
public function displayRegister(): ResponseInterface
{
$route = $this->getRequest()->getAttribute('admin')['route'] ?? '';
if ('' !== $route) {
return $this->createRedirectResponse('/');
}
$this->page = $this->createPage('register');
$this->form = $this->getForm('admin-login-register');
return $this->createDisplayResponse();
}
/**
* @return ResponseInterface
*/
public function displayUnauthorized(): ResponseInterface
{
$uri = (string)$this->getRequest()->getUri();
$ext = pathinfo($uri, PATHINFO_EXTENSION);
$accept = $this->getAccept(['application/json', 'text/html']);
if ($ext === 'json' || $accept === 'application/json') {
return $this->createErrorResponse(new RequestException($this->getRequest(), $this->translate('PLUGIN_ADMIN.LOGGED_OUT'), 401));
}
$this->setMessage($this->translate('PLUGIN_ADMIN.LOGGED_OUT'), 'warning');
return $this->createRedirectResponse('/');
}
/**
* Handle login.
*
* @return ResponseInterface
*/
public function taskLogin(): ResponseInterface
{
$this->page = $this->createPage('login');
$this->form = $this->getActiveForm() ?? $this->getForm('login');
try {
$this->checkNonce();
} catch (PageExpiredException $e) {
$this->setMessage($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'];
}
}

View File

@@ -2,8 +2,10 @@
namespace Grav\Plugin\Admin;
use Grav\Common\Grav;
use Grav\Common\Processors\ProcessorBase;
use Grav\Framework\Route\Route;
use Grav\Plugin\Admin\Routers\LoginRouter;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
@@ -13,6 +15,16 @@ class Router extends ProcessorBase
public $id = 'admin_router';
public $title = 'Admin Panel';
/** @var Admin */
protected $admin;
public function __construct(Grav $container, Admin $admin)
{
parent::__construct($container);
$this->admin = $admin;
}
/**
* Handle routing to the dashboard, group and build objects.
*
@@ -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();

View File

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

View File

@@ -1,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
---

View File

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

View File

@@ -1,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...

View File

@@ -1,17 +1,24 @@
---
title: Reset password
expires: 0
access:
admin.login: false
forms:
admin-login-reset:
type: admin
method: post
form:
fields:
- name: username
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
---

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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