From 930f1052d51e31d886fc7f6acd4114fd315c4d21 Mon Sep 17 00:00:00 2001 From: Ralf Becker Date: Wed, 5 May 2010 09:19:37 +0000 Subject: [PATCH] supporting digest auth (see RFC 2617), which is more secure then basic auth on http (no cleartext password), it currently requires cleartext passwords in the database, to calculate the A1 hash! --- groupdav.php | 40 ++--- phpgwapi/inc/class.egw.inc.php | 8 +- phpgwapi/inc/class.egw_digest_auth.inc.php | 196 +++++++++++++++++++++ phpgwapi/inc/class.egw_session.inc.php | 15 +- webdav.php | 25 +-- 5 files changed, 231 insertions(+), 53 deletions(-) create mode 100644 phpgwapi/inc/class.egw_digest_auth.inc.php diff --git a/groupdav.php b/groupdav.php index ff511b78c3..bfe90c7362 100644 --- a/groupdav.php +++ b/groupdav.php @@ -9,43 +9,23 @@ * @package api * @subpackage groupdav * @author Ralf Becker - * @copyright (c) 2007-9 by Ralf Becker + * @copyright (c) 2007-10 by Ralf Becker * @version $Id$ */ $starttime = microtime(true); -/** - * check if the given user has access - * - * Create a session or if the user has no account return authenticate header and 401 Unauthorized - * - * @param array &$account - * @return int session-id - */ -function check_access(&$account) -{ - if (!isset($_SERVER['PHP_AUTH_USER']) || - !($sessionid = $GLOBALS['egw']->session->create($_SERVER['PHP_AUTH_USER'],$_SERVER['PHP_AUTH_PW'],'text'))) - { - header('WWW-Authenticate: Basic realm="'.groupdav::REALM. - // if the session class gives a reason why the login failed --> append it to the REALM - ($GLOBALS['egw']->session->reason ? ': '.$GLOBALS['egw']->session->reason : '').'"'); - header('HTTP/1.1 401 Unauthorized'); - header('X-WebDAV-Status: 401 Unauthorized', true); - echo "\n\n401 Unauthorized\n\nAuthorization failed.\n\n\n"; - exit; - } - return $sessionid; -} - -$GLOBALS['egw_info']['flags'] = array( - 'noheader' => True, - 'currentapp' => 'groupdav', - 'autocreate_session_callback' => 'check_access', - 'no_exception_handler' => 'basic_auth', // we use a basic auth exception handler (sends exception message as basic auth realm) +$GLOBALS['egw_info'] = array( + 'flags' => array( + 'noheader' => True, + 'currentapp' => 'groupdav', + 'no_exception_handler' => 'basic_auth', // we use a basic auth exception handler (sends exception message as basic auth realm) + 'autocreate_session_callback' => array('egw_digest_auth','autocreate_session_callback'), + 'auth_realm' => 'EGroupware CalDAV/CardDAV/GroupDAV server', // cant use groupdav::REALM as autoloading and include path not yet setup! + ) ); // if you move this file somewhere else, you need to adapt the path to the header! +require_once('phpgwapi/inc/class.egw_digest_auth.inc.php'); include(dirname(__FILE__).'/header.inc.php'); $headertime = microtime(true); diff --git a/phpgwapi/inc/class.egw.inc.php b/phpgwapi/inc/class.egw.inc.php index 3247bd4e0e..2d84e13139 100644 --- a/phpgwapi/inc/class.egw.inc.php +++ b/phpgwapi/inc/class.egw.inc.php @@ -301,8 +301,8 @@ class egw extends egw_minimal // check if we have a session, if not try to automatic create one if ($this->session->verify()) return true; - if (($account_callback = $GLOBALS['egw_info']['flags']['autocreate_session_callback']) && function_exists($account_callback) && - ($sessionid = $account_callback($account)) === true) // $account_call_back returns true, false or a session-id + if (($account_callback = $GLOBALS['egw_info']['flags']['autocreate_session_callback']) && is_callable($account_callback) && + ($sessionid = call_user_func_array($account_callback,array(&$account))) === true) // $account_call_back returns true, false or a session-id { $sessionid = $this->session->create($account); } @@ -481,6 +481,10 @@ class egw extends egw_minimal print("\n\n"); } @ob_flush(); flush(); + + // commit session (if existing), to fix timing problems sometimes preventing session creation ("Your session can not be verified") + if (isset($GLOBALS['egw']->session)) $GLOBALS['egw']->session->commit_session(); + exit; } diff --git a/phpgwapi/inc/class.egw_digest_auth.inc.php b/phpgwapi/inc/class.egw_digest_auth.inc.php new file mode 100644 index 0000000000..eb4816c24e --- /dev/null +++ b/phpgwapi/inc/class.egw_digest_auth.inc.php @@ -0,0 +1,196 @@ + + * @copyright (c) 2010 by Ralf Becker + * @version $Id$ + */ + +/** + * 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' => array('egw_digest_auth','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 + */ +class egw_digest_auth +{ + /** + * Log to error_log: + * 0 = dont + * 1 = no cleartext passwords + * 2 = all + */ + const ERROR_LOG = 1; + + /** + * 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) + { + if (self::ERROR_LOG) error_log(__METHOD__.'() PHP_AUTH_USER='.array2string($_SERVER['PHP_AUTH_USER']).', PHP_AUTH_PW='.array2string($_SERVER['PHP_AUTH_PW']).', PHP_AUTH_DIGEST='.array2string($_SERVER['PHP_AUTH_DIGEST'])); + $realm = $GLOBALS['egw_info']['flags']['auth_realm']; + if (empty($realm)) $realm = 'EGroupware'; + + if (!isset($_SERVER['PHP_AUTH_USER']) && !isset($_SERVER['PHP_AUTH_DIGEST']) || + isset($_SERVER['PHP_AUTH_DIGEST']) && (!self::is_valid($realm,$_SERVER['PHP_AUTH_DIGEST'],$username,$password) || + !($sessionid = $GLOBALS['egw']->session->create($username,$password,'text'))) || + isset($_SERVER['PHP_AUTH_USER']) && !($sessionid = $GLOBALS['egw']->session->create($_SERVER['PHP_AUTH_USER'],$_SERVER['PHP_AUTH_PW'],'text'))) + { + // if the session class gives a reason why the login failed --> append it to the REALM + if ($GLOBALS['egw']->session->reason) $realm .= ': '.$GLOBALS['egw']->session->reason; + + header('WWW-Authenticate: Basic realm="'.$realm.'"'); + self::digest_header($realm); + header('HTTP/1.1 401 Unauthorized'); + header('X-WebDAV-Status: 401 Unauthorized', true); + echo "\n\n401 Unauthorized\n\nAuthorization failed.\n\n\n"; + exit; + } + return $sessionid; + } + + /** + * 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 authoretive, 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) error_log(__METHOD__."('$realm','$username','$user_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 = uniqid(); + 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); + + if (!$data || !($A1 = self::get_digest_A1($realm,$username=$data['username'],$password=null))) + { + 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) + { + 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)); + + 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; + } +} diff --git a/phpgwapi/inc/class.egw_session.inc.php b/phpgwapi/inc/class.egw_session.inc.php index f6b4f568db..8a9dde7584 100644 --- a/phpgwapi/inc/class.egw_session.inc.php +++ b/phpgwapi/inc/class.egw_session.inc.php @@ -206,7 +206,7 @@ class egw_session $GLOBALS['egw_info']['server']['max_history'] = 20; $save_rep = true; } - + if ($save_rep) { $config = new config('phpgwapi'); @@ -741,6 +741,15 @@ class egw_session // we generate a pseudo-sessionid from the basic auth credentials $sessionid = md5($_SERVER['PHP_AUTH_USER'].':'.$_SERVER['PHP_AUTH_PW'].':'.$_SERVER['HTTP_HOST'].':'.EGW_SERVER_ROOT.':'.self::getuser_ip()); } + // same for digest auth + elseif (isset($_SERVER['PHP_AUTH_DIGEST']) && + in_array(basename($_SERVER['SCRIPT_NAME']),array('webdav.php','groupdav.php'))) + { + // we generate a pseudo-sessionid from the digest username, realm and nounce + // can't use full $_SERVER['PHP_AUTH_DIGEST'], as it changes (contains eg. the url) + $data = egw_digest_auth::parse_digest($_SERVER['PHP_AUTH_DIGEST']); + $sessionid = md5($data['username'].':'.$data['realm'].':'.$data['nonce'].':'.$_SERVER['HTTP_HOST'].':'.EGW_SERVER_ROOT.':'.self::getuser_ip()); + } elseif(!$only_basic_auth && isset($_REQUEST[self::EGW_SESSION_NAME])) { $sessionid = $_REQUEST[self::EGW_SESSION_NAME]; @@ -1254,7 +1263,7 @@ class egw_session public static function search_instance($login,$domain_requested,&$default_domain,$server_name,array $domains=null) { if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."('$login','$domain_requested',".array2string($default_domain).".'$server_name'.".array2string($domains).")"); - + if (is_null($domains)) $domains = $GLOBALS['egw_domain']; if (!isset($default_domain) || !isset($domains[$default_domain])) // allow to overwrite the default domain @@ -1299,7 +1308,7 @@ class egw_session $domain = $default_domain; } if (self::ERROR_LOG_DEBUG) error_log(__METHOD__."() default_domain=".array2string($default_domain).', login='.array2string($login)." returning ".array2string($domain)); - + return $domain; } diff --git a/webdav.php b/webdav.php index 5cb1b17107..4bae0530ee 100644 --- a/webdav.php +++ b/webdav.php @@ -9,7 +9,7 @@ * @package api * @subpackage vfs * @author Ralf Becker - * @copyright (c) 2006-9 by Ralf Becker + * @copyright (c) 2006-10 by Ralf Becker * @version $Id$ */ @@ -25,25 +25,11 @@ $starttime = microtime(true); */ function check_access(&$account) { - if (isset($_SERVER['PHP_AUTH_USER'])) + if (isset($_GET['auth'])) { - $user = $_SERVER['PHP_AUTH_USER']; - $pass = $_SERVER['PHP_AUTH_PW']; + list($_SERVER['PHP_AUTH_USER'],$_SERVER['PHP_AUTH_PW']) = explode(':',base64_decode($_GET['auth']),2); } - elseif(isset($_GET['auth'])) - { - list($user,$pass) = explode(':',base64_decode($_GET['auth']),2); - } - if (!isset($user) || !($sessionid = $GLOBALS['egw']->session->create($user,$pass,'text'))) - { - header('WWW-Authenticate: Basic realm="'.vfs_webdav_server::REALM. - // if the session class gives a reason why the login failed --> append it to the REALM - ($GLOBALS['egw']->session->reason ? ': '.$GLOBALS['egw']->session->reason : '').'"'); - header("HTTP/1.1 401 Unauthorized"); - header("X-WebDAV-Status: 401 Unauthorized", true); - exit; - } - return $sessionid; + return egw_digest_auth::autocreate_session_callback($account); } // if we are called with a /apps/$app path, use that $app as currentapp, to not require filemanager rights for the links @@ -68,8 +54,11 @@ $GLOBALS['egw_info'] = array( 'currentapp' => $app, 'autocreate_session_callback' => 'check_access', 'no_exception_handler' => 'basic_auth', // we use a basic auth exception handler (sends exception message as basic auth realm) + 'auth_realm' => 'EGroupware WebDAV server', // cant use vfs_webdav_server::REALM as autoloading and include path not yet setup! ) ); +require_once('phpgwapi/inc/class.egw_digest_auth.inc.php'); + // if you move this file somewhere else, you need to adapt the path to the header! try {