cake/libs/controller/components/security.php

1 <?php
2 /* SVN FILE: $Id$ */
3 /**
4 * Short description for file.
5 *
6 * Long description for file
7 *
8 * PHP versions 4 and 5
9 *
10 * CakePHP(tm) : Rapid Development Framework (http://www.cakephp.org)
11 * Copyright 2005-2008, Cake Software Foundation, Inc. (http://www.cakefoundation.org)
12 *
13 * Licensed under The MIT License
14 * Redistributions of files must retain the above copyright notice.
15 *
16 * @filesource
17 * @copyright Copyright 2005-2008, Cake Software Foundation, Inc. (http://www.cakefoundation.org)
18 * @link http://www.cakefoundation.org/projects/info/cakephp CakePHP(tm) Project
19 * @package cake
20 * @subpackage cake.cake.libs.controller.components
21 * @since CakePHP(tm) v 0.10.8.2156
22 * @version $Revision$
23 * @modifiedby $LastChangedBy$
24 * @lastmodified $Date$
25 * @license http://www.opensource.org/licenses/mit-license.php The MIT License
26 */
27 /**
28 * Short description for file.
29 *
30 * Long description for file
31 *
32 * @package cake
33 * @subpackage cake.cake.libs.controller.components
34 */
35 class SecurityComponent extends Object {
36 /**
37 * The controller method that will be called if this request is black-hole'd
38 *
39 * @var string
40 * @access public
41 */
42 var $blackHoleCallback = null;
43 /**
44 * List of controller actions for which a POST request is required
45 *
46 * @var array
47 * @access public
48 * @see SecurityComponent::requirePost()
49 */
50 var $requirePost = array();
51 /**
52 * List of controller actions for which a GET request is required
53 *
54 * @var array
55 * @access public
56 * @see SecurityComponent::requireGet()
57 */
58 var $requireGet = array();
59 /**
60 * List of controller actions for which a PUT request is required
61 *
62 * @var array
63 * @access public
64 * @see SecurityComponent::requirePut()
65 */
66 var $requirePut = array();
67 /**
68 * List of controller actions for which a DELETE request is required
69 *
70 * @var array
71 * @access public
72 * @see SecurityComponent::requireDelete()
73 */
74 var $requireDelete = array();
75 /**
76 * List of actions that require an SSL-secured connection
77 *
78 * @var array
79 * @access public
80 * @see SecurityComponent::requireSecure()
81 */
82 var $requireSecure = array();
83 /**
84 * List of actions that require a valid authentication key
85 *
86 * @var array
87 * @access public
88 * @see SecurityComponent::requireAuth()
89 */
90 var $requireAuth = array();
91 /**
92 * List of actions that require an HTTP-authenticated login (basic or digest)
93 *
94 * @var array
95 * @access public
96 * @see SecurityComponent::requireLogin()
97 */
98 var $requireLogin = array();
99 /**
100 * Login options for SecurityComponent::requireLogin()
101 *
102 * @var array
103 * @access public
104 * @see SecurityComponent::requireLogin()
105 */
106 var $loginOptions = array('type' => '', 'prompt' => null);
107 /**
108 * An associative array of usernames/passwords used for HTTP-authenticated logins.
109 * If using digest authentication, passwords should be MD5-hashed.
110 *
111 * @var array
112 * @access public
113 * @see SecurityComponent::requireLogin()
114 */
115 var $loginUsers = array();
116 /**
117 * Controllers from which actions of the current controller are allowed to receive
118 * requests.
119 *
120 * @var array
121 * @access public
122 * @see SecurityComponent::requireAuth()
123 */
124 var $allowedControllers = array();
125 /**
126 * Actions from which actions of the current controller are allowed to receive
127 * requests.
128 *
129 * @var array
130 * @access public
131 * @see SecurityComponent::requireAuth()
132 */
133 var $allowedActions = array();
134 /**
135 * Form fields to disable
136 *
137 * @var array
138 * @access public
139 */
140 var $disabledFields = array();
141 /**
142 * Whether to validate POST data. Set to false to disable for data coming from 3rd party
143 * services, etc.
144 *
145 * @var boolean
146 * @access public
147 */
148 var $validatePost = true;
149 /**
150 * Other components used by the Security component
151 *
152 * @var array
153 * @access public
154 */
155 var $components = array('RequestHandler', 'Session');
156 /**
157 * Holds the current action of the controller
158 *
159 * @var string
160 */
161 var $_action = null;
162 /**
163 * Component startup. All security checking happens here.
164 *
165 * @param object $controller Instantiating controller
166 * @access public
167 */
168 function startup(&$controller) {
169 $this->_action = strtolower($controller->action);
170 $this->_methodsRequired($controller);
171 $this->_secureRequired($controller);
172 $this->_authRequired($controller);
173 $this->_loginRequired($controller);
174  
175 $isPost = ($this->RequestHandler->isPost() || $this->RequestHandler->isPut());
176 $isRequestAction = (
177 !isset($controller->params['requested']) ||
178 $controller->params['requested'] != 1
179 );
180  
181 if ($isPost && $isRequestAction && $this->validatePost) {
182 if ($this->_validatePost($controller) === false) {
183 if (!$this->blackHole($controller, 'auth')) {
184 return null;
185 }
186 }
187 }
188 $this->_generateToken($controller);
189 }
190 /**
191 * Sets the actions that require a POST request, or empty for all actions
192 *
193 * @return void
194 * @access public
195 */
196 function requirePost() {
197 $args = func_get_args();
198 $this->_requireMethod('Post', $args);
199 }
200 /**
201 * Sets the actions that require a GET request, or empty for all actions
202 *
203 * @return void
204 * @access public
205 */
206 function requireGet() {
207 $args = func_get_args();
208 $this->_requireMethod('Get', $args);
209 }
210 /**
211 * Sets the actions that require a PUT request, or empty for all actions
212 *
213 * @return void
214 * @access public
215 */
216 function requirePut() {
217 $args = func_get_args();
218 $this->_requireMethod('Put', $args);
219 }
220 /**
221 * Sets the actions that require a DELETE request, or empty for all actions
222 *
223 * @return void
224 * @access public
225 */
226 function requireDelete() {
227 $args = func_get_args();
228 $this->_requireMethod('Delete', $args);
229 }
230 /**
231 * Sets the actions that require a request that is SSL-secured, or empty for all actions
232 *
233 * @return void
234 * @access public
235 */
236 function requireSecure() {
237 $args = func_get_args();
238 $this->_requireMethod('Secure', $args);
239 }
240 /**
241 * Sets the actions that require an authenticated request, or empty for all actions
242 *
243 * @return void
244 * @access public
245 */
246 function requireAuth() {
247 $args = func_get_args();
248 $this->_requireMethod('Auth', $args);
249 }
250 /**
251 * Sets the actions that require an HTTP-authenticated request, or empty for all actions
252 *
253 * @return void
254 * @access public
255 */
256 function requireLogin() {
257 $args = func_get_args();
258 $base = $this->loginOptions;
259  
260 foreach ($args as $i => $arg) {
261 if (is_array($arg)) {
262 $this->loginOptions = $arg;
263 unset($args[$i]);
264 }
265 }
266 $this->loginOptions = array_merge($base, $this->loginOptions);
267 $this->_requireMethod('Login', $args);
268  
269 if (isset($this->loginOptions['users'])) {
270 $this->loginUsers =& $this->loginOptions['users'];
271 }
272 }
273 /**
274 * Attempts to validate the login credentials for an HTTP-authenticated request
275 *
276 * @param string $type Either 'basic', 'digest', or null. If null/empty, will try both.
277 * @return mixed If successful, returns an array with login name and password, otherwise null.
278 * @access public
279 */
280 function loginCredentials($type = null) {
281 switch (strtolower($type)) {
282 case 'basic':
283 $login = array('username' => env('PHP_AUTH_USER'), 'password' => env('PHP_AUTH_PW'));
284 if (!empty($login['username'])) {
285 return $login;
286 }
287 break;
288 case 'digest':
289 default:
290 $digest = null;
291  
292 if (version_compare(PHP_VERSION, '5.1') != -1) {
293 $digest = env('PHP_AUTH_DIGEST');
294 } elseif (function_exists('apache_request_headers')) {
295 $headers = apache_request_headers();
296 if (isset($headers['Authorization']) && !empty($headers['Authorization']) && substr($headers['Authorization'], 0, 7) == 'Digest ') {
297 $digest = substr($headers['Authorization'], 7);
298 }
299 } else {
300 // Server doesn't support digest-auth headers
301 trigger_error(__('SecurityComponent::loginCredentials() - Server does not support digest authentication', true), E_USER_WARNING);
302 }
303  
304 if (!empty($digest)) {
305 return $this->parseDigestAuthData($digest);
306 }
307 break;
308 }
309 return null;
310 }
311 /**
312 * Generates the text of an HTTP-authentication request header from an array of options.
313 *
314 * @param array $options Set of options for header
315 * @return string HTTP-authentication request header
316 * @access public
317 */
318 function loginRequest($options = array()) {
319 $options = array_merge($this->loginOptions, $options);
320 $this->_setLoginDefaults($options);
321 $auth = 'WWW-Authenticate: ' . ucfirst($options['type']);
322 $out = array('realm="' . $options['realm'] . '"');
323  
324 if (strtolower($options['type']) == 'digest') {
325 $out[] = 'qop="auth"';
326 $out[] = 'nonce="' . uniqid("") . '"';
327 $out[] = 'opaque="' . md5($options['realm']).'"';
328 }
329  
330 return $auth . ' ' . join(',', $out);
331 }
332 /**
333 * Parses an HTTP digest authentication response, and returns an array of the data, or null on failure.
334 *
335 * @param string $digest Digest authentication response
336 * @return array Digest authentication parameters
337 * @access public
338 */
339 function parseDigestAuthData($digest) {
340 if (substr($digest, 0, 7) == 'Digest ') {
341 $digest = substr($digest, 7);
342 }
343 $keys = array();
344 $match = array();
345 $req = array('nonce' => 1, 'nc' => 1, 'cnonce' => 1, 'qop' => 1, 'username' => 1, 'uri' => 1, 'response' => 1);
346 preg_match_all('@(\w+)=([\'"]?)([a-zA-Z0-9=./\_-]+)\2@', $digest, $match, PREG_SET_ORDER);
347  
348 foreach ($match as $i) {
349 $keys[$i[1]] = $i[3];
350 unset($req[$i[1]]);
351 }
352  
353 if (empty($req)) {
354 return $keys;
355 }
356 return null;
357 }
358 /**
359 * Generates a hash to be compared with an HTTP digest-authenticated response
360 *
361 * @param array $data HTTP digest response data, as parsed by SecurityComponent::parseDigestAuthData()
362 * @return string Digest authentication hash
363 * @access public
364 * @see SecurityComponent::parseDigestAuthData()
365 */
366 function generateDigestResponseHash($data) {
367 return md5(
368 md5($data['username'] . ':' . $this->loginOptions['realm'] . ':' . $this->loginUsers[$data['username']]) .
369 ':' . $data['nonce'] . ':' . $data['nc'] . ':' . $data['cnonce'] . ':' . $data['qop'] . ':' .
370 md5(env('REQUEST_METHOD') . ':' . $data['uri'])
371 );
372 }
373 /**
374 * Black-hole an invalid request with a 404 error or custom callback. If SecurityComponent::$blackHoleCallback
375 * is specified, it will use this callback by executing the method indicated in $error
376 *
377 * @param object $controller Instantiating controller
378 * @param string $error Error method
379 * @return mixed If specified, controller blackHoleCallback's response, or no return otherwise
380 * @access public
381 * @see SecurityComponent::$blackHoleCallback
382 */
383 function blackHole(&$controller, $error = '') {
384 $this->Session->del('_Token');
385  
386 if ($this->blackHoleCallback == null) {
387 $code = 404;
388 if ($error == 'login') {
389 $code = 401;
390 $controller->header($this->loginRequest());
391 }
392 $controller->redirect(null, $code, true);
393 } else {
394 return $this->_callback($controller, $this->blackHoleCallback, array($error));
395 }
396 }
397 /**
398 * Sets the actions that require a $method HTTP request, or empty for all actions
399 *
400 * @param string $method The HTTP method to assign controller actions to
401 * @param array $actions Controller actions to set the required HTTP method to.
402 * @return void
403 * @access protected
404 */
405 function _requireMethod($method, $actions = array()) {
406 $this->{'require' . $method} = (empty($actions)) ? array('*'): $actions;
407 }
408 /**
409 * Check if HTTP methods are required
410 *
411 * @param object $controller Instantiating controller
412 * @return bool true if $method is required
413 * @access protected
414 */
415 function _methodsRequired(&$controller) {
416 foreach (array('Post', 'Get', 'Put', 'Delete') as $method) {
417 $property = 'require' . $method;
418 if (is_array($this->$property) && !empty($this->$property)) {
419 $require = array_map('strtolower', $this->$property);
420  
421 if (in_array($this->_action, $require) || $this->$property == array('*')) {
422 if (!$this->RequestHandler->{'is' . $method}()) {
423 if (!$this->blackHole($controller, strtolower($method))) {
424 return null;
425 }
426 }
427 }
428 }
429 }
430 return true;
431 }
432 /**
433 * Check if access requires secure connection
434 *
435 * @param object $controller Instantiating controller
436 * @return bool true if secure connection required
437 * @access protected
438 */
439 function _secureRequired(&$controller) {
440 if (is_array($this->requireSecure) && !empty($this->requireSecure)) {
441 $requireSecure = array_map('strtolower', $this->requireSecure);
442  
443 if (in_array($this->_action, $requireSecure) || $this->requireSecure == array('*')) {
444 if (!$this->RequestHandler->isSSL()) {
445 if (!$this->blackHole($controller, 'secure')) {
446 return null;
447 }
448 }
449 }
450 }
451 return true;
452 }
453 /**
454 * Check if authentication is required
455 *
456 * @param object $controller Instantiating controller
457 * @return bool true if authentication required
458 * @access protected
459 */
460 function _authRequired(&$controller) {
461 if (is_array($this->requireAuth) && !empty($this->requireAuth) && !empty($controller->data)) {
462 $requireAuth = array_map('strtolower', $this->requireAuth);
463  
464 if (in_array($this->_action, $requireAuth) || $this->requireAuth == array('*')) {
465 if (!isset($controller->data['_Token'] )) {
466 if (!$this->blackHole($controller, 'auth')) {
467 return null;
468 }
469 }
470  
471 if ($this->Session->check('_Token')) {
472 $tData = unserialize($this->Session->read('_Token'));
473  
474 if (!empty($tData['allowedControllers']) && !in_array($controller->params['controller'], $tData['allowedControllers']) || !empty($tData['allowedActions']) && !in_array($controller->params['action'], $tData['allowedActions'])) {
475 if (!$this->blackHole($controller, 'auth')) {
476 return null;
477 }
478 }
479 } else {
480 if (!$this->blackHole($controller, 'auth')) {
481 return null;
482 }
483 }
484 }
485 }
486 return true;
487 }
488 /**
489 * Check if login is required
490 *
491 * @param object $controller Instantiating controller
492 * @return bool true if login is required
493 * @access protected
494 */
495 function _loginRequired(&$controller) {
496 if (is_array($this->requireLogin) && !empty($this->requireLogin)) {
497 $requireLogin = array_map('strtolower', $this->requireLogin);
498  
499 if (in_array($this->_action, $requireLogin) || $this->requireLogin == array('*')) {
500 $login = $this->loginCredentials($this->loginOptions['type']);
501  
502 if ($login == null) {
503 $controller->header($this->loginRequest());
504  
505 if (!empty($this->loginOptions['prompt'])) {
506 $this->_callback($controller, $this->loginOptions['prompt']);
507 } else {
508 $this->blackHole($controller, 'login');
509 }
510 } else {
511 if (isset($this->loginOptions['login'])) {
512 $this->_callback($controller, $this->loginOptions['login'], array($login));
513 } else {
514 if (strtolower($this->loginOptions['type']) == 'digest') {
515 if ($login && isset($this->loginUsers[$login['username']])) {
516 if ($login['response'] == $this->generateDigestResponseHash($login)) {
517 return true;
518 }
519 }
520 $this->blackHole($controller, 'login');
521 } else {
522 if (
523 !(in_array($login['username'], array_keys($this->loginUsers)) &&
524 $this->loginUsers[$login['username']] == $login['password'])
525 ) {
526 $this->blackHole($controller, 'login');
527 }
528 }
529 }
530 }
531 }
532 }
533 return true;
534 }
535 /**
536 * Validate submitted form
537 *
538 * @param object $controller Instantiating controller
539 * @return bool true if submitted form is valid
540 * @access protected
541 */
542 function _validatePost(&$controller) {
543 if (empty($controller->data)) {
544 return true;
545 }
546 $data = $controller->data;
547  
548 if (!isset($data['_Token']) || !isset($data['_Token']['fields'])) {
549 return false;
550 }
551 $token = $data['_Token']['key'];
552  
553 if ($this->Session->check('_Token')) {
554 $tokenData = unserialize($this->Session->read('_Token'));
555  
556 if ($tokenData['expires'] < time() || $tokenData['key'] !== $token) {
557 return false;
558 }
559 }
560  
561 $locked = null;
562 $check = $controller->data;
563 $token = urldecode($check['_Token']['fields']);
564  
565 if (strpos($token, ':')) {
566 list($token, $locked) = explode(':', $token, 2);
567 }
568 unset($check['_Token']);
569  
570 $lockedFields = array();
571 $fields = Set::flatten($check);
572 $fieldList = array_keys($fields);
573 $locked = unserialize(str_rot13($locked));
574 $multi = array();
575  
576 foreach ($fieldList as $i => $key) {
577 if (preg_match('/\.\d+$/', $key)) {
578 $multi[$i] = preg_replace('/\.\d+$/', '', $key);
579 unset($fieldList[$i]);
580 }
581 }
582 if (!empty($multi)) {
583 $fieldList += array_unique($multi);
584 }
585  
586 foreach ($fieldList as $i => $key) {
587 $isDisabled = false;
588 $isLocked = (is_array($locked) && in_array($key, $locked));
589  
590 if (!empty($this->disabledFields)) {
591 foreach ((array)$this->disabledFields as $disabled) {
592 $disabled = explode('.', $disabled);
593 $field = array_values(array_intersect(explode('.', $key), $disabled));
594 $isDisabled = ($field === $disabled);
595 if ($isDisabled) {
596 break;
597 }
598 }
599 }
600  
601 if ($isDisabled || $isLocked) {
602 unset($fieldList[$i]);
603 if ($isLocked) {
604 $lockedFields[$key] = $fields[$key];
605 }
606 }
607 }
608 sort($fieldList, SORT_STRING);
609 ksort($lockedFields, SORT_STRING);
610  
611 $fieldList += $lockedFields;
612 $check = Security::hash(serialize($fieldList) . Configure::read('Security.salt'));
613 return ($token === $check);
614 }
615 /**
616 * Add authentication key for new form posts
617 *
618 * @param object $controller Instantiating controller
619 * @return bool Success
620 * @access protected
621 */
622 function _generateToken(&$controller) {
623 if (isset($controller->params['requested']) && $controller->params['requested'] === 1) {
624 return false;
625 }
626 $authKey = Security::generateAuthKey();
627 $expires = strtotime('+' . Security::inactiveMins() . ' minutes');
628 $token = array(
629 'key' => $authKey,
630 'expires' => $expires,
631 'allowedControllers' => $this->allowedControllers,
632 'allowedActions' => $this->allowedActions,
633 'disabledFields' => $this->disabledFields
634 );
635  
636 if (!isset($controller->data)) {
637 $controller->data = array();
638 }
639  
640 if ($this->Session->check('_Token')) {
641 $tokenData = unserialize($this->Session->read('_Token'));
642 $valid = (
643 isset($tokenData['expires']) &&
644 $tokenData['expires'] > time() &&
645 isset($tokenData['key'])
646 );
647  
648 if ($valid) {
649 $token['key'] = $tokenData['key'];
650 }
651 }
652 $controller->params['_Token'] = $token;
653 $this->Session->write('_Token', serialize($token));
654  
655 return true;
656 }
657 /**
658 * Sets the default login options for an HTTP-authenticated request
659 *
660 * @param array $options Default login options
661 * @return void
662 * @access protected
663 */
664 function _setLoginDefaults(&$options) {
665 $options = array_merge(array(
666 'type' => 'basic',
667 'realm' => env('SERVER_NAME'),
668 'qop' => 'auth',
669 'nonce' => String::uuid()
670 ), array_filter($options));
671 $options = array_merge(array('opaque' => md5($options['realm'])), $options);
672 }
673 /**
674 * Calls a controller callback method
675 *
676 * @param object $controller Controller to run callback on
677 * @param string $method Method to execute
678 * @param array $params Parameters to send to method
679 * @return mixed Controller callback method's response
680 * @access protected
681 */
682 function _callback(&$controller, $method, $params = array()) {
683 if (is_callable(array($controller, $method))) {
684 return call_user_func_array(array(&$controller, $method), empty($params) ? null : $params);
685 } else {
686 // Debug::warning('Callback method ' . $method . ' in controller ' . get_class($controller)
687 return null;
688 }
689 }
690 }
691  
692 ?>
693