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 1 commit
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
31 changes: 31 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 throw if no sessionStorage specified', () => {
expect(() =>
(databaseManager as any).validateConfiguration({
Expand Down Expand Up @@ -244,4 +261,18 @@ 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');
});
});
20 changes: 19 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;

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,16 @@ export class DatabaseManager implements DatabaseInterface {
public get setUserDeactivated(): DatabaseInterface['setUserDeactivated'] {
return this.userStorage.setUserDeactivated.bind(this.userStorage);
}

public get createMfaLoginAttempt(): DatabaseInterface['createMfaLoginAttempt'] {
return this.mfaLoginAttemptsStorage.createMfaLoginAttempt.bind(this.mfaLoginAttemptsStorage);
}

public get getMfaLoginAttempt(): DatabaseInterface['getMfaLoginAttempt'] {
return this.mfaLoginAttemptsStorage.getMfaLoginAttempt.bind(this.mfaLoginAttemptsStorage);
}

public get removeMfaLoginAttempt(): DatabaseInterface['removeMfaLoginAttempt'] {
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
77 changes: 77 additions & 0 deletions packages/database-mongo/__tests__/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// tslint:disable-next-line
import { randomBytes } from 'crypto';
import { ObjectID, ObjectId } from 'mongodb';
import { MfaLoginAttempt } from '@accounts/types';

import { Mongo } from '../src';
import { DatabaseTests } from './database-tests';
Expand Down Expand Up @@ -836,4 +837,80 @@ describe('Mongo', () => {
expect((retUser as any).createdAt).not.toEqual((retUser as any).updatedAt);
});
});

describe('MfaLoginAttempts', () => {
it('should create a new mfa login attempt', async () => {
const attempt = { _id: '123', loginToken: '456', userId: '789' };
await databaseTests.database.createMfaLoginAttempt(
attempt._id,
attempt.loginToken,
attempt.userId
);

const dbObject = await databaseTests.db
.collection('mfa-login-attempts')
.findOne({ _id: attempt._id });

expect(dbObject).toEqual(attempt);
});

it('should not create a new mfa login attempt if already exists', async () => {
const attempt = { _id: '123', loginToken: '456', userId: '789' };
await databaseTests.db.collection('mfa-login-attempts').insertOne(attempt);

try {
await databaseTests.database.createMfaLoginAttempt(
attempt._id,
attempt.loginToken,
attempt.userId
);
} catch (e) {
const db = await databaseTests.db
.collection('mfa-login-attempts')
.find()
.toArray();

expect(db).toHaveLength(1);
}

expect.assertions(1);
});

it('should get a mfa login attempt', async () => {
const attempt = { _id: '123', loginToken: '456', userId: '789' };
await databaseTests.db.collection('mfa-login-attempts').insertOne(attempt);

const attemptFromDb = (await databaseTests.database.getMfaLoginAttempt(
attempt._id
)) as MfaLoginAttempt;

expect(attemptFromDb.id).toEqual(attempt._id);
expect(attemptFromDb.mfaToken).toEqual(attempt._id);
expect(attemptFromDb.loginToken).toEqual(attempt.loginToken);
expect(attemptFromDb.userId).toEqual(attempt.userId);
});

it('should return null while getting a mfa login attempt with wrong id', async () => {
const attempt = { _id: '123', loginToken: '456', userId: '789' };
await databaseTests.db.collection('mfa-login-attempts').insertOne(attempt);

const attemptFromDb = await databaseTests.database.getMfaLoginAttempt('111');

expect(attemptFromDb).toBeNull();
});

it('should remove a mfa login attempt', async () => {
const attempt = { _id: '123', loginToken: '456', userId: '789' };
await databaseTests.db.collection('mfa-login-attempts').insertOne(attempt);

await databaseTests.database.removeMfaLoginAttempt(attempt._id);

const db = await databaseTests.db
.collection('mfa-login-attempts')
.find()
.toArray();

expect(db).toHaveLength(0);
});
});
});
2 changes: 1 addition & 1 deletion packages/database-mongo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"precompile": "yarn clean",
"compile": "tsc",
"prepublishOnly": "yarn compile",
"testonly": "jest --runInBand --forceExit",
"testonly": "jest --runInBand",
"test:watch": "jest --watch",
"coverage": "yarn testonly --coverage"
},
Expand Down
42 changes: 41 additions & 1 deletion packages/database-mongo/src/mongo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import {
CreateUser,
DatabaseInterface,
Session,
MfaLoginAttempt,
User,
} from '@accounts/types';
import { get, merge } from 'lodash';
import { Collection, Db, ObjectID } from 'mongodb';
import { Collection, Db, ObjectID, MongoError } from 'mongodb';

