NestJS

[NestJS] 인증 (Authentication)

sihanni 2025. 6. 18. 15:56

https://docs.nestjs.com/security/authentication

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

Authentication (인증)

인증은 대부분의 애플리케이션에서 필수적인 요소이다.

인증을 처리하는 방법은 다양하고, 어떤 방법을 선택할지는 해당 프로젝트의 요구사항에 따라 달라진다.

NestJS 공식문서 인증(Authentication) 섹션에서는 다양한 요구사항에 적용할 수 있는 여러 인증 방식을 소개하고 있다.

 

요구사항 정의

  1. 클라이언트는 username, password 를 통해 인증을 시도
  2. 인증이 성공하면 서버는 JWT을 발급
  3. 클라이언트는 이 토큰을 Authorization 헤더의 Bearer 토큰으로 포함시켜 요청을 보냄
  4. 서버는 이 JWT가 유효한 경우에만 특정 보호된 라우트 (protected route)에 접근을 허용함.

인증 모듈 생성 과정

1. AuthModule, AuthService, AuthController 생성
$ nest g module auth
$ nest g controller auth
$ nest g service auth

2. UsersModule과 UsersService 생성
$ nest g module users
$ nest g service users

 

UsersService 

// users/users.service.ts

import { Injectable } from '@nestjs/common';

// 유저 정보를 정의하는 타입. 실제 앱에서는 별도의 User 클래스/인터페이스를 사용하는 것이 좋다.
export type User = any;

@Injectable()
export class UsersService {
  private readonly users = [
    { userId: 1, username: 'john', password: 'changeme' },
    { userId: 2, username: 'maria', password: 'guess' },
  ];

  async findOne(username: string): Promise<User | undefined> {
    return this.users.find(user => user.username === username);
  }
}

// 해당 테스트 코드에서는 메모리 내 하드코딩되어진 사용자 목록을 사용한다.
// 실제 서비스에서는 ORM, 연결된 DB 을 활용해 해시된 비밀번호를 비교하게 된다.

 

UsersModule

// users/users.module.ts

import { Module } from '@nestjs/common';
import { UsersService } from './users.service';

@Module({
  providers: [UsersService],
  exports: [UsersService], // 외부에서 사용 가능하도록 export
})
export class UsersModule {}

 

AuthService에 signIn 메서드 구현

AuthService의 역할

  • 유저를 찾고 -> 비밀번호를 검증하고 -> 인증된 유저 정보 (또는 이후에는 JWT)를 반환하는 것
// auth/auth.service.ts

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '../users/users.service';

@Injectable()
export class AuthService{
  constructor(private usersService: UsersService){}
  
  async signIn(username: string, password: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    
    if(user?.password !== pass){
    	throw new UnauthorizedException();
    }
    
    const { password, ...result } = user; // 비밀번호 필드 제거
    // TODO: JWT를 생성하여 반환하도록 수정 예정
    return result;
  }
}

경고: 실제 애플리케이션에서는 절대 비밀번호를 평문(plain text)로 저장하거나 비교하지 말자
일반적으로는 bcrypt 같은 라이브러리를 사용하여 해싱한 후, 비교 시에도 해시된 값을 비교해야 한다.

 

AuthModule에서 UsersModule을 import 하기

// auth/auth.module.ts

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { UsersModule } from '../users/users.module';

@Module({
  imports: [UsersModule],
  providers: [AuthService],
  controllers: [AuthController],
})
export class AuthModule {}

 

 

AuthController에 로그인 엔드포인트 구현

// auth/auth.controller.ts

import { Body, Controller, Post, HttpCode, HttpStatus } from '@nestjs/common';
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @HttpCode(HttpStatus.OK)
  @Post('login')
  signIn(@Body() signInDto: Record<string, any>) {
    return this.authService.signIn(signInDto.username, signInDto.password);
  }
}

// 실제 서비스에서는 Record<string, any> 대신 DTO클래스를 사용하는 것이 좋다.
이렇게 하면 NestJS의 class-validation를 통해 입력값 유효성 검사도 함께 적용할 수 있다.

 

JWT 토큰 발급 및 설정

 

요구사항을 다시 정리하면, 

  1.  사용자가 username/password로 로그인을 하면 JWT를 반환한다.
  2. 이후 요청마다 JWT를 Bearer Token으로 보내면 보호된 API에 접근이 가능해야한다.

의존성 패키지 설치

$ npm install --save @nestjs/jwt

JWT 토큰의 생성 및 검증을 하는데 필요한 NestJS 공식 유틸리티 패키지

 

AuthService 수정

// auth/auth.service.ts

