Browse Source

feat: create trpc router for auth service

Nicolas Meienberger 2 years ago
parent
commit
2e13666d80

+ 5 - 0
.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

+ 2 - 0
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",

+ 2 - 0
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;

+ 12 - 0
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)),
+});

+ 1 - 1
packages/dashboard/src/server/routers/system/system.router.ts

@@ -7,7 +7,7 @@ export type SystemRouterOutput = inferRouterOutputs<typeof systemRouter>;
 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),
 });

+ 228 - 0
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');
+  });
+});

+ 120 - 0
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<TokenResponse> => {
+  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<TokenResponse> => {
+  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<Pick<User, 'id' | 'username'> | 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<boolean> => {
+  if (session) {
+    await TipiCache.del(session);
+  }
+
+  return true;
+};
+
+const refreshToken = async (session?: string): Promise<TokenResponse | null> => {
+  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<boolean> => {
+  const count = await ctx.prisma.user.count();
+
+  return count > 0;
+};
+
+const AuthService = {
+  login,
+  register,
+  me,
+  logout,
+  refreshToken,
+  isConfigured,
+};
+
+export default AuthService;

+ 15 - 0
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 };

+ 4 - 1
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