Don't send plaintext reset passwords via email (#8457)

* start work to avoid sending reset password in plaintext via email

* start checking parameters

* fix new password reset email

* render error if password reset code is missing or invalid

* implement POST route, conversion to bcrypt and messages

* add auth.local.passwordResetCode field

* add failing tests, move reset code validation func to lib, fixes, remove old tests

* fix unit tests

* fix page rendering and add integration tests

* fix password reset page

* add integration test

* fix string

* fix tests url
This commit is contained in:
Matteo Pagliazzi
2017-02-14 18:08:31 +01:00
committed by GitHub
parent c6c6632405
commit d30e7b9251
12 changed files with 690 additions and 44 deletions

View File

@@ -0,0 +1,140 @@
import {
encrypt,
} from '../../../../../../website/server/libs/encryption';
import moment from 'moment';
import {
generateUser,
} from '../../../../../helpers/api-integration/v3';
import superagent from 'superagent';
import nconf from 'nconf';
const API_TEST_SERVER_PORT = nconf.get('PORT');
describe('GET /user/auth/local/reset-password-set-new-one', () => {
let endpoint = `http://localhost:${API_TEST_SERVER_PORT}/static/user/auth/local/reset-password-set-new-one`;
// Tests to validate the validatePasswordResetCodeAndFindUser function
it('renders an error page if the code is missing', async () => {
try {
await superagent.get(endpoint);
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
it('renders an error page if the code is invalid json', async () => {
try {
await superagent.get(`${endpoint}?code=invalid`);
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
it('renders an error page if the code cannot be decrypted', async () => {
let user = await generateUser();
try {
let code = JSON.stringify({ // not encrypted
userId: user._id,
expiresAt: new Date(),
});
await superagent.get(`${endpoint}?code=${code}`);
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
it('renders an error page if the code is expired', async () => {
let user = await generateUser();
let code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().subtract({minutes: 1}),
}));
await user.update({
'auth.local.passwordResetCode': code,
});
try {
await superagent.get(`${endpoint}?code=${code}`);
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
it('renders an error page if the user does not exist', async () => {
let code = encrypt(JSON.stringify({
userId: Date.now().toString(),
expiresAt: moment().add({days: 1}),
}));
try {
await superagent.get(`${endpoint}?code=${code}`);
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
it('renders an error page if the user has no local auth', async () => {
let user = await generateUser();
let code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().add({days: 1}),
}));
await user.update({
auth: 'not an object with valid fields',
});
try {
await superagent.get(`${endpoint}?code=${code}`);
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
it('renders an error page if the code doesn\'t match the one saved at user.auth.passwordResetCode', async () => {
let user = await generateUser();
let code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().add({days: 1}),
}));
await user.update({
'auth.local.passwordResetCode': 'invalid',
});
try {
await superagent.get(`${endpoint}?code=${code}`);
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
//
it('returns the password reset page if the password reset code is valid', async () => {
let user = await generateUser();
let code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().add({days: 1}),
}));
await user.update({
'auth.local.passwordResetCode': code,
});
let res = await superagent.get(`${endpoint}?code=${code}`);
expect(res.status).to.equal(200);
});
});

View File

@@ -0,0 +1,267 @@
import {
encrypt,
} from '../../../../../../website/server/libs/encryption';
import {
compare,
bcryptCompare,
sha1MakeSalt,
sha1Encrypt as sha1EncryptPassword,
} from '../../../../../../website/server/libs/password';
import moment from 'moment';
import {
generateUser,
} from '../../../../../helpers/api-integration/v3';
import superagent from 'superagent';
import nconf from 'nconf';
const API_TEST_SERVER_PORT = nconf.get('PORT');
describe('POST /user/auth/local/reset-password-set-new-one', () => {
let endpoint = `http://localhost:${API_TEST_SERVER_PORT}/static/user/auth/local/reset-password-set-new-one`;
// Tests to validate the validatePasswordResetCodeAndFindUser function
it('renders an error page if the code is missing', async () => {
try {
await superagent.post(endpoint);
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
it('renders an error page if the code is invalid json', async () => {
try {
await superagent.post(`${endpoint}?code=invalid`);
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
it('renders an error page if the code cannot be decrypted', async () => {
let user = await generateUser();
try {
let code = JSON.stringify({ // not encrypted
userId: user._id,
expiresAt: new Date(),
});
await superagent.post(`${endpoint}?code=${code}`);
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
it('renders an error page if the code is expired', async () => {
let user = await generateUser();
let code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().subtract({minutes: 1}),
}));
await user.update({
'auth.local.passwordResetCode': code,
});
try {
await superagent.post(`${endpoint}?code=${code}`);
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
it('renders an error page if the user does not exist', async () => {
let code = encrypt(JSON.stringify({
userId: Date.now().toString(),
expiresAt: moment().add({days: 1}),
}));
try {
await superagent.post(`${endpoint}?code=${code}`);
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
it('renders an error page if the user has no local auth', async () => {
let user = await generateUser();
let code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().add({days: 1}),
}));
await user.update({
auth: 'not an object with valid fields',
});
try {
await superagent.post(`${endpoint}?code=${code}`);
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
it('renders an error page if the code doesn\'t match the one saved at user.auth.passwordResetCode', async () => {
let user = await generateUser();
let code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().add({days: 1}),
}));
await user.update({
'auth.local.passwordResetCode': 'invalid',
});
try {
await superagent.post(`${endpoint}?code=${code}`);
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
//
it('renders the error page if the new password is missing', async () => {
let user = await generateUser();
let code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().add({days: 1}),
}));
await user.update({
'auth.local.passwordResetCode': code,
});
try {
await superagent.post(`${endpoint}?code=${code}`);
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
it('renders the error page if the password confirmation is missing', async () => {
let user = await generateUser();
let code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().add({days: 1}),
}));
await user.update({
'auth.local.passwordResetCode': code,
});
try {
await superagent
.post(`${endpoint}?code=${code}`)
.send({newPassword: 'my new password'});
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
it('renders the error page if the password confirmation does not match', async () => {
let user = await generateUser();
let code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().add({days: 1}),
}));
await user.update({
'auth.local.passwordResetCode': code,
});
try {
await superagent
.post(`${endpoint}?code=${code}`)
.send({
newPassword: 'my new password',
confirmPassword: 'not matching',
});
throw new Error('Request should fail.');
} catch (err) {
expect(err.status).to.equal(401);
}
});
it('renders the success page and save the user', async () => {
let user = await generateUser();
let code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().add({days: 1}),
}));
await user.update({
'auth.local.passwordResetCode': code,
});
let res = await superagent
.post(`${endpoint}?code=${code}`)
.send({
newPassword: 'my new password',
confirmPassword: 'my new password',
});
expect(res.status).to.equal(200);
await user.sync();
expect(user.auth.local.passwordResetCode).to.equal(undefined);
expect(user.auth.local.passwordHashMethod).to.equal('bcrypt');
expect(user.auth.local.salt).to.be.undefined;
let isPassValid = await compare(user, 'my new password');
expect(isPassValid).to.equal(true);
});
it('renders the success page and convert the password from sha1 to bcrypt', async () => {
let user = await generateUser();
let textPassword = 'mySecretPassword';
let salt = sha1MakeSalt();
let sha1HashedPassword = sha1EncryptPassword(textPassword, salt);
await user.update({
'auth.local.hashed_password': sha1HashedPassword,
'auth.local.passwordHashMethod': 'sha1',
'auth.local.salt': salt,
});
await user.sync();
expect(user.auth.local.passwordHashMethod).to.equal('sha1');
expect(user.auth.local.salt).to.equal(salt);
expect(user.auth.local.hashed_password).to.equal(sha1HashedPassword);
let code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().add({days: 1}),
}));
await user.update({
'auth.local.passwordResetCode': code,
});
let res = await superagent
.post(`${endpoint}?code=${code}`)
.send({
newPassword: 'my new password',
confirmPassword: 'my new password',
});
expect(res.status).to.equal(200);
await user.sync();
expect(user.auth.local.passwordResetCode).to.equal(undefined);
expect(user.auth.local.passwordHashMethod).to.equal('bcrypt');
expect(user.auth.local.salt).to.be.undefined;
expect(user.auth.local.hashed_password).not.to.equal(sha1HashedPassword);
let isValidPassword = await bcryptCompare('my new password', user.auth.local.hashed_password);
expect(isValidPassword).to.equal(true);
});
});

View File

@@ -2,10 +2,10 @@ import {
generateUser, generateUser,
translate as t, translate as t,
} from '../../../../../helpers/api-integration/v3'; } from '../../../../../helpers/api-integration/v3';
import moment from 'moment';
import { import {
sha1MakeSalt, decrypt,
sha1Encrypt as sha1EncryptPassword, } from '../../../../../../website/server/libs/encryption';
} from '../../../../../../website/server/libs/password';
describe('POST /user/reset-password', async () => { describe('POST /user/reset-password', async () => {
let endpoint = '/user/reset-password'; let endpoint = '/user/reset-password';
@@ -25,33 +25,6 @@ describe('POST /user/reset-password', async () => {
expect(user.auth.local.hashed_password).to.not.eql(previousPassword); expect(user.auth.local.hashed_password).to.not.eql(previousPassword);
}); });
it('converts user with SHA1 encrypted password to bcrypt encryption', async () => {
let textPassword = 'mySecretPassword';
let salt = sha1MakeSalt();
let sha1HashedPassword = sha1EncryptPassword(textPassword, salt);
await user.update({
'auth.local.hashed_password': sha1HashedPassword,
'auth.local.passwordHashMethod': 'sha1',
'auth.local.salt': salt,
});
await user.sync();
expect(user.auth.local.passwordHashMethod).to.equal('sha1');
expect(user.auth.local.salt).to.equal(salt);
expect(user.auth.local.hashed_password).to.equal(sha1HashedPassword);
// update email
await user.post(endpoint, {
email: user.auth.local.email,
});
await user.sync();
expect(user.auth.local.passwordHashMethod).to.equal('bcrypt');
expect(user.auth.local.salt).to.be.undefined;
expect(user.auth.local.hashed_password).not.to.equal(sha1HashedPassword);
});
it('same message on error as on success', async () => { it('same message on error as on success', async () => {
let response = await user.post(endpoint, { let response = await user.post(endpoint, {
email: 'nonExistent@email.com', email: 'nonExistent@email.com',
@@ -66,4 +39,19 @@ describe('POST /user/reset-password', async () => {
message: t('invalidReqParams'), message: t('invalidReqParams'),
}); });
}); });
it('sets a new password reset code on user.auth.local that expires in 1 day', async () => {
expect(user.auth.local.passwordResetCode).to.be.undefined;
await user.post(endpoint, {
email: user.auth.local.email,
});
await user.sync();
expect(user.auth.local.passwordResetCode).to.be.a.string;
let decryptedCode = JSON.parse(decrypt(user.auth.local.passwordResetCode));
expect(decryptedCode.userId).to.equal(user._id);
expect(moment(decryptedCode.expiresAt).isAfter(moment().add({hours: 23}))).to.equal(true);
});
}); });

View File

@@ -1,5 +1,12 @@
/* eslint-disable camelcase */ /* eslint-disable camelcase */
import {
encrypt,
} from '../../../../../website/server/libs/encryption';
import moment from 'moment';
import {
generateUser,
} from '../../../../helpers/api-integration/v3';
import { import {
sha1Encrypt as sha1EncryptPassword, sha1Encrypt as sha1EncryptPassword,
sha1MakeSalt, sha1MakeSalt,
@@ -7,6 +14,7 @@ import {
bcryptCompare, bcryptCompare,
compare, compare,
convertToBcrypt, convertToBcrypt,
validatePasswordResetCodeAndFindUser,
} from '../../../../../website/server/libs/password'; } from '../../../../../website/server/libs/password';
describe('Password Utilities', () => { describe('Password Utilities', () => {
@@ -172,6 +180,95 @@ describe('Password Utilities', () => {
}); });
}); });
describe('validatePasswordResetCodeAndFindUser', () => {
it('returns false if the code is missing', async () => {
let res = await validatePasswordResetCodeAndFindUser();
expect(res).to.equal(false);
});
it('returns false if the code is invalid json', async () => {
let res = await validatePasswordResetCodeAndFindUser('invalid json');
expect(res).to.equal(false);
});
it('returns false if the code cannot be decrypted', async () => {
let user = await generateUser();
let res = await validatePasswordResetCodeAndFindUser(JSON.stringify({ // not encrypted
userId: user._id,
expiresAt: new Date(),
}));
expect(res).to.equal(false);
});
it('returns false if the code is expired', async () => {
let user = await generateUser();
let code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().subtract({minutes: 1}),
}));
await user.update({
'auth.local.passwordResetCode': code,
});
let res = await validatePasswordResetCodeAndFindUser(code);
expect(res).to.equal(false);
});
it('returns false if the user does not exist', async () => {
let res = await validatePasswordResetCodeAndFindUser(encrypt(JSON.stringify({
userId: Date.now().toString(),
expiresAt: moment().add({days: 1}),
})));
expect(res).to.equal(false);
});
it('returns false if the user has no local auth', async () => {
let user = await generateUser({
auth: 'not an object with valid fields',
});
let res = await validatePasswordResetCodeAndFindUser(encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().add({days: 1}),
})));
expect(res).to.equal(false);
});
it('returns false if the code doesn\'t match the one saved at user.auth.passwordResetCode', async () => {
let user = await generateUser();
let code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().add({days: 1}),
}));
await user.update({
'auth.local.passwordResetCode': 'invalid',
});
let res = await validatePasswordResetCodeAndFindUser(code);
expect(res).to.equal(false);
});
it('returns the user if the password reset code is valid', async () => {
let user = await generateUser();
let code = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().add({days: 1}),
}));
await user.update({
'auth.local.passwordResetCode': code,
});
let res = await validatePasswordResetCodeAndFindUser(code);
expect(res).not.to.equal(false);
expect(res._id).to.equal(user._id);
});
});
describe('bcrypt', () => { describe('bcrypt', () => {
describe('Hash', () => { describe('Hash', () => {
it('returns a hashed string', async () => { it('returns a hashed string', async () => {

View File

@@ -252,10 +252,11 @@
"usernameTaken": "Username already taken.", "usernameTaken": "Username already taken.",
"passwordConfirmationMatch": "Password confirmation doesn't match password.", "passwordConfirmationMatch": "Password confirmation doesn't match password.",
"invalidLoginCredentials": "Incorrect username and/or email and/or password.", "invalidLoginCredentials": "Incorrect username and/or email and/or password.",
"passwordReset": "If we have your email on file, a new password has been sent to your email.", "passwordResetPage": "Reset Password",
"passwordReset": "If we have your email on file, instructions for setting a new password have been sent to your email.",
"passwordResetEmailSubject": "Password Reset for Habitica", "passwordResetEmailSubject": "Password Reset for Habitica",
"passwordResetEmailText": "Password for <%= username %> has been reset to <%= newPassword %> . Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them. Log in at <%= baseUrl %>. After you have logged in, head to <%= baseUrl %>/#/options/settings/settings and change your password.", "passwordResetEmailText": "If you requested a password reset for <%= username %> on Habitica, head to <%= passwordResetLink %> to set a new one. The link will expire after 24 hours. If you haven't requested a password reset, please ignore this email.",
"passwordResetEmailHtml": "Password for <strong><%= username %></strong> has been reset to <strong><%= newPassword %></strong><br /><br />Important! Both username and password are case-sensitive -- you must enter both exactly as shown here. We recommend copying and pasting both instead of typing them.<br /><br />Log in at <%= baseUrl %>. After you have logged in, head to <%= baseUrl %>/#/options/settings/settings and change your password.", "passwordResetEmailHtml": "If you requested a password reset for <strong><%= username %></strong> on Habitica, <a href=\"<%= passwordResetLink %>\">click here</a> to set a new one. The link will expire after 24 hours.<br/><br>If you haven't requested a password reset, please ignore this email.",
"invalidLoginCredentialsLong": "Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\".", "invalidLoginCredentialsLong": "Uh-oh - your username or password is incorrect.\n- Make sure your username or email is typed correctly.\n- You may have signed up with Facebook, not email. Double-check by trying Facebook login.\n- If you forgot your password, click \"Forgot Password\".",
"invalidCredentials": "There is no account that uses those credentials.", "invalidCredentials": "There is no account that uses those credentials.",
"accountSuspended": "Account has been suspended, please contact leslie@habitica.com with your User ID \"<%= userId %>\" for assistance.", "accountSuspended": "Account has been suspended, please contact leslie@habitica.com with your User ID \"<%= userId %>\" for assistance.",

View File

@@ -108,7 +108,7 @@
"buyThis": "Buy this <%= text %> with <%= price %> of your <%= gems %> Gems?", "buyThis": "Buy this <%= text %> with <%= price %> of your <%= gems %> Gems?",
"noReachServer": "Server not currently reachable, try again later", "noReachServer": "Server not currently reachable, try again later",
"errorUpCase": "ERROR:", "errorUpCase": "ERROR:",
"newPassSent": "If we have your email on file, a new password has been sent to your email.", "newPassSent": "If we have your email on file, instructions for setting a new password have been sent to your email.",
"serverUnreach": "Server currently unreachable.", "serverUnreach": "Server currently unreachable.",
"requestError": "Yikes, an error occurred! <strong>Please reload the page,</strong> your last action may not have been saved correctly.", "requestError": "Yikes, an error occurred! <strong>Please reload the page,</strong> your last action may not have been saved correctly.",
"seeConsole": "If the error persists, please report it at Help > Report a Bug. If you're familiar with your browser's console, please include any error messages.", "seeConsole": "If the error persists, please report it at Help > Report a Bug. If you're familiar with your browser's console, please include any error messages.",

View File

@@ -88,6 +88,8 @@
"deleteDo": "Do it, delete my account!", "deleteDo": "Do it, delete my account!",
"enterNumber": "Please enter a number between 0 and 24", "enterNumber": "Please enter a number between 0 and 24",
"fillAll": "Please fill out all fields", "fillAll": "Please fill out all fields",
"invalidPasswordResetCode": "The supplied password reset code is invalid or has expired.",
"passwordChangeSuccess": "Your password was successfully changed to the one you just chose. You can now use it to access your account.",
"passwordSuccess": "Password successfully changed", "passwordSuccess": "Password successfully changed",
"usernameSuccess": "Login Name successfully changed", "usernameSuccess": "Login Name successfully changed",
"emailSuccess": "Email successfully changed", "emailSuccess": "Email successfully changed",

View File

@@ -16,11 +16,13 @@ import { model as User } from '../../models/user';
import { model as Group } from '../../models/group'; import { model as Group } from '../../models/group';
import { model as EmailUnsubscription } from '../../models/emailUnsubscription'; import { model as EmailUnsubscription } from '../../models/emailUnsubscription';
import { sendTxn as sendTxnEmail } from '../../libs/email'; import { sendTxn as sendTxnEmail } from '../../libs/email';
import { decrypt } from '../../libs/encryption'; import { decrypt, encrypt } from '../../libs/encryption';
import { send as sendEmail } from '../../libs/email'; import { send as sendEmail } from '../../libs/email';
import pusher from '../../libs/pusher'; import pusher from '../../libs/pusher';
import common from '../../../common'; import common from '../../../common';
const BASE_URL = nconf.get('BASE_URL');
let api = {}; let api = {};
// When the user signed up after having been invited to a group, invite them automatically to the group // When the user signed up after having been invited to a group, invite them automatically to the group
@@ -549,11 +551,14 @@ api.resetPassword = {
let user = await User.findOne({ 'auth.local.email': email }).exec(); let user = await User.findOne({ 'auth.local.email': email }).exec();
if (user) { if (user) {
// use a salt as the new password too (they'll change it later) // create an encrypted link to be used to reset the password
let newPassword = passwordUtils.sha1MakeSalt(); const passwordResetCode = encrypt(JSON.stringify({
userId: user._id,
expiresAt: moment().add({ hours: 24 }),
}));
let link = `${BASE_URL}/static/user/auth/local/reset-password-set-new-one?code=${passwordResetCode}`;
// set new password and make sure it's using bcrypt for hashing user.auth.local.passwordResetCode = passwordResetCode;
await passwordUtils.convertToBcrypt(user, newPassword); // user is saved a few lines below
sendEmail({ sendEmail({
from: 'Habitica <admin@habitica.com>', from: 'Habitica <admin@habitica.com>',
@@ -561,13 +566,11 @@ api.resetPassword = {
subject: res.t('passwordResetEmailSubject'), subject: res.t('passwordResetEmailSubject'),
text: res.t('passwordResetEmailText', { text: res.t('passwordResetEmailText', {
username: user.auth.local.username, username: user.auth.local.username,
newPassword, passwordResetLink: link,
baseUrl: nconf.get('BASE_URL'),
}), }),
html: res.t('passwordResetEmailHtml', { html: res.t('passwordResetEmailHtml', {
username: user.auth.local.username, username: user.auth.local.username,
newPassword, passwordResetLink: link,
baseUrl: nconf.get('BASE_URL'),
}), }),
}); });

View File

@@ -1,7 +1,89 @@
import locals from '../../middlewares/locals';
import { validatePasswordResetCodeAndFindUser, convertToBcrypt} from '../../libs/password';
let api = {}; let api = {};
// Internal authentication routes // Internal authentication routes
function renderPasswordResetPage (options = {}) {
// res is express' res, error any error and success if the password was successfully changed
let {res, hasError, success = false, message} = options;
return res.status(hasError ? 401 : 200).render('auth/reset-password-set-new-one.jade', {
env: res.locals.habitrpg,
success,
hasError,
message, // can be error or success message
});
}
// Set a new password after having requested a password reset (GET route to input password)
api.resetPasswordSetNewOne = {
method: 'GET',
url: '/static/user/auth/local/reset-password-set-new-one',
middlewares: [locals],
runCron: false,
async handler (req, res) {
let user = await validatePasswordResetCodeAndFindUser(req.query.code);
let isValidCode = Boolean(user);
return renderPasswordResetPage({
res,
hasError: !isValidCode,
message: !isValidCode ? res.t('invalidPasswordResetCode') : null,
});
},
};
// Set a new password after having requested a password reset (POST route to save password)
api.resetPasswordSetNewOneSubmit = {
method: 'POST',
url: '/static/user/auth/local/reset-password-set-new-one',
middlewares: [locals],
runCron: false,
async handler (req, res) {
let user = await validatePasswordResetCodeAndFindUser(req.query.code);
let isValidCode = Boolean(user);
if (!isValidCode) return renderPasswordResetPage({
res,
hasError: true,
message: res.t('invalidPasswordResetCode'),
});
let newPassword = req.body.newPassword;
let confirmPassword = req.body.confirmPassword;
if (!newPassword) {
return renderPasswordResetPage({
res,
hasError: true,
message: res.t('missingNewPassword'),
});
}
if (newPassword !== confirmPassword) {
return renderPasswordResetPage({
res,
hasError: true,
message: res.t('passwordConfirmationMatch'),
});
}
// set new password and make sure it's using bcrypt for hashing
await convertToBcrypt(user, String(newPassword));
user.auth.local.passwordResetCode = undefined; // Reset saved password reset code
await user.save();
return renderPasswordResetPage({
res,
hasError: false,
success: true,
message: res.t('passwordChangeSuccess'),
});
},
};
// Logout the user from the website. // Logout the user from the website.
api.logout = { api.logout = {
method: 'GET', method: 'GET',

View File

@@ -1,6 +1,9 @@
// Utilities for working with passwords // Utilities for working with passwords
import crypto from 'crypto'; import crypto from 'crypto';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { decrypt } from './encryption';
import moment from 'moment';
import { model as User } from '../models/user';
const BCRYPT_SALT_ROUNDS = 10; const BCRYPT_SALT_ROUNDS = 10;
@@ -63,3 +66,37 @@ export async function convertToBcrypt (user, plainTextPassword) {
user.auth.local.passwordHashMethod = 'bcrypt'; user.auth.local.passwordHashMethod = 'bcrypt';
user.auth.local.hashed_password = await bcryptHash(plainTextPassword); // eslint-disable-line camelcase user.auth.local.hashed_password = await bcryptHash(plainTextPassword); // eslint-disable-line camelcase
} }
// Returns the user if a valid password reset code is supplied, otherwise false
export async function validatePasswordResetCodeAndFindUser (code) {
let isCodeValid = true;
let userId;
let user;
let decryptedPasswordResetCode;
// wrapping the code in a try to be able to handle the error here
try {
decryptedPasswordResetCode = JSON.parse(decrypt(code || 'invalid')); // also catches missing code
userId = decryptedPasswordResetCode.userId;
let expiresAt = decryptedPasswordResetCode.expiresAt;
if (moment(expiresAt).isBefore(moment())) throw new Error();
} catch (err) {
isCodeValid = false;
}
if (isCodeValid) {
user = await User.findById(userId).exec();
// check if user is found and if it's an email & password account
if (!user || !user.auth || !user.auth.local || !user.auth.local.email) {
isCodeValid = false;
} else if (code !== user.auth.local.passwordResetCode) {
// Make sure only the last code can be used
isCodeValid = false;
}
}
return isCodeValid ? user : false;
}

View File

@@ -59,7 +59,9 @@ let schema = new Schema({
type: String, type: String,
enum: ['bcrypt', 'sha1'], enum: ['bcrypt', 'sha1'],
}, },
salt: String, // Salt for SHA1 encrypted passwords, not stored for bcrypt salt: String, // Salt for SHA1 encrypted passwords, not stored for bcrypt,
// Used to validate password reset codes and make sure only the most recent one can be used
passwordResetCode: String,
}, },
timestamps: { timestamps: {
created: {type: Date, default: Date.now}, created: {type: Date, default: Date.now},

View File

@@ -0,0 +1,27 @@
extends ../static/layout
block vars
- var layoutEnv = env // needed to pass env variable to ./layout
block title
title Habitica &VerticalLine;&nbsp;
=env.t('passwordResetPage')
block content
.row(ng-controller='AccordionCtrl')
.col-md-12
.page-header
h1=env.t('passwordResetPage')
if message
p.lead(class=hasError ? 'text-danger' : 'text-success')=message
hr
if success !== true
// action empty is necessary to prevent Angular from handling the form itself (# causes issues)
form(method='post', action='')
.form-group
label(for='newPass')=env.t('newPass')
input#newPass.form-control(name='newPassword', type='password', placeholder=env.t('newPass'))
.form-group
label(for='confirmPass')=env.t('confirmPass')
input#confirmPass.form-control(name='confirmPassword', type='password', placeholder=env.t('confirmPass'))
button.btn.btn-default(type='submit')=env.t('submit')