page = $this->createPage('login'); $user = $this->getUser(); if ($this->is2FA($user)) { $this->form = $this->getForm('login-twofa', ['reset' => true]); } else { $this->form = $this->getForm('login', ['reset' => true]); } return $this->createDisplayResponse(); } /** * @return ResponseInterface */ public function displayForgot(): ResponseInterface { $this->page = $this->createPage('forgot'); $this->form = $this->getForm('admin-login-forgot', ['reset' => true]); return $this->createDisplayResponse(); } /** * Handle the reset password action. * * @param string|null $username * @param string|null $token * @return ResponseInterface */ public function displayReset(string $username = null, string $token = null): ResponseInterface { if ('' === (string)$username || '' === (string)$token) { $this->setMessage($this->translate('PLUGIN_ADMIN.RESET_INVALID_LINK'), 'error'); return $this->createRedirectResponse('/forgot'); } $this->page = $this->createPage('reset'); $this->form = $this->getForm('admin-login-reset', ['reset' => true]); $this->form->setData('username', $username); $this->form->setData('token', $token); $this->setMessage($this->translate('PLUGIN_ADMIN.RESET_NEW_PASSWORD')); return $this->createDisplayResponse(); } /** * @return ResponseInterface */ public function displayRegister(): ResponseInterface { $route = $this->getRequest()->getAttribute('admin')['route'] ?? ''; if ('' !== $route) { return $this->createRedirectResponse('/'); } $this->page = $this->createPage('register'); $this->form = $this->getForm('admin-login-register'); return $this->createDisplayResponse(); } /** * @return ResponseInterface */ public function displayUnauthorized(): ResponseInterface { $uri = (string)$this->getRequest()->getUri(); $ext = pathinfo($uri, PATHINFO_EXTENSION); $accept = $this->getAccept(['application/json', 'text/html']); if ($ext === 'json' || $accept === 'application/json') { return $this->createErrorResponse(new RequestException($this->getRequest(), $this->translate('PLUGIN_ADMIN.LOGGED_OUT'), 401)); } $this->setMessage($this->translate('PLUGIN_ADMIN.LOGGED_OUT'), 'warning'); return $this->createRedirectResponse('/'); } /** * Handle login. * * @return ResponseInterface */ public function taskLogin(): ResponseInterface { $this->page = $this->createPage('login'); $this->form = $this->getActiveForm() ?? $this->getForm('login'); try { $this->checkNonce(); } catch (PageExpiredException $e) { $this->setMessage($this->translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), 'error'); return $this->createDisplayResponse(); } $post = $this->getPost(); $credentials = (array)($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 or clicks logout after the session has been expired. * * @return ResponseInterface */ public function taskLogout(): ResponseInterface { // We do not need to check the nonce here as user session has been expired or user hasn't fully logged in (2FA). // Just be sure we terminate the current session. $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('/'); } $login = $this->getLogin(); $this->page = $this->createPage('login'); $this->form = $this->getForm('login-twofa'); try { $this->checkNonce(); } catch (PageExpiredException $e) { $this->setMessage($this->translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), 'error'); // Failed 2FA nonce check, logout and redirect. $login->logout(['admin' => true]); $this->form->reset(); return $this->createRedirectResponse('/'); } $post = $this->getPost(); $data = $post['data'] ?? []; try { $twoFa = $login->twoFactorAuth(); } catch (TwoFactorAuthException $e) { /** @var Debugger $debugger */ $debugger = $this->grav['debugger']; $debugger->addException($e); $twoFa = null; } $code = $data['2fa_code'] ?? ''; $secret = $user->twofa_secret ?? ''; $twofa_valid = $twoFa->verifyCode($secret, $code); $yubikey_otp = $data['yubikey_otp'] ?? ''; $yubikey_id = $user->yubikey_id ?? ''; $yubikey_valid = $twoFa->verifyYubikeyOTP($yubikey_id, $yubikey_otp); $redirect = (string)$this->getRequest()->getUri(); if (null === $twoFa || !$user->authenticated || (!$twofa_valid && !$yubikey_valid) ) { 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. Grav::instance()['user']->authorized = true; Admin::DEBUG && Admin::addDebugMessage('Admin login: 2FA check succeeded, authorize user and redirect'); $this->setMessage($this->translate('PLUGIN_ADMIN.LOGIN_LOGGED_IN')); $this->form->reset(); return $this->createRedirectResponse($redirect); } /** * Handle the reset password action. * * @param string|null $username * @param string|null $token * @return ResponseInterface */ public function taskReset(string $username = null, string $token = null): ResponseInterface { $this->page = $this->createPage('reset'); $this->form = $this->getForm('admin-login-reset'); try { $this->checkNonce(); } catch (PageExpiredException $e) { $this->setMessage($this->translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), 'error'); return $this->createDisplayResponse(); } $post = $this->getPost(); $data = $post['data'] ?? []; $users = $this->getAccounts(); $username = $username ?? $data['username'] ?? null; $token = $token ?? $data['token'] ?? null; $user = $username ? $users->load($username) : null; $password = $data['password']; if ($user && $user->exists() && !empty($user->get('reset'))) { [$good_token, $expire] = explode('::', $user->get('reset')); if ($good_token === $token) { if (time() > $expire) { $this->setMessage($this->translate('PLUGIN_ADMIN.RESET_LINK_EXPIRED'), 'error'); $this->form->reset(); return $this->createRedirectResponse('/forgot'); } // Set new password. $login = $this->getLogin(); try { $login->validateField('password1', $password); } catch (\RuntimeException $e) { $this->setMessage($this->translate($e->getMessage()), 'error'); return $this->createRedirectResponse("/reset/u/{$username}/{$token}"); } $user->undef('hashed_password'); $user->undef('reset'); $user->update(['password' => $password]); $user->save(); $this->form->reset(); $this->setMessage($this->translate('PLUGIN_ADMIN.RESET_PASSWORD_RESET')); return $this->createRedirectResponse('/login'); } Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed to reset password: Token %s is not good', $token)); } else { Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed to reset password: User %s does not exist or has not requested reset', $username)); } $this->setMessage($this->translate('PLUGIN_ADMIN.RESET_INVALID_LINK'), 'error'); $this->form->reset(); return $this->createRedirectResponse('/forgot'); } /** * Handle the email password recovery procedure. * * Sends email to the user. * * @return ResponseInterface */ public function taskForgot(): ResponseInterface { $this->page = $this->createPage('forgot'); $this->form = $this->getForm('admin-login-forgot'); try { $this->checkNonce(); } catch (PageExpiredException $e) { $this->setMessage($this->translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), 'error'); return $this->createDisplayResponse(); } $post = $this->getPost(); $data = $post['data'] ?? []; $login = $this->getLogin(); $users = $this->getAccounts(); $email = $this->getEmail(); $current = (string)$this->getRequest()->getUri(); $search = isset($data['username']) ? strip_tags($data['username']) : ''; $user = !empty($search) ? $users->load($search) : null; $username = $user->username ?? null; $to = $user->email ?? null; // Only send email to users which are enabled and have an email address. if (null === $user || $user->state !== 'enabled' || !$to) { Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed sending email: %s <%s> was not found or is blocked', $search, $to ?? 'N/A')); $this->form->reset(); $this->setMessage($this->translate('PLUGIN_ADMIN.FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL')); return $this->createRedirectResponse($current); } $config = $this->getConfig(); // Check rate limit for the user. $rateLimiter = $login->getRateLimiter('pw_resets'); $rateLimiter->registerRateLimitedAction($username); if ($rateLimiter->isRateLimited($username)) { Admin::DEBUG && Admin::addDebugMessage(sprintf('Failed sending email: user %s <%s> is rate limited', $search, $to)); $this->form->reset(); $interval = $config->get('plugins.login.max_pw_resets_interval', 2); $this->setMessage($this->translate('PLUGIN_LOGIN.FORGOT_CANNOT_RESET_IT_IS_BLOCKED', $to, $interval), 'error'); return $this->createRedirectResponse($current); } $token = md5(uniqid(mt_rand(), true)); $expire = time() + 3600; // 1 hour $user->set('reset', $token . '::' . $expire); $user->save(); $from = $config->get('plugins.email.from'); if (empty($from)) { Admin::DEBUG && Admin::addDebugMessage('Failed sending email: from address is not configured in email plugin'); $this->form->reset(); $this->setMessage($this->translate('PLUGIN_ADMIN.FORGOT_EMAIL_NOT_CONFIGURED'), 'error'); return $this->createRedirectResponse($current); } // Do not trust username from the request. $fullname = $user->fullname ?: $username; $author = $config->get('site.author.name', ''); $sitename = $config->get('site.title', 'Website'); $reset_link = $this->getAbsoluteAdminUrl("/reset/u/{$username}/{$token}"); // For testing only! //Admin::DEBUG && Admin::addDebugMessage(sprintf('Reset link: %s', $reset_link)); $subject = $this->translate('PLUGIN_ADMIN.FORGOT_EMAIL_SUBJECT', $sitename); $content = $this->translate('PLUGIN_ADMIN.FORGOT_EMAIL_BODY', $fullname, $reset_link, $author, $sitename); $this->grav['twig']->init(); $body = $this->grav['twig']->processTemplate('email/base.html.twig', ['content' => $content]); try { $message = $email->message($subject, $body, 'text/html')->setFrom($from)->setTo($to); $sent = $email->send($message); if ($sent < 1) { throw new \RuntimeException('Sending email failed'); } // For testing only! //Admin::DEBUG && Admin::addDebugMessage(sprintf('Email sent to %s', $to), $body); $this->setMessage($this->translate('PLUGIN_ADMIN.FORGOT_INSTRUCTIONS_SENT_VIA_EMAIL')); } catch (\RuntimeException|\Swift_SwiftException $e) { $rateLimiter->resetRateLimit($username); /** @var Debugger $debugger */ $debugger = $this->grav['debugger']; $debugger->addException($e); $this->form->reset(); $this->setMessage($this->translate('PLUGIN_ADMIN.FORGOT_FAILED_TO_EMAIL'), 'error'); return $this->createRedirectResponse('/forgot'); } $this->form->reset(); return $this->createRedirectResponse('/login'); } /** * @return ResponseInterface */ public function taskRegister(): ResponseInterface { $this->page = $this->createPage('register'); $this->form = $form = $this->getForm('admin-login-register'); try { $this->checkNonce(); } catch (PageExpiredException $e) { $this->setMessage($this->translate('PLUGIN_ADMIN.INVALID_SECURITY_TOKEN'), 'error'); return $this->createDisplayResponse(); } // Note: Calls $this->doRegistration() to perform the user registration. $form->handleRequest($this->getRequest()); $error = $form->getError(); $errors = $form->getErrors(); if ($error || $errors) { foreach ($errors as $field => $list) { foreach ((array)$list as $message) { if ($message !== $error) { $this->setMessage($message, 'error'); } } } return $this->createDisplayResponse(); } $this->setMessage($this->translate('PLUGIN_ADMIN.LOGIN_LOGGED_IN')); return $this->createRedirectResponse('/'); } /** * @param UserInterface $user * @return bool */ protected function is2FA(UserInterface $user): bool { return $user && $user->authenticated && !$user->authorized && $user->get('twofa_enabled'); } /** * @param string $name * @return callable */ protected function getFormSubmitMethod(string $name): callable { switch ($name) { case 'login': case 'login-twofa': case 'admin-login-forgot': case 'admin-login-reset': return static function(array $data, array $files) {}; case 'admin-login-register': return function(array $data, array $files) { $this->doRegistration($data, $files); }; } throw new \RuntimeException('Unknown form'); } /** * Called by registration form when calling handleRequest(). * * @param array $data * @param array $files */ private function doRegistration(array $data, array $files): void { if (Admin::doAnyUsersExist()) { throw new \RuntimeException('A user account already exists, please create an admin account manually.', 400); } $login = $this->getLogin(); if (!$login) { throw new \RuntimeException($this->grav['language']->translate('PLUGIN_LOGIN.PLUGIN_LOGIN_DISABLED', 500)); } $data['title'] = $data['title'] ?? 'Administrator'; // Do not allow form to set the following fields (make super user): $data['state'] = 'enabled'; $data['access'] = ['admin' => ['login' => true, 'super' => true], 'site' => ['login' => true]]; unset($data['groups']); // Create user. $user = $login->register($data, $files); // Log in the new super admin user. unset($this->grav['user']); $this->grav['user'] = $user; $this->grav['session']->user = $user; $user->authenticated = true; $user->authorized = $user->authorize('admin.login') ?? false; } /** * @return Login */ private function getLogin(): Login { return $this->grav['login']; } /** * @return Email */ private function getEmail(): Email { return $this->grav['Email']; } /** * @return UserCollectionInterface */ private function getAccounts(): UserCollectionInterface { return $this->grav['accounts']; } }