본문 바로가기
Computer Science

JWT(JSON Web Token)의 이해와 활용: 3편

by 대박플머 2024. 10. 14.

JWT(JSON Web Token)의 이해와 활용: 1편

JWT(JSON Web Token)의 이해와 활용: 2편

JWT(JSON Web Token)의 이해와 활용: 3편

 

안녕하세요. 이전 글들에서 JWT의 기본 개념, 구조, 장단점, 그리고 보안 이슈에 대해 살펴보았습니다. 이번 글에서는 JWT와 OAuth 2.0의 관계, 백엔드와 프론트엔드에서의 JWT 사용 방법, 그리고 실무에서 JWT를 사용할 때의 팁들을 알아보겠습니다.

6. JWT와 OAuth 2.0

OAuth 2.0은 인증 및 권한 부여를 위한 업계 표준 프로토콜입니다. JWT는 OAuth 2.0과 함께 사용되어 더욱 강력한 인증 시스템을 구축할 수 있습니다.

OAuth 2.0에서 JWT 사용 사례

OAuth 2.0에서 JWT는 주로 다음과 같은 용도로 사용됩니다:

  1. 액세스 토큰: 리소스 서버에 접근할 때 사용되는 토큰
  2. ID 토큰: 사용자 인증 정보를 포함하는 토큰 (OpenID Connect에서 사용)
  3. 리프레시 토큰: 새로운 액세스 토큰을 얻기 위해 사용되는 토큰

액세스 토큰과 ID 토큰의 차이

  1. 액세스 토큰:
    • 목적: 보호된 리소스에 접근하기 위한 권한 부여
    • 포함 정보: 주로 권한 범위(scope)와 만료 시간
    • 사용처: API 요청 시 Authorization 헤더에 포함
  2. ID 토큰:
    • 목적: 사용자 인증 정보 제공
    • 포함 정보: 사용자 ID, 이름, 이메일 등 사용자 관련 클레임
    • 사용처: 클라이언트 애플리케이션에서 사용자 정보 확인

JWT를 사용한 OAuth 2.0 흐름 예시

다음은 JWT를 사용한 OAuth 2.0의 인증 코드 흐름 예시입니다:

  1. 클라이언트가 인증 서버에 인증 요청을 보냅니다.
  2. 사용자가 인증을 완료하면, 인증 서버는 인증 코드를 클라이언트에게 전달합니다.
  3. 클라이언트는 이 인증 코드를 사용하여 액세스 토큰을 요청합니다.
  4. 인증 서버는 JWT 형식의 액세스 토큰을 생성하여 클라이언트에게 전달합니다.
  5. 클라이언트는 이 JWT 액세스 토큰을 사용하여 리소스 서버에 API 요청을 보냅니다.
  6. 리소스 서버는 JWT의 서명을 확인하고, 요청을 처리합니다.

JWT와 OAuth 2.0을 결합한 실제 인증 흐름

실제 애플리케이션에서 JWT와 OAuth 2.0을 결합한 인증 흐름은 다음과 같을 수 있습니다:

  1. 사용자가 클라이언트 애플리케이션에서 로그인을 시도합니다.
  2. 클라이언트는 사용자를 OAuth 2.0 인증 서버로 리다이렉트합니다.
  3. 사용자가 인증을 완료하면, 인증 서버는 인증 코드를 발급합니다.
  4. 클라이언트는 이 인증 코드를 사용하여 액세스 토큰(JWT)과 리프레시 토큰을 요청합니다.
  5. 인증 서버는 JWT 형식의 액세스 토큰과 리프레시 토큰을 발급합니다.
  6. 클라이언트는 이 JWT 액세스 토큰을 사용하여 API 요청을 보냅니다.
  7. 액세스 토큰이 만료되면, 클라이언트는 리프레시 토큰을 사용하여 새로운 액세스 토큰을 요청합니다.

이러한 방식으로 JWT와 OAuth 2.0을 결합하면, 보안성과 확장성이 높은 인증 시스템을 구축할 수 있습니다.

