From 2e13666d80bd9a2a46ab8440c66382b2d2d1f18e Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Mon, 26 Dec 2022 22:07:12 +0100 Subject: [PATCH] feat: create trpc router for auth service --- .env.test | 5 + packages/dashboard/package.json | 2 + packages/dashboard/src/server/routers/_app.ts | 2 + .../src/server/routers/auth/auth.router.ts | 12 + .../server/routers/system/system.router.ts | 2 +- .../server/services/auth/auth.service.test.ts | 228 ++++++++++++++++++ .../src/server/services/auth/auth.service.ts | 120 +++++++++ .../src/server/tests/user.factory.ts | 15 ++ pnpm-lock.yaml | 5 +- 9 files changed, 389 insertions(+), 2 deletions(-) create mode 100644 .env.test create mode 100644 packages/dashboard/src/server/routers/auth/auth.router.ts create mode 100644 packages/dashboard/src/server/services/auth/auth.service.test.ts create mode 100644 packages/dashboard/src/server/services/auth/auth.service.ts create mode 100644 packages/dashboard/src/server/tests/user.factory.ts diff --git a/.env.test b/.env.test new file mode 100644 index 00000000..b750bec4 --- /dev/null +++ b/.env.test @@ -0,0 +1,5 @@ +ROOT_FOLDER=/test +ROOT_FOLDER_HOST=/tipi +JWT_SECRET=secret +STORAGE_PATH=/test/storage +APPS_REPO_URL=http://localhost:8080 diff --git a/packages/dashboard/package.json b/packages/dashboard/package.json index 056d33ca..8434309b 100644 --- a/packages/dashboard/package.json +++ b/packages/dashboard/package.json @@ -48,6 +48,7 @@ "sharp": "0.30.7", "superjson": "^1.12.0", "tslib": "^2.4.0", + "uuid": "^9.0.0", "validator": "^13.7.0", "winston": "^3.7.2", "zod": "^3.19.1", @@ -73,6 +74,7 @@ "@types/react-dom": "18.0.3", "@types/semver": "^7.3.12", "@types/testing-library__jest-dom": "^5.14.5", + "@types/uuid": "^8.3.4", "@types/validator": "^13.7.2", "@typescript-eslint/eslint-plugin": "^5.47.1", "@typescript-eslint/parser": "^5.47.1", diff --git a/packages/dashboard/src/server/routers/_app.ts b/packages/dashboard/src/server/routers/_app.ts index 3b08c4f5..7f0ec0fb 100644 --- a/packages/dashboard/src/server/routers/_app.ts +++ b/packages/dashboard/src/server/routers/_app.ts @@ -1,9 +1,11 @@ import { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; import { router } from '../trpc'; +import { authRouter } from './auth/auth.router'; import { systemRouter } from './system/system.router'; export const appRouter = router({ system: systemRouter, + auth: authRouter, }); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/dashboard/src/server/routers/auth/auth.router.ts b/packages/dashboard/src/server/routers/auth/auth.router.ts new file mode 100644 index 00000000..2ce6a54c --- /dev/null +++ b/packages/dashboard/src/server/routers/auth/auth.router.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import AuthService from '../../services/auth/auth.service'; +import { router, publicProcedure, protectedProcedure } from '../../trpc'; + +export const authRouter = router({ + login: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ ctx, input }) => AuthService.login({ ...input }, ctx)), + logout: protectedProcedure.mutation(async ({ ctx }) => AuthService.logout(ctx.session.id)), + register: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ ctx, input }) => AuthService.register({ ...input }, ctx)), + refreshToken: protectedProcedure.mutation(async ({ ctx }) => AuthService.refreshToken(ctx.session.id)), + me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx)), + isConfigured: publicProcedure.query(async ({ ctx }) => AuthService.isConfigured(ctx)), +}); diff --git a/packages/dashboard/src/server/routers/system/system.router.ts b/packages/dashboard/src/server/routers/system/system.router.ts index 2d2f077b..a46ee4ea 100644 --- a/packages/dashboard/src/server/routers/system/system.router.ts +++ b/packages/dashboard/src/server/routers/system/system.router.ts @@ -7,7 +7,7 @@ export type SystemRouterOutput = inferRouterOutputs; export const systemRouter = router({ status: publicProcedure.query(SystemService.status), systemInfo: protectedProcedure.query(SystemService.systemInfo), - getVersion: protectedProcedure.query(SystemService.getVersion), + getVersion: publicProcedure.query(SystemService.getVersion), restart: protectedProcedure.mutation(SystemService.restart), update: protectedProcedure.mutation(SystemService.update), }); diff --git a/packages/dashboard/src/server/services/auth/auth.service.test.ts b/packages/dashboard/src/server/services/auth/auth.service.test.ts new file mode 100644 index 00000000..ec51383a --- /dev/null +++ b/packages/dashboard/src/server/services/auth/auth.service.test.ts @@ -0,0 +1,228 @@ +import * as argon2 from 'argon2'; +import jwt from 'jsonwebtoken'; +import { faker } from '@faker-js/faker'; +import { setConfig } from '../../core/TipiConfig'; +import { createUser } from '../../tests/user.factory'; +import AuthService from './auth.service'; +import { prisma } from '../../db/client'; +import { Context } from '../../context'; +import TipiCache from '../../core/TipiCache'; + +jest.mock('redis'); + +beforeAll(async () => { + setConfig('jwtSecret', 'test'); +}); + +beforeEach(async () => { + await prisma.user.deleteMany(); +}); + +afterAll(async () => { + await prisma.user.deleteMany(); + await prisma.$disconnect(); +}); + +const ctx = { prisma } as Context; + +describe('Login', () => { + it('Should return a valid jsonwebtoken containing a user id', async () => { + // Arrange + const email = faker.internet.email(); + const user = await createUser(email); + + // Act + const { token } = await AuthService.login({ username: email, password: 'password' }, ctx); + const decoded = jwt.verify(token, 'test') as jwt.JwtPayload; + + // Assert + expect(decoded).toBeDefined(); + expect(decoded).toBeDefined(); + expect(decoded).not.toBeNull(); + expect(decoded).toHaveProperty('id'); + expect(decoded.id).toBe(user.id); + expect(decoded).toHaveProperty('iat'); + expect(decoded).toHaveProperty('exp'); + expect(decoded).toHaveProperty('session'); + }); + + it('Should throw if user does not exist', async () => { + await expect(AuthService.login({ username: 'test', password: 'test' }, ctx)).rejects.toThrowError('User not found'); + }); + + it('Should throw if password is incorrect', async () => { + const email = faker.internet.email(); + await createUser(email); + await expect(AuthService.login({ username: email, password: 'wrong' }, ctx)).rejects.toThrowError('Wrong password'); + }); +}); + +describe('Register', () => { + it('Should return valid jsonwebtoken after register', async () => { + // Arrange + const email = faker.internet.email(); + + // Act + const { token } = await AuthService.register({ username: email, password: 'password' }, ctx); + const decoded = jwt.verify(token, 'test') as jwt.JwtPayload; + + // Assert + expect(decoded).toBeDefined(); + expect(decoded).not.toBeNull(); + expect(decoded).toHaveProperty('id'); + expect(decoded).toHaveProperty('iat'); + expect(decoded).toHaveProperty('exp'); + expect(decoded).toHaveProperty('session'); + }); + + it('Should correctly trim and lowercase email', async () => { + // Arrange + const email = faker.internet.email(); + + // Act + await AuthService.register({ username: email, password: 'test' }, ctx); + const user = await prisma.user.findFirst({ where: { username: email.toLowerCase().trim() } }); + + // Assert + expect(user).toBeDefined(); + expect(user?.username).toBe(email.toLowerCase().trim()); + }); + + it('Should throw if user already exists', async () => { + // Arrange + const email = faker.internet.email(); + + // Act & Assert + await createUser(email); + await expect(AuthService.register({ username: email, password: 'test' }, ctx)).rejects.toThrowError('User already exists'); + }); + + it('Should throw if email is not provided', async () => { + await expect(AuthService.register({ username: '', password: 'test' }, ctx)).rejects.toThrowError('Missing email or password'); + }); + + it('Should throw if password is not provided', async () => { + await expect(AuthService.register({ username: faker.internet.email(), password: '' }, ctx)).rejects.toThrowError('Missing email or password'); + }); + + it('Password is correctly hashed', async () => { + // Arrange + const email = faker.internet.email().toLowerCase().trim(); + + // Act + await AuthService.register({ username: email, password: 'test' }, ctx); + const user = await prisma.user.findUnique({ where: { username: email } }); + const isPasswordValid = await argon2.verify(user?.password || '', 'test'); + + // Assert + expect(isPasswordValid).toBe(true); + }); + + it('Should throw if email is invalid', async () => { + await expect(AuthService.register({ username: 'test', password: 'test' }, ctx)).rejects.toThrowError('Invalid username'); + }); +}); + +describe('Test: logout', () => { + it('Should return true if there is no session to delete', async () => { + // Act + const result = await AuthService.logout(); + + // Assert + expect(result).toBe(true); + }); + + it('Should delete session from cache', async () => { + // Arrange + const session = faker.random.alphaNumeric(32); + await TipiCache.set(session, 'test'); + expect(await TipiCache.get(session)).toBe('test'); + + // Act + const result = await AuthService.logout(session); + + // Assert + expect(result).toBe(true); + expect(await TipiCache.get('session')).toBeUndefined(); + }); +}); + +describe('Test: refreshToken', () => { + it('Should return null if session is not provided', async () => { + // Act + const result = await AuthService.refreshToken(); + + // Assert + expect(result).toBeNull(); + }); + + it('Should return null if session is not found in cache', async () => { + // Act + const result = await AuthService.refreshToken('test'); + + // Assert + expect(result).toBeNull(); + }); + + it('Should return a new token if session is found in cache', async () => { + // Arrange + const session = faker.random.alphaNumeric(32); + await TipiCache.set(session, 'test'); + + // Act + const result = await AuthService.refreshToken(session); + + // Assert + expect(result).not.toBeNull(); + expect(result).toHaveProperty('token'); + expect(result?.token).not.toBe(session); + }); + + it('Should put expiration in 6 seconds for old session', async () => { + // Arrange + const session = faker.random.alphaNumeric(32); + await TipiCache.set(session, '1'); + + // Act + const result = await AuthService.refreshToken(session); + const expiration = await TipiCache.ttl(session); + + // Assert + expect(result).not.toBeNull(); + expect(result).toHaveProperty('token'); + expect(result?.token).not.toBe(session); + expect(expiration).toMatchObject({ EX: 6 }); + }); +}); + +describe('Test: me', () => { + it('Should return null if userId is not provided', async () => { + // Act + const result = await AuthService.me(ctx); + + // Assert + expect(result).toBeNull(); + }); + + it('Should return null if user does not exist', async () => { + // Act + const result = await AuthService.me({ ...ctx, session: { userId: 1 } }); + + // Assert + expect(result).toBeNull(); + }); + + it('Should return user if user exists', async () => { + // Arrange + const email = faker.internet.email(); + const user = await createUser(email); + + // Act + const result = await AuthService.me({ ...ctx, session: { userId: user.id } }); + + // Assert + expect(result).not.toBeNull(); + expect(result).toHaveProperty('id'); + expect(result).toHaveProperty('username'); + }); +}); diff --git a/packages/dashboard/src/server/services/auth/auth.service.ts b/packages/dashboard/src/server/services/auth/auth.service.ts new file mode 100644 index 00000000..2bf0c8e6 --- /dev/null +++ b/packages/dashboard/src/server/services/auth/auth.service.ts @@ -0,0 +1,120 @@ +import * as argon2 from 'argon2'; +import { v4 } from 'uuid'; +import jwt from 'jsonwebtoken'; +import validator from 'validator'; +import { User } from '@prisma/client'; +import { getConfig } from '../../core/TipiConfig'; +import TipiCache from '../../core/TipiCache'; +import { Context } from '../../context'; + +type UsernamePasswordInput = { + username: string; + password: string; +}; + +type TokenResponse = { + token: string; +}; + +const login = async (input: UsernamePasswordInput, ctx: Context): Promise => { + const { password, username } = input; + + const user = await ctx.prisma.user.findUnique({ where: { username: username.trim().toLowerCase() } }); + + if (!user) { + throw new Error('User not found'); + } + + const isPasswordValid = await argon2.verify(user.password, password); + + if (!isPasswordValid) { + throw new Error('Wrong password'); + } + + const session = v4(); + const token = jwt.sign({ id: user.id, session }, getConfig().jwtSecret, { expiresIn: '7d' }); + + await TipiCache.set(session, user.id.toString()); + + return { token }; +}; + +const register = async (input: UsernamePasswordInput, ctx: Context): Promise => { + const { password, username } = input; + const email = username.trim().toLowerCase(); + + if (!username || !password) { + throw new Error('Missing email or password'); + } + + if (username.length < 3 || !validator.isEmail(email)) { + throw new Error('Invalid username'); + } + + const user = await ctx.prisma.user.findUnique({ where: { username: email } }); + + if (user) { + throw new Error('User already exists'); + } + + const hash = await argon2.hash(password); + const newUser = await ctx.prisma.user.create({ data: { username: email, password: hash } }); + + const session = v4(); + const token = jwt.sign({ id: newUser.id, session }, getConfig().jwtSecret, { expiresIn: '1d' }); + + await TipiCache.set(session, newUser.id.toString()); + + return { token }; +}; + +const me = async (ctx: Context): Promise | null> => { + if (!ctx.session?.userId) return null; + + const user = await ctx.prisma.user.findUnique({ where: { id: Number(ctx.session?.userId) }, select: { id: true, username: true } }); + + if (!user) return null; + + return user; +}; + +const logout = async (session?: string): Promise => { + if (session) { + await TipiCache.del(session); + } + + return true; +}; + +const refreshToken = async (session?: string): Promise => { + if (!session) return null; + + const userId = await TipiCache.get(session); + if (!userId) return null; + + // Expire token in 6 seconds + await TipiCache.set(session, userId, 6); + + const newSession = v4(); + const token = jwt.sign({ id: userId, session: newSession }, getConfig().jwtSecret, { expiresIn: '1d' }); + await TipiCache.set(newSession, userId); + + return { token }; +}; + +const isConfigured = async (ctx: Context): Promise => { + const count = await ctx.prisma.user.count(); + + return count > 0; +}; + +const AuthService = { + login, + register, + me, + logout, + refreshToken, + isConfigured, +}; + +export default AuthService; diff --git a/packages/dashboard/src/server/tests/user.factory.ts b/packages/dashboard/src/server/tests/user.factory.ts new file mode 100644 index 00000000..73c3c507 --- /dev/null +++ b/packages/dashboard/src/server/tests/user.factory.ts @@ -0,0 +1,15 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { faker } from '@faker-js/faker'; +import * as argon2 from 'argon2'; +import { prisma } from '../db/client'; + +const createUser = async (email?: string) => { + const hash = await argon2.hash('password'); + + const username = email?.toLowerCase().trim() || faker.internet.email().toLowerCase().trim(); + const user = await prisma.user.create({ data: { username, password: hash } }); + + return user; +}; + +export { createUser }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7d0990b..35f14842 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,7 @@ importers: '@types/react-dom': 18.0.3 '@types/semver': ^7.3.12 '@types/testing-library__jest-dom': ^5.14.5 + '@types/uuid': ^8.3.4 '@types/validator': ^13.7.2 '@typescript-eslint/eslint-plugin': ^5.47.1 '@typescript-eslint/parser': ^5.47.1 @@ -128,6 +129,7 @@ importers: sharp: 0.30.7 superjson: 1.12.0 tslib: 2.4.0 + uuid: 9.0.0 validator: 13.7.0 winston: 3.7.2 zod: 3.19.1 @@ -152,6 +154,7 @@ importers: '@types/react-dom': 18.0.3 '@types/semver': 7.3.12 '@types/testing-library__jest-dom': 5.14.5 + '@types/uuid': 8.3.4 '@types/validator': 13.7.2 '@typescript-eslint/eslint-plugin': 5.47.1_txmweb6yn7coi7nfrp22gpyqmy '@typescript-eslint/parser': 5.47.1_lzzuuodtsqwxnvqeq4g4likcqa @@ -2817,7 +2820,7 @@ packages: nopt: 5.0.0 npmlog: 5.0.1 rimraf: 3.0.2 - semver: 7.3.7 + semver: 7.3.8 tar: 6.1.11 transitivePeerDependencies: - encoding