import { Injectable, UnauthorizedException } from '@nestjs/common';
import { UsersService } from '../users/users.service';
import { JwtService } from '@nestjs/jwt';

@Injectable()
export class AuthService {
  constructor(
    private usersService: UsersService,
    private jwtService: JwtService
  ) {}

  async signIn(
    username: string,
    pass: string,
  ): Promise<{ access_token: string }> {
    const user = await this.usersService.findOne(username);
    if (user?.password !== pass) {
      throw new UnauthorizedException();
    }

    const payload = { sub: user.userId, username: user.username }; // 표준 JWT Claims
    const token = await this.jwtService.signAsync(payload);

    return { access_token: token };
  }
}

 

constants.ts 에 JWT 시크릿 키 관리

// auth/constants.ts

export const jwtConstants = {
  secret: 'DO NOT USE THIS VALUE. INSTEAD, CREATE A COMPLEX SECRET AND KEEP IT SAFE OUTSIDE OF THE SOURCE CODE.',
};

 

AuthModule 설정

// auth/auth.module.ts

import { Module } from '@nestjs/common';
import { AuthService } from './auth.service';
import { UsersModule } from '../users/users.module';
import { JwtModule } from '@nestjs/jwt';
import { AuthController } from './auth.controller';
import { jwtConstants } from './constants';

@Module({
  imports: [
    UsersModule,
    JwtModule.register({
      global: true, // 전역 설정
      secret: jwtConstants.secret,
      signOptions: { expiresIn: '60s' }, // 토큰 만료 시간
    }),
  ],
  providers: [AuthService],
  controllers: [AuthController],
  exports: [AuthService],
})
export class AuthModule {}

// global: true 로 설정하면 다른 모듈에서 따로 JwtModule import 하지 않아도 됨

 

테스트

$ curl -X POST http://localhost:3000/auth/login \
  -d '{"username": "john", "password": "changeme"}' \
  -H "Content-Type: application/json"

# 결과:
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

Authentication Guard 구현

목표

  • 발급된 JWT가 실제 요청에서 유효한지 확인하기 위한 AuthGuard 생성
  • @UseGuards(AuthGuard) 를 통해 특정 라우트를 보호
  • Authorization: Bearer <token> 형식의 헤더를 파싱하여 검증

AuthGuard 구현

// auth/auth.guard.ts

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { jwtConstants } from './constants';
import { Request } from 'express';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private jwtService: JwtService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);
    if (!token) {
      throw new UnauthorizedException(); // 토큰 없으면 401
    }

    try {
      const payload = await this.jwtService.verifyAsync(token, {
        secret: jwtConstants.secret,
      });
      // 💡 payload를 request에 심어서 이후 라우트 핸들러에서 접근 가능하게 만듦
      request['user'] = payload;
    } catch {
      throw new UnauthorizedException(); // 토큰이 만료되었거나 유효하지 않음
    }

    return true; // 통과!
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

 

보호 라우트 설정

// auth/auth.controller.ts

import {
  Body,
  Controller,
  Get,
  HttpCode,
  HttpStatus,
  Post,
  Request,
  UseGuards,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthGuard } from './auth.guard';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @HttpCode(HttpStatus.OK)
  @Post('login')
  signIn(@Body() signInDto: Record<string, any>) {
    return this.authService.signIn(signInDto.username, signInDto.password);
  }

  @UseGuards(AuthGuard)
  @Get('profile')
  getProfile(@Request() req) {
    return req.user; // AuthGuard가 심어준 user payload
  }
}

 

테스트

  • 보호된 라우트 접근 시도(토큰 없는 상태)
    • curl http://localhost:3000/auth/profile
      # 👉 결과: {"statusCode":401,"message":"Unauthorized"}
  • 로그인하여 토큰 발급
    • curl -X POST http://localhost:3000/auth/login \
        -d '{"username": "john", "password": "changeme"}' \
        -H "Content-Type: application/json"

      # 👉 결과: {"access_token":"<JWT-토큰>"}
  • 토큰을 헤더에 담아 재접근
    • curl http://localhost:3000/auth/profile \
        -H "Authorization: Bearer <JWT-토큰>"

      # 👉 결과 예시: {"sub":1,"username":"john","iat":...,"exp":...}

 

JWT 만료 처리 확인

  • AuthModule에서 설정한 60초의 만료기간에 의해 60초 후 자동으로 401 Unauthorized가 반환된다.

 

인증을 전역으로 관리하기

Global Guard의 적용과 @Public() 데코레이터로 예외를 처리하는 방법에 대해 다루고 있다.

 

