diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 000000000..7abe9801e --- /dev/null +++ b/docker-compose-dev.yml @@ -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 \ No newline at end of file diff --git a/packages/boost/src/index.ts b/packages/boost/src/index.ts index 34a255e7d..725dd46b9 100644 --- a/packages/boost/src/index.ts +++ b/packages/boost/src/index.ts @@ -68,6 +68,7 @@ export const accountsBoost = async (userOptions?: AccountsBoostOptions): Promise options.db = new DatabaseManager({ userStorage: storage, sessionStorage: storage, + mfaLoginAttemptsStorage: storage, }); const servicePackages = { diff --git a/packages/client-password/src/client-password.ts b/packages/client-password/src/client-password.ts index 607630d41..af2777e27 100644 --- a/packages/client-password/src/client-password.ts +++ b/packages/client-password/src/client-password.ts @@ -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 { @@ -25,7 +25,7 @@ export class AccountsClientPassword { /** * Log the user in with a password. */ - public async login(user: any): Promise { + public async login(user: any): Promise> { const hashedPassword = this.hashPassword(user.password); return this.client.loginWithService('password', { ...user, diff --git a/packages/client/__tests__/__snapshots__/accounts-client.ts.snap b/packages/client/__tests__/__snapshots__/accounts-client.ts.snap new file mode 100644 index 000000000..f99740cd8 --- /dev/null +++ b/packages/client/__tests__/__snapshots__/accounts-client.ts.snap @@ -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]`; diff --git a/packages/client/__tests__/accounts-client.ts b/packages/client/__tests__/accounts-client.ts index 4d55492f5..698966d3d 100644 --- a/packages/client/__tests__/accounts-client.ts +++ b/packages/client/__tests__/accounts-client.ts @@ -11,6 +11,10 @@ const loggedInResponse = { }, }; +const mfaLoginResult = { + mfaToken: 'mfaToken', +}; + const impersonateResult = { authorized: true, tokens: { accessToken: 'newAccessToken', refreshToken: 'newRefreshToken' }, @@ -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()), @@ -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', () => { diff --git a/packages/client/src/accounts-client.ts b/packages/client/src/accounts-client.ts index 09a4b7a41..de3b47a1b 100644 --- a/packages/client/src/accounts-client.ts +++ b/packages/client/src/accounts-client.ts @@ -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'; @@ -7,6 +7,7 @@ import { isTokenExpired } from './utils'; enum TokenKey { AccessToken = 'accessToken', RefreshToken = 'refreshToken', + MfaToken = 'mfaToken', OriginalAccessToken = 'originalAccessToken', OriginalRefreshToken = 'originalRefreshToken', } @@ -102,12 +103,40 @@ export class AccountsClient { public async loginWithService( service: string, credentials: { [key: string]: any } - ): Promise { + ): Promise> { 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 { + 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 diff --git a/packages/client/src/transport-interface.ts b/packages/client/src/transport-interface.ts index 9e4b43bce..106ed7594 100644 --- a/packages/client/src/transport-interface.ts +++ b/packages/client/src/transport-interface.ts @@ -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 { @@ -15,7 +21,8 @@ export interface TransportInterface { authenticateParams: { [key: string]: string | object; } - ): Promise; + ): Promise; + performMfaChallenge(challenge: string, mfaToken: string, params: any): Promise; logout(): Promise; getUser(): Promise; refreshTokens(accessToken: string, refreshToken: string): Promise; diff --git a/packages/database-manager/__tests__/__snapshots__/database-manager.ts.snap b/packages/database-manager/__tests__/__snapshots__/database-manager.ts.snap new file mode 100644 index 000000000..f059635b0 --- /dev/null +++ b/packages/database-manager/__tests__/__snapshots__/database-manager.ts.snap @@ -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"`; diff --git a/packages/database-manager/__tests__/database-manager.ts b/packages/database-manager/__tests__/database-manager.ts index 0b1196e75..8327cf312 100644 --- a/packages/database-manager/__tests__/database-manager.ts +++ b/packages/database-manager/__tests__/database-manager.ts @@ -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', () => { @@ -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({ @@ -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(); + }); }); diff --git a/packages/database-manager/package.json b/packages/database-manager/package.json index 6f86d2494..d87993698 100644 --- a/packages/database-manager/package.json +++ b/packages/database-manager/package.json @@ -14,7 +14,7 @@ "compile": "tsc", "prepublishOnly": "yarn compile", "test": "npm run test", - "testonly": "jest --coverage", + "testonly": "jest", "coverage": "jest --coverage" }, "jest": { diff --git a/packages/database-manager/src/database-manager.ts b/packages/database-manager/src/database-manager.ts index 0c5878f4e..3bc5cb0d7 100644 --- a/packages/database-manager/src/database-manager.ts +++ b/packages/database-manager/src/database-manager.ts @@ -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 { @@ -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); + } } diff --git a/packages/database-manager/src/types/configuration.ts b/packages/database-manager/src/types/configuration.ts index ef339f2e3..3f5f77632 100644 --- a/packages/database-manager/src/types/configuration.ts +++ b/packages/database-manager/src/types/configuration.ts @@ -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; } diff --git a/packages/database-mongo/__tests__/database-tests.ts b/packages/database-mongo/__tests__/database-tests.ts index 9af26cd0d..11e116558 100644 --- a/packages/database-mongo/__tests__/database-tests.ts +++ b/packages/database-mongo/__tests__/database-tests.ts @@ -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); }; diff --git a/packages/database-mongo/__tests__/index.ts b/packages/database-mongo/__tests__/index.ts index eff566fbc..acc04b476 100644 --- a/packages/database-mongo/__tests__/index.ts +++ b/packages/database-mongo/__tests__/index.ts @@ -1,5 +1,6 @@ import { randomBytes } from 'crypto'; import { ObjectID, ObjectId } from 'mongodb'; +import { MfaLoginAttempt } from '@accounts/types'; import { Mongo } from '../src'; import { DatabaseTests } from './database-tests'; @@ -834,4 +835,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); + }); + }); }); diff --git a/packages/database-mongo/package.json b/packages/database-mongo/package.json index 2369e7dea..be613525f 100644 --- a/packages/database-mongo/package.json +++ b/packages/database-mongo/package.json @@ -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" }, diff --git a/packages/database-mongo/src/mongo.ts b/packages/database-mongo/src/mongo.ts index 0a959d0a3..9cafa2805 100644 --- a/packages/database-mongo/src/mongo.ts +++ b/packages/database-mongo/src/mongo.ts @@ -3,10 +3,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'; @@ -20,6 +21,7 @@ const toMongoID = (objectId: string | ObjectID) => { const defaultOptions = { collectionName: 'users', sessionCollectionName: 'sessions', + mfaLoginCollectionName: 'mfa-login-attempts', timestamps: { createdAt: 'createdAt', updatedAt: 'updatedAt', @@ -39,6 +41,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); @@ -48,6 +52,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 { @@ -426,4 +431,39 @@ export class Mongo implements DatabaseInterface { public async setResetPassword(userId: string, email: string, newPassword: string): Promise { await this.setPassword(userId, newPassword); } + + public async createMfaLoginAttempt( + mfaToken: string, + loginToken: string, + userId: string + ): Promise { + 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 { + 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 { + await this.mfaLoginCollection.deleteOne({ _id: mfaToken }); + } } diff --git a/packages/database-mongo/src/types/index.ts b/packages/database-mongo/src/types/index.ts index 04c77c42e..33edb6fae 100644 --- a/packages/database-mongo/src/types/index.ts +++ b/packages/database-mongo/src/types/index.ts @@ -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'. */ diff --git a/packages/database-typeorm/src/entity/MfaLoginAttempt.ts b/packages/database-typeorm/src/entity/MfaLoginAttempt.ts new file mode 100644 index 000000000..0e6242019 --- /dev/null +++ b/packages/database-typeorm/src/entity/MfaLoginAttempt.ts @@ -0,0 +1,22 @@ +import { Entity, Column, PrimaryColumn, UpdateDateColumn, CreateDateColumn } from 'typeorm'; + +@Entity() +export class MfaLoginAttempt { + @PrimaryColumn() + public id!: string; + + @Column() + public mfaToken!: string; + + @Column() + public loginToken!: string; + + @Column() + public userId!: string; + + @CreateDateColumn() + public createdAt!: string; + + @UpdateDateColumn() + public updatedAt!: string; +} diff --git a/packages/database-typeorm/src/index.ts b/packages/database-typeorm/src/index.ts index 3f53f4a6e..04cb50e6e 100644 --- a/packages/database-typeorm/src/index.ts +++ b/packages/database-typeorm/src/index.ts @@ -3,8 +3,9 @@ import { User } from './entity/User'; import { UserEmail } from './entity/UserEmail'; import { UserService } from './entity/UserService'; import { UserSession } from './entity/UserSession'; +import { MfaLoginAttempt } from './entity/MfaLoginAttempt'; -const entities = [User, UserEmail, UserService, UserSession]; +const entities = [User, UserEmail, UserService, UserSession, MfaLoginAttempt]; -export { AccountsTypeorm, User, UserEmail, UserService, UserSession, entities }; +export { AccountsTypeorm, User, UserEmail, UserService, UserSession, MfaLoginAttempt, entities }; export default AccountsTypeorm; diff --git a/packages/database-typeorm/src/typeorm.ts b/packages/database-typeorm/src/typeorm.ts index a14303125..f533ae1ad 100644 --- a/packages/database-typeorm/src/typeorm.ts +++ b/packages/database-typeorm/src/typeorm.ts @@ -1,9 +1,15 @@ -import { ConnectionInformations, CreateUser, DatabaseInterface } from '@accounts/types'; +import { + ConnectionInformations, + CreateUser, + DatabaseInterface, + MfaLoginAttempt as MfaLoginAttemptType, +} from '@accounts/types'; import { Repository, getRepository } from 'typeorm'; import { User } from './entity/User'; import { UserEmail } from './entity/UserEmail'; import { UserService } from './entity/UserService'; import { UserSession } from './entity/UserSession'; +import { MfaLoginAttempt } from './entity/MfaLoginAttempt'; import { AccountsTypeormOptions } from './types'; const defaultOptions = { @@ -11,6 +17,7 @@ const defaultOptions = { userEmailEntity: UserEmail, userServiceEntity: UserService, userSessionEntity: UserSession, + mfaLoginAttemptEntity: MfaLoginAttempt, }; export class AccountsTypeorm implements DatabaseInterface { @@ -19,6 +26,7 @@ export class AccountsTypeorm implements DatabaseInterface { private emailRepository: Repository = null as any; private serviceRepository: Repository = null as any; private sessionRepository: Repository = null as any; + private mfaLoginAttemptRepository: Repository = null as any; constructor(options?: AccountsTypeormOptions) { this.options = { ...defaultOptions, ...options }; @@ -30,6 +38,7 @@ export class AccountsTypeorm implements DatabaseInterface { userEmailEntity, userServiceEntity, userSessionEntity, + mfaLoginAttemptEntity, } = this.options; const setRepositories = () => { @@ -38,11 +47,13 @@ export class AccountsTypeorm implements DatabaseInterface { this.emailRepository = connection.getRepository(userEmailEntity); this.serviceRepository = connection.getRepository(userServiceEntity); this.sessionRepository = connection.getRepository(userSessionEntity); + this.mfaLoginAttemptRepository = connection.getRepository(mfaLoginAttemptEntity); } else { this.userRepository = getRepository(userEntity, connectionName); this.emailRepository = getRepository(userEmailEntity, connectionName); this.serviceRepository = getRepository(userServiceEntity, connectionName); this.sessionRepository = getRepository(userSessionEntity, connectionName); + this.mfaLoginAttemptRepository = getRepository(mfaLoginAttemptEntity, connectionName); } }; @@ -427,4 +438,21 @@ export class AccountsTypeorm implements DatabaseInterface { } ); } + + public async createMfaLoginAttempt( + mfaToken: string, + loginToken: string, + userId: string + ): Promise { + await this.mfaLoginAttemptRepository.save({ id: mfaToken, mfaToken, loginToken, userId }); + } + public async getMfaLoginAttempt(mfaToken: string): Promise { + const res = await this.mfaLoginAttemptRepository.findOne(mfaToken); + + return res || null; + } + public async removeMfaLoginAttempt(mfaToken: string): Promise { + const mfaLoginAttempt = await this.getMfaLoginAttempt(mfaToken); + await this.mfaLoginAttemptRepository.remove(mfaLoginAttempt as MfaLoginAttempt); + } } diff --git a/packages/database-typeorm/src/types/index.ts b/packages/database-typeorm/src/types/index.ts index 493d96d04..de8c96a5d 100644 --- a/packages/database-typeorm/src/types/index.ts +++ b/packages/database-typeorm/src/types/index.ts @@ -2,6 +2,7 @@ import { User } from '../entity/User'; import { UserEmail } from '../entity/UserEmail'; import { UserService } from '../entity/UserService'; import { UserSession } from '../entity/UserSession'; +import { MfaLoginAttempt } from '../entity/MfaLoginAttempt'; import { Connection } from 'typeorm'; export interface AccountsTypeormOptions { @@ -12,4 +13,5 @@ export interface AccountsTypeormOptions { userServiceEntity?: typeof UserService; userEmailEntity?: typeof UserEmail; userSessionEntity?: typeof UserSession; + mfaLoginAttemptEntity?: typeof MfaLoginAttempt; } diff --git a/packages/e2e/__tests__/password.ts b/packages/e2e/__tests__/password.ts index 8c3903f69..dec386363 100644 --- a/packages/e2e/__tests__/password.ts +++ b/packages/e2e/__tests__/password.ts @@ -1,3 +1,4 @@ +import { LoginResult } from '@accounts/types'; import { servers } from './servers'; const user = { @@ -38,12 +39,12 @@ Object.keys(servers).forEach(key => { }); it('should login the user and get the session', async () => { - const loginResult = await server.accountsClientPassword.login({ + const loginResult = (await server.accountsClientPassword.login({ user: { email: user.email, }, password: user.password, - }); + })) as LoginResult; expect(loginResult.sessionId).toBeTruthy(); expect(loginResult.tokens.accessToken).toBeTruthy(); expect(loginResult.tokens.refreshToken).toBeTruthy(); @@ -99,12 +100,12 @@ Object.keys(servers).forEach(key => { user.password = newPassword; expect(data).toBeNull(); - const loginResult = await server.accountsClientPassword.login({ + const loginResult = (await server.accountsClientPassword.login({ user: { email: user.email, }, password: user.password, - }); + })) as LoginResult; expect(loginResult.sessionId).toBeTruthy(); expect(loginResult.tokens.accessToken).toBeTruthy(); expect(loginResult.tokens.refreshToken).toBeTruthy(); @@ -127,12 +128,12 @@ Object.keys(servers).forEach(key => { user.password = newPassword; expect(data).toBeNull(); - const loginResult = await server.accountsClientPassword.login({ + const loginResult = (await server.accountsClientPassword.login({ user: { email: user.email, }, password: user.password, - }); + })) as LoginResult; expect(loginResult.sessionId).toBeTruthy(); expect(loginResult.tokens.accessToken).toBeTruthy(); expect(loginResult.tokens.refreshToken).toBeTruthy(); diff --git a/packages/e2e/__tests__/servers/server-graphql.ts b/packages/e2e/__tests__/servers/server-graphql.ts index a97957d00..fcc89c527 100644 --- a/packages/e2e/__tests__/servers/server-graphql.ts +++ b/packages/e2e/__tests__/servers/server-graphql.ts @@ -1,13 +1,14 @@ import { AccountsClient } from '@accounts/client'; import { AccountsClientPassword } from '@accounts/client-password'; import { AccountsModule } from '@accounts/graphql-api'; -import { AccountsGraphQLClient } from '@accounts/graphql-client'; +import { AccountsGraphQLClient, IntrospectionResult } from '@accounts/graphql-client'; import { AccountsPassword } from '@accounts/password'; import { AccountsServer } from '@accounts/server'; import { DatabaseInterface, User } from '@accounts/types'; import ApolloClient from 'apollo-boost'; import { ApolloServer } from 'apollo-server'; import fetch from 'node-fetch'; +import { IntrospectionFragmentMatcher, InMemoryCache } from 'apollo-cache-inmemory'; import { ServerTestInterface } from '.'; import { DatabaseTestInterface } from '../databases'; @@ -82,7 +83,13 @@ export class ServerGraphqlTest implements ServerTestInterface { context, }); - const apolloClient = new ApolloClient({ uri: `http://localhost:${this.port}` }); + const fragmentMatcher = new IntrospectionFragmentMatcher({ + introspectionQueryResultData: IntrospectionResult, + }); + + const cache = new InMemoryCache({ fragmentMatcher }); + + const apolloClient = new ApolloClient({ uri: `http://localhost:${this.port}`, cache }); const accountsClientGraphQL = new AccountsGraphQLClient({ graphQLClient: apolloClient, diff --git a/packages/e2e/package.json b/packages/e2e/package.json index 74a7e7779..87ed92fb0 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -5,7 +5,8 @@ "main": "lib/index.js", "typings": "lib/index.d.ts", "scripts": { - "coverage": "jest --forceExit --runInBand" + "testonly": "jest --forceExit --runInBand", + "coverage": "yarn testonly" }, "jest": { "testEnvironment": "node", @@ -45,6 +46,7 @@ "@accounts/types": "^0.19.0", "@graphql-modules/core": "0.7.11", "apollo-boost": "0.4.4", + "apollo-cache-inmemory": "^1.6.3", "apollo-server": "2.9.3", "body-parser": "1.19.0", "core-js": "3.2.1", diff --git a/packages/graphql-api/__tests__/modules/accounts/resolvers/loginResult.ts b/packages/graphql-api/__tests__/modules/accounts/resolvers/loginResult.ts new file mode 100644 index 000000000..225f334e0 --- /dev/null +++ b/packages/graphql-api/__tests__/modules/accounts/resolvers/loginResult.ts @@ -0,0 +1,15 @@ +import { LoginWithServiceResult } from '../../../../src/modules/accounts/resolvers/loginResult'; + +describe('LoginWithServiceResult', () => { + it('returns LoginResult when tokens are available', () => { + const res = LoginWithServiceResult.__resolveType({ tokens: {} }); + + expect(res).toEqual('LoginResult'); + }); + + it('returns MFALoginResult when tokens are not available', () => { + const res = LoginWithServiceResult.__resolveType({ mfaToken: {} }); + + expect(res).toEqual('MFALoginResult'); + }); +}); diff --git a/packages/graphql-api/__tests__/modules/accounts/resolvers/mutation.ts b/packages/graphql-api/__tests__/modules/accounts/resolvers/mutation.ts index c1f1d2d03..982de7e2a 100644 --- a/packages/graphql-api/__tests__/modules/accounts/resolvers/mutation.ts +++ b/packages/graphql-api/__tests__/modules/accounts/resolvers/mutation.ts @@ -4,6 +4,7 @@ import { Mutation } from '../../../../src/modules/accounts/resolvers/mutation'; describe('accounts resolvers mutation', () => { const accountsServerMock = { options: {}, + performMfaChallenge: jest.fn(), loginWithService: jest.fn(), authenticateWithService: jest.fn(), impersonate: jest.fn(), @@ -54,6 +55,23 @@ describe('accounts resolvers mutation', () => { }); }); + describe('performMfaChallenge', () => { + const challenge = 'sms'; + const mfaToken = 'mfa-token'; + const params = {}; + + it('should call performMfaChallenge', async () => { + await Mutation.performMfaChallenge!( + {}, + { challenge, mfaToken, params } as any, + { injector, ip, userAgent } as any, + {} as any + ); + expect(injector.get).toBeCalledWith(AccountsServer); + expect(accountsServerMock.performMfaChallenge).toBeCalledWith(challenge, mfaToken, params); + }); + }); + describe('impersonate', () => { const accessToken = 'accessTokenTest'; const username = 'usernameTest'; diff --git a/packages/graphql-api/src/models.ts b/packages/graphql-api/src/models.ts index 669e7670c..823d89ac1 100644 --- a/packages/graphql-api/src/models.ts +++ b/packages/graphql-api/src/models.ts @@ -114,9 +114,11 @@ export interface Mutation { logout?: Maybe; - authenticate?: Maybe; + authenticate?: Maybe; verifyAuthentication?: Maybe; + + performMfaChallenge?: Maybe; } export interface LoginResult { @@ -139,6 +141,12 @@ export interface ImpersonateReturn { user?: Maybe; } +export interface MfaLoginResult { + mfaToken?: Maybe; + + challenges?: Maybe<(Maybe)[]>; +} + // ==================================================== // Arguments // ==================================================== @@ -193,6 +201,19 @@ export interface VerifyAuthenticationMutationArgs { params: AuthenticateParamsInput; } +export interface PerformMfaChallengeMutationArgs { + challenge: string; + + mfaToken: string; + + params: AuthenticateParamsInput; +} + +// ==================================================== +// Unions +// ==================================================== + +export type LoginWithServiceResult = LoginResult | MfaLoginResult; import { GraphQLResolveInfo } from 'graphql'; @@ -386,9 +407,11 @@ export interface MutationResolvers { logout?: MutationLogoutResolver, TypeParent, TContext>; - authenticate?: MutationAuthenticateResolver, TypeParent, TContext>; + authenticate?: MutationAuthenticateResolver, TypeParent, TContext>; verifyAuthentication?: MutationVerifyAuthenticationResolver, TypeParent, TContext>; + + performMfaChallenge?: MutationPerformMfaChallengeResolver, TypeParent, TContext>; } export type MutationCreateUserResolver, Parent = {}, TContext = {}> = Resolver< @@ -500,7 +523,7 @@ export type MutationLogoutResolver, Parent = {}, TContext = { TContext >; export type MutationAuthenticateResolver< - R = Maybe, + R = Maybe, Parent = {}, TContext = {} > = Resolver; @@ -521,6 +544,19 @@ export interface MutationVerifyAuthenticationArgs { params: AuthenticateParamsInput; } +export type MutationPerformMfaChallengeResolver< + R = Maybe, + Parent = {}, + TContext = {} +> = Resolver; +export interface MutationPerformMfaChallengeArgs { + challenge: string; + + mfaToken: string; + + params: AuthenticateParamsInput; +} + export interface LoginResultResolvers { sessionId?: LoginResultSessionIdResolver, TypeParent, TContext>; @@ -579,6 +615,32 @@ export type ImpersonateReturnUserResolver< TContext = {} > = Resolver; +export interface MfaLoginResultResolvers { + mfaToken?: MfaLoginResultMfaTokenResolver, TypeParent, TContext>; + + challenges?: MfaLoginResultChallengesResolver)[]>, TypeParent, TContext>; +} + +export type MfaLoginResultMfaTokenResolver< + R = Maybe, + Parent = MfaLoginResult, + TContext = {} +> = Resolver; +export type MfaLoginResultChallengesResolver< + R = Maybe<(Maybe)[]>, + Parent = MfaLoginResult, + TContext = {} +> = Resolver; + +export interface LoginWithServiceResultResolvers { + __resolveType: LoginWithServiceResultResolveType; +} +export type LoginWithServiceResultResolveType< + R = 'LoginResult' | 'MFALoginResult', + Parent = LoginResult | MfaLoginResult, + TContext = {} +> = TypeResolveFn; + export type AuthDirectiveResolver = DirectiveResolverFn< Result, {}, @@ -621,6 +683,8 @@ export type IResolvers = { LoginResult?: LoginResultResolvers; Tokens?: TokensResolvers; ImpersonateReturn?: ImpersonateReturnResolvers; + MfaLoginResult?: MfaLoginResultResolvers; + LoginWithServiceResult?: LoginWithServiceResultResolvers; } & { [typeName: string]: never }; export type IDirectiveResolvers = { diff --git a/packages/graphql-api/src/modules/accounts/index.ts b/packages/graphql-api/src/modules/accounts/index.ts index 9c95f1306..486286530 100644 --- a/packages/graphql-api/src/modules/accounts/index.ts +++ b/packages/graphql-api/src/modules/accounts/index.ts @@ -8,7 +8,10 @@ import getSchemaDef from './schema/schema-def'; import { Query } from './resolvers/query'; import { Mutation } from './resolvers/mutation'; import { User as UserResolvers } from './resolvers/user'; -import { LoginResult as LoginResultResolvers } from './resolvers/loginResult'; +import { + LoginResult as LoginResultResolvers, + LoginWithServiceResult, +} from './resolvers/loginResult'; import { User } from '@accounts/types'; import { AccountsPasswordModule } from '../accounts-password'; import { AuthenticatedDirective } from '../../utils/authenticated-directive'; @@ -65,6 +68,7 @@ export const AccountsModule: GraphQLModule< [config.rootMutationName || 'Mutation']: Mutation, User: UserResolvers, LoginResult: LoginResultResolvers, + LoginWithServiceResult, } as any), // If necessary, import AccountsPasswordModule together with this module imports: ({ config }) => diff --git a/packages/graphql-api/src/modules/accounts/resolvers/loginResult.ts b/packages/graphql-api/src/modules/accounts/resolvers/loginResult.ts index 9d27a7062..d94f017e6 100644 --- a/packages/graphql-api/src/modules/accounts/resolvers/loginResult.ts +++ b/packages/graphql-api/src/modules/accounts/resolvers/loginResult.ts @@ -1 +1,17 @@ +import { + LoginWithServiceResultResolvers, + LoginResult as GeneratedLoginResult, + MfaLoginResult as GeneratedMfaLoginResult, +} from '../../../models'; + export const LoginResult = {}; + +export const LoginWithServiceResult: LoginWithServiceResultResolvers = { + __resolveType(obj: GeneratedLoginResult | GeneratedMfaLoginResult) { + if ((obj as GeneratedLoginResult).tokens) { + return 'LoginResult'; + } + + return 'MFALoginResult'; + }, +}; diff --git a/packages/graphql-api/src/modules/accounts/resolvers/mutation.ts b/packages/graphql-api/src/modules/accounts/resolvers/mutation.ts index edcb9c889..1590e4a29 100644 --- a/packages/graphql-api/src/modules/accounts/resolvers/mutation.ts +++ b/packages/graphql-api/src/modules/accounts/resolvers/mutation.ts @@ -14,6 +14,15 @@ export const Mutation: MutationResolvers> = }); return authenticated; }, + performMfaChallenge: async (_, args, ctx) => { + const { challenge, mfaToken, params } = args; + const { injector } = ctx; + + const loginToken = await injector + .get(AccountsServer) + .performMfaChallenge(challenge, mfaToken, params); + return loginToken; + }, verifyAuthentication: async (_, args, ctx) => { const { serviceName, params } = args; const { ip, userAgent, injector } = ctx; diff --git a/packages/graphql-api/src/modules/accounts/schema/mutation.ts b/packages/graphql-api/src/modules/accounts/schema/mutation.ts index ce7a8dde8..00ec295d2 100644 --- a/packages/graphql-api/src/modules/accounts/schema/mutation.ts +++ b/packages/graphql-api/src/modules/accounts/schema/mutation.ts @@ -9,7 +9,9 @@ export default (config: AccountsModuleConfig) => gql` # Example: Login with password # authenticate(serviceName: "password", params: {password: "", user: {email: ""}}) - authenticate(serviceName: String!, params: AuthenticateParamsInput!): LoginResult + authenticate(serviceName: String!, params: AuthenticateParamsInput!): LoginWithServiceResult verifyAuthentication(serviceName: String!, params: AuthenticateParamsInput!): Boolean + + performMfaChallenge(challenge: String!, mfaToken: String!, params: AuthenticateParamsInput!): String } `; diff --git a/packages/graphql-api/src/modules/accounts/schema/types.ts b/packages/graphql-api/src/modules/accounts/schema/types.ts index 74354c5fa..48c0aa7cf 100644 --- a/packages/graphql-api/src/modules/accounts/schema/types.ts +++ b/packages/graphql-api/src/modules/accounts/schema/types.ts @@ -14,6 +14,13 @@ export default ({ userAsInterface }: AccountsModuleConfig) => gql` tokens: Tokens } + type MFALoginResult { + mfaToken: String + challenges: [String] + } + + union LoginWithServiceResult = LoginResult | MFALoginResult + type ImpersonateReturn { authorized: Boolean tokens: Tokens diff --git a/packages/graphql-client/codegen.yml b/packages/graphql-client/codegen.yml new file mode 100644 index 000000000..bc59cb46a --- /dev/null +++ b/packages/graphql-client/codegen.yml @@ -0,0 +1,8 @@ +overwrite: true +schema: ./dev/schema.ts +require: ts-node/register/transpile-only +generates: + ./src/introspection-result.ts: + plugins: + - add: /* tslint:disable */ + - fragment-matcher diff --git a/packages/graphql-client/dev/schema.ts b/packages/graphql-client/dev/schema.ts new file mode 100644 index 000000000..c83d65399 --- /dev/null +++ b/packages/graphql-client/dev/schema.ts @@ -0,0 +1,4 @@ +// tslint:disable-next-line: no-submodule-imports +import typedefs from '@accounts/graphql-api/lib/schema'; + +export default typedefs; diff --git a/packages/graphql-client/package.json b/packages/graphql-client/package.json index cd45cac38..cc574bf4c 100644 --- a/packages/graphql-client/package.json +++ b/packages/graphql-client/package.json @@ -7,7 +7,8 @@ "scripts": { "clean": "rimraf lib", "start": "tsc --watch", - "precompile": "yarn clean", + "precompile": "yarn clean && yarn gen:types", + "gen:types": "graphql-codegen --config codegen.yml", "compile": "tsc", "prepublishOnly": "yarn compile" }, @@ -25,10 +26,16 @@ }, "homepage": "https://github.com/js-accounts/graphql#readme", "devDependencies": { + "@accounts/graphql-api": "^0.19.0", + "@graphql-codegen/add": "^1.7.0", + "@graphql-codegen/cli": "^1.7.0", + "@graphql-codegen/fragment-matcher": "^1.7.0", "@types/jest": "24.0.18", + "graphql": "^14.5.4", "jest": "24.9.0", "lodash": "4.17.15", - "nock": "10.0.6" + "nock": "10.0.6", + "ts-node": "8.3.0" }, "dependencies": { "@accounts/client": "^0.19.0", diff --git a/packages/graphql-client/src/graphql-client.ts b/packages/graphql-client/src/graphql-client.ts index 5e987fd9f..16903a210 100644 --- a/packages/graphql-client/src/graphql-client.ts +++ b/packages/graphql-client/src/graphql-client.ts @@ -1,9 +1,16 @@ import { TransportInterface, AccountsClient } from '@accounts/client'; -import { CreateUser, LoginResult, ImpersonationResult, User } from '@accounts/types'; +import { + CreateUser, + LoginResult, + ImpersonationResult, + User, + MFALoginResult, +} from '@accounts/types'; import { createUserMutation } from './graphql/create-user.mutation'; import { loginWithServiceMutation } from './graphql/login-with-service.mutation'; import { authenticateWithServiceMutation } from './graphql/authenticate-with-service.mutation'; import { logoutMutation } from './graphql/logout.mutation'; +import { performMfaChallengeMutation } from './graphql/perform-mfa-challenge.mutation'; import { refreshTokensMutation } from './graphql/refresh-tokens.mutation'; import { verifyEmailMutation } from './graphql/verify-email.mutation'; import { sendResetPasswordEmailMutation } from './graphql/send-reset-password-email.mutation'; @@ -76,13 +83,25 @@ export default class GraphQLClient implements TransportInterface { public async loginWithService( service: string, authenticateParams: AuthenticateParams - ): Promise { + ): Promise { return this.mutate(loginWithServiceMutation, 'authenticate', { serviceName: service, params: authenticateParams, }); } + public async performMfaChallenge( + challenge: string, + mfaToken: string, + params: AuthenticateParams + ): Promise { + return this.mutate(performMfaChallengeMutation, 'performMfaChallenge', { + challenge, + mfaToken, + params, + }); + } + public async getUser(): Promise { return this.query(getUserQuery(this.options.userFieldsFragment), 'getUser'); } diff --git a/packages/graphql-client/src/graphql/login-with-service.mutation.ts b/packages/graphql-client/src/graphql/login-with-service.mutation.ts index ee7227a50..3e39782e0 100644 --- a/packages/graphql-client/src/graphql/login-with-service.mutation.ts +++ b/packages/graphql-client/src/graphql/login-with-service.mutation.ts @@ -3,10 +3,17 @@ import gql from 'graphql-tag'; export const loginWithServiceMutation = gql` mutation($serviceName: String!, $params: AuthenticateParamsInput!) { authenticate(serviceName: $serviceName, params: $params) { - sessionId - tokens { - refreshToken - accessToken + __typename + ... on LoginResult { + sessionId + tokens { + refreshToken + accessToken + } + } + ... on MFALoginResult { + mfaToken + challenges } } } diff --git a/packages/graphql-client/src/graphql/perform-mfa-challenge.mutation.ts b/packages/graphql-client/src/graphql/perform-mfa-challenge.mutation.ts new file mode 100644 index 000000000..ba43761eb --- /dev/null +++ b/packages/graphql-client/src/graphql/perform-mfa-challenge.mutation.ts @@ -0,0 +1,7 @@ +import gql from 'graphql-tag'; + +export const performMfaChallengeMutation = gql` + mutation($challenge: String!, $mfaToken: String!, $params: AuthenticateParamsInput!) { + performMfaChallenge(challenge: $challenge, mfaToken: $mfaToken, params: $params) + } +`; diff --git a/packages/graphql-client/src/index.ts b/packages/graphql-client/src/index.ts index 933ac76a9..0e298a67c 100644 --- a/packages/graphql-client/src/index.ts +++ b/packages/graphql-client/src/index.ts @@ -1,3 +1,4 @@ export * from './graphql-client'; export { default } from './graphql-client'; export { default as AccountsGraphQLClient } from './graphql-client'; +export { IntrospectionResultData, default as IntrospectionResult } from './introspection-result'; diff --git a/packages/graphql-client/src/introspection-result.ts b/packages/graphql-client/src/introspection-result.ts new file mode 100644 index 000000000..66771b833 --- /dev/null +++ b/packages/graphql-client/src/introspection-result.ts @@ -0,0 +1,34 @@ +/* tslint:disable */ + +export interface IntrospectionResultData { + __schema: { + types: { + kind: string; + name: string; + possibleTypes: { + name: string; + }[]; + }[]; + }; +} + +const result: IntrospectionResultData = { + __schema: { + types: [ + { + kind: 'UNION', + name: 'LoginWithServiceResult', + possibleTypes: [ + { + name: 'LoginResult', + }, + { + name: 'MFALoginResult', + }, + ], + }, + ], + }, +}; + +export default result; diff --git a/packages/graphql-client/tsconfig.json b/packages/graphql-client/tsconfig.json index 4ec56d0f8..cbb5e9862 100644 --- a/packages/graphql-client/tsconfig.json +++ b/packages/graphql-client/tsconfig.json @@ -5,5 +5,5 @@ "outDir": "./lib", "importHelpers": true }, - "exclude": ["node_modules", "__tests__", "lib"] + "exclude": ["node_modules", "__tests__", "lib", "dev"] } diff --git a/packages/password/package.json b/packages/password/package.json index 93eed209a..60d3ee9e0 100644 --- a/packages/password/package.json +++ b/packages/password/package.json @@ -10,7 +10,7 @@ "precompile": "yarn clean", "compile": "tsc", "prepublishOnly": "yarn compile", - "testonly": "jest --coverage", + "testonly": "jest", "coverage": "jest --coverage" }, "jest": { diff --git a/packages/rest-client/__tests__/rest-client.ts b/packages/rest-client/__tests__/rest-client.ts index 765a9fa4d..7f40eeeb4 100644 --- a/packages/rest-client/__tests__/rest-client.ts +++ b/packages/rest-client/__tests__/rest-client.ts @@ -75,6 +75,18 @@ describe('RestClient', () => { }); }); + describe('performMfaChallenge', () => { + it('should call fetch with performMfaChallenge path', async () => { + await restClient.performMfaChallenge('sms', 'mfa-token', {}); + expect((window.fetch as jest.Mock).mock.calls[0][0]).toBe( + 'http://localhost:3000/accounts/performMfaChallenge' + ); + expect((window.fetch as jest.Mock).mock.calls[0][1].body).toBe( + '{"challenge":"sms","mfaToken":"mfa-token","params":{}}' + ); + }); + }); + describe('loginWithService', () => { it('should call fetch with authenticate path', async () => { await restClient.loginWithService('password', { diff --git a/packages/rest-client/src/rest-client.ts b/packages/rest-client/src/rest-client.ts index 004bf30d1..ebebb3170 100644 --- a/packages/rest-client/src/rest-client.ts +++ b/packages/rest-client/src/rest-client.ts @@ -82,6 +82,19 @@ export class RestClient implements TransportInterface { return this.fetch(`${provider}/authenticate`, args, customHeaders); } + public async performMfaChallenge( + challenge: string, + mfaToken: string, + params: any, + customHeaders?: object + ): Promise { + const args = { + method: 'POST', + body: JSON.stringify({ challenge, mfaToken, params }), + }; + return this.fetch(`performMfaChallenge`, args, customHeaders); + } + public impersonate( accessToken: string, impersonated: LoginUserIdentity, diff --git a/packages/rest-express/__tests__/endpoints/perform-mfa-challenge.ts b/packages/rest-express/__tests__/endpoints/perform-mfa-challenge.ts new file mode 100644 index 000000000..b4e1ebdad --- /dev/null +++ b/packages/rest-express/__tests__/endpoints/perform-mfa-challenge.ts @@ -0,0 +1,71 @@ +import { performMfaChallenge } from '../../src/endpoints/perform-mfa-challenge'; + +const res: any = { + json: jest.fn(), + status: jest.fn(() => res), +}; + +describe('performMfaChallenge', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls performMfaChallenge and returns the login token in json format', async () => { + const loginToken = 'login-token'; + const accountsServer = { + performMfaChallenge: jest.fn(() => loginToken), + }; + const middleware = performMfaChallenge(accountsServer as any); + + const req = { + body: { + challenge: 'sms', + mfaToken: 'mfa-token', + params: { + phone: '1122', + }, + }, + headers: {}, + }; + const reqCopy = { ...req }; + + await middleware(req as any, res); + + expect(req).toEqual(reqCopy); + expect(accountsServer.performMfaChallenge).toBeCalledWith('sms', 'mfa-token', { + phone: '1122', + }); + expect(res.json).toBeCalledWith(loginToken); + expect(res.status).not.toBeCalled(); + }); + + it('Sends error if it was thrown on performMfaChallenge', async () => { + const error = { message: 'Could not performMfaChallenge' }; + const accountsServer = { + performMfaChallenge: jest.fn(() => { + throw error; + }), + }; + const middleware = performMfaChallenge(accountsServer as any); + const req = { + body: { + challenge: 'sms', + mfaToken: 'mfa-token', + params: { + phone: '1122', + }, + }, + headers: {}, + }; + const reqCopy = { ...req }; + + await middleware(req as any, res); + + expect(req).toEqual(reqCopy); + expect(accountsServer.performMfaChallenge).toBeCalledWith('sms', 'mfa-token', { + phone: '1122', + }); + expect(res.status).toBeCalledWith(400); + expect(res.json).toBeCalledWith(error); + }); +}); diff --git a/packages/rest-express/__tests__/express-middleware.ts b/packages/rest-express/__tests__/express-middleware.ts index 06c112d65..31b0a3413 100644 --- a/packages/rest-express/__tests__/express-middleware.ts +++ b/packages/rest-express/__tests__/express-middleware.ts @@ -31,6 +31,7 @@ describe('express middleware', () => { expect((router.post as jest.Mock).mock.calls[3][0]).toBe('test/logout'); expect((router.post as jest.Mock).mock.calls[4][0]).toBe('test/:service/verifyAuthentication'); expect((router.post as jest.Mock).mock.calls[5][0]).toBe('test/:service/authenticate'); + expect((router.post as jest.Mock).mock.calls[6][0]).toBe('test/performMfaChallenge'); expect((router.get as jest.Mock).mock.calls[0][0]).toBe('test/user'); }); @@ -50,11 +51,14 @@ describe('express middleware', () => { expect((router.post as jest.Mock).mock.calls[3][0]).toBe('test/logout'); expect((router.post as jest.Mock).mock.calls[4][0]).toBe('test/:service/verifyAuthentication'); expect((router.post as jest.Mock).mock.calls[5][0]).toBe('test/:service/authenticate'); - expect((router.post as jest.Mock).mock.calls[6][0]).toBe('test/password/register'); - expect((router.post as jest.Mock).mock.calls[7][0]).toBe('test/password/verifyEmail'); - expect((router.post as jest.Mock).mock.calls[8][0]).toBe('test/password/resetPassword'); - expect((router.post as jest.Mock).mock.calls[9][0]).toBe('test/password/sendVerificationEmail'); + expect((router.post as jest.Mock).mock.calls[6][0]).toBe('test/performMfaChallenge'); + expect((router.post as jest.Mock).mock.calls[7][0]).toBe('test/password/register'); + expect((router.post as jest.Mock).mock.calls[8][0]).toBe('test/password/verifyEmail'); + expect((router.post as jest.Mock).mock.calls[9][0]).toBe('test/password/resetPassword'); expect((router.post as jest.Mock).mock.calls[10][0]).toBe( + 'test/password/sendVerificationEmail' + ); + expect((router.post as jest.Mock).mock.calls[11][0]).toBe( 'test/password/sendResetPasswordEmail' ); @@ -76,6 +80,7 @@ describe('express middleware', () => { expect((router.post as jest.Mock).mock.calls[3][0]).toBe('test/logout'); expect((router.post as jest.Mock).mock.calls[4][0]).toBe('test/:service/verifyAuthentication'); expect((router.post as jest.Mock).mock.calls[5][0]).toBe('test/:service/authenticate'); + expect((router.post as jest.Mock).mock.calls[6][0]).toBe('test/performMfaChallenge'); expect((router.get as jest.Mock).mock.calls[0][0]).toBe('test/user'); expect((router.get as jest.Mock).mock.calls[1][0]).toBe('test/oauth/:provider/callback'); @@ -94,6 +99,7 @@ describe('express middleware', () => { expect((router.post as jest.Mock).mock.calls[3][0]).toBe('/logout'); expect((router.post as jest.Mock).mock.calls[4][0]).toBe('/:service/verifyAuthentication'); expect((router.post as jest.Mock).mock.calls[5][0]).toBe('/:service/authenticate'); + expect((router.post as jest.Mock).mock.calls[6][0]).toBe('/performMfaChallenge'); expect((router.get as jest.Mock).mock.calls[0][0]).toBe('/user'); }); diff --git a/packages/rest-express/src/endpoints/oauth/provider-callback.ts b/packages/rest-express/src/endpoints/oauth/provider-callback.ts index 2002a5789..de548a3b6 100644 --- a/packages/rest-express/src/endpoints/oauth/provider-callback.ts +++ b/packages/rest-express/src/endpoints/oauth/provider-callback.ts @@ -28,11 +28,11 @@ export const providerCallback = ( ); if (options && options.onOAuthSuccess) { - options.onOAuthSuccess(req, res, loggedInUser); + options.onOAuthSuccess(req, res, loggedInUser as any); } if (options && options.transformOAuthResponse) { - res.json(options.transformOAuthResponse(loggedInUser)); + res.json(options.transformOAuthResponse(loggedInUser as any)); } else { res.json(loggedInUser); } diff --git a/packages/rest-express/src/endpoints/perform-mfa-challenge.ts b/packages/rest-express/src/endpoints/perform-mfa-challenge.ts new file mode 100644 index 000000000..c291a3c1b --- /dev/null +++ b/packages/rest-express/src/endpoints/perform-mfa-challenge.ts @@ -0,0 +1,16 @@ +import * as express from 'express'; +import { AccountsServer } from '@accounts/server'; +import { sendError } from '../utils/send-error'; + +export const performMfaChallenge = (accountsServer: AccountsServer) => async ( + req: express.Request, + res: express.Response +) => { + try { + const { challenge, mfaToken, params } = req.body; + const loginToken = await accountsServer.performMfaChallenge(challenge, mfaToken, params); + res.json(loginToken); + } catch (err) { + sendError(res, err); + } +}; diff --git a/packages/rest-express/src/express-middleware.ts b/packages/rest-express/src/express-middleware.ts index 6a70a26f2..250511bf7 100644 --- a/packages/rest-express/src/express-middleware.ts +++ b/packages/rest-express/src/express-middleware.ts @@ -8,6 +8,7 @@ import { getUser } from './endpoints/get-user'; import { impersonate } from './endpoints/impersonate'; import { logout } from './endpoints/logout'; import { serviceAuthenticate } from './endpoints/service-authenticate'; +import { performMfaChallenge } from './endpoints/perform-mfa-challenge'; import { serviceVerifyAuthentication } from './endpoints/verify-authentication'; import { registerPassword } from './endpoints/password/register'; import { twoFactorSecret, twoFactorSet, twoFactorUnset } from './endpoints/password/two-factor'; @@ -46,6 +47,8 @@ const accountsExpress = ( router.post(`${path}/:service/authenticate`, serviceAuthenticate(accountsServer)); + router.post(`${path}/performMfaChallenge`, performMfaChallenge(accountsServer)); + const services = accountsServer.getServices(); // @accounts/password diff --git a/packages/server/__tests__/__snapshots__/account-server.ts.snap b/packages/server/__tests__/__snapshots__/account-server.ts.snap index 8b4e51d25..73e575aa7 100644 --- a/packages/server/__tests__/__snapshots__/account-server.ts.snap +++ b/packages/server/__tests__/__snapshots__/account-server.ts.snap @@ -8,8 +8,20 @@ exports[`AccountsServer authenticateWithService throws when user not found 1`] = exports[`AccountsServer config throws on invalid db 1`] = `"A database driver is required"`; +exports[`AccountsServer loginWithService throws error when MFA login token is invalid 1`] = `[Error: Service mfa was not able to authenticate user]`; + exports[`AccountsServer loginWithService throws on invalid service 1`] = `"No service with the name facebook was registered."`; exports[`AccountsServer loginWithService throws when user is deactivated 1`] = `"Your account has been deactivated"`; exports[`AccountsServer loginWithService throws when user not found 1`] = `"Service facebook was not able to authenticate user"`; + +exports[`AccountsServer performMfaChallenge throws error the challenge is failing #2 1`] = `[Error: Service sms was not able to authenticate user]`; + +exports[`AccountsServer performMfaChallenge throws error the challenge is failing 1`] = `[Error: Service sms was not able to authenticate user]`; + +exports[`AccountsServer performMfaChallenge throws error when mfa is not enabled for user #2 1`] = `[Error: Performing the mfa challenge is not available]`; + +exports[`AccountsServer performMfaChallenge throws error when mfa is not enabled for user 1`] = `[Error: Performing the mfa challenge is not available]`; + +exports[`AccountsServer performMfaChallenge throws error when mfaToken is wrong 1`] = `[Error: Performing the mfa challenge is not available]`; diff --git a/packages/server/__tests__/account-server.ts b/packages/server/__tests__/account-server.ts index 4671221c1..8dc878b86 100644 --- a/packages/server/__tests__/account-server.ts +++ b/packages/server/__tests__/account-server.ts @@ -1,7 +1,10 @@ import jwtDecode from 'jwt-decode'; +import { LoginResult, MFALoginResult } from '@accounts/types'; + import { AccountsServer } from '../src/accounts-server'; import { JwtData } from '../src/types/jwt-data'; import { ServerHooks } from '../src/utils/server-hooks'; +import * as tokens from '../src/utils/tokens'; const delay = (timeout: number) => new Promise(resolve => setTimeout(resolve, timeout)); @@ -89,7 +92,82 @@ describe('AccountsServer', () => { } ); const res = await accountServer.loginWithService('facebook', {}, {}); - expect(res.tokens).toBeTruthy(); + expect((res as LoginResult).tokens).toBeTruthy(); + }); + + it('should create an MFA login process when enabled', async () => { + const loginToken = 'login-token'; + const mfaToken = '936cef147c65abc808defb3598daa752851176e3505b5879620b4d4200f00462'; // hash of loginToken + jest.spyOn(tokens, 'generateRandomToken').mockImplementation(() => loginToken); + jest.spyOn(tokens, 'hashToken'); + + const authenticate = jest.fn(() => Promise.resolve({ id: 'userId', mfaChallenges: ['sms'] })); + const createMfaLoginAttempt = jest.fn(() => Promise.resolve()); + const service: any = { authenticate, setStore: jest.fn() }; + const accountServer = new AccountsServer( + { + db: { createMfaLoginAttempt } as any, + tokenSecret: 'secret1', + }, + { + facebook: service, + } + ); + const res = (await accountServer.loginWithService('facebook', {}, {})) as MFALoginResult; + + expect(res.challenges).toEqual(['sms']); + expect(res.mfaToken).toEqual(mfaToken); + expect(createMfaLoginAttempt).toHaveBeenCalledWith(mfaToken, loginToken, 'userId'); + expect(tokens.hashToken).toHaveBeenCalledWith(loginToken); + }); + + it('should finish MFA login process', async () => { + const loginToken = 'login-token'; + const userId = 'user-id'; + const mfaToken = 'mfa-token'; + + const getMfaLoginAttempt = jest.fn(() => Promise.resolve({ loginToken, userId })); + const removeMfaLoginAttempt = jest.fn(() => Promise.resolve()); + const findUserById = jest.fn(() => Promise.resolve({ id: userId })); + const createSession = jest.fn(() => Promise.resolve('sessionId')); + + const accountServer = new AccountsServer( + { + db: { getMfaLoginAttempt, findUserById, removeMfaLoginAttempt, createSession } as any, + tokenSecret: 'secret1', + }, + {} + ); + const res = (await accountServer.loginWithService( + 'mfa', + { loginToken, mfaToken }, + {} + )) as LoginResult; + + expect((res as LoginResult).tokens).toBeTruthy(); + expect(getMfaLoginAttempt).toHaveBeenCalledWith(mfaToken); + expect(removeMfaLoginAttempt).toHaveBeenCalledWith(mfaToken); + expect(findUserById).toHaveBeenCalledWith(userId); + }); + + it('throws error when MFA login token is invalid', async () => { + const loginToken = 'login-token'; + const wrongLoginToken = 'wrong-login-token'; + const userId = 'user-id'; + const mfaToken = 'mfa-token'; + + const getMfaLoginAttempt = jest.fn(() => Promise.resolve({ loginToken, userId })); + + const accountServer = new AccountsServer( + { + db: { getMfaLoginAttempt } as any, + tokenSecret: 'secret1', + }, + {} + ); + await expect( + accountServer.loginWithService('mfa', { loginToken: wrongLoginToken, mfaToken }, {}) + ).rejects.toMatchSnapshot(); }); }); @@ -177,6 +255,131 @@ describe('AccountsServer', () => { }); }); + describe('performMfaChallenge', () => { + it('throws error when mfaToken is wrong', async () => { + const mfaToken = 'mfa-token'; + + const getMfaLoginAttempt = jest.fn(() => Promise.resolve(null)); + + const accountServer = new AccountsServer( + { + db: { getMfaLoginAttempt } as any, + tokenSecret: 'secret1', + }, + {} + ); + await expect( + accountServer.performMfaChallenge('sms', mfaToken, {}) + ).rejects.toMatchSnapshot(); + }); + + it('throws error when mfa is not enabled for user', async () => { + const userId = 'userId'; + const mfaToken = 'mfa-token'; + + const getMfaLoginAttempt = jest.fn(() => Promise.resolve({ id: userId })); + const findUserById = jest.fn(() => Promise.resolve({ id: userId })); + + const accountServer = new AccountsServer( + { + db: { getMfaLoginAttempt, findUserById } as any, + tokenSecret: 'secret1', + }, + {} + ); + await expect( + accountServer.performMfaChallenge('sms', mfaToken, {}) + ).rejects.toMatchSnapshot(); + }); + + it('throws error when mfa is not enabled for user #2', async () => { + const userId = 'userId'; + const mfaToken = 'mfa-token'; + + const getMfaLoginAttempt = jest.fn(() => Promise.resolve({ id: userId })); + const findUserById = jest.fn(() => Promise.resolve({ id: userId, mfaChallenges: [] })); + + const accountServer = new AccountsServer( + { + db: { getMfaLoginAttempt, findUserById } as any, + tokenSecret: 'secret1', + }, + {} + ); + await expect( + accountServer.performMfaChallenge('sms', mfaToken, {}) + ).rejects.toMatchSnapshot(); + }); + + it('throws error the challenge is failing', async () => { + const userId = 'userId'; + const mfaToken = 'mfa-token'; + const authenticate = jest.fn(() => Promise.resolve()); + const getMfaLoginAttempt = jest.fn(() => Promise.resolve({ id: userId })); + const findUserById = jest.fn(() => Promise.resolve({ id: userId, mfaChallenges: ['sms'] })); + const service: any = { authenticate, setStore: jest.fn() }; + + const accountServer = new AccountsServer( + { + db: { getMfaLoginAttempt, findUserById } as any, + tokenSecret: 'secret1', + }, + { + sms: service, + } + ); + await expect( + accountServer.performMfaChallenge('sms', mfaToken, {}) + ).rejects.toMatchSnapshot(); + }); + + it('throws error the challenge is failing #2', async () => { + const userId = 'userId'; + const mfaToken = 'mfa-token'; + const authenticate = jest.fn(() => Promise.resolve({ id: 'userId2' })); + const getMfaLoginAttempt = jest.fn(() => Promise.resolve({ id: userId })); + const findUserById = jest.fn(() => Promise.resolve({ id: userId, mfaChallenges: ['sms'] })); + const service: any = { authenticate, setStore: jest.fn() }; + + const accountServer = new AccountsServer( + { + db: { getMfaLoginAttempt, findUserById } as any, + tokenSecret: 'secret1', + }, + { + sms: service, + } + ); + await expect( + accountServer.performMfaChallenge('sms', mfaToken, {}) + ).rejects.toMatchSnapshot(); + }); + + it('should return the loginToken upon success', async () => { + const userId = 'userId'; + const loginToken = 'login-token'; + const mfaToken = 'mfa-token'; + const authenticate = jest.fn(() => Promise.resolve({ id: userId })); + const findUserById = jest.fn(() => Promise.resolve({ id: userId, mfaChallenges: ['sms'] })); + + const getMfaLoginAttempt = jest.fn(() => Promise.resolve({ id: userId, loginToken })); + const service: any = { authenticate, setStore: jest.fn() }; + + const accountServer = new AccountsServer( + { + db: { getMfaLoginAttempt, findUserById } as any, + tokenSecret: 'secret1', + }, + { + sms: service, + } + ); + const res = await accountServer.performMfaChallenge('sms', mfaToken, {}); + + expect(res).toEqual(loginToken); + }); + }); + describe('logout', () => { it('invalidates session', async () => { const invalidateSession = jest.fn(() => Promise.resolve()); diff --git a/packages/server/src/accounts-server.ts b/packages/server/src/accounts-server.ts index 77bf0a250..00aaff0ba 100644 --- a/packages/server/src/accounts-server.ts +++ b/packages/server/src/accounts-server.ts @@ -4,6 +4,7 @@ import Emittery from 'emittery'; import { User, LoginResult, + MFALoginResult, Tokens, Session, ImpersonationResult, @@ -11,9 +12,15 @@ import { DatabaseInterface, AuthenticationService, ConnectionInformations, + MfaLoginAttempt, } from '@accounts/types'; -import { generateAccessToken, generateRefreshToken, generateRandomToken } from './utils/tokens'; +import { + generateAccessToken, + generateRefreshToken, + generateRandomToken, + hashToken, +} from './utils/tokens'; import { emailTemplates, sendMail } from './utils/email'; import { ServerHooks } from './utils/server-hooks'; @@ -127,7 +134,7 @@ Please change it with a strong random token.`); serviceName: string, params: any, infos: ConnectionInformations - ): Promise { + ): Promise { const hooksInfo: any = { // The service name, such as “password” or “twitter”. service: serviceName, @@ -137,11 +144,22 @@ Please change it with a strong random token.`); params, }; try { - if (!this.services[serviceName]) { + if (serviceName !== 'mfa' && !this.services[serviceName]) { throw new Error(`No service with the name ${serviceName} was registered.`); } - const user: User | null = await this.services[serviceName].authenticate(params); + let user: User | null; + + if (serviceName !== 'mfa') { + user = await this.services[serviceName].authenticate(params); + } else { + user = await this.getUserFromMfaToken({ skipValidation: false, ...params }); + + if (user) { + await this.db.removeMfaLoginAttempt(params.mfaToken); + } + } + hooksInfo.user = user; if (!user) { throw new Error(`Service ${serviceName} was not able to authenticate user`); @@ -152,6 +170,11 @@ Please change it with a strong random token.`); // Let the user validate the login attempt await this.hooks.emitSerial(ServerHooks.ValidateLogin, hooksInfo); + + if (serviceName !== 'mfa' && user.mfaChallenges && user.mfaChallenges.length > 0) { + return this.createMfaLoginProcess(user); + } + const loginResult = await this.loginWithUser(user, infos); this.hooks.emit(ServerHooks.LoginSuccess, hooksInfo); return loginResult; @@ -161,6 +184,32 @@ Please change it with a strong random token.`); } } + public async performMfaChallenge( + challenge: string, + mfaToken: string, + params: any + ): Promise { + const userFromMfa = await this.getUserFromMfaToken({ skipValidation: true, mfaToken }); + + if ( + !userFromMfa || + !userFromMfa.mfaChallenges || + userFromMfa.mfaChallenges.indexOf(challenge) === -1 + ) { + throw new Error('Performing the mfa challenge is not available'); + } + + const userFromChallenge: User | null = await this.services[challenge].authenticate(params); + + if (!userFromChallenge || userFromMfa.id !== userFromChallenge.id) { + throw new Error(`Service ${challenge} was not able to authenticate user`); + } + + const mfaLoginAttempt = (await this.db.getMfaLoginAttempt(mfaToken)) as MfaLoginAttempt; + + return mfaLoginAttempt.loginToken; + } + /** * @description Server use only. * This method creates a session without authenticating any user identity. @@ -561,6 +610,40 @@ Please change it with a strong random token.`); ? this.options.tokenCreator.createToken(user) : generateRandomToken(); } + + private async getUserFromMfaToken({ + mfaToken, + loginToken, + skipValidation = false, + }: { + mfaToken: string; + loginToken?: string; + skipValidation: boolean; + }): Promise { + const loginAttempt = await this.db.getMfaLoginAttempt(mfaToken); + + if (!loginAttempt) { + return null; + } + + if (!skipValidation && loginAttempt.loginToken !== loginToken) { + return null; + } + + return this.db.findUserById(loginAttempt.userId); + } + + private async createMfaLoginProcess(user: User): Promise { + const loginToken = generateRandomToken(); + const mfaToken = hashToken(loginToken); + + await this.db.createMfaLoginAttempt(mfaToken, loginToken, user.id); + + return { + mfaToken, + challenges: user.mfaChallenges as string[], + }; + } } export default AccountsServer; diff --git a/packages/server/src/utils/tokens.ts b/packages/server/src/utils/tokens.ts index 26cf1e75f..57b681c64 100644 --- a/packages/server/src/utils/tokens.ts +++ b/packages/server/src/utils/tokens.ts @@ -1,11 +1,18 @@ import * as jwt from 'jsonwebtoken'; -import { randomBytes } from 'crypto'; +import { randomBytes, createHash } from 'crypto'; /** * Generate a random token string */ export const generateRandomToken = (length = 43): string => randomBytes(length).toString('hex'); +export const hashToken = (token: string): string => { + const hash = createHash('sha256'); + hash.update(token); + + return hash.digest('hex'); +}; + export const generateAccessToken = ({ secret, data, diff --git a/packages/two-factor/package.json b/packages/two-factor/package.json index 3e9748d9e..3dba9d1ad 100644 --- a/packages/two-factor/package.json +++ b/packages/two-factor/package.json @@ -14,7 +14,7 @@ "compile": "tsc", "prepublishOnly": "yarn compile", "test": "npm run test", - "testonly": "jest --coverage", + "testonly": "jest", "coverage": "jest --coverage" }, "jest": { diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 3b84ba8c4..ef859c098 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -7,6 +7,8 @@ export * from './types/user'; export * from './types/create-user'; export * from './types/email-record'; export * from './types/login-result'; +export * from './types/mfa-login-result'; +export * from './types/mfa-login-attempt'; export * from './types/impersonation-result'; export * from './types/login-user-identity'; export * from './types/hook-listener'; diff --git a/packages/types/src/types/create-user.ts b/packages/types/src/types/create-user.ts index acbc6c471..06d28ad95 100644 --- a/packages/types/src/types/create-user.ts +++ b/packages/types/src/types/create-user.ts @@ -1,5 +1,6 @@ export interface CreateUser { username?: string; email?: string; + mfaChallenges?: string[]; [additionalKey: string]: any; } diff --git a/packages/types/src/types/database-interface.ts b/packages/types/src/types/database-interface.ts index 175e5acdb..6e0d759ed 100644 --- a/packages/types/src/types/database-interface.ts +++ b/packages/types/src/types/database-interface.ts @@ -1,9 +1,12 @@ import { User } from './user'; import { Session } from './session'; +import { MfaLoginAttempt } from './mfa-login-attempt'; import { CreateUser } from './create-user'; import { ConnectionInformations } from './connection-informations'; -export interface DatabaseInterface extends DatabaseInterfaceSessions { +export interface DatabaseInterface + extends DatabaseInterfaceSessions, + DatabaseInterfaceMfaLoginAttempts { // Find user by identity fields findUserByEmail(email: string): Promise; @@ -58,6 +61,12 @@ export interface DatabaseInterface extends DatabaseInterfaceSessions { setUserDeactivated(userId: string, deactivated: boolean): Promise; } +export interface DatabaseInterfaceMfaLoginAttempts { + createMfaLoginAttempt(mfaToken: string, loginToken: string, userId: string): Promise; + getMfaLoginAttempt(mfaToken: string): Promise; + removeMfaLoginAttempt(mfaToken: string): Promise; +} + export interface DatabaseInterfaceSessions { findSessionById(sessionId: string): Promise; diff --git a/packages/types/src/types/mfa-login-attempt.ts b/packages/types/src/types/mfa-login-attempt.ts new file mode 100644 index 000000000..6ccb33d2a --- /dev/null +++ b/packages/types/src/types/mfa-login-attempt.ts @@ -0,0 +1,6 @@ +export interface MfaLoginAttempt { + id: string; + mfaToken: string; + loginToken: string; + userId: string; +} diff --git a/packages/types/src/types/mfa-login-result.ts b/packages/types/src/types/mfa-login-result.ts new file mode 100644 index 000000000..53042ed67 --- /dev/null +++ b/packages/types/src/types/mfa-login-result.ts @@ -0,0 +1,4 @@ +export interface MFALoginResult { + mfaToken: string; + challenges: string[]; +} diff --git a/packages/types/src/types/user.ts b/packages/types/src/types/user.ts index b62f787bf..abc470f0f 100644 --- a/packages/types/src/types/user.ts +++ b/packages/types/src/types/user.ts @@ -6,4 +6,5 @@ export interface User { id: string; services?: object; deactivated: boolean; + mfaChallenges?: string[]; } diff --git a/yarn.lock b/yarn.lock index fd94449ba..853724a1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -245,7 +245,7 @@ esutils "^2.0.2" js-tokens "^4.0.0" -"@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.2.0", "@babel/parser@^7.4.3", "@babel/parser@^7.4.4", "@babel/parser@^7.5.5": +"@babel/parser@7.5.5", "@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.2.0", "@babel/parser@^7.4.3", "@babel/parser@^7.4.4", "@babel/parser@^7.5.5": version "7.5.5" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.5.5.tgz#02f077ac8817d3df4a832ef59de67565e71cca4b" integrity sha512-E5BN68cqR7dhKan1SfqgPGhQ178bkVKpXTPEXnFJBrEt8/DKRZlybmy+IgYLTeN7tp1R5Ccmbm2rBk17sHYU3g== @@ -934,6 +934,83 @@ resolved "https://registry.yarnpkg.com/@gql2ts/util/-/util-1.9.0.tgz#d07a54832757d2f2d1fc9891e5b0e3e3b4886c6a" integrity sha512-mkHar7AdyShUFJE6Mlke1tUbb+lPCK1EozZeAhCuRrhQ5aCCBAG6RxzNUYX1Q2jeGeyU0WRAtQu1oE/GoIsNXA== +"@graphql-codegen/add@^1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@graphql-codegen/add/-/add-1.7.0.tgz#8969e51913c4013e2ab098205184f38ca517cf7e" + integrity sha512-sk561sxOurcPTUS864pXDEh0sh/E5t0BRWPEdbSIcQ80Ia6WrwA6tuZfs59rkMlw99fpDTOGwjOHzqprZ9DzwQ== + dependencies: + "@graphql-codegen/plugin-helpers" "1.7.0" + tslib "1.10.0" + +"@graphql-codegen/cli@^1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@graphql-codegen/cli/-/cli-1.7.0.tgz#aade52f5c265450258e8fe0e45dc91268e8939a8" + integrity sha512-glrk7A7vzazF8mfR3fUL7baorjxL9w3hFqPmLMp9uEMXyIP3Z0MrRacbDkFzeYXcGsm7K4k+ZK5Og2g8shVCuA== + dependencies: + "@babel/parser" "7.5.5" + "@graphql-codegen/core" "1.7.0" + "@graphql-codegen/plugin-helpers" "1.7.0" + "@types/debounce" "1.2.0" + "@types/is-glob" "4.0.1" + "@types/mkdirp" "0.5.2" + "@types/valid-url" "1.0.2" + babel-types "7.0.0-beta.3" + chalk "2.4.2" + change-case "3.1.0" + chokidar "3.0.2" + commander "3.0.1" + common-tags "1.8.0" + debounce "1.2.0" + detect-indent "6.0.0" + glob "7.1.4" + graphql-config "2.2.1" + graphql-import "0.7.1" + graphql-tag-pluck "0.8.4" + graphql-toolkit "0.5.11" + graphql-tools "4.0.5" + indent-string "4.0.0" + inquirer "7.0.0" + is-glob "4.0.1" + is-valid-path "0.1.1" + js-yaml "3.13.1" + json-to-pretty-yaml "1.2.2" + listr "0.14.3" + listr-update-renderer "0.5.0" + log-symbols "3.0.0" + log-update "3.2.0" + mkdirp "0.5.1" + prettier "1.18.2" + request "2.88.0" + ts-log "2.1.4" + tslib "1.10.0" + valid-url "1.0.9" + +"@graphql-codegen/core@1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@graphql-codegen/core/-/core-1.7.0.tgz#fdad4d3ea9de998f9effd0df492ac3c4c2633bbc" + integrity sha512-NghsdPhI4eqjOJvzC2f8sHPJL7vx4hMTXeg2U90YWtv07lQoxefsJwi4UND6dyALUoH5MdgMyxJl6LM9mYzOVA== + dependencies: + "@graphql-codegen/plugin-helpers" "1.7.0" + graphql-toolkit "0.5.11" + tslib "1.10.0" + +"@graphql-codegen/fragment-matcher@^1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@graphql-codegen/fragment-matcher/-/fragment-matcher-1.7.0.tgz#4ecc7ac2efd1ee52847a2334980dd9c6124652cf" + integrity sha512-uzOsoiKbLGSqiJ5hH+KsnTb1qhbytBz0ko+uhU4XzkHzOFJj8wgJO2j+B38fuZSjOVPuM98Zb/sBSkczEimdmA== + dependencies: + "@graphql-codegen/plugin-helpers" "1.7.0" + +"@graphql-codegen/plugin-helpers@1.7.0": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@graphql-codegen/plugin-helpers/-/plugin-helpers-1.7.0.tgz#b1870a166cf34b2c67c053f2a9ec77823f78ac2d" + integrity sha512-lUWd5A9BQNbPqlMr38Gh5sLsBgMnn26n90/hyTw2J7CFCKFKSMnNBjxfCZU5AFHGxwi6rsNEpwBHRBx3OVWsTA== + dependencies: + change-case "3.1.0" + common-tags "1.8.0" + import-from "3.0.0" + tslib "1.10.0" + "@graphql-modules/core@0.7.11": version "0.7.11" resolved "https://registry.yarnpkg.com/@graphql-modules/core/-/core-0.7.11.tgz#5eab67d25045967c33bf40de5c0eb972370ce0df" @@ -2248,6 +2325,11 @@ dependencies: "@types/express" "*" +"@types/debounce@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@types/debounce/-/debounce-1.2.0.tgz#9ee99259f41018c640b3929e1bb32c3dcecdb192" + integrity sha512-bWG5wapaWgbss9E238T0R6bfo5Fh3OkeoSt245CM7JJwVwpw6MEBCbIxLq5z8KzsE3uJhzcIuQkyiZmzV3M/Dw== + "@types/eslint-visitor-keys@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" @@ -2331,6 +2413,11 @@ resolved "https://registry.yarnpkg.com/@types/is-glob/-/is-glob-4.0.0.tgz#fb8a2bff539025d4dcd6d5efe7689e03341b876d" integrity sha512-zC/2EmD8scdsGIeE+Xg7kP7oi9VP90zgMQtm9Cr25av4V+a+k8slQyiT60qSw8KORYrOKlPXfHwoa1bQbRzskQ== +"@types/is-glob@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/is-glob/-/is-glob-4.0.1.tgz#a93eec1714172c8eb3225a1cc5eb88c2477b7d00" + integrity sha512-k3RS5HyBPu4h+5hTmIEfPB2rl5P3LnGdQEZrV2b9OWTJVtsUQ2VBcedqYKGqxvZqle5UALUXdSfVA8nf3HfyWQ== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" @@ -2404,6 +2491,11 @@ "@types/koa-compose" "*" "@types/node" "*" +"@types/lodash@4.14.136": + version "4.14.136" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.136.tgz#413e85089046b865d960c9ff1d400e04c31ab60f" + integrity sha512-0GJhzBdvsW2RUccNHOBkabI8HZVdOXmXbXhuKlDEd5Vv12P7oAVGfomGp3Ne21o5D/qu1WmthlNKFaoZJJeErA== + "@types/lodash@4.14.138", "@types/lodash@^4.14.138": version "4.14.138" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.138.tgz#34f52640d7358230308344e579c15b378d91989e" @@ -2424,6 +2516,13 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.3.tgz#3dca0e3f33b200fc7d1139c0cd96c1268cadfd9d" integrity sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA== +"@types/mkdirp@0.5.2": + version "0.5.2" + resolved "https://registry.yarnpkg.com/@types/mkdirp/-/mkdirp-0.5.2.tgz#503aacfe5cc2703d5484326b1b27efa67a339c1f" + integrity sha512-U5icWpv7YnZYGsN4/cmh3WD2onMY0aJIiTE6+51TwJCttdHvtCYmkBNOobHlXwrJRL0nkH9jH4kD+1FAdMN4Tg== + dependencies: + "@types/node" "*" + "@types/mongodb@*", "@types/mongodb@3.3.1": version "3.3.1" resolved "https://registry.yarnpkg.com/@types/mongodb/-/mongodb-3.3.1.tgz#9569ffcb356fbb5313ae2d3afa88c230bf8cf0d1" @@ -2432,6 +2531,14 @@ "@types/bson" "*" "@types/node" "*" +"@types/mongoose@5.5.11": + version "5.5.11" + resolved "https://registry.yarnpkg.com/@types/mongoose/-/mongoose-5.5.11.tgz#8562bb84b4f3f41aebec27f263607bfe2183729e" + integrity sha512-Z1W2V3zrB+SeDGI6G1G5XR3JJkkMl4ni7a2Kmq10abdY0wapbaTtUT2/31N+UTPEzhB0KPXUgtQExeKxrc+hxQ== + dependencies: + "@types/mongodb" "*" + "@types/node" "*" + "@types/mongoose@5.5.17": version "5.5.17" resolved "https://registry.yarnpkg.com/@types/mongoose/-/mongoose-5.5.17.tgz#1f8eb3799368ae266758d2df1bd1a7cfca0f6875" @@ -3034,6 +3141,13 @@ ansi-escapes@^3.0.0, ansi-escapes@^3.2.0: resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-3.2.0.tgz#8780b98ff9dbf5638152d1f1fe5c1d7b4442976b" integrity sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ== +ansi-escapes@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.2.1.tgz#4dccdb846c3eee10f6d64dea66273eab90c37228" + integrity sha512-Cg3ymMAdN10wOk/VYfLV7KCQyv7EDirJ64500sU7n9UlmioEtDuU5Gd+hj73hXSU/ex7tHJSssmyftDdkMLO8Q== + dependencies: + type-fest "^0.5.2" + ansi-html@0.0.7: version "0.0.7" resolved "https://registry.yarnpkg.com/ansi-html/-/ansi-html-0.0.7.tgz#813584021962a9e9e6fd039f940d12f56ca7859e" @@ -3084,6 +3198,14 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" +anymatch@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.0.tgz#e609350e50a9313b472789b2f14ef35808ee14d6" + integrity sha512-Ozz7l4ixzI7Oxj2+cw+p0tVUt27BpaJ+1+q1TCeANWxHpvyn2+Un+YamBdfKu0uh8xLodGhoa1v7595NhKDAuA== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + apollo-boost@0.1.27: version "0.1.27" resolved "https://registry.yarnpkg.com/apollo-boost/-/apollo-boost-0.1.27.tgz#77cc796359503a330d5b31780043430afed47899" @@ -3900,6 +4022,11 @@ binary-extensions@^1.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-1.13.1.tgz#598afe54755b2868a5330d2aff9d4ebb53209b65" integrity sha512-Un7MIEDdUC5gNpcGDV97op1Ywk748MpHcFTHoYs6qnj1Z3j7I53VG3nwZhKzoBZmbdRNnb6WRdFlwl7tSDuZGw== +binary-extensions@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c" + integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow== + bluebird@3.5.1: version "3.5.1" resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.5.1.tgz#d9551f9de98f1fcda1e683d17ee91a0602ee2eb9" @@ -3985,7 +4112,7 @@ braces@^2.3.1, braces@^2.3.2: split-string "^3.0.2" to-regex "^3.0.1" -braces@^3.0.1: +braces@^3.0.1, braces@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== @@ -4426,6 +4553,21 @@ chokidar@2.1.2: optionalDependencies: fsevents "^1.2.7" +chokidar@3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.0.2.tgz#0d1cd6d04eb2df0327446188cd13736a3367d681" + integrity sha512-c4PR2egjNjI1um6bamCQ6bUNPDiyofNQruHvKgHQ4gDUP/ITSVSzNsiI5OWtHOsX323i5ha/kk4YmOZ1Ktg7KA== + dependencies: + anymatch "^3.0.1" + braces "^3.0.2" + glob-parent "^5.0.0" + is-binary-path "^2.1.0" + is-glob "^4.0.1" + normalize-path "^3.0.0" + readdirp "^3.1.1" + optionalDependencies: + fsevents "^2.0.6" + chokidar@^2.0.0, chokidar@^2.0.2, chokidar@^2.0.4, chokidar@^2.1.5: version "2.1.8" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" @@ -4509,6 +4651,13 @@ cli-cursor@^2.0.0, cli-cursor@^2.1.0: dependencies: restore-cursor "^2.0.0" +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + dependencies: + restore-cursor "^3.1.0" + cli-highlight@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/cli-highlight/-/cli-highlight-2.1.1.tgz#2180223d51618b112f4509cf96e4a6c750b07e97" @@ -4708,6 +4857,11 @@ commander@2.19.0, commander@~2.19.0: resolved "https://registry.yarnpkg.com/commander/-/commander-2.19.0.tgz#f6198aa84e5b83c46054b94ddedbfed5ee9ff12a" integrity sha512-6tvAOO+D6OENvRAh524Dh9jcfKTYDQAqvqezbCW82xj5X0pSrcpxtvRKHLG0yBY6SD7PSDrJaj+0AiOcKVd1Xg== +commander@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-3.0.1.tgz#4595aec3530525e671fb6f85fb173df8ff8bf57a" + integrity sha512-UNgvDd+csKdc9GD4zjtkHKQbT8Aspt2jCBqNSPp53vAS0L1tS9sXB2TCEOPHJ7kt9bN/niWkYj8T3RQSoMXdSQ== + commander@^2.11.0, commander@^2.20.0, commander@~2.20.0: version "2.20.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422" @@ -5440,6 +5594,11 @@ dateformat@^3.0.0: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== +debounce@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.0.tgz#44a540abc0ea9943018dc0eaa95cce87f65cd131" + integrity sha512-mYtLl1xfZLi1m4RtQYlZgJUNQjl4ZxVnHzIR8nLLgi4q1YT8o/WM+MK/f8yfcc9s5Ir5zRaPZyZU6xs1Syoocg== + debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.8, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" @@ -5665,6 +5824,11 @@ detect-indent@5.0.0, detect-indent@^5.0.0: resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d" integrity sha1-OHHMCmoALow+Wzz38zYmRnXwa50= +detect-indent@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-6.0.0.tgz#0abd0f549f69fc6659a254fe96786186b6f528fd" + integrity sha512-oSyFlqaTHCItVRGK5RmrmjB+CmaMOW7IaNA/kdxqhoa6d17j/5ce9O9eWXmV/KEdRwqpQA+Vqe8a8Bsybu4YnA== + detect-libc@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b" @@ -5966,6 +6130,11 @@ emoji-regex@^7.0.1, emoji-regex@^7.0.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-7.0.3.tgz#933a04052860c85e83c122479c4748a8e4c72156" integrity sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA== +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + emojis-list@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-2.1.0.tgz#4daa4d9db00f9819880c79fa457ae5b09a1fd389" @@ -6734,6 +6903,13 @@ figures@^2.0.0: dependencies: escape-string-regexp "^1.0.5" +figures@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/figures/-/figures-3.0.0.tgz#756275c964646163cc6f9197c7a0295dbfd04de9" + integrity sha512-HKri+WoWoUgr83pehn/SIgLOMZ9nAWC6dcGj26RY2R4F50u4+RTUz0RCrUlOV3nKRAICW1UGzyb+kcX2qK1S/g== + dependencies: + escape-string-regexp "^1.0.5" + file-entry-cache@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-5.0.1.tgz#ca0f6efa6dd3d561333fb14515065c2fafdf439c" @@ -6999,7 +7175,7 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= -fsevents@2.0.7: +fsevents@2.0.7, fsevents@^2.0.6: version "2.0.7" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.0.7.tgz#382c9b443c6cbac4c57187cdda23aa3bf1ccfc2a" integrity sha512-a7YT0SV3RB+DjYcppwVDLtn13UQnmg0SWZS7ezZD0UjnLwXmy8Zm21GMVGLaFGimIqcvyMQaOJBrop8MyOp1kQ== @@ -7204,7 +7380,7 @@ glob@7.1.3: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: +glob@7.1.4, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: version "7.1.4" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255" integrity sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A== @@ -7473,6 +7649,16 @@ graphql-tag-pluck@0.6.0: source-map-support "^0.5.9" typescript "^3.2.2" +graphql-tag-pluck@0.8.4: + version "0.8.4" + resolved "https://registry.yarnpkg.com/graphql-tag-pluck/-/graphql-tag-pluck-0.8.4.tgz#9425627a9358365be519d532acaa38edde049d28" + integrity sha512-weT9fZPILIOkdW26ZkkiGf2OGvSfHQZBudYxkxnNoiLU+9RH+I0THE95iAvzMWbtKVmoBovLF/qQyK4ay/D7Bw== + dependencies: + "@babel/parser" "^7.4.4" + "@babel/traverse" "^7.4.4" + "@babel/types" "^7.4.4" + source-map-support "^0.5.12" + graphql-tag@2.10.1, graphql-tag@^2.10.0, graphql-tag@^2.4.2, graphql-tag@^2.9.2: version "2.10.1" resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.10.1.tgz#10aa41f1cd8fae5373eaf11f1f67260a3cad5e02" @@ -7495,6 +7681,25 @@ graphql-toolkit@0.2.0: tslib "^1.9.3" valid-url "1.0.9" +graphql-toolkit@0.5.11: + version "0.5.11" + resolved "https://registry.yarnpkg.com/graphql-toolkit/-/graphql-toolkit-0.5.11.tgz#f9adf1ecc4df455802d0cc223acbd35556f7d78e" + integrity sha512-CKYzzqcAUbG3mzeQ1+KDqggQMj1lcleanhU4h8EH9bKV2+IyY+vMXQcuxBuLF4BgxYeX04LQnPUfGi9F+lo0qw== + dependencies: + "@kamilkisiela/graphql-tools" "4.0.6" + "@types/glob" "7.1.1" + aggregate-error "3.0.0" + asyncro "^3.0.0" + cross-fetch "^3.0.4" + deepmerge "4.0.0" + globby "10.0.1" + graphql-import "0.7.1" + is-glob "4.0.1" + is-valid-path "0.1.1" + lodash "4.17.15" + tslib "^1.9.3" + valid-url "1.0.9" + graphql-toolkit@0.5.12, graphql-toolkit@^0.5.12: version "0.5.12" resolved "https://registry.yarnpkg.com/graphql-toolkit/-/graphql-toolkit-0.5.12.tgz#2ab4a81ff2e67bd591be5c660f09744024d98617" @@ -8033,6 +8238,13 @@ import-from@2.1.0, import-from@^2.1.0: dependencies: resolve-from "^3.0.0" +import-from@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/import-from/-/import-from-3.0.0.tgz#055cfec38cd5a27d8057ca51376d7d3bf0891966" + integrity sha512-CiuXOFFSzkU5x/CR0+z7T91Iht4CXgfCxVOFRhh2Zyhg5wOpWvvDLQUsWl+gcN+QscYBjez8hDCt85O7RLDttQ== + dependencies: + resolve-from "^5.0.0" + import-lazy@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" @@ -8056,6 +8268,11 @@ indent-string@3.2.0, indent-string@^3.0.0, indent-string@^3.2.0: resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-3.2.0.tgz#4a5fd6d27cc332f37e5419a504dbb837105c9289" integrity sha1-Sl/W0nzDMvN+VBmlBNu4NxBckok= +indent-string@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" + integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== + indent-string@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-2.1.0.tgz#8e2d48348742121b4a8218b7a137e9a52049dc80" @@ -8172,6 +8389,25 @@ inquirer@6.5.0: strip-ansi "^5.1.0" through "^2.3.6" +inquirer@7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-7.0.0.tgz#9e2b032dde77da1db5db804758b8fea3a970519a" + integrity sha512-rSdC7zelHdRQFkWnhsMu2+2SO41mpv2oF2zy4tMhmiLWkcKbOAs87fWAJhVXttKVwhdZvymvnuM95EyEXg2/tQ== + dependencies: + ansi-escapes "^4.2.1" + chalk "^2.4.2" + cli-cursor "^3.1.0" + cli-width "^2.0.0" + external-editor "^3.0.3" + figures "^3.0.0" + lodash "^4.17.15" + mute-stream "0.0.8" + run-async "^2.2.0" + rxjs "^6.4.0" + string-width "^4.1.0" + strip-ansi "^5.1.0" + through "^2.3.6" + inquirer@^6.2.0, inquirer@^6.4.1: version "6.5.2" resolved "https://registry.yarnpkg.com/inquirer/-/inquirer-6.5.2.tgz#ad50942375d036d327ff528c08bd5fab089928ca" @@ -8287,6 +8523,13 @@ is-binary-path@^1.0.0: dependencies: binary-extensions "^1.0.0" +is-binary-path@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + is-buffer@^1.0.2, is-buffer@^1.1.5: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" @@ -8406,6 +8649,11 @@ is-fullwidth-code-point@^2.0.0: resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" integrity sha1-o7MKXE8ZkYMWeqq5O+764937ZU8= +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + is-generator-fn@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" @@ -9860,6 +10108,13 @@ log-symbols@2.2.0: dependencies: chalk "^2.0.1" +log-symbols@3.0.0, log-symbols@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4" + integrity sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ== + dependencies: + chalk "^2.4.2" + log-symbols@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18" @@ -9867,13 +10122,6 @@ log-symbols@^1.0.2: dependencies: chalk "^1.0.0" -log-symbols@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4" - integrity sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ== - dependencies: - chalk "^2.4.2" - log-update@2.3.0, log-update@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/log-update/-/log-update-2.3.0.tgz#88328fd7d1ce7938b29283746f0b1bc126b24708" @@ -9883,6 +10131,15 @@ log-update@2.3.0, log-update@^2.3.0: cli-cursor "^2.0.0" wrap-ansi "^3.0.1" +log-update@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/log-update/-/log-update-3.2.0.tgz#719f24293250d65d0165f4e2ec2ed805ff062eec" + integrity sha512-KJ6zAPIHWo7Xg1jYror6IUDFJBq1bQ4Bi4wAEp2y/0ScjBBVi/g0thr0sUVhuvuXauWzczt7T2QHghPDNnKBuw== + dependencies: + ansi-escapes "^3.2.0" + cli-cursor "^2.1.0" + wrap-ansi "^5.0.0" + logform@^2.1.1: version "2.1.2" resolved "https://registry.yarnpkg.com/logform/-/logform-2.1.2.tgz#957155ebeb67a13164069825ce67ddb5bb2dd360" @@ -10464,7 +10721,7 @@ mute-stream@0.0.7: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.7.tgz#3075ce93bc21b8fab43e1bc4da7e8115ed1e7bab" integrity sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s= -mute-stream@~0.0.4: +mute-stream@0.0.8, mute-stream@~0.0.4: version "0.0.8" resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== @@ -11552,7 +11809,7 @@ pgpass@1.x: dependencies: split "^1.0.0" -picomatch@^2.0.5: +picomatch@^2.0.4, picomatch@^2.0.5: version "2.0.7" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.0.7.tgz#514169d8c7cd0bdbeecc8a2609e34a7163de69f6" integrity sha512-oLHIdio3tZ0qH76NybpeneBhYVj0QFTfXEFTc/B3zKQspYfYYkWYgFsmzo+4kvId/bQRcNkVeguI3y+CD22BtA== @@ -13025,6 +13282,13 @@ readdirp@^2.2.1: micromatch "^3.1.10" readable-stream "^2.0.2" +readdirp@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.1.2.tgz#fa85d2d14d4289920e4671dead96431add2ee78a" + integrity sha512-8rhl0xs2cxfVsqzreYCvs8EwBfn/DhVdqtoLmw19uI3SC5avYX9teCurlErfpPXGmYtMHReGaP2RsLnFvz/lnw== + dependencies: + picomatch "^2.0.4" + realpath-native@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/realpath-native/-/realpath-native-1.1.0.tgz#2003294fea23fb0672f2476ebe22fcf498a2d65c" @@ -13342,6 +13606,11 @@ resolve-from@^4.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + resolve-pathname@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-2.2.0.tgz#7e9ae21ed815fd63ab189adeee64dc831eefa879" @@ -13388,6 +13657,14 @@ restore-cursor@^2.0.0: onetime "^2.0.0" signal-exit "^3.0.2" +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + ret@~0.1.10: version "0.1.15" resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" @@ -13948,7 +14225,7 @@ source-map-resolve@^0.5.0, source-map-resolve@^0.5.2: source-map-url "^0.4.0" urix "^0.1.0" -source-map-support@^0.5.6, source-map-support@^0.5.9, source-map-support@~0.5.12: +source-map-support@^0.5.12, source-map-support@^0.5.6, source-map-support@^0.5.9, source-map-support@~0.5.12: version "0.5.13" resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" integrity sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w== @@ -14201,6 +14478,15 @@ string-width@^3.0.0, string-width@^3.1.0: is-fullwidth-code-point "^2.0.0" strip-ansi "^5.1.0" +string-width@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.1.0.tgz#ba846d1daa97c3c596155308063e075ed1c99aff" + integrity sha512-NrX+1dVVh+6Y9dnQ19pR0pP4FiEIlUvdTGn8pw6CKTNq5sgib2nIhmUNT5TAmhWmvKr3WcxBcP3E8nWezuipuQ== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^5.2.0" + string.prototype.trimleft@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/string.prototype.trimleft/-/string.prototype.trimleft-2.0.0.tgz#68b6aa8e162c6a80e76e3a8a0c2e747186e271ff" @@ -14827,6 +15113,11 @@ type-fest@^0.3.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.3.1.tgz#63d00d204e059474fe5e1b7c011112bbd1dc29e1" integrity sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ== +type-fest@^0.5.2: + version "0.5.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.5.2.tgz#d6ef42a0356c6cd45f49485c3b6281fc148e48a2" + integrity sha512-DWkS49EQKVX//Tbupb9TFa19c7+MK1XmzkrZUR8TAktmE/DizXoaoJV6TZ/tSIPXipqNiRI6CyAe7x69Jb6RSw== + type-fest@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.6.0.tgz#8d2a2370d3df886eb5c90ada1c5bf6188acf838b" @@ -15653,7 +15944,7 @@ wrap-ansi@^3.0.1: string-width "^2.1.1" strip-ansi "^4.0.0" -wrap-ansi@^5.1.0: +wrap-ansi@^5.0.0, wrap-ansi@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09" integrity sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==