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;