r4313 bugfix: queryLDAPServer: Undefined offset: 1
[racktables] / wwwroot / inc / auth.php
1 <?php
2 /*
3
4 Authentication library for RackTables.
5
6 */
7
8 // This function ensures that we don't continue without a legitimate
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
11 // anonymous binding). It also initializes $remote_* and $*_tags vars.
12 // Fatal errors are followed by exit (1) to aid in script debugging.
13 function authenticate ()
14 {
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))
23 throw new RackTablesError ('secret.php: either user_auth_src or require_local_account are missing', RackTablesError::MISCONFIGURED);
24 if (isset ($_REQUEST['logout']))
25 throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED); // Reset browser credentials cache.
26 switch ($user_auth_src)
27 {
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 )
37 throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED);
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 )
46 throw new RackTablesError ('The web-server didn\'t authenticate the user, although ought to do.', RackTablesError::MISCONFIGURED);
47 $remote_username = $_SERVER['REMOTE_USER'];
48 break;
49 default:
50 throw new RackTablesError ('Invalid authentication source!', RackTablesError::MISCONFIGURED);
51 die;
52 }
53 $userinfo = constructUserCell ($remote_username);
54 if ($require_local_account and !isset ($userinfo['user_id']))
55 throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED);
56 $user_given_tags = $userinfo['etags'];
57 $auto_tags = array_merge ($auto_tags, $userinfo['atags']);
58 switch (TRUE)
59 {
60 // Just trust the server, because the password isn't known.
61 case ('httpd' == $user_auth_src):
62 $remote_displayname = strlen ($userinfo['user_realname']) ?
63 $userinfo['user_realname'] :
64 $remote_username;
65 return; // success
66 // When using LDAP, leave a mean to fix things. Admin user is always authenticated locally.
67 case ('database' == $user_auth_src or (array_key_exists ('user_id', $userinfo) and $userinfo['user_id'] == 1)):
68 $remote_displayname = strlen ($userinfo['user_realname']) ?
69 $userinfo['user_realname'] :
70 $remote_username;
71 if (authenticated_via_database ($userinfo, $_SERVER['PHP_AUTH_PW']))
72 return; // success
73 break; // failure
74 case ('ldap' == $user_auth_src):
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
83 default:
84 throw new RackTablesError ('Invalid authentication source!', RackTablesError::MISCONFIGURED);
85 }
86 throw new RackTablesError ('', RackTablesError::NOT_AUTHENTICATED);
87 }
88
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.
92 function permitted ($p = NULL, $t = NULL, $o = NULL, $annex = array())
93 {
94 global $pageno, $tabno, $op;
95 global $auto_tags;
96
97 if ($p === NULL)
98 $p = $pageno;
99 if ($t === NULL)
100 $t = $tabno;
101 if ($o === NULL and strlen ($op)) // $op can be set to empty string
102 $o = $op;
103 $my_auto_tags = $auto_tags;
104 $my_auto_tags[] = array ('tag' => '$page_' . $p);
105 $my_auto_tags[] = array ('tag' => '$tab_' . $t);
106 if ($o !== NULL) // these tags only make sense in certain cases
107 {
108 $my_auto_tags[] = array ('tag' => '$op_' . $o);
109 $my_auto_tags[] = array ('tag' => '$any_op');
110 }
111 $subject = array_merge
112 (
113 $my_auto_tags,
114 $annex
115 );
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".
141 return gotClearanceForTagChain ($subject);
142 }
143
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
147 function 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:
167 throw new RackTablesError ("Condition match for unknown grant decision '${sentence['decision']}'", RackTablesError::INTERNAL);
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:
179 throw new RackTablesError ("Can't process sentence of unknown type '${sentence['type']}'", RackTablesError::INTERNAL);
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.
189 function 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
225 // a wrapper for two LDAP auth methods below
226 function authenticated_via_ldap ($username, $password, &$ldap_displayname)
227 {
228 global $LDAP_options;
229 if
230 (
231 $LDAP_options['cache_retry'] > $LDAP_options['cache_refresh'] or
232 $LDAP_options['cache_refresh'] > $LDAP_options['cache_expiry']
233 )
234 throw new RackTablesError ('LDAP misconfiguration: refresh/retry/expiry mismatch', RackTablesError::MISCONFIGURED);
235 if ($LDAP_options['cache_expiry'] == 0) // immediate expiry set means disabled cache
236 return authenticated_via_ldap_nocache ($username, $password, $ldap_displayname);
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 }
249 }
250
251 // Authenticate given user with known LDAP server, completely ignore LDAP cache data.
252 function authenticated_via_ldap_nocache ($username, $password, &$ldap_displayname)
253 {
254 global $auto_tags;
255 $server_test = queryLDAPServer ($username, $password);
256 if ($server_test['result'] == 'ACK')
257 {
258 $ldap_displayname = $server_test['displayed_name'];
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.
269 function authenticated_via_ldap_cache ($username, $password, &$ldap_displayname)
270 {
271 global $LDAP_options, $auto_tags;
272
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']);
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 {
287 $ldap_displayname = $newinfo['displayed_name'];
288 foreach ($newinfo['memberof'] as $autotag)
289 $auto_tags[] = array ('tag' => $autotag);
290 replaceLDAPCacheRecord ($username, sha1 ($password), $newinfo['displayed_name'], $newinfo['memberof']);
291 releaseLDAPCache();
292 discardLDAPCache ($LDAP_options['cache_expiry']);
293 return TRUE;
294 }
295 releaseLDAPCache();
296 return FALSE;
297 }
298 // cache HIT
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.
302 if ($oldinfo['success_age'] < $LDAP_options['cache_refresh'] or $oldinfo['retry_age'] < $LDAP_options['cache_retry'])
303 {
304 releaseLDAPCache();
305 $ldap_displayname = $oldinfo['displayed_name'];
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
315 $ldap_displayname = $newinfo['displayed_name'];
316 foreach ($newinfo['memberof'] as $autotag)
317 $auto_tags[] = array ('tag' => $autotag);
318 replaceLDAPCacheRecord ($username, sha1 ($password), $newinfo['displayed_name'], $newinfo['memberof']);
319 releaseLDAPCache();
320 discardLDAPCache ($LDAP_options['cache_expiry']);
321 return TRUE;
322 case 'NAK': // The record isn't valid any more.
323 deleteLDAPCacheRecord ($username);
324 releaseLDAPCache();
325 discardLDAPCache ($LDAP_options['cache_expiry']);
326 return FALSE;
327 case 'CAN': // retry failed, do nothing, use old value till next retry
328 $ldap_displayname = $oldinfo['displayed_name'];
329 foreach ($oldinfo['memberof'] as $autotag)
330 $auto_tags[] = array ('tag' => $autotag);
331 touchLDAPCacheRecord ($username);
332 releaseLDAPCache();
333 discardLDAPCache ($LDAP_options['cache_expiry']);
334 return TRUE;
335 default:
336 throw new InvalidArgException ('result', $newinfo['result'], 'Internal error during LDAP cache dispatching');
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
349 // 'displayed_name' : a string built according to LDAP displayname_attrs option
350 // 'memberof' => filtered list of all LDAP groups the user belongs to
351 //
352 function queryLDAPServer ($username, $password)
353 {
354 global $LDAP_options;
355
356 if(extension_loaded('ldap') === FALSE)
357 throw new RackTablesError ('LDAP misconfiguration. LDAP PHP Module is not installed.', RackTablesError::MISCONFIGURED);
358
359 $connect = @ldap_connect ($LDAP_options['server']);
360 if ($connect === FALSE)
361 return array ('result' => 'CAN');
362
363 // Decide on the username we will actually authenticate for.
364 if (isset ($LDAP_options['domain']) and strlen ($LDAP_options['domain']))
365 $auth_user_name = $username . "@" . $LDAP_options['domain'];
366 elseif
367 (
368 isset ($LDAP_options['search_dn']) and
369 strlen ($LDAP_options['search_dn']) and
370 isset ($LDAP_options['search_attr']) and
371 strlen ($LDAP_options['search_attr'])
372 )
373 {
374 $results = @ldap_search ($connect, $LDAP_options['search_dn'], '(' . $LDAP_options['search_attr'] . "=${username})", array("dn"));
375 if ($results === FALSE)
376 return array ('result' => 'CAN');
377 if (@ldap_count_entries ($connect, $results) != 1)
378 {
379 @ldap_close ($connect);
380 return array ('result' => 'NAK');
381 }
382 $info = @ldap_get_entries ($connect, $results);
383 ldap_free_result ($results);
384 $auth_user_name = $info[0]['dn'];
385 }
386 else
387 throw new RackTablesError ('LDAP misconfiguration. Cannon build username for authentication.', RackTablesError::MISCONFIGURED);
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);
391 $bind = @ldap_bind ($connect, $auth_user_name, $password);
392 if ($bind === FALSE)
393 switch (ldap_errno ($connect))
394 {
395 case 49: // LDAP_INVALID_CREDENTIALS
396 return array ('result' => 'NAK');
397 default:
398 return array ('result' => 'CAN');
399 }
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 (
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'])
412 )
413 {
414 $results = @ldap_search
415 (
416 $connect,
417 $LDAP_options['search_dn'],
418 '(' . $LDAP_options['search_attr'] . "=${username})",
419 array_merge (array ('memberof'), explode (' ', $LDAP_options['displayname_attrs']))
420 );
421 if (@ldap_count_entries ($connect, $results) != 1)
422 {
423 @ldap_close ($connect);
424 return array ('result' => 'NAK');
425 }
426 $info = @ldap_get_entries ($connect, $results);
427 ldap_free_result ($results);
428 $space = '';
429 foreach (explode (' ', $LDAP_options['displayname_attrs']) as $attr)
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 {
439 $items = explode ('=', $pair);
440 if (count ($items) != 2)
441 continue;
442 list ($attr_name, $attr_value) = $items;
443 if (strtoupper ($attr_name) == 'CN' and validTagName ('$lgcn_' . $attr_value, TRUE))
444 $ret['memberof'][] = '$lgcn_' . $attr_value;
445 }
446 }
447 @ldap_close ($connect);
448 return $ret;
449 }
450
451 function authenticated_via_database ($userinfo, $password)
452 {
453 if (!isset ($userinfo['user_id'])) // not a local account
454 return FALSE;
455 return $userinfo['user_password_hash'] == sha1 ($password);
456 }
457
458 ?>