[Nestjs] JWT 비대칭 토큰 발급하기
상황
여러 어플리케이션에서 권한 별로 편리하게 운영하기 위해,
MSA 구조로 인증 서버를 분리하여 어플리케이션 별로 운영할 수 있도록 만드는 작업이 필요했다.
기존에는 인증 서버에서 토큰 발급에 필요한 SECRET KEY와
어플리케이션에서의 토큰 인증하는 SECRET KEY가 같아 보안적으로 이슈가 있을 것으로 생각되었다.
따라서 인증 서버와 어플리케이션의 키를 분리할 필요가 있다고 생각했고,
자연스럽게 비대칭 키 도입을 해보게 됐다.
추가로 JWT 토큰과 관련된 내용은 이미 많은 블로그에서 포스팅되었지만,
비대칭 키를 이용해 인증하는 방법은 많이 보지 못해 내 나름대로의 이해를 붙여서 글을 쓰게 되었다.
내가 생각하는 인증 서버의 절차는 아래와 같다.
- 유저가 인증 서버를 통해 로그인한다.
- 인증 서버는 유저에게 토큰을 발급한다.
- 유저가 어플리케이션 서버에 토큰을 이용해 어플리케이션을 이용한다.
이렇게 진행한 이유는
기존 MSA 구조에서 인증 서버를 API Gateway로 앞단에 두고 어플리케이션 서버들을 붙이는 방법들도 존재하지만,
사내용으로 쓰기 위해 만들다보니 서버가 클 필요도 없었고, 실시간일 필요도 없었기 때문에
일단은 서버리스로 구축하기 위해 이렇게 진행하였다.
방법
먼저, 토큰을 발급해주는 인증서버부터 진행하였다.
(글을 쓰면서 약간의 변형을 넣어 각색을 하다보니 오타가 있을 수도 있습니다)
인증서버
1. 아래의 라이브러리를 설치한다.
yarn add @nestjs/passport passport-jwt @nestjs/jwt
2. 암호화에 필요한 RSA 키를 생성하고, 환경변수로 등록한다.
#. env
JWT_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
asdfasdfasdf~~~
asdfasdf~~~
asdfasdf~~
asdfasdf~
-----END RSA PRIVATE KEY-----"
3. src/auth 폴더를 생성한 뒤, auth.controller.ts, auth.module.ts, auth.service.ts 를 생성한다.
jwt 인증 과정의 로직이 담긴 auth.service.ts 부터 설정해보겠다.
- auth.service.ts
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { User } from '@/entity/user.entity';
@Injectable()
export class AuthService {
constructor(private jwtService: JwtService) {}
// ~~
async signIn(id: string, type: string){
// 1. 유저 인증 작업
// - 생략 -
// 2. 토큰 발급
const payload = { id: id, type: type};
const accessToken = this.jwtService.sign(payload, {
secret: process.env.JWT_PRIVATE_KEY,
expiresIn: process.env.JWT_EXPIRES_IN,
algorithm: 'RS256',
});
return accessToken;
}
}
jwtService에서 sign() 메소드를 통해 token으로 암호화하는 작업이 들어간다.
일반적으로는 HS256 알고리즘을 사용하지만, 지금은 비대칭 키를 이용하므로 RS256 이라고 적어주어야 한다.
- auth.controller.ts
서비스를 만들고 난 뒤 Controller를 통해 API를 붙여주는데, 이 부분은 설명할 부분이 없어 간략하게 적고 넘어간다.
// 생략 ~~
@ApiOperation({ summary: '로그인 기능' })
@ApiOkResponse({ description: '로그인' })
@ApiForbiddenResponse({ description: '로그인 실패' })
@Post('sign-in')
async signIn(@Req() req: Request, @Res() res: Response, loginDto: LoginDto) {
const token = await this.authService.signIn(loginDto);
res.status(HttpStatus.OK).send({
token: token,
});
}
- auth.module.ts
import { Module } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Module({
imports: [
PassportModule,
],
controllers: [AuthController],
providers: [
AuthService,
JwtService,
],
exports: [AuthService],
})
export class AuthModule {}
어플리케이션 서버
1. 인증 서버와 동일한 라이브러리를 설치한다.
yarn add @nestjs/passport passport-jwt @nestjs/jwt
2. 마찬가지로 src/auth 폴더를 생성한 뒤, auth.controller.ts, auth.module.ts, auth.strategy.ts, auth.guard.ts 를 생성한다.
어플리케이션 서버는 인증 서버와 다르게 토큰을 검증하는 작업이 들어가야 한다.
nest.js에서는 인증 작업을 UseGuards라는 모듈을 통해 진행하는데,
passport 라이브러리와 strategy 패턴을 도입하면 편리하게 진행할 수 있다.
- auth.guard.ts
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {}
- auth.strategy.ts
등록하는 방법에 파라미터가 어떤 파라미터가 있는지부터 제대로 알지 못했기 때문에,
passport-jwt 라이브러리의 깃허브 링크까지 들어가서 하나씩 뜯어보며 검색해보았고,
다행히 친절하게도 주석이 쓰여있었는데 내용은 다음과 같다.
자세한 설명으로는 passport-jwt 깃허브 링크를 통해 확인할 수 있다.
중요한 것은 토큰을 생성할 때, RS256 알고리즘을 기반으로 생성했기 때문에
algorithms 파라미터에 ['RS256'] 필터를 추가하고, 반대로 어플리케이션 서버에서는
secretOrKey 파라미터로 PUBLIC_KEY를 등록해야 한다.
이를 이용해 파라미터를 다음과 같이 입력해준다.
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
@Injectable()
export class AuthStrategy extends PassportStrategy(Strategy, 'jwt') {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: `${process.env.JWT_PUBLIC_KEY}`,
algorithms: ['RS256'],
});
}
async validate(payload: any) {
return { id: payload.id, type: payload.type, roles: payload.roles };
}
}
- auth.module.ts
마지막으로 auth.module.ts 에 위의 내용들을 등록한다.
import { Module } from '@nestjs/common';
import { PassportModule } from '@nestjs/passport';
import { JwtModule } from '@nestjs/jwt';
import { AuthStrategy } from './auth.strategy';
import { AuthService } from './auth.service';
@Module({
imports: [
PassportModule,
JwtModule
//.register({
// global: true,
// publicKey: `${PUBLIC_KEY}`,
// signOptions: { expiresIn: `${JWT_EXPIRES_IN}` },
//}),
],
providers: [AuthStrategy, AuthService],
exports: [JwtModule],
})
export class AuthModule {}
주석으로 된 부분은 인터넷에서 처음 검색했을 때에는 남들이 등록하라길래 등록했었던 부분이다.
글을 올리면서 다시 테스트해보니 지워도 정상적으로 작동되는 걸 확인했는데, 필요하면 쓸 수 있을 것 같다.
추가로 위의 jwtModule에서는 어떤 파라미터가 있는지 몰라 jwt Github 링크를 타고 찾아가보니
어떠한 파라미터가 있는지 확인할 수 있었다.
이렇게 토큰을 생성한 뒤, swagger로 테스트를 진행해보았다.
아래는 토큰을 입력하지 않은 경우로 당연히 401 에러가 나왔다.
다음으로 인증 서버로부터 토큰을 받아 입력시켜 진행해보았다.
정상적으로 진행된 걸 확인할 수 있었다.
후기
nestjs에서 사용하는 Module, service, guards, providers 등의 개념을 어느정도 알고 있었다 생각했는데,
삽질하면서 모듈과 생각보다 많이 모른다는 생각이 들어 많이 검색하고 배우게 됐고,
덩달아 암호화에 대해서도 겉햝기로 조금 배우게 됐다.
막상 다 하고나니, 키 마저도 텍스트로 노출되면 어떡하나 싶어 KMS를 붙여서 관리할까라는 생각도 들었지만,
KMS도 관리요소가 되서 결국 같은 일이 될 것 같아 일단은 넘어간다.
참고
[1] https://stackoverflow.com/questions/74370400/how-do-i-use-asymmetric-jwt-validation-in-nestjs
[2] https://velog.io/@anjoy/%EB%B8%94%EB%A1%9C%EA%B7%B8%EB%A7%8C%EB%93%A4%EA%B8%B09-NestJS-passport-jwt
[3] https://github.com/mikenicholson/passport-jwt/blob/master/lib/strategy.js
[4] https://github.com/nestjs/jwt/blob/master/lib/interfaces/jwt-module-options.interface.ts#L39
[5] https://github.com/nestjs/passport/blob/master/lib/passport/passport.strategy.ts