Przeglądaj źródła

Implemented first version of security and user classes

Taddeus Kroes 13 lat temu
rodzic
commit
e7456e9777
4 zmienionych plików z 441 dodań i 77 usunięć
  1. 195 77
      security.php
  2. 4 0
      session.php
  3. 236 0
      users.php
  4. 6 0
      webbasics.php

+ 195 - 77
security.php

@@ -7,7 +7,7 @@
  * try {
  *     $security = webbasics\Security::getInstance();
  *     
- *     // Authentication: can the origin of the request be trusted?
+ *     // SecureUser: can the origin of the request be trusted?
  *     
  *     // Verify that a user is logged in
  *     $security->requireLogin();
@@ -24,11 +24,11 @@
  *     
  *     // Pass token to template so that it can be used in a submitted form or
  *     // AJAX request
- *     $template->set('token', $auth->generateToken());
+ *     $template->set('token', $user->generateToken());
  *     
  *     ...
  *     
- * } catch(webbasics\AuthenticationFailed $e) {
+ * } catch(webbasics\SecureUserFailed $e) {
  *     die('Get lost hacker!');
  * } catch(webbasics\AuthorizationFailed $e) {
  *     http_response_code(403);
@@ -44,86 +44,55 @@
  * if (!$user)
  *     die('Invalid username');
  * 
- * // Current user is part of the 
  * $security = webbasics\Security::getInstance();
- * $security->setUser($user);
  * 
  * // Simple: use a plain password
- * if (!$security->attemptPassword($user, $_POST['password']))
+ * if (!$security->attemptPasswordLogin($user, $_POST['password']))
  *     die('Invalid password');
  * 
  * // More secure: hash the password in a javascript function before
  * // submitting the login form
- * if (!$security->attemptPasswordHash($user, $_POST['password_hash']))
+ * if (!$security->attemptPasswordHashLogin($user, $_POST['password_hash']))
  *     die('Invalid password');
  * </code>
  * 
  * And the User model implementation used in the example above:
  * <code>
- * use ActiveRecord\Model;
- * use webbasics\AuthenticatedUser;
- * use webbasics\AuthorizedUser;
  * 
- * class User extends Model implements AuthenticatedUser, AuthorizedUser {
- *     function getUsername() {
- *         return $this->username;
- *     }
- *     
- *     function getPasswordHash() {
- *         return $this->password;
- *     }
- *     
- *     function getCookieToken() {
- *         return $this->cookie_token;
- *     }
- *     
- *     function setCookieToken($token) {
- *         $this->update_attribute('cookie_token', $token);
- *     }
- *     
- *     function getRegistrationToken() {
- *         return $this->registration_token;
- *     }
- *     
- *     function setRegistrationToken($token) {
- *         $this->update_attribute('registration_token', $token);
- *     }
- *     
- *     function getRole() {
- *         return $this->role;
- *     }
- * }
+ * use webbasics\ActiveRecordUser;
+ * class User extends webbasics\ActiveRecordUser {}
  * </code>
  * 
+ * WebBasics provides the {@link AuthenticatedUser} and {@link AuthorizedUser}
+ * classes, which extend ActiveRecord\Model and implement both security
+ * interfaces.
+ * 
  * @author Taddeus Kroes
- * @date 05-10-2012
+ * @date 06-10-2012
+ * @since 0.2
+ * @todo Documentation, unit tests
  */
 
 namespace webbasics;
 
-require_once 'base.php';
-
-interface AuthenticatedUser {
-	function getUsername();
-	function getPasswordHash();
-	
-	function getCookieToken();
-	function setCookieToken($token);
-	
-	function getRegistrationToken();
-	function setRegistrationToken($token);
-}
-
-interface AuthorizedUser {
-	function getRole();
-}
+require_once 'session.php';
 
 class Security implements Singleton {
-	const SESSION_TOKEN_NAME = 'auth_token';
-	const SESSION_NAME_USERDATA = 'auth_userdata';
+	/**
+	 * All alphanumeric characters.
+	 * @var string
+	 */
+	const ALNUM_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUWXYZ01234567890123456789';
+	
+	const TOKEN_NAME = 'auth_token';
+	const USERDATA_NAME = 'auth_userdata';
 	
 	private static $instance;
 	
+	/**
+	 * User that is currently logged in.
+	 * @var BaseUser
+	 */
 	private $user;
 	
 	static function getInstance() {
@@ -138,58 +107,207 @@ class Security implements Singleton {
 	function generateToken() {
 		$session = Session::getInstance();
 		$token = sha1(self::generateRandomString(10));
-		$session->set(self::SESSION_TOKEN_NAME, $token);
+		$session->set(self::TOKEN_NAME, $token);
 		return $token;
 	}
 	
 	function requireToken($request_token) {
 		if ($request_token != $this->getSavedToken())
-			throw new AuthenticationFailed('invalid token "%s"', $request_token);
+			throw new SecureUserFailed('invalid token "%s"', $request_token);
 	}
 	
 	private function getSavedToken() {
 		$session = Session::getInstance();
 		
-		if (!$session->isRegistered(self::SESSION_TOKEN_NAME))
-			throw new AuthenticationError('no token saved in session');
+		if (!$session->isRegistered(self::TOKEN_NAME))
+			throw new SecureUserError('no token saved in session');
 		
-		return $session->get(self::SESSION_TOKEN_NAME);
+		return $session->get(self::TOKEN_NAME);
 	}
 	
-	function sessionDataExists() {
-		return Session::getInstance()->areRegistered(array(
-			self::SESSION_TOKEN_NAME, self::SESSION_NAME_USERDATA));
+	function requireLogin() {
+		if ($this->user === null && !$this->loadUserFromSession())
+			throw new SecureUserFailed('no user is logged in');
 	}
 	
-	function requireLogin() {
+	private function loadUserFromSession() {
+		$session = Session::getInstance();
 		
+		if (!$session->isRegistered(self::USERDATA_NAME))
+			return false;
+		
+		// Load session data
+		$user = $session->get(self::USERDATA_NAME);
+		
+		// Verify session data
+		if ($user->getSessionHash() != $this->getSessionHash())
+			throw new SecureUserFailure('session data could not be verified');
+		
+		$this->user = $user;
+		return true;
+	}
+	
+	function getSessionHash() {
+		return self::hash(Session::getInstance()->getId());
 	}
 	
-	function requireUserRole() {
+	function requireUserRole($required_role) {
+		if (!($this->user instanceof RoleUser))
+			throw new AuthorizationError('user must implement interface RoleUser');
 		
+		if ($this->user->getRole() != $required_role)
+			throw new AuthorizationFailed('page requires user role "%s"', $required_role);
 	}
 	
-	//function setUser(AuthenticatedUser $user) {
-	//	$this->user = $user;
-	//}
+	function attemptPasswordLogin(BaseUser $user, $password) {
+		return $this->attemptPasswordHashLogin($user, self::hash($password));
+	}
 	
-	static function generateRandomString($length) {
-		$CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUWXYZ01234567890123456789';
-		$string = '';
+	function attemptPasswordHashLogin(BaseUser $user, $password_hash) {
+		if ($password_hash != $user->getPasswordHash())
+			return false;
+		
+		$this->loginUser($user);
+		return true;
+	}
+	
+	private function loginUser(BaseUser $user) {
+		// Create a new session id so that a hijacked session will not become
+		// logged in as well
+		Session::getInstance()->regenerateId();
 		
+		// Save a hash of the new session id in the database for verification
+		$user->setSessionHash($this->getSessionHash());
+		$this->user = clone $user;
+		Session::getInstance()->set(self::USERDATA_NAME, $this->user);
+	}
+	
+	/**
+	 * Get the hash value of a string.
+	 * 
+	 * THe hash function used is SHA-256.
+	 * 
+	 * @param string $string The string to hash.
+	 * @return string A 64-byte hash.
+	 */
+	static function hash($string) {
+		return hash('sha256', $string);
+	}
+	
+	/**
+	 * Generate a random string of the specified length.
+	 * 
+	 * @param int $lengh The length of the string to generate.
+	 * @param string $chars The caracters to choose from, defaults to {@link ALNUM_CHARS}.
+	 * @return string The generated string.
+	 */
+	static function generateRandomString($length, $chars=self::ALNUM_CHARS) {
 		srand(time());
+		$string = '';
 		
 		for ($i = 0; $i < $length; $i++)
-			$string .= $CHARS[rand(0, strlen($CHARS) - 1)];
+			$string .= $chars[rand(0, strlen($chars) - 1)];
 		
 		return $string;
 	}
 }
 
-class AuthenticationError extends FormattedException {}
-class AuthenticationFailed extends FormattedException {}
+interface BaseUser {
+	function getUsername();
+	function getPasswordHash();
+}
+
+interface SecureUser extends BaseUser {
+	function getSessionHash();
+	function setSessionHash($hash);
+}
+
+interface RoleUser {
+	function getRole();
+}
+
+/**
+ * Exception, thrown when an error occurs during authentication.
+ */
+class SecureUserError extends FormattedException {}
 
+/**
+ * Exception, thrown when the current user cannot be authenticated.
+ */
+class SecureUserFailed extends FormattedException {}
+
+/**
+ * Exception, thrown when an error occurs during authorization.
+ */
 class AuthorizationError extends FormattedException {}
+
+/**
+ * Exception, thrown when the current user is unauthorized to perform the
+ * current request.
+ */
 class AuthorizationFailed extends FormattedException {}
 
+/**
+ * The StaticUser class is meant for websites without databases, it allows for
+ * hard-coded usernames and passwords to be specified in user objects.
+ * 
+ * Example usage:
+ * <code>
+ * class JohnDoe extends webbasics\StaticUser {
+ *     function getUsername() {
+ *         return 'john';
+ *     }
+ *     
+ *     function getPassword() {
+ *         return 'foobar';
+ *     }
+ * }
+ * 
+ * // Login controller:
+ * $security = webbasics\Security::getInstance();
+ * 
+ * switch ($_POST['username']) {
+ *     case 'john':
+ *         $success = $security->attemptPasswordLogin(new JohnDoe, $_POST['password']);
+ *         break;
+ *     ...
+ * }
+ * 
+ * // And this might be handy during debugging:
+ * $security->loginUser(new JohnDoe);
+ * </code>
+ * 
+ * Note that this simple method of authentication even allows role-based authorization:
+ * <code>
+ * abstract class User extends webbasics\StaticUser implements webbasics\RoleUser {}
+ * 
+ * class JohnDoe extends User {
+ *     function getUsername { return 'john'; }
+ *     function getPassword { return 'foobar'; }
+ *     function getRole     { return 'admin'; }
+ * }
+ * 
+ * class JaneDoe extends User {
+ *     function getUsername { return 'jane'; }
+ *     function getPassword { return 'barfoo'; }
+ *     function getRole     { return 'member'; }
+ * }
+ * </code>
+ */
+abstract class StaticUser implements BaseUser {
+	/**
+	 * @see BaseUser::getPasswordHash()
+	 */
+	function getPasswordHash() {
+		return Security::hash($this->getPassword());
+	}
+	
+	/**
+	 * 
+	 * 
+	 * @return string 
+	 */
+	abstract function getPassword();
+}
+
 ?>

+ 4 - 0
session.php

@@ -77,6 +77,10 @@ class Session implements Singleton {
 		return true;
 	}
 	
+	function getId() {
+		return session_id();
+	}
+	
 	function regenerateId() {
 		session_regenerate_id();
 	}

+ 236 - 0
users.php

@@ -0,0 +1,236 @@
+<?php
+/**
+ * User model implementation linking PHPActiveRecord to the Security class.
+ * 
+ * @author Taddeus Kroes
+ * @date 06-10-2012
+ * @since 0.2
+ */
+
+namespace webbasics;
+
+require_once 'security.php';
+require_once 'php-activerecord/ActiveRecord.php';
+
+abstract class ActiveRecordUser extends \ActiveRecord\Model implements SecureUser, RoleUser {
+	/**
+	 * Name of the cookie holding user data.
+	 * @var string
+	 */
+	const COOKIE_NAME = 'auth_userdata';
+	
+	/**
+	 * Keep authentication cookies for one month.
+	 * @var int
+	 */
+	const COOKIE_EXPIRE = 2592000;
+	
+	/**
+	 * Length of the salt prepended to the password.
+	 * @var int
+	 */
+	const SALT_LENGTH = 6;
+	
+	/**
+	 * Name of the table users are saved in.
+	 * @var string
+	 */
+	static $table_name = 'users';
+	
+	/**
+	 * 
+	 * @var string[5]
+	 */
+	static $attr_protected = array('username', 'password_hash', 'salt', 'session_hash', 'role');
+	
+	/**
+	 * Plain password, optional and used only during registration process.
+	 * @var string
+	 */
+	public $password;
+	
+	/**
+	 * 
+	 */
+	function before_create() {
+		$this->hashPassword();
+		$this->saltPasswordHash();
+	}
+	
+	/**
+	 * 
+	 */
+	function hashPassword() {
+		if (!$this->password_hash && $this->password)
+			$this->password_hash = Security::hash($this->password);
+	}
+	
+	/**
+	 * 
+	 */
+	function saltPasswordHash() {
+		$this->password_hash = Security::hash(self::generateSalt() . $this->password_hash);
+	}
+	
+	/**
+	 * @see BaseUser::getUsername()
+	 */
+	function getUsername() {
+		return $this->username;
+	}
+	
+	/**
+	 * @see BaseUser::getPasswordHash()
+	 */
+	function getPasswordHash() {
+		return $this->password_hash;
+	}
+	
+	/**
+	 * @see SecureUser::getSessionHash()
+	 */
+	function getSessionHash() {
+		return $this->session_hash;
+	}
+	
+	/**
+	 * @see SecureUser::setSessionHash()
+	 */
+	function setSessionHash($hash) {
+		$this->update_attribute('session_hash', $hash);
+	}
+	
+	/**
+	 * 
+	 * 
+	 * @return string A 6-byte salt.
+	 */
+	private static function generateSalt() {
+		$class_name = get_called_class();
+		return Security::generateRandomString($class_name::SALT_LENGTH);
+	}
+	
+	/**
+	 * @see SecureUser::getRole()
+	 */
+	function getRole() {
+		return $this->role;
+	}
+	
+	function saveCookie() {
+		$class_name = get_class($this);
+		$data = array($this->getUsername(), $this->getSessionHash());
+		setcookie($class_name::COOKIE_NAME, implode(',', $data), $class_name::COOKIE_EXPIRE);
+	}
+	
+	/**
+	 * @see 
+	 */
+	static function loadFromCookie() {
+		$class_name = get_called_class();
+		
+		if (!isset($_COOKIE[$class_name::COOKIE_NAME]))
+			return false;
+		
+		list($username, $session_hash) = explode(',', $_COOKIE[$class_name::COOKIE_NAME]);
+		$user = $class_name::first(array(
+			'conditions' => compact('username', 'session_hash')
+		));
+		
+		Security::getInstance()->loginUser($user);
+		$user->saveCookie();
+		
+		return true;
+	}
+}
+
+abstract class RegisteredUser extends ActiveRecordUser {
+	/**
+	 * Length of passwords generated after registration.
+	 * @var int
+	 */
+	const PASSWORD_LENGTH = 8;
+	
+	/**
+	 * Attributes protected against mass assignment.
+	 * @var string[6]
+	 */
+	static $attr_protected = array('username', 'password_hash', 'salt',
+		'session_hash', 'role', 'registration_token');
+	
+	/**
+	 * Send a confirmation e-mail after registration.
+	 * @var string[1]
+	 */
+	static $after_create = array('composeConfirmationMail');
+	
+	function before_create() {
+		parent::before_create();
+		$this->registration_token = sha1($this->getUsername() . time());
+	}
+	
+	static function confirmRegistrationToken($obfuscated_token) {
+		$token = self::deobfuscateToken($obfuscated_token);
+		$user = self::first(array(
+			'conditions' => array('registration_token' => $token)
+		));
+		
+		if (!$user || $token != $user->registration_token)
+			return false;
+		
+		$user->confirmRegistration();
+		
+		return true;
+	}
+	
+	function confirmRegistration() {
+		$this->clearRegistrationToken();
+		$this->composeWelcomeMail();
+	}
+	
+	function clearRegistrationToken() {
+		$this->update_attribute('registration_token', null);
+	}
+	
+	function composeWelcomeMail() {
+		$this->sendWelcomeMail($this->username, $this->generatePassword());
+	}
+	
+	function generatePassword() {
+		$class_name = get_class($this);
+		$password = Security::generateRandomString($class_name::PASSWORD_LENGTH);
+		$this->update_attribute('password_hash', Security::hash($password));
+		return $password;
+	}
+	
+	function composeConfirmationMail() {
+		$this->sendConfirmationMail($this->username, $this->obfuscateToken());
+	}
+	
+	function obfuscateToken() {
+		$obfuscated = '';
+		
+		foreach (range(0, strlen($this->registration_token) - 1) as $i)
+			$obfuscated .= $this->registration_token[$i] . chr(rand(97, 122));
+		
+		return $obfuscated;
+	}
+	
+	static function deobfuscateToken($obfuscated) {
+		$token = '';
+		
+		foreach (range(0, strlen($obfuscated) - 1, 2) as $i)
+			$token .= $obfuscated[$i];
+		
+		return $token;
+	}
+	
+	function isConfirmed() {
+		return $this->registration_token === null;
+	}
+	
+	abstract function sendConfirmationMail($username, $token);
+	abstract function sendWelcomeMail($username, $password);
+}
+
+?>

+ 6 - 0
webbasics.php

@@ -15,6 +15,12 @@ require_once 'collection.php';
 require_once 'router.php';
 require_once 'template.php';
 require_once 'session.php';
+require_once 'security.php';
+
+if (defined('WB_INCLUDE_PHPACTIVERECORD') && WB_INCLUDE_PHPACTIVERECORD) {
+	require_once 'php-activerecord/ActiveRecord.php';
+	require_once 'user.php';
+}
 // @codeCoverageIgnoreEnd
 
 ?>