diff --git a/package.json b/package.json index 376da3202..4de9b44b9 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,16 @@ "trailingComma": "es5", "printWidth": 100 }, + "jest": { + "transform": { + ".(ts|tsx)": "/node_modules/ts-jest/preprocessor.js" + }, + "testRegex": "(/__tests__/.*|\\.(test|spec))\\.(ts|tsx)$", + "moduleFileExtensions": [ + "ts", + "js" + ] + }, "renovate": { "extends": [ "config:base" diff --git a/packages/email-ns-debug/src/email-ns-debug.ts b/packages/email-ns-debug/src/email-ns-debug.ts index 826d17c75..228c27a82 100644 --- a/packages/email-ns-debug/src/email-ns-debug.ts +++ b/packages/email-ns-debug/src/email-ns-debug.ts @@ -10,19 +10,14 @@ export default class EmailNotificationServiceDebug implements NotificationServic private plugins: NotificationPlugins; constructor(config: Configuration){ - // Register plugins - this.plugins = config.plugins.reduce( - (a: NotificationPlugins ,notificationPlugin: NotificationPlugin) => - ({ ...a, [notificationPlugin.name]: notificationPlugin }) - ,{}) - - } + this.registerPlugins(config.plugins) + } public send = (mail: object): void => { // tslint:disable-next-line console.dir(mail) - } + } public notify (notificationPluginName: string, actionName: string, params: object): void { const plugin = this.plugins[notificationPluginName]; @@ -36,4 +31,11 @@ export default class EmailNotificationServiceDebug implements NotificationServic action(this.send, params); } + private registerPlugins(plugins: NotificationPlugin[]): void { + this.plugins = plugins.reduce( + (a: NotificationPlugins ,notificationPlugin: NotificationPlugin) => + ({ ...a, [notificationPlugin.name]: notificationPlugin }) + ,{}) + } + } \ No newline at end of file diff --git a/packages/password/src/accounts-password.ts b/packages/password/src/accounts-password.ts index 9091df867..fb9942b37 100644 --- a/packages/password/src/accounts-password.ts +++ b/packages/password/src/accounts-password.ts @@ -9,7 +9,7 @@ import { ConnectionInformations, Message } from '@accounts/types'; -import { trim, isEmpty, isFunction, isString, isPlainObject, get, find, includes } from 'lodash'; +import { trim, isEmpty, isFunction, isString, isPlainObject, get } from 'lodash'; import { HashAlgorithm } from '@accounts/common'; import { TwoFactor, AccountsTwoFactorOptions } from '@accounts/two-factor'; import { AccountsServer, getFirstUserEmail } from '@accounts/server'; @@ -127,17 +127,13 @@ export default class AccountsPassword implements AuthenticationService { throw new Error('Verify email link expired'); } - const verificationTokens: TokenRecord[] = get( - user, - ['services', 'email', 'verificationTokens'], - [] - ); - const tokenRecord = find(verificationTokens, (t: TokenRecord) => t.token === token); + const verificationTokens: TokenRecord[] = get(user, 'services.email.verificationTokens', []); + const tokenRecord = verificationTokens.find((t: TokenRecord) => t.token === token); if (!tokenRecord) { throw new Error('Verify email link expired'); } // TODO check time for expiry date - const emailRecord = find(user.emails, (e: EmailRecord) => e.address === tokenRecord.address); + const emailRecord = user.emails.find((e: EmailRecord) => e.address === tokenRecord.address); if (!emailRecord) { throw new Error('Verify email link is for unknown address'); } @@ -158,15 +154,15 @@ export default class AccountsPassword implements AuthenticationService { } // TODO move this getter into a password service module - const resetTokens = get(user, ['services', 'password', 'reset']); - const resetTokenRecord = find(resetTokens, t => t.token === token); + const resetTokens = get(user, 'services.password.reset', []); + const resetTokenRecord = resetTokens.find(t => t.token === token); if (this.server.tokenManager.isEmailTokenExpired(token, resetTokenRecord)) { throw new Error('Reset password link expired'); } - const emails = user.emails || []; - if (!includes(emails.map((email: EmailRecord) => email.address), resetTokenRecord.address)) { + const emails: EmailRecord[] = user.emails || []; + if (!emails.find((e: EmailRecord) => e.address === resetTokenRecord.address )) { throw new Error('Token has invalid email address'); } @@ -195,7 +191,7 @@ export default class AccountsPassword implements AuthenticationService { } // Make sure the address is valid const emails = user.emails || []; - if (!address || !includes(emails.map(email => email.address), address)) { + if (!emails.find((e: EmailRecord) => e.address === address )) { throw new Error('No such email address for user'); } const token = this.server.tokenManager.generateRandomToken(); diff --git a/packages/rest-express/src/transport-express.ts b/packages/rest-express/src/transport-express.ts index 9aca6e5b2..64f24b5fd 100644 --- a/packages/rest-express/src/transport-express.ts +++ b/packages/rest-express/src/transport-express.ts @@ -86,8 +86,8 @@ export default class TransportExpress { try { const accessToken = this.tokenTransport.getAccessToken(req) const { username } = req.body; - const { userAgent, ip } = getConnectionInformations(req) - const { tokens, ...data } = await this.accountsServer.impersonate(accessToken, username, ip, userAgent); + const connectionInfo = getConnectionInformations(req) + const { tokens, ...data } = await this.accountsServer.impersonate(accessToken, username, connectionInfo); this.tokenTransport.setTokens(tokens, res); this.send(res, data) } catch (err) { @@ -108,12 +108,11 @@ export default class TransportExpress { private refreshTokens = async ( req: Request, res: Response ) => { try { const { accessToken, refreshToken } = this.tokenTransport.getTokens(req) - const { userAgent, ip } = getConnectionInformations(req) + const connectionInfo = getConnectionInformations(req) const refreshedSession = await this.accountsServer.refreshTokens( accessToken, refreshToken, - ip, - userAgent + connectionInfo ); const { tokens, ...data } = refreshedSession; this.tokenTransport.setTokens(tokens, res); diff --git a/packages/server/__tests__/account-server.ts b/packages/server/__tests__/account-server.ts index 0953e71f3..c948aa840 100644 --- a/packages/server/__tests__/account-server.ts +++ b/packages/server/__tests__/account-server.ts @@ -37,26 +37,6 @@ describe('AccountsServer', () => { }); }); - describe('getServices', () => { - it('should return instance services', async () => { - const passwordService = { - serviceName: 'password', - link: () => passwordService - } - const authenticationServices = [passwordService] - - const expectedServices: any = { - password: passwordService - }; - const account = new AccountsServer({ - db: {}, - tokenManager, - authenticationServices - } as any); - expect(account.getServices()).toEqual(expectedServices); - }); - }); - describe('loginWithUser', () => { it('creates a session when given a proper user object', async () => { const user = { @@ -468,7 +448,7 @@ describe('AccountsServer', () => { accessToken: 'newAccessToken', refreshToken: 'newRefreshToken', }); - const res = await accountsServer.refreshTokens(accessToken, refreshToken, 'ip', 'user agent'); + const res = await accountsServer.refreshTokens(accessToken, refreshToken, { ip:'ip', userAgent:'user agent' }); expect(updateSession.mock.calls[0]).toEqual(['456', { ip: 'ip', userAgent: 'user agent' }]); expect(res.user).toEqual({ userId: '123', @@ -637,21 +617,6 @@ describe('AccountsServer', () => { }); }); - describe('findUserById', () => { - it('call this.db.findUserById', async () => { - const findUserById = jest.fn(() => Promise.resolve('user')); - const accountsServer = new AccountsServer( - { - db: { findUserById } as any, - tokenManager, - } - ); - const user = await accountsServer.findUserById('id'); - expect(findUserById.mock.calls[0]).toEqual(['id']); - expect(user).toEqual('user'); - }); - }); - describe('resumeSession', () => { it('throws error if user is not found', async () => { const accountsServer = new AccountsServer( @@ -963,7 +928,7 @@ describe('AccountsServer', () => { userId: '123', } as any); - const impersonationAuthorize = accountsServer.getOptions().impersonationAuthorize; + const impersonationAuthorize = accountsServer.config.impersonationAuthorize; expect(impersonationAuthorize).toBeDefined(); const res = await accountsServer.impersonate(accessToken, { userId: 'userId' }, null, null); @@ -1016,27 +981,23 @@ describe('AccountsServer', () => { const userObject = { username: 'test', services: [], id: '123' }; it('internal sanitizer should clean services field from the user object', () => { - const accountsServer = new AccountsServer( - { - db: db as any, - tokenManager, - } - ); + const accountsServer = new AccountsServer({ db: db as any, tokenManager }); const modifiedUser = accountsServer.sanitizeUser(userObject); expect(modifiedUser.services).toBeUndefined(); }); it('should run external sanitizier when provided one', () => { - const accountsServer = new AccountsServer( - { - db: db as any, - tokenManager, - - userObjectSanitizer: (user, omit) => omit(user, ['username']), + const accountsServer = new AccountsServer({ + db: db as any, + tokenManager, + sanitizeUser: (user) => { + const { username, ...rest } = user; + return rest } - ); + }); const modifiedUser = accountsServer.sanitizeUser(userObject); expect(modifiedUser.username).toBeUndefined(); + expect(modifiedUser.services).toBeUndefined(); }); }); }); diff --git a/packages/server/src/accounts-server.ts b/packages/server/src/accounts-server.ts index d9c7f2d97..c6d007a44 100644 --- a/packages/server/src/accounts-server.ts +++ b/packages/server/src/accounts-server.ts @@ -18,45 +18,45 @@ import { TokenRecord, NotificationService, NotificationServices, + UserSafe, } from '@accounts/types'; import { ServerHooks } from './utils/server-hooks'; -import { AccountsServerOptions } from './types/accounts-server-options'; +import { Configuration } from './types/configuration'; import { JwtData } from './types/jwt-data'; -const defaultOptions = { - userObjectSanitizer: (user: User) => user, +const defaultConfig = { authenticationServices: [], notificationServices: [] }; export class AccountsServer { - public options: AccountsServerOptions; + public config: Configuration; public tokenManager: TokenManager; public db: DatabaseInterface; public notificationServices: NotificationServices; private services: AuthenticationServices; private hooks: Emittery; - constructor(options: AccountsServerOptions) { - this.options = { ...defaultOptions, ...options }; - if (!this.options.db) { + constructor(config: Configuration) { + this.config = { ...defaultConfig, ...config }; + if (!this.config.db) { throw new AccountsError('A database driver is required'); } - if (!this.options.tokenManager) { + if (!this.config.tokenManager) { throw new AccountsError('A tokenManager is required'); } - this.db = this.options.db; - this.tokenManager = this.options.tokenManager; + this.db = this.config.db; + this.tokenManager = this.config.tokenManager; - this.services = this.options.authenticationServices.reduce( + this.services = this.config.authenticationServices.reduce( ( acc: AuthenticationServices, authenticationService: AuthenticationService ) => ({ ...acc, [authenticationService.serviceName]: authenticationService.link(this) }) ,{}) - this.notificationServices = this.options.notificationServices.reduce( + this.notificationServices = this.config.notificationServices.reduce( ( acc: NotificationServices, notificationService: NotificationService ) => ({ ...acc, [notificationService.name]: notificationService }) ,{}) @@ -65,14 +65,6 @@ export class AccountsServer { this.hooks = new Emittery(); } - public getServices(): { [key: string]: AuthenticationService } { - return this.services; - } - - public getOptions(): AccountsServerOptions { - return this.options; - } - public on(eventName: string, callback: HookListener): () => void { this.hooks.on(eventName, callback); @@ -101,8 +93,7 @@ export class AccountsServer { * without authenticating any user identity. * Any authentication should happen before calling this function. * @param {User} userId - The user object. - * @param {string} ip - User's ip. - * @param {string} userAgent - User's client agent. + * @param {ConnectionInformations} connectionInfo - The user connectionInformations. * @returns {Promise} - Session tokens and user object. */ public async loginWithUser(user: User, infos: ConnectionInformations): Promise { @@ -114,16 +105,10 @@ export class AccountsServer { ip, userAgent, }); - const { accessToken, refreshToken } = this.createTokens(token); + const tokens = this.createTokens(token); - const loginResult = { - sessionId, - user: this.sanitizeUser(user), - tokens: { - refreshToken, - accessToken, - }, - }; + const userSafe = this.sanitizeUser(user); + const loginResult = { sessionId, user: userSafe, tokens }; this.hooks.emit(ServerHooks.LoginSuccess, user); return loginResult; @@ -137,8 +122,7 @@ export class AccountsServer { * @description Impersonate to another user. * @param {string} accessToken - User access token. * @param {object} impersonated - impersonated user. - * @param {string} ip - The user ip. - * @param {string} userAgent - User user agent. + * @param {ConnectionInformations} connectionInfo - The user connectionInformations. * @returns {Promise} - ImpersonationResult */ public async impersonate( @@ -148,8 +132,7 @@ export class AccountsServer { username?: string; email?: string; }, - ip: string, - userAgent: string + connectionInfo: ConnectionInformations ): Promise { try { if (!isString(accessToken)) { @@ -187,11 +170,11 @@ export class AccountsServer { throw new AccountsError(`Impersonated user not found`); } - if (!this.options.impersonationAuthorize) { + if (!this.config.impersonationAuthorize) { return { authorized: false }; } - const isAuthorized = await this.options.impersonationAuthorize(user, impersonatedUser); + const isAuthorized = await this.config.impersonationAuthorize(user, impersonatedUser); if (!isAuthorized) { return { authorized: false }; @@ -201,10 +184,7 @@ export class AccountsServer { const newSessionId = await this.db.createSession( impersonatedUser.id, token, - { - ip, - userAgent, - }, + connectionInfo, { impersonatorUserId: user.id } ); const impersonationTokens = this.createTokens(newSessionId, true); @@ -231,15 +211,13 @@ export class AccountsServer { * @description Refresh a user token. * @param {string} accessToken - User access token. * @param {string} refreshToken - User refresh token. - * @param {string} ip - User ip. - * @param {string} userAgent - User user agent. + * @param {ConnectionInformations} connectionInfo - The user connectionInformations. * @returns {Promise} - LoginResult. */ public async refreshTokens( accessToken: string, refreshToken: string, - ip: string, - userAgent: string + connectionInfo: ConnectionInformations ): Promise { try { if (!isString(accessToken) || !isString(refreshToken)) { @@ -269,7 +247,7 @@ export class AccountsServer { throw new AccountsError('User not found', { id: session.userId }); } const tokens = this.createTokens(sessionToken); - await this.db.updateSession(session.id, { ip, userAgent }); + await this.db.updateSession(session.id, connectionInfo); const result = { sessionId: session.id, @@ -350,9 +328,9 @@ export class AccountsServer { throw new AccountsError('User not found', { id: session.userId }); } - if (this.options.resumeSessionValidator) { + if (this.config.resumeSessionValidator) { try { - await this.options.resumeSessionValidator(user, session); + await this.config.resumeSessionValidator(user, session); } catch (e) { throw new AccountsError(e, { id: session.userId }, 403); } @@ -402,15 +380,6 @@ export class AccountsServer { return session; } - /** - * @description Find a user by his id. - * @param {string} userId - User id. - * @returns {Promise} - Return a user or null if not found. - */ - public findUserById(userId: string): Promise { - return this.db.findUserById(userId); - } - /** * @description Change the profile for a user. * @param {string} userId - User id. @@ -440,15 +409,11 @@ export class AccountsServer { return this.db.setProfile(userId, { ...user.profile, ...profile }); } - public sanitizeUser(user: User): User { - const { userObjectSanitizer } = this.options; - - return userObjectSanitizer(this.internalUserSanitizer(user), omit, pick); + public sanitizeUser(user: User): UserSafe { + const { services, ...userSafe } = user; + return this.config.sanitizeUser ? this.config.sanitizeUser(userSafe) : userSafe } - private internalUserSanitizer(user: User): User { - return omit(user, ['services']); - } } export default AccountsServer; diff --git a/packages/server/src/types/accounts-server-options.ts b/packages/server/src/types/configuration.ts similarity index 69% rename from packages/server/src/types/accounts-server-options.ts rename to packages/server/src/types/configuration.ts index 662778832..4fa37a9b7 100644 --- a/packages/server/src/types/accounts-server-options.ts +++ b/packages/server/src/types/configuration.ts @@ -1,14 +1,13 @@ import TokenManager from '@accounts/token-manager'; -import { User, DatabaseInterface, AuthenticationService, NotificationService } from '@accounts/types'; -import { UserObjectSanitizerFunction } from './user-object-sanitizer-function'; +import { User, DatabaseInterface, AuthenticationService, NotificationService, UserSafe } from '@accounts/types'; import { ResumeSessionValidator } from './resume-session-validator'; -export interface AccountsServerOptions { +export interface Configuration { db: DatabaseInterface; tokenManager: TokenManager; authenticationServices?: AuthenticationService[]; notificationServices?: NotificationService[]; - userObjectSanitizer?: UserObjectSanitizerFunction; + sanitizeUser?: (user: UserSafe) => UserSafe; impersonationAuthorize?: (user: User, impersonateToUser: User) => Promise; resumeSessionValidator?: ResumeSessionValidator } diff --git a/packages/server/src/types/user-object-sanitizer-function.ts b/packages/server/src/types/user-object-sanitizer-function.ts deleted file mode 100644 index 901cc2bd2..000000000 --- a/packages/server/src/types/user-object-sanitizer-function.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { User } from '@accounts/types'; - -export type UserObjectSanitizerFunction = ( - userObject: User, - omitFunction: (userDoc: object, fields: string[]) => User, - pickFunction: (userDoc: object, fields: string[]) => User -) => any; diff --git a/packages/types/src/types/user.ts b/packages/types/src/types/user.ts index de2021e24..951121729 100644 --- a/packages/types/src/types/user.ts +++ b/packages/types/src/types/user.ts @@ -1,9 +1,12 @@ import { EmailRecord } from './email-record'; -export interface User { +export interface UserSafe { username?: string; emails?: EmailRecord[]; id: string; profile?: object; - services?: object; } + +export interface User extends UserSafe { + services?: object; +} \ No newline at end of file