node-local-auth
Version:
Framework agnostic library for secure username/email/password authentication including registration and password management
410 lines (303 loc) • 16.7 kB
JavaScript
'use strict';
const chai = require('chai');
const assert = chai.assert;
const UserStoreFake = require('./fakes/userStoreFake');
const TokenStoreFake = require('./fakes/tokenStoreFake');
const EmailServiceFake = require('./fakes/emailServiceFake');
const hashAlgoFake = require('./fakes/hashAlgoFake');
const Registration = require('../lib/registration');
const ForgotPassword = require('../lib/forgotPassword.js');
const _ = require('lodash');
const testUtils = require('./testUtils');
describe('Forgot Password', () => {
let userStoreFake;
let verifyEmailTokenStoreFake;
let passwordResetTokenStoreFake;
let emailServiceFake;
let registration;
const existingUserEmail = 'foo@example.com';
const existingUserPassword = 'bar';
let sut;
beforeEach(() => {
userStoreFake = new UserStoreFake();
verifyEmailTokenStoreFake = new TokenStoreFake();
passwordResetTokenStoreFake = new TokenStoreFake();
emailServiceFake = new EmailServiceFake();
sut = createSut();
});
function createSut(options, services) {
const opts = _.merge({
userStore: userStoreFake,
verifyEmailTokenStore: verifyEmailTokenStoreFake,
passwordResetTokenStore: passwordResetTokenStoreFake,
hashAlgo: hashAlgoFake,
emailService: emailServiceFake
}, services);
registration = new Registration(
opts.userStore,
opts.verifyEmailTokenStore,
opts.hashAlgo,
opts.emailService,
options
);
return new ForgotPassword(
opts.userStore,
opts.passwordResetTokenStore,
opts.hashAlgo,
opts.emailService,
options);
}
describe('Step 1 - Requesting Reset', () => {
it('requires valid email', function *() {
const email = '';
const err = yield testUtils.assertThrows(function *() {
yield sut.requestPasswordReset(email);
});
assert.equal(err.message, 'Valid email address required');
});
it('sends forgot password email for existing account on entering matching email', function *() {
yield registerUser(existingUserEmail, existingUserPassword);
assert.equal(emailServiceFake.calls.sendForgotPasswordEmail.length, 0, 'no calls yet');
yield sut.requestPasswordReset(existingUserEmail);
assert.equal(emailServiceFake.calls.sendForgotPasswordEmail.length, 1, 'forgot password email sent');
const callArgs = emailServiceFake.calls.sendForgotPasswordEmail[0];
assert.equal(callArgs[0].email, existingUserEmail);
const token = callArgs[1];
assert.ok(token, 'has token');
});
it('ignores case of email when sending forgot password email', function *() {
yield registerUser('foo@example.com', existingUserPassword);
assert.equal(emailServiceFake.calls.sendForgotPasswordEmail.length, 0, 'no calls yet');
yield sut.requestPasswordReset('FoO@EXAMPLE.com');
assert.equal(emailServiceFake.calls.sendForgotPasswordEmail.length, 1, 'forgot password email sent');
const callArgs = emailServiceFake.calls.sendForgotPasswordEmail[0];
assert.equal(callArgs[0].email, 'foo@example.com');
});
it('can send forgot password attempt notification email on entering unknown email', function *() {
yield registerUser('foo@example.com', existingUserPassword);
assert.equal(emailServiceFake.calls.handleForgotPasswordForUnregisteredEmail.length, 0, 'no calls yet');
yield sut.requestPasswordReset('unknown@example.com');
assert.equal(emailServiceFake.calls.handleForgotPasswordForUnregisteredEmail.length, 1, 'handled unknown email');
const callArgs = emailServiceFake.calls.handleForgotPasswordForUnregisteredEmail[0];
assert.equal(callArgs[0], 'unknown@example.com');
});
it('stores new password reset token for email', function *() {
yield registerUser(existingUserEmail, existingUserPassword);
assert.lengthOf(passwordResetTokenStoreFake.tokens, 0, 'no stored token yet');
yield sut.requestPasswordReset(existingUserEmail);
assert.lengthOf(passwordResetTokenStoreFake.tokens, 1, 'stores token');
const tokenDetails = passwordResetTokenStoreFake.tokens[0];
assert.equal(tokenDetails.email, existingUserEmail);
assert.isNotNull(tokenDetails.hashedToken);
assert.isNotNull(tokenDetails.expiry);
});
it('ensures password reset token does not contain any user identifiers to prevent guessing', function *() {
const email = 'user@example.com';
yield registerUser(email, existingUserPassword);
yield sut.requestPasswordReset(email);
const user = userStoreFake.users[0];
assert.equal(user.id, 'User#1');
assert.equal(emailServiceFake.calls.sendForgotPasswordEmail.length, 1, 'forgot password email sent');
const callArgs = emailServiceFake.calls.sendForgotPasswordEmail[0];
const token = callArgs[1];
assert.ok(token, 'Has token');
// makes sure token does not contain 'user' - which covers email address and user id
assert.isFalse(token.toLowerCase().includes('user'), 'Token does not contain "user"');
});
it('deletes any pending reset tokens for same email on receipt of a new password reset request', function *() {
yield registerUser(existingUserEmail, existingUserPassword);
yield sut.requestPasswordReset(existingUserEmail);
assert.lengthOf(passwordResetTokenStoreFake.tokens, 1);
assert.equal(passwordResetTokenStoreFake.tokens[0].tokenId, 'Token#1');
yield sut.requestPasswordReset(existingUserEmail);
assert.lengthOf(passwordResetTokenStoreFake.tokens, 1);
assert.equal(passwordResetTokenStoreFake.tokens[0].tokenId, 'Token#2');
});
});
describe('Step 1 - Requesting Reset (when email verification required)', () => {
beforeEach(function() {
sut = createSut({
verifyEmail: true
});
});
it('forbids resetting password if user email not previously verified', function *() {
assert.lengthOf(userStoreFake.users, 0);
yield registerUser(existingUserEmail, existingUserPassword);
assert.lengthOf(userStoreFake.users, 1);
assert.isFalse(userStoreFake.users[0].emailVerified, 'emailVerified is false');
const err = yield testUtils.assertThrows(function *() {
yield sut.requestPasswordReset(existingUserEmail);
});
assert.equal(err.message, 'Please verify your email address first by clicking on the link in the registration email');
});
it('sends forgot password email if user email previously verified', function *() {
assert.lengthOf(userStoreFake.users, 0);
yield registerUser(existingUserEmail, existingUserPassword);
assert.lengthOf(userStoreFake.users, 1);
// Mark email verified (this functionality tested elsewhere)
// TODO: use proper call here
userStoreFake.users[0].emailVerified = true;
yield sut.requestPasswordReset(existingUserEmail);
assert.equal(emailServiceFake.calls.sendForgotPasswordEmail.length, 1, 'forgot password email sent');
const callArgs = emailServiceFake.calls.sendForgotPasswordEmail[0];
assert.equal(callArgs[0].email, existingUserEmail);
});
});
describe('Step 2 - Verifying Token (when rendering password reset page)', () => {
let passwordResetToken;
beforeEach(function *() {
// Set up existing pwd reset request and capture token:
yield registerUser(existingUserEmail, existingUserPassword);
yield sut.requestPasswordReset(existingUserEmail);
assert.equal(emailServiceFake.calls.sendForgotPasswordEmail.length, 1, 'forgot password email sent');
const callArgs = emailServiceFake.calls.sendForgotPasswordEmail[0];
passwordResetToken = callArgs[1];
});
it('ensures email is required', function *() {
const email = '';
const token = 'foo';
const err = yield testUtils.assertThrows(function *() {
yield sut.assertPasswordResetTokenValid(email, token);
});
assert.equal(err.message, 'Valid email address required');
});
it('ensures token is required', function *() {
const token = '';
const err = yield testUtils.assertThrows(function *() {
yield sut.assertPasswordResetTokenValid(existingUserEmail, token);
});
assert.equal(err.message, 'Password reset token required');
});
it('ensures invalid password request tokens are ignored', function *() {
const token = 'unknown';
const err = yield testUtils.assertThrows(function *() {
yield sut.assertPasswordResetTokenValid(existingUserEmail, token);
});
assert.equal(err.message, 'Unknown or expired token');
});
it('ensures that password reset request is only valid for limited period of time', function *() {
// expire the existing token:
assert.lengthOf(passwordResetTokenStoreFake.tokens, 1);
passwordResetTokenStoreFake.tokens[0].expiry = new Date(Date.now() - 1);
const err = yield testUtils.assertThrows(function *() {
yield sut.assertPasswordResetTokenValid(existingUserEmail, passwordResetToken);
});
assert.equal(err.message, 'Unknown or expired token');
});
it('does not throw if password reset token is valid', function *() {
yield sut.assertPasswordResetTokenValid(existingUserEmail, passwordResetToken);
});
it('ignores email case when checking if password reset token is valid', function *() {
yield sut.assertPasswordResetTokenValid(existingUserEmail.toUpperCase(), passwordResetToken);
});
});
describe('Step 3 - Resetting Password', () => {
const ValidPassword = 'password';
const ValidToken = 'foo';
let passwordResetToken;
beforeEach(function *() {
// Set up existing pwd reset request and capture token:
yield registerUser(existingUserEmail, existingUserPassword);
yield sut.requestPasswordReset(existingUserEmail);
assert.equal(emailServiceFake.calls.sendForgotPasswordEmail.length, 1, 'forgot password email sent');
const callArgs = emailServiceFake.calls.sendForgotPasswordEmail[0];
passwordResetToken = callArgs[1];
});
it('ensures email is required', function *() {
const email = '';
const err = yield testUtils.assertThrows(function *() {
yield sut.resetPassword(email, ValidToken, ValidPassword, ValidPassword);
});
assert.equal(err.message, 'Valid email address required');
});
it('ensures token is required', function *() {
const token = '';
const err = yield testUtils.assertThrows(function *() {
yield sut.resetPassword(existingUserEmail, token, ValidPassword, ValidPassword);
});
assert.equal(err.message, 'Password reset token required');
});
it('ensures password is required', function *() {
const password = '';
const err = yield testUtils.assertThrows(function *() {
yield sut.resetPassword(existingUserEmail, passwordResetToken, password, ValidPassword);
});
assert.equal(err.message, 'New password required');
});
it('ensures confirm password is required', function *() {
const confirmPassword = '';
const err = yield testUtils.assertThrows(function *() {
yield sut.resetPassword(existingUserEmail, passwordResetToken, ValidPassword, confirmPassword);
});
assert.equal(err.message, 'Password confirmation required');
});
it('ensures password matches confirm password', function *() {
const err = yield testUtils.assertThrows(function *() {
yield sut.resetPassword(existingUserEmail, passwordResetToken, 'password', 'confirm-password');
});
assert.equal(err.message, 'Password and confirm password do not match');
});
it('ensures unknown password request tokens are ignored', function *() {
const token = 'unknown-token';
const err = yield testUtils.assertThrows(function *() {
yield sut.resetPassword(existingUserEmail, token, ValidPassword, ValidPassword);
});
assert.equal(err.message, 'Unknown or expired token');
});
it('ensures that expired tokens are ignored', function *() {
// expire the existing token:
assert.lengthOf(passwordResetTokenStoreFake.tokens, 1);
passwordResetTokenStoreFake.tokens[0].expiry = new Date(Date.now() - 1);
const err = yield testUtils.assertThrows(function *() {
yield sut.resetPassword(existingUserEmail, passwordResetToken, ValidPassword, ValidPassword);
});
assert.equal(err.message, 'Unknown or expired token');
});
it('ensures unknown password request emails are ignored', function *() {
const email = 'unknown-email@example.com';
const err = yield testUtils.assertThrows(function *() {
yield sut.resetPassword(email, passwordResetToken, ValidPassword, ValidPassword);
});
assert.equal(err.message, 'Unknown or expired token');
});
it('ensures password reset tokens for unknown users are ignored', function *() {
// Just remove all users so no possibility of a match:
userStoreFake.users = [];
const err = yield testUtils.assertThrows(function *() {
yield sut.resetPassword(existingUserEmail, passwordResetToken, ValidPassword, ValidPassword);
});
assert.equal(err.message, 'Unknown or expired token');
});
it('allows password to be reset', function *() {
const newPassword = existingUserPassword + '-new';
yield sut.resetPassword(existingUserEmail, passwordResetToken, newPassword, newPassword);
assert.lengthOf(userStoreFake.users, 1);
var user = userStoreFake.users[0];
assert.equal(user.hashedPassword, 'hashed:' + newPassword);
});
it('ignores case of email when resetting password', function *() {
const newPassword = existingUserPassword + '-new';
const uppercaseEmail = existingUserEmail.toUpperCase();
yield sut.resetPassword(uppercaseEmail, passwordResetToken, newPassword, newPassword);
assert.lengthOf(userStoreFake.users, 1);
var user = userStoreFake.users[0];
assert.equal(user.hashedPassword, 'hashed:' + newPassword);
});
it('deletes password reset token after password reset', function *() {
assert.lengthOf(passwordResetTokenStoreFake.tokens, 1);
yield sut.resetPassword(existingUserEmail, passwordResetToken, ValidPassword, ValidPassword);
assert.lengthOf(passwordResetTokenStoreFake.tokens, 0);
});
it('emails user confirmation of change after password reset', function *() {
assert.lengthOf(emailServiceFake.calls.sendPasswordSuccessfullyResetEmail, 0);
yield sut.resetPassword(existingUserEmail, passwordResetToken, ValidPassword, ValidPassword);
assert.lengthOf(emailServiceFake.calls.sendPasswordSuccessfullyResetEmail, 1);
const args = emailServiceFake.calls.sendPasswordSuccessfullyResetEmail[0];
const user = args[0];
assert.equal(user.email, existingUserEmail, 'Email sent to user');
});
});
function *registerUser(email, password) {
yield registration.register(email, password);
}
});