src/app/api/mobile/auth/route.ts
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.
Concepts détectés — comprends la théorie
ORM Prisma
13 occurrencesCe fichier accède à la base de données via Prisma. Prisma est l'ORM utilisé côté backend pour les requêtes typées sur PostgreSQL.
Voir l'article général
Route API Next.js
3 occurrencesCe fichier est une route API Next.js (App Router). Voir le contrat API complet pour les conventions de réponse et d'auth.
Voir l'article général
JWT / Auth backend
3 occurrencesCe fichier touche au système d'authentification (JWT, session, getSessionFromRequest). Voir le contrat API pour la logique complète.
Voir l'article général
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",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",