<?php
/**
* @version	$Id: password_formatter.php 15590 2012-10-18 15:37:18Z alex $
* @package	In-Portal
* @copyright	Copyright (C) 1997 - 2011 Intechnic. All rights reserved.
* @license      GNU/GPL
* In-Portal is Open Source software.
* This means that this software may have been modified pursuant
* the GNU General Public License, and as distributed it includes
* or is derivative of works licensed under the GNU General Public License
* or other free or open source software licenses.
* See http://www.in-portal.org/license for copyright notices and details.
*/

defined('FULL_PATH') or die('restricted access!');

class kPasswordFormatter extends kFormatter
{
	/**
	 * Instance of PHPPass
	 *
	 * @var PasswordHash
	 * @access protected
	 */
	protected $_phpPass;

	/**
	 * Creates formatter instance
	 *
	 * @access public
	 */
	public function __construct()
	{
		parent::__construct();

		$this->_phpPass = $this->Application->makeClass('PasswordHash', Array (8, false));
	}

	/**
	 * The method is supposed to alter config options or configure object in some way based on its usage of formatters
	 * The methods is called for every field with formatter defined when configuring item.
	 * Could be used for adding additional VirtualFields to an object required by some special Formatter
	 *
	 * @param string $field_name
	 * @param array $field_options
	 * @param kDBBase $object
	 */
	function PrepareOptions($field_name, &$field_options, &$object)
	{
		if ( !isset($field_options['verify_field']) ) {
			return;
		}

		$add_fields = Array ();
		$options = Array (
			'master_field' => $field_name,
//			'error_field' => $field_name,
			'formatter' => 'kPasswordFormatter'
		);

		$copy_options = Array ('hashing_method', 'hashing_method_field', 'salt', 'required', 'skip_empty');

		foreach ($copy_options as $copy_option) {
			if ( array_key_exists($copy_option, $field_options) ) {
				$options[$copy_option] = $field_options[$copy_option];
			}
		}

		$add_fields[$field_options['verify_field']] = $options;

		$add_fields[$field_name . '_plain'] = Array ('type' => 'string', 'error_field' => $field_name);
		$add_fields[$field_options['verify_field'] . '_plain'] = Array ('type' => 'string', 'error_field' => $field_options['verify_field']);

		$virtual_fields = $object->getVirtualFields();
		$add_fields = kUtil::array_merge_recursive($add_fields, $virtual_fields);
		$object->setVirtualFields($add_fields);
	}

	/**
	 * Formats value of a given field
	 *
	 * @param string $value
	 * @param string $field_name
	 * @param kDBItem|kDBList $object
	 * @param string $format
	 * @return string
	 */
	function Format($value, $field_name, &$object, $format = null)
	{
		return $value;
	}

	/**
	 * Performs password & verify password field validation
	 *
	 * @param mixed $value
	 * @param string $field_name
	 * @param kDBItem $object
	 * @return mixed
	 * @access public
	 */
	public function Parse($value, $field_name, &$object)
	{
		list ($password_field, $verify_field) = $this->_getPasswordFields($value, $field_name, $object);

		$options = $object->GetFieldOptions($field_name);
		$salt = $object->GetFieldOption($password_field, 'salt', false, '');
		$hashing_method = isset($options['hashing_method']) ? $options['hashing_method'] : $object->GetDBField($options['hashing_method_field']);

		if ( $object->GetFieldOption($password_field, 'verify_field_set') && $object->GetFieldOption($verify_field, 'master_field_set') ) {
			$new_password = $object->GetDBField($password_field . '_plain');
			$verify_password = $object->GetDBField($verify_field . '_plain');

			if ( $new_password == '' && $verify_password == '' ) {
				$stored_hash = $object->GetDBField($password_field);

				if ( !$this->checkPassword('', $stored_hash, $hashing_method) ) {
					// return empty string causing password from database to stay
					return $value;
				}
				else {
					return $this->hashPassword($value, $salt, $hashing_method);
				}
			}

			// determine admin or front
			$phrase_error_prefix = $this->Application->isAdmin ? 'la' : 'lu';

			if ( $new_password != $verify_password ) {
				// passwords don't match (no matter what is their length)
				$object->SetError($verify_field, 'passwords_do_not_match', $phrase_error_prefix . '_passwords_do_not_match');
			}

			$min_length = $this->Application->ConfigValue('Min_Password'); // for error message too
			$min_length = $object->GetFieldOption($password_field, 'min_length', false, $min_length);

			if ( mb_strlen($new_password) < $min_length ) {
				$error_msg = '+' . sprintf($this->Application->Phrase($phrase_error_prefix . '_passwords_too_short'), $min_length); // + -> not phrase
				$object->SetError($password_field, 'passwords_min_length', $error_msg);
			}
		}

		if ( $value == '' ) {
			// new value is empty - return hash from database
			return $object->GetDBField($field_name);
		}

		return $this->hashPassword($value, $salt, $hashing_method);
	}

