Blog

[NestJS] 7. 회원가입 및 로그인 인증 구현과 JWT | 🎯 중요

Category
Author
citeFred
citeFred
PinOnMain
1 more property
NestJS, TypeORM 이해하기
Table of Content

1. 인증과 인가 구현을 위한 배경 지식

1.1 인증과 인가의 용어 정리

인증(Authentication)
인증은 해당 유저가 DB에 존재하는 실제 유저인지 인증하는 개념
인가(Authorization)
인가는 해당 유저가 특정 리소스에 접근이 가능한지 허가를 확인하는 개념

1.2 웹 어플리케이션의 특수한 환경의 이해

인증과 인가를 구현하기 전에 우리가 개발하고 있는 웹 서비스가 어떠한 환경을 기반으로 하고 있는지 좀 더 정확하게 이해할 필요가 있다.
무상태(Stateless)
서버가 클라이언트의 상태를 저장하지 않음
HTTP 프로토콜은 본질적으로 stateless로 설계되었음
이는, 각 요청이 독립적이며, 서버는 이전 요청의 상태를 기억하지 않는다는 것을 의미
예를 들어, 클라이언트가 여러 번의 요청을 보내더라도, 서버는 각 요청을 별도로 처리
비연결성(Connectionless)
서버와 클라이언트는 실제로 연결되어 있지 않음
클라이언트와 서버 간의 연결이 지속되지 않고 요청과 응답 후 연결이 끊어지는 방식을 의미
리소스, 비용을 절약하기 위한 개념, 예로 서버와 클라이언트가 실제로 계속 연결(실시간 요청/응답)되어있다면 서버의 비용이 기하급수적으로 늘어나기 때문
HTTP의 진화
상태 유지의 방법
우리의 프로젝트는 기본적으로 위와 같은 환경에 있음
따라서, 어떤 유저가 로그인을 통해 인증을 통과했다는 상태를 유지하는 것이 인가의 핵심 과제
대표적인 인증과 인가 방법은 Cookie-Session 방식또는 Header방식으로 JWT 을 활용하여 상태를 유지

1.2.1 Cookie-Session 방식

Cookie-Session
세션 ID를 쿠키에 저장하여 클라이언트와 서버 간의 세션을 유지하는 방식
쿠키와 세션 모두 HTTP 에 상태 정보를 유지(Stateful)하기 위해 사용
Cookie : 클라이언트에 저장될 목적으로 생성한 작은 정보를 담은 파일
Session : 서버에서 일정시간 동안 클라이언트 상태를 유지하기 위해 사용
Cookie-Session 인증, 인가의 흐름
1.
클라이언트(사용자)의 로그인 요청
2.
서버는 요청에 담긴 유저 정보로 DB를 조회하여 일치한다면 인증 완료
3.
서버는 인증이 성공한 경우, 해당 사용자를 위한 세션을 생성
4.
서버는 생성된 세션에 고유한 식별자(session-id)를 부여
5.
서버는 위 과정 까지의 로그인 요청에 대한 응답에 session-id를 포함한 cookie를 전달
6.
클라이언트는 응답에 담긴 cookie(session-id 포함)를 자동으로 쿠키 저장소(브라우저)에 보관
7.
추가 요청마다 cookie(session-id 포함)를 담아서 요청 (HTTP header에 담아서 전달)
8.
서버는 요청에 포함된 session-id 쿠키를 확인하고, 이를 세션 저장소에서 조회하여 해당 세션이 유효한지 확인
9.
세션이 유효하면, 서버는 사용자가 인증된 상태임을 인식하고, 요청된 자원에 대한 접근 권한을 확인
10.
서버는 요청된 리소스나 데이터를 응답으로 반환. 사용자가 인증된 상태라면, 이후의 모든 요청에 대해 이 과정이 반복

1.2.2 JWT 활용 (로그인 구현 부분)

JWT(Json Web Token)
JWT : 정보를 비밀리에 전달하거나 인증할 때 주로 사용하는 토큰
JSON 포맷을 이용하여 사용자에 대한 속성을 저장
세 파트로 나뉘어지며, 각 파트는 점(.)에 의해 구분
Header : 토큰의 타입과 해시 암호화 알고리즘으로 구성
Payload : Claim 정보 포함. userId, expire, scope 등 자유롭게 구성 가능
Signature : secret key를 포함하여 암호화한 서명 정보
JWT 는 누구나 평문으로 복호화 가능하지만 Secret Key 가 없으면 JWT 수정 불가능
일반적으로 쿠키 저장소를 사용하여 JWT를 저장
JWT(JSON Web Token)를 사용하는 이유
Stateless (무상태)
서버 부하 감소: JWT는 자체적으로 사용자의 인증 정보를 포함하기 때문에, 서버가 별도의 세션 상태를 유지할 필요가 없음
서버는 각 요청에 대해 JWT를 검증하기만 하면 되는 구조로 서버의 메모리 사용량이 줄어들고, 확장성 확보
보안성과 데이터 무결성
서명과 검증: JWT는 서명이 포함되어 있어 토큰의 진위 여부를 확인할 수 있음
HMAC, RSA, ECDSA 등의 알고리즘을 사용해 서명하고 검증하여 토큰이 변조되지 않았음을 보장
자기 포함적인 토큰
정보 포함: JWT는 자체적으로 사용자 정보와 권한을 포함할 수 있어, 추가적인 데이터베이스 조회 없이도 필요한 정보를 확인 할 수 있는 편리함, 성능적 유리함
다양한 클라이언트와의 호환성
다중 플랫폼 지원: 웹, 모바일, 데스크톱 등 다양한 클라이언트에서 쉽게 사용할 수 있음
Cross-Origin Resource Sharing (CORS): 쿠키와 달리 JWT는 CORS 문제를 해결하는 데 유리함
HTTP 헤더에 포함되어 전송되므로, 도메인 간 요청에서도 비교적 쉽게 사용 가능
CORS(Cross-Origin Resource Sharing)는 웹 브라우저에서 출처(origin)가 다른 도메인 간의 리소스 요청을 제한하는 보안 메커니즘
유연한 권한 관리
스코프와 권한: JWT 페이로드에 사용자의 권한이나 역할을 포함할 수 있어, 다양한 권한 관리가 편리
예를 들어, 특정 리소스에 대한 접근 권한을 JWT에 명시 할 수 있음
JWT 인증, 인가의 흐름(Cookie-Session 활용 방식)
쿠키/세션 방식과 유사하게 JWT 토큰(Access Token)을 HTTP 헤더에 실어 서버가 클라이언트를 식별
1.
클라이언트(사용자)가 로그인 요청
2.
서버는 요청에 담긴 유저 정보로 DB를 조회하여 일치한다면 인증 완료
3.
서버는 인증이 된 유저의 정보를 암호화 하여 cookie 에 암호화된 JWT 토큰을 생성
4.
서버는 위 첫 로그인 요청에 대한 응답으로 cookie(JWT를 포함)를 응답에 담아 전달
5.
클라이언트는 cookie(JWT를 포함)를 쿠키 저장소(브라우저)에 보관
6.
이후 요청마다 cookie(JWT를 포함)토큰을 같이 전달
7.
서버는 클라이언트의 요청에서 JWT 토큰을 발견했다면 토큰을 Secret Key 사용하여 JWT 위조 여부, 유효 기간 등을 검증
8.
서버는 JWT의 Payload 부분에 있는 사용자 정보를 사용하여 사용자를 식별하고, 요청된 리소스에 대한 권한을 확인 후 권한이 유효하면 요청에 따른 적절한 응답을 클라이언트에게 반환
로그인 기능 구현에서 JWT 로그인 기능 구현 예정
위 흐름을 이해하고 어려운 경우엔 다음과 같이 5단계로 구분하여 정리하면 된다.
JWT 인증/인가 를 구현 할 때 공통적으로 개발자가 구현하는 흐름은 다음과 같다.
첫 요청(로그인시도상태)
1.
JWT 생성
2.
생성된 JWT를 Cookie에 저장(또는 Header→Local Storage저장
---------------------------------------
두번째 요청(로그인상태)
3.
Cookie에(또는 Storage) 들어있던 JWT 토큰을 추출
4.
JWT 검증
5.
JWT에서 사용자 정보 가져오기

2. 회원 가입 및 로그인 기능 추가를 위한 준비

2.1 모듈 생성

Auth 모듈 생성과 기본요소 작성
터미널을 통해 다음 명령어들을 입력하여 Auth 모듈과 컨트롤러, 서비스 계층을 생성해준다.
nest g module auth
Shell
복사
nest g controller auth --no-spec
Shell
복사
nest g service auth --no-spec
Shell
복사

2.2 엔터티 설계 및 생성

회원 가지게되는 속성은 우선 기본적인 회원가입, 로그인을 위해 최소화된 엔터티를 연습하고자 한다.
id : 회원의 번호(고유번호) - 숫자
username : 회원 이름 - 문자
password : 비밀번호 - 문자
email : 이메일 주소 - 문자
role : 회원 구분 - USER / ADMIN ENUM 열거형
user.entity.ts
위 회원 엔터티 설계에 따라 TypeORM 데코레이터를 활용하여 엔터티 클래스를 작성한다.
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; import { UserRole } from "./user-role.enum"; @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column() username: string; @Column() password: string; @Column() email: string; @Column() role: UserRole; }
TypeScript
복사

