PHP Remember Me

Created with Sketch.

PHP Remember Me

Summary: in this tutorial, you’ll learn to securely implement the remember me feature in PHP.

Introduction to the PHP remember me feature

When users log in to a web application and then close web browsers, the session cookies associated with the logins expire immediately. It means that if the users access the web application later, they need to log in again.

The remember me feature allows the users to save their logins for some time, even after closing the web browsers. To implement the remember me feature, you’ll use cookies with expiration times in the future.

The common but insecure way

The insecure way to implement the remember me is to add a user id to the cookie with an expiration time:

user_id=120

Code language: PHP (php)

When users access the web application, you check if the user id in the cookie is valid before logging them in automatically.

This naive approach relies solely on cookies, which is not secure for the following reasons:

  • First, users can change the id to another to log in as another user.
  • Second, the user id may reveal the number of users in the system.

A more secure approach

A more secure way to implement the remember me feature is to store a random token instead of a user id in both cookies and database server.

The value in the cookies will look like this:

remember_me=6179f9c66a9d007e689c7809b5d8320a6692787773488f12a4330cd5ffd25d91

Code language: PHP (php)

And here’s a database table that stores the tokens:

CREATE TABLE user_tokens
(
id INT AUTO_INCREMENT PRIMARY KEY,
token VARCHAR(255) NOT NULL,
expiry DATETIME NOT NULL,
user_id INT NOT NULL,
CONSTRAINT fk_user_id
FOREIGN KEY (user_id)
REFERENCES users (id) ON DELETE CASCADE
);

Code language: SQL (Structured Query Language) (sql)

When users access the web application, you match the cookies’ tokens with those stored in the database. Also, you can check the token’s expiration time. If the tokens match and have not expired, you can get the user id associated with the token and sign the user in automatically.

The query for matching the token will look like this:

SELECT user_id
FROM user_tokens
WHERE token = '6179f9c66a9d007e689c7809b5d8320a6692787773488f12a4330cd5ffd25d91' and
expiry > NOW()

Code language: PHP (php)

This approach solves two issues above:

  • First, the token is more challenging to guess.
  • Second, the token doesn’t reveal the number of users.

However, this approach exposes another security issue which is known as a timing attack.

When the database compares the cookie’s token with the token stored in the database, it returns the different comparison times according to how much two tokens are similar.

For example, if you have the following token stored in the cookie:

6179f9c66a9d007e689c7809b5d8320a6692787773488f12a4330cd5ffd25d91

Code language: PHP (php)

And the following token in the database:

6f9a1ef3020bb8351456cd65176e1e62ceeefcdca0a750201886a230f8736cad

Code language: PHP (php)

When comparing these tokens, the database compares each character in the tokens and stops matching when it finds a mismatch. In this example, the database stops at the second character:

However, when comparing the following pair of tokens:

6179f9c66a9d007e689c7809b5d8320a6692787773488f12a4330cd5ffd25d91
6179f9c66a9d007e689c7809b5d8320a6692787773488f12a4330cd5ffd25d92

Code language: PHP (php)

The database stops matching after it comparing the second last character.

The comparing time in the second example will always be greater than the second one because the database needs to compare more characters.

By testing different tokens, you can get different response times. In other words, the timing is leaked. To avoid timing leaks, the comparison function needs to return a constant time regardless of the tokens.

Prevent timing attacks

The following shows how to prevent the timing attack as proposed by P.I.E. In this approach, instead of storing a single token in the cookie, you store a pair of tokens: selector and validator with the format: selector:validator.

The selector is for selecting the validator stored in the database. In the database, you store the selector and the validator‘s hash:

CREATE TABLE user_tokens
(
id INT AUTO_INCREMENT PRIMARY KEY,
selector VARCHAR(255) NOT NULL,
hashed_validator VARCHAR(255) NOT NULL,
user_id INT NOT NULL,
expiry DATETIME NOT NULL,
CONSTRAINT fk_user_id
FOREIGN KEY (user_id)
REFERENCES users (id) ON DELETE CASCADE
);

Code language: PHP (php)

To hash the validator, you use the password_hash() function.

To get a user id, you match the selector from the cookie with the selector from the database:

SELECT id, selector, hashed_validator, user_id, expiry
FROM user_tokens
WHERE selector = :selector

Code language: PHP (php)

If the query returns a row, you can match the validator from the cookie with the hashed_validator using the password_verify() function.

If the validators match, you can log the user with the user_id in automatically.

The following section will enhance the login system by adding the remember me feature using the third approach.

Create a user_tokens table to store the tokens

The following statement creates a user_tokens table that stores the selector, hashed validator, expiry, and user id.

