src/app/api/mobile/auth/route.ts

route·app·8.5 KB · 208 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· tronqué à 200 lignes sur 208

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma/client";
import { redis } from "@/lib/redis";
import { createHash } from "crypto";
import { SignJWT } from "jose";
import bcrypt from "bcryptjs";
import { captureError } from "@/lib/sentry";

const NEXTAUTH_SECRET = process.env.NEXTAUTH_SECRET;
if (!NEXTAUTH_SECRET) {
  throw new Error("NEXTAUTH_SECRET is required");
}
const secret = new TextEncoder().encode(NEXTAUTH_SECRET);

const MAX_OTP_ATTEMPTS = 3;
const BLOCK_TTL = 300; // 5 minutes

async function buildResponse(client: { id: string; email: string | null; phone: string | null; nom: string | null; prenom: string | null; profilComplet: boolean }, isNewClient: boolean, tenantId: string | null) {
  const bearerToken = await new SignJWT({ userId: client.id, role: "CLIENT", tenantId })
    .setProtectedHeader({ alg: "HS256" })
    .setExpirationTime("7d")
    .sign(secret);

  let tenant = null;
  if (tenantId) {
    tenant = await prisma.tenant.findUnique({
      where: { subdomain: tenantId },
      select: { id: true, subdomain: true, nom: true, logoUrl: true, themeCouleur: true, couleurAccent: true, whatsapp: true, ville: true, pays: true, actif: true },
    });
  }

  return NextResponse.json({
    bearerToken,
    user: {
      id: client.id,
      email: client.email ?? null,
      phone: client.phone ?? null,
      nom: client.nom ?? null,
      prenom: client.prenom ?? null,
      profilComplet: client.profilComplet,
      isNewClient,
      role: "CLIENT",
      tenantId,
    },
    tenant,
  });
}

