Skip to content

Commit

Permalink
Feat: JWT Refresh Token (#60)
Browse files Browse the repository at this point in the history
* chore(util): ms 패키지 추가

* feat: DTO 수정

* feat: Exception 수정

* test: Mocking 및 테스트 추가

* refactor: jwt expires를 호출 당시에 쓰도록

* feat: 리프레시 토큰 구현
  • Loading branch information
son-daehyeon authored Aug 16, 2024
1 parent 44ee3a9 commit 9a28473
Show file tree
Hide file tree
Showing 21 changed files with 265 additions and 20 deletions.
4 changes: 3 additions & 1 deletion config/config.template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@ s3:

jwt:
secret: ${JWT_SECRET:=secret}
expiresIn: ${JWT_EXPIRES_IN:=14d}
expiresIn:
access: ${JWT_EXPIRES_IN_ACCESS:=1h}
refresh: ${JWT_EXPIRES_IN_REFRESH:=7d}
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"js-yaml": "^4.1.0",
"mongoose": "^8.5.1",
"mongoose-autopopulate": "^1.1.0",
"ms": "^3.0.0-canary.1",
"nest-winston": "^1.9.7",
"nodemailer": "^6.9.14",
"reflect-metadata": "^0.2.2",
Expand Down
3 changes: 0 additions & 3 deletions src/common/config/jwt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ export class JwtConfig implements JwtOptionsFactory {
createJwtOptions(): JwtModuleOptions {
return {
secret: this.configService.getOrThrow<string>('jwt.secret'),
signOptions: {
expiresIn: this.configService.getOrThrow<string>('jwt.expiresIn'),
},
};
}
}
6 changes: 6 additions & 0 deletions src/common/util/event/type/service/auth.service.event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ export class LoginEvent {
constructor(public readonly member: Member) {}
}

export class RefreshEvent {
public static readonly EVENT_NAME = 'auth.refresh';

constructor(public readonly member: Member) {}
}

export class RegisterEvent {
public static readonly EVENT_NAME = 'auth.register';

Expand Down
5 changes: 5 additions & 0 deletions src/domain/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import { MailModule } from '@wink/mail';
providers: [
AuthService,

{
provide: `${RedisService.name}-refresh`,
useFactory: (repository: RedisService) => repository.sub('auth:refresh'),
inject: [RedisService],
},
{
provide: `${RedisService.name}-code`,
useFactory: (repository: RedisService) => repository.sub('auth:code'),
Expand Down
13 changes: 13 additions & 0 deletions src/domain/auth/controller/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
LoginRequestDto,
LoginResponseDto,
MyInfoResponseDto,
RefreshRequestDto,
RefreshResponseDto,
RegisterRequestDto,
SendCodeRequestDto,
VerifyCodeRequestDto,
Expand All @@ -13,6 +15,7 @@ import {
import {
AlreadyRegisteredByEmailException,
AlreadyRegisteredByStudentIdException,
InvalidRefreshTokenException,
InvalidVerifyCodeException,
InvalidVerifyTokenException,
MemberNotFoundException,
Expand Down Expand Up @@ -45,6 +48,16 @@ export class AuthController {
return this.authService.login(request);
}

@Post('/refresh')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '토큰 재발행' })
@ApiProperty({ type: RefreshRequestDto })
@ApiCustomResponse({ type: RefreshResponseDto, status: HttpStatus.OK })
@ApiCustomErrorResponse([InvalidRefreshTokenException, MemberNotFoundException])
async refresh(@Body() request: RefreshRequestDto): Promise<RefreshResponseDto> {
return this.authService.refresh(request);
}

@Post('/register')
@HttpCode(HttpStatus.CREATED)
@ApiOperation({ summary: '회원가입' })
Expand Down
2 changes: 2 additions & 0 deletions src/domain/auth/dto/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// Request
export * from './request/login.request.dto';
export * from './request/refresh.request.dto';
export * from './request/register.request.dto';
export * from './request/send-code.request.dto';
export * from './request/verify-code.request.dto';

// Response
export * from './response/login.response.dto';
export * from './response/my-info.response.dto';
export * from './response/refresh.response.dto';
export * from './response/verify-code.response.dto';
13 changes: 13 additions & 0 deletions src/domain/auth/dto/request/refresh.request.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';

import { CommonValidation, TypeValidation } from '@wink/validation';

export class RefreshRequestDto {
@ApiProperty({
description: 'Refresh Token',
example: 'A.B.C',
})
@CommonValidation.IsNotEmpty()
@TypeValidation.IsString()
refreshToken!: string;
}
10 changes: 8 additions & 2 deletions src/domain/auth/dto/response/login.response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@ import { ApiProperty } from '@nestjs/swagger';

export class LoginResponseDto {
@ApiProperty({
description: 'JWT 토큰',
description: 'Access Token',
example: 'A.B.C',
})
token!: string;
accessToken!: string;

@ApiProperty({
description: 'Refresh Token',
example: 'A.B.C',
})
refreshToken!: string;
}
15 changes: 15 additions & 0 deletions src/domain/auth/dto/response/refresh.response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';

export class RefreshResponseDto {
@ApiProperty({
description: 'Access Token',
example: 'A.B.C',
})
accessToken!: string;

@ApiProperty({
description: 'Refresh Token',
example: 'A.B.C',
})
refreshToken!: string;
}
13 changes: 13 additions & 0 deletions src/domain/auth/exception/expired-access-token.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { HttpStatus } from '@nestjs/common';

import { ApiException } from '@wink/swagger';

export class ExpiredAccessTokenException extends ApiException {
constructor() {
super({
swagger: '접근 토큰이 만료되었을 때',
message: '접근 토큰이 만료되었습니다.',
code: HttpStatus.UNAUTHORIZED,
});
}
}
3 changes: 3 additions & 0 deletions src/domain/auth/exception/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
export * from './already-registered-by-email.exception';
export * from './already-registered-by-student-id.exception';

export * from './expired-access-token.exception';

export * from './invalid-refresh-token.exception';
export * from './invalid-verify-code.exception';
export * from './invalid-verify-token.exception';

Expand Down
13 changes: 13 additions & 0 deletions src/domain/auth/exception/invalid-refresh-token.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { HttpStatus } from '@nestjs/common';

import { ApiException } from '@wink/swagger';

export class InvalidRefreshTokenException extends ApiException {
constructor() {
super({
swagger: '잘못된 리프레시 토큰일 때',
message: '올바르지 않은 리프레시 토큰입니다.',
code: HttpStatus.UNAUTHORIZED,
});
}
}
22 changes: 15 additions & 7 deletions src/domain/auth/guard/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ import {
SetMetadata,
UseGuards,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { JwtService, TokenExpiredError } from '@nestjs/jwt';
import { ApiBearerAuth } from '@nestjs/swagger';

import { PermissionException, UnauthorizedException } from '@wink/auth/exception';
import {
ExpiredAccessTokenException,
PermissionException,
UnauthorizedException,
} from '@wink/auth/exception';

import { Role } from '@wink/member/constant';
import { NotApprovedMemberException } from '@wink/member/exception';
Expand All @@ -38,7 +42,11 @@ export class AuthGuard implements CanActivate {
let decoded: Record<string, unknown>;
try {
decoded = await this.jwtService.verifyAsync(token as string);
} catch {
} catch (error) {
if (error instanceof TokenExpiredError) {
throw new ExpiredAccessTokenException();
}

throw new UnauthorizedException();
}

Expand Down Expand Up @@ -104,10 +112,10 @@ export const AuthAdminAccount = () =>
ApiBearerAuth(),
);

export const AuthAccountException = [UnauthorizedException, NotApprovedMemberException];

export const AuthAdminAccountException = [
export const AuthAccountException = [
UnauthorizedException,
ExpiredAccessTokenException,
NotApprovedMemberException,
PermissionException,
];

export const AuthAdminAccountException = [...AuthAccountException, PermissionException];
71 changes: 67 additions & 4 deletions src/domain/auth/service/auth.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { EventEmitter2 } from '@nestjs/event-emitter';

import {
LoginRequestDto,
LoginResponseDto,
MyInfoResponseDto,
RefreshRequestDto,
RefreshResponseDto,
RegisterRequestDto,
SendCodeRequestDto,
VerifyCodeRequestDto,
Expand All @@ -14,6 +17,7 @@ import {
import {
AlreadyRegisteredByEmailException,
AlreadyRegisteredByStudentIdException,
InvalidRefreshTokenException,
InvalidVerifyCodeException,
InvalidVerifyTokenException,
MemberNotFoundException,
Expand All @@ -25,24 +29,39 @@ import { MemberRepository } from '@wink/member/repository';
import { Member, omitMember } from '@wink/member/schema';

import { RedisService } from '@wink/redis';
import { LoginEvent, RegisterEvent, SendCodeEvent, VerifyCodeEvent } from '@wink/event';
import {
LoginEvent,
RefreshEvent,
RegisterEvent,
SendCodeEvent,
VerifyCodeEvent,
} from '@wink/event';
import { MailService, RegisterCompleteTemplate, VerifyCodeTemplate } from '@wink/mail';

import { v4 as uuid } from 'uuid';
import ms, { StringValue } from 'ms';
import * as bcrypt from 'bcrypt';

@Injectable()
export class AuthService {
private readonly accessExpiresIn: string;
private readonly refreshExpiresIn: string;

constructor(
private readonly configService: ConfigService,
private readonly jwtService: JwtService,

private readonly memberRepository: MemberRepository,
@Inject(`${RedisService.name}-refresh`) private readonly redisRefreshRepository: RedisService,
@Inject(`${RedisService.name}-code`) private readonly redisCodeRepository: RedisService,
@Inject(`${RedisService.name}-token`) private readonly redisTokenRepository: RedisService,

private readonly eventEmitter: EventEmitter2,
private readonly mailService: MailService,
) {}
) {
this.accessExpiresIn = this.configService.getOrThrow<string>('jwt.expiresIn.access');
this.refreshExpiresIn = this.configService.getOrThrow<string>('jwt.expiresIn.refresh');
}

async login({ email, password }: LoginRequestDto): Promise<LoginResponseDto> {
const member = await this.memberRepository.findByEmailWithPassword(email);
Expand All @@ -59,11 +78,55 @@ export class AuthService {
throw new NotApprovedMemberException();
}

const token = await this.jwtService.signAsync({ id: member._id });
const accessToken = await this.jwtService.signAsync(
{ id: member._id },
{ expiresIn: this.accessExpiresIn },
);

const refreshToken = await this.jwtService.signAsync({}, { expiresIn: this.refreshExpiresIn });

await this.redisRefreshRepository.ttl(
refreshToken,
member._id,
ms(this.refreshExpiresIn as StringValue),
);

this.eventEmitter.emit(LoginEvent.EVENT_NAME, new LoginEvent(member));

return { token };
return { accessToken, refreshToken };
}

async refresh({ refreshToken }: RefreshRequestDto): Promise<RefreshResponseDto> {
const memberId = await this.redisRefreshRepository.get(refreshToken);
if (!memberId) {
throw new InvalidRefreshTokenException();
}
await this.redisRefreshRepository.delete(refreshToken);

const member = await this.memberRepository.findById(memberId);
if (!member) {
throw new MemberNotFoundException();
}

const newAccessToken = await this.jwtService.signAsync(
{ id: memberId },
{ expiresIn: this.accessExpiresIn },
);

const newRefreshToken = await this.jwtService.signAsync(
{},
{ expiresIn: this.refreshExpiresIn },
);

await this.redisRefreshRepository.ttl(
newRefreshToken,
memberId,
ms(this.refreshExpiresIn as StringValue),
);

this.eventEmitter.emit(RefreshEvent.EVENT_NAME, new RefreshEvent(member));

return { accessToken: newAccessToken, refreshToken: newRefreshToken };
}

async register({ name, studentId, password, verifyToken }: RegisterRequestDto): Promise<void> {
Expand Down
11 changes: 10 additions & 1 deletion test/auth/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,20 @@ describe('AuthService', () => {
let mailService: MailService;

let memoryMemberRepository: Member[];
let memoryRedisRefreshRepository: Record<string, string>;
let memoryRedisCodeRepository: Record<string, string>;
let memoryRedisTokenRepository: Record<string, string>;

beforeAll(async () => {
const mock = await mockAuth();

const { module } = mock;
({ memoryMemberRepository, memoryRedisCodeRepository, memoryRedisTokenRepository } = mock);
({
memoryMemberRepository,
memoryRedisRefreshRepository,
memoryRedisCodeRepository,
memoryRedisTokenRepository,
} = mock);

authService = module.get<AuthService>(AuthService);
mailService = module.get<MailService>(MailService);
Expand All @@ -44,6 +50,9 @@ describe('AuthService', () => {
afterEach(() => {
jest.clearAllMocks();
memoryMemberRepository.splice(0, memoryMemberRepository.length);
Object.keys(memoryRedisRefreshRepository).forEach((key) => {
delete memoryRedisRefreshRepository[key];
});
Object.keys(memoryRedisCodeRepository).forEach((key) => {
delete memoryRedisCodeRepository[key];
});
Expand Down
Loading

0 comments on commit 9a28473

Please sign in to comment.