diff --git a/src/modules/auth/controllers/auth.public.controller.ts b/src/modules/auth/controllers/auth.public.controller.ts index 7528544e2..b52845138 100644 --- a/src/modules/auth/controllers/auth.public.controller.ts +++ b/src/modules/auth/controllers/auth.public.controller.ts @@ -173,7 +173,8 @@ export class AuthPublicController { const token = this.authService.createToken( userWithRole, - session._id + session._id, + session.jti ); await this.databaseService.commitTransaction(databaseSession); @@ -257,7 +258,8 @@ export class AuthPublicController { const token = this.authService.createToken( userWithRole, - session._id + session._id, + session.jti ); return { @@ -338,7 +340,8 @@ export class AuthPublicController { const token = this.authService.createToken( userWithRole, - session._id + session._id, + session.jti ); return { diff --git a/src/modules/auth/controllers/auth.shared.controller.ts b/src/modules/auth/controllers/auth.shared.controller.ts index d218ce9bc..01a7faf3f 100644 --- a/src/modules/auth/controllers/auth.shared.controller.ts +++ b/src/modules/auth/controllers/auth.shared.controller.ts @@ -48,6 +48,8 @@ import { IAuthJwtAccessTokenPayload, IAuthJwtRefreshTokenPayload, } from '@modules/auth/interfaces/auth.interface'; +import { v4 as uuidV4 } from 'uuid'; +import { SessionJtiProtected } from '@modules/session/decorators/session.jti.decorator'; @ApiTags('modules.shared.auth') @Controller({ @@ -69,6 +71,7 @@ export class AuthSharedController { @AuthSharedRefreshDoc() @Response('auth.refresh') + @SessionJtiProtected() @UserProtected() @AuthJwtRefreshProtected() @ApiKeyProtected() @@ -77,9 +80,13 @@ export class AuthSharedController { async refresh( @AuthJwtToken() refreshToken: string, @AuthJwtPayload() - { user: userFromPayload, session }: IAuthJwtRefreshTokenPayload + { + user: userFromPayload, + session: sessionId, + }: IAuthJwtRefreshTokenPayload ): Promise> { - const checkActive = await this.sessionService.findLoginSession(session); + const checkActive = + await this.sessionService.findLoginSession(sessionId); if (!checkActive) { throw new UnauthorizedException({ statusCode: ENUM_SESSION_STATUS_CODE_ERROR.NOT_FOUND, @@ -87,13 +94,43 @@ export class AuthSharedController { }); } - const user: IUserDoc = - await this.userService.findOneActiveById(userFromPayload); - const token = this.authService.refreshToken(user, refreshToken); + const dbSession: ClientSession = + await this.databaseService.createTransaction(); + try { + const activeSession = await this.sessionService.findOneActiveById( + sessionId, + { session: dbSession } + ); + const session = await this.sessionService.updateJti( + activeSession, + uuidV4(), + { session: dbSession } + ); + + const user: IUserDoc = await this.userService.findOneActiveById( + userFromPayload, + { session: dbSession } + ); + const token = this.authService.refreshToken( + user, + refreshToken, + session.jti + ); + + await this.databaseService.commitTransaction(dbSession); - return { - data: token, - }; + return { + data: token, + }; + } catch (err: unknown) { + await this.databaseService.abortTransaction(dbSession); + + throw new InternalServerErrorException({ + statusCode: ENUM_APP_STATUS_CODE_ERROR.UNKNOWN, + message: 'http.serverError.internalServerError', + _error: err, + }); + } } @AuthSharedChangePasswordDoc() diff --git a/src/modules/auth/interfaces/auth.interface.ts b/src/modules/auth/interfaces/auth.interface.ts index 7d8be91b0..fa130ef0c 100644 --- a/src/modules/auth/interfaces/auth.interface.ts +++ b/src/modules/auth/interfaces/auth.interface.ts @@ -34,6 +34,7 @@ export interface IAuthJwtAccessTokenPayload { termPolicy: IAuthJwtTermPolicyPayload; verification: IAuthJwtVerificationPayload; type: ENUM_POLICY_ROLE_TYPE; + jti?: string; iat?: number; nbf?: number; exp?: number; diff --git a/src/modules/auth/interfaces/auth.service.interface.ts b/src/modules/auth/interfaces/auth.service.interface.ts index d1a556e38..fc7abc5a1 100644 --- a/src/modules/auth/interfaces/auth.service.interface.ts +++ b/src/modules/auth/interfaces/auth.service.interface.ts @@ -13,13 +13,15 @@ import { AuthLoginResponseDto } from '@modules/auth/dtos/response/auth.login.res export interface IAuthService { createAccessToken( subject: string, - payload: IAuthJwtAccessTokenPayload + payload: IAuthJwtAccessTokenPayload, + jwtid: string ): string; validateAccessToken(subject: string, token: string): boolean; payload(token: string): T; createRefreshToken( subject: string, - payload: IAuthJwtRefreshTokenPayload + payload: IAuthJwtRefreshTokenPayload, + jti: string ): string; validateRefreshToken(subject: string, token: string): boolean; validateUser(passwordString: string, passwordHash: string): boolean; @@ -27,7 +29,7 @@ export interface IAuthService { data: IUserDoc, session: string, loginDate: Date, - loginFrom: ENUM_AUTH_LOGIN_FROM + loginFrom: ENUM_AUTH_LOGIN_FROM, ): IAuthJwtAccessTokenPayload; createPayloadRefreshToken({ user, @@ -42,10 +44,15 @@ export interface IAuthService { ): IAuthPassword; createPasswordRandom(): string; checkPasswordExpired(passwordExpired: Date): boolean; - createToken(user: IUserDoc, session: string): AuthLoginResponseDto; + createToken( + user: IUserDoc, + session: string, + jti: string + ): AuthLoginResponseDto; refreshToken( user: IUserDoc, - refreshTokenFromRequest: string + refreshTokenFromRequest: string, + jti: string ): AuthLoginResponseDto; getPasswordAttempt(): boolean; getPasswordMaxAttempt(): number; diff --git a/src/modules/auth/services/auth.service.ts b/src/modules/auth/services/auth.service.ts index 64464f321..56270ec36 100644 --- a/src/modules/auth/services/auth.service.ts +++ b/src/modules/auth/services/auth.service.ts @@ -21,6 +21,7 @@ import { AuthLoginResponseDto } from '@modules/auth/dtos/response/auth.login.res import { readFileSync } from 'fs'; import { JwtService, JwtSignOptions } from '@nestjs/jwt'; import { join } from 'path'; +import { v4 as uuidV4 } from 'uuid'; @Injectable() export class AuthService implements IAuthService { @@ -153,7 +154,8 @@ export class AuthService implements IAuthService { createAccessToken( subject: string, - payload: IAuthJwtAccessTokenPayload + payload: IAuthJwtAccessTokenPayload, + jwtid: string ): string { return this.jwtService.sign(payload, { privateKey: this.jwtAccessTokenPrivateKey, @@ -163,6 +165,7 @@ export class AuthService implements IAuthService { subject, algorithm: this.jwtAlgorithm, keyid: this.jwtAccessTokenKid, + jwtid: jwtid, } as JwtSignOptions); } @@ -188,7 +191,8 @@ export class AuthService implements IAuthService { createRefreshToken( subject: string, - payload: IAuthJwtRefreshTokenPayload + payload: IAuthJwtRefreshTokenPayload, + jti: string ): string { return this.jwtService.sign(payload, { privateKey: this.jwtRefreshTokenPrivateKey, @@ -198,6 +202,7 @@ export class AuthService implements IAuthService { subject, algorithm: this.jwtAlgorithm, keyid: this.jwtRefreshTokenKid, + jwtid: jti } as JwtSignOptions); } @@ -228,7 +233,7 @@ export class AuthService implements IAuthService { data: IUserDoc, session: string, loginDate: Date, - loginFrom: ENUM_AUTH_LOGIN_FROM + loginFrom: ENUM_AUTH_LOGIN_FROM, ): IAuthJwtAccessTokenPayload { return { user: data._id, @@ -306,7 +311,11 @@ export class AuthService implements IAuthService { return today > passwordExpiredConvert; } - createToken(user: IUserDoc, session: string): AuthLoginResponseDto { + createToken( + user: IUserDoc, + session: string, + jti: string + ): AuthLoginResponseDto { const loginDate = this.helperDateService.create(); const roleType = user.role.type; @@ -315,18 +324,20 @@ export class AuthService implements IAuthService { user, session, loginDate, - ENUM_AUTH_LOGIN_FROM.CREDENTIAL + ENUM_AUTH_LOGIN_FROM.CREDENTIAL, ); const accessToken: string = this.createAccessToken( user._id, - payloadAccessToken + payloadAccessToken, + jti ); const payloadRefreshToken: IAuthJwtRefreshTokenPayload = this.createPayloadRefreshToken(payloadAccessToken); const refreshToken: string = this.createRefreshToken( user._id, - payloadRefreshToken + payloadRefreshToken, + jti ); return { @@ -340,7 +351,8 @@ export class AuthService implements IAuthService { refreshToken( user: IUserDoc, - refreshTokenFromRequest: string + refreshTokenFromRequest: string, + jti: string ): AuthLoginResponseDto { const roleType = user.role.type; @@ -356,7 +368,8 @@ export class AuthService implements IAuthService { ); const accessToken: string = this.createAccessToken( user._id, - payloadAccessToken + payloadAccessToken, + jti ); return { diff --git a/src/modules/session/decorators/session.jti.decorator.ts b/src/modules/session/decorators/session.jti.decorator.ts new file mode 100644 index 000000000..c58ab449a --- /dev/null +++ b/src/modules/session/decorators/session.jti.decorator.ts @@ -0,0 +1,6 @@ +import { applyDecorators, UseGuards } from '@nestjs/common'; +import { SessionJtiGuard } from '@modules/session/guards/session.jti.guard'; + +export function SessionJtiProtected(): MethodDecorator { + return applyDecorators(UseGuards(SessionJtiGuard)); +} \ No newline at end of file diff --git a/src/modules/session/enums/session.status-code.enum.ts b/src/modules/session/enums/session.status-code.enum.ts index 021de8b05..4d3cdb503 100644 --- a/src/modules/session/enums/session.status-code.enum.ts +++ b/src/modules/session/enums/session.status-code.enum.ts @@ -2,4 +2,5 @@ export enum ENUM_SESSION_STATUS_CODE_ERROR { NOT_FOUND = 5070, EXPIRED = 5071, FORBIDDEN_REVOKE = 5072, + INVALID_JTI = 5080 } diff --git a/src/modules/session/guards/session.jti.guard.ts b/src/modules/session/guards/session.jti.guard.ts new file mode 100644 index 000000000..f36ecc0a5 --- /dev/null +++ b/src/modules/session/guards/session.jti.guard.ts @@ -0,0 +1,52 @@ +import { + CanActivate, + ExecutionContext, + Injectable, + UnauthorizedException, +} from '@nestjs/common'; +import { SessionService } from '@modules/session/services/session.service'; +import { Reflector } from '@nestjs/core'; +import { ENUM_SESSION_STATUS_CODE_ERROR } from '@modules/session/enums/session.status-code.enum'; +import { IRequestApp } from '@common/request/interfaces/request.interface'; +import { IAuthJwtRefreshTokenPayload } from '@modules/auth/interfaces/auth.interface'; + +@Injectable() +export class SessionJtiGuard implements CanActivate { + constructor( + private readonly sessionService: SessionService, + private readonly reflector: Reflector + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context + .switchToHttp() + .getRequest>(); + const user = request.user; + + if (!user || !user.session || !user.jti) { + throw new UnauthorizedException({ + statusCode: ENUM_SESSION_STATUS_CODE_ERROR.NOT_FOUND, + message: 'session.error.notFound', + }); + } + + const session = await this.sessionService.findOneActiveById( + user.session + ); + if (!session) { + throw new UnauthorizedException({ + statusCode: ENUM_SESSION_STATUS_CODE_ERROR.NOT_FOUND, + message: 'session.error.notFound', + }); + } + + if (session.jti !== user.jti) { + throw new UnauthorizedException({ + statusCode: ENUM_SESSION_STATUS_CODE_ERROR.INVALID_JTI, + message: 'session.error.invalidJti', + }); + } + + return true; + } +} diff --git a/src/modules/session/interfaces/session.service.interface.ts b/src/modules/session/interfaces/session.service.interface.ts index 8ad2bd999..074e83959 100644 --- a/src/modules/session/interfaces/session.service.interface.ts +++ b/src/modules/session/interfaces/session.service.interface.ts @@ -4,7 +4,7 @@ import { IDatabaseDeleteManyOptions, IDatabaseFindAllOptions, IDatabaseGetTotalOptions, - IDatabaseOptions, + IDatabaseOptions, IDatabaseSaveOptions, IDatabaseUpdateManyOptions, } from '@common/database/interfaces/database.interface'; import { SessionCreateRequestDto } from '@modules/session/dtos/request/session.create.request.dto'; @@ -59,6 +59,11 @@ export interface ISessionService { setLoginSession(user: IUserDoc, session: SessionDoc): Promise; deleteLoginSession(_id: string): Promise; resetLoginSession(): Promise; + updateJti( + repository: SessionDoc, + jti: string, + options?: IDatabaseSaveOptions + ): Promise updateRevoke( repository: SessionDoc, options?: IDatabaseOptions diff --git a/src/modules/session/repository/entities/session.entity.ts b/src/modules/session/repository/entities/session.entity.ts index e83753fc7..0f7268bfb 100644 --- a/src/modules/session/repository/entities/session.entity.ts +++ b/src/modules/session/repository/entities/session.entity.ts @@ -36,6 +36,14 @@ export class SessionEntity extends DatabaseUUIDEntityBase { }) user: string; + @DatabaseProp({ + required: true, + index: true, + trim: true, + type: String, + }) + jti: string; + @DatabaseProp({ required: true, trim: true, diff --git a/src/modules/session/services/session.service.ts b/src/modules/session/services/session.service.ts index 5fadf320a..e51f454e0 100644 --- a/src/modules/session/services/session.service.ts +++ b/src/modules/session/services/session.service.ts @@ -13,7 +13,7 @@ import { IDatabaseFindAllOptions, IDatabaseFindOneOptions, IDatabaseGetTotalOptions, - IDatabaseOptions, + IDatabaseOptions, IDatabaseSaveOptions, IDatabaseUpdateManyOptions, } from '@common/database/interfaces/database.interface'; import { HelperDateService } from '@common/helper/services/helper.date.service'; @@ -27,6 +27,9 @@ import { } from '@modules/session/repository/entities/session.entity'; import { SessionRepository } from '@modules/session/repository/repositories/session.repository'; import { IUserDoc } from '@modules/user/interfaces/user.interface'; +import { v4 as uuidV4 } from 'uuid'; +import { UserDoc } from '@modules/user/repository/entities/user.entity'; +import { UserUpdateRequestDto } from '@modules/user/dtos/request/user.update.request.dto'; @Injectable() export class SessionService implements ISessionService { @@ -143,6 +146,7 @@ export class SessionService implements ISessionService { const create = new SessionEntity(); create.user = user; + create.jti = uuidV4() create.hostname = request.hostname; create.ip = request.ip ?? '0.0.0.0'; create.protocol = request.protocol; @@ -202,6 +206,16 @@ export class SessionService implements ISessionService { return; } + async updateJti( + repository: SessionDoc, + jti: string, + options?: IDatabaseSaveOptions + ): Promise { + repository.jti = jti + + return this.sessionRepository.save(repository, options); + } + async updateRevoke( repository: SessionDoc, options?: IDatabaseOptions diff --git a/src/modules/user/controllers/user.shared.controller.ts b/src/modules/user/controllers/user.shared.controller.ts index 62e7c9ba1..af5105de5 100644 --- a/src/modules/user/controllers/user.shared.controller.ts +++ b/src/modules/user/controllers/user.shared.controller.ts @@ -45,6 +45,7 @@ import { } from '@modules/user/pipes/user.parse.pipe'; import { UserDoc } from '@modules/user/repository/entities/user.entity'; import { UserService } from '@modules/user/services/user.service'; +import { SessionJtiProtected } from '@modules/session/decorators/session.jti.decorator'; import { UserUploadPhotoProfileRequestDto } from '@modules/user/dtos/request/user.upload-photo-profile.request.dto'; import { TermPolicyAcceptanceProtected } from '@modules/term-policy/decorators/term-policy.decorator'; import { ENUM_TERM_POLICY_TYPE } from '@modules/term-policy/enums/term-policy.enum'; @@ -67,6 +68,7 @@ export class UserSharedController { @UserSharedProfileDoc() @Response('user.profile') @TermPolicyAcceptanceProtected(ENUM_TERM_POLICY_TYPE.PRIVACY) + @SessionJtiProtected() @UserProtected() @AuthJwtAccessProtected() @ApiKeyProtected() @@ -82,6 +84,7 @@ export class UserSharedController { @UserSharedUpdateProfileDoc() @Response('user.updateProfile') + @SessionJtiProtected() @UserProtected() @AuthJwtAccessProtected() @ApiKeyProtected() diff --git a/src/modules/user/controllers/user.user.controller.ts b/src/modules/user/controllers/user.user.controller.ts index 8f4f4c142..afaadec37 100644 --- a/src/modules/user/controllers/user.user.controller.ts +++ b/src/modules/user/controllers/user.user.controller.ts @@ -37,6 +37,7 @@ import { CountryService } from '@modules/country/services/country.service'; import { ENUM_COUNTRY_STATUS_CODE_ERROR } from '@modules/country/enums/country.status-code.enum'; import { UserProtected } from '@modules/user/decorators/user.decorator'; import { DatabaseService } from '@common/database/services/database.service'; +import { SessionJtiProtected } from '@modules/session/decorators/session.jti.decorator'; @ApiTags('modules.user.user') @Controller({ @@ -56,6 +57,7 @@ export class UserUserController { @UserUserDeleteDoc() @Response('user.delete') @PolicyRoleProtected(ENUM_POLICY_ROLE_TYPE.USER) + @SessionJtiProtected() @UserProtected([false]) @AuthJwtAccessProtected() @ApiKeyProtected()