1
0
mirror of https://github.com/delight-im/PHP-Auth.git synced 2025-08-24 08:32:59 +02:00

16 Commits

Author SHA1 Message Date
Marco
8acd3a9779 Add tests for method 'loginWithUsername' in class 'Auth' 2017-02-20 21:46:36 +01:00
Marco
374f27176b Add tests for method 'registerWithUniqueUsername' in class 'Auth' 2017-02-20 21:42:48 +01:00
Marco
3cb2284870 Add public method 'loginWithUsername' to class 'Auth' 2017-02-20 21:36:45 +01:00
Marco
690485ba6d Add support for sign in via username to 'authenticateUserInternal' 2017-02-20 21:32:45 +01:00
Marco
495a87d499 No need to check for uniqueness of username if none has been provided 2017-02-20 20:52:02 +01:00
Marco
784030139b Treat empty string or whitespace-only string as non-existent username 2017-02-20 20:48:03 +01:00
Marco
fb6f3d31b8 Add private method 'getUserDataByUsername' to class 'Auth' 2017-02-20 19:57:23 +01:00
Marco
370ecc4933 Add class 'AmbiguousUsernameException' 2017-02-16 09:48:55 +01:00
Marco
da2d282648 Add class 'UnknownUsernameException' 2017-02-16 09:48:04 +01:00
Marco
4aaf85e3cf Add class 'EmailOrUsernameRequiredError' 2017-02-16 09:47:26 +01:00
Marco
f2561a1932 Re-use 'getUserDataByEmailAddress' in 'authenticateUserInternal' 2017-02-16 08:56:44 +01:00
Marco
8cc54473e3 Improve language of parameter name 2017-02-16 08:30:24 +01:00
Marco
f26f2209cd Store email address in session data as found in the database 2017-02-16 08:25:14 +01:00
Marco
188086f2e4 Do not validate password earlier than necessary 2017-02-16 08:18:48 +01:00
Marco
c6213a6081 Change order of parameters in 'authenticateUserInternal' 2017-02-16 08:15:48 +01:00
Marco
c55250c572 Refactor body of 'login' method into new 'authenticateUserInternal' 2017-02-15 18:29:15 +01:00
4 changed files with 247 additions and 71 deletions

View File

