<?php
/**
 * EGroupware API: Basic and Digest Auth
 *
 * For Apache FCGI you need the following rewrite rule:
 *
 * 	RewriteEngine on
 * 	RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization},L]
 *
 * Otherwise authentication request will be send over and over again, as password is NOT available to PHP!
 * (This makes authentication details available in PHP as $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
 *
 * @link http://www.egroupware.org
 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
 * @package api
 * @subpackage header
 * @author Ralf Becker <RalfBecker-AT-outdoor-training.de>
 * @copyright (c) 2010-16 by Ralf Becker <RalfBecker-AT-outdoor-training.de>
 * @version $Id$
 */

namespace EGroupware\Api\Header;

use EGroupware\Api;
use EGroupware\OpenID\Token;

/**
 * Class to authenticate via basic or digest auth
 *
 * The more secure digest auth requires:
 *	a) cleartext passwords in SQL table
 *	b) md5 hashes of username, realm, password stored somewhere (NOT yet implemented)
 * Otherwise digest auth is not possible and therefore not offered to the client.
 *
 * Usage example:
 *
 * $GLOBALS['egw_info']['flags'] = array(
 * 	'noheader'  => True,
 * 	'currentapp' => 'someapp',
 * 	'no_exception_handler' => 'basic_auth',	// we use a basic auth exception handler (sends exception message as basic auth realm)
 * 	'autocreate_session_callback' => 'EGroupware\\Api\\Header\\Authenticate::autocreate_session_callback',
 * 	'auth_realm' => 'EGroupware',
 * );
 * include(dirname(__FILE__).'/header.inc.php');
 *
 * @link http://www.php.net/manual/en/features.http-auth.php
 * @ToDo check if we have to check if returned nonce matches our challange (not done in above link, but why would it be there)
 * @link http://en.wikipedia.org/wiki/Digest_access_authentication
 * @link http://tools.ietf.org/html/rfc2617
 *
 * Commented out is accept-charset parameter from (seems not supported by any client I tested with)
 * @link https://tools.ietf.org/id/draft-reschke-basicauth-enc-06.html
 *
 * Implemented support for clients sending credentials in iso-8859-1 instead of our utf-8:
 * - Firefox 19.0
 * - Thunderbird 17.0.3 with Lightning 1.8
 * - IE 8
 * - Netdrive
 * (Chrome 24 or Safari 6 sends credentials in charset of webpage.)
 */
class Authenticate
{
	/**
	 * Log to error_log:
	 * 	0 = dont
	 *  1 = no cleartext passwords
	 *  2 = all
	 */
	const ERROR_LOG = 0;

