199 lines
5.5 KiB
TypeScript
199 lines
5.5 KiB
TypeScript
|
|
import { Router, Request, Response, NextFunction } from "express";
|
||
|
|
import bcrypt from "bcryptjs";
|
||
|
|
import jwt from "jsonwebtoken";
|
||
|
|
import { z } from "zod";
|
||
|
|
import { prisma } from "../lib/prisma.js";
|
||
|
|
import { AppError } from "../middleware/errorHandler.js";
|
||
|
|
import { requireAuth } from "../middleware/auth.js";
|
||
|
|
import { AuthenticatedRequest } from "../types/index.js";
|
||
|
|
|
||
|
|
const router = Router();
|
||
|
|
|
||
|
|
const ACCESS_TOKEN_TTL = "15m";
|
||
|
|
const REFRESH_TOKEN_TTL = "7d";
|
||
|
|
const REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||
|
|
|
||
|
|
function signAccessToken(payload: object): string {
|
||
|
|
const secret = process.env.JWT_SECRET;
|
||
|
|
if (!secret) throw new Error("JWT_SECRET not configured");
|
||
|
|
return jwt.sign(payload, secret, { expiresIn: ACCESS_TOKEN_TTL });
|
||
|
|
}
|
||
|
|
|
||
|
|
function signRefreshToken(payload: object): string {
|
||
|
|
const secret = process.env.JWT_SECRET;
|
||
|
|
if (!secret) throw new Error("JWT_SECRET not configured");
|
||
|
|
return jwt.sign(payload, secret, { expiresIn: REFRESH_TOKEN_TTL });
|
||
|
|
}
|
||
|
|
|
||
|
|
// POST /api/v1/auth/login
|
||
|
|
const LoginSchema = z.object({
|
||
|
|
email: z.string().email(),
|
||
|
|
password: z.string().min(1),
|
||
|
|
});
|
||
|
|
|
||
|
|
router.post(
|
||
|
|
"/login",
|
||
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
||
|
|
try {
|
||
|
|
const body = LoginSchema.parse(req.body);
|
||
|
|
|
||
|
|
const user = await prisma.user.findUnique({
|
||
|
|
where: { email: body.email },
|
||
|
|
include: { role: true, vendor: true },
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!user || !(await bcrypt.compare(body.password, user.passwordHash))) {
|
||
|
|
throw new AppError(401, "INVALID_CREDENTIALS", "Invalid email or password");
|
||
|
|
}
|
||
|
|
|
||
|
|
const tokenPayload = {
|
||
|
|
userId: user.id,
|
||
|
|
vendorId: user.vendorId,
|
||
|
|
roleId: user.roleId,
|
||
|
|
roleName: user.role.name,
|
||
|
|
};
|
||
|
|
|
||
|
|
const accessToken = signAccessToken(tokenPayload);
|
||
|
|
const refreshToken = signRefreshToken({ userId: user.id });
|
||
|
|
|
||
|
|
await prisma.refreshToken.create({
|
||
|
|
data: {
|
||
|
|
token: refreshToken,
|
||
|
|
userId: user.id,
|
||
|
|
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
res.json({
|
||
|
|
accessToken,
|
||
|
|
refreshToken,
|
||
|
|
user: {
|
||
|
|
id: user.id,
|
||
|
|
email: user.email,
|
||
|
|
name: user.name,
|
||
|
|
role: user.role.name,
|
||
|
|
vendorId: user.vendorId,
|
||
|
|
vendorName: user.vendor.name,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
} catch (err) {
|
||
|
|
next(err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
// POST /api/v1/auth/refresh
|
||
|
|
const RefreshSchema = z.object({
|
||
|
|
refreshToken: z.string().min(1),
|
||
|
|
});
|
||
|
|
|
||
|
|
router.post(
|
||
|
|
"/refresh",
|
||
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
||
|
|
try {
|
||
|
|
const { refreshToken } = RefreshSchema.parse(req.body);
|
||
|
|
const secret = process.env.JWT_SECRET;
|
||
|
|
if (!secret) throw new AppError(500, "CONFIG_ERROR", "JWT secret not configured");
|
||
|
|
|
||
|
|
let decoded: { userId: string };
|
||
|
|
try {
|
||
|
|
decoded = jwt.verify(refreshToken, secret) as { userId: string };
|
||
|
|
} catch {
|
||
|
|
throw new AppError(401, "INVALID_TOKEN", "Invalid or expired refresh token");
|
||
|
|
}
|
||
|
|
|
||
|
|
const stored = await prisma.refreshToken.findUnique({
|
||
|
|
where: { token: refreshToken },
|
||
|
|
include: { user: { include: { role: true } } },
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!stored || stored.userId !== decoded.userId || stored.expiresAt < new Date()) {
|
||
|
|
throw new AppError(401, "INVALID_TOKEN", "Refresh token not found or expired");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Rotate refresh token
|
||
|
|
await prisma.refreshToken.delete({ where: { id: stored.id } });
|
||
|
|
|
||
|
|
const user = stored.user;
|
||
|
|
const tokenPayload = {
|
||
|
|
userId: user.id,
|
||
|
|
vendorId: user.vendorId,
|
||
|
|
roleId: user.roleId,
|
||
|
|
roleName: user.role.name,
|
||
|
|
};
|
||
|
|
|
||
|
|
const newAccessToken = signAccessToken(tokenPayload);
|
||
|
|
const newRefreshToken = signRefreshToken({ userId: user.id });
|
||
|
|
|
||
|
|
await prisma.refreshToken.create({
|
||
|
|
data: {
|
||
|
|
token: newRefreshToken,
|
||
|
|
userId: user.id,
|
||
|
|
expiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL_MS),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
res.json({ accessToken: newAccessToken, refreshToken: newRefreshToken });
|
||
|
|
} catch (err) {
|
||
|
|
next(err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
// POST /api/v1/auth/logout
|
||
|
|
router.post(
|
||
|
|
"/logout",
|
||
|
|
requireAuth as unknown as (req: Request, res: Response, next: NextFunction) => void,
|
||
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
||
|
|
try {
|
||
|
|
const authReq = req as AuthenticatedRequest;
|
||
|
|
const { refreshToken } = z.object({ refreshToken: z.string().optional() }).parse(req.body);
|
||
|
|
|
||
|
|
if (refreshToken) {
|
||
|
|
await prisma.refreshToken.deleteMany({
|
||
|
|
where: { token: refreshToken, userId: authReq.auth.userId },
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
// Logout all devices
|
||
|
|
await prisma.refreshToken.deleteMany({
|
||
|
|
where: { userId: authReq.auth.userId },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
res.json({ message: "Logged out successfully" });
|
||
|
|
} catch (err) {
|
||
|
|
next(err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
// GET /api/v1/auth/me
|
||
|
|
router.get(
|
||
|
|
"/me",
|
||
|
|
requireAuth as unknown as (req: Request, res: Response, next: NextFunction) => void,
|
||
|
|
async (req: Request, res: Response, next: NextFunction) => {
|
||
|
|
try {
|
||
|
|
const authReq = req as AuthenticatedRequest;
|
||
|
|
const user = await prisma.user.findUnique({
|
||
|
|
where: { id: authReq.auth.userId },
|
||
|
|
include: { role: true, vendor: true },
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!user) throw new AppError(404, "NOT_FOUND", "User not found");
|
||
|
|
|
||
|
|
res.json({
|
||
|
|
id: user.id,
|
||
|
|
email: user.email,
|
||
|
|
name: user.name,
|
||
|
|
role: user.role.name,
|
||
|
|
vendorId: user.vendorId,
|
||
|
|
vendorName: user.vendor.name,
|
||
|
|
});
|
||
|
|
} catch (err) {
|
||
|
|
next(err);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
export default router;
|