mirror of
https://github.com/getgrav/grav-plugin-admin.git
synced 2025-11-01 19:06:16 +01:00
Greatly improve login related actions for Admin
* Better isolate admin to prevent session related vulnerabilities * Removed support for custom login redirects for improved security * Shorten forgot password link lifetime from 7 days to 1 hour * Fixed login related pages being accessible from admin when user has logged in * Fixed admin user creation and password reset allowing unsafe passwords * Fixed missing validation when registering the first admin user * Fixed reset password email not to have session specific token in it
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,12 +1,22 @@
|
||||
# v1.10.8
|
||||
## 03/19/2021
|
||||
|
||||
1. [](#new)
|
||||
* Requires **Grav 1.7.10**
|
||||
1. [](#improved)
|
||||
* Include alt text and title for images added to the editor [#2098](https://github.com/getgrav/grav-plugin-admin/issues/2098)
|
||||
1. [](#bugfix)
|
||||
* Fixed issue replacing `wildcard` field names in flex collections [#2092](https://github.com/getgrav/grav-plugin-admin/pull/2092)
|
||||
* Fixed legacy Pages having old `modular` reference instead of `module` [#2093](https://github.com/getgrav/grav-plugin-admin/issues/2093)
|
||||
* Fixed issue where Add New modal would close if selecting an item outside of the modal window. It is now necessary go through the Cancel button and clicking the overlay won't trigger the closing of the modal [#2089](https://github.com/getgrav/grav-plugin-admin/issues/2089), [#2065](https://github.com/getgrav/grav-plugin-admin/issues/2065)
|
||||
1. [](#branch)
|
||||
* Better isolate admin to prevent session related vulnerabilities
|
||||
* Removed support for custom login redirects for improved security
|
||||
* Shorten forgot password link lifetime from 7 days to 1 hour
|
||||
* Fixed login related pages being accessible from admin when user has logged in
|
||||
* Fixed admin user creation and password reset allowing unsafe passwords
|
||||
* Fixed missing validation when registering the first admin user
|
||||
* Fixed reset password email not to have session specific token in it
|
||||
|
||||
# v1.10.7
|
||||
## 03/17/2021
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
182
classes/plugin/AdminForm.php
Normal file
182
classes/plugin/AdminForm.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
classes/plugin/AdminFormFactory.php
Normal file
44
classes/plugin/AdminFormFactory.php
Normal 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;
|
||||
}
|
||||
}
|
||||
359
classes/plugin/Controllers/AdminController.php
Normal file
359
classes/plugin/Controllers/AdminController.php
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
630
classes/plugin/Controllers/Login/LoginController.php
Normal file
630
classes/plugin/Controllers/Login/LoginController.php
Normal file
@@ -0,0 +1,630 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Grav\Plugin\Admin
|
||||
*
|
||||
* @copyright Copyright (c) 2015 - 2021 Trilby Media, LLC. All rights reserved.
|
||||
* @license MIT License; see LICENSE file for details.
|
||||
*/
|
||||
|
||||
namespace Grav\Plugin\Admin\Controllers\Login;
|
||||
|
||||
use Grav\Common\Debugger;
|
||||
use Grav\Common\Page\Pages;
|
||||
use Grav\Common\Uri;
|
||||
use Grav\Common\User\Interfaces\UserCollectionInterface;
|
||||
use Grav\Common\User\Interfaces\UserInterface;
|
||||
use Grav\Framework\RequestHandler\Exception\PageExpiredException;
|
||||
use Grav\Framework\RequestHandler\Exception\RequestException;
|
||||
use Grav\Plugin\Admin\Admin;
|
||||
use Grav\Plugin\Admin\Controllers\AdminController;
|
||||
use Grav\Plugin\Email\Email;
|
||||
use Grav\Plugin\Login\Login;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use RobThree\Auth\TwoFactorAuthException;
|
||||
|
||||
/**
|
||||
* Class LoginController
|
||||
* @package Grav\Plugin\Admin\Controllers\Login
|
||||
*/
|
||||
class LoginController extends AdminController
|
||||
{
|
||||
/** @var string */
|
||||
protected $nonce_action = 'admin-login';
|
||||
/** @var string */
|
||||
protected $nonce_name = 'login-nonce';
|
||||
|
||||
/**
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function displayLogin(): ResponseInterface
|
||||
{
|
||||
$this->page = $this->createPage('login');
|
||||
|
||||
$user = $this->getUser();
|
||||
if ($this->is2FA($user)) {
|
||||
$this->form = $this->getForm('login-twofa', ['reset' => true]);
|
||||
} else {
|
||||
$this->form = $this->getForm('login', ['reset' => true]);
|
||||
}
|
||||
|
||||
return $this->createDisplayResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function displayForgot(): ResponseInterface
|
||||
{
|
||||
$this->page = $this->createPage('forgot');
|
||||
$this->form = $this->getForm('admin-login-forgot', ['reset' => true]);
|
||||
|
||||
return $this->createDisplayResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the reset password action.
|
||||
*
|
||||
* @param string|null $username
|
||||
* @param string|null $token
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function displayReset(string $username = null, string $token = null): ResponseInterface
|
||||
{
|
||||
if ('' === (string)$username || '' === (string)$token) {
|
||||
$this->setMessage($this->translate('PLUGIN_ADMIN.RESET_INVALID_LINK'), 'error');
|
||||
|
||||
return $this->createRedirectResponse('/forgot');
|
||||
}
|
||||
|
||||
$this->page = $this->createPage('reset');
|
||||
$this->form = $this->getForm('admin-login-reset', ['reset' => true]);
|
||||
$this->form->setData('username', $username);
|
||||
$this->form->setData('token', $token);
|
||||
|
||||
$this->setMessage($this->translate('PLUGIN_ADMIN.RESET_NEW_PASSWORD'));
|
||||
|
||||
return $this->createDisplayResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function displayRegister(): ResponseInterface
|
||||
{
|
||||
$route = $this->getRequest()->getAttribute('admin')['route'] ?? '';
|
||||
if ('' !== $route) {
|
||||
return $this->createRedirectResponse('/');
|
||||
}
|
||||
|
||||
$this->page = $this->createPage('register');
|
||||
$this->form = $this->getForm('admin-login-register');
|
||||
|
||||
return $this->createDisplayResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function displayUnauthorized(): ResponseInterface
|
||||
{
|
||||
$uri = (string)$this->getRequest()->getUri();
|
||||
|
||||
$ext = pathinfo($uri, PATHINFO_EXTENSION);
|
||||
$accept = $this->getAccept(['application/json', 'text/html']);
|
||||
if ($ext === 'json' || $accept === 'application/json') {
|
||||
return $this->createErrorResponse(new RequestException($this->getRequest(), $this->translate('PLUGIN_ADMIN.LOGGED_OUT'), 401));
|
||||
}
|
||||
|
||||
$this->setMessage($this->translate('PLUGIN_ADMIN.LOGGED_OUT'), 'warning');
|
||||
|
||||
return $this->createRedirectResponse('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle login.
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function taskLogin(): ResponseInterface
|
||||
{
|
||||
$this->page = $this->createPage('login');
|
||||
$this->form = $this->getActiveForm() ?? $this->getForm('login');
|
||||
try {
|
||||
$this->checkNonce();
|
||||
} catch (PageExpiredException $e) {
|
||||
$this->setMessage($e->getMessage(), 'error');
|
||||
|
||||
return $this->createDisplayResponse();
|
||||
}
|
||||
|
||||
$post = $this->getPost();
|
||||
$credentials = $post['data'] ?? [];
|
||||
$login = $this->getLogin();
|
||||
$config = $this->getConfig();
|
||||
|
||||
$userKey = (string)($credentials['username'] ?? '');
|
||||
// Pseudonymization of the IP.
|
||||
$ipKey = sha1(Uri::ip() . $config->get('security.salt'));
|
||||
|
||||
$rateLimiter = $login->getRateLimiter('login_attempts');
|
||||
|
||||
// Check if the current IP has been used in failed login attempts.
|
||||
$attempts = count($rateLimiter->getAttempts($ipKey, 'ip'));
|
||||
|
||||
$rateLimiter->registerRateLimitedAction($ipKey, 'ip')->registerRateLimitedAction($userKey);
|
||||
|
||||
// Check rate limit for both IP and user, but allow each IP a single try even if user is already rate limited.
|
||||
if ($rateLimiter->isRateLimited($ipKey, 'ip') || ($attempts && $rateLimiter->isRateLimited($userKey))) {
|
||||
Admin::DEBUG && Admin::addDebugMessage('Admin login: rate limit, redirecting', $credentials);
|
||||
|
||||
$this->setMessage($this->translate('PLUGIN_LOGIN.TOO_MANY_LOGIN_ATTEMPTS', $rateLimiter->getInterval()), 'error');
|
||||
|
||||
$this->form->reset();
|
||||
|
||||
/** @var Pages $pages */
|
||||
$pages = $this->grav['pages'];
|
||||
|
||||
// Redirect to the home page of the site.
|
||||
return $this->createRedirectResponse($pages->homeUrl(null, true));
|
||||
}
|
||||
|
||||
Admin::DEBUG && Admin::addDebugMessage('Admin login', $credentials);
|
||||
|
||||
// Fire Login process.
|
||||
$event = $login->login(
|
||||
$credentials,
|
||||
['admin' => true, 'twofa' => $config->get('plugins.admin.twofa_enabled', false)],
|
||||
['authorize' => 'admin.login', 'return_event' => true]
|
||||
);
|
||||
$user = $event->getUser();
|
||||
|
||||
Admin::DEBUG && Admin::addDebugMessage('Admin login: user', $user);
|
||||
|
||||
$redirect = (string)$this->getRequest()->getUri();
|
||||
|
||||
if ($user->authenticated) {
|
||||
$rateLimiter->resetRateLimit($ipKey, 'ip')->resetRateLimit($userKey);
|
||||
if ($user->authorized) {
|
||||
$event->defMessage('PLUGIN_ADMIN.LOGIN_LOGGED_IN', 'info');
|
||||
}
|
||||
|
||||
$event->defRedirect($redirect);
|
||||
} elseif ($user->authorized) {
|
||||
$event->defMessage('PLUGIN_LOGIN.ACCESS_DENIED', 'error');
|
||||
} else {
|
||||
$event->defMessage('PLUGIN_LOGIN.LOGIN_FAILED', 'error');
|
||||
}
|
||||
|
||||
$event->defRedirect($redirect);
|
||||
|
||||
$message = $event->getMessage();
|
||||
if ($message) {
|
||||
$this->setMessage($this->translate($message), $event->getMessageType());
|
||||
}
|
||||
|
||||
$this->form->reset();
|
||||
|
||||
return $this->createRedirectResponse($event->getRedirect());
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle logout when user isn't fully logged in.
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function taskLogout(): ResponseInterface
|
||||
{
|
||||
try {
|
||||
$this->checkNonce();
|
||||
} catch (PageExpiredException $e) {
|
||||
$this->setMessage($e->getMessage(), 'error');
|
||||
|
||||
return $this->createDisplayResponse();
|
||||
}
|
||||
|
||||
$login = $this->getLogin();
|
||||
$event = $login->logout(['admin' => true], ['return_event' => true]);
|
||||
|
||||
$event->defMessage('PLUGIN_ADMIN.LOGGED_OUT', 'info');
|
||||
$message = $event->getMessage();
|
||||
if ($message) {
|
||||
$this->getSession()->setFlashCookieObject(Admin::TMP_COOKIE_NAME, ['message' => $this->translate($message), 'status' => $event->getMessageType()]);
|
||||
}
|
||||
|
||||
return $this->createRedirectResponse('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle 2FA verification.
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function taskTwofa(): ResponseInterface
|
||||
{
|
||||
$user = $this->getUser();
|
||||
if (!$this->is2FA($user)) {
|
||||
Admin::DEBUG && Admin::addDebugMessage('Admin login: user is not logged in or does not have 2FA enabled', $user);
|
||||
|
||||
// Task is visible only for users who have enabled 2FA.
|
||||
return $this->createRedirectResponse('/');
|
||||
}
|
||||
|
||||
$this->page = $this->createPage('login');
|
||||
$this->form = $this->getForm('admin-login-twofa');
|
||||
try {
|
||||
$this->checkNonce();
|
||||
} catch (PageExpiredException $e) {
|
||||
$this->setMessage($e->getMessage(), 'error');
|
||||
|
||||
return $this->createDisplayResponse();
|
||||
}
|
||||
|
||||
|
||||
$post = $this->getPost();
|
||||
$data = $post['data'] ?? [];
|
||||
|
||||
$login = $this->getLogin();
|
||||
try {
|
||||
$twoFa = $login->twoFactorAuth();
|
||||
} catch (TwoFactorAuthException $e) {
|
||||
/** @var Debugger $debugger */
|
||||
$debugger = $this->grav['debugger'];
|
||||
$debugger->addException($e);
|
||||
|
||||
$twoFa = null;
|
||||
}
|
||||
|
||||
$code = $data['2fa_code'] ?? null;
|
||||
$secret = $user->twofa_secret ?? null;
|
||||
$redirect = (string)$this->getRequest()->getUri();
|
||||
|
||||
if (null === $twoFa || !$user->authenticated || !$code || !$secret || !$twoFa->verifyCode($secret, $code)) {
|
||||
Admin::DEBUG && Admin::addDebugMessage('Admin login: 2FA check failed, log out!');
|
||||
|
||||
// Failed 2FA auth, logout and redirect to the current page.
|
||||
$login->logout(['admin' => true]);
|
||||
|
||||
$this->grav['session']->setFlashCookieObject(Admin::TMP_COOKIE_NAME, ['message' => $this->translate('PLUGIN_ADMIN.2FA_FAILED'), 'status' => 'error']);
|
||||
|
||||
$this->form->reset();
|
||||
|
||||
return $this->createRedirectResponse($redirect);
|
||||
}
|
||||
|
||||
// Successful 2FA, authorize user and redirect.
|
||||
$user->authorized = true;
|
||||
|
||||
Admin::DEBUG && Admin::addDebugMessage('Admin login: 2FA check succeeded, authorize user and redirect');
|
||||
|
||||
$this->setMessage($this->translate('PLUGIN_ADMIN.LOGIN_LOGGED_IN'));
|
||||
|
||||
$this->form->reset();
|
||||
|
||||
return $this->createRedirectResponse($redirect);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the reset password action.
|
||||
*
|
||||
* @param string|null $username
|
||||
* @param string|null $token
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function taskReset(string $username = null, string $token = null): ResponseInterface
|
||||
{
|
||||
$this->page = $this->createPage('reset');
|
||||
$this->form = $this->getForm('admin-login-reset');
|
||||
try {
|
||||
$this->checkNonce();
|
||||
} catch (PageExpiredException $e) {
|
||||
$this->setMessage($e->getMessage(), 'error');
|
||||
|
||||
return $this->createDisplayResponse();
|
||||
}
|
||||
|
||||
|
||||
$post = $this->getPost();
|
||||
$data = $post['data'] ?? [];
|
||||
$users = $this->getAccounts();
|
||||
|
||||
$username = $username ?? $data['username'] ?? null;
|
||||
$token = $token ?? $data['token'] ?? null;
|
||||
|
||||
$user = $username ? $users->load($username) : null;
|
||||
$password = $data['password'];
|
||||
|
||||
if ($user && $user->exists() && !empty($user->get('reset'))) {
|
||||
[$good_token, $expire] = explode('::', $user->get('reset'));
|
||||
|
||||
if ($good_token === $token) {
|
||||
if (time() > $expire) {
|
||||
$this->setMessage($this->translate('PLUGIN_ADMIN.RESET_LINK_EXPIRED'), 'error');
|
||||
|
||||
$this->form->reset();
|
||||
|
||||
return $this->createRedirectResponse('/forgot');
|
||||
}
|
||||
|
||||
// Set new password.
|
||||
$login = $this->getLogin();
|
||||
try {
|
||||
$login->validateField('password1', $password);
|
||||
} catch (\RuntimeException $e) {
|
||||
$this->setMessage($this->translate($e->getMessage()), 'error');
|
||||
|
||||
return $this->createRedirectResponse("/reset/u/{$username}/{$token}");
|
||||
}
|
||||
|
||||
$user->undef('hashed_password');
|
||||
$user->undef('reset');
|
||||
$user->update(['password' => $password]);
|
||||
$user->save();
|
||||
|
||||
$this->form->reset();
|
||||
|
||||
$this->setMessage($this->translate('PLUGIN_ADMIN.RESET_PASSWORD_RESET'));
|
||||
|
||||
return $this->createRedirectResponse('/login');
|
||||
}
|
||||
|
||||
Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed to reset password: Token %s is not good', $token));
|
||||
} else {
|
||||
Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed to reset password: User %s does not exist or has not requested reset', $username));
|
||||
}
|
||||
|
||||
$this->setMessage($this->translate('PLUGIN_ADMIN.RESET_INVALID_LINK'), 'error');
|
||||
|
||||
$this->form->reset();
|
||||
|
||||
return $this->createRedirectResponse('/forgot');
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the email password recovery procedure.
|
||||
*
|
||||
* Sends email to the user.
|
||||
*
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function taskForgot(): ResponseInterface
|
||||
{
|
||||
$this->page = $this->createPage('forgot');
|
||||
$this->form = $this->getForm('admin-login-forgot');
|
||||
try {
|
||||
$this->checkNonce();
|
||||
} catch (PageExpiredException $e) {
|
||||
$this->setMessage($e->getMessage(), 'error');
|
||||
|
||||
return $this->createDisplayResponse();
|
||||
}
|
||||
|
||||
|
||||
$post = $this->getPost();
|
||||
$data = $post['data'] ?? [];
|
||||
$login = $this->getLogin();
|
||||
$users = $this->getAccounts();
|
||||
$email = $this->getEmail();
|
||||
|
||||
$current = (string)$this->getRequest()->getUri();
|
||||
|
||||
$search = isset($data['username']) ? strip_tags($data['username']) : '';
|
||||
$user = !empty($search) ? $users->load($search) : null;
|
||||
$username = $user->username ?? null;
|
||||
$to = $user->email ?? null;
|
||||
|
||||
// Only send email to users which are enabled and have an email address.
|
||||
if (null === $user || $user->state !== 'enabled' || !$to) {
|
||||
Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed sending email: %s <%s> was not found or is blocked', $search, $to ?? 'N/A'));
|
||||
|
||||
$this->form->reset();
|
||||
|
||||
$this->setMessage($this->translate('PLUGIN_ADMIN.FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL'));
|
||||
|
||||
return $this->createRedirectResponse($current);
|
||||
}
|
||||
|
||||
$config = $this->getConfig();
|
||||
|
||||
// Check rate limit for the user.
|
||||
$rateLimiter = $login->getRateLimiter('pw_resets');
|
||||
$rateLimiter->registerRateLimitedAction($username);
|
||||
if ($rateLimiter->isRateLimited($username)) {
|
||||
Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed sending email: user %s <%s> is rate limited', $search, $to));
|
||||
|
||||
$this->form->reset();
|
||||
|
||||
$interval = $config->get('plugins.login.max_pw_resets_interval', 2);
|
||||
|
||||
$this->setMessage($this->translate('PLUGIN_LOGIN.FORGOT_CANNOT_RESET_IT_IS_BLOCKED', $to, $interval), 'error');
|
||||
|
||||
return $this->createRedirectResponse($current);
|
||||
}
|
||||
|
||||
$token = md5(uniqid(mt_rand(), true));
|
||||
$expire = time() + 3600; // 1 hour
|
||||
|
||||
$user->set('reset', $token . '::' . $expire);
|
||||
$user->save();
|
||||
|
||||
$from = $config->get('plugins.email.from');
|
||||
if (empty($from)) {
|
||||
Admin::DEBUG && Admin::addDebugMessage('Failed sending email: from address is not configured in email plugin');
|
||||
|
||||
$this->form->reset();
|
||||
|
||||
$this->setMessage($this->translate('PLUGIN_ADMIN.FORGOT_EMAIL_NOT_CONFIGURED'), 'error');
|
||||
|
||||
return $this->createRedirectResponse($current);
|
||||
}
|
||||
|
||||
// Do not trust username from the request.
|
||||
$fullname = $user->fullname ?: $username;
|
||||
$author = $config->get('site.author.name', '');
|
||||
$sitename = $config->get('site.title', 'Website');
|
||||
$reset_link = $this->getAbsoluteAdminUrl("/reset/u/{$username}/{$token}");
|
||||
|
||||
// For testing only!
|
||||
//Admin::DEBUG && Admin::addDebugMessage(sprintf('Reset link: %s', $reset_link));
|
||||
|
||||
$subject = $this->translate('PLUGIN_ADMIN.FORGOT_EMAIL_SUBJECT', $sitename);
|
||||
$content = $this->translate('PLUGIN_ADMIN.FORGOT_EMAIL_BODY', $fullname, $reset_link, $author, $sitename);
|
||||
|
||||
$this->grav['twig']->init();
|
||||
$body = $this->grav['twig']->processTemplate('email/base.html.twig', ['content' => $content]);
|
||||
|
||||
try {
|
||||
$message = $email->message($subject, $body, 'text/html')->setFrom($from)->setTo($to);
|
||||
$sent = $email->send($message);
|
||||
if ($sent < 1) {
|
||||
throw new \RuntimeException('Sending email failed');
|
||||
}
|
||||
|
||||
// For testing only!
|
||||
//Admin::DEBUG && Admin::addDebugMessage(sprintf('Email sent to %s', $to), $body);
|
||||
|
||||
$this->setMessage($this->translate('PLUGIN_ADMIN.FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL'));
|
||||
} catch (\RuntimeException|\Swift_SwiftException $e) {
|
||||
$rateLimiter->resetRateLimit($username);
|
||||
|
||||
/** @var Debugger $debugger */
|
||||
$debugger = $this->grav['debugger'];
|
||||
$debugger->addException($e);
|
||||
|
||||
$this->form->reset();
|
||||
|
||||
$this->setMessage($this->translate('PLUGIN_ADMIN.FORGOT_FAILED_TO_EMAIL'), 'error');
|
||||
|
||||
return $this->createRedirectResponse('/forgot');
|
||||
}
|
||||
|
||||
$this->form->reset();
|
||||
|
||||
return $this->createRedirectResponse('/login');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ResponseInterface
|
||||
*/
|
||||
public function taskRegister(): ResponseInterface
|
||||
{
|
||||
$this->page = $this->createPage('register');
|
||||
$this->form = $form = $this->getForm('admin-login-register');
|
||||
try {
|
||||
$this->checkNonce();
|
||||
} catch (PageExpiredException $e) {
|
||||
$this->setMessage($e->getMessage(), 'error');
|
||||
|
||||
return $this->createDisplayResponse();
|
||||
}
|
||||
|
||||
// Note: Calls $this->doRegistration() to perform the user registration.
|
||||
$form->handleRequest($this->getRequest());
|
||||
$error = $form->getError();
|
||||
$errors = $form->getErrors();
|
||||
if ($error || $errors) {
|
||||
foreach ($errors as $field => $list) {
|
||||
foreach ((array)$list as $message) {
|
||||
if ($message !== $error) {
|
||||
$this->setMessage($message, 'error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->createDisplayResponse();
|
||||
}
|
||||
|
||||
$this->setMessage($this->translate('PLUGIN_ADMIN.LOGIN_LOGGED_IN'));
|
||||
|
||||
return $this->createRedirectResponse('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param UserInterface $user
|
||||
* @return bool
|
||||
*/
|
||||
protected function is2FA(UserInterface $user): bool
|
||||
{
|
||||
return $user && $user->authenticated && !$user->authorized && $user->get('twofa_enabled');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name
|
||||
* @return callable
|
||||
*/
|
||||
protected function getFormSubmitMethod(string $name): callable
|
||||
{
|
||||
switch ($name) {
|
||||
case 'login':
|
||||
case 'login-twofa':
|
||||
case 'admin-login-forgot':
|
||||
case 'admin-login-reset':
|
||||
return static function(array $data, array $files) {};
|
||||
case 'admin-login-register':
|
||||
return function(array $data, array $files) {
|
||||
$this->doRegistration($data, $files);
|
||||
};
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Unknown form');
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by registration form when calling handleRequest().
|
||||
*
|
||||
* @param array $data
|
||||
* @param array $files
|
||||
*/
|
||||
private function doRegistration(array $data, array $files): void
|
||||
{
|
||||
if (Admin::doAnyUsersExist()) {
|
||||
throw new \RuntimeException('A user account already exists, please create an admin account manually.', 400);
|
||||
}
|
||||
|
||||
$login = $this->getLogin();
|
||||
if (!$login) {
|
||||
throw new \RuntimeException($this->grav['language']->translate('PLUGIN_LOGIN.PLUGIN_LOGIN_DISABLED', 500));
|
||||
}
|
||||
|
||||
$data['title'] = $data['title'] ?? 'Administrator';
|
||||
|
||||
// Do not allow form to set the following fields (make super user):
|
||||
$data['state'] = 'enabled';
|
||||
$data['access'] = ['admin' => ['login' => true, 'super' => true], 'site' => ['login' => true]];
|
||||
unset($data['groups']);
|
||||
|
||||
// Create user.
|
||||
$user = $login->register($data, $files);
|
||||
|
||||
// Log in the new super admin user.
|
||||
unset($this->grav['user']);
|
||||
$this->grav['user'] = $user;
|
||||
$this->grav['session']->user = $user;
|
||||
$user->authenticated = true;
|
||||
$user->authorized = $user->authorize('admin.login') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Login
|
||||
*/
|
||||
private function getLogin(): Login
|
||||
{
|
||||
return $this->grav['login'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Email
|
||||
*/
|
||||
private function getEmail(): Email
|
||||
{
|
||||
return $this->grav['Email'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return UserCollectionInterface
|
||||
*/
|
||||
private function getAccounts(): UserCollectionInterface
|
||||
{
|
||||
return $this->grav['accounts'];
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace Grav\Plugin\Admin;
|
||||
|
||||
use Grav\Common\Grav;
|
||||
use Grav\Common\Processors\ProcessorBase;
|
||||
use Grav\Framework\Route\Route;
|
||||
use Grav\Plugin\Admin\Routers\LoginRouter;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
@@ -13,6 +15,16 @@ class Router extends ProcessorBase
|
||||
public $id = 'admin_router';
|
||||
public $title = 'Admin Panel';
|
||||
|
||||
/** @var Admin */
|
||||
protected $admin;
|
||||
|
||||
public function __construct(Grav $container, Admin $admin)
|
||||
{
|
||||
parent::__construct($container);
|
||||
|
||||
$this->admin = $admin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle routing to the dashboard, group and build objects.
|
||||
*
|
||||
@@ -30,26 +42,26 @@ class Router extends ProcessorBase
|
||||
$route = $context['route'];
|
||||
$normalized = mb_strtolower(trim($route->getRoute(), '/'));
|
||||
$parts = explode('/', $normalized);
|
||||
array_shift($parts);
|
||||
$key = array_shift($parts);
|
||||
array_shift($parts); // Admin path
|
||||
$routeStr = implode('/', $parts);
|
||||
$view = array_shift($parts);
|
||||
$path = implode('/', $parts);
|
||||
$task = $this->container['task'] ?? null;
|
||||
$action = $this->container['action'] ?? null;
|
||||
|
||||
$request = $request->withAttribute('admin', ['path' => $path, 'parts' => $parts]);
|
||||
$params = ['view' => $view, 'route' => $routeStr, 'path' => $path, 'parts' => $parts, 'task' => $task, 'action' => $action];
|
||||
$request = $request->withAttribute('admin', $params);
|
||||
|
||||
$response = null;
|
||||
/*
|
||||
if ($key === '__TODO__') {
|
||||
|
||||
$controller = new TodoController();
|
||||
|
||||
$response = $controller->handle($request);
|
||||
// Run login controller if user isn't fully logged in or asks to logout.
|
||||
$user = $this->admin->user;
|
||||
if (!$user->authorized || !$user->authorize('admin.login')) {
|
||||
$params = (new LoginRouter())->matchServerRequest($request);
|
||||
$request = $request->withAttribute('admin', $params + $request->getAttribute('admin'));
|
||||
}
|
||||
*/
|
||||
|
||||
if (!$response) {
|
||||
// Fallback to the old admin behavior.
|
||||
$response = $handler->handle($request);
|
||||
}
|
||||
$this->admin->request = $request;
|
||||
|
||||
$response = $handler->handle($request);
|
||||
|
||||
$this->stopTimer();
|
||||
|
||||
|
||||
93
classes/plugin/Routers/LoginRouter.php
Normal file
93
classes/plugin/Routers/LoginRouter.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
---
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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...
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user