diff --git a/front/app/(visualiser)/interpeller/error/page.tsx b/front/app/(visualiser)/interpeller/error/page.tsx new file mode 100644 index 00000000..a8eb50ac --- /dev/null +++ b/front/app/(visualiser)/interpeller/error/page.tsx @@ -0,0 +1,48 @@ +import Image from 'next/image'; +import Link from 'next/link'; + +import { Button } from '#components/ui/button'; + +export default function InterpellateError() { + return ( +
+
+
+

Lien invalide ou expiré

+
+ + Erreur +

+ Ce lien de confirmation n'est plus valide. +
+ Il a peut-être expiré ou a déjà été utilisé. +

+ + +
+
+ ); +} diff --git a/front/app/api/interpellate/confirm/route.ts b/front/app/api/interpellate/confirm/route.ts index 1274fedb..3ff32911 100644 --- a/front/app/api/interpellate/confirm/route.ts +++ b/front/app/api/interpellate/confirm/route.ts @@ -1,23 +1,31 @@ import { redirect } from 'next/navigation'; -import { ConfirmInterpellateSubject, getCommunityTitle, getFullName } from '#utils/emails/emailRendering'; +import { + ConfirmInterpellateSubject, + getCommunityTitle, + getFullName, +} from '#utils/emails/emailRendering'; import { renderEmailTemplate } from '#utils/emails/emailRendering-server'; +import { verifyInterpellateToken } from '#utils/emails/interpellateToken'; import { trySendMail } from '#utils/emails/send-email'; import Mail from 'nodemailer/lib/mailer'; - export async function GET(request: Request) { const { searchParams } = new URL(request.url); - const siren = searchParams.get('siren'); - const communityType = searchParams.get('communityType') ?? ''; - const communityName = searchParams.get('communityName') ?? ''; - const firstname = searchParams.get('firstname') ?? ''; - const lastname = searchParams.get('lastname') ?? ''; - // mail en CC - const email = searchParams.get('email') ?? ''; - // Mails à interpeller - const emails = searchParams.get('emails') ?? ''; - const isCC = searchParams.get('isCC') === 'true'; + const token = searchParams.get('token'); + + if (!token) { + redirect('/interpeller/error'); + } + + const payload = verifyInterpellateToken(token); + + if (!payload) { + redirect('/interpeller/error'); + } + + const { siren, communityType, communityName, firstname, lastname, email, emails, isCC } = + payload; const confirmInterpellateHtml = renderEmailTemplate('interpellate-community', { communityTitle: getCommunityTitle(communityType), diff --git a/front/app/api/interpellate/route.ts b/front/app/api/interpellate/route.ts index 388b8cb0..c654d7a8 100644 --- a/front/app/api/interpellate/route.ts +++ b/front/app/api/interpellate/route.ts @@ -1,9 +1,8 @@ -// TODO: Review and remove unused variables. This file ignores unused vars for now. -/* eslint-disable @typescript-eslint/no-unused-vars */ import { NextResponse } from 'next/server'; import { InterpellateFormSchema } from '#components/Interpellate/types'; import { renderEmailTemplate } from '#utils/emails/emailRendering-server'; +import { createInterpellateToken } from '#utils/emails/interpellateToken'; import { trySendMail } from '#utils/emails/send-email'; import Mail from 'nodemailer/lib/mailer'; @@ -45,20 +44,20 @@ export async function POST(request: Request) { return NextResponse.json({ errors: zodErrors }); } - const confirmUrl = new URL('api/interpellate/confirm', process.env.NEXT_PUBLIC_BASE_URL); - const params = new URLSearchParams(); - params.append('siren', siren ?? ''); - params.append('isCC', isCC !== undefined ? isCC.toString() : 'false'); - params.append('firstname', firstname ?? ''); - params.append('lastname', lastname ?? ''); - params.append('email', email ?? ''); - params.append('emails', emails ?? ''); - params.append('communityType', communityType ?? ''); - params.append('communityName', communityName ?? ''); - confirmUrl.search = params.toString(); + const token = createInterpellateToken({ + siren: siren ?? '', + firstname: firstname ?? '', + lastname: lastname ?? '', + email: email ?? '', + emails: emails ?? '', + isCC: isCC ?? false, + communityType: communityType ?? '', + communityName: communityName ?? '', + }); + const confirmUrl = `${process.env.NEXT_PUBLIC_BASE_URL}/api/interpellate/confirm?token=${token}`; const confirmInterpellateHtml = renderEmailTemplate('confirm-interpellate', { firstname: firstname ?? '', - link: confirmUrl.toString(), + link: confirmUrl, }); const confirmInterpellateHtmlObject = `${firstname}, Confirmez votre interpellation citoyenne - Éclaireur Public`; diff --git a/front/utils/emails/interpellateToken.ts b/front/utils/emails/interpellateToken.ts new file mode 100644 index 00000000..5628669d --- /dev/null +++ b/front/utils/emails/interpellateToken.ts @@ -0,0 +1,66 @@ +import crypto from 'crypto'; + +export interface InterpellateTokenPayload { + siren: string; + firstname: string; + lastname: string; + email: string; + emails: string; + isCC: boolean; + communityType: string; + communityName: string; + exp: number; +} + +function getSecret(): string { + const secret = process.env.INTERPELLATE_SECRET; + if (!secret) { + throw new Error('INTERPELLATE_SECRET environment variable is not set'); + } + return secret; +} + +export function createInterpellateToken( + data: Omit, + expiresInMinutes: number = 1440 +): string { + const payload: InterpellateTokenPayload = { + ...data, + exp: Date.now() + expiresInMinutes * 60 * 1000, + }; + + const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url'); + const signature = crypto.createHmac('sha256', getSecret()).update(encoded).digest('base64url'); + + return `${encoded}.${signature}`; +} + +export function verifyInterpellateToken(token: string): InterpellateTokenPayload | null { + const parts = token.split('.'); + if (parts.length !== 2) { + return null; + } + + const [encoded, signature] = parts; + + const expectedSignature = crypto + .createHmac('sha256', getSecret()) + .update(encoded) + .digest('base64url'); + + if (signature !== expectedSignature) { + return null; + } + + try { + const payload = JSON.parse(Buffer.from(encoded, 'base64url').toString('utf-8')); + + if (payload.exp < Date.now()) { + return null; + } + + return payload as InterpellateTokenPayload; + } catch { + return null; + } +}