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 소셜 로그인까지 실무에서 필요한 모든 인증 패턴을 선언적으로 구현할 수 있다.