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