<?php namespace ProcessWire;

/**
 * ProcessWire Session Login Throttle Module
 *
 * Throttles the frequency of logins for a given account, helps to reduce dictionary attacks.
 * 
 * ProcessWire 3.x, Copyright 2018 by Ryan Cramer
 * https://processwire.com
 *
 * 
 * @property int|bool $checkIP
 * @property int $seconds
 * @property int $maxSeconds
 * @property int|bool $logFails
 *
 */

class SessionLoginThrottle extends WireData implements Module, ConfigurableModule {

	public static function getModuleInfo() {
		return array(
			'title' => 'Session Login Throttle', 
			'version' => 103, 
			'summary' => 'Throttles login attempts to help prevent dictionary attacks.',
			'permanent' => false, 
			'singular' => true, 
			'autoload' => function() { return count($_POST) > 0;  }
			);
	}

	/**
	 * Default module settings
	 * 
	 * @var array
	 * 
	 */
	protected static $defaultSettings = array(
		'checkIP' => 0,
		'seconds' => 5,
		'maxSeconds' => 60,
		'logFails' => 0,
	);

	/**
	 * Cached results of allowLogin() in case there are multiple calls
	 * 
	 * This ensures that only one attempt can be recorded per request. 
	 * 
	 * @var array of name (string) => result (bool)
	 * 
	 */
	protected $allowLoginResults = array();

	/**
	 * Construct
	 * 
	 */
	public function __construct() {
		foreach(self::$defaultSettings as $key => $value) {
			$this->set($key, $value); 
		}
	}

	/**
	 * Initialize the hooks
	 *
	 */
	public function init() {
		if($this->wire('config')->demo) return;
		$this->session->addHookAfter('allowLoginAttempt', $this, 'hookSessionAllowLoginAttempt'); 
	}

	/**
	 * Hooks into Session::authenticate to make it respond 'false' if the user has already failed a login. 
	 *
	 * Further, it imposes an increasing delay for every failed attempt 
	 * 
	 * @param HookEvent $event
	 *
	 */
	public function hookSessionAllowLoginAttempt(HookEvent $event) {

		// check if some other module has already disallowed login, in which case we won't do anything
		$allowed = $event->return; 
		if(!$allowed) return;

		$name = $event->arguments[0]; 

		// now check user $name and optionally IP address
		if(!$this->allowLogin($name)) {
			$allowed = false; 

		} else if($this->checkIP) {
			$ip = $this->wire('session')->getIP(); 
			if(strlen($ip) && !$this->allowLogin($ip)) $allowed = false;
		}

		$event->return = $allowed;
	}

	/**
	 * Allow given user name to login?
	 * 
	 * @param string $name User name, may also be IP address 
	 * @return bool
	 * @throws WireException
	 * 
	 */
	protected function allowLogin($name) {
		
		if(isset($this->allowLoginResults[$name])) return $this->allowLoginResults[$name];

		$time = time();
		$database = $this->wire('database');
		$name = $this->wire('sanitizer')->pageName($name, Sanitizer::toAscii);
		
		$query = $database->prepare("SELECT attempts, last_attempt FROM session_login_throttle WHERE name=:name");
		$query->bindValue(":name", $name);
		$query->execute();
		$numRows = $query->rowCount();
		if($numRows) {
			list($attempts, $lastAttempt) = $query->fetch(\PDO::FETCH_NUM);
		} else {
			$attempts = 0;
			$lastAttempt = 0;
		}
		$query->closeCursor();
		$allowed = false;

		if($numRows) {
			if($attempts > 1) {
				$requireSeconds = ($attempts-1) * $this->seconds; 
				if($requireSeconds > $this->maxSeconds) $requireSeconds = $this->maxSeconds; 
				$elapsedSeconds = $time - $lastAttempt; 
				if($elapsedSeconds < $requireSeconds) {
					if($this->logFails) {
						$this->wire('log')->save(
							"login-throttle", 
							"Blocked login attempt for '$name' (attempts=$attempts, seconds=$requireSeconds)",
							array('showUser' => false)
						);
					}
					$error = 
						$this->_('Login not attempted due to overflow.') . ' ' . 
						sprintf($this->_("Please wait at least %d seconds before attempting another login."), $requireSeconds); 
					if($this->wire('process') == 'ProcessLogin') {
						parent::error($error);
					} else {
						throw new SessionLoginThrottleException($error); // ensures the error can't be missed in unknown API usage
					}
				} else {
					$allowed = true; 
				}
			} else {
				$allowed = true; 
			}
			$attempts++;

			// if there have been more than $this->maxSeconds since the previous attempt, consider this as a first login attempt (@jlj)
			if($time - $lastAttempt > $this->maxSeconds) $attempts = 1;

			$sql = 'UPDATE session_login_throttle SET attempts=:attempts, last_attempt=:time WHERE name=:name';
			$query = $database->prepare($sql);
			$query->bindValue(':attempts', $attempts);
			$query->bindValue(':time', $time);
			$query->bindValue(':name', $name);
			$query->execute();

		} else {
			$allowed = true;
			$attempts = 1;

			$sql = 'INSERT INTO session_login_throttle (name, attempts, last_attempt) VALUES(:name, :attempts, :last_attempt)';
			$query = $database->prepare($sql);
			$query->bindValue(":name", $name);
			$query->bindValue(":attempts", $attempts, \PDO::PARAM_INT);
			$query->bindValue(":last_attempt", $time, \PDO::PARAM_INT);
			$query->execute();
		}
		
		// delete saved login attempts that are no longer applicable
		$expired = $time - $this->maxSeconds;
		
		$sql = "DELETE FROM session_login_throttle WHERE last_attempt < :expired ";
		$query = $database->prepare($sql);
		$query->bindValue(":expired", $expired, \PDO::PARAM_INT);
		$query->execute();
		
		$this->allowLoginResults[$name] = $allowed;
			
		return $allowed; 
	}