@@ -137,6 +137,8 @@ catch (\Delight\Auth\TooManyRequestsException $e) {
}
```
If you want to sign in with usernames on the other hand, either in addition to the login via email address or as a replacement, that's possible as well. Simply call the method `loginWithUsername` instead of method `login`. Then, instead of catching `InvalidEmailException`, make sure to catch both `UnknownUsernameException` and `AmbiguousUsernameException`. You may also want to read the notes about the uniqueness of usernames in the section that explains how to [sign up new users](#registration-sign-up).
### Email verification
Extract the selector and token from the URL that the user clicked on in the verification email.

View File

@@ -254,7 +254,7 @@ class Auth {
}
/**
* Attempts to sign in a user
* Attempts to sign in a user with their email address and password
*
* @param string $email the user's email address
* @param string $password the user's password
@@ -265,61 +265,27 @@ class Auth {
* @throws AuthError if an internal problem occurred (do *not* catch)
*/
public function login($email, $password, $rememberDuration = null) {
$email = self::validateEmailAddress($email);
$password = self::validatePassword($password);
$this->authenticateUserInternal($password, $email, null, $rememberDuration);
}
try {
$userData = $this->db->selectRow(
'SELECT id, password, verified, username FROM users WHERE email = ?',
[ $email ]
);
}
catch (Error $e) {
throw new DatabaseError();
}
if (!empty($userData)) {
if (password_verify($password, $userData['password'])) {
// if the password needs to be re-hashed to keep up with improving password cracking techniques
if (password_needs_rehash($userData['password'], PASSWORD_DEFAULT)) {
// create a new hash from the password and update it in the database
$this->updatePassword($userData['id'], $password);
}
if ($userData['verified'] === 1) {
$this->onLoginSuccessful($userData['id'], $email, $userData['username'], false);
// continue to support the old parameter format
if ($rememberDuration === true) {
$rememberDuration = 60 * 60 * 24 * 28;
}
elseif ($rememberDuration === false) {
$rememberDuration = null;
}
if ($rememberDuration !== null) {
$this->createRememberDirective($userData['id'], $rememberDuration);
}
return;
}
else {
throw new EmailNotVerifiedException();
}
}
else {
$this->throttle(self::THROTTLE_ACTION_LOGIN);
$this->throttle(self::THROTTLE_ACTION_LOGIN, $email);
throw new InvalidPasswordException();
}
}
else {
$this->throttle(self::THROTTLE_ACTION_LOGIN);
$this->throttle(self::THROTTLE_ACTION_LOGIN, $email);
throw new InvalidEmailException();
}
/**
* Attempts to sign in a user with their username and password
*
* When using this method to authenticate users, you should ensure that usernames are unique
*
* Consistently using {@see registerWithUniqueUsername} instead of {@see register} can be helpful
*
* @param string $username the user's username
* @param string $password the user's password
* @param int|bool|null $rememberDuration (optional) the duration in seconds to keep the user logged in ("remember me"), e.g. `60 * 60 * 24 * 365.25` for one year
* @throws UnknownUsernameException if the specified username does not exist
* @throws AmbiguousUsernameException if the specified username is ambiguous, i.e. there are multiple users with that name
* @throws InvalidPasswordException if the password was invalid
* @throws EmailNotVerifiedException if the email address has not been verified yet via confirmation email
* @throws AuthError if an internal problem occurred (do *not* catch)
*/
public function loginWithUsername($username, $password, $rememberDuration = null) {
$this->authenticateUserInternal($password, null, $username, $rememberDuration);
}
/**
@@ -765,18 +731,27 @@ class Auth {
$username = isset($username) ? trim($username) : null;
// if the supplied username is the empty string or has consisted of whitespace only
if ($username === '') {
// this actually means that there is no username
$username = null;
}
// if the uniqueness of the username is to be ensured
if ($requireUniqueUsername) {
// count the number of users who do already have that specified username
$occurrencesOfUsername = $this->db->selectValue(
'SELECT COUNT(*) FROM users WHERE username = ?',
[ $username ]
);
// if a username has actually been provided
if ($username !== null) {
// count the number of users who do already have that specified username
$occurrencesOfUsername = $this->db->selectValue(
'SELECT COUNT(*) FROM users WHERE username = ?',
[ $username ]
);
// if any user with that username does already exist
if ($occurrencesOfUsername > 0) {
// cancel the operation and report the violation of this requirement
throw new DuplicateUsernameException();
// if any user with that username does already exist
if ($occurrencesOfUsername > 0) {
// cancel the operation and report the violation of this requirement
throw new DuplicateUsernameException();
}
}
}
@@ -812,20 +787,135 @@ class Auth {
return $newUserId;
}
/**
* Authenticates an existing user
*
* @param string $password the user's password
* @param string|null $email (optional) the user's email address
* @param string|null $username (optional) the user's username
* @param int|bool|null $rememberDuration (optional) the duration in seconds to keep the user logged in ("remember me"), e.g. `60 * 60 * 24 * 365.25` for one year
* @throws InvalidEmailException if the email address was invalid or could not be found
* @throws UnknownUsernameException if an attempt has been made to authenticate with a non-existing username
* @throws AmbiguousUsernameException if an attempt has been made to authenticate with an ambiguous username
* @throws InvalidPasswordException if the password was invalid
* @throws EmailNotVerifiedException if the email address has not been verified yet via confirmation email
* @throws AuthError if an internal problem occurred (do *not* catch)
*/
private function authenticateUserInternal($password, $email = null, $username = null, $rememberDuration = null) {
if ($email !== null) {
$email = self::validateEmailAddress($email);
// attempt to look up the account information using the specified email address
try {
$userData = $this->getUserDataByEmailAddress(
$email,
[ 'id', 'email', 'password', 'verified', 'username' ]
);
}
// if there is no user with the specified email address
catch (InvalidEmailException $e) {
// throttle this operation
$this->throttle(self::THROTTLE_ACTION_LOGIN);
$this->throttle(self::THROTTLE_ACTION_LOGIN, $email);
// and re-throw the exception
throw new InvalidEmailException();
}
}
elseif ($username !== null) {
$username = trim($username);
// attempt to look up the account information using the specified username
try {
$userData = $this->getUserDataByUsername(
$username,
[ 'id', 'email', 'password', 'verified', 'username' ]
);
}
// if there is no user with the specified username
catch (UnknownUsernameException $e) {
// throttle this operation
$this->throttle(self::THROTTLE_ACTION_LOGIN);
$this->throttle(self::THROTTLE_ACTION_LOGIN, $username);
// and re-throw the exception
throw new UnknownUsernameException();
}
// if there are multiple users with the specified username
catch (AmbiguousUsernameException $e) {
// throttle this operation
$this->throttle(self::THROTTLE_ACTION_LOGIN);
$this->throttle(self::THROTTLE_ACTION_LOGIN, $username);
// and re-throw the exception
throw new AmbiguousUsernameException();
}
}
// if neither an email address nor a username has been provided
else {
// we can't do anything here because the method call has been invalid
throw new EmailOrUsernameRequiredError();
}
$password = self::validatePassword($password);
if (password_verify($password, $userData['password'])) {
// if the password needs to be re-hashed to keep up with improving password cracking techniques
if (password_needs_rehash($userData['password'], PASSWORD_DEFAULT)) {
// create a new hash from the password and update it in the database
$this->updatePassword($userData['id'], $password);
}
if ($userData['verified'] === 1) {
$this->onLoginSuccessful($userData['id'], $userData['email'], $userData['username'], false);
// continue to support the old parameter format
if ($rememberDuration === true) {
$rememberDuration = 60 * 60 * 24 * 28;
}
elseif ($rememberDuration === false) {
$rememberDuration = null;
}
if ($rememberDuration !== null) {
$this->createRememberDirective($userData['id'], $rememberDuration);
}
return;
}
else {
throw new EmailNotVerifiedException();
}
}
else {
// throttle this operation
$this->throttle(self::THROTTLE_ACTION_LOGIN);
if (isset($email)) {
$this->throttle(self::THROTTLE_ACTION_LOGIN, $email);
}
elseif (isset($username)) {
$this->throttle(self::THROTTLE_ACTION_LOGIN, $username);
}
// we cannot authenticate the user due to the password being wrong
throw new InvalidPasswordException();
}
}
/**
* Returns the requested user data for the account with the specified email address (if any)
*
* You must never pass untrusted input to the parameter that takes the column list
*
* @param string $email the email address to look for
* @param array $requestColumns the columns to request from the user's record
* @param array $requestedColumns the columns to request from the user's record
* @return array the user data (if an account was found)
* @throws InvalidEmailException if the email address could not be found
* @throws AuthError if an internal problem occurred (do *not* catch)
*/
private function getUserDataByEmailAddress($email, array $requestColumns) {
private function getUserDataByEmailAddress($email, array $requestedColumns) {
try {
$projection = implode(', ', $requestColumns);
$projection = implode(', ', $requestedColumns);
$userData = $this->db->selectRow(
'SELECT ' . $projection . ' FROM users WHERE email = ?',
[ $email ]
@@ -843,6 +933,43 @@ class Auth {
}
}
/**
* Returns the requested user data for the account with the specified username (if any)
*
* You must never pass untrusted input to the parameter that takes the column list
*
* @param string $username the username to look for
* @param array $requestedColumns the columns to request from the user's record
* @return array the user data (if an account was found unambiguously)
* @throws UnknownUsernameException if no user with the specified username has been found
* @throws AmbiguousUsernameException if multiple users with the specified username have been found
* @throws AuthError if an internal problem occurred (do *not* catch)
*/
private function getUserDataByUsername($username, array $requestedColumns) {
try {
$projection = implode(', ', $requestedColumns);
$users = $this->db->select(
'SELECT ' . $projection . ' FROM users WHERE username = ? LIMIT 0, 2',
[ $username ]
);
}
catch (Error $e) {
throw new DatabaseError();
}
if (empty($users)) {
throw new UnknownUsernameException();
}
else {
if (count($users) === 1) {
return $users[0];
}
else {
throw new AmbiguousUsernameException();
}
}
}
/**
* Returns the number of open requests for a password reset by the specified user
*

View File

@@ -12,6 +12,8 @@ class AuthException extends \Exception {}
class InvalidEmailException extends AuthException {}
class UnknownUsernameException extends AuthException {}
class InvalidPasswordException extends AuthException {}
class EmailNotVerifiedException extends AuthException {}
@@ -28,6 +30,8 @@ class TooManyRequestsException extends AuthException {}
class DuplicateUsernameException extends AuthException {}
class AmbiguousUsernameException extends AuthException {}
class AuthError extends \Exception {}
class DatabaseError extends AuthError {}
@@ -35,3 +39,5 @@ class DatabaseError extends AuthError {}
class MissingCallbackError extends AuthError {}
class HeadersAlreadySentError extends AuthError {}
class EmailOrUsernameRequiredError extends AuthError {}

View File

@@ -48,13 +48,27 @@ function processRequestData(\Delight\Auth\Auth $auth) {
}
try {
$auth->login($_POST['email'], $_POST['password'], $rememberDuration);
if (isset($_POST['email'])) {
$auth->login($_POST['email'], $_POST['password'], $rememberDuration);
}
elseif (isset($_POST['username'])) {
$auth->loginWithUsername($_POST['username'], $_POST['password'], $rememberDuration);
}
else {
return 'either email address or username required';
}
return 'ok';
}
catch (\Delight\Auth\InvalidEmailException $e) {
return 'wrong email address';
}
catch (\Delight\Auth\UnknownUsernameException $e) {
return 'unknown username';
}
catch (\Delight\Auth\AmbiguousUsernameException $e) {
return 'ambiguous username';
}
catch (\Delight\Auth\InvalidPasswordException $e) {
return 'wrong password';
}
@@ -86,7 +100,16 @@ function processRequestData(\Delight\Auth\Auth $auth) {
$callback = null;
}
return $auth->register($_POST['email'], $_POST['password'], $_POST['username'], $callback);
if (!isset($_POST['require_unique_username'])) {
$_POST['require_unique_username'] = '0';
}
if ($_POST['require_unique_username'] == 0) {
return $auth->register($_POST['email'], $_POST['password'], $_POST['username'], $callback);
}
else {
return $auth->registerWithUniqueUsername($_POST['email'], $_POST['password'], $_POST['username'], $callback);
}
}
catch (\Delight\Auth\InvalidEmailException $e) {
return 'invalid email address';
@@ -95,7 +118,10 @@ function processRequestData(\Delight\Auth\Auth $auth) {
return 'invalid password';
}
catch (\Delight\Auth\UserAlreadyExistsException $e) {
return 'user already exists';
return 'email address already exists';
}
catch (\Delight\Auth\DuplicateUsernameException $e) {
return 'username already exists';
}
catch (\Delight\Auth\TooManyRequestsException $e) {
return 'too many requests';
@@ -263,7 +289,18 @@ function showGuestUserForm() {
echo '<option value="0">Remember (keep logged in)? — No</option>';
echo '<option value="1">Remember (keep logged in)? — Yes</option>';
echo '</select> ';
echo '<button type="submit">Login</button>';
echo '<button type="submit">Log in with email address</button>';
echo '</form>';
echo '<form action="" method="post" accept-charset="utf-8">';
echo '<input type="hidden" name="action" value="login" />';
echo '<input type="text" name="username" placeholder="Username" /> ';
echo '<input type="text" name="password" placeholder="Password" /> ';
echo '<select name="remember" size="1">';
echo '<option value="0">Remember (keep logged in)? — No</option>';
echo '<option value="1">Remember (keep logged in)? — Yes</option>';
echo '</select> ';
echo '<button type="submit">Log in with username</button>';
echo '</form>';
echo '<form action="" method="post" accept-charset="utf-8">';
@@ -275,6 +312,10 @@ function showGuestUserForm() {
echo '<option value="0">Require email confirmation? — No</option>';
echo '<option value="1">Require email confirmation? — Yes</option>';
echo '</select> ';
echo '<select name="require_unique_username" size="1">';
echo '<option value="0">Username — Any</option>';
echo '<option value="1">Username — Unique</option>';
echo '</select> ';
echo '<button type="submit">Register</button>';
echo '</form>';