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

This commit is contained in:
Nicolas Meienberger 2023-05-03 22:51:06 +02:00 committed by Nicolas Meienberger
parent e0363829b3
commit 4eaf727ef8
10 changed files with 220 additions and 225 deletions

View file

@ -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",

58
pnpm-lock.yaml generated
View file

@ -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:

View file

@ -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,
});
};

View file

@ -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) => {

View file

@ -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,
});

View file

@ -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

View file

@ -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 })),
});

View file

@ -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();
});
});

View file

@ -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' });
req.session.userId = user.id;
await TipiCache.set(session, user.id.toString());
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' });
req.session.userId = newUser.id;
await TipiCache.set(session, newUser.id.toString());
return { token };
return true;
};
/**
@ -265,47 +256,23 @@ 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);
public static logout = async (req: Context['req']): Promise<boolean> => {
if (!req.session) {
return true;
}
req.session.destroy((err) => {
if (err) {
Logger.error(err);
}
});
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;
}
// 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);
return { token };
};
/**
* Check if the system is configured and has at least one user
*

View file

@ -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 });
});
/**