PHP Lembre-se de mim

Resumo : neste tutorial, você aprenderá a implementar com segurança o recurso lembre-me em PHP.

Introdução ao recurso Lembre-me do PHP

Quando os usuários fazem login em um aplicativo da Web e fecham os navegadores, os cookies de sessão associados aos logins expiram imediatamente. Isso significa que se os usuários acessarem a aplicação web posteriormente, eles precisarão fazer login novamente.

O recurso lembrar de mim permite que os usuários salvem seus logins por algum tempo, mesmo após fecharem os navegadores. Para implementar o recurso Lembre-me, você usará cookies com prazos de validade futuros.

A maneira comum, mas insegura

A maneira insegura de implementar o lembre-me é adicionar um ID de usuário ao cookie com um prazo de validade:

user_id=120Linguagem de código:  PHP  ( php )

Quando os usuários acessam o aplicativo da web, você verifica se o ID do usuário no cookie é válido antes de fazer login automaticamente.

Esta abordagem ingênua depende apenas de cookies, que não são seguros pelos seguintes motivos:

  • Primeiro, os usuários podem alterar o ID para outro para fazer login como outro usuário.
  • Segundo, o ID do usuário pode revelar o número de usuários no sistema.

Uma abordagem mais segura

Uma maneira mais segura de implementar o recurso Lembre-me é armazenar um token aleatório em vez de um ID de usuário nos cookies e no servidor de banco de dados.

O valor nos cookies ficará assim:

remember_me=6179f9c66a9d007e689c7809b5d8320a6692787773488f12a4330cd5ffd25d91Linguagem de código:  PHP  ( php )

E aqui está uma tabela de banco de dados que armazena os 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
);Linguagem de código:  SQL (linguagem de consulta estruturada)  ( sql )

Quando os usuários acessam o aplicativo web, você combina os tokens dos cookies com aqueles armazenados no banco de dados. Além disso, você pode verificar o tempo de expiração do token. Se os tokens corresponderem e não tiverem expirado, você poderá obter o ID do usuário associado ao token e conectar o usuário automaticamente.

A consulta para correspondência do token ficará assim:

SELECT user_id
FROM user_tokens
WHERE token = '6179f9c66a9d007e689c7809b5d8320a6692787773488f12a4330cd5ffd25d91' and
      expiry > NOW()Linguagem de código:  PHP  ( php )

Essa abordagem resolve dois problemas acima:

  • Primeiro, o token é mais difícil de adivinhar.
  • Segundo, o token não revela o número de usuários.

No entanto, esta abordagem expõe outro problema de segurança conhecido como ataque de temporização.

Quando o banco de dados compara o token do cookie com o token armazenado no banco de dados, ele retorna os diferentes tempos de comparação de acordo com o quanto dois tokens são semelhantes.

Por exemplo, se você tiver o seguinte token armazenado no cookie:

6179f9c66a9d007e689c7809b5d8320a6692787773488f12a4330cd5ffd25d91Linguagem de código:  PHP  ( php )

E o seguinte token no banco de dados:

6f9a1ef3020bb8351456cd65176e1e62ceeefcdca0a750201886a230f8736cadLinguagem de código:  PHP  ( php )

Ao comparar esses tokens, o banco de dados compara cada caractere nos tokens e interrompe a correspondência quando encontra uma incompatibilidade. Neste exemplo, o banco de dados para no segundo caractere:

No entanto, ao comparar o seguinte par de tokens:

6179f9c66a9d007e689c7809b5d8320a6692787773488f12a4330cd5ffd25d91
6179f9c66a9d007e689c7809b5d8320a6692787773488f12a4330cd5ffd25d92Linguagem de código:  PHP  ( php )

O banco de dados para de corresponder após comparar o penúltimo caractere.

O tempo de comparação no segundo exemplo será sempre maior que o segundo porque o banco de dados precisa comparar mais caracteres.

Ao testar tokens diferentes, você pode obter tempos de resposta diferentes. Em outras palavras, o tempo vazou. Para evitar vazamentos de tempo, a função de comparação precisa retornar um tempo constante, independentemente dos tokens.

Evite ataques de temporização

O seguinte mostra como prevenir o ataque de temporização proposto por PIE . Nesta abordagem, em vez de armazenar um único token no cookie, você armazena um par de tokens: selectore validatorcom o formato: selector:validator.

O selectorserve para selecionar o validatorarmazenado no banco de dados. No banco de dados, você armazena o hash do selectore do validator:

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
);Linguagem de código:  PHP  ( php )

Para fazer o hash do validador, você usa a função password_hash().

Para obter um ID de usuário, você combina o selectordo cookie com o selectordo banco de dados:

SELECT id, selector, hashed_validator, user_id, expiry
FROM user_tokens
WHERE selector = :selectorLinguagem de código:  PHP  ( php )

Se a consulta retornar uma linha, você poderá combinar validatorfrom the cookie com the hashed_validatorusando a password_verify()função.

Se os validadores corresponderem, você poderá logar o usuário user_idautomaticamente.

A seção a seguir aprimorará o sistema de login adicionando o recurso Lembre-me usando a terceira abordagem.

