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 diff --git a/src/app/auth/auth.controller.ts b/src/app/auth/auth.controller.ts index 52145ec..d48387e 100644 --- a/src/app/auth/auth.controller.ts +++ b/src/app/auth/auth.controller.ts @@ -11,6 +11,7 @@ import { AuthService } from './auth.service'; import { ApiBadRequestResponse, ApiBearerAuth, + ApiCreatedResponse, ApiInternalServerErrorResponse, ApiNotFoundResponse, ApiOkResponse, @@ -28,6 +29,10 @@ 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'; +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') @@ -35,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, }) @@ -61,21 +62,39 @@ export class AuthController { return this.authService.login(dto); } - @UseGuards(JwtAuthGuard) - @ApiBearerAuth('access_token') + @UseGuards(JwtRefreshStrategy) + @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(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, }) @@ -86,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.module.ts b/src/app/auth/auth.module.ts index 0b00787..08956b0 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,13 @@ import { AuthRepository } from './auth.repository'; PassportModule, JwtModule.register({ secret: jwtConstants.secret, - signOptions: { expiresIn: '3600s' }, + signOptions: { + expiresIn: `${process.env.JWT_ACCESS_TOKEN_EXPIRATION_TIME}s`, + }, }), ], controllers: [AuthController], - providers: [AuthService, AuthRepository, JwtStrategy], + providers: [AuthService, AuthRepository, JwtStrategy, JwtRefreshStrategy], exports: [AuthService], }) export class AuthModule {} diff --git a/src/app/auth/auth.repository.ts b/src/app/auth/auth.repository.ts index f183079..96a1328 100644 --- a/src/app/auth/auth.repository.ts +++ b/src/app/auth/auth.repository.ts @@ -5,13 +5,39 @@ 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); + return 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 +46,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 =:userId`, { userId }) + .execute(); + } } diff --git a/src/app/auth/auth.service.ts b/src/app/auth/auth.service.ts index 9c54af8..d3c539d 100644 --- a/src/app/auth/auth.service.ts +++ b/src/app/auth/auth.service.ts @@ -11,6 +11,8 @@ 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 { jwtConstants } from './jwt/constants'; +import { UserIdRequest } from '../../libs/request/users/user-id.request'; @Injectable() export class AuthService { @@ -23,10 +25,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); @@ -40,7 +49,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, @@ -54,13 +63,91 @@ 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, ): 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: jwtConstants.access_token_secret, + expiresIn: `${jwtConstants.access_token_expiration}s`, + }); + + return { + accessToken: token, + domain: 'localhost', + path: '/', + httpOnly: true, + maxAge: Number(jwtConstants.access_token_expiration) * 1000, + }; + } + + getCookieWithJwtRefreshToken(id: number) { + const payload = { id }; + const token = this.jwtService.sign(payload, { + secret: jwtConstants.refresh_token_secret, + expiresIn: `${jwtConstants.refresh_token_expiration}s`, + }); + + return { + refreshToken: token, + domain: 'localhost', + path: '/', + httpOnly: true, + maxAge: Number(jwtConstants.refresh_token_expiration) * 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; + } } diff --git a/src/app/auth/jwt/constants.ts b/src/app/auth/jwt/constants.ts index b01cebd..b670b96 100644 --- a/src/app/auth/jwt/constants.ts +++ b/src/app/auth/jwt/constants.ts @@ -2,5 +2,9 @@ import * as dotenv from 'dotenv'; dotenv.config(); export const jwtConstants = { - secret: process.env.JWT_SECRET, + 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-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 new file mode 100644 index 0000000..4a60899 --- /dev/null +++ b/src/app/auth/jwt/jwt-refresh.strategy.ts @@ -0,0 +1,35 @@ +import { ExtractJwt, Strategy } from 'passport-jwt'; +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', +) { + constructor() { + super({ + jwtFromRequest: ExtractJwt.fromExtractors([ + JwtRefreshStrategy.extractJWT, + ExtractJwt.fromAuthHeaderAsBearerToken(), + ]), + ignoreExpiration: false, + secretOrKey: jwtConstants.refresh_token_secret, + passReqToCallback: true, + }); + } + + 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 7e1422c..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.secret, + 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)); diff --git a/src/libs/entity/user.entity.ts b/src/libs/entity/user.entity.ts index cb6ebfc..bcdce79 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({ type: 'varchar', nullable: true }) + @Exclude() + currentHashedRefreshToken?: string; + @OneToMany((type) => ReservationEntity, (reservation) => reservation.user) Reservations: ReservationEntity[]; } 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"`); + } + +}