diff --git a/migrations/20250104074851-create-user.js b/migrations/20250104074851-create-user.js index d2dba97..60a35cb 100644 --- a/migrations/20250104074851-create-user.js +++ b/migrations/20250104074851-create-user.js @@ -46,10 +46,6 @@ module.exports = { type: Sequelize.ENUM('male', 'female'), allowNull: false, }, - refreshToken: { - type: Sequelize.STRING, - allowNull: true, - }, createdAt: { type: Sequelize.DATE, allowNull: false, diff --git a/package-lock.json b/package-lock.json index f00ce78..c178949 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "bcryptjs": "^2.4.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "ioredis": "^5.4.2", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.13.1", @@ -30,6 +31,7 @@ "sequelize": "^6.37.5", "sequelize-typescript": "^2.1.6", "umzug": "^3.8.2", + "uuid": "^11.0.5", "zarinpal-checkout": "^0.3.0" }, "devDependencies": { @@ -936,6 +938,12 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1857,6 +1865,19 @@ "sequelize-typescript": "^2.0.0" } }, + "node_modules/@nestjs/sequelize/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nestjs/testing": { "version": "10.4.15", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-10.4.15.tgz", @@ -3980,6 +4001,15 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -4367,6 +4397,15 @@ "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", "license": "MIT" }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -6103,6 +6142,30 @@ "node": ">=12.0.0" } }, + "node_modules/ioredis": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.2.tgz", + "integrity": "sha512-0SZXGNGZ+WzISQ67QDyZ2x0+wVxjjUndtD8oSeik/4ajifeiRufed8fCb8QW8VMyi4MXcS+UO1k/0NGhvq1PAg==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -7409,12 +7472,24 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, "node_modules/lodash.includes": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", "license": "MIT" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, "node_modules/lodash.isboolean": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", @@ -8805,6 +8880,27 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -9681,6 +9777,12 @@ "node": ">=8" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -10619,16 +10721,16 @@ } }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/v8-compile-cache-lib": { diff --git a/package.json b/package.json index 6f5a4c0..653e5ab 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "bcryptjs": "^2.4.3", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "ioredis": "^5.4.2", "passport": "^0.7.0", "passport-jwt": "^4.0.1", "pg": "^8.13.1", @@ -41,6 +42,7 @@ "sequelize": "^6.37.5", "sequelize-typescript": "^2.1.6", "umzug": "^3.8.2", + "uuid": "^11.0.5", "zarinpal-checkout": "^0.3.0" }, "devDependencies": { diff --git a/src/shop/shop.module.ts b/src/shop/shop.module.ts index 187e2d0..bf4d9cc 100644 --- a/src/shop/shop.module.ts +++ b/src/shop/shop.module.ts @@ -11,6 +11,8 @@ import { Wallet } from "./entities/wallet.entity"; import { Transaction } from "./entities/transaction.entity"; import { Payment } from "./entities/payment.entity"; import { Invoice } from "./entities/invoice.entity"; +import { RedisService } from "src/redis/redis.service"; +import { JwtStrategy } from "src/users/guard/jwt.strategy"; @Module({ imports: [ @@ -21,6 +23,6 @@ import { Invoice } from "./entities/invoice.entity"; }), ], controllers: [ShopController], - providers: [ShopService, RoleGuard, JwtAuthGuard], + providers: [ShopService, RoleGuard, JwtAuthGuard,RedisService,JwtStrategy], }) export class ShopModule {} diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts index a1c2727..c8d44c3 100644 --- a/src/users/entities/user.entity.ts +++ b/src/users/entities/user.entity.ts @@ -33,11 +33,6 @@ export class User extends Model { }) gender: string; - @Column({ - type: DataType.STRING, - allowNull: true, - }) - refreshToken: string; } export enum Gender { Male = "male", diff --git a/src/users/guard/auth.guard.ts b/src/users/guard/auth.guard.ts index ed3931a..2566e1c 100644 --- a/src/users/guard/auth.guard.ts +++ b/src/users/guard/auth.guard.ts @@ -1,13 +1,16 @@ import { Injectable } from "@nestjs/common"; import { CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; -import { Observable } from "rxjs"; +import { JwtStrategy } from './jwt.strategy'; @Injectable() export class JwtAuthGuard implements CanActivate { - constructor(private readonly jwtService: JwtService) {} + constructor( + private readonly jwtService: JwtService, + private readonly jwtStrategy: JwtStrategy + ) {} - canActivate(context: ExecutionContext): boolean | Promise | Observable { + async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const token = request.headers["authorization"]?.split(" ")[1]; @@ -16,10 +19,15 @@ export class JwtAuthGuard implements CanActivate { } try { - const decoded = this.jwtService.verify(token,{secret:process.env.JWT_SECRET}); - request.user = decoded; - return true; + const decoded = this.jwtService.verify(token, { + secret: process.env.JWT_SECRET, + }); + await this.jwtStrategy.validate(decoded); + + request.user = decoded; + return true; } catch (error) { + console.log(error) throw new UnauthorizedException("Invalid or expired token"); } } diff --git a/src/users/guard/auth.module.ts b/src/users/guard/auth.module.ts index b6e051b..5afe90a 100644 --- a/src/users/guard/auth.module.ts +++ b/src/users/guard/auth.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { JwtAuthGuard } from './auth.guard'; +import { RedisService } from 'src/redis/redis.service'; +import { JwtStrategy } from './jwt.strategy'; @Module({ - providers: [JwtService, JwtAuthGuard], + providers: [JwtService, JwtAuthGuard,RedisService,JwtStrategy], exports: [JwtService, JwtAuthGuard], }) export class AuthModule {} diff --git a/src/users/guard/jwt.strategy.ts b/src/users/guard/jwt.strategy.ts new file mode 100644 index 0000000..3cf9b78 --- /dev/null +++ b/src/users/guard/jwt.strategy.ts @@ -0,0 +1,26 @@ +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, ExtractJwt } from 'passport-jwt'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { RedisService } from 'src/redis/redis.service'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(private readonly redisService: RedisService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + secretOrKey: process.env.JWT_SECRET, + }); + } + + async validate(payload: any): Promise { + const { jti, id } = payload; + + const response = await this.redisService.isWhitelisted(id, jti) + console.log(response) + if (!response) { + throw new UnauthorizedException('Token is not whitelisted'); + } + + return payload; + } +} diff --git a/src/users/guard/role.guard.ts b/src/users/guard/role.guard.ts index a0db94e..616b5c7 100644 --- a/src/users/guard/role.guard.ts +++ b/src/users/guard/role.guard.ts @@ -1,4 +1,4 @@ -import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"; +import { CanActivate, ExecutionContext, HttpException, Injectable, UnauthorizedException } from "@nestjs/common"; import { JwtService } from "@nestjs/jwt"; @Injectable() @@ -23,7 +23,9 @@ export class RoleGuard implements CanActivate { request.user = decoded; return true; } catch (error) { - console.log(error); + if(error instanceof HttpException){ + throw error + } throw new UnauthorizedException(error.response); } } diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index f9feef9..da56e63 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -29,8 +29,8 @@ export class UsersController { @UseGuards(JwtAuthGuard) @Get("logout") async logout(@Request() req) { - const userId = req.user.id; - return this.usersService.logout(userId); + const user = req.user; + return this.usersService.logout(user); } //retrieve a user information @UseGuards(JwtAuthGuard) diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 0ad1819..26a727c 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -1,30 +1,33 @@ -import { Module } from '@nestjs/common'; -import { SequelizeModule } from '@nestjs/sequelize'; -import { ConfigModule, ConfigService } from '@nestjs/config'; -import { JwtModule } from '@nestjs/jwt'; -import { PassportModule } from '@nestjs/passport'; -import { UsersService } from './users.service'; -import { UsersController } from './users.controller'; -import { User } from './entities/user.entity'; -import { JwtAuthGuard } from 'src/users/guard/auth.guard'; +import { Module } from "@nestjs/common"; +import { SequelizeModule } from "@nestjs/sequelize"; +import { ConfigModule, ConfigService } from "@nestjs/config"; +import { JwtModule } from "@nestjs/jwt"; +import { PassportModule } from "@nestjs/passport"; +import { UsersService } from "./users.service"; +import { UsersController } from "./users.controller"; +import { User } from "./entities/user.entity"; +import { JwtAuthGuard } from "src/users/guard/auth.guard"; +import { JwtStrategy } from "./guard/jwt.strategy"; +import { RedisService } from "src/redis/redis.service"; @Module({ - imports: [SequelizeModule.forFeature([User]), - PassportModule.register({ defaultStrategy: 'jwt' }), + imports: [ + SequelizeModule.forFeature([User]), + PassportModule.register({ defaultStrategy: "jwt" }), JwtModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => { return { - secret: config.get('JWT_SECRET'), + secret: config.get("JWT_SECRET"), signOptions: { - expiresIn: config.get('JWT_EXPIRES', '1h'), + expiresIn: config.get("JWT_EXPIRES", "1h"), }, }; }, }), -], + ], controllers: [UsersController], - providers: [UsersService,JwtAuthGuard], + providers: [UsersService, JwtAuthGuard, RedisService, JwtStrategy], }) export class UsersModule {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 4e0170c..11a930b 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -7,6 +7,8 @@ import { ConfigService } from "@nestjs/config"; import { CreateUserDto } from "./dto/create-user.dto"; import { LoginUserDto } from "./dto/login-user.dto"; import { UpdateUserDto } from "./dto/update-user.dto"; +import { v4 as uuidv4 } from "uuid"; +import { RedisService } from "src/redis/redis.service"; @Injectable() export class UsersService { @@ -14,6 +16,7 @@ export class UsersService { @InjectModel(User) private readonly userModel: typeof User, private readonly jwtService: JwtService, private readonly configService: ConfigService, + private readonly redisService: RedisService, ) {} // Register method @@ -46,7 +49,6 @@ export class UsersService { const user = await this.userModel.findOne({ where: { email: createUserDto.email }, }); - const refreshToken = this.jwtService.sign( { id: user.id }, { @@ -54,9 +56,6 @@ export class UsersService { expiresIn: this.configService.get("JWT_REFRESH_EXPIRES") || "7d", }, ); - - await this.userModel.update({ refreshToken }, { where: { id: user.id } }); - return { message: "User registered successfully." }; } catch (error) { if (error instanceof HttpException) { @@ -73,7 +72,7 @@ export class UsersService { }); if (!user) { - throw new UnauthorizedException("Invalid email , username or password."); + throw new UnauthorizedException("Invalid email, username or password."); } const passwordMatch = await bcrypt.compare(loginUserDto.password, user.password); @@ -81,8 +80,9 @@ export class UsersService { throw new UnauthorizedException("Invalid email or password."); } + const jti = uuidv4(); const accessToken = this.jwtService.sign( - { id: user.id, role: user.role }, + { id: user.id, role: user.role, jti }, { secret: this.configService.get("JWT_SECRET"), expiresIn: this.configService.get("JWT_EXPIRES") || "1h", @@ -90,17 +90,13 @@ export class UsersService { ); const refreshToken = this.jwtService.sign( - { id: user.id }, + { id: user.id, jti }, { secret: this.configService.get("JWT_REFRESH_SECRET"), expiresIn: this.configService.get("JWT_REFRESH_EXPIRES") || "7d", }, ); - - await this.userModel.update( - { refreshToken }, // - { where: { id: user.id } }, - ); + await this.redisService.addToWhitelist(user.id, jti, 3600); return { accessToken, refreshToken }; } catch (error) { @@ -127,38 +123,30 @@ export class UsersService { if (!user) { throw new HttpException("User not found.", HttpStatus.NOT_FOUND); } - - if (user.refreshToken !== refreshToken) { - throw new HttpException("Invalid refresh token.", HttpStatus.FORBIDDEN); - } - + + const jti = uuidv4(); const accessToken = this.jwtService.sign( - { id: user.id, role: user.role }, + { id: user.id, role: user.role, jti }, { secret: this.configService.get("JWT_SECRET"), expiresIn: this.configService.get("JWT_EXPIRES") || "1h", }, ); + await this.redisService.addToWhitelist(user.id, jti, 3600); return { accessToken }; } //logout (delete refresh token from database) - async logout(userId: number) { - if (!userId) { - throw new HttpException("User ID is required.", HttpStatus.BAD_REQUEST); - } - + async logout(user: any) { try { - const user = await this.userModel.findOne({ where: { id: userId } }); - if (!user) { - throw new HttpException("User not found.", HttpStatus.NOT_FOUND); - } + const jti = user.jti; + const userId = user.id; - await this.userModel.update({ refreshToken: null }, { where: { id: userId } }); + await this.redisService.deleteFromWhitelist(userId, jti); - return { message: "Logout is successful." }; + return { message: "Logged out successfully." }; } catch (error) { - throw new HttpException("An error occurred while logging out.", HttpStatus.INTERNAL_SERVER_ERROR); + throw new UnauthorizedException("An error occurred during logout."); } } //get information user method @@ -166,7 +154,7 @@ export class UsersService { try { const user = await this.userModel.findOne({ where: { id: userId }, - attributes: { exclude: ["password","refreshToken"] }, + attributes: { exclude: ["password", "refreshToken"] }, }); if (!user) { throw new HttpException("User not found.", HttpStatus.NOT_FOUND); @@ -252,14 +240,14 @@ export class UsersService { return users; } catch (error) { - if(error instanceof HttpException){ - throw error + if (error instanceof HttpException) { + throw error; } throw new HttpException("An unexpected error occurred while fetching users.", HttpStatus.INTERNAL_SERVER_ERROR); } } //get users list - async findSpecificUserInfoByUser(userId: number){ + async findSpecificUserInfoByUser(userId: number) { try { const user = await this.userModel.findByPk(userId, { attributes: { exclude: ["refreshToken", "password"] }, @@ -269,8 +257,8 @@ export class UsersService { } return user; } catch (error) { - if(error instanceof HttpException){ - throw error + if (error instanceof HttpException) { + throw error; } throw new HttpException("An unexpected error occurred while fetching user information.", HttpStatus.INTERNAL_SERVER_ERROR); } @@ -292,8 +280,8 @@ export class UsersService { return { message: "User deleted successfully." }; } catch (error) { - if(error instanceof HttpException){ - throw error + if (error instanceof HttpException) { + throw error; } throw new HttpException("An error occurred while deleting the user.", HttpStatus.INTERNAL_SERVER_ERROR); }