Crie uma tabela user_tokens para armazenar os tokens

A instrução a seguir cria uma user_tokenstabela que armazena o selector, hash validator, expirye o ID do usuário.

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
);Linguagem de código:  PHP  ( php )

Adicione a caixa de seleção lembrar de mim ao formulário de login

Primeiro, adicione uma caixa de seleção Lembre-me ao formulário de login no public/login.phparquivo:

<?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') ?>Linguagem de código:  PHP  ( php )

Em segundo lugar, adicione o código para lidar com a caixa de seleção lembrar de mim ao src/login.phparquivo:

<?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');
}Linguagem de código:  PHP  ( php )

No src/login.php, adicione a caixa de seleção lembrar de mim à chamada da função filter():

[$inputs, $errors] = filter($_POST, [
    'username' => 'string | required',
    'password' => 'string | required',
    'remember_me' => 'string'
]);Linguagem de código:  PHP  ( php )

Além disso, adicione o terceiro parâmetro à função login():

login($inputs['username'], $inputs['password'], isset($inputs['remember_me'])Linguagem de código:  PHP  ( php )

Voltaremos para aprimorar a login()função mais tarde.

Definir funções para lidar com o recurso Lembre-me

Primeiro, crie o remember.phparquivo na srcpasta.

Segundo, defina as seguintes novas funções para manipular os tokens no remember.phparquivo:

Gerar tokens

O seguinte define como generate_tokens()gerar um par de tokens aleatórios chamados seletor e validador:

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

    return [$selector, $validator, $selector . ':' . $validator];
}Linguagem de código:  PHP  ( php )

A generate_tokens()função retorna uma matriz de três elementos: selector, valdiatore selector:validator.

Analise o token

A parse_token()função a seguir divide o token armazenado no cookie em selectore validator:

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

    if ($parts && count($parts) == 2) {
        return [$parts[0], $parts[1]];
    }
    return null;
}Linguagem de código:  PHP  ( php )

Insira um novo token de usuário

A insert_user_tokens()função a seguir adiciona uma nova linha à user_tokenstabela:

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();
}Linguagem de código:  PHP  ( php )

Encontre o token por um seletor

A find_user_token_by_selector()função a seguir encontra uma linha na user_tokenstabela por um seletor. Ele só retorna o seletor de correspondência se o token não tiver expirado, comparando a expiração com o horário atual.

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);
}Linguagem de código:  PHP  ( php )

Excluir um token de usuário

A delete_user_token()função a seguir exclui todos os tokens associados a um usuário:

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();
}Linguagem de código:  PHP  ( php )

Encontre um usuário por um token

A find_user_by_token()função a seguir retorna user_ide usernamepor um 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);
}Linguagem de código:  PHP  ( php )

Verifique se um token é válido

A toke_is_valid()função a seguir analisa o token armazenado no cookie ( selector:validator) e retorna truese o token é válido e não expirou:

function token_is_valid(string $token): bool { // analisa o token para obter o seletor e o validador [$selector, $validator] = parse_token($token);

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

return password_verify($validator, $tokens['hashed_validator']);Linguagem de código:  PHP  ( php )

Modificando a função no auth.php

A seguir descrevemos as alterações nas funções no auth.phparquivo:

A função login()

O seguinte adiciona o terceiro parâmetro $rememberà login()função:

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;
}Linguagem de código:  PHP  ( php )

Se $rememberfor true, chame a remember_me()função.

A função log_user_in()

A função log_user_in() registra um usuário:

/**
 * log a user in
 * @param array $user
 * @return bool
 */
function log_user_in(array $user): bool
{
    // prevent session fixation attack
    if (session_regenerate_id()) {
        // set username & id in the session
        $_SESSION['username'] = $user['username'];
        $_SESSION['user_id'] = $user['id'];
        return true;
    }

    return false;
}
Linguagem de código:  PHP  ( php )

A função lembre-me()

O seguinte define a remember_me()função:

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);
    }
}Linguagem de código:  PHP  ( php )

A remember_me()função salva o login de um usuário por um número especificado de dias. Por padrão, ele lembra o login por 30 dias.

A remember_me()função faz o seguinte:

  • Primeiro, gere selector, validatore token ( selector:validator)
  • Segundo, insira uma nova linha na user_tokenstabela.
  • Terceiro, defina um cookie com o prazo de validade especificado.

A função logout()

Caso o usuário efetue logout, além de deletar a sessão, é necessário deletar os registros da user_tokenstabela e remover o remember_mecookie:

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');
    }
}Linguagem de código:  PHP  ( php )

A função is_user_logged_in()

A is_user_logged_in()função a seguir verifica se o usuário está logado no momento:

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;
}Linguagem de código:  PHP  ( php )

Como funciona

  • Primeiro, a função retorna truese a sessão tiver a chave username.
  • Em seguida, verifique o token nos cookies e faça login do usuário se o token for válido.

Resumo

  • O recurso lembrar de mim salva o login por algum tempo, mesmo depois que os navegadores da web são fechados.
  • Use cookies para implementar o recurso Lembre-me.

Deixe um comentário

O seu endereço de email não será publicado. Campos obrigatórios marcados com *