인증은 대부분의 애플리케이션에서 중요한 부분입니다. 인증을 처리하기 위한 다양한 접근 방식과 전략이 있으며, 프로젝트에 적용되는 접근 방식은 해당 애플리케이션의 특정 요구 사항에 따라 결정됩니다. 이 장에서는 다양한 요구 사항에 적용할 수 있는 몇 가지 인증 접근 방식을 제시합니다.
먼저 요구 사항을 자세히 살펴보겠습니다. 이 사용 사례에서 클라이언트는 사용자 이름과 비밀번호를 사용하여 인증을 시작합니다. 인증된 후 서버는 JWT를 발급하며, 이 JWT는 인증을 증명하기 위해 후속 요청의 인증 헤더로서 전송될 수 있습니다. 또한 유효한 JWT를 포함하는 요청에만 접근할 수 있는 보호된 경로를 생성할 것입니다.
먼저 첫 번째 요구 사항부터 시작합니다. 사용자를 인증하는 것입니다. 그런 다음 JWT를 발급하는 방법을 확장하고, 유효한 JWT를 요청에서 확인하는 보호된 경로를 만듭니다.
먼저 AuthModule
을 생성하고, 그 안에 AuthService
와 AuthController
를 생성합니다. AuthService
를 사용하여 인증 로직을 구현하고, AuthController
를 사용하여 인증 엔드포인트를 노출합니다.
$ nest g module auth
$ nest g controller auth
$ nest g service auth
AuthService
를 구현하는 동안 User 작업을 캡슐화하는 것이 유용할 것입니다.
따라서 UsersModule
및 UsersService
를 생성합니다.
$ 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 {}
우리의 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 부분으로 넘어가겠습니다. 우리의 요구 사항을 검토하고 정리해보겠습니다.
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
이제 마지막 요구 사항을 다룰 차례입니다. 유효한 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;
}
}
우리는 방금 생성한 AuthGuard
를 GET /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 서버와 안전하게 인증 및 통신할 수 있습니다.
대부분의 엔드포인트가 기본적으로 보호되어야 하는 경우, 인증 가드를 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"
메타데이터를 찾을 때 AuthGuard
가 true
를 반환해야 합니다. 이를 위해 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는 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를 사용했다.