Authenticate   C
last analyzed

Complexity

Total Complexity 55

Size/Duplication

Total Lines 217
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 81
dl 0
loc 217
rs 6
c 0
b 0
f 0
wmc 55

7 Methods

Rating   Name   Duplication   Size   Complexity  
A get_digest_A1() 0 12 6
C digest_auth_available() 0 25 14
A digest_header() 0 7 3
D autocreate_session_callback() 0 50 19
A parse_digest() 0 17 4
A decode_password() 0 18 4
A is_valid() 0 17 5

How to fix   Complexity   

Complex Class

Complex classes like Authenticate often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Authenticate, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * EGroupware API: Basic and Digest Auth
4
 *
5
 * For Apache FCGI you need the following rewrite rule:
6
 *
7
 * 	RewriteEngine on
8
 * 	RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization},L]
9
 *
10
 * Otherwise authentication request will be send over and over again, as password is NOT available to PHP!
11
 * (This makes authentication details available in PHP as $_SERVER['REDIRECT_HTTP_AUTHORIZATION']
12
 *
13
 * @link http://www.egroupware.org
14
 * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
15
 * @package api
16
 * @subpackage header
17
 * @author Ralf Becker <RalfBecker-AT-outdoor-training.de>
18
 * @copyright (c) 2010-16 by Ralf Becker <RalfBecker-AT-outdoor-training.de>
19
 * @version $Id$
20
 */
21
22
namespace EGroupware\Api\Header;
23
24
use EGroupware\Api;
25
26
/**
27
 * Class to authenticate via basic or digest auth
28
 *
29
 * The more secure digest auth requires:
30
 *	a) cleartext passwords in SQL table
31
 *	b) md5 hashes of username, realm, password stored somewhere (NOT yet implemented)
32
 * Otherwise digest auth is not possible and therefore not offered to the client.
33
 *
34
 * Usage example:
35
 *
36
 * $GLOBALS['egw_info']['flags'] = array(
37
 * 	'noheader'  => True,
38
 * 	'currentapp' => 'someapp',
39
 * 	'no_exception_handler' => 'basic_auth',	// we use a basic auth exception handler (sends exception message as basic auth realm)
40
 * 	'autocreate_session_callback' => 'EGroupware\\Api\\Header\\Authenticate::autocreate_session_callback',
41
 * 	'auth_realm' => 'EGroupware',
42
 * );
43
 * include(dirname(__FILE__).'/header.inc.php');
44
 *
45
 * @link http://www.php.net/manual/en/features.http-auth.php
46
 * @ToDo check if we have to check if returned nonce matches our challange (not done in above link, but why would it be there)
47
 * @link http://en.wikipedia.org/wiki/Digest_access_authentication
48
 * @link http://tools.ietf.org/html/rfc2617
49
 *
50
 * Commented out is accept-charset parameter from (seems not supported by any client I tested with)
51
 * @link https://tools.ietf.org/id/draft-reschke-basicauth-enc-06.html
52
 *
53
 * Implemented support for clients sending credentials in iso-8859-1 instead of our utf-8:
54
 * - Firefox 19.0
55
 * - Thunderbird 17.0.3 with Lightning 1.8
56
 * - IE 8
57
 * - Netdrive
58
 * (Chrome 24 or Safari 6 sends credentials in charset of webpage.)
59
 */
60
class Authenticate
61
{
62
	/**
63
	 * Log to error_log:
64
	 * 	0 = dont
65
	 *  1 = no cleartext passwords
66
	 *  2 = all
67
	 */
68
	const ERROR_LOG = 0;
69
70
	/**
71
	 * Callback to be used to create session via header include authenticated via basic or digest auth
72
	 *
73
	 * @param array $account NOT used!
74
	 * @return string valid session-id or does NOT return at all!
75
	 */
76
	static public function autocreate_session_callback(&$account)
77
	{
78
		unset($account);	// not used, but required by function signature
79
		if (self::ERROR_LOG)
80
		{
81
			$pw = self::ERROR_LOG > 1 ? $_SERVER['PHP_AUTH_PW'] : '**********';
82
			error_log(__METHOD__.'() PHP_AUTH_USER='.array2string($_SERVER['PHP_AUTH_USER']).', PHP_AUTH_PW='.array2string($pw).', PHP_AUTH_DIGEST='.array2string($_SERVER['PHP_AUTH_DIGEST']));
83
		}
84
		$realm = $GLOBALS['egw_info']['flags']['auth_realm'];
85
		if (empty($realm)) $realm = 'EGroupware';
86
87
		$username = $_SERVER['PHP_AUTH_USER']; $password = $_SERVER['PHP_AUTH_PW'];
88
		// Support for basic auth when using PHP CGI (what about digest auth?)
89
		if (!isset($username) && !empty($_SERVER['REDIRECT_HTTP_AUTHORIZATION']) && strpos($_SERVER['REDIRECT_HTTP_AUTHORIZATION'],'Basic ') === 0)
90
		{
91
			$hash = base64_decode(substr($_SERVER['REDIRECT_HTTP_AUTHORIZATION'],6));
92
			if (strpos($hash, ':') !== false)
93
			{
94
				list($username, $password) = explode(':', $hash, 2);
95
			}
96
		}
97
		elseif (isset($_SERVER['PHP_AUTH_DIGEST']) && !self::is_valid($realm,$_SERVER['PHP_AUTH_DIGEST'],$username,$password))
98
		{
99
			unset($password);
100
		}
101
		// if given password contains non-ascii chars AND we can not authenticate with it
102
		if (isset($username) && isset($password) &&
103
			(preg_match('/[^\x20-\x7F]/', $password) || strpos($password, '\\x') !== false) &&
104
			!$GLOBALS['egw']->auth->authenticate($username, $password, 'text'))
105
		{
106
			self::decode_password($password);
107
		}
108
		// create session without session cookie (session->create(..., true), as we use pseudo sessionid from credentials
109
		if (!isset($username) || !($sessionid = $GLOBALS['egw']->session->create($username, $password, 'text', true)))
110
		{
111
			// if the session class gives a reason why the login failed --> append it to the REALM
112
			if ($GLOBALS['egw']->session->reason &&
113
				// not for bad-login-or-password as it stalls storing the credentials!
114
				$GLOBALS['egw']->session->cd_reason != Api\Session::CD_BAD_LOGIN_OR_PASSWORD)
115
			{
116
				$realm .= ': '.$GLOBALS['egw']->session->reason;
117
			}
118
			header('WWW-Authenticate: Basic realm="'.$realm.'"');// draft-reschke-basicauth-enc-06 adds, accept-charset="'.Api\Translation::charset().'"');
119
			self::digest_header($realm);
120
			header('HTTP/1.1 401 Unauthorized');
121
			header('X-WebDAV-Status: 401 Unauthorized', true);
122
			echo "<html>\n<head>\n<title>401 Unauthorized</title>\n<body>\nAuthorization failed.\n</body>\n</html>\n";
123
			exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
124
		}
125
		return $sessionid;
126
	}
127
128
	/**
129
	 * Decode password containing non-ascii chars
130
	 *
131
	 * @param string &$password
132
	 * @return boolean true if conversation happend, false if there was no need for a conversation
133
	 */
134
	public static function decode_password(&$password)
135
	{
136
		// if given password contains non-ascii chars AND we can not authenticate with it
137
		if (preg_match('/[^\x20-\x7F]/', $password) || strpos($password, '\\x') !== false)
138
		{
139
			// replace \x encoded non-ascii chars in password, as they are used eg. by Thunderbird for German umlauts
140
			if (strpos($password, '\\x') !== false)
141
			{
142
				$password = preg_replace_callback('/\\\\x([0-9A-F]{2})/i', function($matches){
143
					return chr(hexdec($matches[1]));
144
				}, $password);
145
			}
146
			// try translating the password from iso-8859-1 to utf-8
147
			$password = Api\Translation::convert($password, 'iso-8859-1');
148
			//error_log(__METHOD__."() Fixed non-ascii password of user '$username' from '$_SERVER[PHP_AUTH_PW]' to '$password'");
149
			return true;
150
		}
151
		return false;
152
	}
153
154
	/**
155
	 * Check if digest auth is available for a given realm (and user): do we use cleartext passwords
156
	 *
157
	 * If no user is given, check is NOT authoritative, as we can only check if cleartext passwords are generally used
158
	 *
159
	 * @param string $realm
160
	 * @param string $username =null username or null to only check if we auth agains sql and use plaintext passwords
161
	 * @param string &$user_pw =null stored cleartext password, if $username given AND function returns true
162
	 * @return boolean true if digest auth is available, false otherwise
163
	 */
164
	static public function digest_auth_available($realm,$username=null,&$user_pw=null)
165
	{
166
		// we currently require plaintext passwords!
167
		if (!($GLOBALS['egw_info']['server']['auth_type'] == 'sql' && $GLOBALS['egw_info']['server']['sql_encryption_type'] == 'plain') ||
168
			  $GLOBALS['egw_info']['server']['auth_type'] == 'ldap' && $GLOBALS['egw_info']['server']['ldap_encryption_type'] == 'plain')
169
		{
170
			if (self::ERROR_LOG) error_log(__METHOD__."('$username') return false (no plaintext passwords used)");
171
			return false;	// no plain-text passwords used
172
		}
173
		// check for specific user, if given
174
		if (!is_null($username) && !(($user_pw = $GLOBALS['egw']->accounts->id2name($username,'account_pwd','u')) ||
175
			$GLOBALS['egw_info']['server']['auth_type'] == 'sql' && substr($user_pw,0,7) != '{PLAIN}'))
176
		{
177
			unset($user_pw);
178
			if (self::ERROR_LOG) error_log(__METHOD__."('$realm','$username') return false (unknown user or NO plaintext password for user)");
179
			return false;	// user does NOT exist, or has no plaintext passwords (ldap server requires real root_dn or special ACL!)
180
		}
181
		if (substr($user_pw,0,7) == '{PLAIN}') $user_pw = substr($user_pw,7);
182
183
		if (self::ERROR_LOG)
184
		{
185
			$pw = self::ERROR_LOG > 1 ? $user_pw : '**********';
186
			error_log(__METHOD__."('$realm','$username','$pw') return true");
187
		}
188
		return true;
189
	}
190
191
	/**
192
	 * Send header offering digest auth, if it's generally available
193
	 *
194
	 * @param string $realm
195
	 * @param string &$nonce=null on return
196
	 */
197
	static public function digest_header($realm,&$nonce=null)
198
	{
199
		if (self::digest_auth_available($realm))
200
		{
201
			$nonce = uniqid();
202
   			header('WWW-Authenticate: Digest realm="'.$realm.'",qop="auth",nonce="'.$nonce.'",opaque="'.md5($realm).'"');
203
			if (self::ERROR_LOG) error_log(__METHOD__."() offering digest auth for realm '$realm' using nonce='$nonce'");
204
		}
205
	}
206
207
	/**
208
	 * Check digest
209
	 *
210
	 * @param string $realm
211
	 * @param string $auth_digest =null default to $_SERVER['PHP_AUTH_DIGEST']
212
	 * @param string &$username on return username
213
	 * @param string &$password on return cleartext password
214
	 * @return boolean true if digest is correct, false otherwise
215
	 */
216
	static public function is_valid($realm,$auth_digest=null,&$username=null,&$password=null)
217
	{
218
		if (is_null($auth_digest)) $auth_digest = $_SERVER['PHP_AUTH_DIGEST'];
219
220
		$data = self::parse_digest($auth_digest);
221
222
		if (!$data || !($A1 = self::get_digest_A1($realm,$username=$data['username'],$password=null)))
0 ignored issues
show
Bug introduced by
$password = null cannot be passed to EGroupware\Api\Header\Au...ticate::get_digest_A1() as the parameter $password expects a reference. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

222
		if (!$data || !($A1 = self::get_digest_A1($realm,$username=$data['username'],/** @scrutinizer ignore-type */ $password=null)))
Loading history...
introduced by
The condition $data is always false.
Loading history...
223
		{
224
			error_log(__METHOD__."('$realm','$auth_digest','$username') returning FALSE");
225
			return false;
226
		}
227
		$A2 = md5($_SERVER['REQUEST_METHOD'].':'.$data['uri']);
228
229
		$valid_response = md5($A1.':'.$data['nonce'].':'.$data['nc'].':'.$data['cnonce'].':'.$data['qop'].':'.$A2);
230
231
		if (self::ERROR_LOG) error_log(__METHOD__."('$realm','$auth_digest','$username') response='$data[response]', valid_response='$valid_response' returning ".array2string($data['response'] === $valid_response));
232
		return $data['response'] === $valid_response;
233
	}
234
235
	/**
236
	 * Calculate the A1 digest hash
237
	 *
238
	 * @param string $realm
239
	 * @param string $username
240
	 * @param string &$password=null password to use or if null, on return stored password
241
	 * @return string|boolean false if $password not given and can NOT be read
242
	 */
243
	static private function get_digest_A1($realm,$username,&$password=null)
244
	{
245
		$user_pw = null;
246
		if (empty($username) || empty($realm) || !self::digest_auth_available($realm,$username,$user_pw))
247
		{
248
			return false;
249
		}
250
		if (is_null($password)) $password = $user_pw;
251
252
		$A1 = md5($username . ':' . $realm . ':' . $password);
253
		if (self::ERROR_LOG > 1) error_log(__METHOD__."('$realm','$username','$password') returning ".array2string($A1));
254
		return $A1;
255
	}
256
257
	/**
258
	 * Parse the http auth header
259
	 */
260
	static public function parse_digest($txt)
261
	{
262
	    // protect against missing data
263
	    $needed_parts = array('nonce'=>1, 'nc'=>1, 'cnonce'=>1, 'qop'=>1, 'username'=>1, 'uri'=>1, 'response'=>1);
264
	    $data = array();
265
	    $keys = implode('|', array_keys($needed_parts));
266
267
		$matches = null;
268
	    preg_match_all('@(' . $keys . ')=(?:([\'"])([^\2]+?)\2|([^\s,]+))@', $txt, $matches, PREG_SET_ORDER);
269
270
	    foreach ($matches as $m)
271
	    {
272
	        $data[$m[1]] = $m[3] ? $m[3] : $m[4];
273
	        unset($needed_parts[$m[1]]);
274
	    }
275
	    //error_log(__METHOD__."('$txt') returning ".array2string($needed_parts ? false : $data));
276
	    return $needed_parts ? false : $data;
0 ignored issues
show
introduced by
$needed_parts is a non-empty array, thus is always true.
Loading history...
277
	}
278
}
279