인증·인가와 서버 실행
앞 절의 서버에 비밀번호 로그인과 JWT 발급, 보호 요청의 토큰 검증, 관리자 역할 검사를 더해 요청한 사람이 누구인지와 그 일을 할 수 있는지를 따로 판단하고, 서버를 실행해 각 요청의 상태 코드와 응답을 확인합니다.
로그인한 사용자라고 다른 사용자의 정보를 바꿀 수 있어야 하는 것은 아닙니다. 그래서 요청을 보낸 사람이 누구인지 확인하는 인증(authentication)과, 그 사람이 요청한 일을 할 수 있는지 판단하는 인가(authorization)를 나누어 구현합니다. 앞 절의 /users는 관리자만 쓰게 하고, 일반 사용자는 /auth/profile에서 자기 정보만 봅니다.
이 절은 API 서버 구현에서 만든 packages/server에 이어서 작업합니다. 로그인에서 비밀번호를 확인해 토큰을 발급하고, 이후의 요청은 그 토큰으로 사용자를 확인한 뒤, 관리자 경로는 그 사용자가 지금 관리자인지를 검사합니다.
인증과 인가의 흐름
신원 확인과 현재 정책 검사는 서로 다른 경계다 — 상태 추적
로그인에서 bcrypt/JWT 발급 뒤 보호 요청에서 JWT·현재 사용자·관리자 권한·공개 DTO를 확인한다. 401/403/허용과 credential 상태를 추적할 수 있다.
AUTHN · AUTHZ BOUNDARY
신원 확인과 현재 정책 검사는 서로 다른 경계다
로그인에서는 비밀번호 hash를 확인하고 JWT를 발급합니다. 보호 요청은 Bearer JWT를 검증한 뒤 현재 저장 사용자를 조회합니다. 사용자 관리는 관리자만 허용하고, 응답은 id·email·name만 담습니다. 리소스별 소유권 정책은 별도입니다.
단계별 조건과 보충 설명
- 로그인 1 · Credentials → hash 확인
email·password를 확인합니다. 비밀번호는 보호 요청마다 다시 비교하는 값이 아닙니다.
- 로그인 2 · JWT 발급
확인된 로그인에 access token을 발급해 클라이언트로 반환합니다.
- 보호 요청 1 · Bearer JWT 검증
서명·만료를 확인합니다. issuer/audience 검증은 설정이 필요하며 본문 예제에는 없습니다. 무효 인증은 401입니다.
- 보호 요청 2 · Principal → 정책
현재 저장된 사용자를 조회합니다. 삭제된 계정은401, 일반 사용자의 users 관리 요청은403입니다. 관리자 여부는 JWT claim 대신 현재 조회 결과를 사용합니다.
- 보호 요청 3 · Public DTO
profile·users 응답은 mapper가 id·email·name만 고릅니다. 공개 회원가입 register도 같은 mapper를 사용합니다. password hash와 isAdmin은 응답에 담지 않습니다.
- 핵심 경계
- 진행·선택 경로
JWT claim은 암호화된 비밀 저장소가 아닙니다. 이 예제는 서명·만료·현재 사용자와 관리자 여부를 연결합니다. issuer/audience 제한, 토큰 폐기·갱신과 리소스별 정책은 추가 설계 범위입니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 로그인과 이후 Bearer 보호 요청은 별도 호출입니다. 마지막 장면은 사용자 관리 /users 요청의 관리자 제한이며 일반 사용자의 /auth/profile 요청은 별도 허용 경로로 공개 DTO를 받습니다. profile과 /users 처리를 한 요청의 순서로 잇지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 로그인에서는 비밀번호를 확인해 토큰을 발급한다. 보호 요청은 JWT와 현재 저장 사용자를 확인한다. 사용자 관리는 현재 관리자 여부를 검사하고 id·email·name만 응답한다. 인증 실패는 401, 관리자 작업 거절은 403이다. |
| 원도표 조건·주석 | 로그인 1 · Credentials → hash 확인: email·password를 확인합니다. 비밀번호는 보호 요청마다 다시 비교하는 값이 아닙니다. |
| 원도표 조건·주석 | 로그인 2 · JWT 발급: 확인된 로그인에 access token을 발급해 클라이언트로 반환합니다. |
| 원도표 조건·주석 | 보호 요청 1 · Bearer JWT 검증: 서명·만료를 확인합니다. issuer/audience 검증은 설정이 필요하며 본문 예제에는 없습니다. 무효 인증은 401입니다. |
| 원도표 설명 | 현재 저장된 사용자를 조회합니다. 삭제된 계정은 401, 일반 사용자의 users 관리 요청은 403입니다. 관리자 여부는 JWT claim 대신 현재 조회 결과를 사용합니다. |
| 원도표 조건·주석 | 보호 요청 2 · Principal → 정책: 현재 저장된 사용자를 조회합니다. 삭제된 계정은 401, 일반 사용자의 users 관리 요청은 403입니다. 관리자 여부는 JWT claim 대신 현재 조회 결과를 사용합니다. |
| 원도표 조건·주석 | 보호 요청 3 · Public DTO: profile·users 응답은 mapper가 id·email·name만 고릅니다. 공개 회원가입 register도 같은 mapper를 사용합니다. password hash와 isAdmin은 응답에 담지 않습니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 로그인 | 검사: 비밀번호 / 결과: JWT 또는 로그인 거절 | email과 pwd를 현재 사용자의 bcrypt 검사 결과로 확인한다 | JWT 발급을 위한 인증과 보호 요청의 토큰 검증은 서로 다른 호출이다 |
| Bearer 신원 | 인증 실패: 401 / 신원: 현재 사용자 | 보호 요청에서 JWT와 현재 사용자를 확인한다 | 보호 요청마다 비밀번호를 다시 해시하는 것은 아니다 |
| 사용자 관리 /users 권한 | /users 역할 부족: 403 / 독립 /auth/profile: 일반 사용자도 공개 DTO id·email·name | 사용자 관리 /users의 요청자가 현재 관리자가 아니면 403이며 /auth/profile 공개 DTO 경로와 구분한다 | 인증된 현재 사용자의 관리자 여부는 /users 인가 조건이며 모든 보호 경로의 관리자 요구가 아니다 |
신원 확인과 현재 정책 검사는 서로 다른 경계다 — 상태 추적
로그인에서 bcrypt/JWT 발급 뒤 보호 요청에서 JWT·현재 사용자·관리자 권한·공개 DTO를 확인한다. 401/403/허용과 credential 상태를 추적할 수 있다.
AUTHN · AUTHZ BOUNDARY
신원 확인과 현재 정책 검사는 서로 다른 경계다
로그인에서는 비밀번호 hash를 확인하고 JWT를 발급합니다. 보호 요청은 Bearer JWT를 검증한 뒤 현재 저장 사용자를 조회합니다. 사용자 관리는 관리자만 허용하고, 응답은 id·email·name만 담습니다. 리소스별 소유권 정책은 별도입니다.
단계별 조건과 보충 설명
- 로그인 1 · Credentials → hash 확인
email·password를 확인합니다. 비밀번호는 보호 요청마다 다시 비교하는 값이 아닙니다.
- 로그인 2 · JWT 발급
확인된 로그인에 access token을 발급해 클라이언트로 반환합니다.
- 보호 요청 1 · Bearer JWT 검증
서명·만료를 확인합니다. issuer/audience 검증은 설정이 필요하며 본문 예제에는 없습니다. 무효 인증은 401입니다.
- 보호 요청 2 · Principal → 정책
현재 저장된 사용자를 조회합니다. 삭제된 계정은401, 일반 사용자의 users 관리 요청은403입니다. 관리자 여부는 JWT claim 대신 현재 조회 결과를 사용합니다.
- 보호 요청 3 · Public DTO
profile·users 응답은 mapper가 id·email·name만 고릅니다. 공개 회원가입 register도 같은 mapper를 사용합니다. password hash와 isAdmin은 응답에 담지 않습니다.
- 핵심 경계
- 진행·선택 경로
JWT claim은 암호화된 비밀 저장소가 아닙니다. 이 예제는 서명·만료·현재 사용자와 관리자 여부를 연결합니다. issuer/audience 제한, 토큰 폐기·갱신과 리소스별 정책은 추가 설계 범위입니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 로그인과 이후 Bearer 보호 요청은 별도 호출입니다. 마지막 장면은 사용자 관리 /users 요청의 관리자 제한이며 일반 사용자의 /auth/profile 요청은 별도 허용 경로로 공개 DTO를 받습니다. profile과 /users 처리를 한 요청의 순서로 잇지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 로그인에서는 비밀번호를 확인해 토큰을 발급한다. 보호 요청은 JWT와 현재 저장 사용자를 확인한다. 사용자 관리는 현재 관리자 여부를 검사하고 id·email·name만 응답한다. 인증 실패는 401, 관리자 작업 거절은 403이다. |
| 원도표 조건·주석 | 로그인 1 · Credentials → hash 확인: email·password를 확인합니다. 비밀번호는 보호 요청마다 다시 비교하는 값이 아닙니다. |
| 원도표 조건·주석 | 로그인 2 · JWT 발급: 확인된 로그인에 access token을 발급해 클라이언트로 반환합니다. |
| 원도표 조건·주석 | 보호 요청 1 · Bearer JWT 검증: 서명·만료를 확인합니다. issuer/audience 검증은 설정이 필요하며 본문 예제에는 없습니다. 무효 인증은 401입니다. |
| 원도표 설명 | 현재 저장된 사용자를 조회합니다. 삭제된 계정은 401, 일반 사용자의 users 관리 요청은 403입니다. 관리자 여부는 JWT claim 대신 현재 조회 결과를 사용합니다. |
| 원도표 조건·주석 | 보호 요청 2 · Principal → 정책: 현재 저장된 사용자를 조회합니다. 삭제된 계정은 401, 일반 사용자의 users 관리 요청은 403입니다. 관리자 여부는 JWT claim 대신 현재 조회 결과를 사용합니다. |
| 원도표 조건·주석 | 보호 요청 3 · Public DTO: profile·users 응답은 mapper가 id·email·name만 고릅니다. 공개 회원가입 register도 같은 mapper를 사용합니다. password hash와 isAdmin은 응답에 담지 않습니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 로그인 | 검사: 비밀번호 / 결과: JWT 또는 로그인 거절 | email과 pwd를 현재 사용자의 bcrypt 검사 결과로 확인한다 | JWT 발급을 위한 인증과 보호 요청의 토큰 검증은 서로 다른 호출이다 |
| Bearer 신원 | 인증 실패: 401 / 신원: 현재 사용자 | 보호 요청에서 JWT와 현재 사용자를 확인한다 | 보호 요청마다 비밀번호를 다시 해시하는 것은 아니다 |
| 사용자 관리 /users 권한 | /users 역할 부족: 403 / 독립 /auth/profile: 일반 사용자도 공개 DTO id·email·name | 사용자 관리 /users의 요청자가 현재 관리자가 아니면 403이며 /auth/profile 공개 DTO 경로와 구분한다 | 인증된 현재 사용자의 관리자 여부는 /users 인가 조건이며 모든 보호 경로의 관리자 요구가 아니다 |
그림의 위쪽은 로그인입니다. 서버는 이메일과 비밀번호를 받아 저장된 해시와 비교하고, 맞으면 서명한 접근 토큰을 돌려줍니다. 아래쪽은 그 뒤의 보호 요청으로, 서버는 Authorization: Bearer <토큰> 헤더의 토큰에서 서명과 만료를 검증하고, 토큰이 가리키는 사용자를 데이터베이스에서 다시 찾습니다. 비밀번호는 로그인할 때만 비교하며, 보호 요청마다 다시 비교하지 않습니다.
이 예제의 토큰은 JWT(JSON Web Token)로, 사용자 번호와 이메일을 담고 서버의 비밀 키로 서명합니다. JWT의 내용은 암호화되지 않아 누구나 읽을 수 있으므로 비밀번호 해시 같은 값은 넣지 않습니다. 관리자 여부도 토큰에 넣지 않고 보호 요청마다 데이터베이스의 현재 값에서 읽으므로, 관리자 권한을 회수하면 다음 요청부터 바로 반영됩니다. 인증에 실패하면 401, 인증은 되었지만 권한이 없으면 403으로 구별합니다.
패키지와 서명 키
인증에 쓰는 Passport와 JWT 패키지를 정확한 버전으로 설치합니다. --save-exact는 package.json에 ^ 같은 범위 없이 적은 버전을 그대로 기록해, 다시 설치해도 같은 버전이 들어오게 합니다. 첫 명령은 실행에 필요한 라이브러리를, 둘째 명령은 타입 정의만 개발 의존성으로 설치합니다.
npm install --save-exact @nestjs/[email protected] @nestjs/[email protected] [email protected] [email protected] [email protected] [email protected]
npm install --save-dev --save-exact @types/[email protected] @types/[email protected] @types/[email protected]Passport는 로그인 방식을 전략(strategy)이라는 단위로 끼워 넣는 인증 라이브러리이고, @nestjs/passport는 그 전략을 NestJS의 가드로 쓰게 해 줍니다. passport-local은 이메일과 비밀번호로, passport-jwt는 JWT로 인증하는 전략입니다. bcryptjs 3은 타입 정의를 포함하므로 @types/bcryptjs는 설치하지 않으며, 앞 절의 UserService와 같은 패키지를 써야 저장한 해시를 로그인에서 비교할 수 있습니다.
JWT에 서명할 비밀 키는 packages/server에서 무작위 값으로 만듭니다.
node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"출력된 값을 .env에 JWT_SECRET=...으로 넣습니다. 이 값을 아는 사람은 누구의 토큰이든 만들 수 있으므로 저장소에 올리지 않으며, 아래 코드는 ConfigService.getOrThrow()로 읽어 키가 없으면 서버의 시작을 거절합니다.
인증 서비스와 로그인 전략
먼저 비밀번호 확인을 통과한 사용자를 서버 안에서 나타낼 타입을 만듭니다. 인증 서비스, 두 전략, 컨트롤러가 모두 이 타입의 값을 주고받으므로, 여러 파일이 같은 모양을 쓰도록 먼저 따로 정해 둡니다.
export interface AuthenticatedUser {
id: number;
email: string;
name: string;
isAdmin: boolean;
}이 타입은 공개 응답의 세 필드에 가드가 쓸 관리자 여부만 더한 서버 내부의 인증 결과이며, 비밀번호 해시나 시각 같은 엔티티의 다른 필드는 담지 않습니다. 클라이언트에 보내는 PublicUserDto와 쓰는 곳이 다르므로 shared에 내보내지 않습니다.
import { Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";
import * as bcrypt from "bcryptjs";
import { UserService } from "../user/user.service";
import { AuthenticatedUser } from "./authenticated-user";
@Injectable()
export class AuthService {
constructor(
private readonly userService: UserService,
private readonly jwtService: JwtService,
) {}
async validateUser(email: string, password: string): Promise<AuthenticatedUser | null> {
const user = await this.userService.findOneByEmail(email);
if (user && (await bcrypt.compare(password, user.password))) {
return { id: user.id, email: user.email, name: user.name, isAdmin: user.isAdmin };
}
return null;
}
login(user: AuthenticatedUser): { accessToken: string } {
return { accessToken: this.jwtService.sign({ sub: String(user.id), email: user.email }) };
}
}validateUser는 이메일로 사용자를 찾고 bcrypt.compare로 비밀번호를 해시와 비교해, 맞으면 인증 결과를, 사용자가 없거나 비밀번호가 다르면 null을 돌려줍니다. 두 경우를 같은 null로 다루는 것은, 응답이 달라지면 어떤 이메일이 가입되어 있는지를 바깥에서 알아낼 수 있기 때문입니다. login은 토큰의 표준 필드인 sub에 사용자 번호를 넣어 서명한 토큰을 돌려주며, RFC 7519가 sub를 문자열로 정하므로 번호를 String으로 바꾸어 넣습니다.
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { PassportStrategy } from "@nestjs/passport";
import { Strategy } from "passport-local";
import { AuthService } from "./auth.service";
import { AuthenticatedUser } from "./authenticated-user";
@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
super({ usernameField: "email", passwordField: "password" });
}
async validate(email: string, password: string): Promise<AuthenticatedUser> {
const user = await this.authService.validateUser(email, password);
if (!user) {
throw new UnauthorizedException();
}
return user;
}
}passport-local은 기본으로 본문의 username 필드를 읽으므로, usernameField로 email을 읽게 바꿉니다. 이 전략의 이름은 local이고, validate가 돌려준 값은 요청의 req.user에 들어가며, 예외를 던지면 401 응답이 됩니다.
토큰 검증 전략
보호 요청에서는 토큰의 서명과 만료를 검증한 뒤에도, 토큰이 가리키는 계정이 지금도 있는지 확인합니다. 다음 JwtStrategy에서 생성자의 super() 호출은 토큰을 어디서 꺼내고 어떤 키로 검증할지를 정하고, validate 메서드가 계정을 다시 확인하는 부분입니다.
import { Injectable, UnauthorizedException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { PassportStrategy } from "@nestjs/passport";
import { ExtractJwt, Strategy } from "passport-jwt";
import { UserService } from "../user/user.service";
import { AuthenticatedUser } from "./authenticated-user";
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
configService: ConfigService,
private readonly userService: UserService,
) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: configService.getOrThrow<string>("JWT_SECRET"),
});
}
async validate(payload: unknown): Promise<AuthenticatedUser> {
if (
typeof payload !== "object" || payload === null ||
!("sub" in payload) || typeof payload.sub !== "string" || !/^[1-9][0-9]*$/.test(payload.sub) ||
!Number.isSafeInteger(Number(payload.sub)) ||
!("email" in payload) || typeof payload.email !== "string"
) {
throw new UnauthorizedException();
}
const user = await this.userService.findOneByIdOrNull(Number(payload.sub));
if (!user || user.email !== payload.email) {
throw new UnauthorizedException();
}
return { id: user.id, email: user.email, name: user.name, isAdmin: user.isAdmin };
}
}passport-jwt는 헤더에서 토큰을 꺼내 서명과 만료를 검증한 뒤, 토큰의 내용을 validate에 넘깁니다. 서명이 맞아도 그 내용은 서버가 예전에 넣은 값이므로, payload를 unknown으로 받아 sub가 0으로 시작하지 않는 십진 숫자 문자열이면서 안전한 정수 범위이고 email이 문자열인지 확인합니다. 그다음 사용자를 다시 찾아, 삭제된 계정이나 이메일이 바뀐 계정의 토큰은 401로 거절하고, 관리자 여부는 지금 저장된 값으로 채웁니다.
이 구현은 서명과 만료, 현재 사용자의 확인까지를 연결했습니다. 토큰을 하나씩 무효로 만드는 폐기 목록, 비밀번호를 바꿀 때 기존 토큰을 막는 처리, 토큰의 발급자(iss)와 대상(aud)의 제한, 갱신 토큰과 계정 복구는 구현하지 않았으며, 운영하는 서비스라면 따로 설계해야 합니다.
더 깊이: 서명 알고리즘을 고정하는 이유
JWT는 머리 부분의 alg에 어떤 알고리즘으로 서명했는지를 적어 보내는데, 이 값도 토큰을 만든 쪽이 정합니다. JWT를 다룰 때의 권고를 모은 RFC 8725는 공격자가 이 값을 서명이 없다는 뜻의 none으로 바꾸거나, RS256을 HS256으로 바꾸어 공개 키를 HMAC의 비밀 키처럼 쓰게 만드는 공격을 소개합니다. 그래서 라이브러리는 호출하는 쪽이 허용할 알고리즘의 목록을 정할 수 있어야 하고, 키 하나는 정확히 한 알고리즘에만 써야 한다고 정합니다. 같은 문서는 사람이 외우기 쉬운 비밀번호를 HS256 같은 HMAC 알고리즘의 키로 바로 쓰지 말라고도 하는데, 이 절에서 서명 키를 무작위 값으로 만든 것이 그 권고에 맞습니다.
passport-jwt는 토큰을 jsonwebtoken 라이브러리로 검증하며, algorithms 옵션으로 허용할 알고리즘을 정할 수 있습니다. 이 절의 JwtStrategy처럼 이 옵션을 적지 않으면 jsonwebtoken은 키의 종류에 따라 기본 목록을 쓰는데, 비밀 문자열이라면 HS256, HS384, HS512입니다. 서명에 쓰는 알고리즘이 하나로 정해져 있다면 algorithms: ["HS256"]처럼 직접 적어 두면 RFC의 권고처럼 키와 알고리즘의 짝이 코드에 드러납니다. 본문에서 구현하지 않았다고 한 iss와 aud도 같은 RFC가 다룹니다. 토큰에 iss가 있으면 서명 키가 그 발급자의 것인지 확인해야 하고, 한 발급자가 여러 서비스에 쓸 토큰을 발급한다면 aud를 넣고 받는 쪽이 검증해야 합니다. 두 값은 passport-jwt의 issuer와 audience 옵션으로 검사할 수 있습니다.
인증 모듈과 컨트롤러
두 전략을 쓰는 가드를 만듭니다. AuthGuard("local")과 AuthGuard("jwt")는 이름으로 전략을 찾아 실행하는 가드입니다.
import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class LocalAuthGuard extends AuthGuard("local") {}import { Injectable } from "@nestjs/common";
import { AuthGuard } from "@nestjs/passport";
@Injectable()
export class JwtAuthGuard extends AuthGuard("jwt") {}두 가드는 본문이 비어 있지만, AuthGuard가 받은 전략 이름이 각각 앞에서 만든 local, jwt 전략과 이어 줍니다. 이렇게 클래스로 만들어 두면 컨트롤러에서 @UseGuards(JwtAuthGuard)처럼 전략 이름 문자열을 다시 적지 않고 쓸 수 있습니다.
인증 모듈은 UserModule을 가져와 사용자 서비스를 쓰고, JwtModule에 서명 키와 토큰의 유효 기간을 설정합니다.
import { Module } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtModule } from "@nestjs/jwt";
import { PassportModule } from "@nestjs/passport";
import { UserModule } from "../user/user.module";
import { AuthController } from "./auth.controller";
import { AuthService } from "./auth.service";
import { JwtAuthGuard } from "./jwt-auth.guard";
import { JwtStrategy } from "./jwt.strategy";
import { LocalAuthGuard } from "./local-auth.guard";
import { LocalStrategy } from "./local.strategy";
@Module({
imports: [
UserModule,
PassportModule,
JwtModule.registerAsync({
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
secret: configService.getOrThrow<string>("JWT_SECRET"),
signOptions: { expiresIn: "60m" },
}),
}),
],
controllers: [AuthController],
providers: [AuthService, LocalStrategy, JwtStrategy, LocalAuthGuard, JwtAuthGuard],
})
export class AuthModule {}JwtModule.registerAsync는 ConfigService를 주입받아 설정을 만드는 방식으로, 앞 절에서 ConfigModule을 전역으로 등록했으므로 imports에 다시 적지 않아도 됩니다. 토큰은 60분 뒤에 만료되며, 그 뒤의 요청은 401을 받으므로 다시 로그인해야 합니다. 루트 모듈의 imports에도 AuthModule을 더합니다.
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm";
import { AuthModule } from "./auth/auth.module";
import { UserModule } from "./user/user.module";
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, envFilePath: ".env" }),
TypeOrmModule.forRoot({
type: "postgres",
host: process.env.DATABASE_HOST,
port: Number.parseInt(process.env.DATABASE_PORT ?? "5432", 10),
username: process.env.DATABASE_USER,
password: process.env.DATABASE_PASSWORD,
database: process.env.DATABASE_NAME,
entities: [__dirname + "/**/*.entity{.ts,.js}"],
synchronize: process.env.NODE_ENV === "development",
logging: process.env.NODE_ENV === "development",
}),
UserModule,
AuthModule,
],
})
export class AppModule {}앞 절의 AppModule과 비교하면 AuthModule을 가져오는 import 줄과 imports 맨 끝의 AuthModule이 더해졌고, 나머지 설정은 같습니다.
컨트롤러는 공개 회원가입, 로그인, 자기 정보 조회의 세 경로를 둡니다. 다음 코드에서는 세 경로 가운데 어느 것에 어떤 가드가 붙는지를 먼저 보면 됩니다.
import { Body, Controller, Get, HttpCode, HttpStatus, Post, Request, UseGuards } from "@nestjs/common";
import { CreateUserDto } from "../user/dto/create-user.dto";
import { toPublicUser } from "../user/dto/public-user.dto";
import type { PublicUserDto } from "../user/dto/public-user.dto";
import { UserService } from "../user/user.service";
import { AuthService } from "./auth.service";
import { AuthenticatedUser } from "./authenticated-user";
import { JwtAuthGuard } from "./jwt-auth.guard";
import { LocalAuthGuard } from "./local-auth.guard";
@Controller("auth")
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly userService: UserService,
) {}
@Post("register")
@HttpCode(HttpStatus.CREATED)
async register(@Body() dto: CreateUserDto): Promise<PublicUserDto> {
return toPublicUser(await this.userService.create(dto));
}
@UseGuards(LocalAuthGuard)
@Post("login")
@HttpCode(HttpStatus.OK)
login(@Request() req: { user: AuthenticatedUser }): { accessToken: string } {
return this.authService.login(req.user);
}
@UseGuards(JwtAuthGuard)
@Get("profile")
profile(@Request() req: { user: AuthenticatedUser }): PublicUserDto {
return toPublicUser(req.user);
}
}PublicUserDto를 import type으로 따로 가져온 것은, 데코레이터를 붙인 profile의 반환 타입에 인터페이스가 그대로 쓰이기 때문입니다. 프로젝트의 isolatedModules와 emitDecoratorMetadata가 함께 켜져 있으면, 데코레이터가 붙은 시그니처의 인터페이스는 타입 전용으로 가져와야 한다는 진단이 나옵니다. 로그인 경로는 LocalAuthGuard가 이메일과 비밀번호를 확인한 뒤에만 실행되므로, 처리 메서드는 req.user의 인증 결과로 토큰만 만들면 됩니다. POST의 기본 성공 코드는 201이지만 로그인은 새 자원을 만들지 않으므로 @HttpCode(HttpStatus.OK)로 200을 돌려줍니다. 프로필 경로는 JwtAuthGuard가 넣은 인증 결과에서 공개할 세 필드만 골라 응답하므로, 관리자 여부는 응답에 나가지 않습니다.
관리자 역할 검사
인증된 사용자가 관리자 경로를 쓸 수 있는지는 역할 데코레이터와 역할 가드로 판단합니다. 이 예제는 관리자 한 가지 역할만 다루며, 여러 역할과 자원별 권한을 조합하는 정책은 만들지 않습니다.
import { SetMetadata } from "@nestjs/common";
export const ROLES_KEY = "roles";
export const Roles = (...roles: Array<"admin">) => SetMetadata(ROLES_KEY, roles);import { Injectable } from "@nestjs/common";
import type { CanActivate, ExecutionContext } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { AuthenticatedUser } from "./authenticated-user";
import { ROLES_KEY } from "./roles.decorator";
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<Array<"admin">>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) {
return true;
}
const { user } = context.switchToHttp().getRequest<{ user?: AuthenticatedUser }>();
return requiredRoles.some((role) => role === "admin" && user?.isAdmin === true);
}
}@Roles("admin")은 그 경로에 필요한 역할을 메타데이터로 남길 뿐, 그것만으로 요청이 막히지는 않습니다. RolesGuard가 Reflector로 메서드와 클래스에 붙은 역할을 읽고, JwtAuthGuard가 넣어 둔 사용자가 관리자인지 확인해 false면 403을 만듭니다. 앞 절의 UserController는 두 가드를 JwtAuthGuard, RolesGuard의 순서로 붙였으므로, 토큰이 없거나 틀리면 역할 가드까지 가지 않고 401로 끝납니다.
서버 실행과 응답 확인
앞 절과 이 절의 파일을 모두 만들었다면 서버를 실행합니다. 실습용 PostgreSQL이 실행 중이고 .env의 DATABASE_*와 JWT_SECRET이 채워져 있어야 합니다.
npm run start:dev서버가 http://localhost:4100에서 시작하면, 다른 터미널에서 회원가입과 로그인을 차례로 보냅니다. 아래의 이메일과 비밀번호는 로컬 실습용이며 실제 계정에 쓰지 않습니다.
curl -i http://localhost:4100/auth/register -H "Content-Type: application/json" -d '{"email":"[email protected]","password":"local-practice-password","name":"학습자"}'
curl -i http://localhost:4100/auth/login -H "Content-Type: application/json" -d '{"email":"[email protected]","password":"local-practice-password"}'회원가입은 201과 함께 {"id":1,"email":"[email protected]","name":"학습자"} 모양의 본문을 돌려주며, id는 데이터베이스가 정하므로 다를 수 있습니다. 로그인은 200과 {"accessToken":"..."}를 돌려주며, 그 토큰을 다음 요청의 헤더에 넣습니다.
curl -i http://localhost:4100/auth/profile -H "Authorization: Bearer <받은 accessToken>"각 요청에서 기대하는 응답과, 다를 때 먼저 확인할 곳은 다음과 같습니다.
| 요청 | 기대하는 응답 | 다르면 확인할 곳 |
|---|---|---|
| 올바른 이메일과 비밀번호로 로그인 | 200, accessToken | LocalStrategy의 등록과 usernameField, 해시의 저장 |
| 틀린 비밀번호나 없는 이메일로 로그인 | 401 | validateUser의 비교와 UnauthorizedException |
| 받은 토큰으로 프로필 조회 | 200, id, email, name | Bearer 헤더, JwtStrategy와 서명 키 |
| 토큰 없이 또는 틀린 토큰으로 프로필 조회 | 401 | JwtAuthGuard의 적용 |
회원가입 본문에 isAdmin: true를 더함 | 400, 계정이 만들어지지 않음 | CreateUserDto와 forbidNonWhitelisted |
일반 사용자의 토큰으로 GET /users | 403 | 클래스의 @Roles("admin")과 RolesGuard |
토큰 없이 GET /users | 401 | 가드의 순서 |
관리자의 토큰으로 GET /users | 200, 각 항목에 세 필드만 있음 | 저장된 isAdmin과 toPublicUser |
로그인 경로가 404라면 모듈과 컨트롤러의 등록부터, 401이라면 본문의 email과 password, 저장된 사용자부터 확인합니다. 관리자의 응답을 보려면 실습용 데이터베이스에서 믿을 수 있는 방법으로 한 계정의 isAdmin을 true로 바꾸어야 하며, 공개 회원가입으로는 관리자를 만들 수 없으므로 일반 사용자의 /users 요청이 403인 것이 의도한 결과입니다.
삭제된 계정의 토큰은 401을, 관리자 권한을 회수한 계정의 관리 요청은 403을 받으며, 그 계정도 자기 프로필은 계속 볼 수 있습니다. 같은 이메일로 다시 가입하면 데이터베이스의 유일성 제약에 걸리는데 이 오류를 따로 처리하지 않았으므로 500이 되며, 이메일 중복 응답과 데이터베이스 오류의 처리, 토큰 폐기는 이 예제가 다루지 않는 범위입니다.
정리
- 인증은 요청한 사람이 누구인지를, 인가는 그 사람이 요청한 일을 할 수 있는지를 판단하며, 실패는 각각 401과 403으로 구별합니다.
- 로그인은
LocalStrategy가 이메일과 비밀번호를 해시와 비교하고, 성공하면 사용자 번호와 이메일을 담은 JWT를 서명해 돌려줍니다. - 보호 요청은
JwtStrategy가 서명과 만료를 검증한 뒤 토큰의 내용을 확인하고, 현재 사용자를 다시 찾아 관리자 여부를 채웁니다. - JWT의 내용은 누구나 읽을 수 있으므로 비밀 값을 넣지 않고, 서명 키는
.env로만 주입하며 없으면 시작을 거절합니다. - 역할 데코레이터는 필요한 역할을 메타데이터로 남기고, 역할 가드가 그것을 읽어 현재 사용자가 관리자인지 판단합니다.
- 서버를 실행한 뒤에는 정상 요청뿐 아니라 틀린 비밀번호, 토큰 없음, 권한 없음, 허용하지 않은 필드의 응답도 확인합니다.
연습 문제
1. 관리자 권한의 회수
관리자 A가 로그인해 토큰을 받은 뒤, 데이터베이스에서 A의 isAdmin을 false로 바꾸었습니다. A가 같은 토큰으로 GET /users와 GET /auth/profile을 보내면 각각 어떻게 되나요? 관리자 여부를 토큰에 넣었다면 어떻게 달라졌을까요?
해설 보기
JwtStrategy가 요청마다 사용자를 다시 찾아 지금의 isAdmin을 쓰므로, GET /users는 403이 되고 GET /auth/profile은 200으로 자기 정보를 받습니다. 관리자 여부를 토큰에 넣고 그 값을 믿었다면, 토큰이 만료되는 60분 동안 A는 권한을 잃었는데도 관리자 경로를 계속 쓸 수 있었을 것입니다.
2. 같은 응답을 쓰는 이유
validateUser에서 없는 이메일이면 404를, 비밀번호가 틀리면 401을 돌려주도록 바꾸자는 제안이 있습니다. 어떤 문제가 생기나요?
해설 보기
응답만 보고 어떤 이메일이 가입되어 있는지를 알아낼 수 있게 됩니다. 공격자는 이메일 목록을 하나씩 보내 404가 아닌 것만 골라 비밀번호를 대입할 수 있으므로, 두 경우 모두 같은 401과 같은 메시지로 응답해 어느 쪽이 틀렸는지 알리지 않습니다.
3. 가드의 순서
UserController의 @UseGuards(JwtAuthGuard, RolesGuard)를 @UseGuards(RolesGuard, JwtAuthGuard)로 바꾸면, 토큰 없이 GET /users를 보낸 요청은 어떤 응답을 받나요?
해설 보기
RolesGuard가 먼저 실행되는데, 아직 JwtAuthGuard가 req.user를 넣지 않았으므로 user가 없어 false를 돌려주고 403이 됩니다. 토큰이 없는 것은 인증의 실패이므로 401이 맞으며, 두 상태 코드를 바르게 구별하려면 인증 가드를 먼저 두어야 합니다.
다음 절 프론트엔드 애플리케이션 개발에서는 이 API에 연결되는 React 클라이언트를 만들어, 회원가입과 로그인, 프로필 화면을 구현합니다.