[Node.js – 기초 강좌] 7-2. 사용자 인증하기(Authentication) – (JWT 사용)

JWT(JSON Web Token)는 현대 웹 애플리케이션에서 사용자 인증 및 정보 교환을 위한 강력한 도구입니다.

본 포스팅에서는 JWT의 개념과 동작 원리를 이해하고, Node.js(Typescript & Express)를 사용하여 실제 구현하는 방법을 소개합니다. JWT는 서버와 클라이언트 간의 정보를 안전하게 주고받기 위한 서명된 토큰을 생성하여, 정보의 무결성을 보장합니다.

단계별 예제를 통해 사용자 등록, 로그인, 보호된 라우트 접근 등의 과정을 다루며, 실습을 통해 미들웨어로써 JWT를 효과적으로 사용하는 방법을 배웁니다. 보안과 확장성을 고려한 설정도 함께 확인해 보세요.

본 강의를 듣기전에 아래 Middleware 포스팅을 한번확인하시고 가면 이해에 도움이 됩니다.

[Node.js – 기초 강좌] 5-2. 기본 웹 서버 구축(Middleware 편)

JWT(JSON Web Token) 소개?

개요

JWT( JSON Web Token)는 두 시스템 간에 정보를 안전하게 전송하기 위해 사용하는 개방형 표준(RFC 7519)입니다.

주로 사용자 인증 및 정보 교환에 사용됩니다. JWT는 서명된 토큰 형태로 정보를 인코딩하여, 정보의 위변조 여부를 쉽게 확인할 수 있습니다.

JWT

구성 요소

JWT는 세 부분으로 구성되어 있습니다: 헤더(Header), 페이로드(Payload), 서명(Signature).

1. 헤더(Header)

헤더는 토큰의 타입해싱 알고리즘 정보를 포함합니다.

헤더는 두 부분으로 구성됩니다:

  1. 토큰 타입(Typ): JWT 타입을 명시합니다. 일반적으로 “JWT”로 설정됩니다.
  2. 알고리즘(Alg): 토큰 서명을 검증할 때 사용할 해싱 알고리즘을 명시합니다. 주로 HMAC SHA256 (HS256) 또는 RSA (RS256)가 사용됩니다.

예시:

{
  "alg": "HS256",
  "typ": "JWT"
}
2. 페이로드(Payload)

페이로드는 JWT의 두 번째 부분으로, 실제 정보를 포함합니다.

페이로드는 클레임(Claims)이라고 불리는 여러 가지 정보 조각으로 구성됩니다.

클레임은 세 가지 종류로 나뉩니다:

  • 등록된 클레임(Registered Claims):
    • 사전에 정의된 클레임으로, 표준 클레임이라고도 합니다.
    • 예: iss (발급자), exp (만료 시간), sub (주제), aud (대상자).
  • 공개 클레임(Public Claims):
    • 사용자 정의 클레임으로, 충돌을 방지하기 위해 IANA JSON Web Token Registry에 등록할 수 있습니다.
    • 예: 사용자 ID, 역할.
  • 비공개 클레임(Private Claims):
    • 두 시스템 간에 협의된 클레임으로, 일반적으로 애플리케이션에서 특정 용도로 사용됩니다.

예시:

{
  "sub": "1234567890",
  "name": "John Doe",
  "admin": true,
  "iat": 1516239022
}
3. 서명(Signature)

서명은 JWT의 마지막 부분으로, 토큰의 무결성을 확인하는 데 사용됩니다.

서명은 다음과 같이 생성됩니다:

  1. 헤더와 페이로드를 base64url 인코딩합니다.
  2. 인코딩된 헤더와 페이로드를 결합하여 “.”로 구분합니다.
  3. 결합된 문자열을 비밀 키와 함께 헤더에 지정된 알고리즘을 사용해 서명합니다.

예시:

HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret)

동작 순서

  1. 사용자 로그인 요청: 사용자가 아이디와 비밀번호를 입력하여 서버에 로그인 요청을 합니다.
  2. 서버 인증 및 토큰 발급: 서버는 사용자의 자격 증명을 확인하고, 유효한 경우 JWT를 생성하여 사용자에게 반환합니다.
  3. 클라이언트 저장: 클라이언트(브라우저 또는 앱)는 반환된 JWT를 저장합니다(예: 로컬 스토리지 또는 쿠키).
  4. API 요청 시 JWT 첨부: 클라이언트는 서버 API를 호출할 때, JWT를 HTTP 헤더에 포함시켜 전송합니다.
  5. 서버 검증: 서버는 전달된 JWT의 서명을 확인하여 유효성을 검증합니다. 유효한 경우 요청을 처리하고, 그렇지 않으면 오류를 반환합니다.
+-------------------+
|   사용자 (User)   |
+-------------------+
         |
         | 1. 로그인 정보 전송 (아이디/비밀번호)
         v