2.3 모듈 DI 설정

auth.module.ts
TypeORM이 User 클래스를 맵핑 할 수 있도록 모듈에 등록해준다.
import { Module } from '@nestjs/common'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './user.entity'; @Module({ imports: [TypeOrmModule.forFeature([User])], controllers: [AuthController], providers: [AuthService] }) export class AuthModule {}
TypeScript
복사
app.module.ts
위 새로 생성한 Auth 모듈이 루트 모듈에 정상적으로 등록 되었는지 확인해야 한다.
import { Module } from '@nestjs/common'; import { BoardsModule } from './boards/boards.module'; import { TypeOrmModule } from '@nestjs/typeorm'; import { typeOrmConfig } from './configs/typeorm.config'; import { GlobalModule } from './global.module'; import { AuthModule } from './auth/auth.module'; @Module({ imports: [ GlobalModule, TypeOrmModule.forRoot(typeOrmConfig), BoardsModule, AuthModule], }) export class AppModule {}
TypeScript
복사

2.4 계층 생성

auth.service.ts
Auth 서비스 계층은 TypeORM의 자동 쿼리 메서드들을 사용하기 위해서 생성자를 통해서 Repository를 주입 받아야 한다.
import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from './user.entity'; import { Repository } from 'typeorm'; @Injectable() export class AuthService { constructor( @InjectRepository(User) private UsersRepository: Repository<User> ){} }
TypeScript
복사
auth.controller.ts
@Controller() 데코레이터를 통해 Controller 계층으로 사용된다.
해당 컨트롤러의 기본 엔드포인트는 /api/auth를 포함하도록 데코레이터에 URL 프리픽스를 추가해준다.
Controller 계층 또한 Service에 DTO를 전달하여 비지니스 로직을 호출해야 하므로 생성자를 통해서 Service를 주입 받도록 한다.
import { Controller } from '@nestjs/common'; import { AuthService } from './auth.service'; @Controller('api/auth') export class AuthController { constructor(private authService: AuthService){} }
TypeScript
복사

3. 회원 가입 기능 구현

3.1 회원가입 기본 기능 구현

