NestJS 공식문서 Authentication

GGAE99·2023년 9월 5일
0

NestJS 공식 문서

목록 보기
26/33
post-thumbnail

Authentication

인증은 대부분의 애플리케이션에서 중요한 부분입니다. 인증을 처리하기 위한 다양한 접근 방식과 전략이 있으며, 프로젝트에 적용되는 접근 방식은 해당 애플리케이션의 특정 요구 사항에 따라 결정됩니다. 이 장에서는 다양한 요구 사항에 적용할 수 있는 몇 가지 인증 접근 방식을 제시합니다.

먼저 요구 사항을 자세히 살펴보겠습니다. 이 사용 사례에서 클라이언트는 사용자 이름과 비밀번호를 사용하여 인증을 시작합니다. 인증된 후 서버는 JWT를 발급하며, 이 JWT는 인증을 증명하기 위해 후속 요청의 인증 헤더로서 전송될 수 있습니다. 또한 유효한 JWT를 포함하는 요청에만 접근할 수 있는 보호된 경로를 생성할 것입니다.

먼저 첫 번째 요구 사항부터 시작합니다. 사용자를 인증하는 것입니다. 그런 다음 JWT를 발급하는 방법을 확장하고, 유효한 JWT를 요청에서 확인하는 보호된 경로를 만듭니다.

Creating an authentication module

먼저 AuthModule을 생성하고, 그 안에 AuthServiceAuthController를 생성합니다. AuthService를 사용하여 인증 로직을 구현하고, AuthController를 사용하여 인증 엔드포인트를 노출합니다.

$ nest g module auth
$ nest g controller auth
$ nest g service auth

AuthService를 구현하는 동안 User 작업을 캡슐화하는 것이 유용할 것입니다.
따라서 UsersModuleUsersService를 생성합니다.

$ nest g module users
$ nest g service users

아래와 같이 이러한 생성된 파일의 기본 내용을 아래와 같이 교체합니다. 샘플 앱에서 UsersService는 사용자 목록을 메모리에 하드 코딩된 형태로 유지하고, 사용자 이름으로 사용자를 검색하는 메서드를 구현합니다. 실제 앱에서는 이곳에서 사용자 모델 및 영속성 레이어를 구축할 것이며, 이를 위해 자신의 라이브러리를 선택할 수 있습니다 (예: TypeORM, Sequelize, Mongoose 등).

// users/users.service.ts
import { Injectable } from '@nestjs/common';

// This should be a real class/interface representing a user entity
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);
  }
}

UsersModule에서 필요한 유일한 변경 사항은 UsersService@Module 데코레이터의 exports 배열에 추가하여 이 모듈 외부에서 볼 수 있도록 하는 것입니다 (곧 AuthService에서 사용할 것입니다).

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

@Module({
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

Implementing the "Sign in" endpoint

우리의 AuthService는 사용자를 검색하고 비밀번호를 검증하는 역할을 합니다. 이를 위해 signIn() 메서드를 만듭니다. 아래의 코드에서는 사용자 객체를 반환하기 전에 ES6 전개 연산자를 사용하여 객체의 비밀번호 속성을 제거합니다. 이것은 사용자 객체를 반환할 때 일반적으로 사용되는 관행입니다. 비밀번호와 같은 민감한 필드를 노출하고 싶지 않기 때문입니다.

// 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, pass: string): Promise<any> {
    const user = await this.usersService.findOne(username);
    if (user?.password !== pass) {
      throw new UnauthorizedException();
    }
    const { password, ...result } = user;
    // TODO: Generate a JWT and return it here
    // instead of the user object
    return result;
  }
}

Warning!!
실제 애플리케이션에서는 비밀번호를 평문으로 저장하지 않습니다. 대신 bcrypt와 같은 라이브러리를 사용하여 단방향 해시 알고리즘을 사용합니다. 이 방법으로는 해시된 비밀번호만 저장하고, 저장된 비밀번호를 들어오는 비밀번호의 해시 버전과 비교하여 사용자 비밀번호를 평문으로 저장하거나 노출시키지 않습니다. 우리의 샘플 앱은 간단하게 유지하기 위해 이 절대적인 규칙을 어기고 평문 비밀번호를 사용합니다. 실제 앱에서는 이렇게 하지 마십시오!

이제 AuthModule을 업데이트하여 UsersModule을 가져오도록 합니다.

// 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를 열어 signIn() 메서드를 추가할 수 있습니다. 이 메서드는 클라이언트에 의해 호출되어 사용자를 인증합니다. 이 메서드는 요청 본문에서 사용자 이름과 비밀번호를 받고, 사용자가 인증되면 JWT 토큰을 반환합니다.

// 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);
  }
}

