mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 14:48:04 +02:00
Connexion Facebook / LinkedIn en OAuth depuis le backoffice
Les jetons se posaient à la main dans docker-compose. Un écran Backend ›
Réseaux sociaux permet désormais de coller les identifiants de l'application,
cliquer Connecter, choisir la page, et ne plus y revenir.
- Table `social_accounts` : identifiants, jetons et cible par réseau. Tous les
secrets sont chiffrés en AES-256-GCM (`src/lib/crypto.ts`), clé
`SOCIAL_TOKEN_KEY` avec repli sur `AUTH_SECRET`.
- Routes `api/social/[network]/{connect,callback}` : `state` anti-CSRF en cookie
httpOnly, échange du code, récupération des pages administrées. Aucun jeton ne
transite par une URL — les cibles sont relistées côté serveur au moment de la
sélection.
- Facebook : jeton utilisateur longue durée puis jeton de PAGE, qui n'expire
pas. LinkedIn : jeton 60 jours, rafraîchi automatiquement si l'application a
obtenu les jetons de rafraîchissement programmatiques.
- Faute de quoi le backoffice affiche la date d'expiration, un bandeau sur le
tableau de bord à J-7 et un bouton Reconnecter.
- `social.ts` lit les identifiants via `social-accounts.ts` : base d'abord,
variables d'environnement ensuite. Les installations existantes continuent de
fonctionner sans modification.
- Le secret d'application n'est jamais renvoyé au navigateur : champ vide =
valeur conservée.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QXNXRC4j5VLfKvpyyisrnb
This commit is contained in:
19 files changed
+2599
-82
No files matched your search
@@ -0,0 +1,85 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
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 auth();
|
||||
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) });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { can } from "@/lib/rbac";
|
||||
import {
|
||||
authorizeUrl,
|
||||
getDecryptedAppSecret,
|
||||
getSocialAccount,
|
||||
siteUrl,
|
||||
SOCIAL_NETWORKS,
|
||||
type SocialNetwork,
|
||||
} from "@/lib/social-accounts";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const SETTINGS = "/backend/reseaux";
|
||||
|
||||
function back(request: Request, error: string) {
|
||||
return NextResponse.redirect(new URL(`${SETTINGS}?error=${encodeURIComponent(error)}`, request.url));
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ network: string }> }
|
||||
) {
|
||||
const session = await auth();
|
||||
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, "Réseau inconnu.");
|
||||
}
|
||||
|
||||
if (!siteUrl()) {
|
||||
return back(
|
||||
request,
|
||||
"L'URL publique du site n'est pas configurée (NEXT_PUBLIC_SITE_URL) : impossible de construire l'adresse de retour."
|
||||
);
|
||||
}
|
||||
|
||||
const account = await getSocialAccount(network);
|
||||
const appSecret = await getDecryptedAppSecret(network);
|
||||
if (!account?.appId || !appSecret) {
|
||||
return back(request, "Enregistrez d'abord l'identifiant et le secret de l'application.");
|
||||
}
|
||||
|
||||
// `state` en cookie httpOnly : vérifié au retour pour écarter toute requête
|
||||
// de rappel forgée.
|
||||
const state = randomBytes(24).toString("hex");
|
||||
const response = NextResponse.redirect(authorizeUrl(network, account.appId, state));
|
||||
response.cookies.set(`plr_oauth_${network}`, state, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: new URL(siteUrl()).protocol === "https:",
|
||||
path: "/",
|
||||
maxAge: 600,
|
||||
});
|
||||
return response;
|
||||
}
|
||||
Reference in new issue
Block a user