NestJS Passport 인증 전략

NestJS Passport 인증이란?

Passport는 Node.js에서 가장 널리 사용되는 인증 미들웨어다. NestJS는 @nestjs/passport 패키지로 Passport를 Guard 시스템에 통합하여, Strategy 패턴 기반의 유연한 인증 체계를 구축할 수 있다.

1. 기본 구조: Strategy + AuthGuard

npm install @nestjs/passport passport passport-local passport-jwt
npm install -D @types/passport-local @types/passport-jwt

Passport 인증은 두 요소로 구성된다:

요소 역할
Strategy 인증 로직 구현 (JWT 검증, 비밀번호 확인 등)
AuthGuard Strategy를 Guard로 연결, 요청에 user 주입

2. Local Strategy: 이메일/비밀번호 로그인

// local.strategy.ts
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
  constructor(private readonly authService: AuthService) {
    super({
      usernameField: 'email',  // 기본 'username' 대신 'email' 사용
      passwordField: 'password',
    });
  }

  async validate(email: string, password: string): Promise<User> {
    const user = await this.authService.validateUser(email, password);
    if (!user) {
      throw new UnauthorizedException('이메일 또는 비밀번호가 올바르지 않습니다');
    }
    return user; // request.user에 주입됨
  }
}

// auth.service.ts
@Injectable()
export class AuthService {
  constructor(
    private readonly userService: UserService,
    private readonly jwtService: JwtService,
  ) {}

  async validateUser(email: string, password: string): Promise<User | null> {
    const user = await this.userService.findByEmail(email);
    if (user && await bcrypt.compare(password, user.password)) {
      return user;
    }
    return null;
  }

  async login(user: User) {
    const payload = { sub: user.id, email: user.email, roles: user.roles };
    return {
      accessToken: this.jwtService.sign(payload, { expiresIn: '15m' }),
      refreshToken: this.jwtService.sign(
        { sub: user.id, type: 'refresh' },
        { expiresIn: '7d' },
      ),
    };
  }
}

// 컨트롤러에서 사용
@Post('login')
@UseGuards(AuthGuard('local'))
async login(@Request() req) {
  return this.authService.login(req.user);
}

3. JWT Strategy: 토큰 인증

// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
  constructor(private readonly configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get<string>('JWT_SECRET'),
    });
  }

  async validate(payload: JwtPayload): Promise<RequestUser> {
    return {
      id: payload.sub,
      email: payload.email,
      roles: payload.roles,
    };
  }
}

// jwt-refresh.strategy.ts — Refresh Token용 별도 Strategy
@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
  constructor(
    private readonly configService: ConfigService,
    private readonly authService: AuthService,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromBodyField('refreshToken'),
      secretOrKey: configService.get<string>('JWT_SECRET'),
      passReqToCallback: true,  // request 객체 접근
    });
  }

  async validate(req: Request, payload: JwtPayload) {
    const refreshToken = req.body.refreshToken;
    // DB에 저장된 refresh token과 비교
    return this.authService.validateRefreshToken(payload.sub, refreshToken);
  }
}

// 사용
@Post('refresh')
@UseGuards(AuthGuard('jwt-refresh'))
async refresh(@Request() req) {
  return this.authService.login(req.user);
}

4. 커스텀 AuthGuard 확장

기본 AuthGuard를 확장해 에러 메시지 커스텀, 옵셔널 인증 등을 구현한다.

// 에러 메시지 커스텀
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
  handleRequest(err: any, user: any, info: any) {
    if (info instanceof TokenExpiredError) {
      throw new UnauthorizedException('토큰이 만료되었습니다');
    }
    if (info instanceof JsonWebTokenError) {
      throw new UnauthorizedException('유효하지 않은 토큰입니다');
    }
    if (err || !user) {
      throw new UnauthorizedException('인증이 필요합니다');
    }
    return user;
  }
}

