3
\$\begingroup\$

Here's an excerpt from my migration script for my database for the new "remember me" login option for my web application.

  • Logging in with a valid username/password combination (authenticate_user) will generate a new token and a hash of it is stored in the tokens table.
  • When authenticating with a token (authenticate_token), the old token is removed from the database, the user is given a new one, and a session is started for them.
  • If the user changes their password, all tokens for that user become invalid.

The process appears to work, but is it secure enough?

/*----------------------------------------------------------------------------------------------------*\
                                                                      | New Tables
\*----------------------------------------------------------------------------------------------------*/

CREATE TABLE user_login_tokens (
    user_id INT NOT NULL,
    token TEXT NOT NULL,
    expiry TIMESTAMPTZ NOT NULL DEFAULT now() + interval '1 week',

    PRIMARY KEY (user_id, token),
    UNIQUE (token),
    FOREIGN KEY (user_id) REFERENCES users (id)
);
COMMENT ON TABLE user_login_tokens IS 'A collection authentication tokens for a given user that allows them to login without supplying a username/password';

/*----------------------------------------------------------------------------------------------------*\
                                                                      | New / Updated Functions
\*----------------------------------------------------------------------------------------------------*/

CREATE OR REPLACE FUNCTION authenticate_user(_email TEXT, _password PASSWORD, _remember BOOL) RETURNS TABLE
    (id INT, name TEXT, token TEXT, authenticated BOOL) AS $$
DECLARE
    u record;
BEGIN
    SELECT
        users.id,
        users.name,
        CASE WHEN _remember
            THEN crypt(users.id || users.password, gen_salt('bf'))
            ELSE null END AS token,
        password = crypt(_password, password) AS authenticated
    FROM users
    WHERE email = _email INTO u;

    -- only modify user data if the authentication was successful
    IF u.authenticated THEN
        UPDATE users
        SET
            reset_token_expiry = NULL
        WHERE
            users.id = u.id;

        IF _remember THEN
            INSERT INTO user_login_tokens
                (user_id, token)
            VALUES
                (u.id, crypt(u.token, gen_salt('bf')));
        END IF;
    END IF;

    RETURN QUERY SELECT u.id, u.name, u.token, u.authenticated WHERE u.id IS NOT NULL;
END;
$$ LANGUAGE plpgsql;

---------------------------------------------------------------------

CREATE OR REPLACE FUNCTION authenticate_token(_token TEXT) RETURNS TABLE
    (id INT, name TEXT, email TEXT, token TEXT) AS $$
DECLARE
    u RECORD;
BEGIN
    -- verify the validity of our token
    SELECT
        users.id,
        users.name,
        users.email,
        crypt(users.id || users.password, gen_salt('bf')) AS token,
        user_login_tokens.token AS old_token
    FROM
        users
        JOIN user_login_tokens ON user_id = users.id
    WHERE
        expiry > NOW()
        AND user_login_tokens.token = crypt(_token, user_login_tokens.token)
        AND _token = crypt(users.id || password, _token)
    INTO u;

    -- if our token is valid, we need to store a new token because the old one has been used up
    IF FOUND THEN
        INSERT INTO user_login_tokens
            (user_id, token)
        VALUES
            (u.id, crypt(u.token, gen_salt('bf')));
    END IF;

    -- then delete any expired tokens + the used up token
    DELETE FROM user_login_tokens WHERE expiry < NOW() OR user_login_tokens.token = u.old_token;

    RETURN QUERY SELECT u.id, u.name, u.email :: TEXT, u.token WHERE u.id IS NOT NULL;
END;
$$ LANGUAGE plpgsql;
\$\endgroup\$

0