mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-21 14:18:04 +02:00
Durcissement : mot de passe actuel, sessions révocables, dépendances, CSP, tests
Suite de l'audit : les cinq points laissés en suspens sont traités. Mot de passe - `changeOwnPassword` exige désormais le mot de passe actuel. Un poste laissé ouvert ne suffit plus à s'approprier un compte. Les erreurs reviennent sur l'écran avec un message au lieu d'une page d'erreur brute. Sessions révocables - Colonne `users.session_version`, portée dans le jeton et comparée à la base. - `getSession()` (src/lib/session.ts) remplace `auth()` sur les 20 pages et actions : rôle, rattachement adhérent et existence du compte sont relus à chaque requête. Supprimer un compte ou réinitialiser un mot de passe coupe immédiatement les sessions ouvertes, sans attendre l'expiration du jeton. Dépendances — de 8 vulnérabilités (2 critiques) à zéro - next 15.5.19 → 15.5.21, next-auth beta.25 → beta.32, drizzle-orm 0.38 → 0.45. - postcss et sharp forcés par `overrides` sur leurs versions corrigées, Next ne les ayant pas encore reprises ; drizzle-kit et esbuild montés côté outillage. - `npm audit fix --force` a été écarté : il proposait de RÉTROGRADER Next en 9.3.3 et eslint-config-next en 12, ce qui aurait cassé l'application. - `eslint-config-next` traînait dans node_modules sans être déclaré : retiré. CSP complète - Politique à nonce posée par le middleware, nonce régénéré à chaque requête, `script-src` sans 'unsafe-inline'. `style-src` garde 'unsafe-inline' : tout le design repose sur des attributs style, et une injection de style n'a pas la portée d'une injection de script. - Conséquence assumée : rendu dynamique pour toutes les pages, un HTML pré-généré ne pouvant pas porter de nonce. Tests - `npm test` (runner natif node:test via tsx), 18 tests sur le filtre XSS, la limitation des tentatives de connexion et le chiffrement des jetons. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
36 files changed
+2151
-6053
No files matched your search
@@ -33,7 +33,7 @@ docker compose up --build # full stack
|
|||||||
design-system in `src/app/globals.css` (palette as CSS vars, fonts, twinkle/float
|
design-system in `src/app/globals.css` (palette as CSS vars, fonts, twinkle/float
|
||||||
animations, hover lifts, responsive grid helpers). No Tailwind.
|
animations, hover lifts, responsive grid helpers). No Tailwind.
|
||||||
- **Mutations**: server actions in `src/app/backend/actions.ts`. Each action
|
- **Mutations**: server actions in `src/app/backend/actions.ts`. Each action
|
||||||
re-checks auth + capability via `auth()` and `can()` before writing, then
|
re-checks auth + capability via `getSession()` and `can()` before writing, then
|
||||||
`revalidatePath()`.
|
`revalidatePath()`.
|
||||||
- **Access control**: `src/middleware.ts` gates `/backend/*`; each page further
|
- **Access control**: `src/middleware.ts` gates `/backend/*`; each page further
|
||||||
guards by role (`isStaff`, `can`) and redirects.
|
guards by role (`isStaff`, `can`) and redirects.
|
||||||
@@ -83,6 +83,22 @@ site sans traitement supplémentaire.
|
|||||||
- Les URLs publiques des pages FB/LinkedIn sont des `site_settings`
|
- Les URLs publiques des pages FB/LinkedIn sont des `site_settings`
|
||||||
(`association_facebook`, `association_linkedin`), éditables dans Paramètres.
|
(`association_facebook`, `association_linkedin`), éditables dans Paramètres.
|
||||||
|
|
||||||
|
## Sécurité
|
||||||
|
|
||||||
|
- Le journal d'activité agrège des saisies de tiers, dont le formulaire de
|
||||||
|
contact **public** : il est filtré à l'écriture (`sanitizeActivityMessage`) et
|
||||||
|
rendu en éléments React (`activityNodes`), jamais en HTML brut.
|
||||||
|
- `getSession()` (`src/lib/session.ts`) remplace `auth()` partout : le rôle et le
|
||||||
|
rattachement adhérent sont relus en base à chaque requête, et
|
||||||
|
`users.session_version` invalide les jetons émis avant un changement de mot de
|
||||||
|
passe. N'appelez plus `auth()` directement depuis une page ou une action.
|
||||||
|
- Les images ne sont acceptées qu'en data-URI (`asImageDataUri`) : une URL ferait
|
||||||
|
appeler par le serveur une cible choisie par l'utilisateur (SSRF).
|
||||||
|
- La CSP à nonce est posée par `src/middleware.ts`. Elle impose un rendu
|
||||||
|
dynamique : `export const dynamic = "force-dynamic"` est dans `app/layout.tsx`,
|
||||||
|
un HTML pré-généré ne pouvant pas porter de nonce.
|
||||||
|
- `npm test` verrouille ces protections (`tests/security.test.ts`).
|
||||||
|
|
||||||
## Roles
|
## Roles
|
||||||
|
|
||||||
`admin` > `moderator` > `editor` are staff; `member` is an adhérent linked to a
|
`admin` > `moderator` > `editor` are staff; `member` is an adhérent linked to a
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ npm run dev # http://localhost:3000
|
|||||||
| `npm run db:generate` | Génère les migrations Drizzle |
|
| `npm run db:generate` | Génère les migrations Drizzle |
|
||||||
| `npm run db:migrate` | Applique les migrations |
|
| `npm run db:migrate` | Applique les migrations |
|
||||||
| `npm run db:seed` | Insère les données de démonstration |
|
| `npm run db:seed` | Insère les données de démonstration |
|
||||||
|
| `npm test` | Tests de sécurité (filtre XSS, limitation de connexion, chiffrement) |
|
||||||
|
|
||||||
## Variables d'environnement
|
## Variables d'environnement
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "users" ADD COLUMN "session_version" integer DEFAULT 0 NOT NULL;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -71,6 +71,13 @@
|
|||||||
"when": 1784968500537,
|
"when": 1784968500537,
|
||||||
"tag": "0009_texte_mission",
|
"tag": "0009_texte_mission",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 10,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1784972681534,
|
||||||
|
"tag": "0010_woozy_luminals",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
+3
-4
@@ -20,10 +20,9 @@ const nextConfig = {
|
|||||||
key: "Permissions-Policy",
|
key: "Permissions-Policy",
|
||||||
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
value: "camera=(), microphone=(), geolocation=(), interest-cohort=()",
|
||||||
},
|
},
|
||||||
// frame-ancestors double X-Frame-Options pour les navigateurs récents.
|
// La CSP complète est posée par le middleware : elle a besoin d'un
|
||||||
// Une CSP complète (script-src) demanderait des nonces sur les scripts
|
// nonce différent à chaque requête, ce qu'une valeur statique ne peut
|
||||||
// d'hydratation de Next : à traiter séparément.
|
// pas fournir.
|
||||||
{ key: "Content-Security-Policy", value: "frame-ancestors 'self'" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
Generated
+408
-5991
File diff suppressed because it is too large.
Load diff
+12
-7
@@ -10,15 +10,15 @@
|
|||||||
"db:generate": "drizzle-kit generate",
|
"db:generate": "drizzle-kit generate",
|
||||||
"db:migrate": "tsx src/db/migrate.ts",
|
"db:migrate": "tsx src/db/migrate.ts",
|
||||||
"db:seed": "tsx src/db/seed.ts",
|
"db:seed": "tsx src/db/seed.ts",
|
||||||
"build:scripts": "esbuild src/db/migrate.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/migrate.cjs --external:pg-native && esbuild src/db/seed.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/seed.cjs --external:pg-native"
|
"build:scripts": "esbuild src/db/migrate.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/migrate.cjs --external:pg-native && esbuild src/db/seed.ts --bundle --platform=node --target=node20 --format=cjs --outfile=dist/seed.cjs --external:pg-native",
|
||||||
|
"test": "tsx --test tests/**/*.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"drizzle-orm": "^0.38.3",
|
"drizzle-orm": "^0.45.2",
|
||||||
"eslint-config-next": "^15.5.19",
|
"next": "^15.5.21",
|
||||||
"next": "^15.5.19",
|
"next-auth": "^5.0.0-beta.32",
|
||||||
"next-auth": "5.0.0-beta.25",
|
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"react": "19.0.0",
|
"react": "19.0.0",
|
||||||
"react-dom": "19.0.0",
|
"react-dom": "19.0.0",
|
||||||
@@ -30,9 +30,14 @@
|
|||||||
"@types/pg": "^8.11.10",
|
"@types/pg": "^8.11.10",
|
||||||
"@types/react": "^19.0.7",
|
"@types/react": "^19.0.7",
|
||||||
"@types/react-dom": "^19.0.3",
|
"@types/react-dom": "^19.0.3",
|
||||||
"drizzle-kit": "^0.30.1",
|
"drizzle-kit": "^0.31.10",
|
||||||
"esbuild": "^0.24.2",
|
"esbuild": "^0.28.1",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"postcss": "^8.5.23",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
|
"esbuild": "^0.28.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
import {
|
import {
|
||||||
exchangeCode,
|
exchangeCode,
|
||||||
@@ -27,7 +27,7 @@ export async function GET(
|
|||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ network: string }> }
|
{ params }: { params: Promise<{ network: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageSettings")) {
|
if (!can(session?.user.role, "manageSettings")) {
|
||||||
return NextResponse.redirect(new URL("/backend", request.url));
|
return NextResponse.redirect(new URL("/backend", request.url));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
import {
|
import {
|
||||||
authorizeUrl,
|
authorizeUrl,
|
||||||
@@ -23,7 +23,7 @@ export async function GET(
|
|||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: Promise<{ network: string }> }
|
{ params }: { params: Promise<{ network: string }> }
|
||||||
) {
|
) {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageSettings")) {
|
if (!can(session?.user.role, "manageSettings")) {
|
||||||
return NextResponse.redirect(new URL("/backend", request.url));
|
return NextResponse.redirect(new URL("/backend", request.url));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { headers } from "next/headers";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { and, eq, sql } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { auth, signOut } from "@/auth";
|
import { signOut } from "@/auth";
|
||||||
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import {
|
import {
|
||||||
activityLog,
|
activityLog,
|
||||||
@@ -86,7 +87,7 @@ async function requireRole(): Promise<{
|
|||||||
name: string;
|
name: string;
|
||||||
userId: number | null;
|
userId: number | null;
|
||||||
}> {
|
}> {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!session?.user) throw new Error("Non authentifié");
|
if (!session?.user) throw new Error("Non authentifié");
|
||||||
const userId = Number(session.user.id);
|
const userId = Number(session.user.id);
|
||||||
return {
|
return {
|
||||||
@@ -509,6 +510,8 @@ export async function resetMemberPassword(formData: FormData) {
|
|||||||
passwordHash: await bcrypt.hash(tempPassword, 10),
|
passwordHash: await bcrypt.hash(tempPassword, 10),
|
||||||
tempPassword,
|
tempPassword,
|
||||||
mustChangePassword: true,
|
mustChangePassword: true,
|
||||||
|
// Coupe les sessions ouvertes avec l'ancien mot de passe.
|
||||||
|
sessionVersion: (u.sessionVersion ?? 0) + 1,
|
||||||
})
|
})
|
||||||
.where(eq(users.id, u.id));
|
.where(eq(users.id, u.id));
|
||||||
|
|
||||||
@@ -727,7 +730,7 @@ export async function saveMeetingRegistration(
|
|||||||
_previousState: MeetingRegistrationState,
|
_previousState: MeetingRegistrationState,
|
||||||
formData: FormData,
|
formData: FormData,
|
||||||
): Promise<MeetingRegistrationState> {
|
): Promise<MeetingRegistrationState> {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!session?.user?.memberId) {
|
if (!session?.user?.memberId) {
|
||||||
return { status: "error", message: "Connectez-vous avec un compte adhérent pour vous inscrire." };
|
return { status: "error", message: "Connectez-vous avec un compte adhérent pour vous inscrire." };
|
||||||
}
|
}
|
||||||
@@ -995,7 +998,7 @@ export async function saveSiteSettings(formData: FormData) {
|
|||||||
|
|
||||||
// ---- RGPD / droit à l'image ----
|
// ---- RGPD / droit à l'image ----
|
||||||
export async function saveImageConsent(formData: FormData) {
|
export async function saveImageConsent(formData: FormData) {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!session?.user?.memberId) throw new Error("Compte adhérent requis");
|
if (!session?.user?.memberId) throw new Error("Compte adhérent requis");
|
||||||
|
|
||||||
const decision = asString(formData, "decision");
|
const decision = asString(formData, "decision");
|
||||||
@@ -1025,14 +1028,29 @@ export async function saveImageConsent(formData: FormData) {
|
|||||||
|
|
||||||
// ---- Self password change (forced at first login) ----
|
// ---- Self password change (forced at first login) ----
|
||||||
export async function changeOwnPassword(formData: FormData) {
|
export async function changeOwnPassword(formData: FormData) {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!session?.user) throw new Error("Non authentifié");
|
if (!session?.user) throw new Error("Non authentifié");
|
||||||
const userId = Number(session.user.id);
|
const userId = Number(session.user.id);
|
||||||
|
|
||||||
|
const current = String(formData.get("currentPassword") ?? "");
|
||||||
const password = String(formData.get("password") ?? "");
|
const password = String(formData.get("password") ?? "");
|
||||||
const confirm = String(formData.get("confirm") ?? "");
|
const confirm = String(formData.get("confirm") ?? "");
|
||||||
if (password.length < 8) throw new Error("Le mot de passe doit faire au moins 8 caractères.");
|
|
||||||
if (password !== confirm) throw new Error("Les deux mots de passe ne correspondent pas.");
|
const back = (message: string) =>
|
||||||
|
redirect(`/backend/changer-mot-de-passe?error=${encodeURIComponent(message)}`);
|
||||||
|
|
||||||
|
if (password.length < 8) back("Le nouveau mot de passe doit faire au moins 8 caractères.");
|
||||||
|
if (password !== confirm) back("Les deux mots de passe ne correspondent pas.");
|
||||||
|
|
||||||
|
const [user] = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
if (!user) back("Compte introuvable.");
|
||||||
|
|
||||||
|
// Le mot de passe actuel est exigé : sans lui, un poste laissé ouvert suffit
|
||||||
|
// à un tiers pour s'approprier le compte.
|
||||||
|
if (!current || !(await bcrypt.compare(current, user!.passwordHash))) {
|
||||||
|
back("Le mot de passe actuel est incorrect.");
|
||||||
|
}
|
||||||
|
if (current === password) back("Le nouveau mot de passe doit être différent de l'actuel.");
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(users)
|
.update(users)
|
||||||
@@ -1040,6 +1058,8 @@ export async function changeOwnPassword(formData: FormData) {
|
|||||||
passwordHash: await bcrypt.hash(password, 10),
|
passwordHash: await bcrypt.hash(password, 10),
|
||||||
tempPassword: null,
|
tempPassword: null,
|
||||||
mustChangePassword: false,
|
mustChangePassword: false,
|
||||||
|
// Invalide toutes les autres sessions ouvertes sur ce compte.
|
||||||
|
sessionVersion: (user!.sessionVersion ?? 0) + 1,
|
||||||
})
|
})
|
||||||
.where(eq(users.id, userId));
|
.where(eq(users.id, userId));
|
||||||
|
|
||||||
@@ -1049,7 +1069,7 @@ export async function changeOwnPassword(formData: FormData) {
|
|||||||
|
|
||||||
// ---- Member: edit own profile ----
|
// ---- Member: edit own profile ----
|
||||||
export async function updateOwnProfile(formData: FormData) {
|
export async function updateOwnProfile(formData: FormData) {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!session?.user) throw new Error("Non authentifié");
|
if (!session?.user) throw new Error("Non authentifié");
|
||||||
const memberId = session.user.memberId;
|
const memberId = session.user.memberId;
|
||||||
if (!memberId) throw new Error("Aucune fiche adhérent liée à votre compte.");
|
if (!memberId) throw new Error("Aucune fiche adhérent liée à votre compte.");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import { asc, eq } from "drizzle-orm";
|
import { asc, eq } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { categories, members, users } from "@/db/schema";
|
import { categories, members, users } from "@/db/schema";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
@@ -17,7 +17,7 @@ export default async function EditMemberPage({
|
|||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageMembers")) {
|
if (!can(session?.user.role, "manageMembers")) {
|
||||||
redirect("/backend");
|
redirect("/backend");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { asc, eq, ilike } from "drizzle-orm";
|
import { asc, eq, ilike } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { categories, members, users } from "@/db/schema";
|
import { categories, members, users } from "@/db/schema";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
@@ -19,7 +19,7 @@ export default async function AdherentsPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ q?: string }>;
|
searchParams: Promise<{ q?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageMembers")) {
|
if (!can(session?.user.role, "manageMembers")) {
|
||||||
redirect("/backend");
|
redirect("/backend");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { inArray } from "drizzle-orm";
|
import { inArray } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { users } from "@/db/schema";
|
import { users } from "@/db/schema";
|
||||||
import { can, ROLE_LABELS, STAFF_ROLES } from "@/lib/rbac";
|
import { can, ROLE_LABELS, STAFF_ROLES } from "@/lib/rbac";
|
||||||
@@ -13,7 +13,7 @@ function initialsOf(name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function AdminsPage() {
|
export default async function AdminsPage() {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageAdmins")) {
|
if (!can(session?.user.role, "manageAdmins")) {
|
||||||
redirect("/backend");
|
redirect("/backend");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { asc, count, eq } from "drizzle-orm";
|
import { asc, count, eq } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { categories, members } from "@/db/schema";
|
import { categories, members } from "@/db/schema";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
@@ -9,7 +9,7 @@ import { addCategory, deleteCategory, renameCategory } from "../actions";
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function CategoriesPage() {
|
export default async function CategoriesPage() {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageCategories")) {
|
if (!can(session?.user.role, "manageCategories")) {
|
||||||
redirect("/backend");
|
redirect("/backend");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { changeOwnPassword } from "../actions";
|
import { changeOwnPassword } from "../actions";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function ChangePasswordPage() {
|
export default async function ChangePasswordPage({
|
||||||
const session = await auth();
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ error?: string }>;
|
||||||
|
}) {
|
||||||
|
const session = await getSession();
|
||||||
if (!session?.user) redirect("/login");
|
if (!session?.user) redirect("/login");
|
||||||
|
|
||||||
|
const { error } = await searchParams;
|
||||||
const forced = session.user.mustChangePassword;
|
const forced = session.user.mustChangePassword;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -23,7 +28,25 @@ export default async function ChangePasswordPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ background: "#fbe9e6", border: "1px solid #f2d5cf", color: "#a8503c", borderRadius: 10, padding: "11px 14px", fontSize: 13.5, marginBottom: 16 }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form action={changeOwnPassword}>
|
<form action={changeOwnPassword}>
|
||||||
|
<label className="field-label">
|
||||||
|
{forced ? "Mot de passe temporaire reçu" : "Mot de passe actuel"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
name="currentPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
className="field"
|
||||||
|
style={{ marginBottom: 14 }}
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
|
||||||
<label className="field-label">Nouveau mot de passe (8 caractères min.)</label>
|
<label className="field-label">Nouveau mot de passe (8 caractères min.)</label>
|
||||||
<input name="password" type="password" required minLength={8} className="field" style={{ marginBottom: 14 }} autoComplete="new-password" />
|
<input name="password" type="password" required minLength={8} className="field" style={{ marginBottom: 14 }} autoComplete="new-password" />
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { desc } from "drizzle-orm";
|
import { desc } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { contactMessages, membershipRequests } from "@/db/schema";
|
import { contactMessages, membershipRequests } from "@/db/schema";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
@@ -41,7 +41,7 @@ function ActionBtn({ children, color }: { children: React.ReactNode; color: stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function DemandesPage() {
|
export default async function DemandesPage() {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageMembers")) {
|
if (!can(session?.user.role, "manageMembers")) {
|
||||||
redirect("/backend");
|
redirect("/backend");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { EmailCreator } from "@/components/EmailCreator";
|
import { EmailCreator } from "@/components/EmailCreator";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
import { getSiteSettings } from "@/lib/site-settings";
|
import { getSiteSettings } from "@/lib/site-settings";
|
||||||
@@ -7,7 +7,7 @@ import { getSiteSettings } from "@/lib/site-settings";
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function EmailsPage() {
|
export default async function EmailsPage() {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageEmails")) redirect("/backend");
|
if (!can(session?.user.role, "manageEmails")) redirect("/backend");
|
||||||
const settings = await getSiteSettings();
|
const settings = await getSiteSettings();
|
||||||
const brand = {
|
const brand = {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, gte, inArray, sql } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { categories, imageConsents, meetingRegistrations, meetings, members, promotions, socialPosts, type Member } from "@/db/schema";
|
import { categories, imageConsents, meetingRegistrations, meetings, members, promotions, socialPosts, type Member } from "@/db/schema";
|
||||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||||
@@ -32,7 +32,7 @@ function initialsOf(name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function EspacePage() {
|
export default async function EspacePage() {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
const memberId = session?.user.memberId ?? null;
|
const memberId = session?.user.memberId ?? null;
|
||||||
const fallbackName = session?.user.name ?? "Adhérent";
|
const fallbackName = session?.user.name ?? "Adhérent";
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { meetingRegistrations, meetings } from "@/db/schema";
|
import { meetingRegistrations, meetings } from "@/db/schema";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
@@ -18,7 +18,7 @@ function formatDate(date: Date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function InscriptionsPage({ searchParams }: { searchParams: Promise<{ meeting?: string }> }) {
|
export default async function InscriptionsPage({ searchParams }: { searchParams: Promise<{ meeting?: string }> }) {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
||||||
const { meeting: meetingParam } = await searchParams;
|
const { meeting: meetingParam } = await searchParams;
|
||||||
const selectedMeetingId = Number(meetingParam) || null;
|
const selectedMeetingId = Number(meetingParam) || null;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { contactMessages, imageConsents, membershipRequests, promotions } from "@/db/schema";
|
import { contactMessages, imageConsents, membershipRequests, promotions } from "@/db/schema";
|
||||||
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
import { ImageConsentForm } from "@/components/ImageConsentForm";
|
||||||
@@ -15,7 +15,7 @@ export default async function BackendLayout({
|
|||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
redirect("/login");
|
redirect("/login");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema";
|
import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema";
|
||||||
import { activityNodes } from "@/lib/activity";
|
import { activityNodes } from "@/lib/activity";
|
||||||
@@ -35,7 +35,7 @@ function StatCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!isStaff(session?.user.role)) {
|
if (!isStaff(session?.user.role)) {
|
||||||
redirect("/backend/espace");
|
redirect("/backend/espace");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { ImageField } from "@/components/ImageField";
|
import { ImageField } from "@/components/ImageField";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
import { getSiteSettings } from "@/lib/site-settings";
|
import { getSiteSettings } from "@/lib/site-settings";
|
||||||
@@ -9,7 +9,7 @@ import { saveSiteSettings } from "../actions";
|
|||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function ParametresPage() {
|
export default async function ParametresPage() {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
|
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
|
||||||
|
|
||||||
const settings = await getSiteSettings();
|
const settings = await getSiteSettings();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { desc, eq, inArray } from "drizzle-orm";
|
import { desc, eq, inArray } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { members, promotions, socialPosts, users } from "@/db/schema";
|
import { members, promotions, socialPosts, users } from "@/db/schema";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
@@ -27,7 +27,7 @@ function fmtDate(d: Date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function PromotionsPage() {
|
export default async function PromotionsPage() {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "moderatePromos")) {
|
if (!can(session?.user.role, "moderatePromos")) {
|
||||||
redirect("/backend");
|
redirect("/backend");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { asc, desc, eq, sql } from "drizzle-orm";
|
import { asc, desc, eq, sql } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
|
||||||
import { ImageField } from "@/components/ImageField";
|
import { ImageField } from "@/components/ImageField";
|
||||||
@@ -23,7 +23,7 @@ function dateValue(date: Date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function PastMeetingsAdminPage() {
|
export default async function PastMeetingsAdminPage() {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
||||||
|
|
||||||
const [meetingRows, archives, photos, refusals] = await Promise.all([
|
const [meetingRows, archives, photos, refusals] = await Promise.all([
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Link from "next/link";
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { desc, eq, sql } from "drizzle-orm";
|
import { desc, eq, sql } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { meetingRegistrations, meetings } from "@/db/schema";
|
import { meetingRegistrations, meetings } from "@/db/schema";
|
||||||
import { ImageField } from "@/components/ImageField";
|
import { ImageField } from "@/components/ImageField";
|
||||||
@@ -20,7 +20,7 @@ function dateTimeValue(date: Date) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function RencontresAdminPage() {
|
export default async function RencontresAdminPage() {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
|
||||||
|
|
||||||
const [rows, settings] = await Promise.all([
|
const [rows, settings] = await Promise.all([
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { can } from "@/lib/rbac";
|
import { can } from "@/lib/rbac";
|
||||||
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
|
||||||
import {
|
import {
|
||||||
@@ -51,7 +51,7 @@ export default async function ReseauxPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ error?: string; connected?: string; choose?: string }>;
|
searchParams: Promise<{ error?: string; connected?: string; choose?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
|
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
|
||||||
|
|
||||||
const { error, connected, choose } = await searchParams;
|
const { error, connected, choose } = await searchParams;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { and, asc, eq, gte, sql } from "drizzle-orm";
|
import { and, asc, eq, gte, sql } from "drizzle-orm";
|
||||||
import { auth } from "@/auth";
|
import { getSession } from "@/lib/session";
|
||||||
import { MeetingRegistrationForm } from "@/components/MeetingRegistrationForm";
|
import { MeetingRegistrationForm } from "@/components/MeetingRegistrationForm";
|
||||||
import { SiteFooter } from "@/components/SiteFooter";
|
import { SiteFooter } from "@/components/SiteFooter";
|
||||||
import { SiteHeader } from "@/components/SiteHeader";
|
import { SiteHeader } from "@/components/SiteHeader";
|
||||||
@@ -25,7 +25,7 @@ function formatTime(date: Date) {
|
|||||||
export default async function MeetingRegistrationPage({ params }: { params: Promise<{ id: string }> }) {
|
export default async function MeetingRegistrationPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const meetingId = Number(id);
|
const meetingId = Number(id);
|
||||||
const session = await auth();
|
const session = await getSession();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const [meeting] = Number.isInteger(meetingId)
|
const [meeting] = Number.isInteger(meetingId)
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
// Rendu dynamique pour toutes les pages : la CSP à nonce du middleware ne peut
|
||||||
|
// pas signer les scripts d'un HTML pré-généré au build. Toutes les pages
|
||||||
|
// interrogent de toute façon la base.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Plein R — Commerçants & entreprises du Bassin de Pompey",
|
title: "Plein R — Commerçants & entreprises du Bassin de Pompey",
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export const authConfig = {
|
|||||||
token.memberId = (user as { memberId?: number | null }).memberId ?? null;
|
token.memberId = (user as { memberId?: number | null }).memberId ?? null;
|
||||||
token.mustChangePassword =
|
token.mustChangePassword =
|
||||||
(user as { mustChangePassword?: boolean }).mustChangePassword ?? false;
|
(user as { mustChangePassword?: boolean }).mustChangePassword ?? false;
|
||||||
|
token.sessionVersion = (user as { sessionVersion?: number }).sessionVersion ?? 0;
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
@@ -36,6 +37,8 @@ export const authConfig = {
|
|||||||
| "member";
|
| "member";
|
||||||
session.user.memberId = (token.memberId as number | null) ?? null;
|
session.user.memberId = (token.memberId as number | null) ?? null;
|
||||||
session.user.mustChangePassword = Boolean(token.mustChangePassword);
|
session.user.mustChangePassword = Boolean(token.mustChangePassword);
|
||||||
|
// Exposé pour la revalidation en base (src/lib/session.ts).
|
||||||
|
session.sessionVersion = Number(token.sessionVersion ?? 0);
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
role: user.role,
|
role: user.role,
|
||||||
memberId: user.memberId,
|
memberId: user.memberId,
|
||||||
mustChangePassword: user.mustChangePassword,
|
mustChangePassword: user.mustChangePassword,
|
||||||
|
sessionVersion: user.sessionVersion ?? 0,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -82,6 +82,9 @@ export const users = pgTable(
|
|||||||
memberId: integer("member_id").references(() => members.id),
|
memberId: integer("member_id").references(() => members.id),
|
||||||
mustChangePassword: boolean("must_change_password").notNull().default(false),
|
mustChangePassword: boolean("must_change_password").notNull().default(false),
|
||||||
tempPassword: varchar("temp_password", { length: 60 }),
|
tempPassword: varchar("temp_password", { length: 60 }),
|
||||||
|
// Incrémenté pour invalider les jetons déjà émis (changement ou
|
||||||
|
// réinitialisation de mot de passe).
|
||||||
|
sessionVersion: integer("session_version").notNull().default(0),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(t) => ({
|
(t) => ({
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { users } from "@/db/schema";
|
||||||
|
import type { AppRole } from "@/types/next-auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Session revalidée en base à chaque requête.
|
||||||
|
*
|
||||||
|
* Auth.js fige le rôle dans le jeton JWT : sans cette relecture, supprimer un
|
||||||
|
* administrateur ou le rétrograder ne coupait pas sa session en cours, qui
|
||||||
|
* restait valide jusqu'à expiration. On relit donc l'utilisateur à chaque appel
|
||||||
|
* et on refuse le jeton si le compte a disparu ou si sa version de session a
|
||||||
|
* changé (mot de passe modifié ou réinitialisé).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AppSession = {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: AppRole;
|
||||||
|
memberId: number | null;
|
||||||
|
mustChangePassword: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getSession(): Promise<AppSession | null> {
|
||||||
|
const session = await auth();
|
||||||
|
const rawId = session?.user?.id;
|
||||||
|
if (!rawId) return null;
|
||||||
|
|
||||||
|
const userId = Number(rawId);
|
||||||
|
if (!Number.isFinite(userId)) return null;
|
||||||
|
|
||||||
|
const [user] = await db.select().from(users).where(eq(users.id, userId));
|
||||||
|
// Compte supprimé : le jeton ne vaut plus rien.
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const tokenVersion = Number(
|
||||||
|
(session as { sessionVersion?: number }).sessionVersion ?? 0
|
||||||
|
);
|
||||||
|
if ((user.sessionVersion ?? 0) !== tokenVersion) return null;
|
||||||
|
|
||||||
|
// Le rôle et le rattachement adhérent viennent de la base, jamais du jeton.
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: String(user.id),
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
role: user.role,
|
||||||
|
memberId: user.memberId,
|
||||||
|
mustChangePassword: user.mustChangePassword,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+52
-3
@@ -1,13 +1,62 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
import NextAuth from "next-auth";
|
import NextAuth from "next-auth";
|
||||||
import { authConfig } from "@/auth.config";
|
import { authConfig } from "@/auth.config";
|
||||||
|
|
||||||
// Edge-safe auth instance (no providers / db) used only to gate routes.
|
// Edge-safe auth instance (no providers / db) used only to gate routes.
|
||||||
// The redirect target is derived from AUTH_URL (set it to your public URL when
|
// The redirect target is derived from AUTH_URL (set it to your public URL when
|
||||||
// running behind a reverse proxy, e.g. AUTH_URL=https://pleinr.ffnancy.fr).
|
// running behind a reverse proxy, e.g. AUTH_URL=https://pleinr.ffnancy.fr).
|
||||||
export const { auth: middleware } = NextAuth(authConfig);
|
const { auth } = NextAuth(authConfig);
|
||||||
|
|
||||||
export default middleware;
|
/**
|
||||||
|
* Politique de sécurité du contenu, avec un nonce par requête.
|
||||||
|
*
|
||||||
|
* Next signe ses propres scripts d'hydratation avec le nonce lu dans l'en-tête
|
||||||
|
* CSP de la requête : c'est ce qui permet d'interdire les scripts en ligne sans
|
||||||
|
* casser l'application.
|
||||||
|
*
|
||||||
|
* `style-src` garde 'unsafe-inline' : tout le design repose sur des attributs
|
||||||
|
* `style` et sur la feuille Google Fonts. C'est un compromis assumé — une
|
||||||
|
* injection de style est sans commune mesure avec une injection de script.
|
||||||
|
*/
|
||||||
|
function contentSecurityPolicy(nonce: string, isDev: boolean): string {
|
||||||
|
return [
|
||||||
|
"default-src 'self'",
|
||||||
|
"base-uri 'self'",
|
||||||
|
"object-src 'none'",
|
||||||
|
"form-action 'self'",
|
||||||
|
"frame-ancestors 'self'",
|
||||||
|
// 'strict-dynamic' laisse les scripts chargés par un script de confiance
|
||||||
|
// s'exécuter ; en développement Next a besoin d'eval pour le rafraîchissement.
|
||||||
|
`script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${isDev ? "'unsafe-eval'" : ""}`.trim(),
|
||||||
|
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||||
|
"font-src 'self' https://fonts.gstatic.com",
|
||||||
|
// data: pour les images stockées en data-URI, https: pour les vignettes distantes.
|
||||||
|
"img-src 'self' data: https:",
|
||||||
|
"connect-src 'self'",
|
||||||
|
// Carte « Nous situer » de la fiche adhérent.
|
||||||
|
"frame-src https://www.google.com https://maps.google.com",
|
||||||
|
"upgrade-insecure-requests",
|
||||||
|
].join("; ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default auth((request: NextRequest) => {
|
||||||
|
// `btoa` et non `Buffer` : le middleware tourne sur le runtime Edge.
|
||||||
|
const nonce = btoa(crypto.randomUUID());
|
||||||
|
const csp = contentSecurityPolicy(nonce, process.env.NODE_ENV !== "production");
|
||||||
|
|
||||||
|
// L'en-tête est posé sur la REQUÊTE : Next y lit le nonce pour l'appliquer à
|
||||||
|
// ses balises <script>. Il est ensuite renvoyé sur la réponse au navigateur.
|
||||||
|
const requestHeaders = new Headers(request.headers);
|
||||||
|
requestHeaders.set("x-nonce", nonce);
|
||||||
|
requestHeaders.set("content-security-policy", csp);
|
||||||
|
|
||||||
|
const response = NextResponse.next({ request: { headers: requestHeaders } });
|
||||||
|
response.headers.set("content-security-policy", csp);
|
||||||
|
return response;
|
||||||
|
}) as unknown as (request: NextRequest) => Response | Promise<Response>;
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/backend/:path*"],
|
// La CSP doit couvrir toutes les pages, pas seulement /backend. On exclut les
|
||||||
|
// ressources statiques, qui n'ont pas besoin d'être traitées.
|
||||||
|
matcher: ["/((?!_next/static|_next/image|assets|favicon.ico).*)"],
|
||||||
};
|
};
|
||||||
Vendored
+4
@@ -10,12 +10,15 @@ declare module "next-auth" {
|
|||||||
memberId: number | null;
|
memberId: number | null;
|
||||||
mustChangePassword: boolean;
|
mustChangePassword: boolean;
|
||||||
} & DefaultSession["user"];
|
} & DefaultSession["user"];
|
||||||
|
/** Version du jeton, comparée à la base pour révoquer les sessions. */
|
||||||
|
sessionVersion?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
role: AppRole;
|
role: AppRole;
|
||||||
memberId: number | null;
|
memberId: number | null;
|
||||||
mustChangePassword?: boolean;
|
mustChangePassword?: boolean;
|
||||||
|
sessionVersion?: number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,5 +28,6 @@ declare module "next-auth/jwt" {
|
|||||||
role?: AppRole;
|
role?: AppRole;
|
||||||
memberId?: number | null;
|
memberId?: number | null;
|
||||||
mustChangePassword?: boolean;
|
mustChangePassword?: boolean;
|
||||||
|
sessionVersion?: number;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { strict as assert } from "node:assert";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
|
||||||
|
import { activityNodes, sanitizeActivityMessage } from "../src/lib/activity";
|
||||||
|
import { clearLoginFailures, isLoginBlocked, recordLoginFailure } from "../src/lib/login-throttle";
|
||||||
|
import { decryptSecret, encryptSecret, tryDecryptSecret } from "../src/lib/crypto";
|
||||||
|
|
||||||
|
process.env.SOCIAL_TOKEN_KEY ??= "cle-de-test-suffisamment-longue-pour-scrypt";
|
||||||
|
|
||||||
|
/** Rend les nœuds React en texte brut, pour vérifier ce qui atteint le DOM. */
|
||||||
|
function renderToText(message: string): string {
|
||||||
|
const nodes = activityNodes(message);
|
||||||
|
const flat = Array.isArray(nodes) ? nodes : [nodes];
|
||||||
|
return JSON.stringify(flat);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Journal d'activité — XSS stocké", () => {
|
||||||
|
// Ces messages agrègent des saisies de tiers, dont le formulaire de contact
|
||||||
|
// public : ils étaient rendus en HTML brut sur le tableau de bord admin.
|
||||||
|
const payloads = [
|
||||||
|
`<img src=x onerror="alert(1)">`,
|
||||||
|
`<script>alert(1)</script>`,
|
||||||
|
`<strong onclick="alert(1)">x</strong>`,
|
||||||
|
`<svg/onload=alert(1)>`,
|
||||||
|
`<iframe src="javascript:alert(1)">`,
|
||||||
|
`<ScRiPt>alert(1)</ScRiPt>`,
|
||||||
|
`<img src=x onerror=alert(1)`,
|
||||||
|
`<a href="javascript:alert(1)">clic</a>`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const payload of payloads) {
|
||||||
|
it(`neutralise ${payload.slice(0, 32)}`, () => {
|
||||||
|
const stored = sanitizeActivityMessage(`<strong>Pirate</strong> « ${payload} »`);
|
||||||
|
assert.ok(!/<script|<iframe|<svg|<img|<a\b|onerror|onload|onclick/i.test(stored),
|
||||||
|
`balise survivante dans : ${stored}`);
|
||||||
|
|
||||||
|
// Même si un message malveillant existait déjà en base, le rendu React
|
||||||
|
// l'échappe : rien d'exécutable ne peut atteindre le DOM.
|
||||||
|
const rendered = renderToText(payload);
|
||||||
|
assert.ok(!rendered.includes('"dangerouslySetInnerHTML"'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("conserve la mise en gras légitime", () => {
|
||||||
|
const stored = sanitizeActivityMessage("<strong>Au Bon Pain</strong> a soumis une promotion");
|
||||||
|
assert.equal(stored, "<strong>Au Bon Pain</strong> a soumis une promotion");
|
||||||
|
assert.ok(renderToText(stored).includes("Au Bon Pain"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("garde le texte lisible quand une balise est retirée", () => {
|
||||||
|
const stored = sanitizeActivityMessage("Promotion « <b>Soldes</b> » validée");
|
||||||
|
assert.ok(stored.includes("Soldes"));
|
||||||
|
assert.ok(stored.includes("validée"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Limitation des tentatives de connexion", () => {
|
||||||
|
it("laisse passer les premières tentatives puis bloque", () => {
|
||||||
|
const key = `essai-${Date.now()}@test.fr`;
|
||||||
|
assert.equal(isLoginBlocked(key), false);
|
||||||
|
for (let i = 0; i < 7; i++) recordLoginFailure(key);
|
||||||
|
assert.equal(isLoginBlocked(key), false, "blocage prématuré");
|
||||||
|
recordLoginFailure(key);
|
||||||
|
assert.equal(isLoginBlocked(key), true, "8e échec non bloqué");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remet le compteur à zéro après une connexion réussie", () => {
|
||||||
|
const key = `succes-${Date.now()}@test.fr`;
|
||||||
|
for (let i = 0; i < 10; i++) recordLoginFailure(key);
|
||||||
|
assert.equal(isLoginBlocked(key), true);
|
||||||
|
clearLoginFailures(key);
|
||||||
|
assert.equal(isLoginBlocked(key), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isole les comptes entre eux", () => {
|
||||||
|
const cible = `cible-${Date.now()}@test.fr`;
|
||||||
|
const voisin = `voisin-${Date.now()}@test.fr`;
|
||||||
|
for (let i = 0; i < 10; i++) recordLoginFailure(cible);
|
||||||
|
assert.equal(isLoginBlocked(cible), true);
|
||||||
|
assert.equal(isLoginBlocked(voisin), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Chiffrement des secrets réseaux", () => {
|
||||||
|
const secret = "EAAB-jeton-de-page-très-long-avec-accents-éàç";
|
||||||
|
|
||||||
|
it("fait un aller-retour fidèle", () => {
|
||||||
|
assert.equal(decryptSecret(encryptSecret(secret)), secret);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne laisse pas le clair dans le chiffré", () => {
|
||||||
|
assert.ok(!encryptSecret(secret).includes(secret));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produit un résultat différent à chaque appel (IV aléatoire)", () => {
|
||||||
|
assert.notEqual(encryptSecret(secret), encryptSecret(secret));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejette un contenu altéré (authentification GCM)", () => {
|
||||||
|
const parts = encryptSecret(secret).split(":");
|
||||||
|
const payload = Buffer.from(parts[3], "base64");
|
||||||
|
payload[0] ^= 0xff;
|
||||||
|
parts[3] = payload.toString("base64");
|
||||||
|
assert.throws(() => decryptSecret(parts.join(":")));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejette un format inattendu", () => {
|
||||||
|
assert.throws(() => decryptSecret("pas-un-secret-chiffré"));
|
||||||
|
assert.equal(tryDecryptSecret("pas-un-secret-chiffré"), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user