c7ead24e581702350346d696c078ba58851fc8ad
[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 # a "throwing" wrapper for above
145 function assertPermission ($p = NULL, $t = NULL, $o = NULL, $annex = array())
146 {
147 if (! permitted ($p, $t, $o, $annex))
148 throw new RTPermissionDenied();
149 }
150
151 // The argument doesn't include explicit and implicit tags. This allows us to derive implicit chain
152 // each time we modify the given argument (and work with the modified copy from now on).
153 // After the work is done the global $impl_tags is silently modified
154 function gotClearanceForTagChain ($const_base)
155 {
156 global $rackCode, $expl_tags, $impl_tags;
157 $ptable = array();
158 foreach ($rackCode as $sentence)
159 {
160 switch ($sentence['type'])
161 {
162 case 'SYNT_DEFINITION':
163 $ptable[$sentence['term']] = $sentence['definition'];
164 break;
165 case 'SYNT_GRANT':
166 if (eval_expression ($sentence['condition'], array_merge ($const_base, $expl_tags, $impl_tags), $ptable))
167 switch ($sentence['decision'])
168 {
169 case 'LEX_ALLOW':
170 return TRUE;
171 case 'LEX_DENY':
172 return FALSE;
173 default:
174 throw new RackTablesError ("Condition match for unknown grant decision '${sentence['decision']}'", RackTablesError::INTERNAL);
175 }
176 break;
177 case 'SYNT_ADJUSTMENT':
178 if
179 (
180 eval_expression ($sentence['condition'], array_merge ($const_base, $expl_tags, $impl_tags), $ptable) and
181 processAdjustmentSentence ($sentence['modlist'], $expl_tags)
182 ) // recalculate implicit chain only after actual change, not just on matched condition
183 $impl_tags = getImplicitTags ($expl_tags); // recalculate
184 break;
185 default:
186 throw new RackTablesError ("Can't process sentence of unknown type '${sentence['type']}'", RackTablesError::INTERNAL);
187 }
188 }
189 return FALSE;
190 }
191
192 // Process a context adjustment request, update given chain accordingly,
193 // return TRUE on any changes done.
194 // The request is a sequence of clear/insert/remove requests exactly as cooked
195 // for each SYNT_CTXMODLIST node.
196 function processAdjustmentSentence ($modlist, &$chain)
197 {
198 global $rackCode;
199 $didChanges = FALSE;
200 foreach ($modlist as $mod)
201 switch ($mod['op'])
202 {
203 case 'insert':
204 foreach ($chain as $etag)
205 if ($etag['tag'] == $mod['tag']) // already there, next request
206 break 2;
207 $search = getTagByName ($mod['tag']);
208 if ($search === NULL) // skip martians silently
209 break;
210 $chain[] = $search;
211 $didChanges = TRUE;
212 break;
213 case 'remove':
214 foreach ($chain as $key => $etag)
215 if ($etag['tag'] == $mod['tag']) // drop first match and return
216 {
217 unset ($chain[$key]);
218 $didChanges = TRUE;
219 break 2;
220 }
221 break;
222 case 'clear':
223 $chain = array();
224 $didChanges = TRUE;
225 break;
226 default: // HCF
227 throw new RackTablesError ('invalid structure', RackTablesError::INTERNAL);
228 }
229 return $didChanges;
230 }
231
232 // a wrapper for two LDAP auth methods below
233 function authenticated_via_ldap ($username, $password, &$ldap_displayname)
234 {
235 global $LDAP_options;
236 if
237 (
238 $LDAP_options['cache_retry'] > $LDAP_options['cache_refresh'] or
239 $LDAP_options['cache_refresh'] > $LDAP_options['cache_expiry']
240 )
241 throw new RackTablesError ('LDAP misconfiguration: refresh/retry/expiry mismatch', RackTablesError::MISCONFIGURED);
242 if ($LDAP_options['cache_expiry'] == 0) // immediate expiry set means disabled cache
243 return authenticated_via_ldap_nocache ($username, $password, $ldap_displayname);
244 // authenticated_via_ldap_cache()'s way of locking can sometimes result in
245 // a PDO error condition, which convertPDOException() was not able to dispatch.
246 // To avoid reaching printPDOException() (which prints backtrace with password
247 // argument in cleartext), any remaining PDO condition is converted locally.
248 try
249 {
250 return authenticated_via_ldap_cache ($username, $password, $ldap_displayname);
251 }
252 catch (PDOException $e)
253 {
254 throw new RackTablesError ('LDAP caching error', RackTablesError::DB_WRITE_FAILED);
255 }
256 }
257
258 // Authenticate given user with known LDAP server, completely ignore LDAP cache data.
259 function authenticated_via_ldap_nocache ($username, $password, &$ldap_displayname)
260 {
261 global $auto_tags;
262 $server_test = queryLDAPServer ($username, $password);
263 if ($server_test['result'] == 'ACK')
264 {
265 $ldap_displayname = $server_test['displayed_name'];
266 foreach ($server_test['memberof'] as $autotag)
267 $auto_tags[] = array ('tag' => $autotag);
268 return TRUE;
269 }
270 return FALSE;
271 }
272
273 // Idem, but consider existing data in cache and modify/discard it, when necessary.
274 // Remember to have releaseLDAPCache() called before any return statement.
275 // Perform cache maintenance on each update.
276 function authenticated_via_ldap_cache ($username, $password, &$ldap_displayname)
277 {
278 global $LDAP_options, $auto_tags;
279
280 // Destroy the cache each time config changes.
281 if (sha1 (serialize ($LDAP_options)) != loadScript ('LDAPConfigHash'))
282 {
283 discardLDAPCache();
284 saveScript ('LDAPConfigHash', sha1 (serialize ($LDAP_options)));
285 }
286 $oldinfo = acquireLDAPCache ($username, sha1 ($password), $LDAP_options['cache_expiry']);
287 if ($oldinfo === NULL) // cache miss
288 {
289 // On cache miss execute complete procedure and return the result. In case
290 // of successful authentication put a record into cache.
291 $newinfo = queryLDAPServer ($username, $password);
292 if ($newinfo['result'] == 'ACK')
293 {
294 $ldap_displayname = $newinfo['displayed_name'];
295 foreach ($newinfo['memberof'] as $autotag)
296 $auto_tags[] = array ('tag' => $autotag);
297 replaceLDAPCacheRecord ($username, sha1 ($password), $newinfo['displayed_name'], $newinfo['memberof']);
298 releaseLDAPCache();
299 discardLDAPCache ($LDAP_options['cache_expiry']);
300 return TRUE;
301 }
302 releaseLDAPCache();
303 return FALSE;
304 }
305 // cache HIT
306 // There are two confidence levels of cache hits: "certain" and "uncertain". In either case
307 // expect authentication success, unless it's well-timed to perform a retry,
308 // which may sometimes bring a NAK decision.
309 if ($oldinfo['success_age'] < $LDAP_options['cache_refresh'] or $oldinfo['retry_age'] < $LDAP_options['cache_retry'])
310 {
311 releaseLDAPCache();
312 $ldap_displayname = $oldinfo['displayed_name'];
313 foreach ($oldinfo['memberof'] as $autotag)
314 $auto_tags[] = array ('tag' => $autotag);
315 return TRUE;
316 }
317 // Either refresh threshold or retry threshold reached.
318 $newinfo = queryLDAPServer ($username, $password);
319 switch ($newinfo['result'])
320 {
321 case 'ACK': // refresh existing record
322 $ldap_displayname = $newinfo['displayed_name'];
323 foreach ($newinfo['memberof'] as $autotag)
324 $auto_tags[] = array ('tag' => $autotag);
325 replaceLDAPCacheRecord ($username, sha1 ($password), $newinfo['displayed_name'], $newinfo['memberof']);
326 releaseLDAPCache();
327 discardLDAPCache ($LDAP_options['cache_expiry']);
328 return TRUE;
329 case 'NAK': // The record isn't valid any more.
330 deleteLDAPCacheRecord ($username);
331 releaseLDAPCache();
332 discardLDAPCache ($LDAP_options['cache_expiry']);
333 return FALSE;
334 case 'CAN': // retry failed, do nothing, use old value till next retry
335 $ldap_displayname = $oldinfo['displayed_name'];
336 foreach ($oldinfo['memberof'] as $autotag)
337 $auto_tags[] = array ('tag' => $autotag);
338 touchLDAPCacheRecord ($username);
339 releaseLDAPCache();
340 discardLDAPCache ($LDAP_options['cache_expiry']);
341 return TRUE;
342 default:
343 throw new InvalidArgException ('result', $newinfo['result'], 'Internal error during LDAP cache dispatching');
344 }
345 // This is never reached.
346 return FALSE;
347 }
348
349 // Attempt a server conversation and return an array describing the outcome:
350 //
351 // 'result' => 'CAN' : connect (or search) failed completely
352 //
353 // 'result' => 'NAK' : server replied and denied access (or search returned odd data)
354 //
355 // 'result' => 'ACK' : server replied and cleared access, there were no search errors
356 // 'displayed_name' : a string built according to LDAP displayname_attrs option
357 // 'memberof' => filtered list of all LDAP groups the user belongs to
358 //
359 function queryLDAPServer ($username, $password)
360 {
361 global $LDAP_options;
362
363 if(extension_loaded('ldap') === FALSE)
364 throw new RackTablesError ('LDAP misconfiguration. LDAP PHP Module is not installed.', RackTablesError::MISCONFIGURED);
365
366 $connect = @ldap_connect ($LDAP_options['server']);
367 if ($connect === FALSE)
368 return array ('result' => 'CAN');
369
370 // Decide on the username we will actually authenticate for.
371 if (isset ($LDAP_options['domain']) and strlen ($LDAP_options['domain']))
372 $auth_user_name = $username . "@" . $LDAP_options['domain'];
373 elseif
374 (
375 isset ($LDAP_options['search_dn']) and
376 strlen ($LDAP_options['search_dn']) and
377 isset ($LDAP_options['search_attr']) and
378 strlen ($LDAP_options['search_attr'])
379 )
380 {
381 $results = @ldap_search ($connect, $LDAP_options['search_dn'], '(' . $LDAP_options['search_attr'] . "=${username})", array("dn"));
382 if ($results === FALSE)
383 return array ('result' => 'CAN');
384 if (@ldap_count_entries ($connect, $results) != 1)
385 {
386 @ldap_close ($connect);
387 return array ('result' => 'NAK');
388 }
389 $info = @ldap_get_entries ($connect, $results);
390 ldap_free_result ($results);
391 $auth_user_name = $info[0]['dn'];
392 }
393 else
394 throw new RackTablesError ('LDAP misconfiguration. Cannon build username for authentication.', RackTablesError::MISCONFIGURED);
395 if (array_key_exists ('options', $LDAP_options) and is_array ($LDAP_options['options']))
396 foreach ($LDAP_options['options'] as $opt_code => $opt_value)
397 ldap_set_option ($connect, $opt_code, $opt_value);
398 $bind = @ldap_bind ($connect, $auth_user_name, $password);
399 if ($bind === FALSE)
400 switch (ldap_errno ($connect))
401 {
402 case 49: // LDAP_INVALID_CREDENTIALS
403 return array ('result' => 'NAK');
404 default:
405 return array ('result' => 'CAN');
406 }
407 // preliminary decision may change during searching
408 $ret = array ('result' => 'ACK', 'displayed_name' => '', 'memberof' => array());
409 // Some servers deny anonymous search, thus search (if requested) only after binding.
410 // Displayed name only makes sense for authenticated users anyway.
411 if
412 (
413 isset ($LDAP_options['displayname_attrs']) and
414 strlen ($LDAP_options['displayname_attrs']) and
415 isset ($LDAP_options['search_dn']) and
416 strlen ($LDAP_options['search_dn']) and
417 isset ($LDAP_options['search_attr']) and
418 strlen ($LDAP_options['search_attr'])
419 )
420 {
421 $results = @ldap_search
422 (
423 $connect,
424 $LDAP_options['search_dn'],
425 '(' . $LDAP_options['search_attr'] . "=${username})",
426 array_merge (array ('memberof'), explode (' ', $LDAP_options['displayname_attrs']))
427 );
428 if (@ldap_count_entries ($connect, $results) != 1)
429 {
430 @ldap_close ($connect);
431 return array ('result' => 'NAK');
432 }
433 $info = @ldap_get_entries ($connect, $results);
434 ldap_free_result ($results);
435 $space = '';
436 foreach (explode (' ', $LDAP_options['displayname_attrs']) as $attr)
437 {
438 $ret['displayed_name'] .= $space . $info[0][$attr][0];
439 $space = ' ';
440 }
441 // Pull group membership, if any was returned.
442 if (isset ($info[0]['memberof']))
443 for ($i = 0; $i < $info[0]['memberof']['count']; $i++)
444 foreach (explode (',', $info[0]['memberof'][$i]) as $pair)
445 {
446 $items = explode ('=', $pair);
447 if (count ($items) != 2)
448 continue;
449 list ($attr_name, $attr_value) = $items;
450 if (strtoupper ($attr_name) == 'CN' and validTagName ('$lgcn_' . $attr_value, TRUE))
451 $ret['memberof'][] = '$lgcn_' . $attr_value;
452 }
453 }
454 @ldap_close ($connect);
455 return $ret;
456 }
457
458 function authenticated_via_database ($userinfo, $password)
459 {
460 if (!isset ($userinfo['user_id'])) // not a local account
461 return FALSE;
462 return $userinfo['user_password_hash'] == sha1 ($password);
463 }
464
465 ?>