Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions webapp-backoffice/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,5 @@ PROCONNECT_CLIENT_SECRET=""
#INSEE
INSEE_API_URL="https://api.insee.fr"
INSEE_API_KEY=""

IP_HASH_SALT="RANDOM_STRING"
4 changes: 3 additions & 1 deletion webapp-backoffice/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@
"@emotion/server": "11.11.0",
"@emotion/styled": "11.11.0",
"@mui/material": "5.15.14",
"@mui/x-internal-exceljs-fork": "^4.4.5",
"@mui/x-date-pickers": "7.24.1",
"@mui/x-internal-exceljs-fork": "^4.4.5",
"@prisma/client": "5.14.0",
"@react-email/components": "0.5.6",
"@socialgouv/matomo-next": "1.9.0",
"@tanstack/react-query": "4.36.1",
"@trpc-limiter/memory": "1.0.0",
"@trpc/client": "10.41.0",
"@trpc/next": "10.41.0",
"@trpc/react-query": "10.41.0",
Expand All @@ -70,6 +71,7 @@
"eslint-config-next": "13.5.5",
"gray-matter": "4.0.3",
"ioredis": "^5.10.1",
"ipaddr.js": "1.9.1",
"jose": "^6.2.2",
"jsonwebtoken": "9.0.2",
"lodash": "4.17.21",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "LimiterReporting" ALTER COLUMN "product_id" DROP NOT NULL;
ALTER TABLE "LimiterReporting" ALTER COLUMN "button_id" DROP NOT NULL;
4 changes: 2 additions & 2 deletions webapp-backoffice/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -506,8 +506,8 @@ model LimiterReporting {
id Int @id @default(autoincrement())
ip_id String @unique
ip_adress String
product_id Int
button_id Int
product_id Int?
button_id Int?
url String?
total_attempts Int
first_attempt DateTime
Expand Down
2 changes: 1 addition & 1 deletion webapp-backoffice/src/components/auth/ResetForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export const ResetForm = () => {
}
}, [loadingError]);

