1
votes

I want to add reset/forgot password functionality to my personal express.js app. I decided to implement it in similar way how Django does it.

Basically, it generates unique token based on user (id, hashed password, email, last login time and current time, all mixed with unqiue password and salt). Then, user receives that token in his "reset password link". Someone explained it better than me in one of stackoverflow answers.

And here is the source code of Django PasswordResetTokenGenerator class

I will post my javascript implementation on the bottom. It would be nice if you check it for possible flaws, but it is not my main question :)

So, user gets e-mail with "reset password link". Link looks like this https://example.com/reset-password/MQ/58ix7l-35858854f74c35d0c64a5a17bd127f71cd3ad1da, where:

  • MQ is base64 encoded user id (1 in this example)
  • 58ix7l is base36 encoded timestamp
  • 35858... is actual token

User clicks on link. Server receives GET request -> server checks if user with that id exist -> then server checks correctness of the token. If everything is OK, server sends user html response with "set new password" form.

So far, everything was almost exactly the same how django does it (few minor differences). But now I want to do somewhat different. Django (after receiving GET request) establishes anonymous session, stores token in session, and redirect (302) to reset password form. There is no any sign of token on the client side. User fills a form, POST request is send to server with new password. Server checks token (stored in session) again. If everything is right - password is changed.

For some reason (it will make my app a lot more complicated :)), I don't want to add anonymous session, I don't want to store token in session.

I want to just take token from req.params -> escape it -> check if it is valid -> and send to user with form, like this:

<form action="/reset-password" method="POST">
    <label for="new-password">New password</label><input id="new-password" type="password" name="new-password" />
    <label for="repeat-new-password">Repeat new password</label><input id="repeat-new-password" type="password" name="repeat-new-password" />
    <input name="token" type="hidden" value="58ix7l-35858854f74c35d0c64a5a17bd127f71cd3ad1da">
    <input type="submit" value="Set new password" />
</form>

User sends form, server checks token again, and then change password.

So after wall of text, my question is:

Is it safe to store token in html form like this?

I can think of one possible threat: Evil user can send someone link with <script>alert('boo!')</script> instead of token. But it should not be problem if token is validated and escaped before. Any other possible holes?

As I said before, im posting my generateToken, checkToken javascript implementation, just in case...


generate-change-password-token.js

const { differenceInSeconds } = require('date-fns');
const makeTokenWithTimestamp = require('../crypto/make-token-with-timestamp');

function generateChangePasswordToken(user) {
    const timestamp = differenceInSeconds(new Date(), new Date(2010, 1, 1));
    const token = makeTokenWithTimestamp(user, timestamp);
    return token;
}

module.exports = generateChangePasswordToken;

verify-change-password-token.js

const crypto = require('crypto');
const { differenceInSeconds } = require('date-fns');
const makeTokenWithTimestamp = require('../crypto/make-token-with-timestamp');

function verifyChangePasswordToken(user, token) {
    const timestamp = parseInt(token.split('-')[0], 36);

    const difference = differenceInSeconds(new Date(), new Date(2010, 1, 1)) - timestamp;

    if (difference > 60 * 60 * 24) {
        return false;
    }
    const newToken = makeTokenWithTimestamp(user, timestamp);
    const valid = crypto.timingSafeEqual(Buffer.from(token), Buffer.from(newToken));
    if (valid === true) {
        return true;
    }
    return false;
}

module.exports = verifyChangePasswordToken;

make-token-with-timestamp.js

const crypto = require('crypto');

function saltedHmac(keySalt, value, secret) {
    const hash = crypto.createHash('sha1').update(keySalt + secret).digest('hex');
    const hmac = crypto.createHmac('sha1', hash).update(value).digest('hex');
    return hmac;
}

function makeHashValue(user, timestamp) {
    const { last_login: lastLogin, id, password } = user;
    const loginTimestamp = lastLogin ? lastLogin.getTime() : '';
    return String(id) + password + String(loginTimestamp) + String(timestamp);
}

function makeTokenWithTimestamp(user, timestamp) {
    const timestamp36 = timestamp.toString(36);
    const hashValue = makeHashValue(user, timestamp);
    const keySalt = process.env.KEY_SALT;
    const secret = process.env.SECRET_KEY;
    if (!(keySalt && secret)) {
        throw new Error('You need to set KEY_SALT and SECRET_KEY in env variables');
    }
    const hashString = saltedHmac(keySalt, hashValue, secret);
    return `${timestamp36}-${hashString}`;
}

module.exports = makeTokenWithTimestamp;

Thx

1
If you're going to store the token on the client, I would be more apt to store it as a HttpOnly cookie, that javascript has no access to.Taplar
The user can already get the token from the link in the email, so it's no more dangerous. I suggest you put all those fields in hidden inputs, and do the same validation when the form is submitted.Barmar

1 Answers

1
votes

From a security standpoint there isn't a huge difference between storing the reset token in the URL (a get variable) or in the form (as a post variable). In both cases anyone who has access to the URL is going to have access to reset the password.

As you mention, you'll want to watch out for XSS attacks (embedding javascript in the token that then gets displayed in page), and validating that the token is just alpha numeric should resolve that particular issue. You'll also want to watch out for CORS style attacks as well, which most frameworks can handle for you.

For me the other two things to consider are-

  1. That the token expires in a reasonable amount of time, as it basically is a password and can be used to take over an account.

  2. That notifications are sent after any password request, so that if the user did not purposefully reset their own password they can be made aware of the incident.