+-------------------+
|     서버 (Server)  |
+-------------------+
         |
         | 2. 사용자 정보 검증
         v
+-------------------+
|  JWT 생성 (Header, Payload, Signature) |
+-------------------+
         |
         | 3. JWT 반환
         v
+-------------------+
|   사용자 (User)   |
+-------------------+
         |
         | 4. JWT 저장 (로컬 스토리지/쿠키)
         v
+-------------------+
|   사용자 (User)   |
+-------------------+
         |
         | 5. 보호된 경로 요청 (JWT 포함)
         v
+-------------------+
|     서버 (Server)  |
+-------------------+
         |
         | 6. JWT 검증
         v
+-------------------+
|    접근 허용 (Protected Resource)   |
+-------------------+

JWT 사용예제

Node.js와 TypeScript를 사용하여 JWT를 이용한 사용자 인증 예제입니다.

1. 프로젝트 초기 설정

필요한 패키지를 설치 및 초기화

$ npm init -y
$ npm install express jsonwebtoken bcryptjs body-parser
$ npm install typescript ts-node ts-node-dev @types/node @types/jsonwebtoken @types/bcryptjs @types/express --save-dev
$ npx tsc --init

2. 프로젝트 구조

┣ 📂src
┃ ┣ 📂middleware
┃ ┃ ┗ 📜authMiddleware.ts
┃ ┣ 📂routes
┃ ┃ ┗ 📜auth.ts
┃ ┣ 📂types
┃ ┃ ┗ 📂express
┃ ┃   ┗ 📜index.d.ts
┃ ┣ 📂utils
┃ ┃ ┗ 📜jwt.ts
┃ ┗ 📜app.ts
┣ 📜package.json
┗ 📜tsconfig.json

3. Bcrypt

아래 예제 코드들에서 Bcrypt를 사용할 예정인데, Bcrypt는 비밀번호를 안전하게 해시하는 알고리즘으로, Blowfish 암호를 기반으로 합니다.

비밀번호 해싱은 데이터베이스에서 비밀번호를 평문으로 저장하는 대신, 해시된 형태로 저장하여 보안을 강화합니다.

Bcrypt는 단순히 해싱하는 것 외에도, 무차별 대입 공격을 방지하기 위해 반복적인 키 스트레칭을 사용합니다.

4. 코드

/src/app.ts

express 모듈을 사용해서 /auth로 오는 request에 대해 사용자 인증을 처리하도록 라우팅합니다.

import bodyParser from "body-parser";
import express from "express";
import authRoutes from "./routes/auth";

const app = express();
const PORT = 3000;

app.use(bodyParser.json());

app.use("/auth", authRoutes);

app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

/src/types/express/index.d.ts

Typescript에서 사용하는 express 모듈의 Request 객체는 user속성을 포함하지 않으므로 user속성을 추가 해줍니다.

user는 string 타입이 될수도 있고, JWT의 Payload가 될 수도 있게 정의합니다.

import { JwtPayload } from "jsonwebtoken";

declare module "express-serve-static-core" {
  interface Request {
    user?: string | JwtPayload;
  }
}

일반적으로 http Request객체는 다음을 기본으로 포함합니다.

  • req.body: 요청 본문 (주로 POST, PUT 요청에서 사용)
  • req.params: 경로 매개변수
  • req.query: 쿼리 매개변수
  • req.headers: 요청 헤더

src/utils/jwt.ts

jsonwebtoken 라이브러리를 사용하여 JWT를 생성하고, 검증하는 유틸을 정의합니다.

여기서 secretKey는 JWT 서명 및 검증에 사용되는 비밀키로 소스코드에서 관리하지 않고 환경 변수로 관리하는 것이 좋습니다.

  • generateToken
    • 매개변수:
      • payload: JWT에 포함될 데이터 객체입니다. 예를 들어 사용자 정보가 포함될 수 있습니다.
      • expiresIn: JWT의 만료 시간을 설정하는 옵션입니다. 기본값은 ‘1h’ (1시간)입니다.
    • 리턴값: 생성된 JWT 문자열을 반환합니다.
    • 동작 방식:
      • jwt.sign 함수를 사용하여 JWT를 생성합니다.
      • payloadsecretKey를 사용하여 서명된 토큰을 만듭니다.
      • expiresIn 옵션을 통해 토큰의 만료 시간을 설정합니다.
  • verifyToken
    • 매개변수:
      • token: 검증할 JWT 문자열입니다.
    • 리턴값: 검증된 데이터 객체 또는 오류 메시지 문자열을 반환합니다.
    • 동작 방식:
      • jwt.verify 함수를 사용하여 JWT를 검증합니다.
      • 검증이 성공하면, 토큰에 포함된 데이터를 반환합니다.
      • 검증이 실패하면(예: 토큰이 유효하지 않거나 만료된 경우), ‘Invalid token’ 문자열을 반환합니다.