CREATE TABLE user_tokens
(
id INT AUTO_INCREMENT PRIMARY KEY,
selector VARCHAR(255) NOT NULL,
hashed_validator VARCHAR(255) NOT NULL,
user_id INT NOT NULL,
expiry DATETIME NOT NULL,
CONSTRAINT fk_user_id
FOREIGN KEY (user_id)
REFERENCES users (id) ON DELETE CASCADE
);

Code language: PHP (php)

Add the remember me checkbox to the login form

First, add a remember me checkbox to the login form in the public/login.php file:

<?php

require __DIR__ . '/../src/bootstrap.php';
require __DIR__ . '/../src/login.php';
?>

<?php view('header', ['title' => 'Login']) ?>

<?php if (isset($errors['login'])) : ?>
<div class="alert alert-error">
<?= $errors['login'] ?>
</div>
<?php endif ?>

<form action="login.php" method="post">
<h1>Login</h1>
<div>
<label for="username">Username:</label>
<input type="text" name="username" id="username" value="<?= $inputs['username'] ?? '' ?>">
<small><?= $errors['username'] ?? '' ?></small>
</div>

<div>
<label for="password">Password:</label>
<input type="password" name="password" id="password">
<small><?= $errors['password'] ?? '' ?></small>
</div>

<div>
<label for="remember_me">
<input type="checkbox" name="remember_me" id="remember_me"
value="checked" <?= $inputs['remember_me'] ?? '' ?> />
Remember Me
</label>
<small><?= $errors['agree'] ?? '' ?></small>
</div>

<section>
<button type="submit">Login</button>
<a href="register.php">Register</a>
</section>

</form>

<?php view('footer') ?>

Code language: PHP (php)

Second, add the code to handle the remember me checkbox to the src/login.php file:

<?php

if (is_user_logged_in()) {
redirect_to('index.php');
}

$inputs = [];
$errors = [];

if (is_post_request()) {

[$inputs, $errors] = filter($_POST, [
'username' => 'string | required',
'password' => 'string | required',
'remember_me' => 'string'
]);

if ($errors) {
redirect_with('login.php', ['errors' => $errors, 'inputs' => $inputs]);
}

// if login fails
if (!login($inputs['username'], $inputs['password'], isset($inputs['remember_me']))) {

$errors['login'] = 'Invalid username or password';

redirect_with('login.php', [
'errors' => $errors,
'inputs' => $inputs
]);
}

// login successfully
redirect_to('index.php');

} else if (is_get_request()) {
[$errors, $inputs] = session_flash('errors', 'inputs');
}

Code language: PHP (php)

In the src/login.php, add the remember me checkbox to the filter() function call:

[$inputs, $errors] = filter($_POST, [
'username' => 'string | required',
'password' => 'string | required',
'remember_me' => 'string'
]);

Code language: PHP (php)

Also, add the third parameter to the login() function:

