Преглед изворни кода

refactor(server): move from jwt session to cookie based session

Nicolas Meienberger пре 2 година
родитељ
комит
4eaf727ef8

+ 3 - 0
package.json

@@ -46,8 +46,10 @@
     "@trpc/server": "^10.16.0",
     "argon2": "^0.30.3",
     "clsx": "^1.1.1",
+    "connect-redis": "^7.0.1",
     "drizzle-orm": "^0.24.1",
     "express": "^4.17.3",
+    "express-session": "^1.17.3",
     "fs-extra": "^11.1.0",
     "isomorphic-fetch": "^3.0.0",
     "jsonwebtoken": "^9.0.0",
@@ -88,6 +90,7 @@
     "@total-typescript/shoehorn": "^0.1.0",
     "@total-typescript/ts-reset": "^0.4.2",
     "@types/express": "^4.17.13",
+    "@types/express-session": "^1.17.7",
     "@types/fs-extra": "^11.0.1",
     "@types/isomorphic-fetch": "^0.0.36",
     "@types/jest": "^29.5.0",

+ 57 - 1
pnpm-lock.yaml

@@ -58,12 +58,18 @@ dependencies:
   clsx:
     specifier: ^1.1.1
     version: 1.2.1
+  connect-redis:
+    specifier: ^7.0.1
+    version: 7.0.1(express-session@1.17.3)
   drizzle-orm:
     specifier: ^0.24.1
     version: 0.24.1(@types/pg@8.6.6)(pg@8.10.0)
   express:
     specifier: ^4.17.3
     version: 4.18.2
+  express-session:
+    specifier: ^1.17.3
+    version: 1.17.3
   fs-extra:
     specifier: ^11.1.0
     version: 11.1.0
@@ -180,6 +186,9 @@ devDependencies:
   '@types/express':
     specifier: ^4.17.13
     version: 4.17.17
+  '@types/express-session':
+    specifier: ^1.17.7
+    version: 1.17.7
   '@types/fs-extra':
     specifier: ^11.0.1
     version: 11.0.1
@@ -2388,6 +2397,12 @@ packages:
       '@types/range-parser': 1.2.4
     dev: true
 
+  /@types/express-session@1.17.7:
+    resolution: {integrity: sha512-L25080PBYoRLu472HY/HNCxaXY8AaGgqGC8/p/8+BYMhG0RDOLQ1wpXOpAzr4Gi5TGozTKyJv5BVODM5UNyVMw==}
+    dependencies:
+      '@types/express': 4.17.17
+    dev: true
+
   /@types/express@4.17.17:
     resolution: {integrity: sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q==}
     dependencies:
@@ -3567,6 +3582,15 @@ packages:
     resolution: {integrity: sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==}
     dev: true
 
+  /connect-redis@7.0.1(express-session@1.17.3):
+    resolution: {integrity: sha512-xxyhus0nfPw96s0jI5fvRwGGYwJYISgVuJv40OSFV8N4l4ystNHZdoq0w90XhOGbsZTQJGc9Nwr6RnYsVZZv8w==}
+    engines: {node: '>=16'}
+    peerDependencies:
+      express-session: '>=1'
+    dependencies:
+      express-session: 1.17.3
+    dev: false
+
   /console-control-strings@1.1.0:
     resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==}
     dev: false
@@ -3597,7 +3621,6 @@ packages:
   /cookie@0.4.2:
     resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==}
     engines: {node: '>= 0.6'}
-    dev: true
 
   /cookie@0.5.0:
     resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
@@ -4948,6 +4971,22 @@ packages:
       jest-util: 29.5.0
     dev: true
 
+  /express-session@1.17.3:
+    resolution: {integrity: sha512-4+otWXlShYlG1Ma+2Jnn+xgKUZTMJ5QD3YvfilX3AcocOAbIkVylSWEklzALe/+Pu4qV6TYBj5GwOBFfdKqLBw==}
+    engines: {node: '>= 0.8.0'}
+    dependencies:
+      cookie: 0.4.2
+      cookie-signature: 1.0.6
+      debug: 2.6.9
+      depd: 2.0.0
+      on-headers: 1.0.2
+      parseurl: 1.3.3
+      safe-buffer: 5.2.1
+      uid-safe: 2.1.5
+    transitivePeerDependencies:
+      - supports-color
+    dev: false
+
   /express@4.18.2:
     resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==}
     engines: {node: '>= 0.10.0'}