Hint!
이상적으로, Record<string, any> 타입 대신 요청 본문의 형태를 정의하기 위해 DTO 클래스를 사용해야 합니다. 자세한 내용은 유효성 검사 장에서 확인할 수 있습니다.

JWT token

이제 인증 시스템의 JWT 부분으로 넘어가겠습니다. 우리의 요구 사항을 검토하고 정리해보겠습니다.

  • 사용자가 사용자 이름/비밀번호로 인증하고, 이를 사용하여 보호된 API 엔드포인트로의 후속 호출에서 사용할 JWT를 반환합니다. 이 요구 사항을 충족하기 위해 진행 중에 있습니다. 이를 완료하려면 JWT를 발급하는 코드를 작성해야 합니다.
  • 유효한 JWT의 존재 여부에 따라 보호된 API 경로를 생성합니다.

JWT 요구 사항을 지원하기 위해 하나의 추가 패키지를 설치해야 합니다.

$ npm install --save @nestjs/jwt

Hint!
@nestjs/jwt 패키지는 JWT 조작을 지원하는 유틸리티 패키지로, JWT 토큰 생성 및 확인을 도와줍니다.

서비스를 깔끔하게 모듈화하기 위해 JWT 생성은 authService에서 처리합니다. auth 폴더의 auth.service.ts 파일을 열고 JwtService를 주입하고 signIn 메서드를 아래와 같이 업데이트합니다.

// 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, pass) {
    const user = await this.usersService.findOne(username);
    if (user?.password !== pass) {
      throw new UnauthorizedException();
    }
    const payload = { sub: user.userId, username: user.username }; // payload 생성
    return {
      access_token: await this.jwtService.signAsync(payload), //jwt 생성
    };
  }
}

우리는 @nestjs/jwt 라이브러리를 사용하고 있으며, 여기서 사용자 객체의 일부 속성을 사용하여 JWT를 생성하는 signAsync() 함수를 제공합니다. 그런 다음 이를 단일 access_token 속성을 가진 객체로 반환합니다. JWT 표준을 준수하기 위해 userId 값을 포함할 속성 이름으로 sub를 선택했습니다. JwtService 프로바이더를 AuthService에 주입하는 것을 잊지 마세요.

이제 AuthModule을 업데이트하여 새로운 종속성을 가져오고 JwtModule을 구성해야 합니다.

먼저 auth 폴더에 constants.ts 파일을 만들고 다음 코드를 추가하세요.

// 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.',
};
// 이 값을 사용하지 마세요. 대신 복잡한 비밀을 만들어서 소스 코드 외부에 안전하게 보관하세요

Warning!!
이 키를 공개적으로 노출하지 마십시오. 우리는 여기서 코드의 동작을 명확히 하기 위해 노출했지만, 실제 시스템에서는 이 키를 비밀 저장소, 환경 변수 또는 구성 서비스와 같은 적절한 방법을 사용하여 보호해야 합니다.

이제 auth 폴더의 auth.module.ts 파일을 열고 다음과 같이 업데이트하세요.

// 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 {}

Hint!
우리는 JwtModule을 전역으로 등록하여 우리에게 더 편하게 만들었습니다.
이것은 우리 애플리케이션 어디에서든 JwtModule을 가져올 필요가 없다는 것을 의미합니다.

register()를 사용하여 구성 객체를 전달하여 JwtModule을 구성합니다. JwtModule에 대한 자세한 내용은 여기에서 확인하고 사용 가능한 구성 옵션에 대한 자세한 내용은 여기에서 확인하세요.

이제 cURL을 사용하여 라우트를 테스트해 보겠습니다. UsersService에 하드 코딩된 사용자 객체 중 하나로 테스트할 수 있습니다.

$ # POST to /auth/login
$ curl -X POST http://localhost:3000/auth/login -d '{"username": "john", "password": "changeme"}' -H "Content-Type: application/json"
{"access_token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}
$ # Note: above JWT truncated

Implementing the authentication guard

이제 마지막 요구 사항을 다룰 차례입니다. 유효한 JWT가 요청에 있어야 하는 요청에 대한 보호를 설정해 보겠습니다. 이를 위해 라우트를 보호하기 위해 사용할 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();
    }
    try {
      const payload = await this.jwtService.verifyAsync(
        token,
        {
          secret: jwtConstants.secret
        }
      );
      // 💡 We're assigning the payload to the request object here
      // so that we can access it in our route handlers
      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;
  }
}

보호된 라우트를 구현하고 AuthGuard를 등록하겠습니다.

auth.controller.ts 파일을 열고 다음과 같이 업데이트합니다:

// auth/auth.controller.ts
import {
  Body,
  Controller,
  Get,
  HttpCode,
  HttpStatus,
  Post,
  Request,
  UseGuards
} from '@nestjs/common';
import { AuthGuard } from './auth.guard';
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);
  }

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

