r4073 minor change in UI for recent change: VST form commit on ENTER and hints on...
[racktables] / 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 $LDAP_options['cache_retry'] > $LDAP_options['cache_refresh'] or
231 $LDAP_options['cache_refresh'] > $LDAP_options['cache_expiry']
232 )
233 {
234 showError ('Fatal LDAP configuration error, check secret.php options.', __FUNCTION__);
235 die;
236 }
237 if ($LDAP_options['cache_expiry'] == 0) // immediate expiry set means disabled cache
238 return authenticated_via_ldap_nocache ($username, $password, $ldap_displayname);
239 // authenticated_via_ldap_cache()'s way of locking can sometimes result in
240 // a PDO error condition, which convertPDOException() was not able to dispatch.
241 // To avoid reaching printPDOException() (which prints backtrace with password
242 // argument in cleartext), any remaining PDO condition is converted locally.
243 try
244 {
245 return authenticated_via_ldap_cache ($username, $password, $ldap_displayname);
246 }
247 catch (PDOException $e)
248 {
249 throw new RackTablesError ('LDAP caching error', RackTablesError::DB_WRITE_FAILED);
250 }
251 }
252
253 // Authenticate given user with known LDAP server, completely ignore LDAP cache data.
254 function authenticated_via_ldap_nocache ($username, $password, &$ldap_displayname)
255 {
256 global $auto_tags;
257 $server_test = queryLDAPServer ($username, $password);
258 if ($server_test['result'] == 'ACK')
259 {
260 $ldap_displayname = $server_test['displayed_name'];
261 foreach ($server_test['memberof'] as $autotag)
262 $auto_tags[] = array ('tag' => $autotag);
263 return TRUE;
264 }
265 return FALSE;
266 }
267
268 // Idem, but consider existing data in cache and modify/discard it, when necessary.
269 // Remember to have releaseLDAPCache() called before any return statement.
270 // Perform cache maintenance on each update.
271 function authenticated_via_ldap_cache ($username, $password, &$ldap_displayname)
272 {
273 global $LDAP_options, $auto_tags;
274
275 // Destroy the cache each time config changes.
276 if (sha1 (serialize ($LDAP_options)) != loadScript ('LDAPConfigHash'))
277 {
278 discardLDAPCache();
279 saveScript ('LDAPConfigHash', sha1 (serialize ($LDAP_options)));
280 }
281 $oldinfo = acquireLDAPCache ($username, sha1 ($password), $LDAP_options['cache_expiry']);
282 if ($oldinfo === NULL) // cache miss
283 {
284 // On cache miss execute complete procedure and return the result. In case
285 // of successful authentication put a record into cache.
286 $newinfo = queryLDAPServer ($username, $password);
287 if ($newinfo['result'] == 'ACK')
288 {
289 $ldap_displayname = $newinfo['displayed_name'];
290 foreach ($newinfo['memberof'] as $autotag)
291 $auto_tags[] = array ('tag' => $autotag);
292 replaceLDAPCacheRecord ($username, sha1 ($password), $newinfo['displayed_name'], $newinfo['memberof']);
293 releaseLDAPCache();
294 discardLDAPCache ($LDAP_options['cache_expiry']);
295 return TRUE;
296 }
297 releaseLDAPCache();
298 return FALSE;
299 }
300 // cache HIT
301 // There are two confidence levels of cache hits: "certain" and "uncertain". In either case
302 // expect authentication success, unless it's well-timed to perform a retry,
303 // which may sometimes bring a NAK decision.
304 if ($oldinfo['success_age'] < $LDAP_options['cache_refresh'] or $oldinfo['retry_age'] < $LDAP_options['cache_retry'])
305 {
306 releaseLDAPCache();
307 $ldap_displayname = $oldinfo['displayed_name'];
308 foreach ($oldinfo['memberof'] as $autotag)
309 $auto_tags[] = array ('tag' => $autotag);
310 return TRUE;
311 }
312 // Either refresh threshold or retry threshold reached.
313 $newinfo = queryLDAPServer ($username, $password);
314 switch ($newinfo['result'])
315 {
316 case 'ACK': // refresh existing record
317 $ldap_displayname = $newinfo['displayed_name'];
318 foreach ($newinfo['memberof'] as $autotag)
319 $auto_tags[] = array ('tag' => $autotag);
320 replaceLDAPCacheRecord ($username, sha1 ($password), $newinfo['displayed_name'], $newinfo['memberof']);
321 releaseLDAPCache();
322 discardLDAPCache ($LDAP_options['cache_expiry']);
323 return TRUE;
324 case 'NAK': // The record isn't valid any more.
325 deleteLDAPCacheRecord ($username);
326 releaseLDAPCache();
327 discardLDAPCache ($LDAP_options['cache_expiry']);
328 return FALSE;
329 case 'CAN': // retry failed, do nothing, use old value till next retry
330 $ldap_displayname = $oldinfo['displayed_name'];
331 foreach ($oldinfo['memberof'] as $autotag)
332 $auto_tags[] = array ('tag' => $autotag);
333 touchLDAPCacheRecord ($username);
334 releaseLDAPCache();
335 discardLDAPCache ($LDAP_options['cache_expiry']);
336 return TRUE;
337 default:
338 throw new InvalidArgException ('result', $newinfo['result'], 'Internal error during LDAP cache dispatching');
339 }
340 // This is never reached.
341 return FALSE;
342 }
343
344 // Attempt a server conversation and return an array describing the outcome:
345 //
346 // 'result' => 'CAN' : connect (or search) failed completely
347 //
348 // 'result' => 'NAK' : server replied and denied access (or search returned odd data)
349 //
350 // 'result' => 'ACK' : server replied and cleared access, there were no search errors
351 // 'displayed_name' : a string built according to LDAP displayname_attrs option
352 // 'memberof' => filtered list of all LDAP groups the user belongs to
353 //
354 function queryLDAPServer ($username, $password)
355 {
356 global $LDAP_options;
357
358 if(extension_loaded('ldap') === FALSE)
359 throw new RackTablesError ('LDAP misconfiguration. LDAP PHP Module is not installed.', RackTablesError::MISCONFIGURED);
360
361 $connect = @ldap_connect ($LDAP_options['server']);
362 if ($connect === FALSE)
363 return array ('result' => 'CAN');
364
365 // Decide on the username we will actually authenticate for.
366 if (isset ($LDAP_options['domain']) and strlen ($LDAP_options['domain']))
367 $auth_user_name = $username . "@" . $LDAP_options['domain'];
368 elseif
369 (
370 isset ($LDAP_options['search_dn']) and
371 strlen ($LDAP_options['search_dn']) and
372 isset ($LDAP_options['search_attr']) and
373 strlen ($LDAP_options['search_attr'])
374 )
375 {
376 $results = @ldap_search ($connect, $LDAP_options['search_dn'], '(' . $LDAP_options['search_attr'] . "=${username})", array("dn"));
377 if ($results === FALSE)
378 return array ('result' => 'CAN');
379 if (@ldap_count_entries ($connect, $results) != 1)
380 {
381 @ldap_close ($connect);
382 return array ('result' => 'NAK');
383 }
384 $info = @ldap_get_entries ($connect, $results);
385 ldap_free_result ($results);
386 $auth_user_name = $info[0]['dn'];
387 }
388 else
389 throw new RackTablesError ('LDAP misconfiguration. Cannon build username for authentication.', RackTablesError::MISCONFIGURED);
390 if (array_key_exists ('options', $LDAP_options) and is_array ($LDAP_options['options']))
391 foreach ($LDAP_options['options'] as $opt_code => $opt_value)
392 ldap_set_option ($connect, $opt_code, $opt_value);
393 $bind = @ldap_bind ($connect, $auth_user_name, $password);
394 if ($bind === FALSE)
395 switch (ldap_errno ($connect))
396 {
397 case 49: // LDAP_INVALID_CREDENTIALS
398 return array ('result' => 'NAK');
399 default:
400 return array ('result' => 'CAN');
401 }
402 // preliminary decision may change during searching
403 $ret = array ('result' => 'ACK', 'displayed_name' => '', 'memberof' => array());
404 // Some servers deny anonymous search, thus search (if requested) only after binding.
405 // Displayed name only makes sense for authenticated users anyway.
406 if
407 (
408 isset ($LDAP_options['displayname_attrs']) and
409 strlen ($LDAP_options['displayname_attrs']) and
410 isset ($LDAP_options['search_dn']) and
411 strlen ($LDAP_options['search_dn']) and
412 isset ($LDAP_options['search_attr']) and
413 strlen ($LDAP_options['search_attr'])
414 )
415 {
416 $results = @ldap_search
417 (
418 $connect,
419 $LDAP_options['search_dn'],
420 '(' . $LDAP_options['search_attr'] . "=${username})",
421 array_merge (array ('memberof'), explode (' ', $LDAP_options['displayname_attrs']))
422 );
423 if (@ldap_count_entries ($connect, $results) != 1)
424 {
425 @ldap_close ($connect);
426 return array ('result' => 'NAK');
427 }
428 $info = @ldap_get_entries ($connect, $results);
429 ldap_free_result ($results);
430 $space = '';
431 foreach (explode (' ', $LDAP_options['displayname_attrs']) as $attr)
432 {
433 $ret['displayed_name'] .= $space . $info[0][$attr][0];
434 $space = ' ';
435 }
436 // Pull group membership, if any was returned.
437 if (isset ($info[0]['memberof']))
438 for ($i = 0; $i < $info[0]['memberof']['count']; $i++)
439 foreach (explode (',', $info[0]['memberof'][$i]) as $pair)
440 {
441 list ($attr_name, $attr_value) = explode ('=', $pair);
442 if (strtoupper ($attr_name) == 'CN' and validTagName ('$lgcn_' . $attr_value, TRUE))
443 $ret['memberof'][] = '$lgcn_' . $attr_value;
444 }
445 }
446 @ldap_close ($connect);
447 return $ret;
448 }
449
450 function authenticated_via_database ($userinfo, $password)
451 {
452 if (!isset ($userinfo['user_id'])) // not a local account
453 return FALSE;
454 return $userinfo['user_password_hash'] == sha1 ($password);
455 }
456
457 ?>