src/app/api/auth/client/magic-link/route.ts

route·app·5.5 KB · 149 lignes· Voir l'itinéraire
Annotation non disponible

Lance npm run annotate (nécessite ANTHROPIC_API_KEY dans .env.local) pour générer une annotation française par Claude Haiku 4.5.

1 export

POST

Code source· typescript

import { NextRequest, NextResponse } from "next/server";
import { envoyerMagicLink } from "@/lib/email";
import { prisma } from "@/lib/prisma/client";
import { redis } from "@/lib/redis";
import { createHash, randomUUID } from "crypto";
import { captureError } from "@/lib/sentry";

const TTL = parseInt(process.env.MAGIC_LINK_TTL_SECONDS || "900");
const BASE_URL = process.env.MAGIC_LINK_BASE_URL || "http://localhost:3000";

const SUBDOMAIN_REGEX = /^[a-z0-9-]{1,63}$/;

// P0-7 — Rate-limit OTP émission (1/min + 5/h par identifier)
const RATE_LIMIT_MIN = 1;
const RATE_LIMIT_HOUR = 5;
const RATE_TTL_MIN = 60;
const RATE_TTL_HOUR = 3600;

function genOtp(): string {
  return Math.floor(100000 + Math.random() * 900000).toString();
}

export async function POST(req: NextRequest) {
  try {
    const { identifier, identifierType, tenantId, source } = await req.json();

    if (!identifier || !identifierType || !tenantId) {
      return NextResponse.json({ error: "Parametres manquants" }, { status: 400 });
    }
    if (!["phone", "email"].includes(identifierType)) {
      return NextResponse.json({ error: "identifierType invalide" }, { status: 400 });
    }

    const normalizedIdentifier = identifierType === "email"
      ? identifier.trim().toLowerCase()
      : identifier.trim();

    // P0-3 — Validation stricte tenantId + lookup DB obligatoire (open redirect)
    const normalizedTenantId = typeof tenantId === "string" ? tenantId.trim().toLowerCase() : "";
    if (!normalizedTenantId) {
      return NextResponse.json({ error: "tenantId requis" }, { status: 400 });
    }
    let dbTenantSubdomain: string | null = null;
    let dbTenantNom = "wari.pro";
    if (normalizedTenantId !== "wari") {
      if (!SUBDOMAIN_REGEX.test(normalizedTenantId)) {
        return NextResponse.json({ error: "tenantId invalide" }, { status: 400 });
      }
      const tenant = await prisma.tenant.findUnique({
        where: { subdomain: normalizedTenantId },
        select: { subdomain: true, nom: true, actif: true },
      });
      if (!tenant || !tenant.actif) {
        return NextResponse.json({ error: "Vitrine introuvable" }, { status: 404 });
      }
      dbTenantSubdomain = tenant.subdomain; // DB-validé
      dbTenantNom = tenant.nom ?? "wari.pro";
    }

    // P0-7 — Rate-limit avant tout work serveur
    const keyMin = `magic_link:emit:${normalizedIdentifier}`;
    const keyHour = `magic_link:hour:${normalizedIdentifier}`;
    const countMin = await redis.incr(keyMin);
    if (countMin === 1) await redis.expire(keyMin, RATE_TTL_MIN);
    if (countMin > RATE_LIMIT_MIN) {
      const ttl = await redis.ttl(keyMin);
      return NextResponse.json(
        { error: `Trop de tentatives, réessaie dans ${Math.max(ttl, 1)} secondes` },
        { status: 429 },
      );
    }
    const countHour = await redis.incr(keyHour);
    if (countHour === 1) await redis.expire(keyHour, RATE_TTL_HOUR);
    if (countHour > RATE_LIMIT_HOUR) {
      const ttl = await redis.ttl(keyHour);
      const minutes = Math.max(Math.ceil(ttl / 60), 1);
      return NextResponse.json(
        { error: `Trop de tentatives, réessaie dans ${minutes} minutes` },
        { status: 429 },
      );
    }

    const existingClient = await prisma.clientAccount.findFirst({
      where: identifierType === "email" ? { email: normalizedIdentifier } : { phone: normalizedIdentifier },
    });

    // Générer OTP 6 chiffres (mobile) + token lien web
    const otp = genOtp();
    const rawToken = randomUUID();
    const hashedToken = createHash("sha256").update(rawToken).digest("hex");
    const expiresAt = new Date(Date.now() + TTL * 1000);

    // Invalider les anciens tokens en attente
    await prisma.magicToken.updateMany({
      where: { identifier: normalizedIdentifier, usedAt: null },
      data: { usedAt: new Date() },
    });

    await prisma.magicToken.create({
      data: {
        token: hashedToken,
        clientId: existingClient?.id ?? null,
        tenantId: normalizedTenantId,
        identifier: normalizedIdentifier,
        identifierType,
        expiresAt,
      },
    });

    // Stocker OTP dans Redis (TTL = 900s)
    await redis.set(`otp:${normalizedIdentifier}`, otp, "EX", TTL);
    // Compteur tentatives (pour blocage après 3 erreurs)
    await redis.del(`otp_attempts:${normalizedIdentifier}`);

    // P0-3 — Utiliser le subdomain DB-validé pour construire l'URL, JAMAIS la valeur user
    const tenantBaseUrl = dbTenantSubdomain
      ? BASE_URL.replace("https://", "https://" + dbTenantSubdomain + ".")
      : BASE_URL;
    const magicLink = tenantBaseUrl + "/auth/client/verify?token=" + rawToken + (source ? "&source=" + source : "");

    if (identifierType === "email") {
      try {
        await envoyerMagicLink({
          identifier: normalizedIdentifier,
          magicLink,
          otp,
          tenantNom: dbTenantNom,
          expiresInMinutes: Math.round(TTL / 60),
        });
      } catch (e) {
        captureError(e, {
          route: "/api/auth/client/magic-link",
          extra: { phase: "envoi_magic_link_email", identifierType, tenantId: normalizedTenantId },
        });
        console.error("Erreur envoi magic-link email:", e);
      }
    }

    // P0-7 — Réponse uniforme (cacher isNewUser, CWE-204 énumération de comptes)
    return NextResponse.json({ ok: true, sentAt: Date.now() });
  } catch (error) {
    captureError(error, {
      route: "/api/auth/client/magic-link",
    });
    console.error("magic-link error:", error);
    return NextResponse.json({ error: "Erreur serveur" }, { status: 500 });
  }
}