7. 백엔드에서 JWT 사용하기

백엔드에서 JWT를 사용하는 방법을 Node.js를 기반으로 살펴보겠습니다.

Node.js 기반 서버에서 JWT 구현 예제

다음은 Express.js를 사용한 JWT 구현 예제입니다:

const express = require("express");
const jwt = require("jsonwebtoken");
const app = express();

const SECRET_KEY = "your-secret-key";

// JWT 발급
app.post("/login", (req, res) => {
  // 사용자 인증 로직 (생략)
  const user = { id: 1, username: "example" };

  const token = jwt.sign(user, SECRET_KEY, { expiresIn: "1h" });
  res.json({ token });
});

// JWT 유효성 검증 미들웨어
function authenticateToken(req, res, next) {
  const authHeader = req.headers["authorization"];
  const token = authHeader && authHeader.split(" ")[1];

  if (token == null) return res.sendStatus(401);

  jwt.verify(token, SECRET_KEY, (err, user) => {
    if (err) return res.sendStatus(403);
    req.user = user;
    next();
  });
}

// 보호된 라우트
app.get("/protected", authenticateToken, (req, res) => {
  res.json({ message: "인증된 사용자입니다.", user: req.user });
});

app.listen(3000, () => console.log("Server running on port 3000"));

이 예제에서는 JWT 발급, 유효성 검증, 그리고 보호된 라우트 접근을 구현하고 있습니다.

프레임워크별 JWT 적용 예시 (NestJS, Express)

  1. NestJS에서의 JWT 적용:

NestJS에서는 @nestjs/jwt 패키지를 사용하여 JWT를 쉽게 구현할 수 있습니다.

import { Injectable } from "@nestjs/common";
import { JwtService } from "@nestjs/jwt";

@Injectable()
export class AuthService {
  constructor(private jwtService: JwtService) {}

  async login(user: any) {
    const payload = { username: user.username, sub: user.userId };
    return {
      access_token: this.jwtService.sign(payload),
    };
  }
}
  1. Express에서의 JWT 적용:

Express에서는 express-jwt 미들웨어를 사용하여 JWT를 쉽게 적용할 수 있습니다.

const express = require("express");
const jwt = require("express-jwt");
const app = express();

const jwtMiddleware = jwt({ secret: "your-secret-key", algorithms: ["HS256"] });

app.get("/protected", jwtMiddleware, (req, res) => {
  res.json({ message: "인증된 사용자입니다.", user: req.user });
});

사용자 권한 및 역할 기반 인증(Authorization)

JWT를 사용하여 사용자의 권한과 역할을 관리할 수 있습니다. 토큰의 페이로드에 사용자의 역할 정보를 포함시키고, 이를 기반으로 접근 제어를 구현할 수 있습니다.

// JWT 발급 시 역할 정보 포함
const token = jwt.sign({ userId: user.id, role: user.role }, SECRET_KEY);

// 역할 기반 접근 제어 미들웨어
function checkRole(role) {
  return (req, res, next) => {
    if (req.user.role !== role) {
      return res.status(403).json({ message: "권한이 없습니다." });
    }
    next();
  };
}

// 관리자만 접근 가능한 라우트
app.get("/admin", authenticateToken, checkRole("admin"), (req, res) => {
  res.json({ message: "관리자 페이지입니다." });
});

8. 프론트엔드에서 JWT 사용하기

프론트엔드에서 JWT를 안전하게 관리하고 사용하는 방법을 알아보겠습니다.

프론트엔드에서 JWT 관리 전략

JWT를 프론트엔드에서 저장하는 방법에는 여러 가지가 있습니다:

  1. 로컬 스토리지:
    • 장점: 간단하게 구현 가능
    • 단점: XSS 공격에 취약
  2. 세션 스토리지:
    • 장점: 브라우저 세션이 끝나면 자동으로 삭제됨
    • 단점: 여전히 XSS 공격에 취약
  3. HttpOnly 쿠키:
    • 장점: JavaScript를 통한 접근 불가능, XSS 공격으로부터 안전
    • 단점: CSRF 공격에 대한 추가적인 보호 필요

