|
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; |
|
|
|
|
|
|
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))) |
|
|
|
|
|
|
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; |
|
|
|
|
|
|
277
|
|
|
} |
|
278
|
|
|
} |
|
279
|
|
|
|
In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.