mirror of
https://github.com/HabitRPG/habitica.git
synced 2025-12-17 22:57:21 +01:00
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:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'),
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
27
website/views/auth/reset-password-set-new-one.jade
Normal file
27
website/views/auth/reset-password-set-new-one.jade
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
extends ../static/layout
|
||||||
|
|
||||||
|
block vars
|
||||||
|
- var layoutEnv = env // needed to pass env variable to ./layout
|
||||||
|
|
||||||
|
block title
|
||||||
|
title Habitica |
|
||||||
|
=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')
|
||||||
Reference in New Issue
Block a user