From 273136e122a2a514a61f21677ca3f413f4f1cb9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20Schw=C3=B6rer?= Date: Mon, 18 Oct 2021 17:10:58 +0200 Subject: [PATCH] feat: add api endpoint to change ones password --- common/dtos.ts | 5 + lib/controllers/AuthController.ts | 54 +++++++---- lib/controllers/SessionsController.ts | 18 +--- lib/core.ts | 4 +- lib/middlewares/AuthMiddleware.ts | 8 +- lib/schemas/changePassword.json | 17 ++++ lib/services/AuthService.ts | 101 +++++++++++--------- lib/services/InvitationsService.ts | 12 ++- lib/services/SessionsService.ts | 8 +- lib/typings/AuthContext.ts | 8 ++ package-lock.json | 117 ++++++++++++++++++++++++ package.json | 1 + test/controllers/AuthController.test.ts | 96 +++++++++++++++++++ test/services/AuthService.test.ts | 116 +++++++++++++++++++++++ test/testAuthHelpers.ts | 38 ++++++++ test/testHelpers.ts | 14 ++- 16 files changed, 518 insertions(+), 99 deletions(-) create mode 100644 lib/schemas/changePassword.json create mode 100644 lib/typings/AuthContext.ts create mode 100644 test/controllers/AuthController.test.ts create mode 100644 test/services/AuthService.test.ts create mode 100644 test/testAuthHelpers.ts diff --git a/common/dtos.ts b/common/dtos.ts index c42c3b2..e99a797 100644 --- a/common/dtos.ts +++ b/common/dtos.ts @@ -43,6 +43,11 @@ export type RegisterRequestDto = { password: string; }; +export type ChangePasswordRequestDto = { + currentPassword: string; + newPassword: string; +}; + export type UserResponseDto = { user: User; artworkToken: string; diff --git a/lib/controllers/AuthController.ts b/lib/controllers/AuthController.ts index 6bbcce1..605a035 100644 --- a/lib/controllers/AuthController.ts +++ b/lib/controllers/AuthController.ts @@ -1,35 +1,22 @@ import { AuthRequestDto, + ChangePasswordRequestDto, RegisterRequestDto, - User, UserResponseDto, } from '@common'; +import { AuthService } from '@services/AuthService'; import { FastifyPluginAsync } from 'fastify'; import { UAParser } from 'ua-parser-js'; +import { + sendBadRequestError, + sendNotAuthorizedError, +} from '../helpers/responses'; +import ChangePasswordSchema from '../schemas/changePassword.json'; import LoginSchema from '../schemas/login.json'; import RegisterSchema from '../schemas/register.json'; import { InvitationsService } from '../services/InvitationsService'; import { Middleware } from './../middlewares/Middleware'; -type Credentials = { - username: string; - password: string; -}; - -type AuthResult = { - user: User; - sessionToken: string; -}; - -interface AuthService { - authenticate( - credentials: Credentials, - browserInfo: Browser, - ): Promise; - makeJwtToken(): string; - logout(sessionId: string): Promise; -} - type Config = { minimumPasswordLength: number; sessionMaxAge: number; @@ -103,6 +90,33 @@ export function AuthController({ }, ); + router.post<{ Body: ChangePasswordRequestDto }>( + '/password', + { schema: ChangePasswordSchema, preValidation: authMiddleware }, + async (request, reply) => { + const { currentPassword, newPassword } = request.body; + + if (!request.auth.isValidPassword(currentPassword)) { + return sendNotAuthorizedError( + reply, + 'incorrect current password given', + ); + } + + const result = await authService.changeUserPassword({ + activeSessionId: request.auth.getSessionId(), + newPassword, + userId: request.auth.getUserId(), + }); + + if (result instanceof Error) { + return sendBadRequestError(reply, result.message); + } + + return reply.status(204).send(); + }, + ); + router.post<{ Body: RegisterRequestDto }>( '/register', { schema: RegisterSchema }, diff --git a/lib/controllers/SessionsController.ts b/lib/controllers/SessionsController.ts index ea34351..feb9adc 100644 --- a/lib/controllers/SessionsController.ts +++ b/lib/controllers/SessionsController.ts @@ -1,23 +1,11 @@ +import { RevokeSessionRequestDto, UserSessionsResponseDto } from '@common'; +import { SessionsService } from '@services/SessionsService'; import { FastifyPluginAsync } from 'fastify'; -import { - RevokeSessionRequestDto, - UserSession, - UserSessionsResponseDto, -} from '@common'; -import RevokeSessionSchema from '../schemas/revokeSession.json'; import { sendNotAuthorizedError, sendNotFoundError, } from '../helpers/responses'; - -interface SessionsService { - findByIdAndUserId(params: { - id: string; - userId: string; - }): Promise; - findAllByUserId(userId: string): Promise; - deleteById(id: string): Promise; -} +import RevokeSessionSchema from '../schemas/revokeSession.json'; type Injects = { sessionsService: SessionsService; diff --git a/lib/core.ts b/lib/core.ts index 192e8ac..4b52aed 100644 --- a/lib/core.ts +++ b/lib/core.ts @@ -34,8 +34,9 @@ const albumsService = createAlbumsService({ db, songsService }); const artworksService = createArtworksService({ config }); const sessionsService = createSessionsService({ db }); const authService = createAuthService({ + db, + config, sessionsService, - config: config.security, usersService, }); const artistsService = createArtistsService({ @@ -48,6 +49,7 @@ const audioFilesService = createAudioFilesService({ db }); const invitationsService = createInvitationsService({ db, config, + authService, usersService, }); const discoverService = createDiscoverService({ db }); diff --git a/lib/middlewares/AuthMiddleware.ts b/lib/middlewares/AuthMiddleware.ts index da45275..66a9986 100644 --- a/lib/middlewares/AuthMiddleware.ts +++ b/lib/middlewares/AuthMiddleware.ts @@ -1,3 +1,4 @@ +import { AuthContext } from '@typings/AuthContext'; import { FastifyRequest } from 'fastify'; import { sendNotAuthorizedError } from '../helpers/responses'; import { Middleware } from './Middleware'; @@ -17,13 +18,6 @@ type SessionWithUser = { user: UserWithPassword; }; -interface AuthContext { - getUser(): User; - getUserId(): string; - getSessionId(): string; - isValidPassword(password: string): boolean; -} - declare module 'fastify' { interface FastifyRequest { auth: AuthContext; diff --git a/lib/schemas/changePassword.json b/lib/schemas/changePassword.json new file mode 100644 index 0000000..d22bd25 --- /dev/null +++ b/lib/schemas/changePassword.json @@ -0,0 +1,17 @@ +{ + "body": { + "type": "object", + "properties": { + "currentPassword": { + "type": "string" + }, + "newPassword": { + "type": "string" + } + }, + "required": [ + "currentPassword", + "newPassword" + ] + } +} diff --git a/lib/services/AuthService.ts b/lib/services/AuthService.ts index 9925d2d..fbcf02b 100644 --- a/lib/services/AuthService.ts +++ b/lib/services/AuthService.ts @@ -1,53 +1,27 @@ -import jwt from 'jsonwebtoken'; import { User } from '@common'; -import { UserRow } from '../database/rows'; -import { comparePasswords } from '../helpers/passwords'; +import jwt from 'jsonwebtoken'; +import Knex from 'knex'; +import { LeafplayerConfig } from '../config'; +import { comparePasswords, createPasswordHash } from '../helpers/passwords'; import { getCurrentUnixTimestamp } from '../helpers/time'; - -type Config = { - sessionMaxAge: number; - secret: string; -}; +import { SessionsService } from './SessionsService'; +import { UsersService } from './UsersService'; type Credentials = { username: string; password: string; }; -type SessionWithUser = { - id: string; - user: UserRow; -}; - type Browser = { name: string; os: string; }; -type SessionCreateParams = { - userId: string; - browser: Browser; - maxAge: number; -}; - -interface SessionsService { - create(params: SessionCreateParams): Promise; - findWithUserByToken(token: string): Promise; - deleteById(id: string): Promise; -} - -type UserWithPassword = User & { - password: string; -}; - -interface UsersService { - findByUsername(username: string): Promise; -} - type Injects = { - usersService: UsersService; + db: Knex; + config: LeafplayerConfig; sessionsService: SessionsService; - config: Config; + usersService: UsersService; }; export interface AuthService { @@ -55,18 +29,33 @@ export interface AuthService { credentials: Credentials, browser: Browser, ): Promise<{ user: User; sessionToken: string } | Error>; - logout(sessionId: string): Promise; + logout(sessionId: string): Promise; + changeUserPassword(params: { + userId: string; + newPassword: string; + activeSessionId: string; + }): Promise; makeJwtToken(): string; isValidJwtToken(token: string): boolean; + validatePasswordSecurity(password: string): Error | void; } export function createAuthService({ + db, + config: { security: securityConfig }, sessionsService, usersService, - config, }: Injects): AuthService { function generateSessionExpireTimestamp(from: number): number { - return from + config.sessionMaxAge; + return from + securityConfig.sessionMaxAge; + } + + function validatePasswordSecurity(password: string): Error | void { + if (password.length < securityConfig.minimumPasswordLength) { + return Error( + `Password needs at least ${securityConfig.minimumPasswordLength} characters`, + ); + } } return { @@ -80,7 +69,7 @@ export function createAuthService({ const sessionToken = await sessionsService.create({ userId: user.id, browser, - maxAge: config.sessionMaxAge, + maxAge: securityConfig.sessionMaxAge, }); return { @@ -93,27 +82,55 @@ export function createAuthService({ }; }, - async logout(sessionId) { + logout(sessionId) { return sessionsService.deleteById(sessionId); }, + async changeUserPassword({ userId, newPassword, activeSessionId }) { + const pwResult = validatePasswordSecurity(newPassword); + if (pwResult instanceof Error) { + return pwResult; + } + + await db.transaction(async trx => { + await trx('users') + .update({ + password: createPasswordHash(newPassword), + }) + .where({ + id: userId, + }); + + await trx('sessions') + .delete() + .where({ + userId, + }) + .whereNot({ + id: activeSessionId, + }); + }); + }, + makeJwtToken() { return jwt.sign( { exp: generateSessionExpireTimestamp(getCurrentUnixTimestamp()), }, - config.secret, + securityConfig.secret, ); }, isValidJwtToken(token) { try { - jwt.verify(token, config.secret); + jwt.verify(token, securityConfig.secret); return true; } catch (e) { return false; } }, + + validatePasswordSecurity, }; } diff --git a/lib/services/InvitationsService.ts b/lib/services/InvitationsService.ts index ba32301..1247c6e 100644 --- a/lib/services/InvitationsService.ts +++ b/lib/services/InvitationsService.ts @@ -2,6 +2,7 @@ import Knex from 'knex'; import { LeafplayerConfig } from 'lib/config'; import { InvitationRow } from '../database/rows'; import { getCurrentUnixTimestamp } from '../helpers/time'; +import { AuthService } from './AuthService'; import { UsersService } from './UsersService'; export enum CreateInvitationResult { @@ -12,6 +13,7 @@ export enum CreateInvitationResult { type Injects = { db: Knex; config: LeafplayerConfig; + authService: AuthService; usersService: UsersService; }; @@ -32,6 +34,7 @@ export interface InvitationsService { export function createInvitationsService({ db, config: { security: securityConfig }, + authService, usersService, }: Injects): InvitationsService { async function isValidInviteCode(code: string): Promise { @@ -62,10 +65,11 @@ export function createInvitationsService({ return Error('Username taken'); } - if (userDetails.password.length < securityConfig.minimumPasswordLength) { - return Error( - `Password needs at least ${securityConfig.minimumPasswordLength} characters`, - ); + const pwResult = authService.validatePasswordSecurity( + userDetails.password, + ); + if (pwResult instanceof Error) { + return pwResult; } // TODO: a transaction would be nice diff --git a/lib/services/SessionsService.ts b/lib/services/SessionsService.ts index 07eb204..01e61e3 100644 --- a/lib/services/SessionsService.ts +++ b/lib/services/SessionsService.ts @@ -27,7 +27,7 @@ type SessionCreateParams = { export interface SessionsService { create(params: SessionCreateParams): Promise; - deleteById(id: string): Promise; + deleteById(id: string): Promise; findWithUserByToken(token: string): Promise; findByIdAndUserId(params: { id: string; @@ -57,11 +57,7 @@ export function createSessionsService({ db }: Injects): SessionsService { }, async deleteById(id) { - try { - await db('sessions').where({ id }).delete(); - } catch (e) { - return e; - } + await db('sessions').where({ id }).delete(); }, async findByIdAndUserId(params) { diff --git a/lib/typings/AuthContext.ts b/lib/typings/AuthContext.ts new file mode 100644 index 0000000..e4a1829 --- /dev/null +++ b/lib/typings/AuthContext.ts @@ -0,0 +1,8 @@ +import { User } from '@common'; + +export interface AuthContext { + getUser(): User; + getUserId(): string; + getSessionId(): string; + isValidPassword(password: string): boolean; +} diff --git a/package-lock.json b/package-lock.json index 1ef56e3..5cef21d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "node-gyp": "^7.1.2", "nodemon": "^2.0.7", "prettier": "^2.2.1", + "testdouble": "^3.16.2", "ts-node": "^9.1.1", "typescript": "^4.1.3" } @@ -5278,6 +5279,15 @@ "node": ">=8" } }, + "node_modules/is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -5295,6 +5305,15 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "dev": true }, + "node_modules/is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-relative": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", @@ -7535,6 +7554,20 @@ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.2.tgz", "integrity": "sha512-dB15eXv3p2jDlbOiNLyMabYg1/sXvppd8DP2J3EOCQ0AkuSXCW2tP7mnVouVLJKgUMY6yP0kcQDVpLCN13h4Xg==" }, + "node_modules/quibble": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/quibble/-/quibble-0.6.5.tgz", + "integrity": "sha512-L3/bDHWjHm9zdG0Aqj7lhmp6Q5RFjXeitO9CGzWKP83d6BlGS0lLo9oswxgq62gwuIF7apT9tO0dw9kNuvb9eg==", + "dev": true, + "dependencies": { + "lodash": "^4.17.14", + "resolve": "^1.11.1" + }, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.1.tgz", @@ -8938,6 +8971,19 @@ "node": ">=0.10.0" } }, + "node_modules/stringify-object-es5": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/stringify-object-es5/-/stringify-object-es5-2.5.0.tgz", + "integrity": "sha1-BXw8mpChJzObudFwSikLt70KHsU=", + "dev": true, + "dependencies": { + "is-plain-obj": "^1.0.0", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", @@ -9209,12 +9255,33 @@ "node": ">=8" } }, + "node_modules/testdouble": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/testdouble/-/testdouble-3.16.2.tgz", + "integrity": "sha512-oPVoKBXrkP/SaTsF2W7Tpe77whd/jRKbeAVZok1lQKkZ2lFjjFrulpxuHOVF84tMr8PrN8GUUr24Nto/9vs1Lw==", + "dev": true, + "dependencies": { + "lodash": "^4.17.15", + "quibble": "^0.6.4", + "stringify-object-es5": "^2.5.0", + "theredoc": "^1.0.0" + }, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, + "node_modules/theredoc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/theredoc/-/theredoc-1.0.0.tgz", + "integrity": "sha512-KU3SA3TjRRM932jpNfD3u4Ec3bSvedyo5ITPI7zgWYnKep7BwQQaxlhI9qbO+lKJoRnoAbEVfMcAHRuKVYikDA==", + "dev": true + }, "node_modules/through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", @@ -14441,6 +14508,12 @@ "integrity": "sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg==", "dev": true }, + "is-plain-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", + "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=", + "dev": true + }, "is-plain-object": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", @@ -14455,6 +14528,12 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "dev": true }, + "is-regexp": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-1.0.0.tgz", + "integrity": "sha1-/S2INUXEa6xaYz57mgnof6LLUGk=", + "dev": true + }, "is-relative": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", @@ -16235,6 +16314,16 @@ "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.2.tgz", "integrity": "sha512-dB15eXv3p2jDlbOiNLyMabYg1/sXvppd8DP2J3EOCQ0AkuSXCW2tP7mnVouVLJKgUMY6yP0kcQDVpLCN13h4Xg==" }, + "quibble": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/quibble/-/quibble-0.6.5.tgz", + "integrity": "sha512-L3/bDHWjHm9zdG0Aqj7lhmp6Q5RFjXeitO9CGzWKP83d6BlGS0lLo9oswxgq62gwuIF7apT9tO0dw9kNuvb9eg==", + "dev": true, + "requires": { + "lodash": "^4.17.14", + "resolve": "^1.11.1" + } + }, "quick-format-unescaped": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.1.tgz", @@ -17400,6 +17489,16 @@ "strip-ansi": "^3.0.0" } }, + "stringify-object-es5": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/stringify-object-es5/-/stringify-object-es5-2.5.0.tgz", + "integrity": "sha1-BXw8mpChJzObudFwSikLt70KHsU=", + "dev": true, + "requires": { + "is-plain-obj": "^1.0.0", + "is-regexp": "^1.0.0" + } + }, "strip-ansi": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", @@ -17617,12 +17716,30 @@ "integrity": "sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==", "dev": true }, + "testdouble": { + "version": "3.16.2", + "resolved": "https://registry.npmjs.org/testdouble/-/testdouble-3.16.2.tgz", + "integrity": "sha512-oPVoKBXrkP/SaTsF2W7Tpe77whd/jRKbeAVZok1lQKkZ2lFjjFrulpxuHOVF84tMr8PrN8GUUr24Nto/9vs1Lw==", + "dev": true, + "requires": { + "lodash": "^4.17.15", + "quibble": "^0.6.4", + "stringify-object-es5": "^2.5.0", + "theredoc": "^1.0.0" + } + }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", "dev": true }, + "theredoc": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/theredoc/-/theredoc-1.0.0.tgz", + "integrity": "sha512-KU3SA3TjRRM932jpNfD3u4Ec3bSvedyo5ITPI7zgWYnKep7BwQQaxlhI9qbO+lKJoRnoAbEVfMcAHRuKVYikDA==", + "dev": true + }, "through2": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", diff --git a/package.json b/package.json index f084203..519b99c 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "node-gyp": "^7.1.2", "nodemon": "^2.0.7", "prettier": "^2.2.1", + "testdouble": "^3.16.2", "ts-node": "^9.1.1", "typescript": "^4.1.3" } diff --git a/test/controllers/AuthController.test.ts b/test/controllers/AuthController.test.ts new file mode 100644 index 0000000..555d0ce --- /dev/null +++ b/test/controllers/AuthController.test.ts @@ -0,0 +1,96 @@ +import { User } from '@common'; +import { AuthService } from '@services/AuthService'; +import { InvitationsService } from '@services/InvitationsService'; +import anyTest, { TestInterface } from 'ava'; +import { FastifyInstance } from 'fastify'; +import td from 'testdouble'; +import { SecurityConfig } from '../../lib/config'; +import { AuthController } from '../../lib/controllers'; +import { mockAuthMiddleware } from '../testAuthHelpers'; +import { createMinimalServer } from '../testHelpers'; + +const MOCK_USER: User = { + id: '03aefc55-92ed-4210-bded-d6a84f5e3aa1', + displayName: 'Test User', + username: 'testuser', +}; +const MOCK_SESSION_ID = '76264bec-0b46-4ef8-932d-4a41f8bd93eb'; +const MOCK_USER_PASSWORD = 'supersecret'; + +const test = anyTest as TestInterface<{ + server: FastifyInstance; +}>; + +test.beforeEach(t => { + t.context.server = createMinimalServer(); +}); +test.afterEach(async t => t.context.server.close()); + +test('it should call service method when invoking endpoint', async t => { + const authService = td.object(); + + const controller = createController(authService); + + await t.context.server.register(controller); + + const response = await t.context.server.inject({ + method: 'POST', + path: '/password', + payload: { + currentPassword: 'supersecret', + newPassword: 'timeforachange', + }, + }); + + t.is(response.statusCode, 204); + t.notThrows(() => + td.verify( + authService.changeUserPassword({ + activeSessionId: MOCK_SESSION_ID, + newPassword: 'timeforachange', + userId: MOCK_USER.id, + }), + ), + ); +}); + +test('it should send an unauthorized error and not invoke the service method, when supplying an invalid password', async t => { + const authService = td.object(); + + const controller = createController(authService); + + await t.context.server.register(controller); + + const response = await t.context.server.inject({ + method: 'POST', + path: '/password', + payload: { + currentPassword: 'notthepassword', + newPassword: 'timeforachange', + }, + }); + + t.is(response.statusCode, 401); + t.notThrows(() => + td.verify(authService.changeUserPassword(td.matchers.anything()), { + times: 0, + }), + ); +}); + +function createController(authService: AuthService) { + const config = td.object(); + config.minimumPasswordLength = 8; + const invitationsService = td.object(); + + return AuthController({ + config, + authMiddleware: mockAuthMiddleware({ + user: MOCK_USER, + sessionId: MOCK_SESSION_ID, + userPassword: MOCK_USER_PASSWORD, + }), + authService, + invitationsService, + }); +} diff --git a/test/services/AuthService.test.ts b/test/services/AuthService.test.ts new file mode 100644 index 0000000..cf6bb91 --- /dev/null +++ b/test/services/AuthService.test.ts @@ -0,0 +1,116 @@ +import { createAuthService } from '@services/AuthService'; +import { createSessionsService } from '@services/SessionsService'; +import { createUsersService } from '@services/UsersService'; +import anyTest, { TestInterface } from 'ava'; +import Knex from 'knex'; +import { LeafplayerConfig } from '../../lib/config'; +import { + comparePasswords, + createPasswordHash, +} from '../../lib/helpers/passwords'; +import { afterEachHook, beforeEachHook, TestContext } from '../testContext'; + +const test = anyTest as TestInterface; + +test.beforeEach(beforeEachHook); +test.afterEach(afterEachHook); + +function createServiceUnderTest({ + db, + config, +}: { + db: Knex; + config: LeafplayerConfig; +}) { + const sessionsService = createSessionsService({ + db, + }); + + const usersService = createUsersService({ + db, + }); + + return createAuthService({ + config, + db, + sessionsService, + usersService, + }); +} + +const TEST_USER = { + id: 'e7d6b934-a264-4661-a888-53f3363aefe8', + username: 'testuser', + displayName: 'Testuser', + password: createPasswordHash('supersecret'), +}; + +test('changeUserPassword -> it should change a users password', async ({ + context: { config, db }, + ...t +}) => { + await db('users').insert([TEST_USER]); + + const authService = createServiceUnderTest({ db, config }); + + await authService.changeUserPassword({ + userId: TEST_USER.id, + newPassword: 'timeforachange', + activeSessionId: '', + }); + + const updatedUser = await db('users').where({ id: TEST_USER.id }).first(); + + t.true(comparePasswords('timeforachange', updatedUser?.password || '')); +}); + +test('changeUserPassword -> it should invalidate all sessions except the one issuing the request', async ({ + context: { config, db }, + ...t +}) => { + await db('users').insert([TEST_USER]); + await db('sessions').insert([ + { + id: 'd67f709d-b932-4ca9-a85c-9969d4599197', + token: 'supersecret1', + userId: TEST_USER.id, + lastUsedAt: 0, + expiresAt: 0, + }, + { + id: '91651965-e140-486a-9158-aa8fa17df111', + token: 'supersecret2', + userId: TEST_USER.id, + lastUsedAt: 0, + expiresAt: 0, + }, + ]); + + const authService = createServiceUnderTest({ db, config }); + + await authService.changeUserPassword({ + userId: TEST_USER.id, + newPassword: 'timeforachange', + activeSessionId: 'd67f709d-b932-4ca9-a85c-9969d4599197', + }); + + const sessions = await db('sessions').where(true); + + t.is(sessions.length, 1); + t.is(sessions[0].id, 'd67f709d-b932-4ca9-a85c-9969d4599197'); +}); + +test('changeUserPassword -> it should reject an insecure password', async ({ + context: { config, db }, + ...t +}) => { + const authService = createServiceUnderTest({ db, config }); + + const maybeError = await authService.changeUserPassword({ + userId: TEST_USER.id, + newPassword: 'time', + activeSessionId: '', + }); + + t.true(maybeError instanceof Error); +}); diff --git a/test/testAuthHelpers.ts b/test/testAuthHelpers.ts new file mode 100644 index 0000000..2ced746 --- /dev/null +++ b/test/testAuthHelpers.ts @@ -0,0 +1,38 @@ +import { User } from '@common'; +import { Middleware } from '@middlewares/Middleware'; +import { AuthContext } from '@typings/AuthContext'; + +type Params = { + user: User; + sessionId: string; + userPassword: string; +}; + +declare module 'fastify' { + interface FastifyRequest { + auth: AuthContext; + } +} + +export function mockAuthMiddleware({ + user, + sessionId, + userPassword, +}: Params): Middleware { + return async function (request) { + request.auth = { + getUser() { + return user; + }, + getUserId() { + return user.id; + }, + getSessionId() { + return sessionId; + }, + isValidPassword(password: string) { + return userPassword === password; + }, + }; + }; +} diff --git a/test/testHelpers.ts b/test/testHelpers.ts index 11add8c..4cf1d93 100644 --- a/test/testHelpers.ts +++ b/test/testHelpers.ts @@ -1,6 +1,5 @@ import { ExecutionContext } from 'ava'; -import { FastifyInstance } from 'fastify'; -import { createDiscoverService } from '../lib/services/DiscoverService'; +import fastify, { FastifyInstance } from 'fastify'; import { Response as LightMyRequestResponse } from 'light-my-request'; import { v4 as uuidv4 } from 'uuid'; import { createPasswordHash } from '../lib/helpers/passwords'; @@ -10,17 +9,22 @@ import { createArtistsService } from '../lib/services/ArtistsService'; import { createArtworksService } from '../lib/services/ArtworksService'; import { createAudioFilesService } from '../lib/services/AudioFilesService'; import { createAuthService } from '../lib/services/AuthService'; +import { createDiscoverService } from '../lib/services/DiscoverService'; import { createInvitationsService } from '../lib/services/InvitationsService'; +import { createSearchService } from '../lib/services/SearchService'; import { createSessionsService } from '../lib/services/SessionsService'; import { createSongsService } from '../lib/services/SongsService'; import { createUsersService } from '../lib/services/UsersService'; import { TestContext } from './testContext'; -import { createSearchService } from '../lib/services/SearchService'; export async function waitFor(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } +export function createMinimalServer(): FastifyInstance { + return fastify({ logger: process.env.TEST_DEBUG === 'true' }); +} + export async function insertValidTestUser( t: ExecutionContext, ): Promise { @@ -65,8 +69,9 @@ export function createServerFromTestContext( const albumsService = createAlbumsService({ db, songsService }); const artworksService = createArtworksService({ config }); const authService = createAuthService({ + db, usersService, - config: config.security, + config, sessionsService, }); const artistsService = createArtistsService({ @@ -78,6 +83,7 @@ export function createServerFromTestContext( const invitationsService = createInvitationsService({ db, config, + authService, usersService, }); const discoverService = createDiscoverService({ db });