@@ -7530,6 +7569,11 @@ packages:
       ee-first: 1.1.1
     dev: false
 
+  /on-headers@1.0.2:
+    resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==}
+    engines: {node: '>= 0.8'}
+    dev: false
+
   /once@1.4.0:
     resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
     dependencies:
@@ -7931,6 +7975,11 @@ packages:
     resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
     dev: true
 
+  /random-bytes@1.0.0:
+    resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==}
+    engines: {node: '>= 0.8'}
+    dev: false
+
   /range-parser@1.2.1:
     resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
     engines: {node: '>= 0.6'}
@@ -9051,6 +9100,13 @@ packages:
     hasBin: true
     dev: true
 
+  /uid-safe@2.1.5:
+    resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==}
+    engines: {node: '>= 0.8'}
+    dependencies:
+      random-bytes: 1.0.0
+    dev: false
+
   /unbox-primitive@1.0.2:
     resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
     dependencies:

+ 7 - 8
src/server/context.ts

@@ -1,14 +1,15 @@
 import { inferAsyncReturnType } from '@trpc/server';
 import { CreateNextContextOptions } from '@trpc/server/adapters/next';
-import { getServerAuthSession } from './common/get-server-auth-session';
 
 type Session = {
   userId?: number;
-  id?: string;
 };
 
 type CreateContextOptions = {
-  session: Session | null;
+  req: CreateNextContextOptions['req'] & {
+    session?: Session;
+  };
+  res: CreateNextContextOptions['res'];
 };
 
 /**
@@ -20,7 +21,7 @@ type CreateContextOptions = {
  * @see https://create.t3.gg/en/usage/trpc#-servertrpccontextts
  */
 export const createContextInner = async (opts: CreateContextOptions) => ({
-  session: opts.session,
+  ...opts,
 });
 
 /**
@@ -31,11 +32,9 @@ export const createContextInner = async (opts: CreateContextOptions) => ({
 export const createContext = async (opts: CreateNextContextOptions) => {
   const { req, res } = opts;
 
-  // Get the session from the server using the unstable_getServerSession wrapper function
-  const session = await getServerAuthSession({ req, res });
-
   return createContextInner({
-    session,
+    req,
+    res,
   });
 };
 

+ 4 - 0
src/server/index.ts

@@ -2,6 +2,7 @@
 /* eslint-disable global-require */
 import express from 'express';
 import { parse } from 'url';
+
 import type { NextServer } from 'next/dist/server/next';
 import { EventDispatcher } from './core/EventDispatcher';
 import { getConfig, setConfig } from './core/TipiConfig';
@@ -9,6 +10,7 @@ import { Logger } from './core/Logger';
 import { runPostgresMigrations } from './run-migration';
 import { AppServiceClass } from './services/apps/apps.service';
 import { db } from './db';
+import { sessionMiddleware } from './middlewares/session.middleware';
 
 let conf = {};
 let nextApp: NextServer;
