refactor: use next.js middleware for session parsing and move out of custom express server

This commit is contained in:
Nicolas Meienberger 2023-08-28 20:42:15 +02:00 committed by Nicolas Meienberger
parent 77701cb6b0
commit 55ceb32fd9
15 changed files with 169 additions and 136 deletions

View file

@ -11,15 +11,13 @@
"test:client": "jest --colors --selectProjects client --",
"test:server": "jest --colors --selectProjects server --",
"test:vite": "dotenv -e .env.test -- vitest run --coverage",
"dev": "npm run db:migrate && nodemon",
"dev": "npm run db:migrate && next dev",
"dev:watcher": "pnpm -r --filter cli dev",
"db:migrate": "NODE_ENV=development dotenv -e .env -- tsx ./src/server/run-migrations-dev.ts",
"start": "NODE_ENV=production node index.js",
"lint": "next lint",
"lint:fix": "next lint --fix",
"build": "node ./esbuild.js build",
"build:server": "node ./esbuild.js build",
"build:next": "next build",
"build": "next build",
"start": "NODE_ENV=production node server.js",
"start:dev-container": "./.devcontainer/filewatcher.sh && npm run start:dev",
"start:rc": "docker compose -f docker-compose.rc.yml --env-file .env up --build",
"start:dev": "npm run prepare && docker compose -f docker-compose.dev.yml up --build",

View file