// 옵셔널 인증: 로그인 안 해도 접근 가능하지만, 로그인하면 user 제공
@Injectable()
export class OptionalJwtGuard extends AuthGuard('jwt') {
  handleRequest(err: any, user: any) {
    // 인증 실패해도 에러 안 던짐, user는 undefined
    return user || null;
  }
}

// 사용: 게시글 조회 — 로그인하면 '좋아요' 여부 포함
@Get(':id')
@UseGuards(OptionalJwtGuard)
async getPost(@Param('id') id: string, @Request() req) {
  return this.postService.findById(id, req.user?.id);
}

5. OAuth2 Strategy: Google 로그인

npm install passport-google-oauth20
npm install -D @types/passport-google-oauth20
@Injectable()
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
  constructor(
    private readonly configService: ConfigService,
    private readonly authService: AuthService,
  ) {
    super({
      clientID: configService.get('GOOGLE_CLIENT_ID'),
      clientSecret: configService.get('GOOGLE_CLIENT_SECRET'),
      callbackURL: configService.get('GOOGLE_CALLBACK_URL'),
      scope: ['email', 'profile'],
    });
  }

  async validate(
    accessToken: string,
    refreshToken: string,
    profile: Profile,
  ): Promise<User> {
    const { emails, displayName, photos } = profile;
    
    // 기존 사용자 찾기 or 생성
    return this.authService.findOrCreateOAuthUser({
      email: emails[0].value,
      name: displayName,
      avatar: photos[0]?.value,
      provider: 'google',
      providerId: profile.id,
    });
  }
}

// 컨트롤러
@Get('google')
@UseGuards(AuthGuard('google'))
googleLogin() {} // Google 로그인 페이지로 리다이렉트

@Get('google/callback')
@UseGuards(AuthGuard('google'))
async googleCallback(@Request() req, @Res() res: Response) {
  const tokens = await this.authService.login(req.user);
  res.redirect(`${this.configService.get('FRONTEND_URL')}/auth/callback?token=${tokens.accessToken}`);
}

6. 다중 Strategy 통합 모듈

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.registerAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        secret: config.get('JWT_SECRET'),
        signOptions: { expiresIn: '15m' },
      }),
    }),
  ],
  providers: [
    AuthService,
    LocalStrategy,
    JwtStrategy,
    JwtRefreshStrategy,
    GoogleStrategy,
  ],
  controllers: [AuthController],
  exports: [AuthService, JwtModule],
})
export class AuthModule {}

defaultStrategy: 'jwt'를 설정하면 @UseGuards(AuthGuard())에서 전략 이름을 생략할 수 있다. Guard 시스템의 동작 원리는 NestJS Guard 접근 제어 글을 참고하자.

7. 테스트 전략

describe('AuthController', () => {
  let app: INestApplication;

  beforeAll(async () => {
    const module = await Test.createTestingModule({
      imports: [AppModule],
    })
      .overrideGuard(AuthGuard('jwt'))
      .useValue({
        canActivate: (ctx: ExecutionContext) => {
          const req = ctx.switchToHttp().getRequest();
          req.user = { id: 1, email: 'test@test.com', roles: ['user'] };
          return true;
        },
      })
      .compile();

    app = module.createNestApplication();
    await app.init();
  });

  it('인증된 사용자 프로필 조회', () => {
    return request(app.getHttpServer())
      .get('/auth/profile')
      .expect(200)
      .expect(res => {
        expect(res.body.email).toBe('test@test.com');
      });
  });
});

Guard를 mock으로 교체하면 인증 없이 컨트롤러 로직을 테스트할 수 있다. NestJS 테스트 전반은 NestJS Testing 실전 글을 참고하자.

마무리

NestJS + Passport는 Strategy 패턴으로 Local, JWT, OAuth2 등 다양한 인증 방식을 하나의 Guard 체계에 통합한다. Refresh Token 전략, 옵셔널 인증, OAuth2 소셜 로그인까지 실무에서 필요한 모든 인증 패턴을 선언적으로 구현할 수 있다.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux