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