mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 06:38:05 +02:00
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
86 lines
3.0 KiB
TypeScript
86 lines
3.0 KiB
TypeScript
import { NextResponse } from "next/server";
|
|
import { getSession } from "@/lib/session";
|
|
import { can } from "@/lib/rbac";
|
|
import {
|
|
exchangeCode,
|
|
getDecryptedAppSecret,
|
|
getSocialAccount,
|
|
saveConnection,
|
|
SOCIAL_NETWORKS,
|
|
type SocialNetwork,
|
|
} from "@/lib/social-accounts";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
const SETTINGS = "/backend/reseaux";
|
|
|
|
function back(request: Request, params: Record<string, string>) {
|
|
const url = new URL(SETTINGS, request.url);
|
|
for (const [key, value] of Object.entries(params)) url.searchParams.set(key, value);
|
|
const response = NextResponse.redirect(url);
|
|
// Le state a fait son office, quel que soit le résultat.
|
|
for (const network of SOCIAL_NETWORKS) response.cookies.delete(`plr_oauth_${network}`);
|
|
return response;
|
|
}
|
|
|
|
export async function GET(
|
|
request: Request,
|
|
{ params }: { params: Promise<{ network: string }> }
|
|
) {
|
|
const session = await getSession();
|
|
if (!can(session?.user.role, "manageSettings")) {
|
|
return NextResponse.redirect(new URL("/backend", request.url));
|
|
}
|
|
|
|
const { network: raw } = await params;
|
|
const network = raw as SocialNetwork;
|
|
if (!SOCIAL_NETWORKS.includes(network)) {
|
|
return back(request, { error: "Réseau inconnu." });
|
|
}
|
|
|
|
const url = new URL(request.url);
|
|
const error = url.searchParams.get("error_description") ?? url.searchParams.get("error");
|
|
if (error) {
|
|
return back(request, { error: `Autorisation refusée : ${error}` });
|
|
}
|
|
|
|
const code = url.searchParams.get("code");
|
|
const state = url.searchParams.get("state");
|
|
const expected = request.headers
|
|
.get("cookie")
|
|
?.split(";")
|
|
.map((c) => c.trim())
|
|
.find((c) => c.startsWith(`plr_oauth_${network}=`))
|
|
?.split("=")[1];
|
|
|
|
if (!code || !state || !expected || state !== expected) {
|
|
return back(request, { error: "Requête de retour invalide (state). Relancez la connexion." });
|
|
}
|
|
|
|
const account = await getSocialAccount(network);
|
|
const appSecret = await getDecryptedAppSecret(network);
|
|
if (!account?.appId || !appSecret) {
|
|
return back(request, { error: "Identifiants d'application introuvables." });
|
|
}
|
|
|
|
try {
|
|
const result = await exchangeCode(network, account.appId, appSecret, code);
|
|
const userId = Number(session?.user.id);
|
|
|
|
// Une seule page administrée : on la sélectionne d'office. Sinon on laisse
|
|
// choisir, en conservant le jeton utilisateur le temps de la sélection.
|
|
if (result.targets.length === 1) {
|
|
await saveConnection(network, result, result.targets[0], Number.isFinite(userId) ? userId : null);
|
|
return back(request, { connected: network });
|
|
}
|
|
|
|
// Plusieurs pages : on garde le jeton utilisateur, l'écran relistera les
|
|
// cibles côté serveur. Rien de sensible ne transite par l'URL.
|
|
await saveConnection(network, result, null, Number.isFinite(userId) ? userId : null);
|
|
return back(request, { choose: network });
|
|
} catch (caught) {
|
|
const message = caught instanceof Error ? caught.message : "Échec de la connexion.";
|
|
return back(request, { error: message.slice(0, 400) });
|
|
}
|
|
}
|