[Nestjs] passport와 jwt를 이용한 로그인 인증 파이프라인

아홉번째태양·2023년 5월 9일
1

패키지 설치

npm i passport passport-jwt @nestjs/passport @nestjs/jwt

베이스 코드

  • auth/auth.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';

@Module({
  imports: [
    ConfigModule,
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        secret: configService.get<string>('JWT_SECRET'),
        signOptions: { expiresIn: configService.get<number>('ACCESS_EXPIRE') },
      }),
      inject: [ConfigService],
    }),
  ],
  providers: [JwtStrategy],
  exports: [PassportModule, JwtModule],
})
export class AuthModule {}
  • auth/auth.guard.ts
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  canActivate(context: ExecutionContext) {
    // Custom authentication logic
    return super.canActivate(context);
  }

  handleRequest(err, user, info) {
    // Handle any authentication errors
    if (err || !user) {
      throw err || new UnauthorizedException();
    }
    return user;
  }
}
  • auth/jwt.strategy.ts
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy, ExtractJwt } from 'passport-jwt';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private configService: ConfigService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get<string>('JWT_SECRET'),
    });
  }
  async validate(payload: any) {
    // Handle token payload
    return { ...payload };
  }
}

코드 설명

api 요청이 들어왔을 때 위 Guard파이프는 다음의 순서로 동작한다.

  1. JwtAuthGuard.canActivate
    Guard 로직을 수행할지 말지를 true/false 반환하여 결정한다. 단, passport 패키지의 AuthGuard를 쓰는 경우, passport strategy의 로직을 필요로 한다면 super클래스의 canActivate에 context를 넘겨줘야한다.
  2. AuthGuard.canActivate
    passport의 커스텀 AuthGuard이며 마찬가지로 후속 Guard 로직을 수행할지 말지를 true/false를 반환하여 결정한다. 따로 오버라이드를 하는 것이 아니라면, 지정한 strategy에 따라 이 로직은 자동으로 수행되며, jwt의 경우 토큰의 유효성을 검사하고 이상이 없다면 strategy로 넘기게 된다.
  3. JwtStrategy.validate
    토큰에 문제가 없다면 decode한 payload가 넘어온다. 여기서 req.user에 보관할 사용자 정보를 검증하거나 가공하여 반환한다.
  4. JwtAuthGuard.handleRequest
    guard 파이프의 결과를 통보받는 곳이다. error가 있었는지, req.user에 저장된 사용자 정보는 원하는대로 잘 들어갔는지 등을 검사할 수 있다.

위 과정에서 유의해야할 점은, 1~3번까지는 Nestjs의 기본 request 파이프라인 상에 있기때문에 throw한 error들이 자동으로 걸러진다. 하지만, 4번의 handleRequest 메소드는 request 파이프라인에 속하지 않으며 여기서 throw한 error는 서버를 충돌시키므로 별도의 처리가 필요하다.

또, handleRequest의 인자로 들어오는 값들은 다음과 같다.

  • err: null|Error
  • user UserPayload|false
  • info undefined|Error

즉, 인가 과정에 문제가 없었다면 err는 null, info는 undefined가 들어오며 req.user의 값이 있다면 user에 false 대신 해당 값이 들어온다.

Refresh Token 로직

refresh token을 이용한 인가는 access token이 유효하지 않는 경우 refresh token을 검사하고 새로 유효한 access token을 발급해줘야한다. 위 코드에서 토큰에 대한 유효성 검사가 이뤄지는 곳은 AuthGuard.canActivate인데, 해당 코드 부분은 @nestjs/passport 패키지 안에 감춰져 있다. 따라서, AuthGuard를 다시한번 오버라이드하거나 아니면 그보다 앞의 단계에서 필요한 검사를 하는 수 밖에 없다. 여기서는 오버라이드를 위해 클래스를 추가하기보다 JwtAuthGuard.canActivate에서 토큰에 대한 인가 작업을 미리 하기로 한다.

  • JwtAuthGuard.canActivate
const req: Request = context.switchToHttp().getRequest();
const res: Response = context.switchToHttp().getResponse();
const isValid = await this.validateTokens(req, res);
if (isValid) {
  //Valid token...passing to passport
  super.canActivate(context);
  return true;
}
// Invalid token...throwing error
throw new UnauthorizedException('Invalid tokens');

혹시 access token이 새로 발급될 때 다음 단계인 AuthGuard.canActivate에서 authorization 헤더를 검사할 것이기 때문에 헤더 수정을 위해, 그리고 이를 클라이언트에 전달하기 위해 Request와 Response 객체가 필요하다.

  • JwtAuthGuard.validateTokens
async validateTokens(req: Request, res: Response) {
  const [type, accessToken] = req.get('authorization')?.split(' ')[1];
  if (type?.toUpperCase() !== 'BEARER' || accessToken === undefined) return false;

  const payload = this.authService.verifyToken(accessToken, 'access');
  if (!payload.isValid) return true;

  const { refreshToken } = req.cookies;
  if (refreshToken === undefined) return false;

  const isRefreshValid = await this.userSessionService.verifySession(payload.userId, refreshToken);
  if (!isRefreshValid) return false;

  const newToken = await this.authService.createAccessToken(payload);
  const expire = this.configService.get<number>('ACCESS_COOKIE');
  res.cookie('accessToken', newToken, { maxAge: expire });
  req.headers.authorization = `Bearer ${newToken}`;

  return true;
}



참고자료
https://docs.nestjs.com/guards
https://docs.nestjs.com/recipes/passport

0개의 댓글