PHP Membership Provider

The MembershipProvider

This is (very)likely to contain many bugs, errors, dragons and other terrible things… any helpful feedback would be most welcome. As noted previously, this requires the Portable PHP password hashing framework.

Note: All $user parameters are referring to a passed User object from the previously created class.

<?php

/**
 * Membership class provides authentication, registration and user management functionality
 *
 * @package MembershipProvider
 */

final class MembershipProvider extends App {
	
	
	/********************************
	 * 	Constants		*
	 ********************************/
	
	
	/**
	 * @var int Number of bytes for the random salt
	 */
	const saltSize = 16;
	
	
	/**
	 * @var int Number iterations for password hashing
	 * Only change this BEFORE registering users or else 
	 * they won't be able to login again.
	 */
	const hashIterations = 12;
	
	
	
	
	/********************************
	 * 	Public Methods		*
	 ********************************/
	
	
	/**
	 * Class constructor
	 */
	public function __construct() {
		parent::__construct( "MembershipProvider" );
		
		// TODO: Other defaults
	}
	
	
	
	/**
	 * Creates a new user in the database
	 * 
	 * @param array $data User parameters to insert
	 * @param MembershipActionStatus $status Result of the operation
	 * @return int Newly created user Id or 0 on failure
	 */
	public function CreateUser( $user, &$status = null ) {
		
		// Duplicate check
		if ( !empty( $this->GetUserId( $user->username, "name", $status ) ) ) {
			
			$status = MembershipActionStatus::DuplicateUsername;
			return 0;
			
		}
		
		if ( !empty( $this->GetUserId( $user->email, "email", $status ) ) ) {
			
			$status = MembershipActionStatus::DuplicateEmail;
			return 0;
			
		}
		
		$users = $this->usersTable;
		$fields = "username, hash, password, passwordSalt, email, avatar, bio, " . 
				"isApproved, isLocked, createdDate, modifiedDate, lastActivity";
		
		// Convert fields to insert parameters
		$iVars = $this->SetParamSQL( $fields, true );
		
		$sql = "INSERT INTO $users ( $fields ) VALUES ( $iVars );";

		$q = $this->db->prepare( $sql );
		
		$hash = $this->PassHash( $user->password );
		
		// Created, modified and last activity
		$t = time();
		
		/**
		 *	Note: A hash is used to identify each user in case the username or email 
		 * 	contains homoglyph attacks and the userId isn't publicly displayed.
		 * 
		 * 	It will be very difficult for two users to share the same hash even if 
		 * 	the names are similar.
		 * 	Paired with the hash, this should dissuade username and email spoofing.
		 *
		 *	Read for more details : 
		 *		http://en.wikipedia.org/wiki/IDN_homograph_attack
		 *		http://stackoverflow.com/a/3470775
		 */
		$uHash = substr( md5( $user->username . $user->email ), 0, 7 );
		
		if ( !isset( $user->isApproved ) ) {
			
			if ( $this->autoApproveUsers )
				$user->isApproved = true;
			else
				$user->isApproved = false;
			
		}
		
		$q->execute( array(
				':username'	=> $user->username,
				':hash'		=> $uHash,
				':password'	=> $hash["pass"],
				':passwordSalt'	=> $hash["salt"],
				':email'	=> $user->email,
				':avatar'	=> $user->avatar,
				':bio'		=> $user->bio,
				':isApproved'	=> $user->isApproved,
				':isLocked'	=> $user->isLocked,
				':createdDate'	=> $t,
				':modifiedDate'	=> $t,
				':lastActivity' => $t
		));
		
		$status = MembershipActionStatus::Success;
		
		return $this->db->lastInsertId();
	}
	
	
	