	/**
	 * Add custom config options (coming soon, just a placeholder for now)
	 * 
	 * @param array $data
	 * @return InputfieldWrapper
	 *
	 */
	public function getModuleConfigInputfields(array $data) {

		/** @var InputfieldWrapper $inputfields */
		$inputfields = $this->wire(new InputfieldWrapper());

		foreach(self::$defaultSettings as $key => $value) {
			if(!isset($data[$key])) $data[$key] = $value;
		}

		/** @var InputfieldCheckbox $f */
		$f = $this->wire('modules')->get('InputfieldCheckbox');
		$f->attr('name', 'checkIP');
		$f->attr('value', 1);
		$f->attr('checked', $data['checkIP'] ? 'checked' : ''); 
		$f->label = $this->_('Throttle by IP address?'); 
		$f->description = $this->_('By default, throttling will only be done by username. If you check this box, then throttling will also be done by IP address. We recommended enabling this option if your users are not coming from a shared IP address.');
		$inputfields->add($f);

		/** @var InputfieldInteger $f */
		$f = $this->wire('modules')->get('InputfieldInteger'); 
		$f->attr('name', 'seconds');
		$f->attr('value', $data['seconds']); 
		$f->label = $this->_('Number of seconds to make user wait after failed login attempt'); 
		$f->description = $this->_('This number is multiplied by the number of failed attempts, so each failed attempt increases the wait time exponentially. As a result, be careful about setting this too high.');
		$inputfields->add($f); 

		$f = $this->wire('modules')->get('InputfieldInteger'); 
		$f->attr('name', 'maxSeconds');
		$f->attr('value', $data['maxSeconds']); 
		$f->label = $this->_('Maximum number of seconds a user would ever have to wait before attempting another login'); 
		$f->description = $this->_('Because the wait time is increased exponentially on each attempt, this places a maximum (cap) on the wait time. You should leave this set to a fairly high number.');
		$f->notes = $this->_('60=1 minute, 300=5 minutes, 600=10 minutes, 3600=1 hour, 86400=1 day'); 
		$inputfields->add($f);
	
		/** @var InputfieldCheckbox $f */
		$f = $this->wire('modules')->get('InputfieldCheckbox');
		$f->attr('name', 'logFails');
		if($data['logFails']) $f->attr('checked', 'checked');
		$f->label = $this->_('Save to log file when name/IP is blocked?');
		$inputfields->add($f);
		
		return $inputfields;
	}

	/**
	 * Install the module by creating a DB table where we store login attempts
	 *
	 */
	public function ___install() { 

		$sql = 	"CREATE TABLE `session_login_throttle` ( " . 
				"`name` varchar(128) NOT NULL, " . 
				"`attempts` int(10) unsigned NOT NULL default '0'," . 
				"`last_attempt` int(10) unsigned NOT NULL," . 
				"PRIMARY KEY (`name`))";

		$this->database->exec($sql);
	}

	/**
	 * Drop the login attempt table when the module is uninstalled
	 *
	 */
	public function ___uninstall() { 
		$this->database->exec("DROP TABLE IF EXISTS session_login_throttle"); 
	}

}

/**
 * Exception thrown when login overflow occurs and throttle is active
 *
 */
class SessionLoginThrottleException extends WireException { }