	/**
	 * Callback to be used to create session via header include authenticated via basic or digest auth
	 *
	 * @param array $account NOT used!
	 * @return string valid session-id or does NOT return at all!
	 */
	static public function autocreate_session_callback(&$account)
	{
		unset($account);	// not used, but required by function signature
		if (self::ERROR_LOG)
		{
			$pw = self::ERROR_LOG > 1 ? $_SERVER['PHP_AUTH_PW'] : '**********';
			error_log(__METHOD__.'() PHP_AUTH_USER='.array2string($_SERVER['PHP_AUTH_USER']).', PHP_AUTH_PW='.array2string($pw).', PHP_AUTH_DIGEST='.array2string($_SERVER['PHP_AUTH_DIGEST']));
		}
		$realm = $GLOBALS['egw_info']['flags']['auth_realm'];
		if (empty($realm)) $realm = 'EGroupware';

		/** @var Api\Session $session */
		$session = $GLOBALS['egw']->session;

		$username = $_SERVER['PHP_AUTH_USER']; $password = $_SERVER['PHP_AUTH_PW'];
		// Support for basic auth when using PHP CGI (what about digest auth?)
		if (!isset($username) && !empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION']) && strpos($_SERVER['REDIRECT_HTTP_AUTHORIZATION'],'Basic ') === 0)
		{
			$hash = base64_decode(substr($_SERVER['REDIRECT_HTTP_AUTHORIZATION'],6));
			if (strpos($hash, ':') !== false)
			{
				list($username, $password) = explode(':', $hash, 2);
			}
		}
		elseif (isset($_SERVER['PHP_AUTH_DIGEST']) && !self::is_valid($realm,$_SERVER['PHP_AUTH_DIGEST'],$username,$password))
		{
			unset($password);
		}
		elseif ((isset($_SERVER['HTTP_AUTHORIZATION']) && preg_match('/^Bearer (.+)$/i', $_SERVER['HTTP_AUTHORIZATION'], $matches) ||
			!empty($_COOKIE['oauth_id_token']) && ($matches = [1 => $_COOKIE['oauth_id_token']])) &&
			class_exists('EGroupware\OpenID\Token') && ($token = (new Token())->validate($matches[1], "PT5M", $client)))
		{
			$username = $token->getClaim('sub');
			unset($password);
			$auth_check = false;    // we just checked the authentication
			// set session->limits from app-* scopes, thought we need to check the client itself, as the token contains only requested scopes
			// we always set "api", as setting nothing give full access like for the user itself
			$session->limits = ['api' => true];
			foreach($client->getScopes() ?? [] as $scope)
			{
				if (str_starts_with($scope, 'app-'))
				{
					$session->limits[substr($scope, 4)] = true;
				}
			}
		}
		// if given password contains non-ascii chars AND we can not authenticate with it
		if (isset($username) && isset($password) &&
			(preg_match('/[^\x20-\x7F]/', $password) || strpos($password, '\\x') !== false) &&
			!$GLOBALS['egw']->auth->authenticate($username, $password, 'text'))
		{
			self::decode_password($password);
		}
		// create session without session cookie (session->create(..., true), as we use pseudo sessionid from credentials
		if (!isset($username) || !($sessionid = $session->create($username, $password, 'text', true, $auth_check ?? true)))
		{
			// if the session class gives a reason why the login failed --> append it to the REALM
			if ($GLOBALS['egw']->session->reason &&
				// not for bad-login-or-password as it stalls storing the credentials!
				$GLOBALS['egw']->session->cd_reason != Api\Session::CD_BAD_LOGIN_OR_PASSWORD)
			{
				$realm .= ': '.$GLOBALS['egw']->session->reason;
			}
			header('WWW-Authenticate: Basic realm="'.$realm.'"');// draft-reschke-basicauth-enc-06 adds, accept-charset="'.Api\Translation::charset().'"');
			self::digest_header($realm);
			header('HTTP/1.1 401 Unauthorized');
			header('X-WebDAV-Status: 401 Unauthorized', true);
			echo "<html>\n<head>\n<title>401 Unauthorized</title>\n<body>\nAuthorization failed.\n</body>\n</html>\n";
			exit;
		}
		return $sessionid;
	}

	/**
	 * Decode password containing non-ascii chars
	 *
	 * @param string &$password
	 * @return boolean true if conversation happend, false if there was no need for a conversation
	 */
	public static function decode_password(&$password)
	{
		// if given password contains non-ascii chars AND we can not authenticate with it
		if (preg_match('/[^\x20-\x7F]/', $password) || strpos($password, '\\x') !== false)
		{
			// replace \x encoded non-ascii chars in password, as they are used eg. by Thunderbird for German umlauts
			if (strpos($password, '\\x') !== false)
			{
				$password = preg_replace_callback('/\\\\x([0-9A-F]{2})/i', function($matches){
					return chr(hexdec($matches[1]));
				}, $password);
			}
			// try translating the password from iso-8859-1 to utf-8
			$password = Api\Translation::convert($password, 'iso-8859-1');
			//error_log(__METHOD__."() Fixed non-ascii password of user '$username' from '$_SERVER[PHP_AUTH_PW]' to '$password'");
			return true;
		}
		return false;
	}

	/**
	 * Check if digest auth is available for a given realm (and user): do we use cleartext passwords
	 *
	 * If no user is given, check is NOT authoritative, as we can only check if cleartext passwords are generally used
	 *
	 * @param string $realm
	 * @param string $username =null username or null to only check if we auth agains sql and use plaintext passwords
	 * @param string &$user_pw =null stored cleartext password, if $username given AND function returns true
	 * @return boolean true if digest auth is available, false otherwise
	 */
	static public function digest_auth_available($realm,$username=null,&$user_pw=null)
	{
		// we currently require plaintext passwords!
		if (!($GLOBALS['egw_info']['server']['auth_type'] == 'sql' && $GLOBALS['egw_info']['server']['sql_encryption_type'] == 'plain') ||
			  $GLOBALS['egw_info']['server']['auth_type'] == 'ldap' && $GLOBALS['egw_info']['server']['ldap_encryption_type'] == 'plain')
		{
			if (self::ERROR_LOG) error_log(__METHOD__."('$username') return false (no plaintext passwords used)");
			return false;	// no plain-text passwords used
		}
		// check for specific user, if given
		if (!is_null($username) && !(($user_pw = $GLOBALS['egw']->accounts->id2name($username,'account_pwd','u')) ||
			$GLOBALS['egw_info']['server']['auth_type'] == 'sql' && substr($user_pw,0,7) != '{PLAIN}'))
		{
			unset($user_pw);
			if (self::ERROR_LOG) error_log(__METHOD__."('$realm','$username') return false (unknown user or NO plaintext password for user)");
			return false;	// user does NOT exist, or has no plaintext passwords (ldap server requires real root_dn or special ACL!)
		}
		if (substr($user_pw,0,7) == '{PLAIN}') $user_pw = substr($user_pw,7);

		if (self::ERROR_LOG)
		{
			$pw = self::ERROR_LOG > 1 ? $user_pw : '**********';
			error_log(__METHOD__."('$realm','$username','$pw') return true");
		}
		return true;
	}

	/**
	 * Send header offering digest auth, if it's generally available
	 *
	 * @param string $realm
	 * @param string &$nonce=null on return
	 */
	static public function digest_header($realm,&$nonce=null)
	{
		if (self::digest_auth_available($realm))
		{
			$nonce = Api\Auth::randomstring();
   			header('WWW-Authenticate: Digest realm="'.$realm.'",qop="auth",nonce="'.$nonce.'",opaque="'.md5($realm).'"');
			if (self::ERROR_LOG) error_log(__METHOD__."() offering digest auth for realm '$realm' using nonce='$nonce'");
		}
	}

	/**
	 * Check digest
	 *
	 * @param string $realm
	 * @param string $auth_digest =null default to $_SERVER['PHP_AUTH_DIGEST']
	 * @param string &$username on return username
	 * @param string &$password on return cleartext password
	 * @return boolean true if digest is correct, false otherwise
	 */
	static public function is_valid($realm,$auth_digest=null,&$username=null,&$password=null)
	{
		if (is_null($auth_digest)) $auth_digest = $_SERVER['PHP_AUTH_DIGEST'];

		$data = self::parse_digest($auth_digest);

		$password = null;
		if (!$data || !($A1 = self::get_digest_A1($realm,$username=$data['username'],$password)))
		{
			error_log(__METHOD__."('$realm','$auth_digest','$username') returning FALSE");
			return false;
		}
		$A2 = md5($_SERVER['REQUEST_METHOD'].':'.$data['uri']);

		$valid_response = md5($A1.':'.$data['nonce'].':'.$data['nc'].':'.$data['cnonce'].':'.$data['qop'].':'.$A2);

		if (self::ERROR_LOG) error_log(__METHOD__."('$realm','$auth_digest','$username') response='$data[response]', valid_response='$valid_response' returning ".array2string($data['response'] === $valid_response));
		return $data['response'] === $valid_response;
	}

	/**
	 * Calculate the A1 digest hash
	 *
	 * @param string $realm
	 * @param string $username
	 * @param string &$password=null password to use or if null, on return stored password
	 * @return string|boolean false if $password not given and can NOT be read
	 */
	static private function get_digest_A1($realm,$username,&$password=null)
	{
		$user_pw = null;
		if (empty($username) || empty($realm) || !self::digest_auth_available($realm,$username,$user_pw))
		{
			return false;
		}
		if (is_null($password)) $password = $user_pw;

		$A1 = md5($username . ':' . $realm . ':' . $password);
		if (self::ERROR_LOG > 1) error_log(__METHOD__."('$realm','$username','$password') returning ".array2string($A1));
		return $A1;
	}

	/**
	 * Parse the http auth header
	 */
	static public function parse_digest($txt)
	{
	    // protect against missing data
	    $needed_parts = array('nonce'=>1, 'nc'=>1, 'cnonce'=>1, 'qop'=>1, 'username'=>1, 'uri'=>1, 'response'=>1);
	    $data = array();
	    $keys = implode('|', array_keys($needed_parts));

		$matches = null;
	    preg_match_all('@(' . $keys . ')=(?:([\'"])([^\2]+?)\2|([^\s,]+))@', $txt, $matches, PREG_SET_ORDER);

	    foreach ($matches as $m)
	    {
	        $data[$m[1]] = $m[3] ? $m[3] : $m[4];
	        unset($needed_parts[$m[1]]);
	    }
	    //error_log(__METHOD__."('$txt') returning ".array2string($needed_parts ? false : $data));
	    return $needed_parts ? false : $data;
	}
}