우리는 방금 생성한 AuthGuardGET /profile 라우트에 적용하여 해당 라우트를 보호합니다.
앱이 실행 중인지 확인하고 cURL을 사용하여 라우트를 테스트합니다.

$ # GET /profile
$ curl http://localhost:3000/auth/profile
{"statusCode":401,"message":"Unauthorized"}

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

$ # GET /profile using access_token returned from previous step as bearer code
$ curl http://localhost:3000/auth/profile -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2Vybm..."
{"sub":1,"username":"john","iat":...,"exp":...}

AuthModule에서 JWT의 만료 시간을 60 seconds로 구성했습니다. 이것은 너무 짧은 만료 시간이며 토큰 만료와 갱신의 세부 사항을 다루는 것은 이 글의 범위를 벗어납니다.
그러나 우리는 JWT의 중요한 특성을 보여주기 위해 그렇게 선택했습니다. 인증 후 GET /auth/profile 요청을 시도하기 전에 60초 동안 기다린다면 401 Unauthorized 응답을 받게 됩니다. 이는 @nestjs/jwt가 자동으로 JWT의 만료 시간을 확인하기 때문에 애플리케이션에서 이를 확인할 필요가 없어진다는 것을 보여줍니다.

이제 JWT 인증 구현을 완료했습니다. JavaScript 클라이언트 (Angular/React/Vue와 같은) 및 기타 JavaScript 앱은 이제 API 서버와 안전하게 인증 및 통신할 수 있습니다.

Enable authentication globally

대부분의 엔드포인트가 기본적으로 보호되어야 하는 경우, 인증 가드를 global guard로 등록하고 각 컨트롤러 위에 @UseGuards() 데코레이터를 사용하는 대신 어떤 라우트가 공개되어야 하는지 지정하는 방법을 제공할 수 있습니다.

먼저 다음 구조를 사용하여 AuthGuard를 전역 가드로 등록합니다 (어떤 모듈에서든 사용 가능합니다. 예를 들어 AuthModule에서):

providers: [
  {
    provide: APP_GUARD,
    useClass: AuthGuard,
  },
],

이렇게 설정하면 Nest는 AuthGuard를 모든 엔드포인트에 자동으로 바인딩합니다.

이제 라우트를 공개로 선언하는 메커니즘을 제공해야 합니다. 이를 위해 SetMetadata 데코레이터 팩토리 함수를 사용하여 사용자 정의 데코레이터를 만들 수 있습니다.

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

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

위의 파일에서 두 개의 상수를 내보내었습니다. 하나는 메타데이터 키인 IS_PUBLIC_KEY이고, 다른 하나는 우리가 Public(또는 프로젝트에 맞게 SkipAuth 또는 AllowAnon과 같이 이름을 지을 수 있음)라고 부를 새로운 데코레이터입니다.

이제 커스텀 @Public() 데코레이터가 있으므로 다음과 같이 어떤 메서드를 데코레이트하는 데 사용할 수 있습니다.

@Public()
@Get()
findAll() {
  return [];
}

마지막으로 "isPublic" 메타데이터를 찾을 때 AuthGuardtrue를 반환해야 합니다. 이를 위해 Reflector 클래스를 사용할 것입니다.

@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) {
      // 💡 See this condition
      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,
      });
      // 💡 We're assigning the payload to the request object here
      // so that we can access it in our route handlers
      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;
  }
}

Passport integration

Passport는 Node.js에서 가장 인기 있는 인증 라이브러리 중 하나로 커뮤니티에서 잘 알려져 있으며 많은 프로덕션 애플리케이션에서 성공적으로 사용되고 있습니다. @nestjs/passport 모듈을 사용하여 이 라이브러리를 Nest 애플리케이션과 간단하게 통합할 수 있습니다.

내 정리

JWT 토큰을 사용하여 인증을 구현하고, 사용자 인증이 필요한 메서드에 Guard를 사용하여 안전하게 보호하도록 코드를 짜는 예시를 보여준다. 이때, 인증의 auth 폴더와 사용자 로직인 users 폴더를 구분해서 사용한다는 점에 주목하자.

Guard를 전역으로 사용하기 위해 하는 설정의 예시이다.

import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { AuthGuard } from './path-to-your-auth-guard/auth.guard'; // AuthGuard의 실제 경로로 변경

@Module({
  providers: [
    {
      provide: APP_GUARD,
      useClass: AuthGuard,
    },
    // 다른 프로바이더들을 여기에 추가할 수 있습니다.
  ],
})
export class AppModule {}

이 예시에서는 AppModule에 전역으로 Guard를 사용했다.

0개의 댓글