From d3750eaa95832f7bfb0c7c9e3e2a65a1e7a8cf04 Mon Sep 17 00:00:00 2001 From: Marsboy02 Date: Fri, 17 Feb 2023 10:08:41 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20refresh=20token=20=EB=8F=84=EC=9E=85?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=9D=BC=20.env.example=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/.env.example b/.env.example index e55633b..4d0d4fe 100644 --- a/.env.example +++ b/.env.example @@ -3,16 +3,18 @@ APP_URL=localhost APP_PORT=3000 # redis -REDIS_HOST="redis" -REDIS_PORT="6379" +REDIS_HOST=redis +REDIS_PORT=6379 # postgres -POSTGRES_HOST="localhost" -POSTGRES_PORT="5432" - -POSTGRES_DB="docker-nest-postgres" -POSTGRES_USER="username" -POSTGRES_PASSWORD="password" +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_DB=database +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres # jwt -JWT_SECRET= \ No newline at end of file +JWT_ACCESS_TOKEN_SECRET= +JWT_ACCESS_TOKEN_EXPIRATION_TIME= +JWT_REFRESH_TOKEN_SECRET= +JWT_REFRESH_EXPIRATION_TIME= \ No newline at end of file From f4534d8403aa2c32fed98801a41f55f852a2c603 Mon Sep 17 00:00:00 2001 From: Marsboy02 Date: Fri, 17 Feb 2023 10:45:18 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20refresh=20token=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20api=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/auth/auth.repository.ts | 39 ++++++++++++++++- src/app/auth/auth.service.ts | 77 ++++++++++++++++++++++++++++++++- src/libs/entity/user.entity.ts | 5 +++ 3 files changed, 118 insertions(+), 3 deletions(-) diff --git a/src/app/auth/auth.repository.ts b/src/app/auth/auth.repository.ts index f183079..fbb0387 100644 --- a/src/app/auth/auth.repository.ts +++ b/src/app/auth/auth.repository.ts @@ -5,13 +5,38 @@ import { UserEntity } from '../../libs/entity/user.entity'; @Injectable() export class AuthRepository { constructor(private readonly dataSource: DataSource) {} - async findOne(username: string): Promise { + async getUserByUsername(username: string): Promise { try { return await this.findOneByUsername(username); } catch (error) { throw error; } } + + async getUserById(userId: number) { + try { + return await this.findOneById(userId); + } catch (error) { + throw error; + } + } + + async updateRefreshToken(userId: number, refreshToken: object) { + try { + await this.updateRefreshTokenByUserId(userId, refreshToken); + } catch (error) { + throw error; + } + } + + private async findOneById(userId: number) { + return await this.dataSource + .createQueryBuilder() + .select() + .from(UserEntity, 'User') + .where(`User.id =: userId`, { userId }) + .getRawOne(); + } private async findOneByUsername(username: string): Promise { return await this.dataSource .createQueryBuilder() @@ -20,4 +45,16 @@ export class AuthRepository { .where(`User.username =:username`, { username }) .getRawOne(); } + + private async updateRefreshTokenByUserId( + userId: number, + refreshToken: object, + ) { + await this.dataSource + .createQueryBuilder() + .update(UserEntity) + .set(refreshToken) + .where(`id =:user_id`, { userId }) + .execute(); + } } diff --git a/src/app/auth/auth.service.ts b/src/app/auth/auth.service.ts index 9c54af8..4f5278c 100644 --- a/src/app/auth/auth.service.ts +++ b/src/app/auth/auth.service.ts @@ -11,6 +11,7 @@ import { UsernameRequest } from '../../libs/request/users/username.request'; import { UserEntity } from '../../libs/entity/user.entity'; import { LoginRequest } from '../../libs/request/auth/login.request'; import * as argon2 from 'argon2'; +import * as process from 'process'; @Injectable() export class AuthService { @@ -40,7 +41,7 @@ export class AuthService { async checkUsername(dto: UsernameRequest): Promise { try { - const user = await this.authRepository.findOne(dto.username); + const user = await this.authRepository.getUserByUsername(dto.username); if (!user) throw new NotFoundException('존재하지 않는 username입니다.'); return { id: user.id, @@ -58,9 +59,81 @@ export class AuthService { username: string, pass: string, ): Promise { - const user = await this.authRepository.findOne(username); + const user = await this.authRepository.getUserByUsername(username); if (!user) throw new NotFoundException('존재하지 않는 username입니다.'); if (await argon2.verify(user.password, pass)) return user; else throw new BadRequestException('password가 일치하지 않습니다.'); } + + getCookieWithJwtAccessToken(id: number) { + const payload = { id }; + const token = this.jwtService.sign(payload, { + secret: process.env.JWT_ACCESS_TOKEN_SECRET, + expiresIn: `${process.env.JWT_ACCESS_TOKEN_EXPIRATION_TIME}s`, + }); + + return { + accessToken: token, + domain: 'localhost', + path: '/', + httpOnly: true, + maxAge: Number(process.env.JWT_ACCESS_TOKEN_EXPIRATION_TIME) * 1000, + }; + } + + getCookieWithJwtRefreshToken(id: number) { + const payload = { id }; + const token = this.jwtService.sign(payload, { + secret: process.env.JWT_REFRESH_TOKEN_SECRET, + expiresIn: `${process.env.JWT_REFRESH_TOKEN_EXPIRATION_TIME}s`, + }); + + return { + refreshToken: token, + domain: 'localhost', + path: '/', + httpOnly: true, + maxAge: Number(process.env.JWT_REFRESH_TOKEN_EXPIRATION_TIME) * 1000, + }; + } + + getCookiesForLogOut() { + return { + accessOption: { + domain: 'localhost', + path: '/', + httpOnly: true, + maxAge: 0, + }, + refreshOption: { + domain: 'localhost', + path: '/', + httpOnly: true, + maxAge: 0, + }, + }; + } + + async setCurrentRefreshToken(refreshToken: string, id: number) { + const currentHashedRefreshToken = await argon2.hash(refreshToken); + await this.authRepository.updateRefreshToken(id, { + currentHashedRefreshToken, + }); + } + + async getUserIfRefreshTokenMatches(refreshToken: string, id: number) { + const user = await this.authRepository.getUserById(id); + const isRefreshTokenMatching = await argon2.verify( + refreshToken, + user.currentHashedRefreshToken, + ); + + if (isRefreshTokenMatching) return user; + } + + async removeRefreshToken(id: number) { + return this.authRepository.updateRefreshToken(id, { + currentHashedRefreshToken: null, + }); + } } diff --git a/src/libs/entity/user.entity.ts b/src/libs/entity/user.entity.ts index cb6ebfc..bb0e27a 100644 --- a/src/libs/entity/user.entity.ts +++ b/src/libs/entity/user.entity.ts @@ -1,6 +1,7 @@ import { Column, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; import { ReservationEntity } from './reservation.entity'; import * as moment from 'moment/moment'; +import { Exclude } from 'class-transformer'; @Entity('user') export class UserEntity { @@ -31,6 +32,10 @@ export class UserEntity { @Column({ type: 'varchar', default: moment().format('YYYY-MM-DD') }) updatedAt!: string; + @Column({ nullable: true }) + @Exclude() + currentHashedRefreshToken?: string; + @OneToMany((type) => ReservationEntity, (reservation) => reservation.user) Reservations: ReservationEntity[]; } From 8b4142168327ce88d9779f30fc7697972212c432 Mon Sep 17 00:00:00 2001 From: Marsboy02 Date: Fri, 17 Feb 2023 10:55:02 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20jwt=20Refresh=20Token=20=EC=A0=84?= =?UTF-8?q?=EB=9E=B5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/auth/auth.module.ts | 6 +++-- src/app/auth/jwt/constants.ts | 2 +- src/app/auth/jwt/jwt-refresh.strategy.ts | 29 ++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 src/app/auth/jwt/jwt-refresh.strategy.ts diff --git a/src/app/auth/auth.module.ts b/src/app/auth/auth.module.ts index 0b00787..35bd623 100644 --- a/src/app/auth/auth.module.ts +++ b/src/app/auth/auth.module.ts @@ -7,6 +7,8 @@ import { JwtStrategy } from './jwt/jwt.strategy'; import { AuthController } from './auth.controller'; import { jwtConstants } from './jwt/constants'; import { AuthRepository } from './auth.repository'; +import * as process from 'process'; +import { JwtRefreshStrategy } from './jwt/jwt-refresh.strategy'; @Module({ imports: [ @@ -14,11 +16,11 @@ import { AuthRepository } from './auth.repository'; PassportModule, JwtModule.register({ secret: jwtConstants.secret, - signOptions: { expiresIn: '3600s' }, + signOptions: { expiresIn: process.env.JWT_ACCESS_TOKEN_EXPIRATION_TIME }, }), ], controllers: [AuthController], - providers: [AuthService, AuthRepository, JwtStrategy], + providers: [AuthService, AuthRepository, JwtStrategy, JwtRefreshStrategy], exports: [AuthService], }) export class AuthModule {} diff --git a/src/app/auth/jwt/constants.ts b/src/app/auth/jwt/constants.ts index b01cebd..7eda68d 100644 --- a/src/app/auth/jwt/constants.ts +++ b/src/app/auth/jwt/constants.ts @@ -2,5 +2,5 @@ import * as dotenv from 'dotenv'; dotenv.config(); export const jwtConstants = { - secret: process.env.JWT_SECRET, + secret: process.env.JWT_ACCESS_TOKEN_SECRET, }; diff --git a/src/app/auth/jwt/jwt-refresh.strategy.ts b/src/app/auth/jwt/jwt-refresh.strategy.ts new file mode 100644 index 0000000..df9a619 --- /dev/null +++ b/src/app/auth/jwt/jwt-refresh.strategy.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { AuthService } from '../auth.service'; + +@Injectable() +export class JwtRefreshStrategy extends PassportStrategy( + Strategy, + 'jwt-refresh-token', +) { + constructor(private readonly authService: AuthService) { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([ + (request) => { + return request?.cookies?.Refresh; + }, + ]), + secretOrKey: process.env.JWT_REFRESH_TOKEN_SECRET, + passReqToCallback: true, + }); + } + async validate(req, payload: any) { + const refreshToken = req.cookies?.Refresh; + return this.authService.getUserIfRefreshTokenMatches( + refreshToken, + payload.id, + ); + } +} From 6b14aa1b2bf1a2b42aa7010c116f0aaad127b42c Mon Sep 17 00:00:00 2001 From: Marsboy02 Date: Fri, 17 Feb 2023 12:15:57 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20logout=20api=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/auth/auth.controller.ts | 8 ++++++ src/app/auth/auth.module.ts | 4 ++- src/app/auth/auth.repository.ts | 2 +- src/app/auth/auth.service.ts | 25 ++++++++++++------- src/app/auth/jwt/constants.ts | 4 +++ src/app/auth/jwt/jwt-refresh.strategy.ts | 3 ++- src/app/auth/jwt/jwt.strategy.ts | 2 +- src/libs/entity/user.entity.ts | 2 +- .../1676601824696-update-refresh-token.ts | 20 +++++++++++++++ 9 files changed, 56 insertions(+), 14 deletions(-) create mode 100644 src/migrations/1676601824696-update-refresh-token.ts diff --git a/src/app/auth/auth.controller.ts b/src/app/auth/auth.controller.ts index 52145ec..4804328 100644 --- a/src/app/auth/auth.controller.ts +++ b/src/app/auth/auth.controller.ts @@ -5,6 +5,7 @@ import { Param, Post, Req, + Res, UseGuards, } from '@nestjs/common'; import { AuthService } from './auth.service'; @@ -28,6 +29,7 @@ import { JwtResponse } from '../../libs/response/auth/jwt.response'; import { UnauthorizedError } from '../../libs/response/status-code/unauthorized.error'; import { AccessTokenResponse } from '../../libs/response/auth/access-token.response'; import { CheckUsernameSuccessResponse } from '../../libs/response/auth/check-username.success.response'; +import { JwtRefreshStrategy } from './jwt/jwt-refresh.strategy'; @Controller('auth') @ApiTags('Auth') @@ -61,6 +63,12 @@ export class AuthController { return this.authService.login(dto); } + @UseGuards(JwtRefreshStrategy) + @Post('logout') + async logOut(@Param() userId: number) { + return this.authService.getCookiesForLogOut(); + } + @UseGuards(JwtAuthGuard) @ApiBearerAuth('access_token') @Get('profile') diff --git a/src/app/auth/auth.module.ts b/src/app/auth/auth.module.ts index 35bd623..08956b0 100644 --- a/src/app/auth/auth.module.ts +++ b/src/app/auth/auth.module.ts @@ -16,7 +16,9 @@ import { JwtRefreshStrategy } from './jwt/jwt-refresh.strategy'; PassportModule, JwtModule.register({ secret: jwtConstants.secret, - signOptions: { expiresIn: process.env.JWT_ACCESS_TOKEN_EXPIRATION_TIME }, + signOptions: { + expiresIn: `${process.env.JWT_ACCESS_TOKEN_EXPIRATION_TIME}s`, + }, }), ], controllers: [AuthController], diff --git a/src/app/auth/auth.repository.ts b/src/app/auth/auth.repository.ts index fbb0387..60625de 100644 --- a/src/app/auth/auth.repository.ts +++ b/src/app/auth/auth.repository.ts @@ -54,7 +54,7 @@ export class AuthRepository { .createQueryBuilder() .update(UserEntity) .set(refreshToken) - .where(`id =:user_id`, { userId }) + .where(`id =:userId`, { userId }) .execute(); } } diff --git a/src/app/auth/auth.service.ts b/src/app/auth/auth.service.ts index 4f5278c..5518b3b 100644 --- a/src/app/auth/auth.service.ts +++ b/src/app/auth/auth.service.ts @@ -11,7 +11,7 @@ import { UsernameRequest } from '../../libs/request/users/username.request'; import { UserEntity } from '../../libs/entity/user.entity'; import { LoginRequest } from '../../libs/request/auth/login.request'; import * as argon2 from 'argon2'; -import * as process from 'process'; +import { jwtConstants } from './jwt/constants'; @Injectable() export class AuthService { @@ -24,10 +24,17 @@ export class AuthService { async login(dto: LoginRequest): Promise { try { const user = await this.validateUser(dto.username, dto.password); - const payload = { username: user.username, password: user.password }; + const { accessToken, ...accessOption } = this.getCookieWithJwtAccessToken( + user.id, + ); + const { refreshToken, ...refreshOption } = + this.getCookieWithJwtRefreshToken(user.id); + + await this.setCurrentRefreshToken(refreshToken, user.id); return { user_id: user.id, - access_token: this.jwtService.sign(payload), + accessToken, + refreshToken, }; } catch (error) { this.logger.error(error); @@ -68,8 +75,8 @@ export class AuthService { getCookieWithJwtAccessToken(id: number) { const payload = { id }; const token = this.jwtService.sign(payload, { - secret: process.env.JWT_ACCESS_TOKEN_SECRET, - expiresIn: `${process.env.JWT_ACCESS_TOKEN_EXPIRATION_TIME}s`, + secret: jwtConstants.access_token_secret, + expiresIn: `${jwtConstants.access_token_expiration}s`, }); return { @@ -77,15 +84,15 @@ export class AuthService { domain: 'localhost', path: '/', httpOnly: true, - maxAge: Number(process.env.JWT_ACCESS_TOKEN_EXPIRATION_TIME) * 1000, + maxAge: Number(jwtConstants.access_token_expiration) * 1000, }; } getCookieWithJwtRefreshToken(id: number) { const payload = { id }; const token = this.jwtService.sign(payload, { - secret: process.env.JWT_REFRESH_TOKEN_SECRET, - expiresIn: `${process.env.JWT_REFRESH_TOKEN_EXPIRATION_TIME}s`, + secret: jwtConstants.refresh_token_secret, + expiresIn: `${jwtConstants.refresh_token_expiration}s`, }); return { @@ -93,7 +100,7 @@ export class AuthService { domain: 'localhost', path: '/', httpOnly: true, - maxAge: Number(process.env.JWT_REFRESH_TOKEN_EXPIRATION_TIME) * 1000, + maxAge: Number(jwtConstants.refresh_token_expiration) * 1000, }; } diff --git a/src/app/auth/jwt/constants.ts b/src/app/auth/jwt/constants.ts index 7eda68d..b670b96 100644 --- a/src/app/auth/jwt/constants.ts +++ b/src/app/auth/jwt/constants.ts @@ -3,4 +3,8 @@ import * as dotenv from 'dotenv'; dotenv.config(); export const jwtConstants = { secret: process.env.JWT_ACCESS_TOKEN_SECRET, + access_token_secret: process.env.JWT_ACCESS_TOKEN_SECRET, + access_token_expiration: process.env.JWT_ACCESS_TOKEN_EXPIRATION_TIME, + refresh_token_secret: process.env.JWT_REFRESH_TOKEN_SECRET, + refresh_token_expiration: process.env.JWT_REFRESH_EXPIRATION_TIME, }; diff --git a/src/app/auth/jwt/jwt-refresh.strategy.ts b/src/app/auth/jwt/jwt-refresh.strategy.ts index df9a619..53aeda9 100644 --- a/src/app/auth/jwt/jwt-refresh.strategy.ts +++ b/src/app/auth/jwt/jwt-refresh.strategy.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { AuthService } from '../auth.service'; +import { jwtConstants } from './constants'; @Injectable() export class JwtRefreshStrategy extends PassportStrategy( @@ -15,7 +16,7 @@ export class JwtRefreshStrategy extends PassportStrategy( return request?.cookies?.Refresh; }, ]), - secretOrKey: process.env.JWT_REFRESH_TOKEN_SECRET, + secretOrKey: jwtConstants.refresh_token_secret, passReqToCallback: true, }); } diff --git a/src/app/auth/jwt/jwt.strategy.ts b/src/app/auth/jwt/jwt.strategy.ts index 7e1422c..3b877d7 100644 --- a/src/app/auth/jwt/jwt.strategy.ts +++ b/src/app/auth/jwt/jwt.strategy.ts @@ -9,7 +9,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) { super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: jwtConstants.secret, + secretOrKey: jwtConstants.access_token_secret, }); } diff --git a/src/libs/entity/user.entity.ts b/src/libs/entity/user.entity.ts index bb0e27a..bcdce79 100644 --- a/src/libs/entity/user.entity.ts +++ b/src/libs/entity/user.entity.ts @@ -32,7 +32,7 @@ export class UserEntity { @Column({ type: 'varchar', default: moment().format('YYYY-MM-DD') }) updatedAt!: string; - @Column({ nullable: true }) + @Column({ type: 'varchar', nullable: true }) @Exclude() currentHashedRefreshToken?: string; diff --git a/src/migrations/1676601824696-update-refresh-token.ts b/src/migrations/1676601824696-update-refresh-token.ts new file mode 100644 index 0000000..9a2286a --- /dev/null +++ b/src/migrations/1676601824696-update-refresh-token.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class updateRefreshToken1676601824696 implements MigrationInterface { + name = 'updateRefreshToken1676601824696' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user" ADD "current_hashed_refresh_token" character varying`); + await queryRunner.query(`ALTER TABLE "user" ALTER COLUMN "created_at" SET DEFAULT '2023-02-17'`); + await queryRunner.query(`ALTER TABLE "user" ALTER COLUMN "updated_at" SET DEFAULT '2023-02-17'`); + await queryRunner.query(`ALTER TABLE "reservation" ALTER COLUMN "created_at" SET DEFAULT '2023-02-17'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "reservation" ALTER COLUMN "created_at" SET DEFAULT '2023-02-13'`); + await queryRunner.query(`ALTER TABLE "user" ALTER COLUMN "updated_at" SET DEFAULT '2023-02-13'`); + await queryRunner.query(`ALTER TABLE "user" ALTER COLUMN "created_at" SET DEFAULT '2023-02-13'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "current_hashed_refresh_token"`); + } + +} From ec6ff56b51022e2549b2fe2040d4faaad40f4428 Mon Sep 17 00:00:00 2001 From: Marsboy02 Date: Fri, 17 Feb 2023 13:37:13 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20refresh=20auth=20guard=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/auth/auth.controller.ts | 42 +++++++++++-------- src/app/auth/auth.repository.ts | 1 + src/app/auth/auth.service.ts | 19 ++++++--- src/app/auth/jwt/jwt-auth-refresh.guard.ts | 5 +++ src/app/auth/jwt/jwt-refresh.strategy.ts | 33 ++++++++------- src/app/auth/jwt/jwt.strategy.ts | 17 ++++++-- .../swagger/swagger.generator.ts | 13 +++++- 7 files changed, 89 insertions(+), 41 deletions(-) create mode 100644 src/app/auth/jwt/jwt-auth-refresh.guard.ts diff --git a/src/app/auth/auth.controller.ts b/src/app/auth/auth.controller.ts index 4804328..d48387e 100644 --- a/src/app/auth/auth.controller.ts +++ b/src/app/auth/auth.controller.ts @@ -5,13 +5,13 @@ import { Param, Post, Req, - Res, UseGuards, } from '@nestjs/common'; import { AuthService } from './auth.service'; import { ApiBadRequestResponse, ApiBearerAuth, + ApiCreatedResponse, ApiInternalServerErrorResponse, ApiNotFoundResponse, ApiOkResponse, @@ -30,6 +30,9 @@ import { UnauthorizedError } from '../../libs/response/status-code/unauthorized. import { AccessTokenResponse } from '../../libs/response/auth/access-token.response'; import { CheckUsernameSuccessResponse } from '../../libs/response/auth/check-username.success.response'; import { JwtRefreshStrategy } from './jwt/jwt-refresh.strategy'; +import { UserIdRequest } from '../../libs/request/users/user-id.request'; +import { OkSuccess } from '../../libs/response/status-code/ok.success'; +import { JwtStrategy } from './jwt/jwt.strategy'; @Controller('auth') @ApiTags('Auth') @@ -37,24 +40,20 @@ export class AuthController { constructor(private readonly authService: AuthService) {} @Post('/login') - @ApiOkResponse({ - status: 201, + @ApiCreatedResponse({ description: '계정 정보가 일치하는 경우 access_token과 user_id를 반환합니다.', type: AccessTokenResponse, }) @ApiBadRequestResponse({ - status: 400, description: 'password가 일치하지 않는 경우', type: BadRequestError, }) @ApiNotFoundResponse({ - status: 404, description: 'username이 존재하지 않는 경우', type: NotFoundError, }) @ApiInternalServerErrorResponse({ - status: 500, description: '서버에 에러가 발생한 경우', type: InternalServerErrorError, }) @@ -64,26 +63,38 @@ export class AuthController { } @UseGuards(JwtRefreshStrategy) - @Post('logout') - async logOut(@Param() userId: number) { - return this.authService.getCookiesForLogOut(); + @ApiOkResponse({ + description: 'refresh 토큰의 정보가 null로 성공적으로 변경된 경우', + type: OkSuccess, + }) + @ApiUnauthorizedResponse({ + description: '인증이 되어있지 않은 경우', + type: UnauthorizedError, + }) + @ApiInternalServerErrorResponse({ + description: '서버에 에러가 발생한 경우', + type: InternalServerErrorError, + }) + @ApiOperation({ summary: 'DB에 저장된 refresh token의 정보를 초기화합니다.' }) + @Post('logout/:id') + async logOut(@Param() dto: UserIdRequest) { + return this.authService.removeRefreshToken(dto); } - @UseGuards(JwtAuthGuard) - @ApiBearerAuth('access_token') + @UseGuards(JwtStrategy) + // @UseGuards(JwtRefreshStrategy) + @ApiBearerAuth('accessToken') + // @ApiBearerAuth('refreshToken') @Get('profile') @ApiOkResponse({ - status: 200, description: 'jwt 토큰의 인증이 성공한 경우 username을 반환합니다.', type: JwtResponse, }) @ApiUnauthorizedResponse({ - status: 401, description: '인증이 되어있지 않은 경우', type: UnauthorizedError, }) @ApiInternalServerErrorResponse({ - status: 500, description: '서버에 에러가 발생한 경우', type: InternalServerErrorError, }) @@ -94,18 +105,15 @@ export class AuthController { @Get(':username') @ApiOkResponse({ - status: 200, description: '존재하는 username을 입력한 경우 user의 id와 name을 반환합니다.', type: CheckUsernameSuccessResponse, }) @ApiNotFoundResponse({ - status: 404, description: '존재하지 않는 username을 입력한 경우', type: NotFoundError, }) @ApiInternalServerErrorResponse({ - status: 500, description: '서버 에러가 발생했을 경우', type: InternalServerErrorError, }) diff --git a/src/app/auth/auth.repository.ts b/src/app/auth/auth.repository.ts index 60625de..96a1328 100644 --- a/src/app/auth/auth.repository.ts +++ b/src/app/auth/auth.repository.ts @@ -24,6 +24,7 @@ export class AuthRepository { async updateRefreshToken(userId: number, refreshToken: object) { try { await this.updateRefreshTokenByUserId(userId, refreshToken); + return refreshToken; } catch (error) { throw error; } diff --git a/src/app/auth/auth.service.ts b/src/app/auth/auth.service.ts index 5518b3b..d3c539d 100644 --- a/src/app/auth/auth.service.ts +++ b/src/app/auth/auth.service.ts @@ -12,6 +12,7 @@ import { UserEntity } from '../../libs/entity/user.entity'; import { LoginRequest } from '../../libs/request/auth/login.request'; import * as argon2 from 'argon2'; import { jwtConstants } from './jwt/constants'; +import { UserIdRequest } from '../../libs/request/users/user-id.request'; @Injectable() export class AuthService { @@ -62,6 +63,18 @@ export class AuthService { } } + async removeRefreshToken(dto: UserIdRequest): Promise { + try { + await this.authRepository.updateRefreshToken(dto.id, { + currentHashedRefreshToken: null, + }); + return {}; + } catch (error) { + this.logger.error(error); + throw new BadRequestException(error.getResponse()); + } + } + private async validateUser( username: string, pass: string, @@ -137,10 +150,4 @@ export class AuthService { if (isRefreshTokenMatching) return user; } - - async removeRefreshToken(id: number) { - return this.authRepository.updateRefreshToken(id, { - currentHashedRefreshToken: null, - }); - } } diff --git a/src/app/auth/jwt/jwt-auth-refresh.guard.ts b/src/app/auth/jwt/jwt-auth-refresh.guard.ts new file mode 100644 index 0000000..88193e5 --- /dev/null +++ b/src/app/auth/jwt/jwt-auth-refresh.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtRefreshGuard extends AuthGuard('jwt-refresh-token') {} diff --git a/src/app/auth/jwt/jwt-refresh.strategy.ts b/src/app/auth/jwt/jwt-refresh.strategy.ts index 53aeda9..4a60899 100644 --- a/src/app/auth/jwt/jwt-refresh.strategy.ts +++ b/src/app/auth/jwt/jwt-refresh.strategy.ts @@ -1,30 +1,35 @@ -import { Injectable } from '@nestjs/common'; -import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; -import { AuthService } from '../auth.service'; +import { PassportStrategy } from '@nestjs/passport'; +import { Injectable } from '@nestjs/common'; +import { Request as RequestType } from 'express'; import { jwtConstants } from './constants'; @Injectable() export class JwtRefreshStrategy extends PassportStrategy( Strategy, - 'jwt-refresh-token', + 'jwt-refresh', ) { - constructor(private readonly authService: AuthService) { + constructor() { super({ jwtFromRequest: ExtractJwt.fromExtractors([ - (request) => { - return request?.cookies?.Refresh; - }, + JwtRefreshStrategy.extractJWT, + ExtractJwt.fromAuthHeaderAsBearerToken(), ]), + ignoreExpiration: false, secretOrKey: jwtConstants.refresh_token_secret, passReqToCallback: true, }); } - async validate(req, payload: any) { - const refreshToken = req.cookies?.Refresh; - return this.authService.getUserIfRefreshTokenMatches( - refreshToken, - payload.id, - ); + + async validate(req: RequestType, payload: any) { + const refreshToken = req.cookies.refresh; + return { ...payload, refreshToken }; + } + + private static extractJWT(req: RequestType): string | null { + if (req.cookies && 'refresh_token' in req.cookies) { + return req.cookies.refresh_token; + } + return null; } } diff --git a/src/app/auth/jwt/jwt.strategy.ts b/src/app/auth/jwt/jwt.strategy.ts index 3b877d7..40982be 100644 --- a/src/app/auth/jwt/jwt.strategy.ts +++ b/src/app/auth/jwt/jwt.strategy.ts @@ -1,19 +1,30 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; +import { Request as RequestType } from 'express'; import { jwtConstants } from './constants'; @Injectable() -export class JwtStrategy extends PassportStrategy(Strategy) { +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { constructor() { super({ - jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + jwtFromRequest: ExtractJwt.fromExtractors([ + JwtStrategy.extractJWT, + ExtractJwt.fromAuthHeaderAsBearerToken(), + ]), ignoreExpiration: false, secretOrKey: jwtConstants.access_token_secret, }); } async validate(payload: any) { - return { userId: payload.sub, username: payload.username }; + return { username: payload.username, role: payload.role }; + } + + private static extractJWT(req: RequestType): string | null { + if (req.cookies && 'accessToken' in req.cookies) { + return req.cookies.accessToken; + } + return null; } } diff --git a/src/infrastructure/swagger/swagger.generator.ts b/src/infrastructure/swagger/swagger.generator.ts index 45e92bf..c4866bf 100644 --- a/src/infrastructure/swagger/swagger.generator.ts +++ b/src/infrastructure/swagger/swagger.generator.ts @@ -15,7 +15,18 @@ const document = new DocumentBuilder() description: '사용자의 JWT access token을 입력해주세요.', in: 'header', }, - 'access_token', + 'accessToken', + ) + .addBearerAuth( + { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + name: 'JWT', + description: '사용자의 JWT refresh token을 입력해주세요.', + in: 'cookie', + }, + 'refreshToken', ); tags.forEach((tag) => document.addTag(tag.name, tag.description));