import { AccountsMongoOptions, MongoUser } from './types';

Expand All @@ -21,6 +22,7 @@ const toMongoID = (objectId: string | ObjectID) => {
const defaultOptions = {
collectionName: 'users',
sessionCollectionName: 'sessions',
mfaLoginCollectionName: 'mfa-login-attempts',
timestamps: {
createdAt: 'createdAt',
updatedAt: 'updatedAt',
Expand All @@ -40,6 +42,8 @@ export class Mongo implements DatabaseInterface {
private collection: Collection;
// Session collection
private sessionCollection: Collection;
// Session collection
private mfaLoginCollection: Collection;

constructor(db: any, options?: AccountsMongoOptions) {
this.options = merge({ ...defaultOptions }, options);
Expand All @@ -49,6 +53,7 @@ export class Mongo implements DatabaseInterface {
this.db = db;
this.collection = this.db.collection(this.options.collectionName);
this.sessionCollection = this.db.collection(this.options.sessionCollectionName);
this.mfaLoginCollection = this.db.collection(this.options.mfaLoginCollectionName);
}

public async setupIndexes(): Promise<void> {
Expand Down Expand Up @@ -427,4 +432,39 @@ export class Mongo implements DatabaseInterface {
public async setResetPassword(userId: string, email: string, newPassword: string): Promise<void> {
await this.setPassword(userId, newPassword);
}

public async createMfaLoginAttempt(
mfaToken: string,
loginToken: string,
userId: string
): Promise<void> {
try {
await this.mfaLoginCollection.insertOne({ _id: mfaToken, loginToken, userId });
} catch (e) {
const me = e as MongoError;
if (me.code === 11000) {
// duplicate key
throw new Error('mfa login attempt already exists');
}
}
}

public async getMfaLoginAttempt(mfaToken: string): Promise<MfaLoginAttempt | null> {
const dbObject = await this.mfaLoginCollection.findOne({ _id: mfaToken });

if (!dbObject) {
return null;
}

return {
id: dbObject._id,
mfaToken: dbObject._id,
loginToken: dbObject.loginToken,
userId: dbObject.userId,
};
}

public async removeMfaLoginAttempt(mfaToken: string): Promise<void> {
await this.mfaLoginCollection.deleteOne({ _id: mfaToken });
}
}
4 changes: 4 additions & 0 deletions packages/database-mongo/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ export interface AccountsMongoOptions {
* The sessions collection name, default 'sessions'.
*/
sessionCollectionName?: string;
/**
* The MFA login attempts collection name, default 'mfa-login-attempts'.
*/
mfaLoginCollectionName?: string;
/**
* The timestamps for the users and sessions collection, default 'createdAt' and 'updatedAt'.
*/
Expand Down
3 changes: 2 additions & 1 deletion packages/server/__tests__/account-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as jwtDecode from 'jwt-decode';
import { AccountsServer } from '../src/accounts-server';
import { JwtData } from '../src/types/jwt-data';
import { ServerHooks } from '../src/utils/server-hooks';
import { LoginResult } from '@accounts/types';

const delay = (timeout: number) => new Promise(resolve => setTimeout(resolve, timeout));

Expand Down Expand Up @@ -90,7 +91,7 @@ describe('AccountsServer', () => {
}
);
const res = await accountServer.loginWithService('facebook', {}, {});
expect(res.tokens).toBeTruthy();
expect((res as LoginResult).tokens).toBeTruthy();
});
});

Expand Down
Loading