	/**
	 * Finds out names of password and verify password fields and updates "_plain" virtual field
	 *
	 * @param string $value
	 * @param string $field_name
	 * @param kDBItem $object
	 * @return Array
	 * @access protected
	 */
	protected function _getPasswordFields($value, $field_name, &$object)
	{
		$options = $object->GetFieldOptions($field_name);

		$flip_count = 0;
		$password_field = $verify_field = '';
		$fields = Array ('master_field', 'verify_field');

		// 1. collect values from both Password and VerifyPassword fields
		while ($flip_count < 2) {
			if ( getArrayValue($options, $fields[0]) ) {
				$tmp_field = $options[$fields[0]];
				$object->SetDBField($field_name . '_plain', $value);

				if ( !$object->GetFieldOption($tmp_field, $fields[1] . '_set') ) {
					$object->SetFieldOption($tmp_field, $fields[1] . '_set', true);
				}

				$password_field = $options[$fields[0]];
				$verify_field = $field_name;
			}

			$fields = array_reverse($fields);
			$flip_count++;
		}

		return Array ($password_field, $verify_field);
	}

	/**
	 * Creates hash from given password and salt
	 *
	 * @param string $password
	 * @param string $salt
	 * @param int $hashing_method
	 * @return string
	 * @throws InvalidArgumentException
	 * @access public
	 */
	public function hashPassword($password, $salt = null, $hashing_method = PasswordHashingMethod::PHPPASS)
	{
		switch ( $hashing_method ) {
			case PasswordHashingMethod::NONE:
				return $password;
				break;

			case PasswordHashingMethod::MD5:
				return $this->_md5hash($password, $salt, false);
				break;

			case PasswordHashingMethod::MD5_AND_PHPPASS:
				return $this->_phpPass->hashPassword($this->_md5hash($password, $salt, true));
				break;

			case PasswordHashingMethod::PHPPASS:
				return $this->_phpPass->hashPassword($password);
				break;

			default:
				throw new InvalidArgumentException('Unknown password hashing method "' . $hashing_method . '"');
				break;
		}
	}

	/**
	 * Checks, that user password is valid
	 *
	 * @param string $password Non-hashed password provided by user
	 * @param string $stored_hash Hash, calculated before from correct user password (must have salt inside)
	 * @param int $hashing_method Hash generation method
	 * @return bool
	 * @access public
	 * @throws InvalidArgumentException
	 */
	public function checkPassword($password, $stored_hash = null, $hashing_method = PasswordHashingMethod::PHPPASS)
	{
		$salt = '';

		if ( $hashing_method != PasswordHashingMethod::PHPPASS && strpos($stored_hash, ':') !== false ) {
			list ($salt, $stored_hash) = explode(':', $stored_hash, 2);
		}

		switch ( $hashing_method ) {
			case PasswordHashingMethod::NONE:
				return $password == $stored_hash;
				break;

			case PasswordHashingMethod::MD5:
				return $this->_md5hash($password, $salt, false) == $stored_hash;
				break;

			case PasswordHashingMethod::MD5_AND_PHPPASS:
				$password_hashed = preg_match('/^[a-f0-9]{32}$/', $password);
				return $this->_phpPass->checkPassword($this->_md5hash($password, $salt, $password_hashed), $stored_hash);
				break;

			case PasswordHashingMethod::PHPPASS:
				return $this->_phpPass->checkPassword($password, $stored_hash);
				break;

			default:
				throw new InvalidArgumentException('Unknown password hashing method "' . $hashing_method . '"');
				break;
		}
	}

	/**
	 * Checks a password stored as system setting using phppass with fallback to salted md5
	 *
	 * @param string $setting_name
	 * @param string $password
	 * @param int $hashing_method
	 * @return bool
	 * @access public
	 */
	public function checkPasswordFromSetting($setting_name, $password, $hashing_method = PasswordHashingMethod::PHPPASS)
	{
		$stored_hash = $this->Application->ConfigValue($setting_name);
		$stored_hash = $this->prepareHash($stored_hash, 'b38', $hashing_method);

		if ( $this->checkPassword($password, $stored_hash, $hashing_method) ) {
			return true;
		}

		if ( $hashing_method != PasswordHashingMethod::MD5 ) {
			if ( $this->checkPasswordFromSetting($setting_name, $password, PasswordHashingMethod::MD5) ) {
				// rehash password on the go using more secure algorithm
				$this->Application->SetConfigValue($setting_name, $this->hashPassword($password));

				return true;
			}
		}

		return false;
	}

	/**
	 * Ensures, that salt is always present in the hash
	 *
	 * @param string $stored_hash
	 * @param string $salt
	 * @param int $hashing_method
	 * @return string
	 * @access public
	 */
	public function prepareHash($stored_hash, $salt = '', $hashing_method = PasswordHashingMethod::PHPPASS)
	{
		if ( $hashing_method == PasswordHashingMethod::PHPPASS ) {
			return $stored_hash;
		}

		// embed salt into hash generated not by phppass
		return $salt . ':' . $stored_hash;
	}

	/**
	 * Hashes password using MD5 algorithm
	 *
	 * @param string $password
	 * @param string $salt
	 * @param bool $password_hashed
	 * @return string
	 * @access protected
	 */
	protected function _md5hash($password, $salt = null, $password_hashed = false)
	{
		if ( !$password_hashed ) {
			$password = md5($password);
		}

		if ( isset($salt) && $salt ) {
			return md5($password . $salt);
		}

		// if empty salt, assume, that it's not passed at all
		return $password;
	}
}