JWT(JSON Web Token)은 JSON 형식으로 사용자에 대한 정보를 저장하는 웹 토큰이다. 디지털 서명이 되어 있으므로 안전하고 신뢰할 수 있다. 정보를 안전하게 전달할 때, 유저의 권한을 체크할 때 사용하는 유용한 모듈이다.
보통 로그인을 할 때 많이 사용된다. 로그인한 고유 유저를 위한 JWT 토큰을 생성하여 안전하게 로그인하고 또 권한을 확인하는 용도다.
Header
, Payload
, Signiture
세 파트가 합쳐진 구조를 띈다.
Header
토큰에 대한 메타 데이터를 포함하고 있다. 메타 데이터란 타입, 해싱 알고리즘, SHA256 등을 뜻한다.
Payload
실제적으로 담긴 데이터다. 유저 정보, 만료 기간 등이 담겨 있다. 필요한 데이터만 넣는 것이 중요하나, 보안의 위험 때문에 중요한 데이터 삽입은 지양하는 게 좋다.
Signiture
토큰이 보낸 사람에 의해 시크릿키로 서명되었으며 조작되지 않았음을 확인하는 데 사용된다. 위 사진에서 볼 수 있듯이 base64로 인코딩된 Header
와 Payload
+ 서명 알고리즘 + 시크릿키(혹은 퍼블릭키)가 조합되어 생성된다.
이 세 세그먼트가 모여서 하나의 JWT 토큰이 된다.
Nest 환경에서 JWT의 인증 처리를 더 간편하게 사용하기 위해 Passport
모듈도 같이 설치해 준다. 네 개 모두 설치.
npm install --save @nestjs/jwt passport-jwt
npm install --save-dev @types/passport-jwt
npm install --save @nestjs/passport passport
npm install --save-dev @types/passport-local
새로 생성한 auth.module
에서 사용하기 때문에 imports에 JwtModule을 넣어 준다.
JwtModule.register({
secret: `${process.env.JWT_SECRET}`,
signOptions: { expiresIn: '1y' },
})
secret
은 노출되면 안 되는 시크릿 키라서 환경 변수로 따로 저장했다. 이렇게 환경 변수를 이용할 때는 꼭 ConfigModule.forRoot()
를 imports 해 줘야 한다. imports 없이 사용하면 찾을 수 없어서 오류가 발생한다.
signOptions
의 expiresIn
는 토큰의 유효 기간이다. 나는 우선 1년으로 지정했다.
다음으로는 auth.module.ts
에 passport 모듈을 등록한다. 마찬가지로 imports 해 주면 된다.
PassportModule.register({ defaultStrategy: 'jwt', session: false })
session을 사용하지 않을 거라서 false로 두었다.
추후 서비스단에서 토큰을 생성하려면 jwtService
가 필요하다. jwtService
는 별도의 서비스단을 만들지 않고, 사용할 서비스단에 바로 주입하는 방식으로 사용 가능하다. 로그인 서비스를 구현할 Auth.service.ts
파일에 jwtService
를 주입했다.
@Injectable()
export class AuthService {
constructor(
private readonly userRepository: UserRepository,
private jwtService: JwtService,
) {}
로그인에 필요한 유효성 검사를 거쳐 로그인에 성공하면 유저 토큰이 생성되도록 한다.
const payload = { id: id, sub: user._id };
return {
token: this.jwtService.sign(payload),
};
토큰의 payload에는 유니크값인 id
와 토큰의 제목(sub)으로 user._id
고유값을 담았다. sign
메서드로 payload를 담아서 토큰을 생성한다. 로그인이 성공적으로 완료되면 클라이언트는 이 토큰을 보관한다.
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly userRepository: UserRepository) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: `${process.env.JWT_SECRET}`,
ignoreExpiration: false,
});
}
jwt.strategy.ts
를 생성하고 다른 곳에서도 인증 과정을 사용할 수 있게끔 @Injectable()
데코레이터를 달아 준다. userRepository
를 주입받는 걸 확인할 수 있는데, 인증 요청이 날아온 토큰의 payload 데이터와 DB의 데이터가 상응하는지 확인하고 가져오기 위함이다.
super 안의 과정은 토큰이 Bearer 타입으로 넘어오는 걸 가져와서 시크릿 키로 유효한지 확인하는 것이다. 이때의 시크릿 키는 토큰 생성 시에 사용한 시크릿 키와 그 값이 동일해야 한다. 생성과 인증으로 용도만 다를 뿐이지 시크릿 키 자체가 달라지는 게 아니다.
위의 과정으로 유효한 토큰이라는 게 확인되면 validate
메서드에서 데이터베이스 검증 과정을 거쳐 boolean
타입으로 리턴한다.
async validate(payload: Payload) {
const user = await this.userRepository.findUserByIdWithoutPwd(payload.sub);
if (user) {
return user; // request.user안에 user가 들어감
} else {
throw new UnauthorizedException('접근 오류입니다');
}
}
payload에는 유저의 id 값이 id
로, _id 값이 sub
로 저장되어 있다. 둘 다 유니크하기 때문에 어떤 값을 이용하든 단 한 명의 유저를 뽑아낼 수 있다. 데이터베이스와 연결된 userRepository
에서 payload의 sub
를 이용해 유저를 찾은 후, 유저가 존재하면 그 정보를 user
객체에 담는다. 객체를 리턴해 주면 request
객체 안에서 user
를 사용할 수 있게 된다.
위에서 만든 클래스 JwtStrategy
를 사용하기 위해 providers에 담아 준다. 다른 곳에서도 사용해야 하므로 exports도 해 준다.
@Module({
imports: [
ConfigModule.forRoot(),
// jwt 모듈 등록
JwtModule.register({
secret: `${process.env.JWT_SECRET}`,
signOptions: { expiresIn: '1y' },
}),
// passport 모듈 등록
PassportModule.register({ defaultStrategy: 'jwt', session: false }),
// 두 모듈이 서로를 import하고 있기 때문에 순환 모듈 문제를 아래처럼 해결
forwardRef(() => UserModule),
],
providers: [AuthService, JwtStrategy],
exports: [AuthService, JwtStrategy],
})
export class AuthModule {}
나는 현재 로그인된 유저가 누구인지 알아보기 위해 user.controller.ts
파일에서 로그인 유저 인증을 필요로 했다. 현재 로그인된 유저는 request
객체에 담겼다고 (2)에서 말했는데, 이 유저를 사용하기 위해 @UseGuards(JwtAuthGuard)
를 이용한다.
가드는 인증 미들웨어로, 지정된 경로로 통과할 수 있는 사람과 허용되지 않는 사람을 서버에 알려 준다. 또한 가드가 실행되면 JwtStrategy
를 자동으로 실행시켜 인증이 진행되게 도와준다.
@ApiOperation({ summary: '현재 유저 가지고 오기' })
@UseGuards(JwtAuthGuard)
@Get()
getCurrentUser(@CurrentUser() user) {
return user.readOnlyUser;
}
나는 커스텀 데코레이터를 이용해서 @Req() req
로 req.user
를 얻지 않고 바로 user라는 파라미터를 가져왔다. @CurrentUser()
가 커스텀 데코레이터다.