SproutCMS

This is the code documentation for the SproutCMS project

source of /sprout/Helpers/AdminAuth.php

  1. <?php
  2. /*
  3.  * Copyright (C) 2017 Karmabunny Pty Ltd.
  4.  *
  5.  * This file is a part of SproutCMS.
  6.  *
  7.  * SproutCMS is free software: you can redistribute it and/or modify it under the terms
  8.  * of the GNU General Public License as published by the Free Software Foundation, either
  9.  * version 2 of the License, or (at your option) any later version.
  10.  *
  11.  * For more information, visit <http://getsproutcms.com>.
  12.  */
  13.  
  14. namespace Sprout\Helpers;
  15.  
  16. use Exception;
  17.  
  18. use Kohana;
  19.  
  20. use karmabunny\pdb\Exceptions\QueryException;
  21.  
  22.  
  23. /**
  24. * Provides user authentication functions for the admin
  25. **/
  26. class AdminAuth extends Auth
  27. {
  28. const KEY = 'admin';
  29. const LOGIN_URL = 'admin/login';
  30.  
  31. /**
  32.   * @var array A cache of categories the current operator is a member of, populated on demand
  33.   */
  34. private static $category_cache = [];
  35.  
  36. /**
  37.   * If the user is not logged, redirect them to a login page
  38.   **/
  39. public static function checkLogin()
  40. {
  41. if (! self::isLoggedIn()) {
  42. $redirect = Enc::url(Url::current());
  43.  
  44. if (Router::$controller == 'admin' and Router::$method == 'index') {
  45. $redirect = null;
  46. }
  47.  
  48. if ($redirect && $redirect !== "admin") Notification::error('You need to be logged in to access this part of the site');
  49. Url::redirect (self::LOGIN_URL . '?redirect=' . $redirect);
  50. }
  51. }
  52.  
  53. /**
  54.   * Check if the user is logged in or not
  55.   *
  56.   * @return boolean True if the user is logged in, false otherwise
  57.   **/
  58. public static function isLoggedIn()
  59. {
  60. Session::instance();
  61. if (!empty($_SESSION[self::KEY]['login_id'])) {
  62. return true;
  63. }
  64.  
  65. return false;
  66. }
  67.  
  68.  
  69. /**
  70.   * Processes the login by a operator with the specified username and password
  71.   *
  72.   * @param string $username The username to attempt login with
  73.   * @param string $password The password to attempt login with
  74.   * @return boolean True on success, false on failure
  75.   **/
  76. public static function processLogin($username, $password)
  77. {
  78. Session::instance();
  79.  
  80. $q = "SELECT id, password, password_algorithm AS algorithm, password_salt AS salt, tfa_method
  81. FROM ~operators
  82. WHERE username LIKE ?";
  83.  
  84. try {
  85. $admin = Pdb::q($q, [Pdb::likeEscape($username)], 'row');
  86. } catch (Exception $ex) {
  87. return false;
  88. }
  89.  
  90. // Check IP restrictions in categories the operator belongs to
  91. $q = "SELECT cat.allowed_ips
  92. FROM ~operators_cat_join AS joiner
  93. INNER JOIN ~operators_cat_list AS cat ON joiner.cat_id = cat.id
  94. WHERE joiner.operator_id = ?";
  95. $ip_lists = Pdb::q($q, [$admin['id']], 'col');
  96. foreach ($ip_lists as $ip_list) {
  97. $ips = preg_split('/,\s*/', $ip_list);
  98. $ips = array_filter($ips);
  99. if (count($ips) == 0) continue;
  100. if (!Sprout::ipaddressInArray(Request::userIp(), $ips)) {
  101. return false;
  102. }
  103. }
  104.  
  105. // Password algorithm supported?
  106. if (! AdminAuth::checkAlgorithm($admin['algorithm'])) {
  107. $err = 'Unable to login - unsupported password hash algorithm. This is a server configuration error.';
  108. throw new Exception($err);
  109. }
  110.  
  111. // Password correct?
  112. if (!self::doPasswordCheck($admin['password'], $admin['algorithm'], $admin['salt'], $password)) {
  113. return false;
  114. }
  115.  
  116. // If the operator has 2FA enabled then don't log them in yet, but the id is required
  117. if ($admin['tfa_method'] !== 'none') {
  118. $_SESSION[self::KEY]['tfa_id'] = $admin['id'];
  119. } else {
  120. $_SESSION[self::KEY]['login_id'] = $admin['id'];
  121. }
  122.  
  123. $_SESSION[self::KEY]['super'] = false;
  124. $_SESSION[self::KEY]['remote'] = false;
  125. $_SESSION[self::KEY]['lock_key'] = Admin::createLockKey();
  126.  
  127. // If the default algorithm has changed, upgrade the password while the plaintext is on hand
  128. $default_algorithm = self::defaultAlgorithm();
  129. if ($admin['algorithm'] != $default_algorithm) {
  130. self::changePassword($password, $admin['id']);
  131. }
  132.  
  133. return true;
  134. }
  135.  
  136.  
  137. /**
  138.   * Checks the password on the database matches the one provided
  139.   * For re-authenticating certain actions of logged in operators
  140.   **/
  141. public static function checkPassword($password, $operator_id = null)
  142. {
  143. $operator_id = (int) $operator_id;
  144.  
  145. if (! $operator_id) {
  146. Session::instance();
  147. if (! self::isLoggedIn()) return false;
  148. $operator_id = $_SESSION[self::KEY]['login_id'];
  149. }
  150.  
  151. $q = "SELECT password, password_algorithm, password_salt
  152. FROM ~operators
  153. WHERE id = ?";
  154. try {
  155. $op = Pdb::q($q, [$operator_id], 'row');
  156. } catch (QueryException $ex) {
  157. return false;
  158. }
  159.  
  160. if (!self::doPasswordCheck($op['password'], $op['password_algorithm'], $op['password_salt'], $password)) {
  161. return false;
  162. }
  163.  
  164. return true;
  165. }
  166.  
  167.  
  168. /**
  169.   * Stub function for future development using OpenID
  170.   *
  171.   * @param string $openid The openid username url
  172.   * @return boolean True on success, false on failure
  173.   **/
  174. public static function processOpenid($openid)
  175. {
  176. return false;
  177. }
  178.  
  179.  
  180. /**
  181.   * Process a local (developer) login, with details stored in a config file
  182.   *
  183.   * @param string $username The username to attempt login with
  184.   * @param string $password The password to attempt login with
  185.   * @return boolean True on success, false on failure
  186.   */
  187. public static function processLocal($username, $password)
  188. {
  189. if ($password == '') return false;
  190.  
  191. try {
  192. $super_users = Kohana::config('super_ops.operators');
  193. } catch (Exception $ex) {
  194. return false;
  195. }
  196.  
  197. foreach ($super_users as $user => $details) {
  198. if ($user != $username) continue;
  199. if (!self::doPasswordCheck($details['hash'], Constants::PASSWORD_BCRYPT12, $details['salt'], $password)) continue;
  200.  
  201. $uid = $details['uid'];
  202. Session::instance();
  203. $_SESSION[self::KEY]['super'] = true;
  204. $_SESSION[self::KEY]['remote'] = false;
  205. $_SESSION[self::KEY]['login_id'] = $uid;
  206. $_SESSION[self::KEY]['login_user'] = $user;
  207. $_SESSION[self::KEY]['lock_key'] = Admin::createLockKey();
  208. return true;
  209. }
  210. return false;
  211. }
  212.  
  213.  
  214. /**
  215.   * Load the existing super-operators list from config, inject another operator, return new array
  216.   *
  217.   * @param string $username The username to add or edit
  218.   * @param string $pass_hash The password hash, as generated by {@see Auth::hashPassword}
  219.   * @param string $pass_salt The password salt, as generated by {@see Auth::hashPassword}
  220.   * @return array New users array
  221.   */
  222. public static function injectLocalSuperConf($username, $pass_hash, $pass_salt)
  223. {
  224. try {
  225. $users = Kohana::config('super_ops.operators');
  226. if (!isset($users)) $users = [];
  227. } catch (Exception $ex) {
  228. $users = [];
  229. }
  230.  
  231. if (isset($users[$username])) {
  232. // Update existing user
  233. $users[$username]['hash'] = $pass_hash;
  234. $users[$username]['salt'] = $pass_salt;
  235. } else {
  236. // Determine user ID and add new user
  237. $uid = 99999;
  238. $q = "SELECT MAX(id) FROM ~operators";
  239. $max_op_id = (int) Pdb::q($q, [], 'val');
  240. $uid = max($uid, $max_op_id);
  241.  
  242. foreach ($users as $user) {
  243. $uid = max($uid, $user['uid']);
  244. }
  245. ++$uid;
  246.  
  247. $users[$username] = ['uid' => $uid, 'hash' => $pass_hash, 'salt' => $pass_salt];
  248. }
  249.  
  250. return $users;
  251. }
  252.  
  253.  
  254. /**
  255.   * Process a remote (developer) login, as provided by the external web service
  256.   *
  257.   * @param string $username The username to attempt login with
  258.   * @param string $password The password to attempt login with
  259.   * @return boolean True on success, false on failure
  260.   **/
  261. public static function processRemote($username, $password)
  262. {
  263. if (!SERVER_ONLINE) return false;
  264. return false;
  265.  
  266. // This method has not been implemented
  267. // Instead, it's available as a stub to allow for third-party customisation
  268. //
  269. // The implementation of this method would have code something like the following
  270. // in the case of an authenticated login
  271. //
  272. // Session::instance();
  273. // $_SESSION[self::KEY]['super'] = true;
  274. // $_SESSION[self::KEY]['remote'] = true;
  275. // $_SESSION[self::KEY]['login_id'] = $uid;
  276. // $_SESSION[self::KEY]['lock_key'] = Admin::createLockKey();
  277. // return true;
  278. }
  279.  
  280.  
  281. /**
  282.   * Sets the password for a operator, or the current operator if a operator-id is not specified.
  283.   *
  284.   * @param string $new_password The new password.
  285.   * @param int $operator_id The operator to update. If not specified, the currently logged in operator is used.
  286.   **/
  287. public static function changePassword($new_password, $operator_id = null)
  288. {
  289. $operator_id = (int) $operator_id;
  290.  
  291. if (! $operator_id) {
  292. Session::instance();
  293. if (! self::isLoggedIn()) return false;
  294. $operator_id = $_SESSION[self::KEY]['login_id'];
  295. }
  296.  
  297. $new_password = trim($new_password);
  298. if ($new_password == '') return false;
  299.  
  300. $hashed = self::hashPassword($new_password);
  301. if (! $hashed) throw new Exception('Password hashing failed');
  302.  
  303. list($hash, $algorithm, $salt) = $hashed;
  304.  
  305. $data = ['password' => $hash, 'password_algorithm' => $algorithm, 'password_salt' => $salt];
  306. Pdb::update('operators', $data, ['id' => $operator_id]);
  307.  
  308. return true;
  309. }
  310.  
  311.  
  312. /**
  313.   * Does a rate-limit check for admin logins against the login_attempts table
  314.   *
  315.   * @return array If the rate limit has been hit. Keys: 0 => problematic field, 1 => max rate
  316.   * @return bool True if things are OK and the rate limit hasn't yet been hit
  317.   */
  318. public static function checkRateLimit($username, $ip)
  319. {
  320. $username = trim($username);
  321. $ip = bin2hex(inet_pton(trim($ip)));
  322.  
  323. $rate_limits = Kohana::config('sprout.auth_rate_limit');
  324. try {
  325. // Limit the username to 10 per hour
  326. $res = Sprout::checkInsertRate('login_attempts', 'username', $username, $rate_limits['username'], 3600, ['success' => 0]);
  327. if (! $res) return array('Username', '10 per hour');
  328.  
  329. // Limit the ip to 10 per hour
  330. $res = Sprout::checkInsertRate('login_attempts', 'ip', $ip, $rate_limits['ip'], 3600, ['success' => 0]);
  331. if (! $res) return array('IP address', '10 per hour');
  332.  
  333. } catch (Exception $ex) {}
  334.  
  335. return true;
  336. }
  337.  
  338. /**
  339.   * Store a login attempt (used for rate checking)
  340.   **/
  341. public static function saveLoginAttempt($username, $ip, $success)
  342. {
  343. $username = trim($username);
  344. $ip = bin2hex(inet_pton(trim($ip)));
  345.  
  346. $data = array();
  347. $data['username'] = $username;
  348. $data['ip'] = $ip;
  349. $data['success'] = $success;
  350. $data['date_added'] = Pdb::now();
  351. $data['date_modified'] = Pdb::now();
  352.  
  353. try {
  354. Pdb::insert('login_attempts', $data);
  355. } catch (Exception $ex) {}
  356. }
  357.  
  358.  
  359. /**
  360.   * Logs an operator out
  361.   **/
  362. public static function logout()
  363. {
  364. Session::instance();
  365. unset($_SESSION[self::KEY]);
  366. return true;
  367. }
  368.  
  369.  
  370. /**
  371.   * Returns the id of the currently logged in operator
  372.   **/
  373. public static function getId()
  374. {
  375. Session::instance();
  376. return @$_SESSION[self::KEY]['login_id'];
  377. }
  378.  
  379.  
  380. /**
  381.   * Fetches the ID of current operator if and only if they're a local operator, otherwise 0.
  382.   *
  383.   * @return int An ID if a local operator, 0 otherwise
  384.   */
  385. public static function getLocalId()
  386. {
  387. Session::instance();
  388. return self::hasDatabaseRecord() ? @$_SESSION[self::KEY]['login_id'] : 0;
  389. }
  390.  
  391.  
  392. /**
  393.   * Gets the id, name, username and email of the currently logged in operator.
  394.   * N.B. the id will be 0 for remote users
  395.   * @return array Under normal circumstances, with keys 'id', 'name', 'username', 'email' and 'editor'
  396.   * @return bool False if fetching data for a remote operator failed
  397.   */
  398. public static function getDetails()
  399. {
  400. Session::instance();
  401.  
  402. if (self::hasDatabaseRecord()) {
  403. $q = "SELECT id, name, username, email, '' AS editor
  404. FROM ~operators
  405. WHERE id = ?";
  406. return Pdb::q($q, [$_SESSION[self::KEY]['login_id']], 'row');
  407.  
  408. } else if ($_SESSION[self::KEY]['remote']) {
  409. // Remote-authenticated super-operators
  410. // This has not been implemented. See {@see AdminAuth::processRemote} for more info
  411.  
  412. } else {
  413. return [
  414. 'id' => $_SESSION[self::KEY]['login_id'],
  415. 'name' => $_SESSION[self::KEY]['login_user'] . ' (super-op)',
  416. 'username' => $_SESSION[self::KEY]['login_user'],
  417. 'email' => '',
  418. 'editor' => '',
  419. ];
  420. }
  421. }
  422.  
  423.  
  424. /**
  425.   * Returns true if the currently logged in user is in the specified category.
  426.   * Always returns true for remotely-logged in users.
  427.   *
  428.   * @param int $category_id The category to check
  429.   **/
  430. public static function inCategory($category_id)
  431. {
  432. Session::instance();
  433.  
  434. if ($_SESSION[self::KEY]['login_id'] == false) return false;
  435. if ($_SESSION[self::KEY]['super'] == true) return true;
  436.  
  437. $category_id = (int) $category_id;
  438.  
  439. if (array_key_exists($category_id, static::$category_cache)) {
  440. return static::$category_cache[$category_id];
  441. }
  442.  
  443. $q = "SELECT 1
  444. FROM ~operators_cat_join
  445. WHERE operator_id = ? AND cat_id = ?";
  446. $hascat = Pdb::q($q, [$_SESSION[self::KEY]['login_id'], $category_id], 'arr');
  447.  
  448. if (count($hascat) == 0) static::$category_cache[$category_id] = false;
  449. else static::$category_cache[$category_id] = true;
  450.  
  451. return static::$category_cache[$category_id];
  452. }
  453.  
  454.  
  455. /**
  456.   * Returns an array of all categories the currently logged in operator is in
  457.   **/
  458. public static function getOperatorCategories()
  459. {
  460. Session::instance();
  461.  
  462. if ($_SESSION[self::KEY]['login_id'] == false) return array();
  463.  
  464. if (self::isSuper()) {
  465. return array_keys(self::getAllCategories());
  466. }
  467.  
  468. $q = "SELECT cat_id
  469. FROM ~operators_cat_join
  470. WHERE operator_id = ?";
  471. return Pdb::q($q, [$_SESSION[self::KEY]['login_id']], 'col');
  472. }
  473.  
  474. /**
  475.   * Gets a list of all of the admin categories
  476.   * Returned as an array of id => name
  477.   **/
  478. public static function getAllCategories()
  479. {
  480. $q = "SELECT id, name FROM ~operators_cat_list ORDER BY name";
  481. return Pdb::q($q, [], 'map');
  482. }
  483.  
  484.  
  485. /**
  486.   * A super-operator -- has access to everything (dev tools, all permissions, etc)
  487.   *
  488.   * @return bool True if the logged-in user is a super-operator
  489.   */
  490. public static function isSuper()
  491. {
  492. Session::instance();
  493. return !empty($_SESSION[self::KEY]['super']);
  494. }
  495.  
  496.  
  497. /**
  498.   * Does the record-id for this login correspond to a local database record?
  499.   *
  500.   * @return bool True if the logged-in operator has a database record
  501.   */
  502. public static function hasDatabaseRecord()
  503. {
  504. Session::instance();
  505. return empty($_SESSION[self::KEY]['super']);
  506. }
  507.  
  508.  
  509. /**
  510.   * Get the ID of the 'Primary administrators' category
  511.   *
  512.   * i.e. the first category with permission to manage operators
  513.   *
  514.   * @return int
  515.   * @throws RowMissingException If there's no such category (in which case the system will be unuseable)
  516.   */
  517. public static function getPrimaryCategoryId()
  518. {
  519. static $q = null;
  520.  
  521. if (!$q) {
  522. $q = Pdb::prepare("SELECT id FROM ~operators_cat_list WHERE access_operators = 1 ORDER BY id LIMIT 1");
  523. }
  524. return Pdb::execute($q, [], 'val');
  525. }
  526.  
  527. }
  528.