refactor(server): move from jwt session to cookie based session
This commit is contained in:
parent
e0363829b3
commit
4eaf727ef8
10 changed files with 220 additions and 225 deletions
|
@ -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
58
pnpm-lock.yaml
generated
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -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
src/server/middlewares/session.middleware.ts
Normal file
26
src/server/middlewares/session.middleware.ts
Normal 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,
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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 })),
|
||||
});
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
|
||||
/**
|
||||
|
|
Loading…
Add table
Reference in a new issue