@@ -33,6 +35,8 @@ nextApp.prepare().then(async () => {
   const app = express();
   app.disable('x-powered-by');
 
+  app.use(sessionMiddleware);
+
   app.use('/static', express.static(`${getConfig().rootFolder}/repos/${getConfig().appsRepoId}/`));
 
   app.all('*', (req, res) => {

+ 26 - 0
src/server/middlewares/session.middleware.ts

@@ -0,0 +1,26 @@
+import RedisStore from 'connect-redis';
+import session from 'express-session';
+import { createClient } from 'redis';
+import { getConfig } from '../core/TipiConfig';
+
+// Initialize client.
+const redisClient = createClient({
+  url: `redis://${getConfig().REDIS_HOST}:6379`,
+});
+redisClient.connect();
+
+const redisStore = new RedisStore({
+  client: redisClient,
+  prefix: 'tipi:',
+});
+
+const COOKIE_MAX_AGE = 1000 * 60 * 60 * 24; // 1 day
+
+export const sessionMiddleware = session({
+  name: 'tipi.sid',
+  cookie: { maxAge: COOKIE_MAX_AGE, httpOnly: true, secure: false, sameSite: false },
+  store: redisStore,
+  resave: false, // required: force lightweight session keep alive (touch)
+  saveUninitialized: false, // recommended: only save session when data exists
+  secret: getConfig().jwtSecret,
+});

+ 12 - 11
src/server/routers/auth/auth.router.test.ts

@@ -1,9 +1,10 @@
+import { fromPartial } from '@total-typescript/shoehorn';
 import { authRouter } from './auth.router';
 
 describe('Test: verifyTotp', () => {
   it('should be accessible without an account', async () => {
     // arrange
-    const caller = authRouter.createCaller({ session: null });
+    const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
     let error;
 
     // act
@@ -23,7 +24,7 @@ describe('Test: verifyTotp', () => {
 describe('Test: getTotpUri', () => {
   it('should not be accessible without an account', async () => {
     // arrange
-    const caller = authRouter.createCaller({ session: null });
+    const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
     let error;
 
     // act
@@ -39,7 +40,7 @@ describe('Test: getTotpUri', () => {
 
   it('should be accessible with an account', async () => {
     // arrange
-    const caller = authRouter.createCaller({ session: { userId: 123456 } });
+    const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 123456 } } }));
     let error;
 
     // act
@@ -57,7 +58,7 @@ describe('Test: getTotpUri', () => {
 describe('Test: setupTotp', () => {
   it('should not be accessible without an account', async () => {
     // arrange
-    const caller = authRouter.createCaller({ session: null });
+    const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
     let error;
 
     // act
@@ -73,7 +74,7 @@ describe('Test: setupTotp', () => {
 
   it('should be accessible with an account', async () => {
     // arrange
-    const caller = authRouter.createCaller({ session: { userId: 123456 } });
+    const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 123456 } } }));
     let error;
 
     // act
@@ -91,7 +92,7 @@ describe('Test: setupTotp', () => {
 describe('Test: disableTotp', () => {
   it('should not be accessible without an account', async () => {
     // arrange
-    const caller = authRouter.createCaller({ session: null });
+    const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
     let error;
 
     // act
@@ -107,7 +108,7 @@ describe('Test: disableTotp', () => {
 
   it('should be accessible with an account', async () => {
     // arrange
-    const caller = authRouter.createCaller({ session: { userId: 122 } });
+    const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 123456 } } }));
     let error;
 
     // act
@@ -126,7 +127,7 @@ describe('Test: disableTotp', () => {
 describe('Test: changeOperatorPassword', () => {
   it('should be accessible without an account', async () => {
     // arrange
-    const caller = authRouter.createCaller({ session: null });
+    const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
     let error;
 
     // act
@@ -142,7 +143,7 @@ describe('Test: changeOperatorPassword', () => {
 
   it('should be accessible with an account', async () => {
     // arrange
-    const caller = authRouter.createCaller({ session: { userId: 122 } });
+    const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 122 } } }));
     let error;
 
     // act
@@ -160,7 +161,7 @@ describe('Test: changeOperatorPassword', () => {
 describe('Test: resetPassword', () => {
   it('should not be accessible without an account', async () => {
     // arrange
-    const caller = authRouter.createCaller({ session: null });
+    const caller = authRouter.createCaller(fromPartial({ req: { session: {} } }));
     let error;
 
     // act
@@ -176,7 +177,7 @@ describe('Test: resetPassword', () => {
 
   it('should be accessible with an account', async () => {
     // arrange
-    const caller = authRouter.createCaller({ session: { userId: 122 } });
+    const caller = authRouter.createCaller(fromPartial({ req: { session: { userId: 122 } } }));
     let error;
 
     // act

+ 9 - 10
src/server/routers/auth/auth.router.ts

@@ -6,11 +6,10 @@ import { db } from '../../db';
 const AuthService = new AuthServiceClass(db);
 
 export const authRouter = router({
-  login: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input }) => AuthService.login({ ...input })),
-  logout: protectedProcedure.mutation(async ({ ctx }) => AuthServiceClass.logout(ctx.session.id)),
-  register: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input }) => AuthService.register({ ...input })),
-  refreshToken: protectedProcedure.mutation(async ({ ctx }) => AuthService.refreshToken(ctx.session.id)),
-  me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.session?.userId)),
+  login: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input, ctx }) => AuthService.login({ ...input }, ctx.req)),
+  logout: protectedProcedure.mutation(async ({ ctx }) => AuthServiceClass.logout(ctx.req)),
+  register: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input, ctx }) => AuthService.register({ ...input }, ctx.req)),
+  me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.req.session?.userId)),
   isConfigured: publicProcedure.query(async () => AuthService.isConfigured()),
   // Password
   checkPasswordChangeRequest: publicProcedure.query(AuthServiceClass.checkPasswordChangeRequest),
@@ -18,10 +17,10 @@ export const authRouter = router({
   cancelPasswordChangeRequest: publicProcedure.mutation(AuthServiceClass.cancelPasswordChangeRequest),
   changePassword: protectedProcedure
     .input(z.object({ currentPassword: z.string(), newPassword: z.string() }))
-    .mutation(({ input, ctx }) => AuthService.changePassword({ userId: Number(ctx.session.userId), ...input })),
+    .mutation(({ input, ctx }) => AuthService.changePassword({ userId: Number(ctx.req.session.userId), ...input })),
   // Totp
-  verifyTotp: publicProcedure.input(z.object({ totpSessionId: z.string(), totpCode: z.string() })).mutation(({ input }) => AuthService.verifyTotp(input)),
-  getTotpUri: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.getTotpUri({ userId: Number(ctx.session.userId), password: input.password })),
-  setupTotp: protectedProcedure.input(z.object({ totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.setupTotp({ userId: Number(ctx.session.userId), totpCode: input.totpCode })),
-  disableTotp: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.disableTotp({ userId: Number(ctx.session.userId), password: input.password })),
+  verifyTotp: publicProcedure.input(z.object({ totpSessionId: z.string(), totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.verifyTotp(input, ctx.req)),
+  getTotpUri: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.getTotpUri({ userId: Number(ctx.req.session.userId), password: input.password })),
+  setupTotp: protectedProcedure.input(z.object({ totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.setupTotp({ userId: Number(ctx.req.session.userId), totpCode: input.totpCode })),
+  disableTotp: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.disableTotp({ userId: Number(ctx.req.session.userId), password: input.password })),
 });

+ 61 - 130
src/server/services/auth/auth.service.test.ts

@@ -1,10 +1,9 @@
 import fs from 'fs-extra';
 import * as argon2 from 'argon2';
-import jwt from 'jsonwebtoken';
 import { faker } from '@faker-js/faker';
 import { TotpAuthenticator } from '@/server/utils/totp';
 import { generateSessionId } from '@/server/common/get-server-auth-session';
-import { fromAny } from '@total-typescript/shoehorn';
+import { fromAny, fromPartial } from '@total-typescript/shoehorn';
 import { mockInsert, mockSelect } from '@/server/tests/drizzle-helpers';
 import { createDatabase, clearDatabase, closeDatabase, TestDatabase } from '@/server/tests/test-utils';
 import { encrypt } from '../../utils/encryption';
@@ -35,56 +34,49 @@ afterAll(async () => {
 });
 
 describe('Login', () => {
-  it('Should return a valid jsonwebtoken containing a user id', async () => {
-    // Arrange
+  it('Should correclty set session on request object', async () => {
+    // arrange
+    const req = { session: { userId: undefined } };
     const email = faker.internet.email();
     const user = await createUser({ email }, database);
 
-    // Act
-    const { token } = await AuthService.login({ username: email, password: 'password' });
-    const decoded = jwt.verify(token as string, 'test') as jwt.JwtPayload;
+    // act
+    await AuthService.login({ username: email, password: 'password' }, fromPartial(req));
 
-    // 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');
+    // assert
+    expect(req.session.userId).toBe(user.id);
   });
 
   it('Should throw if user does not exist', async () => {
-    await expect(AuthService.login({ username: 'test', password: 'test' })).rejects.toThrowError('User not found');
+    await expect(AuthService.login({ username: 'test', password: 'test' }, fromPartial({}))).rejects.toThrowError('User not found');
   });
 
   it('Should throw if password is incorrect', async () => {
     const email = faker.internet.email();
     await createUser({ email }, database);
-    await expect(AuthService.login({ username: email, password: 'wrong' })).rejects.toThrowError('Wrong password');
+    await expect(AuthService.login({ username: email, password: 'wrong' }, fromPartial({}))).rejects.toThrowError('Wrong password');
   });
 
   // TOTP
-  it('should return a totp session id the user totpEnabled is true', async () => {
+  it('should return a totp session if the user totpEnabled is true', async () => {
     // arrange
     const email = faker.internet.email();
     const totpSecret = TotpAuthenticator.generateSecret();
     await createUser({ email, totpEnabled: true, totpSecret }, database);
 
     // act
-    const { totpSessionId, token } = await AuthService.login({ username: email, password: 'password' });
+    const { totpSessionId } = await AuthService.login({ username: email, password: 'password' }, fromPartial({}));
 
     // assert
     expect(totpSessionId).toBeDefined();
     expect(totpSessionId).not.toBeNull();
-    expect(token).toBeUndefined();
   });
 });
 
 describe('Test: verifyTotp', () => {
   it('should return a valid jsonwebtoken if the totp is correct', async () => {
     // arrange
+    const req = { session: { userId: undefined } };
     const email = faker.internet.email();
     const salt = faker.random.word();
     const totpSecret = TotpAuthenticator.generateSecret();
@@ -97,11 +89,14 @@ describe('Test: verifyTotp', () => {
     await TipiCache.set(totpSessionId, user.id.toString());
 
     // act
-    const { token } = await AuthService.verifyTotp({ totpSessionId, totpCode: otp });
+    const result = await AuthService.verifyTotp({ totpSessionId, totpCode: otp }, fromPartial(req));
 
     // assert
-    expect(token).toBeDefined();
-    expect(token).not.toBeNull();
+    expect(result).toBeTruthy();
+    expect(result).not.toBeNull();
+    expect(req.session.userId).toBeDefined();
+    expect(req.session.userId).not.toBeNull();
+    expect(req.session.userId).toBe(user.id);
   });
 
   it('should throw if the totp is incorrect', async () => {
@@ -115,7 +110,7 @@ describe('Test: verifyTotp', () => {
     await TipiCache.set(totpSessionId, user.id.toString());
 
     // act & assert
-    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: 'wrong' })).rejects.toThrowError('Invalid TOTP');
+    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: 'wrong' }, fromPartial({}))).rejects.toThrowError('Invalid TOTP');
   });
 
   it('should throw if the totpSessionId is invalid', async () => {
@@ -131,7 +126,7 @@ describe('Test: verifyTotp', () => {
     await TipiCache.set(totpSessionId, user.id.toString());
 
     // act & assert
-    await expect(AuthService.verifyTotp({ totpSessionId: 'wrong', totpCode: otp })).rejects.toThrowError('TOTP session not found');
+    await expect(AuthService.verifyTotp({ totpSessionId: 'wrong', totpCode: otp }, fromPartial({}))).rejects.toThrowError('TOTP session not found');
   });
 
   it('should throw if the user does not exist', async () => {
@@ -140,7 +135,7 @@ describe('Test: verifyTotp', () => {
     await TipiCache.set(totpSessionId, '1234');
 
     // act & assert
-    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: '1234' })).rejects.toThrowError('User not found');
+    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: '1234' }, fromPartial({}))).rejects.toThrowError('User not found');
   });
 
   it('should throw if the user totpEnabled is false', async () => {
@@ -156,7 +151,7 @@ describe('Test: verifyTotp', () => {
     await TipiCache.set(totpSessionId, user.id.toString());
 
     // act & assert
-    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: otp })).rejects.toThrowError('TOTP is not enabled for this user');
+    await expect(AuthService.verifyTotp({ totpSessionId, totpCode: otp }, fromPartial({}))).rejects.toThrowError('TOTP is not enabled for this user');
   });
 });
 
@@ -359,32 +354,29 @@ describe('Test: disableTotp', () => {
 });
 
 describe('Register', () => {
-  it('Should return valid jsonwebtoken after register', async () => {
-    // Arrange
+  it('Should correctly set session on request object', async () => {
+    // arrange
+    const req = { session: { userId: undefined } };
     const email = faker.internet.email();
 
-    // Act
-    const { token } = await AuthService.register({ username: email, password: 'password' });
-    const decoded = jwt.verify(token, 'test') as jwt.JwtPayload;
+    // act
+    const result = await AuthService.register({ username: email, password: 'password' }, fromPartial(req));
 
-    // Assert
-    expect(decoded).toBeDefined();
-    expect(decoded).not.toBeNull();
-    expect(decoded).toHaveProperty('id');
-    expect(decoded).toHaveProperty('iat');
-    expect(decoded).toHaveProperty('exp');
-    expect(decoded).toHaveProperty('session');
+    // assert
+    expect(result).toBeTruthy();
+    expect(result).not.toBeNull();
+    expect(req.session.userId).toBeDefined();
   });
 
   it('Should correctly trim and lowercase email', async () => {
-    // Arrange
+    // arrange
     const email = faker.internet.email();
 
-    // Act
-    await AuthService.register({ username: email, password: 'test' });
+    // act
+    await AuthService.register({ username: email, password: 'test' }, fromPartial({ session: {} }));
     const user = await getUserByEmail(email.toLowerCase().trim(), database);
 
-    // Assert
+    // assert
     expect(user).toBeDefined();
     expect(user?.username).toBe(email.toLowerCase().trim());
   });
@@ -395,7 +387,9 @@ describe('Register', () => {
 
     // Act & Assert
     await createUser({ email, operator: true }, database);
-    await expect(AuthService.register({ username: email, password: 'test' })).rejects.toThrowError('There is already an admin user. Please login to create a new user from the admin panel.');
+    await expect(AuthService.register({ username: email, password: 'test' }, fromPartial({}))).rejects.toThrowError(
+      'There is already an admin user. Please login to create a new user from the admin panel.',
+    );
   });
 
   it('Should throw if user already exists', async () => {
@@ -404,130 +398,67 @@ describe('Register', () => {
 
     // Act & Assert
     await createUser({ email, operator: false }, database);
-    await expect(AuthService.register({ username: email, password: 'test' })).rejects.toThrowError('User already exists');
+    await expect(AuthService.register({ username: email, password: 'test' }, fromPartial({}))).rejects.toThrowError('User already exists');
   });
 
   it('Should throw if email is not provided', async () => {
-    await expect(AuthService.register({ username: '', password: 'test' })).rejects.toThrowError('Missing email or password');
+    await expect(AuthService.register({ username: '', password: 'test' }, fromPartial({}))).rejects.toThrowError('Missing email or password');
   });
 
   it('Should throw if password is not provided', async () => {
-    await expect(AuthService.register({ username: faker.internet.email(), password: '' })).rejects.toThrowError('Missing email or password');
+    await expect(AuthService.register({ username: faker.internet.email(), password: '' }, fromPartial({}))).rejects.toThrowError('Missing email or password');
   });
 
   it('Password is correctly hashed', async () => {
-    // Arrange
+    // arrange
     const email = faker.internet.email().toLowerCase().trim();
 
-    // Act
-    await AuthService.register({ username: email, password: 'test' });
+    // act
+    await AuthService.register({ username: email, password: 'test' }, fromPartial({ session: {} }));
     const user = await getUserByEmail(email, database);
     const isPasswordValid = await argon2.verify(user?.password || '', 'test');
 
-    // Assert
+    // assert
     expect(isPasswordValid).toBe(true);
   });
 
   it('Should throw if email is invalid', async () => {
-    await expect(AuthService.register({ username: 'test', password: 'test' })).rejects.toThrowError('Invalid username');
+    await expect(AuthService.register({ username: 'test', password: 'test' }, fromPartial({}))).rejects.toThrowError('Invalid username');
   });
 
   it('should throw if db fails to insert user', async () => {
     // Arrange
+    const req = {};
     const email = faker.internet.email();
     const mockDatabase = { select: mockSelect([]), insert: mockInsert([]) };
     const newAuthService = new AuthServiceClass(fromAny(mockDatabase));
 
     // Act & Assert
-    await expect(newAuthService.register({ username: email, password: 'test' })).rejects.toThrowError('Error creating user');
+    await expect(newAuthService.register({ username: email, password: 'test' }, fromPartial(req))).rejects.toThrowError('Error creating user');
   });
 });
 
 describe('Test: logout', () => {
   it('Should return true if there is no session to delete', async () => {
-    // Act
-    const result = await AuthServiceClass.logout();
+    // act
+    const req = {};
+    const result = await AuthServiceClass.logout(fromPartial(req));
 
-    // Assert
+    // 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');
+  it('Should destroy session upon logount', async () => {
+    // arrange
+    const destroy = jest.fn();
+    const req = { session: { userId: 1, destroy } };
 
-    // Act
-    const result = await AuthServiceClass.logout(session);
+    // act
+    const result = await AuthServiceClass.logout(fromPartial(req));
 
-    // Assert
+    // 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 and user exists', async () => {
-    // Arrange
-    const session = faker.random.alphaNumeric(32);
-    const fakeId = faker.datatype.number();
-    await createUser({ id: fakeId }, database);
-
-    await TipiCache.set(session, fakeId.toString());
-
-    // Act
-    const result = await AuthService.refreshToken(session);
-
-    // Assert
-    expect(result).not.toBeNull();
-    expect(result).toHaveProperty('token');
-    expect(result?.token).not.toBe(session);
-  });
-
-  it('Should return null if user does not exist', async () => {
-    // Arrange
-    const session = faker.random.alphaNumeric(32);
-    await TipiCache.set(session, '1');
-
-    // Act
-    const result = await AuthService.refreshToken(session);
-
-    // Assert
-    expect(result).toBeNull();
-  });
-
-  it('Should put expiration in 6 seconds for old session', async () => {
-    // Arrange
-    const session = faker.random.alphaNumeric(32);
-    await createUser({ id: 1 }, database);
-    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 });
+    expect(destroy).toHaveBeenCalled();
   });
 });
 

+ 25 - 58
src/server/services/auth/auth.service.ts

@@ -1,10 +1,12 @@
 import * as argon2 from 'argon2';
-import jwt from 'jsonwebtoken';
 import validator from 'validator';
 import { TotpAuthenticator } from '@/server/utils/totp';
 import { generateSessionId } from '@/server/common/get-server-auth-session';
 import { NodePgDatabase } from 'drizzle-orm/node-postgres';
 import { AuthQueries } from '@/server/queries/auth/auth.queries';
+import { Context } from '@/server/context';
+import { NextApiRequest } from 'next/types';
+import { Logger } from '@/server/core/Logger';
 import { getConfig } from '../../core/TipiConfig';
 import TipiCache from '../../core/TipiCache';
 import { fileExists, unlinkFile } from '../../common/fs.helpers';
@@ -15,10 +17,6 @@ type UsernamePasswordInput = {
   password: string;
 };
 
-type TokenResponse = {
-  token: string;
-};
-
 export class AuthServiceClass {
   private queries;
 
@@ -30,9 +28,10 @@ export class AuthServiceClass {
    * Authenticate user with given username and password
    *
    * @param {UsernamePasswordInput} input - An object containing the user's username and password
+   * @param {NextApiRequest} req - The Next.js request object
    * @returns {Promise<{token:string}>} - A promise that resolves to an object containing the JWT token
    */
-  public login = async (input: UsernamePasswordInput) => {
+  public login = async (input: UsernamePasswordInput, req: Context['req']) => {
     const { password, username } = input;
     const user = await this.queries.getUserByUsername(username);
 
@@ -46,19 +45,15 @@ export class AuthServiceClass {
       throw new Error('Wrong password');
     }
 
-    const session = generateSessionId('auth');
-
     if (user.totpEnabled) {
       const totpSessionId = generateSessionId('otp');
       await TipiCache.set(totpSessionId, user.id.toString());
       return { totpSessionId };
     }
 
-    const token = jwt.sign({ id: user.id, session }, getConfig().jwtSecret, { expiresIn: '7d' });
-
-    await TipiCache.set(session, user.id.toString());
+    req.session.userId = user.id;
 
-    return { token };
+    return {};
   };
 
   /**
@@ -67,9 +62,10 @@ export class AuthServiceClass {
    * @param {object} params - An object containing the TOTP session ID and the TOTP code
    * @param {string} params.totpSessionId - The TOTP session ID
    * @param {string} params.totpCode - The TOTP code
+   * @param {NextApiRequest} req - The Next.js request object
    * @returns {Promise<{token:string}>} - A promise that resolves to an object containing the JWT token
    */
-  public verifyTotp = async (params: { totpSessionId: string; totpCode: string }) => {
+  public verifyTotp = async (params: { totpSessionId: string; totpCode: string }, req: Context['req']) => {
     const { totpSessionId, totpCode } = params;
     const userId = await TipiCache.get(totpSessionId);
 
@@ -94,12 +90,9 @@ export class AuthServiceClass {
       throw new Error('Invalid TOTP code');
     }
 
-    const session = generateSessionId('otp');
-    const token = jwt.sign({ id: user.id, session }, getConfig().jwtSecret, { expiresIn: '7d' });
+    req.session.userId = user.id;
 
-    await TipiCache.set(session, user.id.toString());
-
-    return { token };
+    return true;
   };
 
   /**
@@ -203,10 +196,11 @@ export class AuthServiceClass {
    * Creates a new user with the provided email and password and returns a session token
    *
    * @param {UsernamePasswordInput} input - An object containing the email and password fields
+   * @param {NextApiRequest} req - The Next.js request object
    * @returns {Promise<{token: string}>} - An object containing the session token
    * @throws {Error} - If the email or password is missing, the email is invalid or the user already exists
    */
-  public register = async (input: UsernamePasswordInput) => {
+  public register = async (input: UsernamePasswordInput, req: Context['req']) => {
     const operators = await this.queries.getOperators();
 
     if (operators.length > 0) {
@@ -238,12 +232,9 @@ export class AuthServiceClass {
       throw new Error('Error creating user');
     }
 
-    const session = generateSessionId('auth');
-    const token = jwt.sign({ id: newUser.id, session }, getConfig().jwtSecret, { expiresIn: '1d' });
-
-    await TipiCache.set(session, newUser.id.toString());
+    req.session.userId = newUser.id;
 
-    return { token };
+    return true;
   };
 
   /**
@@ -265,45 +256,21 @@ export class AuthServiceClass {
   /**
    * Logs out the current user by removing the session token
    *
-   * @param {string} [session] - The session token to log out
+   * @param  {NextApiRequest} req - The Next.js request object
    * @returns {Promise<boolean>} - Returns true if the session token is removed successfully
    */
-  public static logout = async (session?: string): Promise<boolean> => {
-    if (session) {
-      await TipiCache.del(session);
-    }
-
-    return true;
-  };
-
-  /**
-   * Refreshes a user's session token
-   *
-   * @param {string} [session] - The current session token
-   * @returns {Promise<{token: string} | null>} - An object containing the new session token, or null if the session is invalid
-   */
-  public refreshToken = async (session?: string): Promise<TokenResponse | null> => {
-    if (!session) return null;
-
-    const userId = await TipiCache.get(session);
-
-    if (!userId) return null;
-
-    const user = await this.queries.getUserById(Number(userId));
-
-    if (!user) {
-      await TipiCache.delByValue(userId.toString(), 'auth');
-      return null;
+  public static logout = async (req: Context['req']): Promise<boolean> => {
+    if (!req.session) {
+      return true;
     }
 
-    // Expire token in 6 seconds
-    await TipiCache.set(session, userId, 6);
-
-    const newSession = generateSessionId('auth');
-    const token = jwt.sign({ id: userId, session: newSession }, getConfig().jwtSecret, { expiresIn: '1d' });
-    await TipiCache.set(newSession, userId);
+    req.session.destroy((err) => {
+      if (err) {
+        Logger.error(err);
+      }
+    });
 
-    return { token };
+    return true;
   };
 
   /**

+ 16 - 7
src/server/trpc.ts

@@ -2,6 +2,10 @@ import { initTRPC, TRPCError } from '@trpc/server';
 import superjson from 'superjson';
 import { typeToFlattenedError, ZodError } from 'zod';
 import { type Context } from './context';
+import { AuthQueries } from './queries/auth/auth.queries';
+import { db } from './db';
+
+const authQueries = new AuthQueries(db);
 
 /**
  * Convert ZodError to a record
@@ -44,15 +48,20 @@ export const publicProcedure = t.procedure;
  * Reusable middleware to ensure
  * users are logged in
  */
-const isAuthed = t.middleware(({ ctx, next }) => {
-  if (!ctx.session || !ctx.session.userId) {
+const isAuthed = t.middleware(async ({ ctx, next }) => {
+  const userId = ctx.req.session?.userId;
+  if (!userId) {
     throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You need to be logged in to perform this action' });
   }
-  return next({
-    ctx: {
-      session: { ...ctx.session, user: ctx.session.userId },
-    },
-  });
+
+  const user = await authQueries.getUserById(userId);
+
+  if (!user) {
+    ctx.req.session.destroy(() => {});
+    throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You need to be logged in to perform this action' });
+  }
+
+  return next({ ctx });
 });
 
 /**