	/**
	 * Changes user profile details
	 *
	 * @param array $data User parameters to save
	 * @param MembershipActionStatus $status Result of the operation
	 * @return int Rows affected
	 */
	public function UpdateUser( $user, &$status = null ) {
		
		// User wants to change email
		if ( !empty( $user->email ) )  {
			
			// Duplicate check
			if ( !empty( $this->GetUserId( $user->email, "email", $status ) ) ) {
				
				$status = MembershipActionStatus::DuplicateEmail;
				return 0;
				
			}
			
		}
		
		$users = $this->usersTable;
		$fields = "avatar, isApproved, isLocked, modifiedDate, bio";
		
		if ( !empty( $user->email ) )
			$fields .= ", email";
		
		
		// Convert fields to update parameters
		$uVars = $this->SetParamSQL( $fields );
		
		$sql = "UPDATE $users SET ( $uVars ) WHERE userId = :userId;";

		$q = $this->db->prepare( $sql );
		
		$params = array(
				':avatar'	=> $user->avatar,
				':isApproved'	=> $user->isApproved,
				':isLocked'	=> $user->isLocked,
				':modifiedDate'	=> time(),
				':bio'		=> $user->bio
			);
		
		if ( !empty( $user->email ) )
			$params[':email'] = $user->email;
		
		$params[':userId'] = $user->userId;
		
		$row = $q->execute( $params );
		
		if ( $row ) {
			
			$status = MembershipActionStatus::Success;
			return $row;
			
		} else {
			
			$status = MembershipActionStatus::UserUpdateFailed;
			return false;
			
		}
	}
	
	
	
	/**
	 * Resets a user's password
	 * 
	 * @param string $username Registered username
	 * @param MembershipActionStatus $status Result of the operation
	 * @return int Rows affected
	 */
	public function ResetPassword( $username, &$status = null ) {
		
		$id = $this->GetUserId( $username, "name", $status );
		
		if ( $id > 0 ) {
			
			$newPassword = $this->GeneratePassword(); // Generate password
			$row = $this->ChangePasswordById( $id, $newPassword, $status );
			
			if ( $row ) 
				return $newPassword;
			
		}

		return false;
	}
	
	
	
	/**
	 * Changes a user's password
	 * 
	 * @param string $username Registered username
	 * @param string $oldPassword Current password in the database
	 * @param string $newPassword New password to change into
	 * @param MembershipActionStatus $status Result of the operation
	 * @return int Rows affected
	 */
	public function ChangePassword( $username, $oldPassword, $newPassword, &$status = null ) {
		
		if ( !$this->ValidateUser( $username, $oldPassword, $status ) )
			return false;

		$id = $this->GetUserId( $username, "name", $status );
		
		if ( $id )
			return $this->ChangePasswordById( $id, $newPassword, $status );
		
		return false;
	}
	
	
	
	/**
	 * Changes a user's password by Id 
	 * (for Admin or reset use since original password isn't required)
	 * 
	 * @param int $userId User's Id
	 * @param string $newPassword New password to change to
	 * @param MembershipActionStatus $status Result of the operation
	 * @return int Rows affected
	 */
	public function ChangePasswordById( $userId, $newPassword, &$status = null ) {
		
		$hash = $this->PassHash( $newPassword );
		
		$users = $this->usersTable;
		
		// Convert fields to update parameters
		$uVars = $this->SetParamSQL( "password, passwordSalt" );
		
		$sql = "UPDATE $users SET ( $uVars ) WHERE userId = :userId;";
		$q = $this->db->prepare( $sql );
		
		$row = $q->execute(array(
				':password'	=> $hash["pass"],
				':passwordSalt'	=> $hash["salt"],
				':userId'	=> $userId
			));
		
		if ( $row ) {
			
			$status = MembershipActionStatus::Success;
			return $row;
			
		} else {
			
			$status = MembershipActionStatus::PasswordChangeFailed;
			return false;
			
		}
	}
	
	
	
	/**
	 * Authenticates a user with the given username and password
	 * 
	 * @param string $username Registered username
	 * @param string $password Given password
	 * @param MembershipActionStatus $status Result of the operation
	 * @return bool True on success or False on failure
	 */
	public function ValidateUser( $username, $password, &$status = null ) {
		
		$users = $this->usersTable;
		
		// SQL Query
		$sql = "SELECT username, password, passwordSalt, isLocked, isApproved FROM $users WHERE username = :username LIMIT 1;";
		$q = $this->db->prepare( $sql );
		
		$q->bindValue( ":username", $username, PDO::PARAM_STR );
		$q->execute();
		
		$row = $q->fetch();

		if ( empty( $row ) ) { // Nothing found
			
			$status = MembershipActionStatus::AuthenticationFailed;
			return false;

		}
		
		$user = new User( $row );
		$hasher = new PasswordHash(self::hashIterations, false);
			
		// Try to match the stored hash with against the given username + stored salt.
		// Validation will fail even if they match if the user is locked or unapproved
		$passValid = $hasher->CheckPassword( 
				( $password . $user->passwordSalt ) , $user->password 
			);
		if ( $passValid && empty( $row["isLocked"] ) && !empty( $row["isApproved"] ) ) {
			
			$status = MembershipActionStatus::Success;
			
			$t = time();
			
			$this->db->prepare("UPDATE $users SET lastActivity = $t WHERE username = :$username");
			$q->bindValue( ":username", $username, PDO::PARAM_STR );
			$q->execute();
			
			return true;
			
		} else {
				
			$status = MembershipActionStatus::AuthenticationFailed;
			
			// Login failed.
			// TODO: Update failed login attempt count
			
			return false;
			
		}
	}
	
	
	
