add otp swagger2
This commit is contained in:
28
app/api/auth/mobile/logout/route.ts
Normal file
28
app/api/auth/mobile/logout/route.ts
Normal 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 });
|
||||
}
|
||||
71
app/api/auth/mobile/refresh/route.ts
Normal file
71
app/api/auth/mobile/refresh/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
|
||||
1689
lib/generated/prisma/index.d.ts
vendored
1689
lib/generated/prisma/index.d.ts
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "prisma-client-49385eb0fcdd57042a26789a1a38ea389dc814b2f500cfcebcca17b00a9cad78",
|
||||
"name": "prisma-client-1872f15576a497df0517238e6417c6ebddb069c572b364759dc6f25c1f09503c",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
"browser": "default.js",
|
||||
|
||||
@@ -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
64
lib/mobileTokens.ts
Normal 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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user