r4313 bugfix: queryLDAPServer: Undefined offset: 1
[racktables] / wwwroot / inc / auth.php
CommitLineData
b325120a 1<?php
e673ee24
DO
2/*
3
4Authentication library for RackTables.
5
6*/
7
8// This function ensures that we don't continue without a legitimate
d4d873be
DO
9// username and password (also make sure, that both are present, this
10// is especially useful for LDAP auth code to not deceive itself with
b82cce3f 11// anonymous binding). It also initializes $remote_* and $*_tags vars.
204284ba 12// Fatal errors are followed by exit (1) to aid in script debugging.
e673ee24
DO
13function authenticate ()
14{
b82cce3f
DO
15 global
16 $remote_username,
17 $remote_displayname,
18 $auto_tags,
19 $user_given_tags,
20 $user_auth_src,
21 $require_local_account;
22 if (!isset ($user_auth_src) or !isset ($require_local_account))
3a089a44 23 throw new RackTablesError ('secret.php: either user_auth_src or require_local_account are missing', RackTablesError::MISCONFIGURED);
dc9ea133 24 if (isset ($_REQUEST['logout']))
3a089a44 25 throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED); // Reset browser credentials cache.
204284ba 26 switch ($user_auth_src)
e673ee24 27 {
dc9ea133
DO
28 case 'database':
29 case 'ldap':
30 if
31 (
32 !isset ($_SERVER['PHP_AUTH_USER']) or
33 !strlen ($_SERVER['PHP_AUTH_USER']) or
34 !isset ($_SERVER['PHP_AUTH_PW']) or
35 !strlen ($_SERVER['PHP_AUTH_PW'])
36 )
3a089a44 37 throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED);
dc9ea133
DO
38 $remote_username = $_SERVER['PHP_AUTH_USER'];
39 break;
40 case 'httpd':
41 if
42 (
43 !isset ($_SERVER['REMOTE_USER']) or
44 !strlen ($_SERVER['REMOTE_USER'])
45 )
3a089a44 46 throw new RackTablesError ('The web-server didn\'t authenticate the user, although ought to do.', RackTablesError::MISCONFIGURED);
dc9ea133
DO
47 $remote_username = $_SERVER['REMOTE_USER'];
48 break;
49 default:
3a089a44 50 throw new RackTablesError ('Invalid authentication source!', RackTablesError::MISCONFIGURED);
dc9ea133 51 die;
e673ee24 52 }
d16af52f
DO
53 $userinfo = constructUserCell ($remote_username);
54 if ($require_local_account and !isset ($userinfo['user_id']))
3a089a44 55 throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED);
d16af52f
DO
56 $user_given_tags = $userinfo['etags'];
57 $auto_tags = array_merge ($auto_tags, $userinfo['atags']);
dc9ea133
DO
58 switch (TRUE)
59 {
60 // Just trust the server, because the password isn't known.
204284ba 61 case ('httpd' == $user_auth_src):
1d34465d
DO
62 $remote_displayname = strlen ($userinfo['user_realname']) ?
63 $userinfo['user_realname'] :
64 $remote_username;
65 return; // success
dc9ea133 66 // When using LDAP, leave a mean to fix things. Admin user is always authenticated locally.
ea17c8b9 67 case ('database' == $user_auth_src or (array_key_exists ('user_id', $userinfo) and $userinfo['user_id'] == 1)):
1d34465d
DO
68 $remote_displayname = strlen ($userinfo['user_realname']) ?
69 $userinfo['user_realname'] :
70 $remote_username;
d16af52f 71 if (authenticated_via_database ($userinfo, $_SERVER['PHP_AUTH_PW']))
1d34465d
DO
72 return; // success
73 break; // failure
204284ba 74 case ('ldap' == $user_auth_src):
1d34465d
DO
75 $ldap_dispname = '';
76 $ldap_success = authenticated_via_ldap ($remote_username, $_SERVER['PHP_AUTH_PW'], $ldap_dispname);
77 if (!$ldap_success)
78 break; // failure
79 $remote_displayname = strlen ($userinfo['user_realname']) ? // local value is most preferred
80 $userinfo['user_realname'] :
81 (strlen ($ldap_dispname) ? $ldap_dispname : $remote_username); // then one from LDAP
82 return; // success
dc9ea133 83 default:
3a089a44 84 throw new RackTablesError ('Invalid authentication source!', RackTablesError::MISCONFIGURED);
dc9ea133 85 }
3a089a44 86 throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED);
e673ee24
DO
87}
88
da958e52
DO
89// Merge accumulated tags into a single chain, add location-specific
90// autotags and try getting access clearance. Page and tab are mandatory,
91// operation is optional.
46f92ff7 92function permitted ($p = NULL, $t = NULL, $o = NULL, $annex = array())
e673ee24 93{
da958e52 94 global $pageno, $tabno, $op;
7ddb2c05 95 global $auto_tags;
da958e52
DO
96
97 if ($p === NULL)
98 $p = $pageno;
99 if ($t === NULL)
100 $t = $tabno;
b46d20f5
DO
101 if ($o === NULL and strlen ($op)) // $op can be set to empty string
102 $o = $op;
00f887f4
DO
103 $my_auto_tags = $auto_tags;
104 $my_auto_tags[] = array ('tag' => '$page_' . $p);
105 $my_auto_tags[] = array ('tag' => '$tab_' . $t);
b46d20f5 106 if ($o !== NULL) // these tags only make sense in certain cases
00f887f4 107 {
b46d20f5 108 $my_auto_tags[] = array ('tag' => '$op_' . $o);
00f887f4
DO
109 $my_auto_tags[] = array ('tag' => '$any_op');
110 }
da958e52
DO
111 $subject = array_merge
112 (
00f887f4 113 $my_auto_tags,
46f92ff7 114 $annex
da958e52 115 );
1c9621a7
DO
116 // XXX: The solution below is only appropriate for a corner case of a more universal
117 // problem: to make the decision for an entity belonging to a cascade of nested
118 // containers. Each container being an entity itself, it may have own tags (explicit
119 // and implicit accordingly). There's a fixed set of rules (RackCode) with each rule
120 // being able to evaluate any built and given context and produce either a decision
121 // or a lack of decision.
122 // There are several levels of context for the target entity, at least one for entities
123 // belonging directly to the tree root. Each level's context is a union of given
124 // container's tags and the tags of the contained entities.
125 // The universal problem originates from the fact, that certain rules may change
126 // their product as context level changes, thus forcing some final decision (but not
127 // adding a lack of it). With rule code being principles and context cascade being
128 // circumstances, there are two uttermost approaches or moralities.
129 //
130 // Fundamentalism: principles over circumstances. When a rule doesn't produce any
131 // decision, go on to the next rule. When all rules are evaluated, go on to the next
132 // security context level.
133 //
134 // Opportunism: circumstances over principles. With a lack of decision, work with the
135 // same rule, trying to evaluate it against the next level (and next, and next...),
136 // until all levels are tried. Only then go on to the next rule.
137 //
138 // With the above being simple discrete algorythms, I believe, that they very reliably
139 // replicate human behavior. This gives a vast ground for further research, so I would
140 // only note, that the morale used in RackTables is "principles first".
da958e52 141 return gotClearanceForTagChain ($subject);
e673ee24
DO
142}
143
ef0503fc
DO
144// The argument doesn't include explicit and implicit tags. This allows us to derive implicit chain
145// each time we modify the given argument (and work with the modified copy from now on).
146// After the work is done the global $impl_tags is silently modified
147function gotClearanceForTagChain ($const_base)
148{
149 global $rackCode, $expl_tags, $impl_tags;
150 $ptable = array();
151 foreach ($rackCode as $sentence)
152 {
153 switch ($sentence['type'])
154 {
155 case 'SYNT_DEFINITION':
156 $ptable[$sentence['term']] = $sentence['definition'];
157 break;
158 case 'SYNT_GRANT':
159 if (eval_expression ($sentence['condition'], array_merge ($const_base, $expl_tags, $impl_tags), $ptable))
160 switch ($sentence['decision'])
161 {
162 case 'LEX_ALLOW':
163 return TRUE;
164 case 'LEX_DENY':
165 return FALSE;
166 default:
dca557e5 167 throw new RackTablesError ("Condition match for unknown grant decision '${sentence['decision']}'", RackTablesError::INTERNAL);
ef0503fc
DO
168 }
169 break;
170 case 'SYNT_ADJUSTMENT':
171 if
172 (
173 eval_expression ($sentence['condition'], array_merge ($const_base, $expl_tags, $impl_tags), $ptable) and
174 processAdjustmentSentence ($sentence['modlist'], $expl_tags)
175 ) // recalculate implicit chain only after actual change, not just on matched condition
176 $impl_tags = getImplicitTags ($expl_tags); // recalculate
177 break;
178 default:
dca557e5 179 throw new RackTablesError ("Can't process sentence of unknown type '${sentence['type']}'", RackTablesError::INTERNAL);
ef0503fc
DO
180 }
181 }
182 return FALSE;
183}
184
185// Process a context adjustment request, update given chain accordingly,
186// return TRUE on any changes done.
187// The request is a sequence of clear/insert/remove requests exactly as cooked
188// for each SYNT_CTXMODLIST node.
189function processAdjustmentSentence ($modlist, &$chain)
190{
191 global $rackCode;
192 $didChanges = FALSE;
193 foreach ($modlist as $mod)
194 switch ($mod['op'])
195 {
196 case 'insert':
197 foreach ($chain as $etag)
198 if ($etag['tag'] == $mod['tag']) // already there, next request
199 break 2;
200 $search = getTagByName ($mod['tag']);
201 if ($search === NULL) // skip martians silently
202 break;
203 $chain[] = $search;
204 $didChanges = TRUE;
205 break;
206 case 'remove':
207 foreach ($chain as $key => $etag)
208 if ($etag['tag'] == $mod['tag']) // drop first match and return
209 {
210 unset ($chain[$key]);
211 $didChanges = TRUE;
212 break 2;
213 }
214 break;
215 case 'clear':
216 $chain = array();
217 $didChanges = TRUE;
218 break;
219 default: // HCF
220 throw new RackTablesError ('invalid structure', RackTablesError::INTERNAL);
221 }
222 return $didChanges;
223}
224
7c963251 225// a wrapper for two LDAP auth methods below
1d34465d 226function authenticated_via_ldap ($username, $password, &$ldap_displayname)
7c963251
DO
227{
228 global $LDAP_options;
6b06a019
DO
229 if
230 (
7c963251
DO
231 $LDAP_options['cache_retry'] > $LDAP_options['cache_refresh'] or
232 $LDAP_options['cache_refresh'] > $LDAP_options['cache_expiry']
233 )
6b06a019 234 throw new RackTablesError ('LDAP misconfiguration: refresh/retry/expiry mismatch', RackTablesError::MISCONFIGURED);
7c963251 235 if ($LDAP_options['cache_expiry'] == 0) // immediate expiry set means disabled cache
1d34465d 236 return authenticated_via_ldap_nocache ($username, $password, $ldap_displayname);
1f54e1ba
DO
237 // authenticated_via_ldap_cache()'s way of locking can sometimes result in
238 // a PDO error condition, which convertPDOException() was not able to dispatch.
239 // To avoid reaching printPDOException() (which prints backtrace with password
240 // argument in cleartext), any remaining PDO condition is converted locally.
241 try
242 {
243 return authenticated_via_ldap_cache ($username, $password, $ldap_displayname);
244 }
245 catch (PDOException $e)
246 {
247 throw new RackTablesError ('LDAP caching error', RackTablesError::DB_WRITE_FAILED);
248 }
7c963251
DO
249}
250
251// Authenticate given user with known LDAP server, completely ignore LDAP cache data.
1d34465d 252function authenticated_via_ldap_nocache ($username, $password, &$ldap_displayname)
7c963251 253{
1d34465d 254 global $auto_tags;
7c963251
DO
255 $server_test = queryLDAPServer ($username, $password);
256 if ($server_test['result'] == 'ACK')
257 {
1d34465d 258 $ldap_displayname = $server_test['displayed_name'];
7c963251
DO
259 foreach ($server_test['memberof'] as $autotag)
260 $auto_tags[] = array ('tag' => $autotag);
261 return TRUE;
262 }
263 return FALSE;
264}
265
266// Idem, but consider existing data in cache and modify/discard it, when necessary.
267// Remember to have releaseLDAPCache() called before any return statement.
268// Perform cache maintenance on each update.
1d34465d 269function authenticated_via_ldap_cache ($username, $password, &$ldap_displayname)
9133d2c5 270{
1d34465d 271 global $LDAP_options, $auto_tags;
9133d2c5 272
ef44d4a3
DO
273 // Destroy the cache each time config changes.
274 if (sha1 (serialize ($LDAP_options)) != loadScript ('LDAPConfigHash'))
275 {
276 discardLDAPCache();
277 saveScript ('LDAPConfigHash', sha1 (serialize ($LDAP_options)));
278 }
279 $oldinfo = acquireLDAPCache ($username, sha1 ($password), $LDAP_options['cache_expiry']);
9133d2c5
DO
280 if ($oldinfo === NULL) // cache miss
281 {
282 // On cache miss execute complete procedure and return the result. In case
283 // of successful authentication put a record into cache.
284 $newinfo = queryLDAPServer ($username, $password);
285 if ($newinfo['result'] == 'ACK')
286 {
1d34465d 287 $ldap_displayname = $newinfo['displayed_name'];
9133d2c5
DO
288 foreach ($newinfo['memberof'] as $autotag)
289 $auto_tags[] = array ('tag' => $autotag);
290 replaceLDAPCacheRecord ($username, sha1 ($password), $newinfo['displayed_name'], $newinfo['memberof']);
7c963251
DO
291 releaseLDAPCache();
292 discardLDAPCache ($LDAP_options['cache_expiry']);
293 return TRUE;
9133d2c5
DO
294 }
295 releaseLDAPCache();
7c963251 296 return FALSE;
9133d2c5 297 }
7c963251 298 // cache HIT
9133d2c5
DO
299 // There are two confidence levels of cache hits: "certain" and "uncertain". In either case
300 // expect authentication success, unless it's well-timed to perform a retry,
301 // which may sometimes bring a NAK decision.
ef44d4a3 302 if ($oldinfo['success_age'] < $LDAP_options['cache_refresh'] or $oldinfo['retry_age'] < $LDAP_options['cache_retry'])
9133d2c5
DO
303 {
304 releaseLDAPCache();
1d34465d 305 $ldap_displayname = $oldinfo['displayed_name'];
9133d2c5
DO
306 foreach ($oldinfo['memberof'] as $autotag)
307 $auto_tags[] = array ('tag' => $autotag);
308 return TRUE;
309 }
310 // Either refresh threshold or retry threshold reached.
311 $newinfo = queryLDAPServer ($username, $password);
312 switch ($newinfo['result'])
313 {
314 case 'ACK': // refresh existing record
1d34465d 315 $ldap_displayname = $newinfo['displayed_name'];
9133d2c5
DO
316 foreach ($newinfo['memberof'] as $autotag)
317 $auto_tags[] = array ('tag' => $autotag);
318 replaceLDAPCacheRecord ($username, sha1 ($password), $newinfo['displayed_name'], $newinfo['memberof']);
319 releaseLDAPCache();
7c963251 320 discardLDAPCache ($LDAP_options['cache_expiry']);
9133d2c5
DO
321 return TRUE;
322 case 'NAK': // The record isn't valid any more.
323 deleteLDAPCacheRecord ($username);
324 releaseLDAPCache();
7c963251 325 discardLDAPCache ($LDAP_options['cache_expiry']);
9133d2c5
DO
326 return FALSE;
327 case 'CAN': // retry failed, do nothing, use old value till next retry
1d34465d 328 $ldap_displayname = $oldinfo['displayed_name'];
9133d2c5
DO
329 foreach ($oldinfo['memberof'] as $autotag)
330 $auto_tags[] = array ('tag' => $autotag);
331 touchLDAPCacheRecord ($username);
332 releaseLDAPCache();
7c963251 333 discardLDAPCache ($LDAP_options['cache_expiry']);
9133d2c5
DO
334 return TRUE;
335 default:
561a9321 336 throw new InvalidArgException ('result', $newinfo['result'], 'Internal error during LDAP cache dispatching');
9133d2c5
DO
337 }
338 // This is never reached.
339 return FALSE;
340}
341
342// Attempt a server conversation and return an array describing the outcome:
343//
344// 'result' => 'CAN' : connect (or search) failed completely
345//
346// 'result' => 'NAK' : server replied and denied access (or search returned odd data)
347//
348// 'result' => 'ACK' : server replied and cleared access, there were no search errors
ef44d4a3 349// 'displayed_name' : a string built according to LDAP displayname_attrs option
9133d2c5
DO
350// 'memberof' => filtered list of all LDAP groups the user belongs to
351//
352function queryLDAPServer ($username, $password)
7dfd5e44 353{
ef44d4a3 354 global $LDAP_options;
9133d2c5 355
7dbf4617
DO
356 if(extension_loaded('ldap') === FALSE)
357 throw new RackTablesError ('LDAP misconfiguration. LDAP PHP Module is not installed.', RackTablesError::MISCONFIGURED);
358
ef44d4a3 359 $connect = @ldap_connect ($LDAP_options['server']);
9133d2c5
DO
360 if ($connect === FALSE)
361 return array ('result' => 'CAN');
362
363 // Decide on the username we will actually authenticate for.
59a83bd8 364 if (isset ($LDAP_options['domain']) and strlen ($LDAP_options['domain']))
ef44d4a3 365 $auth_user_name = $username . "@" . $LDAP_options['domain'];
9133d2c5
DO
366 elseif
367 (
ef44d4a3 368 isset ($LDAP_options['search_dn']) and
59a83bd8 369 strlen ($LDAP_options['search_dn']) and
ef44d4a3 370 isset ($LDAP_options['search_attr']) and
59a83bd8 371 strlen ($LDAP_options['search_attr'])
9133d2c5 372 )
8c3bd904 373 {
ef44d4a3 374 $results = @ldap_search ($connect, $LDAP_options['search_dn'], '(' . $LDAP_options['search_attr'] . "=${username})", array("dn"));
9133d2c5
DO
375 if ($results === FALSE)
376 return array ('result' => 'CAN');
377 if (@ldap_count_entries ($connect, $results) != 1)
8c3bd904 378 {
9133d2c5
DO
379 @ldap_close ($connect);
380 return array ('result' => 'NAK');
d6d79c36 381 }
9133d2c5
DO
382 $info = @ldap_get_entries ($connect, $results);
383 ldap_free_result ($results);
384 $auth_user_name = $info[0]['dn'];
385 }
386 else
3a089a44 387 throw new RackTablesError ('LDAP misconfiguration. Cannon build username for authentication.', RackTablesError::MISCONFIGURED);
a09e3ec7
DO
388 if (array_key_exists ('options', $LDAP_options) and is_array ($LDAP_options['options']))
389 foreach ($LDAP_options['options'] as $opt_code => $opt_value)
390 ldap_set_option ($connect, $opt_code, $opt_value);
9133d2c5
DO
391 $bind = @ldap_bind ($connect, $auth_user_name, $password);
392 if ($bind === FALSE)
393 switch (ldap_errno ($connect))
d6d79c36 394 {
9133d2c5
DO
395 case 49: // LDAP_INVALID_CREDENTIALS
396 return array ('result' => 'NAK');
397 default:
398 return array ('result' => 'CAN');
8c3bd904 399 }
9133d2c5
DO
400 // preliminary decision may change during searching
401 $ret = array ('result' => 'ACK', 'displayed_name' => '', 'memberof' => array());
402 // Some servers deny anonymous search, thus search (if requested) only after binding.
403 // Displayed name only makes sense for authenticated users anyway.
404 if
405 (
59a83bd8
DO
406 isset ($LDAP_options['displayname_attrs']) and
407 strlen ($LDAP_options['displayname_attrs']) and
408 isset ($LDAP_options['search_dn']) and
409 strlen ($LDAP_options['search_dn']) and
410 isset ($LDAP_options['search_attr']) and
411 strlen ($LDAP_options['search_attr'])
9133d2c5
DO
412 )
413 {
414 $results = @ldap_search
415 (
416 $connect,
ef44d4a3
DO
417 $LDAP_options['search_dn'],
418 '(' . $LDAP_options['search_attr'] . "=${username})",
419 array_merge (array ('memberof'), explode (' ', $LDAP_options['displayname_attrs']))
9133d2c5
DO
420 );
421 if (@ldap_count_entries ($connect, $results) != 1)
ae65938e
DO
422 {
423 @ldap_close ($connect);
9133d2c5 424 return array ('result' => 'NAK');
ae65938e 425 }
9133d2c5
DO
426 $info = @ldap_get_entries ($connect, $results);
427 ldap_free_result ($results);
428 $space = '';
ef44d4a3 429 foreach (explode (' ', $LDAP_options['displayname_attrs']) as $attr)
9133d2c5
DO
430 {
431 $ret['displayed_name'] .= $space . $info[0][$attr][0];
432 $space = ' ';
433 }
434 // Pull group membership, if any was returned.
435 if (isset ($info[0]['memberof']))
436 for ($i = 0; $i < $info[0]['memberof']['count']; $i++)
437 foreach (explode (',', $info[0]['memberof'][$i]) as $pair)
438 {
7dd5e6ca
AA
439 $items = explode ('=', $pair);
440 if (count ($items) != 2)
441 continue;
442 list ($attr_name, $attr_value) = $items;
f3e20104 443 if (strtoupper ($attr_name) == 'CN' and validTagName ('$lgcn_' . $attr_value, TRUE))
9133d2c5
DO
444 $ret['memberof'][] = '$lgcn_' . $attr_value;
445 }
8c3bd904 446 }
ae65938e 447 @ldap_close ($connect);
9133d2c5 448 return $ret;
7dfd5e44
DO
449}
450
d16af52f 451function authenticated_via_database ($userinfo, $password)
7dfd5e44 452{
d16af52f 453 if (!isset ($userinfo['user_id'])) // not a local account
b82cce3f 454 return FALSE;
f3371850 455 return $userinfo['user_password_hash'] == sha1 ($password);
e673ee24
DO
456}
457
e673ee24 458?>