diff --git a/server/package.json b/server/package.json index 0b3541d..4d75258 100644 --- a/server/package.json +++ b/server/package.json @@ -31,6 +31,7 @@ "bcrypt": "^5.1.1", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", + "ioredis": "^5.3.2", "nestjs-prisma": "^0.22.0", "passport": "^0.6.0", "passport-jwt": "^4.0.1", diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index 615a39c..dcc4ffa 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -38,6 +38,9 @@ dependencies: class-validator: specifier: ^0.14.0 version: 0.14.0 + ioredis: + specifier: ^5.3.2 + version: 5.3.2 nestjs-prisma: specifier: ^0.22.0 version: 0.22.0(@nestjs/common@10.2.5)(@prisma/client@5.3.1)(prisma@5.3.1) @@ -1674,6 +1677,10 @@ packages: resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} dev: true + /@ioredis/commands@1.2.0: + resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} + dev: false + /@isaacs/cliui@8.0.2: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -3841,6 +3848,11 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + /cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + dev: false + /co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -4224,6 +4236,11 @@ packages: resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} dev: false + /denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dev: false + /depd@1.1.2: resolution: {integrity: sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==} engines: {node: '>= 0.6'} @@ -5550,6 +5567,23 @@ packages: engines: {node: '>= 0.10'} dev: true + /ioredis@5.3.2: + resolution: {integrity: sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==} + engines: {node: '>=12.22.0'} + dependencies: + '@ioredis/commands': 1.2.0 + cluster-key-slot: 1.1.2 + 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 + transitivePeerDependencies: + - supports-color + dev: false + /ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -6383,6 +6417,14 @@ packages: resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} dev: true + /lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + dev: false + + /lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + dev: false + /lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} dev: true @@ -7378,6 +7420,18 @@ packages: resolve: 1.22.4 dev: true + /redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + dev: false + + /redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + dependencies: + redis-errors: 1.2.0 + dev: false + /reflect-metadata@0.1.13: resolution: {integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==} @@ -7814,6 +7868,10 @@ packages: escape-string-regexp: 2.0.0 dev: true + /standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + dev: false + /static-eval@2.1.0: resolution: {integrity: sha512-agtxZ/kWSsCkI5E4QifRwsaPs0P0JmZV6dkLz6ILYfFYQGn+5plctanRN+IC8dJRiFkyXHrwEE3W9Wmx67uDbw==} dependencies: diff --git a/server/src/iam/authentication/authentication.module.ts b/server/src/iam/authentication/authentication.module.ts index 1994a22..4ba6b4c 100644 --- a/server/src/iam/authentication/authentication.module.ts +++ b/server/src/iam/authentication/authentication.module.ts @@ -12,6 +12,7 @@ import { AccessTokenStrategy } from './strategies/access-token/access-token.stra import { AccessTokenGuard } from './guards/access-token/access-token.guard'; import { AuthenticationGuard } from './guards/authentication/authentication.guard'; import { APP_GUARD } from '@nestjs/core'; +import { RefreshTokenIdsStorage } from './utils/refresh-token-ids.storage/refresh-token-ids.storage'; @Module({ providers: [ @@ -23,6 +24,7 @@ import { APP_GUARD } from '@nestjs/core'; provide: APP_GUARD, useClass: AuthenticationGuard, }, + RefreshTokenIdsStorage, AuthenticationService, PrismaService, AccessTokenStrategy, diff --git a/server/src/iam/authentication/controllers/authentication.controller.ts b/server/src/iam/authentication/controllers/authentication.controller.ts index 3659a27..6e4b059 100644 --- a/server/src/iam/authentication/controllers/authentication.controller.ts +++ b/server/src/iam/authentication/controllers/authentication.controller.ts @@ -2,17 +2,14 @@ import { Body, Controller, Post } from '@nestjs/common'; import { AuthenticationService } from '../services/authentication.service'; import { SignUpDto } from '../dto/sign-up.dto/sign-up.dto'; import { SignInDto } from '../dto/sign-in.dto/sign-in.dto'; -import { AccessTokenStrategy } from '../strategies/access-token/access-token.strategy'; import { Auth } from '../decorators/auth/auth.decorator'; import { AuthType } from '../enums/auth-type.enum'; +import { RefreshTokenDto } from '../dto/refresh-token.dto/refresh-token.dto'; @Auth(AuthType.None) @Controller('authentication') export class AuthenticationController { - constructor( - private readonly authService: AuthenticationService, - private readonly accessTokenStrategy: AccessTokenStrategy, - ) {} + constructor(private readonly authService: AuthenticationService) {} @Post('sign-up') signUp(@Body() signUpDto: SignUpDto) { @@ -23,4 +20,9 @@ export class AuthenticationController { signIn(@Body() signInDto: SignInDto) { return this.authService.signIn(signInDto); } + + @Post('refresh-token') + refreshToken(@Body() refreshTokenDto: RefreshTokenDto) { + return this.authService.refreshTokens(refreshTokenDto); + } } diff --git a/server/src/iam/authentication/dto/refresh-token.dto/refresh-token.dto.spec.ts b/server/src/iam/authentication/dto/refresh-token.dto/refresh-token.dto.spec.ts new file mode 100644 index 0000000..84a33ab --- /dev/null +++ b/server/src/iam/authentication/dto/refresh-token.dto/refresh-token.dto.spec.ts @@ -0,0 +1,7 @@ +import { RefreshTokenDto } from './refresh-token.dto'; + +describe('RefreshTokenDto', () => { + it('should be defined', () => { + expect(new RefreshTokenDto()).toBeDefined(); + }); +}); diff --git a/server/src/iam/authentication/dto/refresh-token.dto/refresh-token.dto.ts b/server/src/iam/authentication/dto/refresh-token.dto/refresh-token.dto.ts new file mode 100644 index 0000000..de201a7 --- /dev/null +++ b/server/src/iam/authentication/dto/refresh-token.dto/refresh-token.dto.ts @@ -0,0 +1,6 @@ +import { IsNotEmpty } from 'class-validator'; + +export class RefreshTokenDto { + @IsNotEmpty() + refreshToken: string; +} diff --git a/server/src/iam/authentication/services/authentication.service.ts b/server/src/iam/authentication/services/authentication.service.ts index 95296e1..a1653fd 100644 --- a/server/src/iam/authentication/services/authentication.service.ts +++ b/server/src/iam/authentication/services/authentication.service.ts @@ -15,6 +15,11 @@ import jwtConfig from '../../config/jwt.config/jwt.config'; import { ConfigType } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import { ActiveUserData } from '../interfaces/active-user-data.interface'; +import { + RefreshTokenIdsStorage, + RefreshTokenIdsStorageError, +} from '../utils/refresh-token-ids.storage/refresh-token-ids.storage'; +import { RefreshTokenDto } from '../dto/refresh-token.dto/refresh-token.dto'; @Injectable() export class AuthenticationService { @@ -24,6 +29,7 @@ export class AuthenticationService { @Inject(jwtConfig.KEY) private readonly jwtConfiguration: ConfigType, private readonly jwtService: JwtService, + private readonly refreshTokenIdsStorage: RefreshTokenIdsStorage, ) {} private async checkUserExist(email: string): Promise { @@ -92,6 +98,9 @@ export class AuthenticationService { this.signToken(user.id, this.jwtConfiguration.refreshTokenTtl, { refreshTokenId, }), + + //* Insert refreshTokenId into storage + await this.refreshTokenIdsStorage.insert(user.id, refreshTokenId), ]); return { @@ -100,6 +109,44 @@ export class AuthenticationService { }; } + async refreshTokens(refreshToken: RefreshTokenDto) { + try { + const { sub, refreshTokenId } = await this.jwtService.verifyAsync< + Pick & { refreshTokenId: string } + >(refreshToken.refreshToken, { + secret: this.jwtConfiguration.secret, + audience: this.jwtConfiguration.audience, + issuer: this.jwtConfiguration.issuer, + }); + + const user = await this.prismaService.user.findUnique({ + where: { + id: sub, + }, + }); + + if (!user) throw new UnauthorizedException('User not found'); + + const isValid = await this.refreshTokenIdsStorage.validate( + sub, + refreshTokenId, + ); + + if (isValid) { + await this.refreshTokenIdsStorage.invalidate(user.id); + } else { + throw new UnauthorizedException('Invalid refresh token'); + } + + return await this.generateToken(user); + } catch (error) { + if (error instanceof RefreshTokenIdsStorageError) { + throw new UnauthorizedException('Invalid refresh token'); + } + throw new UnauthorizedException(error.message); + } + } + private async signToken(userID: number, expiresIn: number, payload?: T) { return await this.jwtService.signAsync( { diff --git a/server/src/iam/authentication/utils/refresh-token-ids.storage/refresh-token-ids.storage.spec.ts b/server/src/iam/authentication/utils/refresh-token-ids.storage/refresh-token-ids.storage.spec.ts new file mode 100644 index 0000000..9e40c2c --- /dev/null +++ b/server/src/iam/authentication/utils/refresh-token-ids.storage/refresh-token-ids.storage.spec.ts @@ -0,0 +1,7 @@ +import { RefreshTokenIdsStorage } from './refresh-token-ids.storage'; + +describe('RefreshTokenIdsStorage', () => { + it('should be defined', () => { + expect(new RefreshTokenIdsStorage()).toBeDefined(); + }); +}); diff --git a/server/src/iam/authentication/utils/refresh-token-ids.storage/refresh-token-ids.storage.ts b/server/src/iam/authentication/utils/refresh-token-ids.storage/refresh-token-ids.storage.ts new file mode 100644 index 0000000..f6b3b39 --- /dev/null +++ b/server/src/iam/authentication/utils/refresh-token-ids.storage/refresh-token-ids.storage.ts @@ -0,0 +1,46 @@ +import { + Injectable, + OnApplicationBootstrap, + OnApplicationShutdown, +} from '@nestjs/common'; + +import redisClient from 'ioredis'; + +export class RefreshTokenIdsStorageError extends Error { + constructor(message: string) { + super(message); + } +} + +@Injectable() +export class RefreshTokenIdsStorage + implements OnApplicationBootstrap, OnApplicationShutdown +{ + private redisClient: redisClient; + + onApplicationBootstrap() { + this.redisClient = new redisClient(process.env.REDIS_URL); + } + onApplicationShutdown() { + this.redisClient.quit(); + } + + async insert(userId: number, tokenID: string): Promise { + await this.redisClient.set(this.getKey(userId), tokenID); + } + + async validate(userId: number, tokenID: string): Promise { + const storedTokenID = await this.redisClient.get(this.getKey(userId)); + if (!storedTokenID) + throw new RefreshTokenIdsStorageError('Token ID not found'); + return storedTokenID === tokenID; + } + + async invalidate(userId: number): Promise { + await this.redisClient.del(this.getKey(userId)); + } + + private getKey(userId: number): string { + return `user-${userId}`; + } +}