LoginController.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634
  1. <?php
  2. /**
  3. * @package Grav\Plugin\Admin
  4. *
  5. * @copyright Copyright (c) 2015 - 2023 Trilby Media, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Plugin\Admin\Controllers\Login;
  9. use Grav\Common\Debugger;
  10. use Grav\Common\Grav;
  11. use Grav\Common\Page\Pages;
  12. use Grav\Common\Uri;
  13. use Grav\Common\User\Interfaces\UserCollectionInterface;
  14. use Grav\Common\User\Interfaces\UserInterface;
  15. use Grav\Common\Utils;
  16. use Grav\Framework\RequestHandler\Exception\PageExpiredException;
  17. use Grav\Framework\RequestHandler\Exception\RequestException;
  18. use Grav\Plugin\Admin\Admin;
  19. use Grav\Plugin\Admin\Controllers\AdminController;
  20. use Grav\Plugin\Email\Email;
  21. use Grav\Plugin\Login\Login;
  22. use Psr\Http\Message\ResponseInterface;
  23. use RobThree\Auth\TwoFactorAuthException;
  24. /**
  25. * Class LoginController
  26. * @package Grav\Plugin\Admin\Controllers\Login
  27. */
  28. class LoginController extends AdminController
  29. {
  30. /** @var string */
  31. protected $nonce_action = 'admin-login';
  32. /** @var string */
  33. protected $nonce_name = 'login-nonce';
  34. /**
  35. * @return ResponseInterface
  36. */
  37. public function displayLogin(): ResponseInterface
  38. {
  39. $this->page = $this->createPage('login');
  40. $user = $this->getUser();
  41. if ($this->is2FA($user)) {
  42. $this->form = $this->getForm('login-twofa', ['reset' => true]);
  43. } else {
  44. $this->form = $this->getForm('login', ['reset' => true]);
  45. }
  46. return $this->createDisplayResponse();
  47. }
  48. /**
  49. * @return ResponseInterface
  50. */
  51. public function displayForgot(): ResponseInterface
  52. {
  53. $this->page = $this->createPage('forgot');
  54. $this->form = $this->getForm('admin-login-forgot', ['reset' => true]);
  55. return $this->createDisplayResponse();
  56. }
  57. /**
  58. * Handle the reset password action.
  59. *
  60. * @param string|null $username
  61. * @param string|null $token
  62. * @return ResponseInterface
  63. */
  64. public function displayReset(string $username = null, string $token = null): ResponseInterface
  65. {
  66. if ('' === (string)$username || '' === (string)$token) {
  67. $this->setMessage($this->translate('PLUGIN_ADMIN.RESET_INVALID_LINK'), 'error');
  68. return $this->createRedirectResponse('/forgot');
  69. }
  70. $this->page = $this->createPage('reset');
  71. $this->form = $this->getForm('admin-login-reset', ['reset' => true]);
  72. $this->form->setData('username', $username);
  73. $this->form->setData('token', $token);
  74. $this->setMessage($this->translate('PLUGIN_ADMIN.RESET_NEW_PASSWORD'));
  75. return $this->createDisplayResponse();
  76. }
  77. /**
  78. * @return ResponseInterface
  79. */
  80. public function displayRegister(): ResponseInterface
  81. {
  82. $route = $this->getRequest()->getAttribute('admin')['route'] ?? '';
  83. if ('' !== $route) {
  84. return $this->createRedirectResponse('/');
  85. }
  86. $this->page = $this->createPage('register');
  87. $this->form = $this->getForm('admin-login-register');
  88. return $this->createDisplayResponse();
  89. }
  90. /**
  91. * @return ResponseInterface
  92. */
  93. public function displayUnauthorized(): ResponseInterface
  94. {
  95. $uri = (string)$this->getRequest()->getUri();
  96. $ext = Utils::pathinfo($uri, PATHINFO_EXTENSION);
  97. $accept = $this->getAccept(['application/json', 'text/html']);
  98. if ($ext === 'json' || $accept === 'application/json') {
  99. return $this->createErrorResponse(new RequestException($this->getRequest(), $this->translate('PLUGIN_ADMIN.LOGGED_OUT'), 401));
  100. }
  101. $this->setMessage($this->translate('PLUGIN_ADMIN.LOGGED_OUT'), 'warning');
  102. return $this->createRedirectResponse('/');
  103. }
  104. /**
  105. * Handle login.
  106. *
  107. * @return ResponseInterface
  108. */
  109. public function taskLogin(): ResponseInterface
  110. {
  111. $this->page = $this->createPage('login');
  112. $this->form = $this->getActiveForm() ?? $this->getForm('login');
  113. try {
  114. $this->checkNonce();
  115. } catch (PageExpiredException $e) {
  116. $this->setMessage($this->translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), 'error');
  117. return $this->createDisplayResponse();
  118. }
  119. $post = $this->getPost();
  120. $credentials = (array)($post['data'] ?? []);
  121. $login = $this->getLogin();
  122. $config = $this->getConfig();
  123. $userKey = (string)($credentials['username'] ?? '');
  124. // Pseudonymization of the IP.
  125. $ipKey = sha1(Uri::ip() . $config->get('security.salt'));
  126. $rateLimiter = $login->getRateLimiter('login_attempts');
  127. // Check if the current IP has been used in failed login attempts.
  128. $attempts = count($rateLimiter->getAttempts($ipKey, 'ip'));
  129. $rateLimiter->registerRateLimitedAction($ipKey, 'ip')->registerRateLimitedAction($userKey);
  130. // Check rate limit for both IP and user, but allow each IP a single try even if user is already rate limited.
  131. if ($rateLimiter->isRateLimited($ipKey, 'ip') || ($attempts && $rateLimiter->isRateLimited($userKey))) {
  132. Admin::DEBUG && Admin::addDebugMessage('Admin login: rate limit, redirecting', $credentials);
  133. $this->setMessage($this->translate('PLUGIN_LOGIN.TOO_MANY_LOGIN_ATTEMPTS', $rateLimiter->getInterval()), 'error');
  134. $this->form->reset();
  135. /** @var Pages $pages */
  136. $pages = $this->grav['pages'];
  137. // Redirect to the home page of the site.
  138. return $this->createRedirectResponse($pages->homeUrl(null, true));
  139. }
  140. Admin::DEBUG && Admin::addDebugMessage('Admin login', $credentials);
  141. // Fire Login process.
  142. $event = $login->login(
  143. $credentials,
  144. ['admin' => true, 'twofa' => $config->get('plugins.admin.twofa_enabled', false)],
  145. ['authorize' => 'admin.login', 'return_event' => true]
  146. );
  147. $user = $event->getUser();
  148. Admin::DEBUG && Admin::addDebugMessage('Admin login: user', $user);
  149. $redirect = (string)$this->getRequest()->getUri();
  150. if ($user->authenticated) {
  151. $rateLimiter->resetRateLimit($ipKey, 'ip')->resetRateLimit($userKey);
  152. if ($user->authorized) {
  153. $event->defMessage('PLUGIN_ADMIN.LOGIN_LOGGED_IN', 'info');
  154. }
  155. $event->defRedirect($redirect);
  156. } elseif ($user->authorized) {
  157. $event->defMessage('PLUGIN_LOGIN.ACCESS_DENIED', 'error');
  158. } else {
  159. $event->defMessage('PLUGIN_LOGIN.LOGIN_FAILED', 'error');
  160. }
  161. $event->defRedirect($redirect);
  162. $message = $event->getMessage();
  163. if ($message) {
  164. $this->setMessage($this->translate($message), $event->getMessageType());
  165. }
  166. $this->form->reset();
  167. return $this->createRedirectResponse($event->getRedirect());
  168. }
  169. /**
  170. * Handle logout when user isn't fully logged in or clicks logout after the session has been expired.
  171. *
  172. * @return ResponseInterface
  173. */
  174. public function taskLogout(): ResponseInterface
  175. {
  176. // We do not need to check the nonce here as user session has been expired or user hasn't fully logged in (2FA).
  177. // Just be sure we terminate the current session.
  178. $login = $this->getLogin();
  179. $event = $login->logout(['admin' => true], ['return_event' => true]);
  180. $event->defMessage('PLUGIN_ADMIN.LOGGED_OUT', 'info');
  181. $message = $event->getMessage();
  182. if ($message) {
  183. $this->getSession()->setFlashCookieObject(Admin::TMP_COOKIE_NAME, ['message' => $this->translate($message), 'status' => $event->getMessageType()]);
  184. }
  185. return $this->createRedirectResponse('/');
  186. }
  187. /**
  188. * Handle 2FA verification.
  189. *
  190. * @return ResponseInterface
  191. */
  192. public function taskTwofa(): ResponseInterface
  193. {
  194. $user = $this->getUser();
  195. if (!$this->is2FA($user)) {
  196. Admin::DEBUG && Admin::addDebugMessage('Admin login: user is not logged in or does not have 2FA enabled', $user);
  197. // Task is visible only for users who have enabled 2FA.
  198. return $this->createRedirectResponse('/');
  199. }
  200. $login = $this->getLogin();
  201. $this->page = $this->createPage('login');
  202. $this->form = $this->getForm('login-twofa');
  203. try {
  204. $this->checkNonce();
  205. } catch (PageExpiredException $e) {
  206. $this->setMessage($this->translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), 'error');
  207. // Failed 2FA nonce check, logout and redirect.
  208. $login->logout(['admin' => true]);
  209. $this->form->reset();
  210. return $this->createRedirectResponse('/');
  211. }
  212. $post = $this->getPost();
  213. $data = $post['data'] ?? [];
  214. try {
  215. $twoFa = $login->twoFactorAuth();
  216. } catch (TwoFactorAuthException $e) {
  217. /** @var Debugger $debugger */
  218. $debugger = $this->grav['debugger'];
  219. $debugger->addException($e);
  220. $twoFa = null;
  221. }
  222. $code = $data['2fa_code'] ?? '';
  223. $secret = $user->twofa_secret ?? '';
  224. $twofa_valid = $twoFa->verifyCode($secret, $code);
  225. $yubikey_otp = $data['yubikey_otp'] ?? '';
  226. $yubikey_id = $user->yubikey_id ?? '';
  227. $yubikey_valid = $twoFa->verifyYubikeyOTP($yubikey_id, $yubikey_otp);
  228. $redirect = (string)$this->getRequest()->getUri();
  229. if (null === $twoFa || !$user->authenticated || (!$twofa_valid && !$yubikey_valid) ) {
  230. Admin::DEBUG && Admin::addDebugMessage('Admin login: 2FA check failed, log out!');
  231. // Failed 2FA auth, logout and redirect to the current page.
  232. $login->logout(['admin' => true]);
  233. $this->grav['session']->setFlashCookieObject(Admin::TMP_COOKIE_NAME, ['message' => $this->translate('PLUGIN_ADMIN.2FA_FAILED'), 'status' => 'error']);
  234. $this->form->reset();
  235. return $this->createRedirectResponse($redirect);
  236. }
  237. // Successful 2FA, authorize user and redirect.
  238. Grav::instance()['user']->authorized = true;
  239. Admin::DEBUG && Admin::addDebugMessage('Admin login: 2FA check succeeded, authorize user and redirect');
  240. $this->setMessage($this->translate('PLUGIN_ADMIN.LOGIN_LOGGED_IN'));
  241. $this->form->reset();
  242. return $this->createRedirectResponse($redirect);
  243. }
  244. /**
  245. * Handle the reset password action.
  246. *
  247. * @param string|null $username
  248. * @param string|null $token
  249. * @return ResponseInterface
  250. */
  251. public function taskReset(string $username = null, string $token = null): ResponseInterface
  252. {
  253. $this->page = $this->createPage('reset');
  254. $this->form = $this->getForm('admin-login-reset');
  255. try {
  256. $this->checkNonce();
  257. } catch (PageExpiredException $e) {
  258. $this->setMessage($this->translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), 'error');
  259. return $this->createDisplayResponse();
  260. }
  261. $post = $this->getPost();
  262. $data = $post['data'] ?? [];
  263. $users = $this->getAccounts();
  264. $username = $username ?? $data['username'] ?? null;
  265. $token = $token ?? $data['token'] ?? null;
  266. $user = $username ? $users->load($username) : null;
  267. $password = $data['password'];
  268. if ($user && $user->exists() && !empty($user->get('reset'))) {
  269. [$good_token, $expire] = explode('::', $user->get('reset'));
  270. if ($good_token === $token) {
  271. if (time() > $expire) {
  272. $this->setMessage($this->translate('PLUGIN_ADMIN.RESET_LINK_EXPIRED'), 'error');
  273. $this->form->reset();
  274. return $this->createRedirectResponse('/forgot');
  275. }
  276. // Set new password.
  277. $login = $this->getLogin();
  278. try {
  279. $login->validateField('password1', $password);
  280. } catch (\RuntimeException $e) {
  281. $this->setMessage($this->translate($e->getMessage()), 'error');
  282. return $this->createRedirectResponse("/reset/u/{$username}/{$token}");
  283. }
  284. $user->undef('hashed_password');
  285. $user->undef('reset');
  286. $user->update(['password' => $password]);
  287. $user->save();
  288. $this->form->reset();
  289. $this->setMessage($this->translate('PLUGIN_ADMIN.RESET_PASSWORD_RESET'));
  290. return $this->createRedirectResponse('/login');
  291. }
  292. Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed to reset password: Token %s is not good', $token));
  293. } else {
  294. Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed to reset password: User %s does not exist or has not requested reset', $username));
  295. }
  296. $this->setMessage($this->translate('PLUGIN_ADMIN.RESET_INVALID_LINK'), 'error');
  297. $this->form->reset();
  298. return $this->createRedirectResponse('/forgot');
  299. }
  300. /**
  301. * Handle the email password recovery procedure.
  302. *
  303. * Sends email to the user.
  304. *
  305. * @return ResponseInterface
  306. */
  307. public function taskForgot(): ResponseInterface
  308. {
  309. $this->page = $this->createPage('forgot');
  310. $this->form = $this->getForm('admin-login-forgot');
  311. try {
  312. $this->checkNonce();
  313. } catch (PageExpiredException $e) {
  314. $this->setMessage($this->translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), 'error');
  315. return $this->createDisplayResponse();
  316. }
  317. $post = $this->getPost();
  318. $data = $post['data'] ?? [];
  319. $login = $this->getLogin();
  320. $users = $this->getAccounts();
  321. $email = $this->getEmail();
  322. $current = (string)$this->getRequest()->getUri();
  323. $search = isset($data['username']) ? strip_tags($data['username']) : '';
  324. $user = !empty($search) ? $users->load($search) : null;
  325. $username = $user->username ?? null;
  326. $to = $user->email ?? null;
  327. // Only send email to users which are enabled and have an email address.
  328. if (null === $user || $user->state !== 'enabled' || !$to) {
  329. Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed sending email: %s <%s> was not found or is blocked', $search, $to ?? 'N/A'));
  330. $this->form->reset();
  331. $this->setMessage($this->translate('PLUGIN_ADMIN.FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL'));
  332. return $this->createRedirectResponse($current);
  333. }
  334. $config = $this->getConfig();
  335. // Check rate limit for the user.
  336. $rateLimiter = $login->getRateLimiter('pw_resets');
  337. $rateLimiter->registerRateLimitedAction($username);
  338. if ($rateLimiter->isRateLimited($username)) {
  339. Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed sending email: user %s <%s> is rate limited', $search, $to));
  340. $this->form->reset();
  341. $interval = $config->get('plugins.login.max_pw_resets_interval', 2);
  342. $this->setMessage($this->translate('PLUGIN_LOGIN.FORGOT_CANNOT_RESET_IT_IS_BLOCKED', $to, $interval), 'error');
  343. return $this->createRedirectResponse($current);
  344. }
  345. $token = md5(uniqid(mt_rand(), true));
  346. $expire = time() + 3600; // 1 hour
  347. $user->set('reset', $token . '::' . $expire);
  348. $user->save();
  349. $from = $config->get('plugins.email.from');
  350. if (empty($from)) {
  351. Admin::DEBUG && Admin::addDebugMessage('Failed sending email: from address is not configured in email plugin');
  352. $this->form->reset();
  353. $this->setMessage($this->translate('PLUGIN_ADMIN.FORGOT_EMAIL_NOT_CONFIGURED'), 'error');
  354. return $this->createRedirectResponse($current);
  355. }
  356. // Do not trust username from the request.
  357. $fullname = $user->fullname ?: $username;
  358. $author = $config->get('site.author.name', '');
  359. $sitename = $config->get('site.title', 'Website');
  360. $reset_link = $this->getAbsoluteAdminUrl("/reset/u/{$username}/{$token}");
  361. // For testing only!
  362. //Admin::DEBUG && Admin::addDebugMessage(sprintf('Reset link: %s', $reset_link));
  363. $subject = $this->translate('PLUGIN_ADMIN.FORGOT_EMAIL_SUBJECT', $sitename);
  364. $content = $this->translate('PLUGIN_ADMIN.FORGOT_EMAIL_BODY', $fullname, $reset_link, $author, $sitename);
  365. $this->grav['twig']->init();
  366. $body = $this->grav['twig']->processTemplate('email/base.html.twig', ['content' => $content]);
  367. try {
  368. $message = $email->message($subject, $body, 'text/html')->setFrom($from)->setTo($to);
  369. $sent = $email->send($message);
  370. if ($sent < 1) {
  371. throw new \RuntimeException('Sending email failed');
  372. }
  373. $this->setMessage($this->translate('PLUGIN_ADMIN.FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL'));
  374. } catch (\Exception $e) {
  375. $rateLimiter->resetRateLimit($username);
  376. /** @var Debugger $debugger */
  377. $debugger = $this->grav['debugger'];
  378. $debugger->addException($e);
  379. $this->form->reset();
  380. $this->setMessage($this->translate('PLUGIN_ADMIN.FORGOT_FAILED_TO_EMAIL'), 'error');
  381. return $this->createRedirectResponse('/forgot');
  382. }
  383. $this->form->reset();
  384. return $this->createRedirectResponse('/login');
  385. }
  386. /**
  387. * @return ResponseInterface
  388. */
  389. public function taskRegister(): ResponseInterface
  390. {
  391. $this->page = $this->createPage('register');
  392. $this->form = $form = $this->getForm('admin-login-register');
  393. try {
  394. $this->checkNonce();
  395. } catch (PageExpiredException $e) {
  396. $this->setMessage($this->translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), 'error');
  397. return $this->createDisplayResponse();
  398. }
  399. // Note: Calls $this->doRegistration() to perform the user registration.
  400. $form->handleRequest($this->getRequest());
  401. $error = $form->getError();
  402. $errors = $form->getErrors();
  403. if ($error || $errors) {
  404. foreach ($errors as $field => $list) {
  405. foreach ((array)$list as $message) {
  406. if ($message !== $error) {
  407. $this->setMessage($message, 'error');
  408. }
  409. }
  410. }
  411. return $this->createDisplayResponse();
  412. }
  413. $this->setMessage($this->translate('PLUGIN_ADMIN.LOGIN_LOGGED_IN'));
  414. return $this->createRedirectResponse('/');
  415. }
  416. /**
  417. * @param UserInterface $user
  418. * @return bool
  419. */
  420. protected function is2FA(UserInterface $user): bool
  421. {
  422. return $user && $user->authenticated && !$user->authorized && $user->get('twofa_enabled');
  423. }
  424. /**
  425. * @param string $name
  426. * @return callable
  427. */
  428. protected function getFormSubmitMethod(string $name): callable
  429. {
  430. switch ($name) {
  431. case 'login':
  432. case 'login-twofa':
  433. case 'admin-login-forgot':
  434. case 'admin-login-reset':
  435. return static function(array $data, array $files) {};
  436. case 'admin-login-register':
  437. return function(array $data, array $files) {
  438. $this->doRegistration($data, $files);
  439. };
  440. }
  441. throw new \RuntimeException('Unknown form');
  442. }
  443. /**
  444. * Called by registration form when calling handleRequest().
  445. *
  446. * @param array $data
  447. * @param array $files
  448. */
  449. private function doRegistration(array $data, array $files): void
  450. {
  451. if (Admin::doAnyUsersExist()) {
  452. throw new \RuntimeException('A user account already exists, please create an admin account manually.', 400);
  453. }
  454. $login = $this->getLogin();
  455. if (!$login) {
  456. throw new \RuntimeException($this->grav['language']->translate('PLUGIN_LOGIN.PLUGIN_LOGIN_DISABLED', 500));
  457. }
  458. $data['title'] = $data['title'] ?? 'Administrator';
  459. // Do not allow form to set the following fields (make super user):
  460. $data['state'] = 'enabled';
  461. $data['access'] = ['admin' => ['login' => true, 'super' => true], 'site' => ['login' => true]];
  462. unset($data['groups']);
  463. // Create user.
  464. $user = $login->register($data, $files);
  465. // Log in the new super admin user.
  466. unset($this->grav['user']);
  467. $this->grav['user'] = $user;
  468. $this->grav['session']->user = $user;
  469. $user->authenticated = true;
  470. $user->authorized = $user->authorize('admin.login') ?? false;
  471. }
  472. /**
  473. * @return Login
  474. */
  475. private function getLogin(): Login
  476. {
  477. return $this->grav['login'];
  478. }
  479. /**
  480. * @return Email
  481. */
  482. private function getEmail(): Email
  483. {
  484. return $this->grav['Email'];
  485. }
  486. /**
  487. * @return UserCollectionInterface
  488. */
  489. private function getAccounts(): UserCollectionInterface
  490. {
  491. return $this->grav['accounts'];
  492. }
  493. }