export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const { identifier, token, otp, motDePasse, originTenantSubdomain } = body;

    if (!identifier || (!token && !otp && !motDePasse)) {
      return NextResponse.json({ error: "Paramètres manquants" }, { status: 400 });
    }

    const normalizedIdentifier = identifier.trim().toLowerCase();
    const originTenantSubdomainNormalized =
      typeof originTenantSubdomain === "string" && originTenantSubdomain.trim()
        ? originTenantSubdomain.trim().toLowerCase()
        : null;

    // ─── Voie mot de passe ────────────────────────────────────────────────────
    if (motDePasse) {
      const isEmail = normalizedIdentifier.includes("@");
      const client = await prisma.clientAccount.findFirst({
        where: isEmail ? { email: normalizedIdentifier } : { phone: normalizedIdentifier },
        select: { id: true, email: true, phone: true, nom: true, prenom: true, profilComplet: true, originTenantId: true, motDePasseHash: true },
      });

      if (!client || !client.motDePasseHash) {
        return NextResponse.json({ error: "Aucun mot de passe défini pour ce compte" }, { status: 401 });
      }

      const valide = await bcrypt.compare(motDePasse, client.motDePasseHash);
      if (!valide) {
        return NextResponse.json({ error: "Mot de passe incorrect" }, { status: 401 });
      }

      if (originTenantSubdomainNormalized && !client.originTenantId) {
        await prisma.clientAccount.update({
          where: { id: client.id },
          data: { originTenantId: originTenantSubdomainNormalized },
        });
        client.originTenantId = originTenantSubdomainNormalized;
      }

      await prisma.clientAccount.update({ where: { id: client.id }, data: { lastSeenAt: new Date() } });

      return buildResponse(
        { id: client.id, email: client.email, phone: client.phone, nom: client.nom, prenom: client.prenom, profilComplet: client.profilComplet },
        false,
        null
      );
    }

    // ─── Voie OTP ─────────────────────────────────────────────────────────────
    if (otp) {
      // Vérifier blocage
      const blockKey = `otp_block:${normalizedIdentifier}`;
      const blocked = await redis.get(blockKey);
      if (blocked) {
        return NextResponse.json({ error: "Trop de tentatives. Réessaie dans 5 minutes." }, { status: 429 });
      }

      const storedOtp = await redis.get(`otp:${normalizedIdentifier}`);
      if (!storedOtp) {
        return NextResponse.json({ error: "Code expiré. Demande un nouveau code." }, { status: 401 });
      }

      if (storedOtp !== otp.trim()) {
        // Incrémenter compteur tentatives
        const attemptsKey = `otp_attempts:${normalizedIdentifier}`;
        const attempts = await redis.incr(attemptsKey);
        await redis.expire(attemptsKey, TTL_ATTEMPTS);

        if (attempts >= MAX_OTP_ATTEMPTS) {
          await redis.set(blockKey, "1", "EX", BLOCK_TTL);
          await redis.del(`otp:${normalizedIdentifier}`);
          return NextResponse.json({ error: "Trop de tentatives. Réessaie dans 5 minutes." }, { status: 429 });
        }

        return NextResponse.json({ error: "Code incorrect", attemptsLeft: MAX_OTP_ATTEMPTS - attempts }, { status: 401 });
      }

      // OTP correct — consommer
      await redis.del(`otp:${normalizedIdentifier}`);
      await redis.del(`otp_attempts:${normalizedIdentifier}`);

      const isEmail = normalizedIdentifier.includes("@");
      let client = await prisma.clientAccount.findFirst({
        where: isEmail ? { email: normalizedIdentifier } : { phone: normalizedIdentifier },
        select: { id: true, email: true, phone: true, nom: true, prenom: true, profilComplet: true, originTenantId: true },
      });

      const isNewClient = !client;
      if (!client) {
        client = await prisma.clientAccount.create({
          data: {
            email: isEmail ? normalizedIdentifier : null,
            phone: !isEmail ? identifier.trim() : null,
            originTenantId: originTenantSubdomainNormalized,
          },
          select: { id: true, email: true, phone: true, nom: true, prenom: true, profilComplet: true, originTenantId: true },
        });
      } else if (
        originTenantSubdomainNormalized &&
        !client.originTenantId
      ) {
        // BUG-158 — backfill originTenantId si manquant et fourni au login depuis la vitrine
        await prisma.clientAccount.update({
          where: { id: client.id },
          data: { originTenantId: originTenantSubdomainNormalized },
        });
        client.originTenantId = originTenantSubdomainNormalized;
      }

      await prisma.clientAccount.update({ where: { id: client.id }, data: { lastSeenAt: new Date() } });

      return buildResponse(client, isNewClient, null);
    }

    // ─── Voie token (lien web / legacy) ───────────────────────────────────────
    const hashedToken = createHash("sha256").update(token).digest("hex");
    const magicToken = await prisma.magicToken.findUnique({
      where: { token: hashedToken },
      include: { client: true },
    });

    if (!magicToken) return NextResponse.json({ error: "Token invalide" }, { status: 401 });
    if (magicToken.usedAt) return NextResponse.json({ error: "Token déjà utilisé" }, { status: 401 });
    if (new Date() > magicToken.expiresAt) return NextResponse.json({ error: "Token expiré" }, { status: 401 });
    if (magicToken.identifier !== normalizedIdentifier) return NextResponse.json({ error: "Token invalide" }, { status: 401 });

    const isNewClient = !magicToken.client;
    let client = magicToken.client;

    if (!client) {
      client = await prisma.clientAccount.create({
        data: {
          phone: magicToken.identifierType === "phone" ? magicToken.identifier : null,
          email: magicToken.identifierType === "email" ? magicToken.identifier : null,
          originTenantId: magicToken.tenantId === "wari" ? null : magicToken.tenantId,
        },
      });
    }

    await prisma.magicToken.update({ where: { id: magicToken.id }, data: { usedAt: new Date(), clientId: client.id } });
    await prisma.clientAccount.update({ where: { id: client.id }, data: { lastSeenAt: new Date() } });

    const tenantId = magicToken.tenantId === "wari" ? null : magicToken.tenantId;
    return buildResponse(
      { id: client.id, email: client.email, phone: client.phone, nom: client.nom, prenom: client.prenom, profilComplet: client.profilComplet },
      isNewClient,
      tenantId
    );
  } catch (error) {
    captureError(error, {
      route: "/api/mobile/auth",