const resetPassword = trpc.user.changePAssword.useMutation({
const resetPassword = trpc.user.changePassword.useMutation({
onSuccess: () => {
setSuccessChange('Ok');
},
Expand Down
4 changes: 2 additions & 2 deletions webapp-backoffice/src/server/routers/user/change-password.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export const changePasswordMutation = async ({
const salt = bcrypt.genSaltSync(10);
const hashedPassword = bcrypt.hashSync(password, salt);

const updatedUser = await ctx.prisma.user.update({
await ctx.prisma.user.update({
where: {
id: userResetToken.user_id
},
Expand All @@ -61,5 +61,5 @@ export const changePasswordMutation = async ({
}
});

return { data: updatedUser };
return { data: { success: true } };
};
11 changes: 8 additions & 3 deletions webapp-backoffice/src/server/routers/user/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { protectedProcedure, publicProcedure, router } from '@/src/server/trpc';
import {
limitedProcedure,
protectedProcedure,
publicProcedure,
router
} from '@/src/server/trpc';
import { getUserListInputSchema, getUserListQuery } from './get-list';
import { getUserByIdInputSchema, getUserByIdQuery } from './get-by-id';
import {
Expand Down Expand Up @@ -85,15 +90,15 @@ export const userRouter = router({

getOtp: publicProcedure.input(getOtpInputSchema).mutation(getOtpMutation),

initResetPwd: publicProcedure
initResetPwd: limitedProcedure
.input(initResetPwdInputSchema)
.mutation(initResetPwdMutation),

checkToken: publicProcedure
.input(checkTokenInputSchema)
.query(checkTokenQuery),

changePAssword: publicProcedure
changePassword: publicProcedure
.input(changePasswordInputSchema)
.mutation(changePasswordMutation),

Expand Down
30 changes: 11 additions & 19 deletions webapp-backoffice/src/server/routers/user/init-reset-pwd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import type { Context } from '@/src/server/trpc';
import { renderResetPasswordEmail } from '@/src/utils/emails';
import { sendMail } from '@/src/utils/mailer';
import { generateRandomString } from '@/src/utils/tools';
import { TRPCError } from '@trpc/server';
import { z } from 'zod';

export const initResetPwdInputSchema = z.object({
Expand All @@ -18,35 +17,28 @@ export const initResetPwdMutation = async ({
input: z.infer<typeof initResetPwdInputSchema>;
}) => {
const { email, forgot } = input;
const normalizedEmail = email.toLowerCase();

const silentSuccess = { data: { success: true } };

const user = await ctx.prisma.user.findUnique({
where: {
email: email.toLowerCase()
}
where: { email: normalizedEmail }
});

if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found'
});
}
if (!user) return silentSuccess;

const token = generateRandomString(32);

//delete old token if exists
await ctx.prisma.userResetToken.deleteMany({
where: {
user_id: user.id
}
where: { user_id: user.id }
});

await ctx.prisma.userResetToken.create({
data: {
user_id: user.id,
token: token,
user_email: user.email.toLowerCase(),
expiration_date: new Date(new Date().getTime() + 15 * 60 * 1000)
token,
user_email: normalizedEmail,
expiration_date: new Date(Date.now() + 15 * 60 * 1000)
}
});

Expand All @@ -57,12 +49,12 @@ export const initResetPwdMutation = async ({

await sendMail(
forgot ? 'Mot de passe oublié' : 'Réinitialisation du mot de passe',
email.toLowerCase(),
normalizedEmail,
emailHtml,
`Cliquez sur ce lien pour réinitialiser votre mot de passe : ${
process.env.NODEMAILER_BASEURL
}/reset-password?${new URLSearchParams({ token })}`
);

return { data: 'ok' };
return silentSuccess;
};
117 changes: 116 additions & 1 deletion webapp-backoffice/src/server/trpc.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Client as ElkClient } from '@elastic/elasticsearch';
import { ApiKey, TypeAction } from '@prisma/client';
import { ApiKey } from '@prisma/client';
import { defaultFingerPrint } from '@trpc-limiter/memory';
import { TRPCError, inferAsyncReturnType, initTRPC } from '@trpc/server';
import { CreateNextContextOptions } from '@trpc/server/adapters/next';
import crypto from 'crypto';
import fs from 'fs';
import ipaddr from 'ipaddr.js';
import { Session } from 'next-auth';
import path from 'path';
import SuperJSON from 'superjson';
Expand All @@ -12,6 +15,7 @@ import { getServerAuthSession } from '../pages/api/auth/[...nextauth]';
import { UserWithAccessRight } from '../types/prismaTypesExtended';
import prisma from '../utils/db';
import { actionMapping } from '../utils/tools';
import { createTRPCStoreLimiter } from '../utils/trpcRateLimiter';

// Metadata for protected procedures
interface Meta {
Expand Down Expand Up @@ -275,13 +279,124 @@ const isKeyAllowed = t.middleware(async ({ next, meta, ctx }) => {
}
});

// Rate limiting
const getRequestIp = (req: Context['req']): string => {
const xClientIp = req.headers['x-client-ip'] as string;
const xForwardedFor = req.headers['x-forwarded-for'] as string;
if (xClientIp) return xClientIp.split(',')[0].trim();
if (xForwardedFor) return xForwardedFor.split(',')[0].trim();
return defaultFingerPrint(req);
};

const hashIp = (ip: string): string => {
const now = new Date();
const dateHour = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(
2,
'0'
)}-${String(now.getDate()).padStart(2, '0')}-${String(
now.getHours()
).padStart(2, '0')}`;
return crypto
.createHash('sha256')
.update(`${ip}-${dateHour}${process.env.IP_HASH_SALT || ''}`)
.digest('hex');
};

const transformIp = (ip: string): string => {
const parts = ip.split('.');
if (parts.length !== 4) return ip;
parts[3] = '0';
return parts.join('.');
};

const allowedIps = (process.env.LIMITER_ALLOWED_IPS || '')
.split(',')
.map(s => s.trim())
.filter(Boolean);

const ipToNumber = (ip: string): number =>
ipaddr
.parse(ip)
.toByteArray()
.reduce((acc, byte) => (acc << 8) + byte, 0);

const isIpAllowed = (ip: string): boolean =>
allowedIps.some(allowedIp => {
if (allowedIp.includes('-')) {
const [startIp, endIp] = allowedIp.split('-');
try {
const ipNum = ipToNumber(ip);
return ipNum >= ipToNumber(startIp) && ipNum <= ipToNumber(endIp);
} catch {
return false;
}
}
return allowedIp === ip;
});

const limiter = createTRPCStoreLimiter<typeof t>({
fingerprint: ctx => getRequestIp(ctx.req),
windowMs: 60000,
max: 10,
onLimit: async (retryAfter, ctx) => {
const ip = getRequestIp(ctx.req);
const hashedIp = hashIp(ip);
const referer = (ctx.req.headers['referer'] ||
ctx.req.headers['referrer']) as string | undefined;
const now = new Date();

try {
const existing = await prisma.limiterReporting.findUnique({
where: { ip_id: hashedIp }
});

if (existing) {
await prisma.limiterReporting.update({
where: { id: existing.id },
data: {
total_attempts: existing.total_attempts + 1,
last_attempt: now
}
});
} else {
await prisma.limiterReporting.create({
data: {
ip_id: hashedIp,
ip_adress: transformIp(ip),
total_attempts: 10,
first_attempt: now,
last_attempt: now,
url: referer ?? null
}
});
}
} catch (err) {
console.error('[rate-limit] Failed to log abuse', err);
}

throw new TRPCError({
code: 'TOO_MANY_REQUESTS',
message: `Trop de requêtes, réessayez dans ${retryAfter}s.`
});
}
});

const bypassLimiterForAllowedIps = t.middleware(async ({ ctx, next }) => {
const ip = getRequestIp(ctx.req);
if (isIpAllowed(ip)) return next();
return limiter({ ctx, next });
});

// Base router and middleware helpers
export const router = t.router;
export const middleware = t.middleware;

// Unprotected procedure
export const publicProcedure = t.procedure;

// Rate-limited public procedure (per IP)
export const limitedProcedure = t.procedure.use(bypassLimiterForAllowedIps);

// Protected procedure
export const protectedProcedure = t.procedure.use(isAuthed);

Expand Down
43 changes: 43 additions & 0 deletions webapp-backoffice/src/utils/trpcRateLimiter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { defineTRPCLimiter } from '@trpc-limiter/core';

type HitInfo = {
totalHits: number;
resetTime: Date;
};

class MemoryStore {
private hits: Map<string, HitInfo>;

constructor(private windowMs: number) {
this.hits = new Map();
}

async increment(fingerPrint: string): Promise<HitInfo> {
const now = Date.now();
const existing = this.hits.get(fingerPrint);

if (!existing || now > existing.resetTime.getTime()) {
const fresh = {
totalHits: 1,
resetTime: new Date(now + this.windowMs * 10)
};
this.hits.set(fingerPrint, fresh);
return fresh;
}

existing.totalHits += 1;
this.hits.set(fingerPrint, existing);
return existing;
}
}

export const createTRPCStoreLimiter = defineTRPCLimiter({
store: opts => new MemoryStore(opts.windowMs),
isBlocked: async (store, fingerPrint, opts) => {
const { totalHits, resetTime } = await store.increment(fingerPrint);
if (totalHits > opts.max) {
return Math.ceil((resetTime.getTime() - Date.now()) / 1000);
}
return null;
}
});
12 changes: 12 additions & 0 deletions webapp-backoffice/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3803,6 +3803,18 @@
node-addon-api "^8.3.1"
node-gyp-build "^4.8.4"

"@trpc-limiter/core@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@trpc-limiter/core/-/core-1.0.0.tgz#d3352405643b31bd0add47bcdc928af5639cc357"
integrity sha512-Wjq2oTCmCdwNbZKRfKpzpl1Um9QXGE8OHXOab+EUSj5Wk+26I/V6Vs2p0VBFAz6eEsBn36982cCC/vaaznA8+Q==

"@trpc-limiter/memory@1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@trpc-limiter/memory/-/memory-1.0.0.tgz#d79cd557fc77aaf79b31e55ed96132ec19ffdd3e"
integrity sha512-3ahqbHV7mVLH7+H69kOaNozMsID+3P4hoU178viPIGbfrOCiu2SGUFgB+TdGl2SZ6PNPmeCACF1AlfTfIerDvw==
dependencies:
"@trpc-limiter/core" "1.0.0"

"@trpc/client@10.41.0":
version "10.41.0"
resolved "https://registry.yarnpkg.com/@trpc/client/-/client-10.41.0.tgz#8abde2ae72fe33cb762ccbc8ba03f39d51c91cd5"
Expand Down
Loading