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:
Claude committed 2026-07-25 10:18:04 +00:00
1 parent 2874d79c9e
commit b0788d3f20
36 files changed
+2151 -6053

No files matched your search

@@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import {
exchangeCode,
@@ -27,7 +27,7 @@ export async function GET(
request: Request,
{ params }: { params: Promise<{ network: string }> }
) {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) {
return NextResponse.redirect(new URL("/backend", request.url));
}
@@ -1,6 +1,6 @@
import { randomBytes } from "node:crypto";
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import {
authorizeUrl,
@@ -23,7 +23,7 @@ export async function GET(
request: Request,
{ params }: { params: Promise<{ network: string }> }
) {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) {
return NextResponse.redirect(new URL("/backend", request.url));
}
+28 -8
View File
@@ -6,7 +6,8 @@ import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { and, eq, sql } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { auth, signOut } from "@/auth";
import { signOut } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import {
activityLog,
@@ -86,7 +87,7 @@ async function requireRole(): Promise<{
name: string;
userId: number | null;
}> {
const session = await auth();
const session = await getSession();
if (!session?.user) throw new Error("Non authentifié");
const userId = Number(session.user.id);
return {
@@ -509,6 +510,8 @@ export async function resetMemberPassword(formData: FormData) {
passwordHash: await bcrypt.hash(tempPassword, 10),
tempPassword,
mustChangePassword: true,
// Coupe les sessions ouvertes avec l'ancien mot de passe.
sessionVersion: (u.sessionVersion ?? 0) + 1,
})
.where(eq(users.id, u.id));
@@ -727,7 +730,7 @@ export async function saveMeetingRegistration(
_previousState: MeetingRegistrationState,
formData: FormData,
): Promise<MeetingRegistrationState> {
const session = await auth();
const session = await getSession();
if (!session?.user?.memberId) {
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 ----
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");
const decision = asString(formData, "decision");
@@ -1025,14 +1028,29 @@ export async function saveImageConsent(formData: FormData) {
// ---- Self password change (forced at first login) ----
export async function changeOwnPassword(formData: FormData) {
const session = await auth();
const session = await getSession();
if (!session?.user) throw new Error("Non authentifié");
const userId = Number(session.user.id);
const current = String(formData.get("currentPassword") ?? "");
const password = String(formData.get("password") ?? "");
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
.update(users)
@@ -1040,6 +1058,8 @@ export async function changeOwnPassword(formData: FormData) {
passwordHash: await bcrypt.hash(password, 10),
tempPassword: null,
mustChangePassword: false,
// Invalide toutes les autres sessions ouvertes sur ce compte.
sessionVersion: (user!.sessionVersion ?? 0) + 1,
})
.where(eq(users.id, userId));
@@ -1049,7 +1069,7 @@ export async function changeOwnPassword(formData: FormData) {
// ---- Member: edit own profile ----
export async function updateOwnProfile(formData: FormData) {
const session = await auth();
const session = await getSession();
if (!session?.user) throw new Error("Non authentifié");
const memberId = session.user.memberId;
if (!memberId) throw new Error("Aucune fiche adhérent liée à votre compte.");
+2 -2
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { asc, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { categories, members, users } from "@/db/schema";
import { can } from "@/lib/rbac";
@@ -17,7 +17,7 @@ export default async function EditMemberPage({
}: {
params: Promise<{ id: string }>;
}) {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageMembers")) {
redirect("/backend");
}
+2 -2
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { asc, eq, ilike } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { categories, members, users } from "@/db/schema";
import { can } from "@/lib/rbac";
@@ -19,7 +19,7 @@ export default async function AdherentsPage({
}: {
searchParams: Promise<{ q?: string }>;
}) {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageMembers")) {
redirect("/backend");
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { inArray } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { users } from "@/db/schema";
import { can, ROLE_LABELS, STAFF_ROLES } from "@/lib/rbac";
@@ -13,7 +13,7 @@ function initialsOf(name: string) {
}
export default async function AdminsPage() {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageAdmins")) {
redirect("/backend");
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { asc, count, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { categories, members } from "@/db/schema";
import { can } from "@/lib/rbac";
@@ -9,7 +9,7 @@ import { addCategory, deleteCategory, renameCategory } from "../actions";
export const dynamic = "force-dynamic";
export default async function CategoriesPage() {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageCategories")) {
redirect("/backend");
}
+26 -3
View File
@@ -1,13 +1,18 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { changeOwnPassword } from "../actions";
export const dynamic = "force-dynamic";
export default async function ChangePasswordPage() {
const session = await auth();
export default async function ChangePasswordPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>;
}) {
const session = await getSession();
if (!session?.user) redirect("/login");
const { error } = await searchParams;
const forced = session.user.mustChangePassword;
return (
@@ -23,7 +28,25 @@ export default async function ChangePasswordPage() {
</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}>
<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>
<input name="password" type="password" required minLength={8} className="field" style={{ marginBottom: 14 }} autoComplete="new-password" />
+2 -2
View File
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { desc } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { contactMessages, membershipRequests } from "@/db/schema";
import { can } from "@/lib/rbac";
@@ -41,7 +41,7 @@ function ActionBtn({ children, color }: { children: React.ReactNode; color: stri
}
export default async function DemandesPage() {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageMembers")) {
redirect("/backend");
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { EmailCreator } from "@/components/EmailCreator";
import { can } from "@/lib/rbac";
import { getSiteSettings } from "@/lib/site-settings";
@@ -7,7 +7,7 @@ import { getSiteSettings } from "@/lib/site-settings";
export const dynamic = "force-dynamic";
export default async function EmailsPage() {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageEmails")) redirect("/backend");
const settings = await getSiteSettings();
const brand = {
+2 -2
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
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 { categories, imageConsents, meetingRegistrations, meetings, members, promotions, socialPosts, type Member } from "@/db/schema";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
@@ -32,7 +32,7 @@ function initialsOf(name: string) {
}
export default async function EspacePage() {
const session = await auth();
const session = await getSession();
const memberId = session?.user.memberId ?? null;
const fallbackName = session?.user.name ?? "Adhérent";
+2 -2
View File
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { and, desc, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { meetingRegistrations, meetings } from "@/db/schema";
import { can } from "@/lib/rbac";
@@ -18,7 +18,7 @@ function formatDate(date: Date) {
}
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");
const { meeting: meetingParam } = await searchParams;
const selectedMeetingId = Number(meetingParam) || null;
+2 -2
View File
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { desc, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { contactMessages, imageConsents, membershipRequests, promotions } from "@/db/schema";
import { ImageConsentForm } from "@/components/ImageConsentForm";
@@ -15,7 +15,7 @@ export default async function BackendLayout({
}: {
children: React.ReactNode;
}) {
const session = await auth();
const session = await getSession();
if (!session?.user) {
redirect("/login");
}
+2 -2
View File
@@ -1,7 +1,7 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { desc, eq } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { activityLog, contactMessages, members, membershipRequests, promotions } from "@/db/schema";
import { activityNodes } from "@/lib/activity";
@@ -35,7 +35,7 @@ function StatCard({
}
export default async function DashboardPage() {
const session = await auth();
const session = await getSession();
if (!isStaff(session?.user.role)) {
redirect("/backend/espace");
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { ImageField } from "@/components/ImageField";
import { can } from "@/lib/rbac";
import { getSiteSettings } from "@/lib/site-settings";
@@ -9,7 +9,7 @@ import { saveSiteSettings } from "../actions";
export const dynamic = "force-dynamic";
export default async function ParametresPage() {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
const settings = await getSiteSettings();
+2 -2
View File
@@ -1,6 +1,6 @@
import { redirect } from "next/navigation";
import { desc, eq, inArray } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { members, promotions, socialPosts, users } from "@/db/schema";
import { can } from "@/lib/rbac";
@@ -27,7 +27,7 @@ function fmtDate(d: Date) {
}
export default async function PromotionsPage() {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "moderatePromos")) {
redirect("/backend");
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { asc, desc, eq, sql } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { meetingRegistrations, meetings, pastMeetingPhotos, pastMeetings } from "@/db/schema";
import { ImageField } from "@/components/ImageField";
@@ -23,7 +23,7 @@ function dateValue(date: Date) {
}
export default async function PastMeetingsAdminPage() {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
const [meetingRows, archives, photos, refusals] = await Promise.all([
+2 -2
View File
@@ -2,7 +2,7 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { desc, eq, sql } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { db } from "@/db";
import { meetingRegistrations, meetings } from "@/db/schema";
import { ImageField } from "@/components/ImageField";
@@ -20,7 +20,7 @@ function dateTimeValue(date: Date) {
}
export default async function RencontresAdminPage() {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageMeetings")) redirect("/backend");
const [rows, settings] = await Promise.all([
+2 -2
View File
@@ -1,7 +1,7 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import type { CSSProperties } from "react";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { can } from "@/lib/rbac";
import { SOCIAL_BRAND, SocialIcon } from "@/components/SocialIcons";
import {
@@ -51,7 +51,7 @@ export default async function ReseauxPage({
}: {
searchParams: Promise<{ error?: string; connected?: string; choose?: string }>;
}) {
const session = await auth();
const session = await getSession();
if (!can(session?.user.role, "manageSettings")) redirect("/backend");
const { error, connected, choose } = await searchParams;
+2 -2
View File
@@ -1,6 +1,6 @@
import Link from "next/link";
import { and, asc, eq, gte, sql } from "drizzle-orm";
import { auth } from "@/auth";
import { getSession } from "@/lib/session";
import { MeetingRegistrationForm } from "@/components/MeetingRegistrationForm";
import { SiteFooter } from "@/components/SiteFooter";
import { SiteHeader } from "@/components/SiteHeader";
@@ -25,7 +25,7 @@ function formatTime(date: Date) {
export default async function MeetingRegistrationPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const meetingId = Number(id);
const session = await auth();
const session = await getSession();
const now = new Date();
const [meeting] = Number.isInteger(meetingId)
+5
View File
@@ -1,6 +1,11 @@
import type { Metadata } from "next";
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 = {
title: "Plein R — Commerçants & entreprises du Bassin de Pompey",
description: