본문 바로가기
✨BACKEND/📍NestJS

NestJS 인증 기능 구현하기

by 짱돌보리 2025. 8. 10.
728x90

NestJS 인증 기능 구현하기

본 포스팅은 인프런 강의에서 배운 내용을 개인적으로 정리한 글입니다.
nest g resource auth

📍회원가입

@Entity()
export class User extends BaseEntity {
  @PrimaryGeneratedColumn()
  id: number

  @Column()
  username: string

  @Column()
  password: string
}
@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [AuthController],
  providers: [AuthService]
})
export class AuthModule {}
// auth.service.ts
@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(User)
    private userRepository: Repository<User>
  ) {}

  async signUp(authCredentialsDto: AuthCredentialsDto): Promise<User> {
    const { username, password } = authCredentialsDto

    const user = this.userRepository.create({ username, password })
    await this.userRepository.save(user)

    return user
  }
}
  // auth.controller.ts
@Controller('auth')
export class AuthController {
  constructor(private authService: AuthService) {}

  @Post('/signup')
  signUp(@Body() authCredentialsDto: AuthCredentialsDto): Promise<void | User> {
    return this.authService.signUp(authCredentialsDto)
  }
}

유저 데이터 유효성 체크

https://github.com/typestack/class-validator

 

GitHub - typestack/class-validator: Decorator-based property validation for classes.

Decorator-based property validation for classes. Contribute to typestack/class-validator development by creating an account on GitHub.

github.com

 

export class AuthCredentialsDto {
  @IsString()
  @MinLength(4)
  @MaxLength(20)
  username: string

  @IsString()
  @MinLength(4)
  @MaxLength(20)
  @Matches(/^[a-zA-Z0-9]*$/, {
    message: '비밀번호는 영어와 숫자만 포함할 수 있습니다.'
  }) // 영어랑 숫자만 가능함
  password: string
}

 

요청이 컨트롤러에 있는 핸들러로 들어왔을 때 dto에 있는 유효성 조건에 맞게 체크를 해주려면 validation pipe를 넣어야 한다.

@Post('/signup')
  signUp(
    @Body(ValidationPipe) authCredentialsDto: AuthCredentialsDto
  ): Promise<void | User> {
    return this.authService.signUp(authCredentialsDto)
  }

유저 이름에 유니크한 값 주기

  1. respository에서 findOne 메서드를 이용해 이미 같은 유저 이름을 가진 id가 있는지 확인하고 없다면 데이터를 저장하는 방법
  2. 데이터베이스 레벨에서 만약 같은 이름을 가진 유저가 있다면 에러는 주는 방법
  • user.entity.ts에서 원하는 유니크한 값 주면 된다.
  • 이미 있는 유저를 다시 생성하려 하면 아래와 같은 500 에러가 나온다.

nestJS에서 에러가 발생하고 그걸 try-catch 구문인 catch에서 에러를 잡아주지 않으면 이 에러가 controller 레벨로 가서 그냥 500에러를 발생하기 때문. → try-catch로 에러를 잡아줘야 함!!

export class UserRepository extends Repository<User> {
  async createUser(authCredentialsDto: Auth): Promise<User> {
    const { username, password } = authCredentialsDto

    const user = this.create({
      username,
      password
    })

    try {
      await this.save(user)
    } catch (error) {
      if (error.code === '23505') {
        throw new Error('Username already exists')
      }
      throw new InternalServerErrorException()
    }

    return user
  }
}

비밀번호 암호화하기

yarn add bcryptjs --save

비밀번호를 DB에 저장하는 방법

  1. 원본 비밀번호 저장 (Plain Text)
  • 사용자가 입력한 비밀번호를 아무런 처리 없이 그대로 DB에 저장
입력: 1234
저장: 1234
  • DB가 유출되면 비밀번호도 그대로 노출됨
  • 법적으로도 위법 가능성 있음
  • ❌ 절대 하면 안 되는 방식
  1. 양방향 암호화 (대칭키 암호화)
  • 암호화 알고리즘과 암호화 키로 비밀번호를 암호화하고, 복호화도 가능
입력: 1234
암호화: gUuFwNo4zkMV+erdGtBlf5NunNgcELQuiCFJmCU4F+E=
복호화: 1234
  • 암호화된 값은 사람이 못 읽음
  • 복호화를 위해 암호화 키가 필요함
  • 키가 유출되면 모든 비밀번호를 복호화할 수 있음
  • 알고리즘은 대부분 공개되어 있어서 키만 털려도 위험
  • 비밀번호 저장용으로는 부적절, 주민등록번호나 카드번호 등 복호화가 필요한 정보에만 제한 사용
  1. 단방향 해시 (SHA256 등, 솔트 없음)
  • 비밀번호를 복호화할 수 없게 해시 함수로 암호화
입력: 1234
SHA256 → 03ac674216f3e15c761ee1a5e255f067953623c8b388b4459e13f978d7c846f4
  • 복호화는 원래 불가능
  • 같은 입력값이면 항상 같은 결과 → 예측 가능
  • 공격자가 "레인보우 테이블"을 이용해 미리 계산된 해시값과 비교하여 비밀번호를 역추적할 수 있음
  • 즉, 같은 해시 → 같은 비밀번호일 확률 높음
  • 솔트 없이 단순 해시만 사용하면 위험

레인보우 테이블 (Rainbow Table)

  • 저장 방법이 아니라 해시값을 공격하는 방식
  • 사용자 아님, 공격자 입장에서 아래처럼 미리 해시값을 계산해둔 테이블을 만듦
1234 → 03ac6742...
letmein → 1c8bfe8f...
qwerty → abcd1234...
  • 솔트가 없으면 이런 테이블로 해시값을 빠르게 역추적 가능
  • 보안 기술이 아니라 해시값을 깨는 공격 기법
    → 그래서 단순 해시(SHA256 등)는 실무에서 안 씀

 

5. 솔트(salt) + 해시 저장 (bcrypt, argon2 등)

  • 비밀번호에 랜덤한 문자열(Salt)을 붙인 후 해시 처리
  • 동일한 비밀번호라도 서로 다른 해시값 생성됨
입력: 1234
생성된 솔트: kenfuduWssW
암호화: bcrypt("kenfuduWssW1234") → hashedPw123xyz
저장: hashedPassword + salt
  • 복호화 불가
  • 레인보우 테이블 무력화
  • bcrypt, argon2, scrypt 등은 자동으로 솔트 처리 + 안전한 해시 적용
  • 결론: 가장 안전하고 실무 표준
    → 모든 사이트/서비스에서 이 방법 사용

📍로그인

 async signIn(authCredentialsDto: AuthCredentialsDto): Promise<string> {
    const { username, password } = authCredentialsDto
    const user = await this.userRepository.findOne({ where: { username } })

    if (user && (await bcrypt.compare(password, user.password))) {
      return '로그인 성공'
    }
    throw new UnauthorizedException('로그인 실패')
  }
@Post('/signin')
  signIn(
    @Body(ValidationPipe) authCredentialsDto: AuthCredentialsDto
  ): Promise<string> {
    return this.authService.signIn(authCredentialsDto)
  }

NestJS 커스텀 리포지토리 사용하기

useFactory란?

  • NestJS 프로바이더 등록 시 직접 인스턴스를 생성해서 제공하는 함수
  • 공장(factory) 함수라고 부름
  • NestJS가 의존성 주입 시 해당 함수를 호출해 반환값을 주입함

왜 쓸까?

  • NestJS는 보통 @InjectRepository()로 기본 저장소(Repository)를 바로 만들어 준다.
  • 그런데 내가 직접 만든 커스텀 저장소(UserRepository)는 NestJS가 알아서 만들지 못한다.
  • 그래서 직접 "이렇게 만들어 주세요" 하고 알려줘야 하는 것!!!
// auth.module.ts
{
  provide: UserRepository,                   // 이 이름으로 쓸 거임
  useFactory: (dataSource: DataSource) => {  // dataSource를 받아서
    return new UserRepository(dataSource)    // UserRepository를 직접 만들어서 넘김
  },
  inject: [DataSource],                       // dataSource는 NestJS가 알아서 넣어듐
}
// user.respository.ts (커스텀 repository)

export class UserRepository extends Repository<User> {
  constructor(private dataSource: DataSource) {
    super(User, dataSource.createEntityManager())
  }

  async createUser(authCredentialsDto: Auth): Promise<User> {
    ...
  }
}
  • constructor는 UserRepository가 생성될 때 NestJS로부터 DataSource 객체를 받는다.
  • super(User, dataSource.createEntityManager())
    • Repository 부모 클래스 생성자를 호출하면서
    • 어떤 엔티티를 관리할지(User)와
    • DB 작업을 할 EntityManager를 넘겨서 초기화한다.

📍JWT (JSON Web Token)

  • 사용자 인증 및 정보 교환에 쓰이는 웹 표준 토큰
  • 서버가 사용자에게 토큰을 발급하고, 이후 요청에 이 토큰을 포함해 인증을 진행한다.
  • 토큰 자체에 정보를 담아서, 서버가 별도의 세션 저장 없이 인증 상태를 알 수 있다.
구분 내용 예시 (Base64 인코딩)
Header 토큰 정보(알고리즘 등) {"alg":"HS256","typ":"JWT"}
Payload 사용자 정보(예: username, role, 만료시간) {"sub":"1234567890","name":"John"}
Signature Header와 Payload가 변조되지 않았는지 확인하는 안전장치 HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

 

 

시그니처 검증 과정

  • 클라이언트가 요청할 때 JWT를 헤더에 담아 보낸다.
  • 서버는 JWT를 분해해서 Header와 Payload를 꺼낸다.
  • 서버는 자신이 알고 있는 SecretKey로 다시 위와 같이 시그니처를 생성한다.
  • 서버가 만든 시그니처와 클라이언트가 보낸 시그니처가 일치하면
    → JWT가 변조되지 않은 것이므로 인증 성공
    다르면 → 토큰 위조 또는 변조로 판단해 인증 실패

Passport, Jwt 이용해서 토큰 인증 후 유저 정보 가져오기

yarn add @nestjs/jwt @nestjs/passport passport passport-jwt --save
  1. auth 모듈에 등록하기
  2. passport 모듈 등록하기
imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.register({
      secret: 'Secret1234',
      signOptions: {
        expiresIn: 60 * 60 // 1 hour
      }
    }),

    TypeOrmModule.forFeature([User])
  ],

 

 

3. 로그인 성공시 JWT를 이용해 토큰생성해주기

  async signIn(
    authCredentialsDto: AuthCredentialsDto
  ): Promise<{ accessToken: string }> {
    const { username, password } = authCredentialsDto
    const user = await this.userRepository.findOne({ where: { username } })

    if (user && (await bcrypt.compare(password, user.password))) {
      // 유저 토큰 생성 (Secret + Payload) // 중요 정보는 넣으면 안 됨
      const payload = { username }
      const accessToken = await this.jwtService.sign(payload)
      return { accessToken: accessToken }
    }
    throw new UnauthorizedException('로그인 실패')
  }
}

yarn add @types/passport-jwt --save
// jwt.strategy.ts
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    @InjectRepository(UserRepository)
    private userRepository: UserRepository
  ) {
    super({
      secretOrKey: 'Secret1234', // 유효한지 체크하는 비밀 키
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken()
    })
  }

  async validate(payload) {
    const { username } = payload
    const user: User | null = await this.userRepository.findOne({
      where: { username }
    })

    if (!user) {
      throw new UnauthorizedException('유효하지 않은 토큰입니다.')
    }
    return user // 유저가 있으면 인증 성공
  }
}

NestJS 에서 Middleware…

  • 클라이언트의 요청을 가로채서 필요한 전처리 수행
  • 요청에 대해 로그 남기기, 인증 검사, 헤더 수정 등 가능
  • 컨트롤러에 요청이 전달되기 전에 실행됨
  • next()를 호출해야 요청이 다음 단계(다음 미들웨어나 컨트롤러)로 넘어감
개념 역할 언제 사용?
Pipes 데이터 변환, 유효성 검사 요청 데이터(validation, 변환) 처리 시
Filters 예외 처리 (에러 잡아서 응답 생성) 에러 발생 시 맞춤 에러 메시지 처리할 때
Guards 권한 검사, 인증 요청이 허용되는지 여부(권한 검사) 판단 시
Interceptors 요청/응답 가로채서 추가 작업 수행 로깅, 응답 포맷 변환, 캐싱, 타임아웃 등
@Post('/test')
  @UseGuards(AuthGuard())
  test(@Req() req) {
    console.log(req) // JWT가 검증되면 user 정보가 들어있음
  }

올바른 token을 넣어서 post test를 해보면 user 정보를 볼 수 있다!!


→ console.log(req.user)하면 유저 객체를 얻을 수 있다.
바로 user 라는 파라미터로 가져올 수 있는 방법은?

// get-user.decorator.ts
export const GetUser = createParamDecorator(
  (data, ctx: ExecutionContext): User => {
    const req = ctx.switchToHttp().getRequest()
    return req.user
  }
)

'✨BACKEND > 📍NestJS' 카테고리의 다른 글

NestJS 회원가입/로그인 구현하기(feat. MySQL)  (1) 2025.08.30
NestJS 게시판 CRUD 구현하기  (7) 2025.07.27
NestJS 개념  (6) 2025.07.20