	/**
	 * Unlocks or Locks a user. E.G. After a number of unsuccessful login attempts
	 * or bad behavior, the system can lock the user out
	 * 
	 * @param string $username User to lock or unlock
	 * @param bool $mode Lock or unlock mode (true if lock)
	 * @return int Rows affected
	 */
	public function SetUserLock( $username, $mode = true ) {
		
		$users = $this->usersTable;
		$sql = "UPDATE $users SET isLocked = " . ( ( $mode == true )? "1" : "0" ) . 
			" WHERE username = :username;";
		
		$q = $this->db->prepare( $sql );
		
		$q->bindValue( ":username", $username, PDO::PARAM_STR );
		
		return $q->execute();
	}


	/**
	 * Approves or unapproves a user. E.G. After verifying the registration via email, 
	 * a user can be approved.
	 * 
	 * @param string $username User to approve or unapprove
	 * @param bool $mode Approve or unapprove mode (true if approved)
	 * @return int Rows affected
	 */
	public function SetUserApproval( $username, $mode = true ) {
		
		$users = $this->usersTable;
		$sql = "UPDATE $users SET isApproved = " . ( ( $mode == true )? "1" : "0" ) . 
			" WHERE username = :username;";
		
		$q = $this->db->prepare( $sql );
		
		$q->bindValue( ":username", $username, PDO::PARAM_STR );
		
		return $q->execute();
	}

	
	
	/********************************
	 * 	Search Methods		*
	 * 	( Public API )		*
	 ********************************/
	
	
	public function FindUsersByName( $username, $page, $size, &$total = 0 ) {
		
		return $this->GetUserArray( $page, $size, $total, $username, "name" );
		
	}
		
	public function FindUsersByEmail( $email, $page, $size, &$total = 0 ) {
		
		return $this->GetUserArray( $page, $size, $total, $email, "email" );
		
	}
	
	
	public function GetUserById( $id ) {
		
		return $this->GetUser( $id, "id" );
		
	}
	

	public function GetUserByName( $name ) {
		
		return $this->GetUser( $name, "name" );
		
	}
	
	
	public function GetUserByEmail( $email ) {
		
		return $this->GetUser( $email, "email" );
		
	}
	
	
	public function DeleteUserById( $id ) {
		
		return $this->DeleteUser( $id, "id" );
		
	}
	
	
	public function DeleteUserByName( $name ) {
		
		return $this->DeleteUser( $name, "name" );
		
	}
	
	
	public function DeleteUserByEmail( $email ) {
		
		return $this->DeleteUser( $name, "email" );
		
	}
	
	
	
	
	
	/********************************
	 * 	Private Methods		*
	 ********************************/
	
	
	/**
	 * Removes a user permanently by Id, email or name (this cannot be undone!)
	 * 
	 * @param string $param User parameter to delete by
	 * @param string $mode Search parameter type
	 * @return int Rows affected
	 */
	private function DeleteUser( $param = "*", $mode = "id" ) {

		$mode = strtolower( $mode );
		
		if ( $mode != "id" && $mode != "name" && $mode != "email" )
			throw new Exception( "Unknown search mode $mode ( MembershipProvider::DeleteUser )" );
		elseif ( $param == "*" )
			throw new Exception( "Unknown search parameter $param ( MembershipProvider::DeleteUser )" );
		
		$users = $this->usersTable;
		
		// SQL Statement starter
		$sql = "DELETE FROM $users WHERE ";
		
		switch( $mode ) {
			
			case "id" :
				
				$sql .= "userId = :userId LIMIT 1;";
				
				$q = $this->db->prepare( $sql );
				$q->bindValue( ":userId", $param, PDO::PARAM_INT );
				
			break;
			
			case "name" :
				
				$sql .= "username = :username LIMIT 1;";
				
				$q = $this->db->prepare( $sql );
				$q->bindValue( ":username", $param, PDO::PARAM_STR );
				
			break;
			
			case "email" :
				
				$sql .= "email = :email LIMIT 1;";
				
				$q = $this->db->prepare( $sql );
				$q->bindValue( ":email", $param, PDO::PARAM_STR );
				
			break;
		}
		
		return 	$q->execute();
	}
	
	
	