@ -4,7 +4,7 @@ import { getUrl } from '../../core/helpers/url-helpers';
import styles from './AppLogo.module.scss';
export const AppLogo: React.FC<{ id?: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
const logoUrl = id ? `/static/apps/${id}/metadata/logo.jpg` : getUrl('placeholder.png');
const logoUrl = id ? `/api/app-image?id=${id}` : getUrl('placeholder.png');
return (
<div aria-label={alt} className={clsx(styles.dropShadow, className)} style={{ width: size, height: size }}>

View file

@ -9,7 +9,7 @@ const randomCategory = (): AppCategory[] => {
return [categories[randomIndex] as AppCategory];
};
export const createApp = (overrides?: Partial<AppInfo>): AppInfo => {
const createApp = (overrides?: Partial<AppInfo>): AppInfo => {
const name = faker.lorem.word();
return {
id: name.toLowerCase(),
@ -62,5 +62,3 @@ export const createAppEntity = (params: CreateAppEntityParams): AppWithInfo => {
...overrides,
};
};
export const createAppsRandomly = (count: number): AppInfo[] => Array.from({ length: count }).map(() => createApp());

View file

@ -2,14 +2,12 @@ import { rest } from 'msw';
import SuperJSON from 'superjson';
import type { RouterInput, RouterOutput } from '../../server/routers/_app';
export type RpcResponse<Data> = RpcSuccessResponse<Data> | RpcErrorResponse;
export type RpcSuccessResponse<Data> = {
type RpcSuccessResponse<Data> = {
id: null;
result: { type: 'data'; data: Data };
};
export type RpcErrorResponse = {
type RpcErrorResponse = {
error: {
json: {
message: string;

View file

@ -95,7 +95,7 @@ export const SettingsForm = (props: IProps) => {
const downloadCertificate = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
window.open('/certificate');
window.open('/api/certificate');
};
return (

View file

@ -2,9 +2,11 @@ import { GetServerSideProps } from 'next';
import merge from 'lodash.merge';
import { getLocaleFromString } from '@/shared/internationalization/locales';
import { getCookie } from 'cookies-next';
import TipiCache from '@/server/core/TipiCache/TipiCache';
export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
const { userId } = ctx.req.session;
const sessionId = ctx.req.headers['x-session-id'];
const userId = await TipiCache.get(`session:${sessionId}`);
if (!userId) {
return {
@ -21,11 +23,10 @@ export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
};
export const getMessagesPageProps: GetServerSideProps = async (ctx) => {
const { locale: sessionLocale } = ctx.req.session;
const cookieLocale = getCookie('tipi-locale', { req: ctx.req });
const browserLocale = ctx.req.headers['accept-language']?.split(',')[0];
const locale = getLocaleFromString(String(sessionLocale || cookieLocale || browserLocale || 'en'));
const locale = getLocaleFromString(String(cookieLocale || browserLocale || 'en'));
const englishMessages = (await import(`../messages/en.json`)).default;
const messages = (await import(`../messages/${locale}.json`)).default;

33
src/middleware.ts Normal file
View file

@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
/**
* Middleware to set session ID in request headers
* @param {NextRequest} request - Request object
*/
export async function middleware(request: NextRequest) {
let sessionId = '';
const cookie = request.cookies.get('tipi.sid')?.value;
// Check if session ID exists in cookies
if (cookie) {
sessionId = cookie;
}
const requestHeaders = new Headers(request.headers);
if (sessionId) {
requestHeaders.set('x-session-id', sessionId);
}
const response = NextResponse.next({
request: { headers: requestHeaders },
});
if (sessionId) {
response.headers.set('x-session-id', sessionId);
}
return response;
}

View file

@ -0,0 +1,27 @@
import fs from 'fs';
import { getConfig } from '@/server/core/TipiConfig/TipiConfig';
import { NextApiRequest, NextApiResponse } from 'next';
import path from 'path';
/**
* API endpoint to get the image of an app
*
* @param {NextApiRequest} req - The request
* @param {NextApiResponse} res - The response
*/
export default function handler(req: NextApiRequest, res: NextApiResponse) {
if (typeof req.query.id !== 'string') {
res.status(404).send('Not found');
return;
}
try {
const filePath = path.join(getConfig().rootFolder, 'repos', getConfig().appsRepoId, 'apps', req.query.id, 'metadata', 'logo.jpg');
const file = fs.readFileSync(filePath);
res.setHeader('Content-Type', 'image/jpeg');
res.send(file);
} catch (e) {
res.status(404).send('Not found');
}
}

View file

@ -0,0 +1,37 @@
import { getConfig } from '@/server/core/TipiConfig/TipiConfig';
import TipiCache from '@/server/core/TipiCache/TipiCache';
import { AuthQueries } from '@/server/queries/auth/auth.queries';
import { db } from '@/server/db';
import { NextApiRequest, NextApiResponse } from 'next';
import fs from 'fs-extra';
/**
* API endpoint to get the self-signed certificate
*
* @param {NextApiRequest} req - The request
* @param {NextApiResponse} res - The response
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const authService = new AuthQueries(db);
const sessionId = req.headers['x-session-id'];
const userId = await TipiCache.get(`session:${sessionId}`);
const user = await authService.getUserById(Number(userId));
if (user?.operator) {
const filePath = `${getConfig().rootFolder}/traefik/tls/cert.pem`;
if (await fs.pathExists(filePath)) {
const file = await fs.promises.readFile(filePath);
res.setHeader('Content-Type', 'application/x-pem-file');
res.setHeader('Content-Dispositon', 'attachment; filename=cert.pem');
return res.send(file);
}
res.status(404).send('File not found');
}
return res.status(403).send('Forbidden');
}

View file

@ -1,5 +1,20 @@
import { setCookie } from 'cookies-next';
import { NextApiRequest, NextApiResponse } from 'next';
import { v4 } from 'uuid';
import TipiCache from '../core/TipiCache/TipiCache';
const COOKIE_MAX_AGE = 60 * 60 * 24; // 1 day
const COOKIE_NAME = 'tipi.sid';
export const generateSessionId = (prefix: string) => {
return `${prefix}-${v4()}`;
};
export const setSession = async (sessionId: string, userId: string, req: NextApiRequest, res: NextApiResponse) => {
setCookie(COOKIE_NAME, sessionId, { req, res, maxAge: COOKIE_MAX_AGE, httpOnly: true, secure: true, sameSite: false });
const sessionKey = `session:${sessionId}`;
await TipiCache.set(sessionKey, userId);
await TipiCache.set(`session:${userId}:${sessionId}`, sessionKey);
};

View file

@ -1,17 +1,12 @@
import { inferAsyncReturnType } from '@trpc/server';
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import { Locale } from '@/shared/internationalization/locales';
type Session = {
userId?: number;
locale?: Locale;
};
import TipiCache from './core/TipiCache/TipiCache';
type CreateContextOptions = {
req: CreateNextContextOptions['req'] & {
session?: Session;
};
req: CreateNextContextOptions['req'];
res: CreateNextContextOptions['res'];
sessionId: string;
userId?: number;
};
/**
@ -34,9 +29,15 @@ const createContextInner = async (opts: CreateContextOptions) => ({
export const createContext = async (opts: CreateNextContextOptions) => {
const { req, res } = opts;
const sessionId = req.headers['x-session-id'] as string;
const userId = await TipiCache.get(`session:${sessionId}`);
return createContextInner({
req,
res,
sessionId,
userId: Number(userId) || undefined,
});
};

View file

@ -1,70 +0,0 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-disable global-require */
import express from 'express';
import { parse } from 'url';
import type { NextServer } from 'next/dist/server/next';
import { getConfig, setConfig } from './core/TipiConfig';
import { Logger } from './core/Logger';
import { AppServiceClass } from './services/apps/apps.service';
import { db } from './db';
import { sessionMiddleware } from './middlewares/session.middleware';
import { AuthQueries } from './queries/auth/auth.queries';
let conf = {};
let nextApp: NextServer;
const hostname = 'localhost';
const port = parseInt(process.env.PORT || '3000', 10);
const dev = process.env.NODE_ENV !== 'production';
if (!dev) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
const NextServer = require('next/dist/server/next-server').default;
conf = require('./.next/required-server-files.json').config;
nextApp = new NextServer({ hostname: 'localhost', dev, port, customServer: true, conf });
} else {
const next = require('next');
nextApp = next({ dev, hostname, port });
}
const handle = nextApp.getRequestHandler();
nextApp.prepare().then(async () => {
const authService = new AuthQueries(db);
const app = express();
app.disable('x-powered-by');
app.use(sessionMiddleware);
app.use('/static', express.static(`${getConfig().rootFolder}/repos/${getConfig().appsRepoId}/`));
app.use('/certificate', async (req, res) => {
const userId = req.session?.userId;
const user = await authService.getUserById(userId as number);
if (user?.operator) {
res.setHeader('Content-Dispositon', 'attachment; filename=cert.pem');
return res.sendFile(`${getConfig().rootFolder}/traefik/tls/cert.pem`);
}
return res.status(403).send('Forbidden');
});
app.all('*', (req, res) => {
const parsedUrl = parse(req.url, true);
handle(req, res, parsedUrl);
});
app.listen(port, async () => {
const appService = new AppServiceClass(db);
setConfig('status', 'RUNNING');
appService.startAllApps();
Logger.info(`> Server listening at http://localhost:${port} as ${dev ? 'development' : process.env.NODE_ENV}`);
});
});

View file

@ -6,26 +6,26 @@ 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, 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(), locale: z.string() })).mutation(async ({ input, ctx }) => AuthService.register({ ...input }, ctx.req)),
me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.req.session?.userId)),
login: publicProcedure.input(z.object({ username: z.string(), password: z.string() })).mutation(async ({ input, ctx }) => AuthService.login({ ...input }, ctx.req, ctx.res)),
logout: protectedProcedure.mutation(async ({ ctx }) => AuthServiceClass.logout(ctx.sessionId)),
register: publicProcedure
.input(z.object({ username: z.string(), password: z.string(), locale: z.string() }))
.mutation(async ({ input, ctx }) => AuthService.register({ ...input }, ctx.req, ctx.res)),
me: publicProcedure.query(async ({ ctx }) => AuthService.me(ctx.userId)),
isConfigured: publicProcedure.query(async () => AuthService.isConfigured()),
changeLocale: protectedProcedure
.input(z.object({ locale: z.string() }))
.mutation(async ({ input, ctx }) => AuthService.changeLocale({ userId: Number(ctx.req.session.userId), locale: input.locale })),
changeLocale: protectedProcedure.input(z.object({ locale: z.string() })).mutation(async ({ input, ctx }) => AuthService.changeLocale({ userId: Number(ctx.userId), locale: input.locale })),
// Password
checkPasswordChangeRequest: publicProcedure.query(AuthServiceClass.checkPasswordChangeRequest),
changeOperatorPassword: publicProcedure.input(z.object({ newPassword: z.string() })).mutation(({ input }) => AuthService.changeOperatorPassword({ newPassword: input.newPassword })),
cancelPasswordChangeRequest: publicProcedure.mutation(AuthServiceClass.cancelPasswordChangeRequest),
changePassword: protectedProcedure
.input(z.object({ currentPassword: z.string(), newPassword: z.string() }))
.mutation(({ input, ctx }) => AuthService.changePassword({ userId: Number(ctx.req.session.userId), ...input })),
.mutation(({ input, ctx }) => AuthService.changePassword({ userId: Number(ctx.userId), ...input })),
// Totp
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 })),
verifyTotp: publicProcedure.input(z.object({ totpSessionId: z.string(), totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.verifyTotp(input, ctx.req, ctx.res)),
getTotpUri: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.getTotpUri({ userId: Number(ctx.userId), password: input.password })),
setupTotp: protectedProcedure.input(z.object({ totpCode: z.string() })).mutation(({ input, ctx }) => AuthService.setupTotp({ userId: Number(ctx.userId), totpCode: input.totpCode })),
disableTotp: protectedProcedure.input(z.object({ password: z.string() })).mutation(({ input, ctx }) => AuthService.disableTotp({ userId: Number(ctx.userId), password: input.password })),
});
export type AuthRouter = typeof authRouter;

View file

@ -1,12 +1,13 @@
import { v4 as uuidv4 } from 'uuid';
import * as argon2 from 'argon2';
import validator from 'validator';
import { TotpAuthenticator } from '@/server/utils/totp';
import { AuthQueries } from '@/server/queries/auth/auth.queries';
import { Context } from '@/server/context';
import { TranslatedError } from '@/server/utils/errors';
import { Locales, getLocaleFromString } from '@/shared/internationalization/locales';
import { generateSessionId } from '@/server/common/session.helpers';
import { generateSessionId, setSession } from '@/server/common/session.helpers';
import { Database } from '@/server/db';
import { NextApiRequest, NextApiResponse } from 'next';
import { getConfig } from '../../core/TipiConfig';
import TipiCache from '../../core/TipiCache';
import { fileExists, unlinkFile } from '../../common/fs.helpers';
@ -29,9 +30,10 @@ export class AuthServiceClass {
* Authenticate user with given username and password
*
* @param {UsernamePasswordInput} input - An object containing the user's username and password
* @param {Request} req - The Next.js request object
* @param {NextApiRequest} req - The Next.js request object
* @param {NextApiResponse} res - The Next.js response object
*/
public login = async (input: UsernamePasswordInput, req: Context['req']) => {
public login = async (input: UsernamePasswordInput, req: NextApiRequest, res: NextApiResponse) => {
const { password, username } = input;
const user = await this.queries.getUserByUsername(username);
@ -51,8 +53,8 @@ export class AuthServiceClass {
return { totpSessionId };
}
req.session.userId = user.id;
await TipiCache.set(`session:${user.id}:${req.session.id}`, req.session.id);
const sessionId = uuidv4();
await setSession(sessionId, user.id.toString(), req, res);
return {};
};
@ -63,9 +65,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 {Request} req - The Next.js request object
* @param {NextApiRequest} req - The Next.js request object
* @param {NextApiResponse} res - The Next.js response object
*/
public verifyTotp = async (params: { totpSessionId: string; totpCode: string }, req: Context['req']) => {
public verifyTotp = async (params: { totpSessionId: string; totpCode: string }, req: NextApiRequest, res: NextApiResponse) => {
const { totpSessionId, totpCode } = params;
const userId = await TipiCache.get(totpSessionId);
@ -90,7 +93,8 @@ export class AuthServiceClass {
throw new TranslatedError('server-messages.errors.totp-invalid-code');
}
req.session.userId = user.id;
const sessionId = uuidv4();
await setSession(sessionId, user.id.toString(), req, res);
return true;
};
@ -195,9 +199,10 @@ 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 {Request} req - The Next.js request object
* @param {NextApiRequest} req - The Next.js request object
* @param {NextApiResponse} res - The Next.js response object
*/
public register = async (input: UsernamePasswordInput, req: Context['req']) => {
public register = async (input: UsernamePasswordInput, req: NextApiRequest, res: NextApiResponse) => {
const operators = await this.queries.getOperators();
if (operators.length > 0) {
@ -229,8 +234,8 @@ export class AuthServiceClass {
throw new TranslatedError('server-messages.errors.error-creating-user');
}
req.session.userId = newUser.id;
await TipiCache.set(`session:${newUser.id}:${req.session.id}`, req.session.id);
const sessionId = uuidv4();
await setSession(sessionId, newUser.id.toString(), req, res);
return true;
};
@ -253,15 +258,11 @@ export class AuthServiceClass {
/**
* Logs out the current user by removing the session token
*
* @param {Request} req - The Next.js request object
* @param {string} sessionId - The session token to remove
* @returns {Promise<boolean>} - Returns true if the session token is removed successfully
*/
public static logout = async (req: Context['req']): Promise<boolean> => {
if (!req.session) {
return true;
}
req.session.destroy(() => {});
public static logout = async (sessionId: string): Promise<boolean> => {
await TipiCache.del(`session:${sessionId}`);
return true;
};
@ -345,7 +346,7 @@ export class AuthServiceClass {
await Promise.all(
sessions.map(async (session) => {
await TipiCache.del(session.key);
await TipiCache.del(`tipi:${session.val}`);
if (session.val) await TipiCache.del(session.val);
}),
);
};

View file

@ -1,7 +1,6 @@
import { initTRPC, TRPCError } from '@trpc/server';
import superjson from 'superjson';
import { typeToFlattenedError, ZodError } from 'zod';
import { Locale } from '@/shared/internationalization/locales';
import { type Context } from './context';
import { AuthQueries } from './queries/auth/auth.queries';
import { db } from './db';
@ -53,19 +52,14 @@ export const publicProcedure = t.procedure;
* users are logged in
*/
const isAuthed = t.middleware(async ({ ctx, next }) => {
const userId = ctx.req.session?.userId;
const { userId } = ctx;
if (!userId) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You need to be logged in to perform this action' });
}
const user = await authQueries.getUserById(userId);
if (user?.locale) {
ctx.req.session.locale = user.locale as Locale;
}
if (!user) {
ctx.req.session.destroy(() => {});
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'You need to be logged in to perform this action' });
}