import jwt from 'jsonwebtoken';

const secretKey = 'your-secret-key'; // 비밀 키는 환경 변수로 관리하는 것이 좋습니다.

/**
 * JWT 생성 함수
 * @param payload - JWT에 포함될 데이터
 * @param expiresIn - JWT의 만료 시간 (기본값: '1h')
 * @returns 생성된 JWT
 */
export const generateToken = (payload: object, expiresIn: string | number = '1h'): string => {
  return jwt.sign(payload, secretKey, { expiresIn });
};

/**
 * JWT 검증 함수
 * @param token - 검증할 JWT
 * @returns 검증된 데이터 또는 오류 메시지
 */
export const verifyToken = (token: string): object | string => {
  try {
    return jwt.verify(token, secretKey);
  } catch (error) {
    return 'Invalid token';
  }
};

src/middleware/authMiddleware.ts

앞서 정의한 jwt 유틸리티의 verifyToken 를 사용하여 JWT를 검증하는 미들웨어를 작성합니다.

import { verifyToken } from "../utils/jwt";

export const authMiddleware = (req: any, res: any, next: any) => {
  const authHeader = req.headers["authorization"];

  if (!authHeader) {
    return res.status(403).send("A token is required for authentication");
  }

  // valid check whether authHeader has ' ' or not
  if (authHeader.split(" ").length !== 2) {
    return res.status(401).send("This token is invalid.");
  }

  const token = authHeader.split(" ")[1]; // 'Bearer ' 부분 제거

  console.log(token);

  const verified = verifyToken(token);
  if (typeof verified === "string") {
    return res.status(401).send(verified);
  }

  req.user = verified;
  next();
};

전역적으로 사용하려면

/src/app.ts에서 app.use(authMiddleware); 를 해주어도 되고, 특정 경로에만 적용하려면 라우터에서 적용하면 됩니다.

본 예제에서는 특정 경로에만 적용하도록 하겠습니다.

/src/routes/auth.ts

이 라우터는 사용자의 등록, 로그인, 그리고 보호된 경로 접근을 위한 기능을 포함하고 있습니다.

각 부분에 대한 자세한 설명은 다음과 같습니다.

  • router: Express 라우터 객체를 생성합니다.
  • users: 메모리 내에 사용자 정보를 저장하는 객체입니다. 실제로는 데이터베이스를 사용해야 합니다.
import bcrypt from "bcryptjs";
import express from "express";
import { authMiddleware } from "../middleware/authMiddleware";
import { generateToken } from "../utils/jwt";

const router = express.Router();
const users: { [key: string]: string } = {}; // In-memory user storage (for demo purposes)

// 기본 사용자 등록
const registerDefaultUsers = async () => {
  const defaultUsers = [
    { username: "user1", password: "password1" },
    { username: "user2", password: "password2" },
    { username: "user3", password: "password3" },
  ];

  for (const user of defaultUsers) {
    const hashedPassword = await bcrypt.hash(user.password, 10);
    users[user.username] = hashedPassword;
  }
};

// 서버 시작 시 기본 사용자 등록
registerDefaultUsers();

router.post("/register", async (req, res) => {
  const { username, password } = req.body;
  const hashedPassword = await bcrypt.hash(password, 10);
  users[username] = hashedPassword;
  res.status(201).send("User registered");
});

router.post("/login", async (req, res) => {
  const { username, password } = req.body;
  const user = users[username];

  // log username and password
  console.log(req.body);

  if (!user || !(await bcrypt.compare(password, user))) {
    return res.status(401).send("Invalid credentials");
  }

  const token = generateToken({ username });
  res.json({ token });
});

router.get("/protected", authMiddleware, (req, res) => {
  res.send("This is a protected route");
});

export default router;

결과

잘못된 정보로 로그인을 시도 하면 다음과 같은 유효하지 않은 자격 증명(Invalid credentials)이라는 응답이 전달됩니다.

로그인 실패
invalid-auth-login
invalid-auth-login-response
로그인 성공

정상적인 로그인을 수행했을 때는 다음과 같이 토큰이 응답으로 전달됩니다.

valid-auth-login-request
valid-auth-login-response
검증되지 않은 인증 정보로 보호된 데이터요청

토큰이 없거나 유효하지 않은 토큰을 넣어 요청하면 다음과 같이 Invalid token 응답이 수신됩니다.

req-protected-data-jwt
res-protected-data-jwt
유효한 token을 포함한 보호된 데이터 요청

다음과 같이 유효한 token을 헤더의 authorization에 포함하면 다음과 같은 응답을 얻을 수 있습니다.

req-protected-data-jwt-with-token
res-protected-data-jwt-with-token

그 외 좀 더 심화된 예제는 Payload를 활용하여야 합니다.

참조 링크

Leave a Comment