	/**
	 * Returns an array of all users or by matching username or matching email
	 * 
	 * @return array Of User objects
	 */
	private function GetUserArray( $page, $size, &$total = 0, $param = "*", $mode = "all" ) {
		
		$mode = strtolower( $mode );
		
		if ( $mode != "all" && $mode != "name" && $mode != "email" )
			throw new Exception( "Unknown search mode $mode ( MembershipProvider::GetUserArray )" );
		elseif ( $param == "*" && $mode != "all" )
			throw new Exception( "Unknown search parameter $param ( MembershipProvider::GetUserArray )" );
		
		$skip = ( $page - 1 ) * $size;
		$users = $this->usersTable;
		
		$total = $this->db->query("SELECT COUNT(*) FROM $users;");
		
		// SQL Query starter
		$sql = "SELECT userId, hash, username, hash, email, avatar, createdDate, " . 
			"isApproved, isLocked, lastActivity FROM $users WHERE isApproved = 1 AND ";
		
		switch( $mode ) {
			
			case "email" :
				
				$sql .= "email LIKE :email ORDER BY createdDate ASC LIMIT :size OFFSET :skip;";
				
				$q = $this->db->prepare( $sql );
				$q->bindParam(":email", '%' . $param . '%', PDO::PARAM_STR);
				
			break;
			
			case "name" :
				
				$sql .= "username LIKE :username ORDER BY createdDate ASC LIMIT :size OFFSET :skip;";
				
				$q = $this->db->prepare( $sql );
				$q->bindParam(":username", '%' . $param . '%', PDO::PARAM_STR);
				
			break;
			
			default :
				
				$sql .= "ORDER BY createdDate LIMIT :size OFFSET :skip;";
				
				$q = $this->db->prepare( $sql );
				
			break;
		}

		

		$q->bindParam(":size", $size, PDO::PARAM_INT);
		$q->bindParam(":skip", $skip, PDO::PARAM_INT);
		
		$q->execute();
		
		$users = array();
		
		while ( $row = $q->fetch() ) {
			
			$users[] = new User( $row );
			
		}
		
		return $users;
	}
	


	/**
	 * Finds a user by a given id, username or email
	 * 
	 * @param string $param Search parameter
	 * @param string $mode Search parameter type
	 * @return object User
	 */
	private function GetUser( $param, $mode = "id" ) {
		

		$mode = strtolower( $mode );
		
		if ( $mode != "id" && $mode != "name" && $mode != "email" )
			throw new Exception( "Unknown search parameter $mode ( MembershipProvider::GetUser )" );

		
		$users = $this->usersTable;
		
		// SQL Query starter
		$sql = "SELECT userId, username, hash, email, avatar, createdDate, " . 
			"isApproved, isLocked, modifiedDate, lastActivity, bio FROM $users WHERE ";
		
		switch( $mode ) {
			
			case "email" :
				
				$sql .= "email = :email LIMIT 1";
				$q = $this->db->prepare( $sql );
	
				$q->bindValue( ":email", $param, PDO::PARAM_STR );
				
			break;
			
			case "name" :
				
				$sql .= "username = :username LIMIT 1";
				$q = $this->db->prepare( $sql );
	
				$q->bindValue( ":username", $param, PDO::PARAM_STR );
				
			break;
			
			default :
				
				$sql .= "userId = :userId LIMIT 1";
				$q = $this->db->prepare( $sql );
	
				$q->bindValue( ":userId", $param, PDO::PARAM_INT );
				
			break;
		}
		
		
		$q->execute();
		$row = $q->fetch();
		
		if( $row )
			return new User( $row );
		
		return null;
	}
	
	
	
