<?php
/*
* Copyright (C) 2017 Karmabunny Pty Ltd.
*
* This file is a part of SproutCMS.
*
* SproutCMS is free software: you can redistribute it and/or modify it under the terms
* of the GNU General Public License as published by the Free Software Foundation, either
* version 2 of the License, or (at your option) any later version.
*
* For more information, visit <http://getsproutcms.com>.
*/
namespace Sprout\Helpers;
use Exception;
use Kohana;
use karmabunny\pdb\Exceptions\QueryException;
/**
* Provides user authentication functions for the admin
**/
class AdminAuth extends Auth
{
const LOGIN_URL = 'admin/login';
/**
* @var array A cache of categories the current operator is a member of, populated on demand
*/
private static $category_cache = [];
/**
* If the user is not logged, redirect them to a login page
**/
public static function checkLogin()
{
if (! self::isLoggedIn()) {
$redirect = Enc
::url(Url
::current());
if (Router::$controller == 'admin' and Router::$method == 'index') {
$redirect = null;
}
if ($redirect && $redirect !== "admin") Notification::error('You need to be logged in to access this part of the site');
Url::redirect (self::LOGIN_URL . '?redirect=' . $redirect);
}
}
/**
* Check if the user is logged in or not
*
* @return boolean True if the user is logged in, false otherwise
**/
public static function isLoggedIn()
{
Session::instance();
if (!empty($_SESSION[self::KEY]['login_id'])) { return true;
}
return false;
}
/**
* Processes the login by a operator with the specified username and password
*
* @param string $username The username to attempt login with
* @param string $password The password to attempt login with
* @return boolean True on success, false on failure
**/
public static function processLogin($username, $password)
{
Session::instance();
$q = "SELECT id, password, password_algorithm AS algorithm, password_salt AS salt, tfa_method
FROM ~operators
WHERE username LIKE ?";
try {
$admin = Pdb::q($q, [Pdb::likeEscape($username)], 'row');
} catch (Exception $ex) {
return false;
}
// Check IP restrictions in categories the operator belongs to
$q = "SELECT cat.allowed_ips
FROM ~operators_cat_join AS joiner
INNER JOIN ~operators_cat_list AS cat ON joiner.cat_id = cat.id
WHERE joiner.operator_id = ?";
$ip_lists = Pdb::q($q, [$admin['id']], 'col');
foreach ($ip_lists as $ip_list) {
if (count($ips) == 0) continue; if (!Sprout::ipaddressInArray(Request::userIp(), $ips)) {
return false;
}
}
// Password algorithm supported?
if (! AdminAuth::checkAlgorithm($admin['algorithm'])) {
$err = 'Unable to login - unsupported password hash algorithm. This is a server configuration error.';
throw new Exception($err);
}
// Password correct?
if (!self::doPasswordCheck($admin['password'], $admin['algorithm'], $admin['salt'], $password)) {
return false;
}
// If the operator has 2FA enabled then don't log them in yet, but the id is required
if ($admin['tfa_method'] !== 'none') {
$_SESSION[self::KEY]['tfa_id'] = $admin['id']; } else {
$_SESSION[self::KEY]['login_id'] = $admin['id']; }
$_SESSION[self::KEY]['super'] = false; $_SESSION[self::KEY]['remote'] = false; $_SESSION[self::KEY]['lock_key'] = Admin
::createLockKey();
// If the default algorithm has changed, upgrade the password while the plaintext is on hand
$default_algorithm = self::defaultAlgorithm();
if ($admin['algorithm'] != $default_algorithm) {
self::changePassword($password, $admin['id']);
}
return true;
}
/**
* Checks the password on the database matches the one provided
* For re-authenticating certain actions of logged in operators
**/
public static function checkPassword($password, $operator_id = null)
{
$operator_id = (int) $operator_id;
if (! $operator_id) {
Session::instance();
if (! self::isLoggedIn()) return false;
$operator_id = $_SESSION[self::KEY]['login_id']; }
$q = "SELECT password, password_algorithm, password_salt
FROM ~operators
WHERE id = ?";
try {
$op = Pdb::q($q, [$operator_id], 'row');
} catch (QueryException $ex) {
return false;
}
if (!self::doPasswordCheck($op['password'], $op['password_algorithm'], $op['password_salt'], $password)) {
return false;
}
return true;
}
/**
* Stub function for future development using OpenID
*
* @param string $openid The openid username url
* @return boolean True on success, false on failure
**/
public static function processOpenid($openid)
{
return false;
}
/**
* Process a local (developer) login, with details stored in a config file
*
* @param string $username The username to attempt login with
* @param string $password The password to attempt login with
* @return boolean True on success, false on failure
*/
public static function processLocal($username, $password)
{
if ($password == '') return false;
try {
$super_users = Kohana::config('super_ops.operators');
} catch (Exception $ex) {
return false;
}
foreach ($super_users as $user => $details) {
if ($user != $username) continue;
if (!self::doPasswordCheck($details['hash'], Constants::PASSWORD_BCRYPT12, $details['salt'], $password)) continue;
$uid = $details['uid'];
Session::instance();
$_SESSION[self::KEY]['super'] = true; $_SESSION[self::KEY]['remote'] = false; $_SESSION[self::KEY]['login_id'] = $uid; $_SESSION[self::KEY]['login_user'] = $user; $_SESSION[self::KEY]['lock_key'] = Admin
::createLockKey(); return true;
}
return false;
}
/**
* Load the existing super-operators list from config, inject another operator, return new array
*
* @param string $username The username to add or edit
* @param string $pass_hash The password hash, as generated by {@see Auth::hashPassword}
* @param string $pass_salt The password salt, as generated by {@see Auth::hashPassword}
* @return array New users array
*/
public static function injectLocalSuperConf($username, $pass_hash, $pass_salt)
{
try {
$users = Kohana::config('super_ops.operators');
if (!isset($users)) $users = []; } catch (Exception $ex) {
$users = [];
}
if (isset($users[$username])) { // Update existing user
$users[$username]['hash'] = $pass_hash;
$users[$username]['salt'] = $pass_salt;
} else {
// Determine user ID and add new user
$uid = 99999;
$q = "SELECT MAX(id) FROM ~operators";
$max_op_id = (int) Pdb::q($q, [], 'val');
$uid = max($uid, $max_op_id);
foreach ($users as $user) {
$uid = max($uid, $user['uid']); }
++$uid;
$users[$username] = ['uid' => $uid, 'hash' => $pass_hash, 'salt' => $pass_salt];
}
return $users;
}
/**
* Process a remote (developer) login, as provided by the external web service
*
* @param string $username The username to attempt login with
* @param string $password The password to attempt login with
* @return boolean True on success, false on failure
**/
public static function processRemote($username, $password)
{
if (!SERVER_ONLINE) return false;
return false;
// This method has not been implemented
// Instead, it's available as a stub to allow for third-party customisation
//
// The implementation of this method would have code something like the following
// in the case of an authenticated login
//
// Session::instance();
// $_SESSION[self::KEY]['super'] = true;
// $_SESSION[self::KEY]['remote'] = true;
// $_SESSION[self::KEY]['login_id'] = $uid;
// $_SESSION[self::KEY]['lock_key'] = Admin::createLockKey();
// return true;
}
/**
* Sets the password for a operator, or the current operator if a operator-id is not specified.
*
* @param string $new_password The new password.
* @param int $operator_id The operator to update. If not specified, the currently logged in operator is used.
**/
public static function changePassword($new_password, $operator_id = null)
{
$operator_id = (int) $operator_id;
if (! $operator_id) {
Session::instance();
if (! self::isLoggedIn()) return false;
$operator_id = $_SESSION[self::KEY]['login_id']; }
$new_password = trim($new_password); if ($new_password == '') return false;
$hashed = self::hashPassword($new_password);
if (! $hashed) throw new Exception('Password hashing failed');
list($hash, $algorithm, $salt) = $hashed;
$data = ['password' => $hash, 'password_algorithm' => $algorithm, 'password_salt' => $salt];
Pdb::update('operators', $data, ['id' => $operator_id]);
return true;
}
/**
* Does a rate-limit check for admin logins against the login_attempts table
*
* @return array If the rate limit has been hit. Keys: 0 => problematic field, 1 => max rate
* @return bool True if things are OK and the rate limit hasn't yet been hit
*/
public static function checkRateLimit($username, $ip)
{
$username = trim($username);
$rate_limits = Kohana::config('sprout.auth_rate_limit');
try {
// Limit the username to 10 per hour
$res = Sprout::checkInsertRate('login_attempts', 'username', $username, $rate_limits['username'], 3600, ['success' => 0]);
if (! $res) return array('Username', '10 per hour');
// Limit the ip to 10 per hour
$res = Sprout::checkInsertRate('login_attempts', 'ip', $ip, $rate_limits['ip'], 3600, ['success' => 0]);
if (! $res) return array('IP address', '10 per hour');
} catch (Exception $ex) {}
return true;
}
/**
* Store a login attempt (used for rate checking)
**/
public static function saveLoginAttempt($username, $ip, $success)
{
$username = trim($username);
$data['username'] = $username;
$data['ip'] = $ip;
$data['success'] = $success;
$data['date_added'] = Pdb::now();
$data['date_modified'] = Pdb::now();
try {
Pdb::insert('login_attempts', $data);
} catch (Exception $ex) {}
}
/**
* Logs an operator out
**/
public static function logout()
{
Session::instance();
return true;
}
/**
* Returns the id of the currently logged in operator
**/
public static function getId()
{
Session::instance();
return @$_SESSION[self::KEY]['login_id']; }
/**
* Fetches the ID of current operator if and only if they're a local operator, otherwise 0.
*
* @return int An ID if a local operator, 0 otherwise
*/
public static function getLocalId()
{
Session::instance();
return self::hasDatabaseRecord() ?
@$_SESSION[self::KEY]['login_id'] : 0; }
/**
* Gets the id, name, username and email of the currently logged in operator.
* N.B. the id will be 0 for remote users
* @return array Under normal circumstances, with keys 'id', 'name', 'username', 'email' and 'editor'
* @return bool False if fetching data for a remote operator failed
*/
public static function getDetails()
{
Session::instance();
if (self::hasDatabaseRecord()) {
$q = "SELECT id, name, username, email, '' AS editor
FROM ~operators
WHERE id = ?";
return Pdb
::q($q, [$_SESSION[self::KEY]['login_id']], 'row');
} else if ($_SESSION[self::KEY]['remote']) { // Remote-authenticated super-operators
// This has not been implemented. See {@see AdminAuth::processRemote} for more info
} else {
return [
'id' => $_SESSION[self::KEY]['login_id'], 'name' => $_SESSION[self::KEY]['login_user'] . ' (super-op)', 'username' => $_SESSION[self::KEY]['login_user'], 'email' => '',
'editor' => '',
];
}
}
/**
* Returns true if the currently logged in user is in the specified category.
* Always returns true for remotely-logged in users.
*
* @param int $category_id The category to check
**/
public static function inCategory($category_id)
{
Session::instance();
if ($_SESSION[self::KEY]['login_id'] == false) return false; if ($_SESSION[self::KEY]['super'] == true) return true;
$category_id = (int) $category_id;
return static::$category_cache[$category_id];
}
$q = "SELECT 1
FROM ~operators_cat_join
WHERE operator_id = ? AND cat_id = ?";
$hascat = Pdb
::q($q, [$_SESSION[self::KEY]['login_id'], $category_id], 'arr');
if (count($hascat) == 0) static
::$category_cache[$category_id] = false; else static::$category_cache[$category_id] = true;
return static::$category_cache[$category_id];
}
/**
* Returns an array of all categories the currently logged in operator is in
**/
public static function getOperatorCategories()
{
Session::instance();
if ($_SESSION[self::KEY]['login_id'] == false) return array();
if (self::isSuper()) {
}
$q = "SELECT cat_id
FROM ~operators_cat_join
WHERE operator_id = ?";
return Pdb
::q($q, [$_SESSION[self::KEY]['login_id']], 'col'); }
/**
* Gets a list of all of the admin categories
* Returned as an array of id => name
**/
public static function getAllCategories()
{
$q = "SELECT id, name FROM ~operators_cat_list ORDER BY name";
return Pdb::q($q, [], 'map');
}
/**
* A super-operator -- has access to everything (dev tools, all permissions, etc)
*
* @return bool True if the logged-in user is a super-operator
*/
public static function isSuper()
{
Session::instance();
return !empty($_SESSION[self::KEY]['super']); }
/**
* Does the record-id for this login correspond to a local database record?
*
* @return bool True if the logged-in operator has a database record
*/
public static function hasDatabaseRecord()
{
Session::instance();
return empty($_SESSION[self::KEY]['super']); }
/**
* Get the ID of the 'Primary administrators' category
*
* i.e. the first category with permission to manage operators
*
* @return int
* @throws RowMissingException If there's no such category (in which case the system will be unuseable)
*/
public static function getPrimaryCategoryId()
{
static $q = null;
if (!$q) {
$q = Pdb::prepare("SELECT id FROM ~operators_cat_list WHERE access_operators = 1 ORDER BY id LIMIT 1");
}
return Pdb::execute($q, [], 'val');
}
}