목표

  • 인증을 전역으로 설정
    • AuthGuard를 앱 전체에 자동 적용
  • 특정 라우트만 비공개 해제 
    • @Public() 데코레이터로 인증 예외 지정
  • 메타데이터 기반 제어
    • Reflector를 통해 인증 우회 여부 판단

 

1. APP_GUARD로 전역 등록

//auth.module.ts 또는 app.module.ts의 providers에 아래 추가

import { APP_GUARD } from '@nestjs/core'; // Nest에서 제공하는 전역 가드 등록 토큰
import { AuthGuard } from './auth.guard'; // 경로 주의

@Module({
  imports: [UsersModule, JwtModule.register({...})],
  controllers: [AuthController],
  providers: [
    AuthService,
    {
      provide: APP_GUARD,
      useClass: AuthGuard,
    },
  ],
})
export class AuthModule {}

 

2. @Public() 데코레이터 생성

// auth/public.decorator.ts

import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

//IS_PUBLIC_KEY: 메타데이터 키
//SetMetadata: NestJS에서 커스텀 메타데이터를 설정할 수 있는 데코레이터 팩토리

 

3. AuthGuard 수정 (Reflector 사용)

// auth/auth.guard.ts

import {
  CanActivate,
  ExecutionContext,
  Injectable,
  UnauthorizedException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Reflector } from '@nestjs/core';
import { jwtConstants } from './constants';
import { Request } from 'express';
import { IS_PUBLIC_KEY } from './public.decorator';

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(
    private jwtService: JwtService,
    private reflector: Reflector,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    // 💡 메서드/클래스 단의 메타데이터 조회
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);

    if (isPublic) {
      return true;
    }

    const request = context.switchToHttp().getRequest();
    const token = this.extractTokenFromHeader(request);

    if (!token) {
      throw new UnauthorizedException();
    }

    try {
      const payload = await this.jwtService.verifyAsync(token, {
        secret: jwtConstants.secret,
      });
      request['user'] = payload;
    } catch {
      throw new UnauthorizedException();
    }

    return true;
  }

  private extractTokenFromHeader(request: Request): string | undefined {
    const [type, token] = request.headers.authorization?.split(' ') ?? [];
    return type === 'Bearer' ? token : undefined;
  }
}

//Reflector.getAllAndOverride()는 핸들러 메서드 → 클래스 순으로 @Public() 여부를 확인합니다.

 

4. 라우트에 @Public() 적용

// auth.controller.ts

import { Controller, Post, Get, Body, Request, HttpCode, HttpStatus } from '@nestjs/common';
import { Public } from './public.decorator'; // 추가
import { AuthService } from './auth.service';

@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Public()
  @Post('login')
  @HttpCode(HttpStatus.OK)
  signIn(@Body() body: { username: string; password: string }) {
    return this.authService.signIn(body.username, body.password);
  }

  @Get('profile')
  getProfile(@Request() req) {
    return req.user;
  }
}

 

인증 처리 흐름 정리

  1. 모든 라우트에 AuthGuard가 기본 적용됨 (APP_GUARD)
  2. 특정 라우트에서 @Public()이 있으면 우회
  3. 토큰이 있으면 검증하여 request.user에 payload 저장
  4. 토큰 없거나 유효하지 않으면 401 반환

 

이후 고려해볼 수 있는 사항

  • Passport + 전략 패턴으로 인증 분리
  • 역할(Role) 기반 가드 + 커스텀 데코레이터 (@Roles('admin'))
  • Refresh Token 흐름 도입
  • JWT 키를 .env 또는 ConfigService로 분리 보안 강화

 

* Reflector ?

NestJS에서 Reflector는 데코레이터를 통해 설정된 메타데이터를 조회할 수 있는 도구이다.

즉, @SetMetadata() 로 붙여 놓은 값을 런타임에 읽어오는 데 사용된다.

// 데코레이터 설정
@SetMetadata('key', 'value')
// Reflector를 사용해 조회
this.reflector.get('key', context.getHandler())

 

NestJS의 Guard, Interceptor, Pipe 등에서는 클래스와 핸들러(메서드)의 데코레이터에 접근하려면 Reflector를 사용해야 한다.

일반적으로 이런 메타데이터는 런타임에서 직접 접근할 수 없기 때문에, NestJS는 내부적으로 ReflectMetadata 를 사용해 메타데이터를 붙이고, Reflector를 통해 이를 읽게해주는 것이다.

 

사용하기 위한 전제 조건

 

  • NestJS의 @nestjs/core 패키지에서 제공
  • 사용하는 클래스(예: Guard)의 생성자에 의존성 주입(DI) 해야 함