보안을 최우선으로 고려한다면 HttpOnly 쿠키를 사용하는 것이 좋습니다.

토큰을 HTTP 헤더에 포함하여 요청 보내기 (Axios, Fetch API)

  1. Axios를 사용한 예:
import axios from "axios";

const api = axios.create({
  baseURL: "https://api.example.com",
});

api.interceptors.request.use((config) => {
  const token = localStorage.getItem("token");
  if (token) {
    config.headers["Authorization"] = `Bearer ${token}`;
  }
  return config;
});

// API 요청
api
  .get("/protected-route")
  .then((response) => console.log(response.data))
  .catch((error) => console.error(error));
  1. Fetch API를 사용한 예:
function fetchWithToken(url, options = {}) {
  const token = localStorage.getItem("token");
  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${token}`,
    },
  });
}

// API 요청
fetchWithToken("https://api.example.com/protected-route")
  .then((response) => response.json())
  .then((data) => console.log(data))
  .catch((error) => console.error(error));

만료된 JWT 자동 갱신(Refresh Token 활용)

리프레시 토큰을 사용하여 만료된 액세스 토큰을 자동으로 갱신하는 방법은 다음과 같습니다:

import axios from "axios";

const api = axios.create({
  baseURL: "https://api.example.com",
});

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;
    if (error.response.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      try {
        const refreshToken = localStorage.getItem("refreshToken");
        const response = await axios.post("/refresh-token", { refreshToken });
        const { token } = response.data;
        localStorage.setItem("token", token);
        api.defaults.headers.common["Authorization"] = `Bearer ${token}`;
        return api(originalRequest);
      } catch (refreshError) {
        // 리프레시 토큰도 만료된 경우, 로그아웃 처리
        return Promise.reject(refreshError);
      }
    }
    return Promise.reject(error);
  }
);

JWT와 React, Vue 등 프레임워크 연동 예시

  • React에서의 JWT 사용 예:
import React, { useState, useEffect } from "react";
import axios from "axios";

function App() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    const token = localStorage.getItem("token");
    if (token) {
      axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
      fetchUser();
    }
  }, []);

  const fetchUser = async () => {
    try {
      const response = await axios.get("/api/user");
      setUser(response.data);
    } catch (error) {
      console.error("Failed to fetch user", error);
    }
  };

  const login = async (credentials) => {
    try {
      const response = await axios.post("/api/login", credentials);
      const { token } = response.data;
      localStorage.setItem("token", token);
      axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
      fetchUser();
    } catch (error) {
      console.error("Login failed", error);
    }
  };

  // ... 로그인 폼 및 사용자 정보 표시 JSX
}
  • Vue에서의 JWT 사용 예:
// store/auth.js
import axios from 'axios';

export default {
  state: {
    token: localStorage.getItem('token') || null,
    user: null,
  },
  mutations: {
    setToken(state, token) {
      state.token = token;
    },
    setUser(state, user) {
      state.user = user;
    },
  },
  actions: {
    async login({ commit }, credentials) {
      try {
        const response = await axios.post('/api/login', credentials);
        const { token } = response.data;
        localStorage.setItem('token', token);
        axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
        commit('setToken', token);
        await dispatch('fetchUser');
      } catch (error) {
        console.error('Login failed', error);
      }
    },
    async fetchUser({ commit }) {
      try {
        const response = await axios.get('/api/user');
        commit('setUser', response.data);
      } catch (error) {
        console.error('Failed to fetch user', error);
      }
    },
  },
};

// App.vue
<template>
  <div id="app">
    <!-- 로그인 폼 및 사용자 정보 표시 -->
  </div>
</template>

<script>
import { mapState, mapActions } from 'vuex';

export default {
  computed: {
    ...mapState(['user']),
  },
  methods: {
    ...mapActions(['login', 'fetchUser']),
  },
  created() {
    if (this.$store.state.token) {
      this.fetchUser();
    }
  },
};
</script>

9. JWT 사용 시 실무 팁 및 문제 해결

JWT를 실제 프로젝트에 적용할 때 고려해야 할 몇 가지 팁과 문제 해결 방법을 살펴보겠습니다.

로그아웃 처리 방법

JWT는 stateless하기 때문에 서버 측에서 직접 토큰을 무효화할 수 없습니다. 따라서 로그아웃 처리를 위해 다음과 같은 방법을 사용할 수 있습니다:

1. 클라이언트 측에서 토큰 삭제:

function logout() {
     localStorage.removeItem("token");
     // 필요한 경우 서버에 로그아웃 요청을 보내 서버 측 작업 수행
     // 예: 리프레시 토큰 무효화
     axios.post("/api/logout");
}

 

2. 짧은 만료 시간 설정: 토큰의 만료 시간을 짧게 설정하여 로그아웃 후 토큰이 빨리 무효화되도록 합니다.

3. 블랙리스트 사용: 서버 측에서 로그아웃된 토큰을 블랙리스트에 추가하여 관리합니다.

블랙리스트를 통한 토큰 무효화 방법

블랙리스트를 사용하여 토큰을 무효화하는 방법은 다음과 같습니다:

const redis = require("redis");
const client = redis.createClient();

// 토큰 검증 미들웨어에 블랙리스트 확인 로직 추가
function authenticateToken(req, res, next) {
  const authHeader = req.headers["authorization"];
  const token = authHeader && authHeader.split(" ")[1];

  if (token == null) return res.sendStatus(401);

  client.get(`blacklist_${token}`, (err, data) => {
    if (err || data) return res.sendStatus(403);

    jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
      if (err) return res.sendStatus(403);
      req.user = user;
      next();
    });
  });
}

// 미들웨어 사용 예시
app.get("/protected", authenticateToken, (req, res) => {
  res.json({ message: "보호된 리소스에 접근 성공", user: req.user });
});

// 로그아웃 시 토큰을 블랙리스트에 추가
app.post("/logout", (req, res) => {
  const token = req.headers.authorization.split(" ")[1];
  const decodedToken = jwt.decode(token);
  const expirationTime = decodedToken.exp - Math.floor(Date.now() / 1000);

  client.setex(`blacklist_${token}`, expirationTime, "true");
  res.json({ message: "로그아웃 성공" });
});

이 방법을 사용하면 로그아웃된 토큰을 효과적으로 무효화할 수 있습니다.

다중 디바이스 환경에서의 토큰 관리

다중 디바이스 환경에서 JWT를 관리하는 방법은 다음과 같습니다:

1. 디바이스별 고유 토큰 발급: 각 디바이스마다 고유한 토큰을 발급하여 관리합니다.

2. 토큰에 디바이스 정보 포함:

const token = jwt.sign(
     { userId: user.id, deviceId: req.body.deviceId },
     process.env.JWT_SECRET,
     { expiresIn: "1h" }
);

3. 사용자별 활성 토큰 목록 관리: 데이터베이스에 사용자별로 활성 상태인 토큰 목록을 저장하고 관리합니다.

4. 선택적 로그아웃: 특정 디바이스만 로그아웃하거나 모든 디바이스에서 로그아웃하는 옵션을 제공합니다.

10. 결론

  1. JWT는 JSON 객체를 안전하게 전송하기 위한 컴팩트하고 독립적인 방식을 정의하는 개방형 표준입니다.
  2. JWT는 헤더, 페이로드, 서명의 세 부분으로 구성됩니다.
  3. JWT는 stateless하며, 서버 측에서 별도의 저장소 없이 토큰 자체로 사용자를 인증할 수 있습니다.

JWT는 현대 웹 개발에서 널리 사용되는 강력한 인증 메커니즘입니다. 그러나 올바르게 구현하고 보안 모범 사례를 따르는 것이 중요합니다. JWT의 장단점을 이해하고, 프로젝트의 요구사항에 맞게 적절히 사용한다면 안전하고 효율적인 인증 시스템을 구축할 수 있습니다.