create-user.dto.ts
우선 회원 가입 요청의 JSON 형식 데이터를 Typescript 객체로 받을 수 있는 DTO 클래스를 선언해야 한다.
/auth/dto 폴더를 생성 후 create-user.dto.ts 와 같이 파일을 생성
회원 가입 시 필요한 필드를 고려하여 Entity를 참고하여 아래와 같이 작성한다.
Validation Pipe를 전역으로 설정 해두었기 때문에 게시판과 마찬가지로 회원 가입 DTO에서도 일반적인 Validation을 적용 시켰다.
Validation에 대해서 이해도가 부족한 경우 4장 내용과 공식 문서를 다시 한번 살펴보도록 한다.→ [NestJS] 4. Pipe를 통한 유효성 체크와 예외처리
일부 필드는 정규식(Regular Expression)을 활용하여 보다 세밀한 유효성체크를 진행한다.
각 유효성체크 데코레이터마다 내용은 주석으로 추가해두었다.
import { IsAlphanumeric, IsEmail, IsEnum, IsNotEmpty, Matches, MaxLength, MinLength } from "class-validator"; import { UserRole } from "../user-role.enum"; export class CreateUserDto { @IsNotEmpty() // null 값 체크 @MinLength(2) // 최소 문자 수 @MaxLength(20) // 최대 문자 수 // @IsAlphanumeric() // 영문 알파벳만 허용일 경우 @Matches(/^[가-힣]+$/, { message: 'Username must be in Korean characters', }) // 한글 이름인 경우 username: string; @IsNotEmpty() @MaxLength(20) @Matches(/^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$/, { message: 'Password too weak', }) // 대문자, 소문자, 숫자, 특수문자 포함 password: string; @IsNotEmpty() @IsEmail() // 이메일 형식 @MaxLength(100) email: string; @IsNotEmpty() @IsEnum(UserRole) // 열거형 UserRole에 포함된 상태만 허용, USER / ADMIN role: UserRole; }
TypeScript
복사
auth.service.ts
DTO로 전달된 회원 가입 데이터들을 실제 데이터베이스에 등록하기 위한 소스코드를 작성한다.
Controller로 부터 전달 받는 createUserDto 에서 각 필드 이름의 변수를 할당한다.
TypeORM으로 맵핑된 usersRepository에서 create() 메서드를 통해 엔터티를 참고한 회원 가입 될 user 인스턴스를 생성하게 된다.
usersRepositorysave() 메서드를 통해 데이터베이스의 users 테이블에 insert 쿼리문이 자동으로 작성 되고 실행된다.
import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from './user.entity'; import { Repository } from 'typeorm'; import { CreateUserDto } from './dto/create-user.dto'; import { UserRole } from './user-role.enum'; @Injectable() export class AuthService { constructor( @InjectRepository(User) private usersRepository: Repository<User> ){} // 회원 가입 기능 async createUser(createUserDto: CreateUserDto): Promise<User> { const { username, password, email, role } = createUserDto; if (!username || !password || !email || !role) { throw new BadRequestException('Something went wrong.'); } const newUser: User = { id: 0, username, password, email, role: UserRole.USER }; const createdUser = await this.userRepository.save(newUser); return createdUser; } }
TypeScript
복사
auth.controller.ts
회원 가입 요청을 받게되는 Controller 계층의 핸들러 메서드이다.
POST HTTP Method를 받을 수 있도록 @Post 데코레이터를 사용하고 있으며 기본 엔드포인트 프리픽스뒤에 /signup 으로 localhost:3000/api/auth/signup 과 같은 URL로 요청 할 수 있다.
메서드명은 signUp()로 지정했다.
클라이언트 요청으로부터 username, password, email, role를 전달 받게 되며
createUserDto로 변환되면서 전역 Validation Pipe 에 의해 각 필드에 정의된 유효성검사가 진행된다.
이후 authServicecreateUser() 메서드에 변환된 createUserDto를 인수로 전달하며 호출하게 된다.
service 계층에서 DB에 회원 정보 저장 비지니스 로직 이후 반환되는 user 객체를 반환 받게 되며 이것을 클라이언트로 다시 응답하게 된다.
import { Body, Controller, Post } from '@nestjs/common'; import { AuthService } from './auth.service'; import { CreateUserDto } from './dto/create-user.dto'; import { User } from './user.entity'; @Controller('api/auth') export class AuthController { constructor(private authService: AuthService){} // 회원 가입 기능 @Post('/signup') async createUser(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> { const userResponseDto = new UserResponseDto(await this.authService.createUser(createUserDto)) return userResponseDto; } }
TypeScript
복사
POSTMAN을 통한 테스트
유효성 검사에 의해 다음과 같은 예외가 발생한다.
한글 이름만 사용 가능
비밀번호는 영문소문자+대문자+특수문자를 포함하여야 함
이메일 주소는 abcd@abc.com과 같은 이메일 주소 양식을 따라야 함
회원 유형은 Enum 클래스가 정의하고 있는 USER 또는 ADMIN만 선택 될 수 있음
정상 회원 가입 요청과 응답

3.2 Email 중복에 대한 문제 해결

현재 User 엔터티는 id를 제외하고 특정 유저를 분별 할 수 없다.
중복된 회원 가입이 되지 않도록 하기 위해서는 주민등록번호나 이름 등으로 사용자를 특정 할 수 있어야 한다.
하지만 이름은 중복되는 사례가 많기 때문에 주민등록번호를 검증하는것이 좋지만 현재 샘플 프로젝트에서는 개인정보문제가 있을 수 있기 때문에 Email 주소로 회원을 구분하고자 한다.
user.entity.ts
TypeORM에서는 @Column() 데코레이터에 unique: true로 해당 컬럼 필드 값의 유니크 속성을 추가 할 수 있다.
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm"; import { UserRole } from "./user-role.enum"; @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column() username: string; @Column() password: string; @Column({ unique: true }) // 이메일은 중복되지 않도록 한다. email: string; @Column() role: UserRole; }
TypeScript
복사
auth.service.ts
엔터티에 이메일에 unique를 설정해서 데이터베이스에서는 중복된 이메일주소가 입력 되지 않는다.
하지만 서버에서는 해당 오류가 발생한 것에 대한 처리를 하지 않았기 때문에 서버가 다운될 수 있다.
따라서 회원 가입 기능에 이메일 중복을 검사하는 로직을 추가하고 예외처리를 추가 해야 한다.
회원 가입 기능 메서드 내에 작성해도 되지만 앞으로 추가 될 기능에서 이런 이메일 조회 기능을 사용 할 가능성도 있기 때문에 미리 분리 시켜 checkEmailExists() 라는 메서드를 추가했다.
signUp() 메서드에서는 위 모듈화된 checkEmailExists()를 호출하여 이메일 중복 검사와 해당 예외 처리 기능을 가지게 된다.
앞으로 이런 부가적인 기능은 별도로 모듈화 하는 것이 가독성을 유지하기도 좋다.
import { ConflictException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from './user.entity'; import { Repository } from 'typeorm'; import { CreateUserDto } from './dto/create-user.dto'; @Injectable() export class AuthService { constructor( @InjectRepository(User) private usersRepository: Repository<User> ){} // 회원 가입 async signUp(createUserDto: CreateUserDto): Promise<User> { const { username, password, email, role } = createUserDto; // 이메일 중복 확인 await this.checkEmailExists(email); const user = this.usersRepository.create({ username, password, email, role, }); return await this.usersRepository.save(user); } // 이메일 중복 확인 메서드 private async checkEmailExists(email: string): Promise<void> { const existingUser = await this.usersRepository.findOne({ where: { email } }); if (existingUser) { throw new ConflictException('Email already exists'); } } }
TypeScript
복사

3.3 비밀번호 암호화(Password Encryption)

비밀번호 암호화는 의무화되어 있다.
회원 등록 시 ‘비밀번호’는 문자열 그대로 DB 에 등록되면 안된다.
이는 외부인이 비밀번호를 인지하지 못하는것에 대한 기본적인 보호
더불어 개발자 또는 이해관계자 또한 사용자의 비밀번호를 평문(RawPassword) 상태로 알아 낼 수 없어야 한다.
'정보통신망법, 개인정보보호법' 에 의해 비밀번호 암호화(Encryption)는 의무이다.
BCrypt
BCrypt단방향 해시 함수로, 입력값을 해시화하여 복원이 불가능하다. 따라서 암호학적 용도로 사용되며, 비밀번호와 같은 민감한 데이터를 안전하게 저장하는 데 유용하다.
비밀번호 해싱(암호화)를 위하여 사용되는 구현체인 BCrypt 해싱 모듈을 사용하고자 한다.
이는 NestJS 뿐만 아니라 여러 프레임워크에서도 사용되고 있는 암호화 라이브러리, 모듈이다.
BCrypt 모듈 설치
npm을 통해 bcryptjs 암호화 모듈 의존성을 설치한다.
npm i bcryptjs
Shell
복사
추가적으로 typescript로 위 bcryptjs를 쉽게 사용 할 수 있는 @types/bcryptjs 를 추가로 설치한다.
npm install @types/bcryptjs
Shell
복사
auth.service.ts
hashPassword() 라는 비밀번호 암호화 기능 메서드를 추가했다.
salt 는 비밀번호를 해싱할 때 추가되는 보안성을 높이기 위한 난수라고 생각하면 된다.
해싱 알고리즘을 통과 할 때 비밀번호 평문 RawPasswordSalt가 결합되며 Salt를 통해 해시값이 생성되며 이것이 비밀번호 검증에 사용되게 된다.
해커가 해시값을 사전에 계산하여 저장해두는 공격(레인보우 테이블 공격)에 대한 방어를 강화 방안
평문 password를 위 추가한 암호화 메서드에 인수로 전달하여 암호화된 hashedPassword를 얻게 된다.
엔터티 인스턴스를 만드는 create() 메서드에서 password 필드에 hashedPassword 값을 전달하여 암호화된 user가 저장되도록 한다.
import { ConflictException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from './user.entity'; import { Repository } from 'typeorm'; import { CreateUserDto } from './dto/create-user.dto'; import * as bcrypt from 'bcryptjs'; @Injectable() export class AuthService { constructor( @InjectRepository(User) private usersRepository: Repository<User> ){} // 회원 가입 async signUp(createUserDto: CreateUserDto): Promise<User> { const { username, password, email, role } = createUserDto; // 이메일 중복 확인 await this.checkEmailExists(email); // 비밀번호 해싱 const hashedPassword = await this.hashPassword(password); const user = this.usersRepository.create({ username, password: hashedPassword, // 해싱된 비밀번호 사용 email, role, }); return await this.usersRepository.save(user); } ... // 비밀번호 해싱 암호화 메서드 private async hashPassword(password: string): Promise<string> { const salt = await bcrypt.genSalt(); // 솔트 생성 return await bcrypt.hash(password, salt); // 비밀번호 해싱 } }
TypeScript
복사
POSTMAN을 통한 테스트

4. 로그인 기능 구현

4.1 로그인 기본 기능 구현

login-user.dto.ts
로그인 시 필요한 입력값들은 email, password이다.
email은 이전 회원의 id(index)외에 회원을 구분 할 수 있도록 unique 속성이 부여된 상태이다.
password는 데이터베이스에 암호화되어 저장되지만 사용자 입력값은 평문(RawPassword)를 입력 가능 하다. 평문과 암호화된 비밀번호의 검증은 서버 내 로직에서 처리해야 한다.
회원가입과 다르게 로그인에서는 @Matches() 와 같은 유효성체크로 특정 패턴을 유도하지 않도록 한다.
이에 대한 오류를 통해서도 공격자가 회원 정보를 유추 할 수 있는 근거가 될 수 있다.
공격자가 이메일 존재 여부 확인, 비밀번호 확인 처럼 계획적인 접근을 시도 할 수 있으므로 구체적인 정보를 주지 않는 것이 보안상 안전하여 예외 처리를 공통으로 처리하는 것이 좋다.
기본적으로 null값에 대한 체크와 최대 길이 정도로 유효성 체크를 구성했다.
import { IsNotEmpty, MaxLength } from "class-validator"; export class LoginUserDto { @IsNotEmpty() @MaxLength(20) password: string; @IsNotEmpty() @MaxLength(100) email: string; }
TypeScript
복사
auth.service.ts
signIn() 라는 로그인 기능 메서드를 추가했다.
loginUserDto 로 부터 emailpassword를 파라미터로 전달 받는다
findUserByEmail() 메서드를 통해서 데이터베이스에 해당 email을 가진 회원의 존재 여부를 파악한다.
회원이 존재하는 경우 입력된 비밀번호와 데이터베이스에 저장된 해당 회원의 암호화된 비밀번호가 일치하는지 확인한다.
이는 bcrypt.compare(password, existingUser.password) 처럼 bcrypt가 제공하는 compare() 메서드를 통해서 검증 할 수 있다.
이후 위 email이 존재하지 않거나 password가 검증에 실패한 것에 대해서 공통된 예외를 발생한다.
검증이 된 경우 로그인을 처리한다.
import { ConflictException, Injectable, UnauthorizedException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from './user.entity'; import { Repository } from 'typeorm'; import { CreateUserDto } from './dto/create-user.dto'; import * as bcrypt from 'bcryptjs'; import { LoginUserDto } from './dto/login-user.dto'; @Injectable() export class AuthService { constructor( @InjectRepository(User) private usersRepository: Repository<User> ){} ... // 로그인 async signIn(loginUserDto: LoginUserDto): Promise<string> { const { email, password } = loginUserDto; const existingUser = await this.findUserByEmail(email); if (!existingUser || !(await bcrypt.compare(password, existingUser.password))) { throw new UnauthorizedException('Incorrect email or password.'); } return 'login success'; } // 이메일 중복 확인 메서드 private async checkEmailExists(email: string): Promise<void> { const existingUser = await this.findUserByEmail(email); if (existingUser) { throw new ConflictException('Email already exists'); } } // 이메일로 유저 찾기 메서드 private async findUserByEmail(email: string): Promise<User | undefined> { return await this.usersRepository.findOne({ where: { email } }); } // 비밀번호 해싱 암호화 메서드 private async hashPassword(password: string): Promise<string> { const salt = await bcrypt.genSalt(); // 솔트 생성 return await bcrypt.hash(password, salt); // 비밀번호 해싱 } }
TypeScript
복사
auth.controller.ts
로그인 요청을 받게되는 Controller 계층의 핸들러 메서드이다.
POST HTTP Method를 받을 수 있도록 @Post 데코레이터를 사용하고 있으며 기본 엔드포인트 프리픽스뒤에 /signin 으로 localhost:3000/api/auth/signin 과 같은 URL로 요청 할 수 있다.
메서드명은 signIn()로 지정했다.
클라이언트 요청으로부터password, email을 전달 받게 되며
loginUserDto로 변환되면서 전역 Validation Pipe 에 의해 각 필드에 정의된 유효성검사가 진행된다.
이후 authServicesignIn() 메서드에 변환된 loginUserDto를 인수로 전달하며 호출하게 된다.
service 계층에서 DB에 회원 조회 및 검증 비지니스 로직 이후 반환되는 메시지를 반환 받게 되며 이것을 클라이언트로 다시 응답하게 된다.
import { Body, Controller, Post } from '@nestjs/common'; import { AuthService } from './auth.service'; import { CreateUserDto } from './dto/create-user.dto'; import { User } from './user.entity'; import { LoginUserDto } from './dto/login-user.dto'; @Controller('api/auth') export class AuthController { constructor(private authService: AuthService){} // 회원 가입 기능 @Post('/signup') // PostMapping 핸들러 데코레이터 signUp(@Body() createUserDto: CreateUserDto): Promise<User> { return this.authService.signUp(createUserDto); } // 로그인 기능 @Post('/signin') signIn(@Body() loginUserDto: LoginUserDto) { return this.authService.signIn(loginUserDto); } }
TypeScript
복사
POSTMAN을 통한 테스트
비밀번호 또는 이메일 정보 잘못 입력 시 공통 예외 메시지 반환
로그인 성공

4.2 JWT 로그인 구현 (Authentication)

JWT 로그인 구현에 앞서..
JWT 인증, 인가의 이해가 부족하면 위 링크를 통해서 다시한번 간단한 개념을 정리하도록 한다.
아래 과정대로 기본 로그인을 JWT 로그인으로 구현하고자 한다.
JWT 인증/인가 를 구현 할 때 공통적으로 개발자가 구현하는 흐름은 다음과 같다.
첫 요청(로그인시도상태)
1.
JWT 생성
2.
생성된 JWT를 Cookie에 저장
---------------------------------------
두번째 요청(로그인상태)
3.
Cookie에 들어있던 JWT 토큰을 추출
4.
JWT 검증
5.
JWT에서 사용자 정보 가져오기
의존성 설치
우선 NestJS에서 JWT 기능을 사용하기 위해서 다음과 같은 필요한 패키지 의존성을 npm을 통해 설치해야 한다.
@nestjs/jwt : JWT의 생성과 검증을 위한 실제 구현체
passport : 인증 관련 핵심 미들웨어 (spring security와 유사한 포지션)
passport-jwt : JWT와 Passport.js를 연결해주는 통합 모듈
@nestjs/passport : Passport와 NestJS를 연결해주는 통합 모듈
@types/passport-jwt: passport-jwt의 TypeScript 정의 파일
아래 명령어를 통해서 한번에 설치하도록 한다.
npm i @nestjs/jwt @nestjs/passport passport passport-jwt @types/passport-jwt
Shell
복사
dotenv를 통한 기민정보 은닉 환경 변수 설정
현재 프로젝트의 데이터베이스 연결 정보나 이번 JWT에서 설정하게 되는 secret key 는 외부에 유출되면 안되는 정보들이다.
하지만 현재 소스코드내에 직접 삽입되어 있어 github 등 외부에서 해당 내용들을 확인 할 수 있으므로 적절한 보안 조치가 필요하다.
기본적으로 환경 변수 설정을 통해 특정 파일에 설정 정보를 모아두고, 이를 .gitignore를 통해서 외부로 공유되지 않도록 설정하는 것이 좋다.
해당 설정은 프로젝트 시작 초기에 진행되어야 과거 커밋 기록에서부터 철저하게 해당 파일을 감출 수 있으며 프로젝트 중간에 처리할 경우 기 push된 내용들을 전체적으로 수정해야 하므로 번거로울 수 있다.
아래 명령어를 통해 dotenv 패키지를 설치한다.
npm install dotenv
Shell
복사
.env 라는 파일을 루트 경로에 추가한다.
# DATABASE 설정 정보 DB_HOST=localhost DB_PORT=3306 DB_USER=root DB_PW=1234 DB_NAME=boardapp # JWT Secret Key JWT_SECRET=7Iqk7YyM66W07YOA7L2U65Sp7YG065+9U3ByaW5n6rCV7J2Y7Yqc7YSw7LWc7JuQ67mI7J6F64uI64ukLg== JWT_EXPIRATION=600000 # 외부 API관련 Key 등 필요 시 추가
Shell
복사
.gitignore 확인 및 추가
기본적으로 nest generate 를 통해 프로젝트를 생성해서 .gitignore는 기본적으로 생성되어 있다.
혹시나 해당 파일이 없는 경우 마찬가지로 루트 경로에 파일을 생성하고 내용으로 .env를 작성하면 해당 git에서 파일을 추적하지 않게 된다.
코드 변경
dotenv를 통해 기민 정보들을 환경 변수로 지정했기 때문에 해당 프로젝트에서 하드코딩되어 있는 위 정보들을 변수명으로 수정해주어야 한다.
src/configs/typeorm.config.ts 데이터베이스 관련 환경 변수로 적용해야 한다.
dotenv.config(); 를 상단에 작성해주면 해당 파일에서는 .env로부터 변수값을 사용 할 수 있다.
import { TypeOrmModuleOptions } from "@nestjs/typeorm"; import * as dotenv from 'dotenv'; dotenv.config(); export const typeOrmConfig: TypeOrmModuleOptions = { type: 'mysql', // 사용할 데이터베이스 유형 (MySQL, PostgreSQL, SQLite 등) host: process.env.DB_HOST, // 데이터베이스 호스트 port: parseInt(process.env.DB_PORT, 10), // 데이터베이스 포트 username: process.env.DB_USER, // 데이터베이스 사용자 이름 password: process.env.DB_PW, // 데이터베이스 비밀번호 database: process.env.DB_NAME, // 사용할 데이터베이스 이름 entities: [__dirname + '/../**/*.entity.{js,ts}'], // 엔티티 파일의 위치 synchronize: true, // 애플리케이션 실행 시 스키마를 동기화할지 여부 (개발 중에만 true로 설정) logging: true, // SQL 쿼리 로그를 출력할지 여부 };
TypeScript
복사
애플리케이션에 JWT 패키지 모듈 주입
auth.module.ts
설치된 JWT 모듈은 인증, 인가 관련 기능이 모인 auth 리소스 폴더내에 주입되어 사용 되어야 한다.
따라서 해당 리소스 내의 의존성 주입을 책임, 관리하는 auth.module.ts @Module() 을 수정한다.
Jwt 모듈을 추가해준다.
secret은 JWT 토큰을 검증하는 secret key 를 입력해 두게 되는데 해당 값은 은닉해줄 필요가 있다. 이 부분은 위 dotenv를 통한 환경 변수를 활용한다.
Passport 모듈 또한 추가해준다.
import { Module } from '@nestjs/common'; import { AuthController } from './auth.controller'; import { AuthService } from './auth.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './user.entity'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; import * as dotenv from 'dotenv'; dotenv.config(); @Module({ imports: [ PassportModule.register({ defaultStrategy: 'jwt' }), JwtModule.register({ secret: process.env.JWT_SECRET, signOptions:{ expiresIn: process.env.JWT_EXPIRATION, } }), TypeOrmModule.forFeature([User]) ], controllers: [AuthController], providers: [AuthService] }) export class AuthModule {}
TypeScript
복사

4.2.1 JWT(Access Token) 생성

auth.service.ts
로그인 기능인 signIn() 메서드에 JWT 토큰을 생성하는 로직을 추가한다.
payload에 인증된 회원(existingUser)의 정보 username, email, role등 필요한 정보를 할당한다.
jwtService.sign(payload) 메서드로 payload를 인수로 전달하면 secret key와 암호화 알고리즘(기본값 HMAC SHA256)으로 서명하게 된다.
해당 토큰을 accessToken 변수에 할당
암호화된 문자열을 반환하여 컨트롤러로 전달한다.
import { BadRequestException, ConflictException, Injectable, NotFoundException, UnauthorizedException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { User } from './users.entity'; import { Repository } from 'typeorm'; import { UserRole } from './users-role.enum'; import { CreateUserDto } from './dto/create-user.dto'; import * as bcrypt from 'bcryptjs' import { LoginUserDto } from './dto/login-user.dto'; import { JwtService } from '@nestjs/jwt'; @Injectable() export class AuthService { constructor( @InjectRepository(User) private userRepository: Repository<User>, private jwtService: JwtService ){} // 회원 가입 기능 async createUser(createUserDto: CreateUserDto): Promise<User> { const { username, password, email, role } = createUserDto; if (!username || !password || !email || !role) { throw new BadRequestException('Something went wrong.'); } await this.checkEmailExist(email); const hashedPassword = await this.hashPassword(password); const newUser: User = { id: 0, username, password: hashedPassword, email, role: UserRole.USER }; const createdUser = await this.userRepository.save(newUser); return createdUser; } // 로그인 기능 async signIn(loginUserDto : LoginUserDto): Promise<string> { const { email, password } = loginUserDto; try{ const existingUser = await this.findUserByEmail(email); if(!existingUser || !(await bcrypt.compare(password, existingUser.password))) { throw new UnauthorizedException('Invalid credentials'); } // [1] JWT 토큰 생성 const payload = { id: existingUser.id, email: existingUser.email, username: existingUser.username, role: existingUser.role }; const accessToken = await this.jwtService.sign(payload); return accessToken; } catch (error) { throw error; } } // 이메일로 유저 찾기 메서드 async findUserByEmail(email: string): Promise<User> { const existingUser = await this.userRepository.findOne({ where: { email } }); if(!existingUser) { throw new NotFoundException('User not found'); } return existingUser; } // 이메일 중복 확인 메서드 async checkEmailExist(email: string): Promise<void> { const existingUser = await this.userRepository.findOne({ where: { email } }); if(existingUser) { throw new ConflictException('Email already exists'); } } // 비밀번호 해싱 암호화 메서드 async hashPassword(password: string): Promise<string> { const salt = await bcrypt.genSalt(); // 솔트 생성 return await bcrypt.hash(password, salt); // 비밀번호 해싱 } }
TypeScript
복사
POSTMAN을 통한 테스트
jwt.io 에서 암호화된 JWT 토큰의 내용을 확인 할 수 있다.
다음과 같이 payload에 저장한 email, username, role이 담겨있는 것을 확인 할 수 있다.

4.2.2 생성된 JWT를 Cookie에 저장

auth.controller.ts
Service 계층에서 accessToken을 반환하기 때문에 Controller에서 해당 토큰을 저장하는 방식을 지정 할 수 있다.
현재 코드는 쿠키에 생성하여 반환에 포함하여 클라이언트가 JWT 토큰을 자동 저장하게 된다.
Header에 포함하여 반환하도록 설정하는 경우 쿠키 저장 부분이 setHeader() 메서드로 변경되며, 클라이언트에서는 헤더에 포함된 jwt를 저장하는 로직이 추가되어야 한다.
res.cookie(cookiename, value) 메서드로 간편하게 쿠키를 생성 할 수 있다.
쿠키의 이름은 일반적인 Authorization이라는 명칭을 사용했다.
Service 계층에서 JWT 토큰을 반환받게 되며 Controller는 HTTP 응답 방식을 지정하는 역할과 책임에 따라 쿠키에 저장하는 방법을 사용하고자 한다.
import { Response } from 'express'; express로부터 Response 객체를 주입 받는다.
Controller의 signIn() 메서드 인자에 @Res() res: Response 추가
이제 클라이언트는 해당 요청에 대한 응답으로 JWT AccessToken이 있는 쿠키가 포함된 응답을 받게 되며 브라우저의 쿠키 저장소에 해당 쿠키를 저장하게 된다.
import { Body, Controller, Post, Res } from '@nestjs/common'; import { AuthService } from './auth.service'; import { CreateUserDto } from './dto/create-user.dto'; import { UserResponseDto } from './dto/user-response.dto'; import { LoginUserDto } from './dto/login-user.dto'; import { Response } from 'express'; @Controller('api/auth') export class AuthController { constructor(private authService: AuthService){} // 회원 가입 기능 @Post('/signup') async createUser(@Body() createUserDto: CreateUserDto): Promise<UserResponseDto> { const userResponseDto = new UserResponseDto(await this.authService.createUser(createUserDto)) return userResponseDto; } // 로그인 기능 @Post('/signin') async signIn(@Body() loginUserDto: LoginUserDto, @Res() res:Response): Promise<void> { const accessToken = await this.authService.signIn(loginUserDto); // [2] JWT를 쿠키에 저장 res.cookie('Authorization', accessToken, { httpOnly: true, secure: false, maxAge: 360000, sameSite: 'none' }); res.send({message: "Login Success"}); } }
TypeScript
복사
POSTMAN을 통한 테스트

4.2.3 Cookie에 들어있던 JWT 토큰을 추출, 검증

쿠키에 JWT가 저장되고 있고, 앞으로 요청에 이 쿠키가 포함된다.
우리는 쿠키에 저장하여 클라이언트가 그 쿠키를 사용하는 전략을 사용하고 있다.
헤더에 직접 JWT를 담는 경우엔 아래 쿠키 파서 부분은 생략 해도 된다.
따라서 애플리케이션이 요청에 포함된 쿠키를 읽을 수 있도록 추가적인 미들웨어가 필요하다.
cookie-parser 미들웨어 설치
아래 명령어를 통해 패키지 의존성을 설치해준다.
npm i cookie-parser
Shell
복사
main.ts
위 설치한 cookie-parserexpress 에서 사용되도록 설정해야 한다.
따라서 애플리케이션이 부트스트랩 과정에 있을 때 실행되도록 아래처럼 코드를 추가한다.
import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import * as cookieParser from 'cookie-parser' async function bootstrap() { const app = await NestFactory.create(AppModule); // cookie parser 미들웨어 추가 app.use(cookieParser()); await app.listen(3000); } bootstrap();
TypeScript
복사
jwt.strategy.ts
root/auth/ 경로에 jwt.stategy.ts라는 파일을 생성한다
해당 파일은 인증 관련 핵심 미들웨어인 PassportJWT에 대한 정의서라 보면 된다.
import { Injectable, UnauthorizedException } from "@nestjs/common"; import { Request } from 'express'; import { PassportStrategy } from "@nestjs/passport"; import { InjectRepository } from "@nestjs/typeorm"; import { Repository } from 'typeorm'; import { ExtractJwt, Strategy } from "passport-jwt"; import { User } from "./user.entity"; import * as dotenv from 'dotenv'; dotenv.config(); @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { constructor( @InjectRepository(User) private usersRepository: Repository<User>, ) { // [3] Cookie에 있는 JWT 토큰을 추출 super({ secretOrKey: process.env.JWT_SECRET, // 검증하기 위한 Secret Key jwtFromRequest: ExtractJwt.fromExtractors([(req: Request) => { let token = null; if (req && req.cookies) { token = req.cookies['Authorization']; // 쿠키에서 JWT 추출 } return token; }]), }); } // [4] Secret Key로 검증 - 인스턴스 생성 자체가 Secret Key로 JWT 토큰 검증과정 // [5] JWT에서 사용자 정보 가져오기(인증) async validate(payload) { const { email } = payload; const user: User = await this.usersRepository.findOne({ where : { email } }); if (!user) { throw new UnauthorizedException(); } return user; } }
TypeScript
복사
소스코드의 설명을 부분별로 분리해보았다.
클래스 선언부
PassportStrategy 클래스는 NestJS가 이 프로젝트의 인증을 어떻게 구현하는지에 대한 추상화된 클래스
그 인수로 Strategy를 넘기는데 이것은 passport-jwt라는 의존성으로 부터 추가된 것이라 JWT 인증을 구현할것이라는 인증 종류를 정해주는 추상 클래스
그리고 NestJS에서 JWT 인증을 실제로 구현하는 구현체가 JwtStrategy클래스라고 이해하면 된다.
생성자 부분 - [3] JWT 추출, [4] JWT 검증
UserRepository를 통해 DB와의 인증 확인이 필요하기 때문에 생성자 주입을 받는다.
super를 통해 부모클래스인 class Strategy extends PassportStrategy의 생성자를 호출하게 된다.
super() 부모 클래스 상속 구현부
secretOrKey 값으로 secret key를 dotenv를 통해 할당한다.
jwtFromRequest 값으로 fromExtractors()메서드를 통해 쿠키로부터 JWT 문자열을 추출한 값을 할당한다.
쿠키를 사용하지 않고 헤더에서 가져오는 전략을 사용한다면 fromAuthHeaderAsBearerToken() 메서드를 사용할 수도 있다.
validate() [5] 사용자 정보 획득(인증) 부분
payload는 위 [3] 추출, [4] 검증 을 통해 토큰이 유효하면, 토큰의 본문(즉, payload)이 추출 된 것임.
validate()메서드는 이 payload를 사용하여 실제 데이터베이스의 사용자 정보를 조회하거나 추가 검증을 하는 인증 과정을 담고 있음
프로젝트마다 username이라던지 userId를 사용 할 수도 있지만 본인은 회원의 고유 번호인 id를 사용 - unique 특성을 갖는 단일 데이터가 조회되는것을 사용
auth.controller.ts
로그인으로 인증된 회원의 정보를 가져오는 테스트 핸들러 작성
@UseGuards()
특정 핸들러에 대해 인증(Authorization) 또는 인가(Authentication) 가드를 적용하기 위해 사용되는 데코레이터
요청이 해당 가드를 통과해야만 핸들러가 실행
AuthGuard()
@UseGuards() 데코레이터에 전달될 실제 인증 가드의 종류.
기본적으로 jwt가 적용되며, AuthGuard('jwt')와 같은 의미로 사용
@Req(), Request 타입 객체
요청 헤더, 바디, 쿼리 파라미터 등을 확인하고 접근 할 수 있음
현재 요청(Request) 객체를 컨트롤러 메서드의 파라미터로 주입하는 역할
import { Body, Controller, Post, Req, Res, UseGuards } from '@nestjs/common'; import { Response, Request } from 'express'; import { AuthService } from './auth.service'; import { CreateUserDto } from './dto/create-user.dto'; import { User } from './user.entity'; import { LoginUserDto } from './dto/login-user.dto'; import { AuthGuard } from '@nestjs/passport'; @Controller('api/auth') export class AuthController { constructor(private authService: AuthService){} // 회원 가입 기능 @Post('/signup') // PostMapping 핸들러 데코레이터 signUp(@Body() createUserDto: CreateUserDto): Promise<User> { return this.authService.signUp(createUserDto); } // 로그인 기능 @Post('/signin') signIn(@Body() loginUserDto: LoginUserDto, @Res() res: Response) { return this.authService.signIn(loginUserDto, res); } // 인증된 회원이 들어갈 수 있는 테스트 URL 경로 @Post('/test') @UseGuards(AuthGuard()) // @UseGuards : 핸들러는 지정한 인증 가드가 적용됨 -> AuthGuard()의 'jwt'는 기본값으로 생략가능 testForAuth(@Req() req: Request) { console.log(req.user); // 인증된 사용자의 정보를 출력 return { message: 'You are authenticated', user: req.user }; } }
TypeScript
복사
POSTMAN을 통한 테스트
로그인(JWT access token 생성 및 쿠키로 클라이언트 전달)
가드 적용 URL 요청(쿠키에 JWT 담긴 상태로 요청)

4.3 커스텀 데코레이터

복잡한 코드를 커스텀 데코레이터를 통해 가독성을 높여보기
현재 auth.controllers.ts JWT 인증 기능이 추가되면서 익숙하지 않았던 @Req() 같은 데코레이터가 사용되었다.
보다 가독성을 높이기 위해서는 변수명을 잘 표현할 수도 있지만, 커스텀 데코레이터를 활용하는 방법도 있다.
커스텀 데코레이터를 소개하기 위해서 부분적으로 적용해보고자 한다.
get-user.decorator.ts 생성
createParamDecorator(): NestJS에서 커스텀 데코레이터를 만들 때 사용하는 함수
ExecutionContext: 현재 실행 중인 컨텍스트(HTTP 요청, WebSocket, RPC 등)에 대한 정보
switchToHttp().getRequest(): HTTP 요청 객체(req)를 얻기 위한 메서드
req.user: 이 데코레이터를 통해 반환 되는 객체, 인증 가드(AuthGuard())를 통과한 후, Passport가 req.user에 담긴 사용자 정보만 보여주기 위함
import { createParamDecorator, ExecutionContext } from "@nestjs/common"; import { User } from "./user.entity"; export const GetUser = createParamDecorator((data, ctx: ExecutionContext): User => { const req = ctx.switchToHttp().getRequest(); return req.user; })
TypeScript
복사
auth.controller.ts
위 설정한 커스텀 데코레이터 사용해보기
@커스텀데코레이터클래스명() 으로 사용 할 수 있다.
import { Body, Controller, Post, Req, Res, UseGuards } from '@nestjs/common'; import { Response, Request } from 'express'; import { AuthService } from './auth.service'; import { CreateUserDto } from './dto/create-user.dto'; import { User } from './user.entity'; import { LoginUserDto } from './dto/login-user.dto'; import { AuthGuard } from '@nestjs/passport'; import { GetUser } from './get-user.decorator'; @Controller('api/auth') export class AuthController { constructor(private authService: AuthService){} // 회원 가입 기능 ... // 로그인 기능 ... // 인증된 회원이 들어갈 수 있는 테스트 URL 경로 @Post('/test') @UseGuards(AuthGuard()) // @UseGuards : 핸들러는 지정한 인증 가드가 적용됨 -> AuthGuard()의 'jwt'는 기본값으로 생략가능 testForAuth(@GetUser() logginedUser: User) { console.log(logginedUser); // 인증된 사용자의 정보를 출력 console.log(logginedUser.email); // .연산자로 객체처럼 접근 가능 return { message: 'You are authenticated', logginedUser: user }; } }
TypeScript
복사
POSTMAN을 통한 테스트
결과는 동일하게 나타난다.

4.4 접근 권한 설정(Authorization)

Guard
JWT 로그인을 사용하기 위해서 Controller 레벨에서 작성했던 @UseGuards() 데코레이터가 있었다.
Guard는 런타임에 존재하는 특정 조건(예: 권한, 역할 등)에 따라 주어진 요청이 경로 핸들러에 의해 처리될지 여부를 결정하는 기능을 하는 모듈, 이를 권한(Authorization) 부여 라고 함
우리는 그 중 기본값인 AuthGuard로 JWT 인증 과정을 구현했었고, JWT로 인증되지 않은 사용자들은 페이지에 접근하지 못했던 것을 확인했었다. 이것이 리소스 접근에 대한 권한을 결정 짓는 권한이라고 한다.
하지만 실제 서비스는 보다 복잡한 권한 설정이 되어야 한다. 기본적인 구조로도 사용자는 관리자 권한과 구분되어야 할 것이다.
이를 위해서는 Custom Guard를 생성하고 개발하고자하는 서비스에 맞는 설정을 개발자가 구현해야 한다.
공식 문서에서도 모든 Guard는 canActivate()함수를 구현해야 하는 것으로 지정되어 있다.
우리는 회원의 역할을 UserRoleEnum 클래스를 통해 USER와 ADMIN으로 구분했기 때문에 이것을 통해 접근 권한을 구분해보고자 한다.
custom-role.guard.ts
원하는 Custom Guard를 설정하기 위해서 CanActivate 인터페이스를 상속 받는 클래스를 생성
구현체는 직접 구현해야 한다.
Reflector 클래스를 생성자 주입 받는다.
해당 클래스는 메타데이터를 설정하고 가져오는 데 사용
주로 커스텀 데코레이터를 사용하여 클래스나 메서드에 추가적인 정보를 첨부하고, 이를 런타임에 읽어오는 용도로 사용
canActivate() 메서드 구현
핸들러, 클래스 레벨에서 특정 애너테이션으로 역할값을 받아온다.
역할 값이 없는 경우는 모든 접근을 허용한 것으로 처리
그 외 접근 권한이 명시 된 경우
해당 요청으로부터 유저 권한과 지정한 접근 권한과 일치 여부로 접근 불허 결정
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { ROLES_KEY } from './roles.decorator'; import { UserRole } from './user-role.enum'; import { User } from '../auth/user.entity'; @Injectable() export class RolesGuard implements CanActivate { constructor(private reflector: Reflector) {} canActivate(context: ExecutionContext): boolean { // 핸들러 또는 클래스에 설정된 역할을 가져오기 const requiredRoles = this.reflector.getAllAndOverride<UserRole[]>(ROLES_KEY, [ context.getHandler(), context.getClass(), ]); // 설정된 역할이 없는(==권한설정을 하지 않은) 핸들러는 기본적으로 true를 반환해 접근을 허용 if (!requiredRoles) { return true; } // 요청 객체에서 사용자 정보를 가져오기 const { user }: { user: User } = context.switchToHttp().getRequest(); // 사용자의 역할이 필요한 역할 목록에 포함되는지 권한 확인 return requiredRoles.some((role) => user.role === role); } }
TypeScript
복사
roles.decorator.ts
컨트롤러 또는 핸들러에 권한을 쉽게 설정하기 위하여 커스텀 데코레이터를 생성하고자 한다.
SetMetadata()를 통해 메타데이터에 역할 문자를 저장한다. 이는 위 커스텀 가드에서 Reflector 가 해당 값을 가져오기 위한 설정
ROLES_KEY를 키로 하고, roles 배열을 값으로 설정
...roles: UserRole[]는 가변 인수로, 여러 개의 UserRole 값을 받을 수 있음 ( USER, ADMIN ) 일 수도 있기 때문
import { SetMetadata } from '@nestjs/common'; import { UserRole } from './user-role.enum'; export const ROLES_KEY = 'roles'; export const Roles = (...roles: UserRole[]) => SetMetadata(ROLES_KEY, roles);
TypeScript
복사
boards.controller.ts
위 커스텀 데코레이터, 커스텀 가드를 적용하고자 한다.
이 사례는 예시이며 프로젝트에 따라 접근 권한 정책을 정의하고 그에 맞게 설정해야 한다.
게시판 기능의 적용 부분(컨트롤러 레벨)
기본적으로 JWT 로그인 유저가 사용 가능
모든 핸들러는 Role을 체크
Role이 작성 되지 않은 경우 = 모든 역할 접근 가능
Role이 USER인 경우 = 사용자만 접근 가능
핸들러 메서드 레벨에서 커스텀 데코레이터 @Roles(UserRole.USER) 명시
Role이 ADMIN인 경우 = 관리자만 접근 가능
핸들러 메서드 레벨에서 커스텀 데코레이터 @Roles(UserRole.ADMIN) 명시
import { Body, Controller, Delete, Get, Param, Patch, Post, Put, Query, UseGuards } from '@nestjs/common'; import { BoardsService } from './boards.service'; import { Board } from './board.entity'; import { CreateBoardDto } from './dto/create-board.dto'; import { BoardStatus } from './board-status.enum'; import { UpdateBoardDto } from './dto/update-board.dto'; import { BoardStatusValidationPipe } from './pipes/board-status-validation.pipe'; import { AuthGuard } from '@nestjs/passport'; import { RolesGuard } from 'src/auth/custom-role.guard'; import { Roles } from 'src/auth/roles.decorator'; import { UserRole } from 'src/auth/user-role.enum'; @Controller('api/boards') @UseGuards(AuthGuard('jwt'), RolesGuard) // JWT 인증과 role 커스텀 가드를 적용 export class BoardsController { // 생성자 주입(DI) constructor(private boardsService: BoardsService){} // 게시글 작성 기능 @Post('/') // PostMapping 핸들러 데코레이터 @Roles(UserRole.USER) // User만 게시글 작성 가능 createBoard(@Body() createBoardDto: CreateBoardDto): Promise<Board> { return this.boardsService.createBoard(createBoardDto) } // 게시글 조회 기능 @Get('/') // GetMapping 핸들러 데코레이터 getAllBoard(): Promise<Board[]> { return this.boardsService.getAllBoards(); } // 특정 번호의 게시글 조회 @Get('/:id') getBoardById(@Param('id') id: number): Promise<Board> { return this.boardsService.getBoardById(id); } // 특정 작성자의 게시글 조회 @Get('/search/:keyword') getBoardByAuthor(@Query('author') author: string): Promise<Board[]> { return this.boardsService.getBoardByAuthor(author); } // 특정 번호의 게시글 삭제 @Delete('/:id') @Roles(UserRole.ADMIN, UserRole.USER) // ADMIN, USER만 게시글 삭제 가능 deleteBoardById(@Param('id') id: number): void { this.boardsService.deleteBoardById(id); } // 특정 번호의 게시글의 일부 수정 // 커스텀 파이프 사용은 명시적으로 사용하는 것이 일반적 @Patch('/:id/status') updateBoardStatusById(@Param('id') id: number, @Body('status', BoardStatusValidationPipe) status: BoardStatus): void { this.boardsService.updateBoardStatusById(id, status) } // 특정 번호의 게시글의 전체 수정 @Put('/:id') updateBoardById(@Param('id') id: number, @Body() updateBoardDto: UpdateBoardDto): void { this.boardsService.updateBoardById(id, updateBoardDto) } }
TypeScript
복사
POSTMAN을 통한 테스트
기본적으로 게시판 API는 로그인(JWT 토큰)이 필요하다. (AuthGuard)
권한을 별도로 설정하지 않은 핸들러는 로그인된 상태면 접근 가능하다.
게시글 생성은 USER 권한만 접근 가능하다. (RolesGuard, @Roles(UserRole.USER))
게시글 삭제는 ADMIN, USER 권한만 접근 가능하다. (추후 USER는 본인만, ADMIN은 모든 삭제 권한 을 갖기 위함)
Search
 | Main Page | Category |  Tags | About Me | Contact | Portfolio