Login.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. <?php
  2. /**
  3. * @package Grav\Plugin\Login
  4. *
  5. * @copyright Copyright (C) 2014 - 2017 RocketTheme, LLC. All rights reserved.
  6. * @license MIT License; see LICENSE file for details.
  7. */
  8. namespace Grav\Plugin\Login;
  9. use Birke\Rememberme\Cookie;
  10. use Grav\Common\Config\Config;
  11. use Grav\Common\Data\Data;
  12. use Grav\Common\Grav;
  13. use Grav\Common\File\CompiledYamlFile;
  14. use Grav\Common\Language\Language;
  15. use Grav\Common\Page\Page;
  16. use Grav\Common\Session;
  17. use Grav\Common\User\User;
  18. use Grav\Common\Uri;
  19. use Grav\Plugin\Email\Utils as EmailUtils;
  20. use Grav\Plugin\Login\Events\UserLoginEvent;
  21. use Grav\Plugin\Login\RememberMe\RememberMe;
  22. use Grav\Plugin\Login\RememberMe\TokenStorage;
  23. use Grav\Plugin\Login\TwoFactorAuth\TwoFactorAuth;
  24. /**
  25. * Class Login
  26. * @package Grav\Plugin
  27. */
  28. class Login
  29. {
  30. /** @var Grav */
  31. protected $grav;
  32. /** @var Config */
  33. protected $config;
  34. /** @var Language $language */
  35. protected $language;
  36. /** @var Session */
  37. protected $session;
  38. /** @var Uri */
  39. protected $uri;
  40. /** @var RememberMe */
  41. protected $rememberMe;
  42. /** @var TwoFactorAuth */
  43. protected $twoFa;
  44. /** @var RateLimiter[] */
  45. protected $rateLimiters = [];
  46. /** @var array */
  47. protected $provider_login_templates = [];
  48. /**
  49. * Login constructor.
  50. *
  51. * @param Grav $grav
  52. */
  53. public function __construct(Grav $grav)
  54. {
  55. $this->grav = $grav;
  56. $this->config = $this->grav['config'];
  57. $this->language = $this->grav['language'];
  58. $this->session = $this->grav['session'];
  59. $this->uri = $this->grav['uri'];
  60. }
  61. /**
  62. * Login user.
  63. *
  64. * @param array $credentials Login credentials, eg: ['username' => '', 'password' => '']
  65. * @param array $options Login options, eg: ['remember_me' => true]
  66. * @param array $extra Example: ['authorize' => 'site.login', 'user' => null], undefined variables get set.
  67. * @return User|UserLoginEvent Returns event if $extra['return_event'] is true.
  68. */
  69. public function login(array $credentials, array $options = [], array $extra = [])
  70. {
  71. $grav = Grav::instance();
  72. $eventOptions = [
  73. 'credentials' => $credentials,
  74. 'options' => $options
  75. ] + $extra;
  76. // Attempt to authenticate the user.
  77. $event = new UserLoginEvent($eventOptions);
  78. $grav->fireEvent('onUserLoginAuthenticate', $event);
  79. if ($event->isSuccess()) {
  80. // Make sure that event didn't mess up with the user authorization.
  81. $user = $event->getUser();
  82. $user->authenticated = true;
  83. $user->authorized = false;
  84. // Allow plugins to prevent login after successful authentication.
  85. $event = new UserLoginEvent($event->toArray());
  86. $grav->fireEvent('onUserLoginAuthorize', $event);
  87. }
  88. if ($event->isSuccess()) {
  89. // User has been logged in, let plugins know.
  90. $event = new UserLoginEvent($event->toArray());
  91. $grav->fireEvent('onUserLogin', $event);
  92. // Make sure that event didn't mess up with the user authorization.
  93. $user = $event->getUser();
  94. $user->authenticated = true;
  95. $user->authorized = !$event->isDelayed();
  96. } else {
  97. // Allow plugins to log errors or do other tasks on failure.
  98. $event = new UserLoginEvent($event->toArray());
  99. $grav->fireEvent('onUserLoginFailure', $event);
  100. // Make sure that event didn't mess up with the user authorization.
  101. $user = $event->getUser();
  102. $user->authenticated = false;
  103. $user->authorized = false;
  104. }
  105. $user = $event->getUser();
  106. $user->def('language', 'en');
  107. return !empty($event['return_event']) ? $event : $user;
  108. }
  109. /**
  110. * Logout user.
  111. *
  112. * @param array $options
  113. * @param array|User $extra Array of: ['user' => $user, ...] or User object (deprecated).
  114. * @return User|UserLoginEvent Returns event if $extra['return_event'] is true.
  115. */
  116. public function logout(array $options = [], $extra = [])
  117. {
  118. $grav = Grav::instance();
  119. if ($extra instanceof User) {
  120. $extra = ['user' => $extra];
  121. } elseif (isset($extra['user'])) {
  122. $extra['user'] = $grav['user'];
  123. }
  124. $eventOptions = [
  125. 'options' => $options
  126. ] + $extra;
  127. $event = new UserLoginEvent($eventOptions);
  128. // Logout the user.
  129. $grav->fireEvent('onUserLogout', $event);
  130. $user = $event->getUser();
  131. $user->authenticated = false;
  132. $user->authorized = false;
  133. return !empty($event['return_event']) ? $event : $user;
  134. }
  135. /**
  136. * Authenticate user.
  137. *
  138. * @param array $credentials Form fields.
  139. * @param array $options
  140. *
  141. * @return bool
  142. */
  143. public function authenticate($credentials, $options = ['remember_me' => true])
  144. {
  145. $event = $this->login($credentials, $options, ['return_event' => true]);
  146. $user = $event['user'];
  147. $redirect = $event->getRedirect();
  148. $message = $event->getMessage();
  149. $messageType = $event->getMessageType();
  150. if ($user->authenticated && $user->authorized) {
  151. if (!$message) {
  152. $message = 'PLUGIN_LOGIN.LOGIN_SUCCESSFUL';
  153. $messageType = 'info';
  154. }
  155. if (!$redirect) {
  156. $redirect = $this->uri->route();
  157. }
  158. }
  159. if ($message) {
  160. $this->grav['messages']->add($this->language->translate($message, [$user->language]), $messageType);
  161. }
  162. if ($redirect) {
  163. $this->grav->redirect($redirect, $event->getRedirectCode());
  164. }
  165. return $user->authenticated && $user->authorized;
  166. }
  167. /**
  168. * Create a new user file
  169. *
  170. * @param array $data
  171. *
  172. * @return User
  173. */
  174. public function register($data)
  175. {
  176. if (!isset($data['groups'])) {
  177. //Add new user ACL settings
  178. $groups = (array) $this->config->get('plugins.login.user_registration.groups', []);
  179. if (count($groups) > 0) {
  180. $data['groups'] = $groups;
  181. }
  182. }
  183. if (!isset($data['access'])) {
  184. $access = (array) $this->config->get('plugins.login.user_registration.access.site', []);
  185. if (count($access) > 0) {
  186. $data['access']['site'] = $access;
  187. }
  188. }
  189. $username = $this->validateField('username', $data['username']);
  190. if (User::find($username, ['username'])) {
  191. throw new \RuntimeException('Username "' . $username . '" already exists, please pick another username');
  192. }
  193. $file = CompiledYamlFile::instance($this->grav['locator']->findResource('account://' . $username . YAML_EXT,
  194. true, true));
  195. // Create user object and save it
  196. $user = new User($data);
  197. $user->file($file);
  198. $user->save();
  199. return $user;
  200. }
  201. /**
  202. * @param string $type
  203. * @param mixed $value
  204. * @param string $extra
  205. *
  206. * @return string
  207. */
  208. public function validateField($type, $value, $extra = '')
  209. {
  210. switch ($type) {
  211. case 'user':
  212. case 'username':
  213. /** @var Config $config */
  214. $config = Grav::instance()['config'];
  215. $username_regex = '/' . $config->get('system.username_regex') . '/';
  216. if (!is_string($value) || !preg_match($username_regex, $value)) {
  217. throw new \RuntimeException('Username should be between 3 and 16 characters, including lowercase letters, numbers, underscores, and hyphens. Uppercase letters, spaces, and special characters are not allowed');
  218. }
  219. break;
  220. case 'password1':
  221. /** @var Config $config */
  222. $config = Grav::instance()['config'];
  223. $pwd_regex = '/' . $config->get('system.pwd_regex') . '/';
  224. if (!is_string($value) || !preg_match($pwd_regex, $value)) {
  225. throw new \RuntimeException('Password must contain at least one number and one uppercase and lowercase letter, and at least 8 or more characters');
  226. }
  227. break;
  228. case 'password2':
  229. if (!is_string($value) || strcmp($value, $extra)) {
  230. throw new \RuntimeException('Passwords did not match.');
  231. }
  232. break;
  233. case 'email':
  234. if (!is_string($value) || !filter_var($value, FILTER_VALIDATE_EMAIL)) {
  235. throw new \RuntimeException('Not a valid email address');
  236. }
  237. break;
  238. case 'permissions':
  239. if (!is_string($value) || !in_array($value, ['a', 's', 'b'])) {
  240. throw new \RuntimeException('Permissions ' . $value . ' are invalid.');
  241. }
  242. break;
  243. case 'fullname':
  244. if (!is_string($value) || trim($value) === '') {
  245. throw new \RuntimeException('Fullname cannot be empty');
  246. }
  247. break;
  248. case 'state':
  249. if ($value !== 'enabled' && $value !== 'disabled') {
  250. throw new \RuntimeException('State is not valid');
  251. }
  252. break;
  253. }
  254. return $value;
  255. }
  256. /**
  257. * Handle the email to notify the user account creation to the site admin.
  258. *
  259. * @param User $user
  260. *
  261. * @return bool True if the action was performed.
  262. * @throws \RuntimeException
  263. */
  264. public function sendNotificationEmail(User $user)
  265. {
  266. if (empty($user->email)) {
  267. throw new \RuntimeException($this->language->translate('PLUGIN_LOGIN.USER_NEEDS_EMAIL_FIELD'));
  268. }
  269. $site_name = $this->config->get('site.title', 'Website');
  270. $subject = $this->language->translate(['PLUGIN_LOGIN.NOTIFICATION_EMAIL_SUBJECT', $site_name]);
  271. $content = $this->language->translate([
  272. 'PLUGIN_LOGIN.NOTIFICATION_EMAIL_BODY',
  273. $site_name,
  274. $user->username,
  275. $user->email,
  276. $this->grav['base_url_absolute'],
  277. ]);
  278. $to = $this->config->get('plugins.email.from');
  279. if (empty($to)) {
  280. throw new \RuntimeException($this->language->translate('PLUGIN_LOGIN.EMAIL_NOT_CONFIGURED'));
  281. }
  282. $sent = EmailUtils::sendEmail($subject, $content, $to);
  283. if ($sent < 1) {
  284. throw new \RuntimeException($this->language->translate('PLUGIN_LOGIN.EMAIL_SENDING_FAILURE'));
  285. }
  286. return true;
  287. }
  288. /**
  289. * Handle the email to welcome the new user
  290. *
  291. * @param User $user
  292. *
  293. * @return bool True if the action was performed.
  294. * @throws \RuntimeException
  295. */
  296. public function sendWelcomeEmail(User $user)
  297. {
  298. if (empty($user->email)) {
  299. throw new \RuntimeException($this->language->translate('PLUGIN_LOGIN.USER_NEEDS_EMAIL_FIELD'));
  300. }
  301. $site_name = $this->config->get('site.title', 'Website');
  302. $author = $this->grav['config']->get('site.author.name', '');
  303. $fullname = $user->fullname ?: $user->username;
  304. $subject = $this->language->translate(['PLUGIN_LOGIN.WELCOME_EMAIL_SUBJECT', $site_name]);
  305. $content = $this->language->translate(['PLUGIN_LOGIN.WELCOME_EMAIL_BODY',
  306. $fullname,
  307. $this->grav['base_url_absolute'],
  308. $site_name,
  309. $author
  310. ]);
  311. $to = $user->email;
  312. $sent = EmailUtils::sendEmail($subject, $content, $to);
  313. if ($sent < 1) {
  314. throw new \RuntimeException($this->language->translate('PLUGIN_LOGIN.EMAIL_SENDING_FAILURE'));
  315. }
  316. return true;
  317. }
  318. /**
  319. * Handle the email to activate the user account.
  320. *
  321. * @param User $user
  322. *
  323. * @return bool True if the action was performed.
  324. * @throws \RuntimeException
  325. */
  326. public function sendActivationEmail(User $user)
  327. {
  328. if (empty($user->email)) {
  329. throw new \RuntimeException($this->language->translate('PLUGIN_LOGIN.USER_NEEDS_EMAIL_FIELD'));
  330. }
  331. $token = md5(uniqid(mt_rand(), true));
  332. $expire = time() + 604800; // next week
  333. $user->activation_token = $token . '::' . $expire;
  334. $user->save();
  335. $param_sep = $this->config->get('system.param_sep', ':');
  336. $activation_link = $this->grav['base_url_absolute'] . $this->config->get('plugins.login.route_activate') . '/token' . $param_sep . $token . '/username' . $param_sep . $user->username;
  337. $site_name = $this->config->get('site.title', 'Website');
  338. $author = $this->grav['config']->get('site.author.name', '');
  339. $fullname = $user->fullname ?: $user->username;
  340. $subject = $this->language->translate(['PLUGIN_LOGIN.ACTIVATION_EMAIL_SUBJECT', $site_name]);
  341. $content = $this->language->translate(['PLUGIN_LOGIN.ACTIVATION_EMAIL_BODY',
  342. $fullname,
  343. $activation_link,
  344. $site_name,
  345. $author
  346. ]);
  347. $to = $user->email;
  348. $sent = EmailUtils::sendEmail($subject, $content, $to);
  349. if ($sent < 1) {
  350. throw new \RuntimeException($this->language->translate('PLUGIN_LOGIN.EMAIL_SENDING_FAILURE'));
  351. }
  352. return true;
  353. }
  354. /**
  355. * Gets and sets the RememberMe class
  356. *
  357. * @param mixed $var A rememberMe instance to set
  358. *
  359. * @return RememberMe Returns the current rememberMe instance
  360. * @throws \InvalidArgumentException
  361. */
  362. public function rememberMe($var = null)
  363. {
  364. if ($var !== null) {
  365. $this->rememberMe = $var;
  366. }
  367. if (!$this->rememberMe) {
  368. /** @var Config $config */
  369. $config = $this->grav['config'];
  370. // Setup storage for RememberMe cookies
  371. $storage = new TokenStorage;
  372. $this->rememberMe = new RememberMe($storage);
  373. $this->rememberMe->setCookieName($config->get('plugins.login.rememberme.name'));
  374. $this->rememberMe->setExpireTime($config->get('plugins.login.rememberme.timeout'));
  375. // Hardening cookies with user-agent and random salt or
  376. // fallback to use system based cache key
  377. $server_agent = isset($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : 'unknown';
  378. $data = $server_agent . $config->get('security.salt', $this->grav['cache']->getKey());
  379. $this->rememberMe->setSalt(hash('sha512', $data));
  380. // Set cookie with correct base path of Grav install
  381. $cookie = new Cookie;
  382. $cookie->setPath($this->grav['base_url_relative'] ?: '/');
  383. $this->rememberMe->setCookie($cookie);
  384. }
  385. return $this->rememberMe;
  386. }
  387. /**
  388. * Gets and sets the TwoFactorAuth object
  389. *
  390. * @param TwoFactorAuth $var
  391. * @return TwoFactorAuth
  392. * @throws \RobThree\Auth\TwoFactorAuthException
  393. */
  394. public function twoFactorAuth($var = null)
  395. {
  396. if ($var !== null) {
  397. $this->twoFa = $var;
  398. }
  399. if (!$this->twoFa) {
  400. $this->twoFa = new TwoFactorAuth;
  401. }
  402. return $this->twoFa;
  403. }
  404. /**
  405. * @param string $context
  406. * @param int $maxCount
  407. * @param int $interval
  408. * @return RateLimiter
  409. */
  410. public function getRateLimiter($context, $maxCount = null, $interval = null)
  411. {
  412. if (!isset($this->rateLimiters[$context])) {
  413. switch ($context) {
  414. case 'login_attempts':
  415. $maxCount = $this->grav['config']->get('plugins.login.max_login_count', 5);
  416. $interval = $this->grav['config']->get('plugins.login.max_login_interval', 10);
  417. break;
  418. case 'pw_resets':
  419. $maxCount = $this->grav['config']->get('plugins.login.max_pw_resets_count', 0);
  420. $interval = $this->grav['config']->get('plugins.login.max_pw_resets_interval', 2);
  421. break;
  422. }
  423. $this->rateLimiters[$context] = new RateLimiter($context, $maxCount, $interval);
  424. }
  425. return $this->rateLimiters[$context];
  426. }
  427. /**
  428. * @param User $user
  429. * @param Page $page
  430. * @param Data|null $config
  431. * @return bool
  432. */
  433. public function isUserAuthorizedForPage(User $user, Page $page, $config = null)
  434. {
  435. $header = $page->header();
  436. $rules = isset($header->access) ? (array)$header->access : [];
  437. if ($config !== null && $config->get('parent_acl')) {
  438. // If page has no ACL rules, use its parent's rules
  439. if (!$rules) {
  440. $parent = $page->parent();
  441. while (!$rules and $parent) {
  442. $header = $parent->header();
  443. $rules = isset($header->access) ? (array)$header->access : [];
  444. $parent = $parent->parent();
  445. }
  446. }
  447. }
  448. // Continue to the page if it has no ACL rules.
  449. if (!$rules) {
  450. return true;
  451. }
  452. if (!$user->authorized) {
  453. return false;
  454. }
  455. // Continue to the page if user is authorized to access the page.
  456. foreach ($rules as $rule => $value) {
  457. if (is_array($value)) {
  458. foreach ($value as $nested_rule => $nested_value) {
  459. if ($user->authorize($rule . '.' . $nested_rule) == $nested_value) {
  460. return true;
  461. }
  462. }
  463. } else {
  464. if ($user->authorize($rule) == $value) {
  465. return true;
  466. }
  467. }
  468. }
  469. return false;
  470. }
  471. /**
  472. * Check if user may use password reset functionality.
  473. *
  474. * @param User $user
  475. * @param string $field
  476. * @param int $count
  477. * @param int $interval
  478. * @return bool
  479. * @deprecated 2.5.0 Use $grav['login']->getRateLimiter($context) instead. See Grav\Plugin\Login\RateLimiter class.
  480. */
  481. public function isUserRateLimited(User $user, $field, $count, $interval)
  482. {
  483. if ($count > 0) {
  484. if (!isset($user->{$field})) {
  485. $user->{$field} = [];
  486. }
  487. //remove older than $interval x minute attempts
  488. $actual_resets = [];
  489. foreach ((array)$user->{$field} as $reset) {
  490. if ($reset > (time() - $interval * 60)) {
  491. $actual_resets[] = $reset;
  492. }
  493. }
  494. if (count($actual_resets) >= $count) {
  495. return true;
  496. }
  497. $actual_resets[] = time(); // current reset
  498. $user->{$field} = $actual_resets;
  499. }
  500. return false;
  501. }
  502. /**
  503. * Reset the rate limit counter.
  504. *
  505. * @param User $user
  506. * @param string $field
  507. * @deprecated 2.5.0 Use $grav['login']->getRateLimiter($context) instead. See Grav\Plugin\Login\RateLimiter class.
  508. */
  509. public function resetRateLimit(User $user, $field)
  510. {
  511. $user->{$field} = [];
  512. }
  513. /**
  514. * Get Current logged in user
  515. *
  516. * @return User
  517. * @deprecated 2.5.0 Use $grav['user'] instead.
  518. */
  519. public function getUser()
  520. {
  521. /** @var User $user */
  522. return $this->grav['user'];
  523. }
  524. public function addProviderLoginTemplate($template)
  525. {
  526. $this->provider_login_templates[] = $template;
  527. }
  528. public function getProviderLoginTemplates()
  529. {
  530. $templates = $this->provider_login_templates;
  531. return $templates;
  532. }
  533. }