Skip to content

Commit

Permalink
🔒️ Ajout token CSRF (#2651)
Browse files Browse the repository at this point in the history
* 🚧 WIP

* ♻️ Recupération token depuis composant client

* 🐛 Fix after rebase

* 🐛 Fix error

* 💡 Ajout commentaire sur page CSRF

* ♻️ Move hook hors condition
  • Loading branch information
spontoreau authored Jan 31, 2025
1 parent 3530a6c commit dc57c20
Show file tree
Hide file tree
Showing 9 changed files with 129 additions and 10 deletions.
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/applications/ssr/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
},
"dependencies": {
"@codegouvfr/react-dsfr": "^1.9.11",
"@edge-csrf/nextjs": "^2.5.3-cloudflare-rc1",
"@emotion/react": "*",
"@emotion/server": "*",
"@emotion/styled": "*",
Expand Down
5 changes: 5 additions & 0 deletions packages/applications/ssr/src/app/csrf/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Cette page est utilisé pour la récupératoin du token CSRF
// Merci de ne pas la supprimer ou l'utiliser pour autre chose
export default function CSRF() {
return <></>;
}
24 changes: 23 additions & 1 deletion packages/applications/ssr/src/components/atoms/form/Form.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
'use client';

import { FC, FormHTMLAttributes, ReactNode } from 'react';
import { FC, FormHTMLAttributes, ReactNode, useEffect, useState } from 'react';
import { useFormState } from 'react-dom';
import { useRouter } from 'next/navigation';

import { formAction, ValidationErrors } from '@/utils/formAction';

Expand Down Expand Up @@ -38,17 +39,38 @@ export const Form: FC<FormProps> = ({
onError,
onInvalid,
}) => {
const [csrfToken, setCsrfToken] = useState('');
const router = useRouter();

useEffect(() => {
const fetchCSRFToken = async () => {
const response = await fetch('/csrf', {
method: 'HEAD',
});

const tokenFromHeader = response.headers.get('csrf_token');
setCsrfToken(tokenFromHeader ?? 'empty_token');
};

fetchCSRFToken();
}, []);

const [state, formAction] = useFormState(action, {
status: undefined,
});

if (!state) {
router.push('/error');
}

if (state.status === 'validation-error' && onValidationError) {
onValidationError(state.errors);
}

return (
// eslint-disable-next-line react/no-unknown-property
<form id={id} action={formAction} onInvalid={onInvalid} onError={onError}>
<input type="hidden" name="csrf_token" value={csrfToken ?? 'empty_token'} />
{heading && <Heading2 className="mb-4">{heading}</Heading2>}
<FormFeedback formState={state} successMessage={successMessage} />

Expand Down
14 changes: 5 additions & 9 deletions packages/applications/ssr/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,12 @@
import { withAuth } from 'next-auth/middleware';
import { chain } from './middlewares/chain';
import { withNextAuth } from './middlewares/withNextAuth';
import { withCSRF } from './middlewares/withCSRF';

export default withAuth({
// NB: importing Routes is not working in the middleware
pages: { signIn: '/auth/signIn' },
callbacks: {
authorized: ({ token }) => !!token?.utilisateur,
},
});
export default chain([withNextAuth, withCSRF]);

export const config = {
// do not run middleware for paths matching one of following
matcher: [
'/((?!api|_next/static|_next/image|auth|favicon.ico|robots.txt|images|illustrations|$).*)',
'/((?!api|_next/static|_next/image|auth|favicon.ico|robots.txt|images|illustrations|error|$).*)',
],
};
16 changes: 16 additions & 0 deletions packages/applications/ssr/src/middlewares/chain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server';

import { CustomMiddleware, MiddlewareFactory } from './middleware';

export const chain = (functions: MiddlewareFactory[], index = 0): CustomMiddleware => {
const current = functions[index];

if (current) {
const next = chain(functions, index + 1);
return current(next);
}

return (_request: NextRequest, _event: NextFetchEvent, response: NextResponse) => {
return response;
};
};
10 changes: 10 additions & 0 deletions packages/applications/ssr/src/middlewares/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NextMiddlewareResult } from 'next/dist/server/web/types';
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server';

export type CustomMiddleware = (
request: NextRequest,
event: NextFetchEvent,
response: NextResponse,
) => NextMiddlewareResult | Promise<NextMiddlewareResult>;

export type MiddlewareFactory = (middleware: CustomMiddleware) => CustomMiddleware;
44 changes: 44 additions & 0 deletions packages/applications/ssr/src/middlewares/withCSRF.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { createCsrfProtect, CsrfError } from '@edge-csrf/nextjs';
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server';

import { CustomMiddleware } from './middleware';

const csrfProtect = createCsrfProtect({
cookie: {
sameSite: true,
secure: true,
httpOnly: true,
},
token: {
responseHeader: 'csrf_token',
},
});

export function withCSRF(middleware: CustomMiddleware) {
return async (request: NextRequest, event: NextFetchEvent) => {
const response = NextResponse.next();

try {
await csrfProtect(request, response);
} catch (err) {
if (err instanceof CsrfError) {
const isAction = request.method === 'POST' && request.headers.has('Next-Action');
if (isAction) {
return NextResponse.json(
{
status: 'failed',
},
{
status: 403,
statusText: 'Invalid CSRF token',
},
);
}
return NextResponse.redirect(new URL('/error', request.url));
}
throw err;
}

return middleware(request, event, response);
};
}
15 changes: 15 additions & 0 deletions packages/applications/ssr/src/middlewares/withNextAuth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NextFetchEvent, NextRequest, NextResponse } from 'next/server';
import { getToken } from 'next-auth/jwt';

import { CustomMiddleware } from './middleware';

export function withNextAuth(middleware: CustomMiddleware) {
return async (request: NextRequest, event: NextFetchEvent) => {
const token = await getToken({ req: request, secret: process.env.NEXTAUTH_SECRET });
if (!token) {
return NextResponse.redirect(new URL('/auth/signIn', request.url));
}

return middleware(request, event, NextResponse.next());
};
}

0 comments on commit dc57c20

Please sign in to comment.