	/**
	 * Returns a user's Id by username or email
	 * 
	 * @param string $param Search parameter
	 * @param string $mode Search mode ("name" or "email")
	 * @param MembershipActionStatus $status Result of the operation
	 * @return int Rows affected
	 */
	private function GetUserId( $param, $mode, &$status = null ) {
		
		$users = $this->usersTable;
		
		// SQL Query
		if ( $mode == "name" )
			$sql = "SELECT userId FROM $users WHERE username = :param LIMIT 1;";
		else
			$sql = "SELECT userId FROM $users WHERE email = :param LIMIT 1;";
		
			
		$q = $this->db->prepare( $sql );
		$q->bindValue( ":param", $param, PDO::PARAM_STR );

		$row = $q->execute();
		
		if ( $row ) {
			
			$stats = MembershipActionStatus::Success;
			return $row;
			
		} else {
			
			if ( $mode == "name" )
				$stats = MembershipActionStatus::InvalidUsername;
			else
				$stats = MembershipActionStatus::InvalidEmail;

			return 0;
			
		}
	}
	
	

	/**
	 * Generates a salt for the given password and hashes both
	 *
	 * @param string $password User password (this should not be stored in plain text anywhere!)
	 * @return array With key "pass" specifying hashed password + salt
	 */
	
	private function PassHash( $password ) {
		
		$hasher = new PasswordHash(self::hashIterations, false);
		$salt = $hasher->get_random_bytes(self::saltSize);
		$hash = $hasher->HashPassword( $newPassword . $salt );
		
		return array("pass" => $hash, "salt" => salt);
	}
	
	
	
	/**
	 * Generates a new random password
	 * 
	 * @param int $length Optional parameter defaults to minRequiredPasswordLength if empty
	 */
	private function GeneratePassword( $length = null ) {
		
		// Excludes commonly confused letters and numbers : [0 o], [1 i l], [2 5 s]
		$range = "346789abcdefghkmnpqrtuvwxyzABCDEFGHKMNPQRTUVWXYZ";
		
		$max = strlen( $range );
		$newPassword = "";
		
		if ( empty( $length ) )
			$length = $this->minRequiredPasswordLength;
		
		if ( $length > $max )
			$length = $max;
		
		for ( $i = 0; $ < $length; $i++ ) {
			
			$newPassword .= $range{ mt_rand( 0, $max - 1 ) };
			
		}
		
		return $newPassword;
	}
}

?>

As I said, haven’t tested this, not a professional PHP programmer, may contain bugs/errors, may cause dry eyes, cottonmouth, dizziness, excessive bladder pressure etc… etc… But if anyone was looking for a place to start with a MembershipProvider implementation and maybe some ideas on user management, here it is.

Enjoy!

6 thoughts on “PHP Membership Provider

  1. Although I took a short introductory class for php I consider it, and all coding really, something akin to electricity – i.e. it’s not a hobby – only hire a licensed electrician! I love the idea of myself dabbling in code, but I would never think for myself to go whole hog and live with anything as the risks are too daunting. Nonetheless, I sometimes dream of buckling down and learning to code and creating some useful apps for my job or a cool site for my friends or self etc… Maybe someday.

    • Haha! That’s a great analogy :D

      But see there are people who do electrical work as a hobby too. Some people even build Tesla Coils, which can not only kill instantly, but also blow up spectacularly if everything isn’t perfect… but they’re oh-so-much-fun!

      If you’re considering diving in again, all I can say is stay away from w3schools. It’s really, really, bad!

      I think a lot of tutorials out there are just not intro-friendly. And a lot of people are taught with some really ugly, ugly code (in all languages) with no consideration of human nature. I.E. They jump right into programming techniques — some questionable — without covering basics first and that turns off a lot of people.

      We’re not robots… We use robots to work like robots.

      • Interesting as I’ve always heard of the usefulness of w3schools… But then again, maybe they are a reason I don’t bother progressing with coding. Homemade Tesla coils huh…? Now that’s interesting!

  2. I love this idea. I can’t tell you how many times I’ve written my own user authentication scheme, and typically it’s no more than a hard-coded hack (with the necessary input sanitation, of course!) with some super slap-dash session management thrown in. I would absolutely love a standardized, built-in package so I don’t have to rely on super complicated CMS implementations like Drupal / WordPress / CakePHP just to have a robust authentication and user management system.

    • Thanks!

      If you happen to find it useful I’d love to know about it. I’m also thinking of including a standard sanitization class. Probably something based on a class I’ve written before rather than reinventing the wheel.

      It’s pretty silly that we have to write more when we need less!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s