Commit 8d3bead5 authored by ThinhNC's avatar ThinhNC

feat: refresh token & logout

parent 639eb99b
-- CreateTable
CREATE TABLE "refresh_tokens" (
"id" UUID NOT NULL,
"token" TEXT NOT NULL,
"user_id" UUID NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "refresh_tokens_token_key" ON "refresh_tokens"("token");
-- AddForeignKey
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
......@@ -45,6 +45,7 @@ model User {
reviewedSubmissions TaskSubmission[] @relation("ReviewedSubmissions")
leaderEvaluations WeeklyEvaluation[] @relation("LeaderEvaluations")
notifications Notification[]
refreshTokens RefreshToken[]
@@map("users")
}
......@@ -277,3 +278,16 @@ model NotificationLog {
@@map("notification_logs")
}
// 13. RefreshToken
model RefreshToken {
id String @id @default(uuid()) @db.Uuid
token String @unique
userId String @db.Uuid @map("user_id")
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("refresh_tokens")
}
import { ErrorCode } from './error-code';
export class AppError extends Error {
public readonly statusCode: number;
public readonly code?: string;
public readonly code?: ErrorCode;
public readonly isOperational: boolean;
constructor(message: string, statusCode: number = 500, code?: string) {
constructor(message: string, statusCode: number = 500, code?: ErrorCode) {
super(message);
this.statusCode = statusCode;
this.code = code;
this.isOperational = true;
Object.setPrototypeOf(this, AppError.prototype);
Object.setPrototypeOf(this, new.target.prototype);
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
}
......@@ -30,4 +30,32 @@ export class AuthController {
next(error);
}
};
refresh = async (req: Request, res: Response, next: NextFunction) => {
try {
const { refreshToken } = req.body;
const result = await this.service.refresh(refreshToken);
res.json({
success: true,
data: result,
});
} catch (error) {
next(error);
}
};
logout = async (req: Request, res: Response, next: NextFunction) => {
try {
const { refreshToken } = req.body;
await this.service.logout(refreshToken);
res.json({
success: true,
message: 'Logged out successfully',
});
} catch (error) {
next(error);
}
};
}
......@@ -14,4 +14,26 @@ export class AuthRepository {
include: { role: true },
});
}
async saveRefreshToken(userId: string, token: string, expiresAt: Date) {
return prisma.refreshToken.create({
data: {
userId,
token,
expiresAt,
},
});
}
async findRefreshToken(token: string) {
return prisma.refreshToken.findUnique({
where: { token },
});
}
async deleteRefreshToken(token: string) {
return prisma.refreshToken.deleteMany({
where: { token },
});
}
}
......@@ -2,12 +2,14 @@ import { Router } from 'express';
import { AuthController } from './auth.controller';
import { authMiddleware } from '../../middlewares/auth.middleware';
import { validate } from '../../middlewares/validate.middleware';
import { loginSchema } from './auth.validation';
import { loginSchema, refreshSchema, logoutSchema } from './auth.validation';
const router = Router();
const controller = new AuthController();
router.post('/login', validate(loginSchema), controller.login);
router.get('/me', authMiddleware, controller.me);
router.post('/refresh', validate(refreshSchema), controller.refresh);
router.post('/logout', validate(logoutSchema), controller.logout);
export default router;
......@@ -35,6 +35,10 @@ export class AuthService {
expiresIn: jwtConfig.refreshExpiresIn as any,
});
const decoded = jwt.decode(refreshToken) as { exp: number };
const expiresAt = new Date(decoded.exp * 1000);
await this.repository.saveRefreshToken(user.id, refreshToken, expiresAt);
return {
accessToken,
refreshToken,
......@@ -46,6 +50,55 @@ export class AuthService {
};
}
async refresh(token: string) {
let payload: any;
try {
payload = jwt.verify(token, jwtConfig.refreshSecret);
} catch (error) {
throw new AppError('Invalid refresh token', 401, ERROR_CODE.TOKEN_INVALID);
}
const savedToken = await this.repository.findRefreshToken(token);
if (!savedToken) {
throw new AppError('Invalid or expired refresh token', 401, ERROR_CODE.TOKEN_INVALID);
}
if (savedToken.expiresAt < new Date()) {
await this.repository.deleteRefreshToken(token);
throw new AppError('Refresh token expired', 401, ERROR_CODE.TOKEN_EXPIRED);
}
const user = await this.repository.findById(payload.id);
if (!user || !user.isActive) {
throw new AppError('User not found or inactive', 401, ERROR_CODE.USER_INACTIVE);
}
const newPayload = { id: user.id, email: user.email, role: user.role.name };
const newAccessToken = jwt.sign(newPayload, jwtConfig.accessSecret, {
expiresIn: jwtConfig.accessExpiresIn as any,
});
const newRefreshToken = jwt.sign(newPayload, jwtConfig.refreshSecret, {
expiresIn: jwtConfig.refreshExpiresIn as any,
});
await this.repository.deleteRefreshToken(token);
const decoded = jwt.decode(newRefreshToken) as { exp: number };
const expiresAt = new Date(decoded.exp * 1000);
await this.repository.saveRefreshToken(user.id, newRefreshToken, expiresAt);
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
};
}
async logout(token: string) {
await this.repository.deleteRefreshToken(token);
}
async getMe(userId: string) {
const user = await this.repository.findById(userId);
......
......@@ -4,3 +4,11 @@ export const loginSchema = z.object({
email: z.string().email('Invalid email format'),
password: z.string().min(1, 'Password is required'),
});
export const refreshSchema = z.object({
refreshToken: z.string().min(1, 'Refresh token is required'),
});
export const logoutSchema = z.object({
refreshToken: z.string().min(1, 'Refresh token is required'),
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment