add otp swagger2

This commit is contained in:
2026-05-11 16:06:47 +03:30
parent e60401a86c
commit 3be3a49abd
13 changed files with 2017 additions and 26 deletions

View File

@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import { hashToken, revokeRefreshTokenFamily } from "@/lib/mobileTokens";
export async function POST(req: NextRequest) {
const authHeader = req.headers.get("authorization");
const accessToken = authHeader?.match(/^Bearer\s+(.+)$/i)?.[1];
const { refreshToken } = await req.json().catch(() => ({}));
if (accessToken) {
await db.session.deleteMany({
where: { sessionToken: accessToken },
});
}
if (typeof refreshToken === "string" && refreshToken.trim()) {
const token = await db.refreshToken.findUnique({
where: { tokenHash: hashToken(refreshToken.trim()) },
select: { userId: true, familyId: true },
});
if (token) {
await revokeRefreshTokenFamily(token.userId, token.familyId);
}
}
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db";
import {
ACCESS_TOKEN_EXPIRES_IN,
REFRESH_TOKEN_EXPIRES_IN,
createMobileTokenPair,
hashToken,
revokeRefreshTokenFamily,
} from "@/lib/mobileTokens";
export async function POST(req: NextRequest) {
const { refreshToken } = await req.json().catch(() => ({}));
if (typeof refreshToken !== "string" || !refreshToken.trim()) {
return NextResponse.json({ error: "refreshToken is required" }, { status: 400 });
}
const existingRefreshToken = await db.refreshToken.findUnique({
where: { tokenHash: hashToken(refreshToken.trim()) },
include: {
user: {
select: {
id: true,
name: true,
phone: true,
role: true,
},
},
},
});
if (!existingRefreshToken) {
return NextResponse.json({ error: "Invalid refresh token" }, { status: 401 });
}
if (existingRefreshToken.revokedAt) {
await revokeRefreshTokenFamily(existingRefreshToken.userId, existingRefreshToken.familyId);
return NextResponse.json({ error: "Refresh token has been revoked" }, { status: 401 });
}
if (existingRefreshToken.expiresAt <= new Date()) {
await db.refreshToken.update({
where: { id: existingRefreshToken.id },
data: { revokedAt: new Date() },
});
return NextResponse.json({ error: "Refresh token has expired" }, { status: 401 });
}
const tokens = await createMobileTokenPair(existingRefreshToken.userId, existingRefreshToken.familyId);
await db.refreshToken.update({
where: { id: existingRefreshToken.id },
data: {
revokedAt: new Date(),
replacedByTokenId: tokens.refreshTokenId,
},
});
return NextResponse.json({
accessToken: tokens.accessToken,
token: tokens.accessToken,
tokenType: "Bearer",
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
expiresAt: tokens.accessTokenExpiresAt.toISOString(),
refreshToken: tokens.refreshToken,
refreshExpiresIn: REFRESH_TOKEN_EXPIRES_IN,
refreshExpiresAt: tokens.refreshTokenExpiresAt.toISOString(),
user: existingRefreshToken.user,
});
}

View File

@@ -2,9 +2,13 @@ import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import crypto from "crypto";
import { db } from "@/lib/db";
import {
ACCESS_TOKEN_EXPIRES_IN,
REFRESH_TOKEN_EXPIRES_IN,
createMobileTokenPair,
} from "@/lib/mobileTokens";
const IRAN_MOBILE_REGEX = /^(\+98|98|0)?9\d{9}$/;
const SESSION_MAX_AGE_DAYS = 30;
function normalizeIranMobile(phone: string) {
const digits = phone.replace(/[^\d+]/g, "");
@@ -70,21 +74,17 @@ export async function POST(req: NextRequest) {
data: { consumedAt: new Date() },
});
const token = crypto.randomBytes(32).toString("hex");
const expires = new Date(Date.now() + SESSION_MAX_AGE_DAYS * 24 * 60 * 60 * 1000);
await db.session.create({
data: {
sessionToken: token,
userId: user.id,
expires,
},
});
const tokens = await createMobileTokenPair(user.id);
return NextResponse.json({
token,
accessToken: tokens.accessToken,
token: tokens.accessToken,
tokenType: "Bearer",
expiresAt: expires.toISOString(),
expiresIn: ACCESS_TOKEN_EXPIRES_IN,
expiresAt: tokens.accessTokenExpiresAt.toISOString(),
refreshToken: tokens.refreshToken,
refreshExpiresIn: REFRESH_TOKEN_EXPIRES_IN,
refreshExpiresAt: tokens.refreshTokenExpiresAt.toISOString(),
user: {
id: user.id,
name: user.name,

File diff suppressed because one or more lines are too long

View File

@@ -329,6 +329,17 @@ exports.Prisma.SessionScalarFieldEnum = {
expires: 'expires'
};
exports.Prisma.RefreshTokenScalarFieldEnum = {
id: 'id',
tokenHash: 'tokenHash',
userId: 'userId',
familyId: 'familyId',
expiresAt: 'expiresAt',
revokedAt: 'revokedAt',
replacedByTokenId: 'replacedByTokenId',
createdAt: 'createdAt'
};
exports.Prisma.TeamScalarFieldEnum = {
id: 'id',
name: 'name',
@@ -479,6 +490,7 @@ exports.Prisma.ModelName = {
QuizSubmission: 'QuizSubmission',
GoldenCard: 'GoldenCard',
Session: 'Session',
RefreshToken: 'RefreshToken',
Team: 'Team',
TeamPlayer: 'TeamPlayer',
Package: 'Package',

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
{
"name": "prisma-client-49385eb0fcdd57042a26789a1a38ea389dc814b2f500cfcebcca17b00a9cad78",
"name": "prisma-client-1872f15576a497df0517238e6417c6ebddb069c572b364759dc6f25c1f09503c",
"main": "index.js",
"types": "index.d.ts",
"browser": "default.js",

View File

@@ -237,6 +237,7 @@ model User {
payments Payment[]
quizSubmissions QuizSubmission[]
goldenCards GoldenCard[]
refreshTokens RefreshToken[]
}
model LoginOtp {
@@ -342,6 +343,22 @@ model Session {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model RefreshToken {
id String @id @default(cuid())
tokenHash String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
familyId String
expiresAt DateTime
revokedAt DateTime?
replacedByTokenId String?
createdAt DateTime @default(now())
@@index([userId])
@@index([familyId])
@@index([expiresAt])
}
model Team {
id String @id @default(cuid())
name String

File diff suppressed because one or more lines are too long

64
lib/mobileTokens.ts Normal file
View File

@@ -0,0 +1,64 @@
import crypto from "crypto";
import { db } from "@/lib/db";
export const ACCESS_TOKEN_EXPIRES_IN = 15 * 60;
export const REFRESH_TOKEN_EXPIRES_IN = 30 * 24 * 60 * 60;
function addSeconds(seconds: number) {
return new Date(Date.now() + seconds * 1000);
}
export function hashToken(token: string) {
return crypto.createHash("sha256").update(token).digest("hex");
}
export function generateToken() {
return crypto.randomBytes(48).toString("base64url");
}
export async function createMobileTokenPair(userId: string, refreshFamilyId?: string) {
const accessToken = generateToken();
const refreshToken = generateToken();
const accessTokenExpiresAt = addSeconds(ACCESS_TOKEN_EXPIRES_IN);
const refreshTokenExpiresAt = addSeconds(REFRESH_TOKEN_EXPIRES_IN);
const session = await db.session.create({
data: {
sessionToken: accessToken,
userId,
expires: accessTokenExpiresAt,
},
});
const refreshSession = await db.refreshToken.create({
data: {
tokenHash: hashToken(refreshToken),
userId,
familyId: refreshFamilyId ?? crypto.randomUUID(),
expiresAt: refreshTokenExpiresAt,
},
});
return {
accessToken,
accessTokenExpiresAt,
refreshToken,
refreshTokenExpiresAt,
refreshTokenId: refreshSession.id,
refreshFamilyId: refreshSession.familyId,
sessionId: session.id,
};
}
export async function revokeRefreshTokenFamily(userId: string, familyId: string) {
await db.refreshToken.updateMany({
where: {
userId,
familyId,
revokedAt: null,
},
data: {
revokedAt: new Date(),
},
});
}

View File

@@ -1,5 +1,5 @@
const authSecurity = [{ SessionToken: [] }, { SecureSessionToken: [] }];
const adminSecurity = [{ SessionToken: [] }, { SecureSessionToken: [] }];
const authSecurity = [{ SessionToken: [] }, { SecureSessionToken: [] }, { BearerAuth: [] }];
const adminSecurity = [{ SessionToken: [] }, { SecureSessionToken: [] }, { BearerAuth: [] }];
const jsonContent = (schema: unknown, example?: unknown) => ({
"application/json": {
@@ -71,6 +71,12 @@ export const openApiSpec = {
name: "__Secure-next-auth.session-token",
description: "توکن سشن NextAuth در محیط HTTPS",
},
BearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "opaque",
description: "Mobile app access token returned by /api/auth/mobile/verify or /api/auth/mobile/refresh",
},
},
schemas: {
ErrorResponse: {
@@ -458,9 +464,14 @@ export const openApiSpec = {
MobileOtpVerifyResponse: {
type: "object",
properties: {
accessToken: { type: "string" },
token: { type: "string" },
tokenType: { type: "string", example: "Bearer" },
expiresIn: { type: "integer", example: 900 },
expiresAt: { type: "string", format: "date-time" },
refreshToken: { type: "string" },
refreshExpiresIn: { type: "integer", example: 2592000 },
refreshExpiresAt: { type: "string", format: "date-time" },
user: {
type: "object",
properties: {
@@ -472,6 +483,19 @@ export const openApiSpec = {
},
},
},
MobileRefreshRequest: {
type: "object",
properties: {
refreshToken: { type: "string" },
},
required: ["refreshToken"],
},
MobileLogoutRequest: {
type: "object",
properties: {
refreshToken: { type: "string" },
},
},
CredentialsLoginRequest: {
type: "object",
properties: {
@@ -648,6 +672,41 @@ export const openApiSpec = {
]),
},
},
"/api/auth/mobile/refresh": {
post: {
tags: ["Auth"],
summary: "Rotate refresh token and issue a new mobile access token",
requestBody: requestBody(
{ $ref: "#/components/schemas/MobileRefreshRequest" },
{ refreshToken: "refresh_token_value" }
),
responses: Object.fromEntries([
["200", jsonResponse("Token refreshed", { $ref: "#/components/schemas/MobileOtpVerifyResponse" })],
errorResponse("400", "Missing refresh token", "refreshToken is required"),
errorResponse("401", "Invalid, expired, or revoked refresh token", "Invalid refresh token"),
]),
},
},
"/api/auth/mobile/logout": {
post: {
tags: ["Auth"],
summary: "Revoke mobile refresh token family and delete current access token",
security: authSecurity,
requestBody: requestBody(
{ $ref: "#/components/schemas/MobileLogoutRequest" },
{ refreshToken: "refresh_token_value" },
false
),
responses: {
"200": jsonResponse("Logged out", {
type: "object",
properties: {
ok: { type: "boolean", example: true },
},
}, { ok: true }),
},
},
},
"/api/auth/callback/credentials": {
post: {
tags: ["Auth"],

View File

@@ -237,6 +237,7 @@ model User {
payments Payment[]
quizSubmissions QuizSubmission[]
goldenCards GoldenCard[]
refreshTokens RefreshToken[]
}
model LoginOtp {
@@ -342,6 +343,22 @@ model Session {
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model RefreshToken {
id String @id @default(cuid())
tokenHash String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
familyId String
expiresAt DateTime
revokedAt DateTime?
replacedByTokenId String?
createdAt DateTime @default(now())
@@index([userId])
@@index([familyId])
@@index([expiresAt])
}
model Team {
id String @id @default(cuid())
name String