login($inputs['username'], $inputs['password'], isset($inputs['remember_me'])

Code language: PHP (php)

We’ll go back to enhance the login() function later.

Define functions for handling remember me feature

First, create the remember.php file in the src folder.

Second, define the following new functions for handling the tokens in the remember.php file:

Generate tokens

The following defines the generate_tokens() to generate pair of random tokens called selector and validator:

function generate_tokens(): array
{
$selector = bin2hex(random_bytes(16));
$validator = bin2hex(random_bytes(32));

return [$selector, $validator, $selector . ':' . $validator];
}

Code language: PHP (php)

The generate_tokens() function returns an array of three elements: selector, valdiator, and selector:validator.

Parse the token

The following parse_token() function splits the token stored in the cookie into selector and validator:

function parse_token(string $token): ?array
{
$parts = explode(':', $token);

if ($parts && count($parts) == 2) {
return [$parts[0], $parts[1]];
}
return null;
}

Code language: PHP (php)

Insert a new user token

The following insert_user_tokens() function adds a new row to the user_tokens table:

function insert_user_token(int $user_id, string $selector, string $hashed_validator, string $expiry): bool
{
$sql = 'INSERT INTO user_tokens(user_id, selector, hashed_validator, expiry)
VALUES(:user_id, :selector, :hashed_validator, :expiry)'
;

$statement = db()->prepare($sql);
$statement->bindValue(':user_id', $user_id);
$statement->bindValue(':selector', $selector);
$statement->bindValue(':hashed_validator', $hashed_validator);
$statement->bindValue(':expiry', $expiry);

return $statement->execute();
}

Code language: PHP (php)

Find token by a selector

The following find_user_token_by_selector() function finds a row in the user_tokens table by a selector. It only returns the match selector if the token is not expired by comparing the expiry with the current time.

function find_user_token_by_selector(string $selector)
{

$sql = 'SELECT id, selector, hashed_validator, user_id, expiry
FROM user_tokens
WHERE selector = :selector AND
expiry >= now()
LIMIT 1'
;

$statement = db()->prepare($sql);
$statement->bindValue(':selector', $selector);

$statement->execute();

return $statement->fetch(PDO::FETCH_ASSOC);
}

Code language: PHP (php)

Delete a user token

The following delete_user_token() function deletes all tokens associated with a user:

function delete_user_token(int $user_id): bool
{
$sql = 'DELETE FROM user_tokens WHERE user_id = :user_id';
$statement = db()->prepare($sql);
$statement->bindValue(':user_id', $user_id);

return $statement->execute();
}

Code language: PHP (php)

Find a user by a token

The following find_user_by_token() function returns user_id and username by a token.

function find_user_by_token(string $token)
{
$tokens = parse_token($token);

if (!$tokens) {
return null;
}

$sql = 'SELECT users.id, username
FROM users
INNER JOIN user_tokens ON user_id = users.id
WHERE selector = :selector AND
expiry > now()
LIMIT 1'
;

$statement = db()->prepare($sql);
$statement->bindValue(':selector', $tokens[0]);
$statement->execute();

return $statement->fetch(PDO::FETCH_ASSOC);
}

Code language: PHP (php)

Check if a token is valid

The following toke_is_valid() function parse the token stored in the cookie (selector:validator) and return true if the token is valid and not expired:

function token_is_valid(string $token): bool { // parse the token to get the selector and validator [$selector, $validator] = parse_token($token);

$tokens = find_user_token_by_selector($selector);
if (!$tokens) {
return false;
}

return password_verify($validator, $tokens['hashed_validator']);

Code language: PHP (php)

Modifying the function in the auth.php

The following describes the changes to the functions in the auth.php file:

The login() function

The following adds the third parameter $remember to the login() function:

function login(string $username, string $password, bool $remember = false): bool
{

$user = find_user_by_username($username);

// if user found, check the password
if ($user && is_user_active($user) && password_verify($password, $user['password'])) {

log_user_in($user);

if ($remember) {
remember_me($user['id']);
}

return true;
}

return false;
}

Code language: PHP (php)

If the $remember is true, call the remember_me() function.

The remember_me() function

The following defines the remember_me() function:

function remember_me(int $user_id, int $day = 30)
{
[$selector, $validator, $token] = generate_tokens();

// remove all existing token associated with the user id
delete_user_token($user_id);

// set expiration date
$expired_seconds = time() + 60 * 60 * 24 * $day;

// insert a token to the database
$hash_validator = password_hash($validator, PASSWORD_DEFAULT);
$expiry = date('Y-m-d H:i:s', $expired_seconds);

if (insert_user_token($user_id, $selector, $hash_validator, $expiry)) {
setcookie('remember_me', $token, $expired_seconds);
}
}

Code language: PHP (php)

The remember_me() function saves the login for a user for a specified number of days. By default, it remembers the login for 30 days.

The remember_me() function does the following:

  • First, generate selector, validator, and token (selector:validator)
  • Second, insert a new row into the user_tokens table.
  • Third, set a cookie with the specified expiration time.

The logout() function

If users log out, besides deleting the session, you need to delete the records in the user_tokens table and remove the remember_me cookie:

function logout(): void
{
if (is_user_logged_in()) {

// delete the user token
delete_user_token($_SESSION['user_id']);

// delete session
unset($_SESSION['username'], $_SESSION['user_id`']);

// remove the remember_me cookie
if (isset($_COOKIE['remember_me'])) {
unset($_COOKIE['remember_me']);
setcookie('remember_user', null, -1);
}

// remove all session data
session_destroy();

// redirect to the login page
redirect_to('login.php');
}
}

Code language: PHP (php)

The is_user_logged_in() function

The following is_user_logged_in() function verifies if the user is currently logged in:

function is_user_logged_in(): bool
{
// check the session
if (isset($_SESSION['username'])) {
return true;
}

// check the remember_me in cookie
$token = filter_input(INPUT_COOKIE, 'remember_me', FILTER_SANITIZE_STRING);

if ($token && token_is_valid($token)) {

$user = find_user_by_token($token);

if ($user) {
return log_user_in($user);
}
}
return false;
}

Code language: PHP (php)

How it works

  • First, the function returns true if the session has the key username.
  • Then, verify the token in the cookies and log the user in if the token is valid.

Summary

  • The remember me feature saves the login for some time even after the web browsers are closed.
  • Use cookies to implement the remember me feature.

Leave a Reply

Your email address will not be published. Required fields are marked *