Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Multi-factor authentication #796

Open
wants to merge 20 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docker-compose-dev.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
version: '3'
services:
mongo:
image: mongo
ports:
- "27017:27017"
redis:
image: redis
ports:
- "6379:6379"
postgres:
image: postgres
ports:
- "5432:5432"
environment:
- POSTGRES_USER=postgres
- POSTGRES_DB=accounts-js-tests-e2e
1 change: 1 addition & 0 deletions packages/boost/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ export const accountsBoost = async (userOptions?: AccountsBoostOptions): Promise
options.db = new DatabaseManager({
userStorage: storage,
sessionStorage: storage,
mfaLoginAttemptsStorage: storage,
});

const servicePackages = {
Expand Down
4 changes: 2 additions & 2 deletions packages/client-password/src/client-password.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { AccountsClient } from '@accounts/client';
import { LoginResult, CreateUser } from '@accounts/types';
import { LoginResult, CreateUser, MFALoginResult } from '@accounts/types';
import { AccountsClientPasswordOptions } from './types';

export class AccountsClientPassword {
Expand All @@ -25,7 +25,7 @@ export class AccountsClientPassword {
/**
* Log the user in with a password.
*/
public async login(user: any): Promise<LoginResult> {
public async login(user: any): Promise<LoginResult | Omit<MFALoginResult, 'mfaToken'>> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why don't we return the mfaToken there?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mainly, to simplify the usage

const hashedPassword = this.hashPassword(user.password);
return this.client.loginWithService('password', {
...user,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Accounts performMfaChallenge throws error when no mfaToken exists in storage: mfaToken-not-available 1`] = `[Error: mfaToken is not available in storage]`;
41 changes: 40 additions & 1 deletion packages/client/__tests__/accounts-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ const loggedInResponse = {
},
};

const mfaLoginResult = {
mfaToken: 'mfaToken',
};

const impersonateResult = {
authorized: true,
tokens: { accessToken: 'newAccessToken', refreshToken: 'newRefreshToken' },
Expand All @@ -22,8 +26,11 @@ const tokens = {
};

const mockTransport = {
loginWithService: jest.fn(() => Promise.resolve(loggedInResponse)),
loginWithService: jest.fn((service: string) =>
service === 'mfa-login' ? Promise.resolve(mfaLoginResult) : Promise.resolve(loggedInResponse)
),
authenticateWithService: jest.fn(() => Promise.resolve(true)),
performMfaChallenge: jest.fn(() => Promise.resolve('login-token')),
logout: jest.fn(() => Promise.resolve()),
refreshTokens: jest.fn(() => Promise.resolve(loggedInResponse)),
sendResetPasswordEmail: jest.fn(() => Promise.resolve()),
Expand Down Expand Up @@ -150,6 +157,38 @@ describe('Accounts', () => {
loggedInResponse.tokens.refreshToken
);
});

it('set the mfa token when mfa is enabled', async () => {
await accountsClient.loginWithService('mfa-login', {
username: 'user',
password: 'password',
});
expect(localStorage.setItem).toHaveBeenCalledTimes(1);
expect(localStorage.getItem('accounts:mfaToken')).toEqual(mfaLoginResult.mfaToken);
});
});

describe('performMfaChallenge', () => {
it('throws error when no mfaToken exists in storage', async () => {
await expect(accountsClient.performMfaChallenge('sms', {})).rejects.toMatchSnapshot(
'mfaToken-not-available'
);
});

it('performs the challenge and return the login result', async () => {
localStorage.setItem('accounts:mfaToken', 'mfa-token');

await accountsClient.performMfaChallenge('sms', {});

expect(localStorage.setItem).toHaveBeenCalledTimes(3);
expect(localStorage.getItem('accounts:accessToken')).toEqual(
loggedInResponse.tokens.accessToken
);
expect(localStorage.getItem('accounts:refreshToken')).toEqual(
loggedInResponse.tokens.refreshToken
);
expect(localStorage.getItem('accounts:mfaToken')).toBeNull();
});
});

describe('authenticateWithService', () => {
Expand Down
35 changes: 32 additions & 3 deletions packages/client/src/accounts-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LoginResult, Tokens, ImpersonationResult, User } from '@accounts/types';
import { LoginResult, MFALoginResult, Tokens, ImpersonationResult, User } from '@accounts/types';
import { TransportInterface } from './transport-interface';
import { TokenStorage, AccountsClientOptions } from './types';
import { tokenStorageLocal } from './token-storage-local';
Expand All @@ -7,6 +7,7 @@ import { isTokenExpired } from './utils';
enum TokenKey {
AccessToken = 'accessToken',
RefreshToken = 'refreshToken',
MfaToken = 'mfaToken',
OriginalAccessToken = 'originalAccessToken',
OriginalRefreshToken = 'originalRefreshToken',
}
Expand Down Expand Up @@ -102,12 +103,40 @@ export class AccountsClient {
public async loginWithService(
service: string,
credentials: { [key: string]: any }
): Promise<LoginResult> {
): Promise<LoginResult | Omit<MFALoginResult, 'mfaToken'>> {
const response = await this.transport.loginWithService(service, credentials);
await this.setTokens(response.tokens);

if ((response as LoginResult).tokens) {
await this.setTokens((response as LoginResult).tokens);
} else {
await this.storage.setItem(
this.getTokenKey(TokenKey.MfaToken),
(response as MFALoginResult).mfaToken
);
}

return response;
}

/**
* Performs the mfa needed challenge and logs in afterwards
*/
public async performMfaChallenge(challenge: string, params: any): Promise<LoginResult> {
const mfaToken = await this.storage.getItem(this.getTokenKey(TokenKey.MfaToken));

if (!mfaToken) {
throw new Error('mfaToken is not available in storage');
}

const loginToken = await this.transport.performMfaChallenge(challenge, mfaToken, params);

const result = await this.loginWithService('mfa', { mfaToken, loginToken });

await this.storage.removeItem(this.getTokenKey(TokenKey.MfaToken));

return result as any;
}

/**
* Refresh the user session
* If the tokens have expired try to refresh them
Expand Down
11 changes: 9 additions & 2 deletions packages/client/src/transport-interface.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { LoginResult, ImpersonationResult, CreateUser, User } from '@accounts/types';
import {
LoginResult,
MFALoginResult,
ImpersonationResult,
CreateUser,
User,
} from '@accounts/types';
import { AccountsClient } from './accounts-client';

export interface TransportInterface {
Expand All @@ -15,7 +21,8 @@ export interface TransportInterface {
authenticateParams: {
[key: string]: string | object;
}
): Promise<LoginResult>;
): Promise<LoginResult | MFALoginResult>;
performMfaChallenge(challenge: string, mfaToken: string, params: any): Promise<string>;
logout(): Promise<void>;
getUser(): Promise<User>;
refreshTokens(accessToken: string, refreshToken: string): Promise<LoginResult>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`DatabaseManager configuration without MFA createMfaLoginAttempt should throw error 1`] = `"No mfaLoginAttemptsStorage defined for manager"`;

exports[`DatabaseManager configuration without MFA getMfaLoginAttempt should throw error 1`] = `"No mfaLoginAttemptsStorage defined for manager"`;

exports[`DatabaseManager configuration without MFA removeMfaLoginAttempt should throw error 1`] = `"No mfaLoginAttemptsStorage defined for manager"`;
56 changes: 56 additions & 0 deletions packages/database-manager/__tests__/database-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,24 @@ export default class Database {
public setUserDeactivated() {
return this.name;
}

public createMfaLoginAttempt() {
return this.name;
}

public getMfaLoginAttempt() {
return this.name;
}

public removeMfaLoginAttempt() {
return this.name;
}
}

const databaseManager = new DatabaseManager({
userStorage: new Database('userStorage'),
sessionStorage: new Database('sessionStorage'),
mfaLoginAttemptsStorage: new Database('mfaLoginAttemptsStorage'),
});

describe('DatabaseManager configuration', () => {
Expand All @@ -128,6 +141,10 @@ describe('DatabaseManager configuration', () => {
expect(() => (databaseManager as any).validateConfiguration({ userStorage: true })).toThrow();
});

it('should throw if no mfaLoginAttemptsStorage specified', () => {
expect(() => (databaseManager as any).validateConfiguration({ userStorage: true })).toThrow();
});

it('should not throw if sessionStorage specified', () => {
expect(() =>
(databaseManager as any).validateConfiguration({
Expand Down Expand Up @@ -244,4 +261,43 @@ describe('DatabaseManager', () => {
it('setUserDeactivated should be called on sessionStorage', () => {
expect(databaseManager.setUserDeactivated('userId', true)).toBe('userStorage');
});

it('createMfaLoginAttempt should be called on mfaLoginAttemptsStorage', () => {
expect(databaseManager.createMfaLoginAttempt('mfaToken', 'loginToken', 'userId')).toBe(
'mfaLoginAttemptsStorage'
);
});

it('getMfaLoginAttempt should be called on mfaLoginAttemptsStorage', () => {
expect(databaseManager.getMfaLoginAttempt('mfaToken')).toBe('mfaLoginAttemptsStorage');
});

it('removeMfaLoginAttempt should be called on mfaLoginAttemptsStorage', () => {
expect(databaseManager.removeMfaLoginAttempt('mfaToken')).toBe('mfaLoginAttemptsStorage');
});
});

describe('DatabaseManager configuration without MFA', () => {
const databaseManagerNoMfa = new DatabaseManager({
userStorage: new Database('userStorage'),
sessionStorage: new Database('sessionStorage'),
});

it('createMfaLoginAttempt should throw error', () => {
expect(() =>
databaseManagerNoMfa.createMfaLoginAttempt('mfaToken', 'loginToken', 'userId')
).toThrowErrorMatchingSnapshot();
});

it('getMfaLoginAttempt should throw error', () => {
expect(() =>
databaseManagerNoMfa.getMfaLoginAttempt('mfaToken')
).toThrowErrorMatchingSnapshot();
});

it('removeMfaLoginAttempt should throw error', () => {
expect(() =>
databaseManagerNoMfa.removeMfaLoginAttempt('mfaToken')
).toThrowErrorMatchingSnapshot();
});
});
2 changes: 1 addition & 1 deletion packages/database-manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"compile": "tsc",
"prepublishOnly": "yarn compile",
"test": "npm run test",
"testonly": "jest --coverage",
"testonly": "jest",
"coverage": "jest --coverage"
},
"jest": {
Expand Down
29 changes: 28 additions & 1 deletion packages/database-manager/src/database-manager.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { DatabaseInterface, DatabaseInterfaceSessions } from '@accounts/types';
import {
DatabaseInterface,
DatabaseInterfaceSessions,
DatabaseInterfaceMfaLoginAttempts,
} from '@accounts/types';

import { Configuration } from './types/configuration';

export class DatabaseManager implements DatabaseInterface {
private userStorage: DatabaseInterface;
private sessionStorage: DatabaseInterface | DatabaseInterfaceSessions;
private mfaLoginAttemptsStorage?: DatabaseInterface | DatabaseInterfaceMfaLoginAttempts;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be a required field?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davidyaha pointed out that people might not want mfa at all, so I made it optional


constructor(configuration: Configuration) {
this.validateConfiguration(configuration);
this.userStorage = configuration.userStorage;
this.sessionStorage = configuration.sessionStorage;
this.mfaLoginAttemptsStorage = configuration.mfaLoginAttemptsStorage;
}

private validateConfiguration(configuration: Configuration): void {
Expand Down Expand Up @@ -154,4 +160,25 @@ export class DatabaseManager implements DatabaseInterface {
public get setUserDeactivated(): DatabaseInterface['setUserDeactivated'] {
return this.userStorage.setUserDeactivated.bind(this.userStorage);
}

public get createMfaLoginAttempt(): DatabaseInterface['createMfaLoginAttempt'] {
if (!this.mfaLoginAttemptsStorage) {
throw new Error('No mfaLoginAttemptsStorage defined for manager');
}
return this.mfaLoginAttemptsStorage.createMfaLoginAttempt.bind(this.mfaLoginAttemptsStorage);
}

public get getMfaLoginAttempt(): DatabaseInterface['getMfaLoginAttempt'] {
if (!this.mfaLoginAttemptsStorage) {
throw new Error('No mfaLoginAttemptsStorage defined for manager');
}
return this.mfaLoginAttemptsStorage.getMfaLoginAttempt.bind(this.mfaLoginAttemptsStorage);
}

public get removeMfaLoginAttempt(): DatabaseInterface['removeMfaLoginAttempt'] {
if (!this.mfaLoginAttemptsStorage) {
throw new Error('No mfaLoginAttemptsStorage defined for manager');
}
return this.mfaLoginAttemptsStorage.removeMfaLoginAttempt.bind(this.mfaLoginAttemptsStorage);
}
}
7 changes: 6 additions & 1 deletion packages/database-manager/src/types/configuration.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { DatabaseInterface, DatabaseInterfaceSessions } from '@accounts/types';
import {
DatabaseInterface,
DatabaseInterfaceSessions,
DatabaseInterfaceMfaLoginAttempts,
} from '@accounts/types';

export interface Configuration {
userStorage: DatabaseInterface;
sessionStorage: DatabaseInterface | DatabaseInterfaceSessions;
mfaLoginAttemptsStorage?: DatabaseInterface | DatabaseInterfaceMfaLoginAttempts;
}
5 changes: 4 additions & 1 deletion packages/database-mongo/__tests__/database-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ export class DatabaseTests {

public createConnection = async () => {
const url = 'mongodb://localhost:27017';
this.client = await mongodb.MongoClient.connect(url, { useNewUrlParser: true });
this.client = await mongodb.MongoClient.connect(url, {
useNewUrlParser: true,
useUnifiedTopology: true,
});
this.db = this.client.db('accounts-mongo-tests');
this.database = new Mongo(this.db, this.options);
};
Expand Down
Loading