웹 보안 - Express.js 기반의 백엔드에서 JWT를 사용하는 방법 (3)

이유승·2024년 12월 20일
0

웹 보안

목록 보기
7/7

1. JWT를 사용하는 인증 백엔드를 구현해보자.

백엔드 개괄

📦src
┣ 📂config
┃ ┗ 📜database.js
┣ 📂controllers
┃ ┗ 📜authController.js
┣ 📂middlewares
┃ ┗ 📜authMiddleware.js
┣ 📂routes
┃ ┗ 📜auth.js
┗ 📂services
┗ ┗ 📜authService.js

  • 이런 구조의 백엔드가 있다고 가정해보자.

  • 백엔드는 프론트엔드로부터 요청을 받으면.. 아래 순서로 기능이 동작하게 된다.

routes -> middlewares -> controllers -> services(config)

  • routes는 요청 URL에 따라서 어느 기능이 호출되야 하는지를 제어.

  • middlewares는 기능 호출 이전에 수행되어야 할 로직이 있다면 이를 처리.
    (여기에서는 JWT 검증)

  • controllers는 DB 접근에 필요한 요소들을 정리해서 services로 보낼 준비를 하고, services의 결과를 받아서 적절한 형태로 가공, 에러 처리 등을 수행.

  • services는 DB에 쿼리문을 전송하고, 반환값을 받아서 처리.



authMiddleware.js

const jwt = require('jsonwebtoken');
const { StatusCodes } = require('http-status-codes');
const authService = require('../services/authService');

exports.verifyToken = async (req, res, next) => {
    try {
        const token = req.cookies?.authToken || req.headers.authorization?.split(' ')[1];
        if (!token) {
            return res.status(StatusCodes.UNAUTHORIZED).json({ error: '인증 토큰이 필요합니다.' });
        }

        try {
            const decoded = jwt.verify(token, process.env.JWT_SECRET_KEY);
            req.userId = decoded.id; // 디코딩된 사용자 ID를 요청 객체에 추가
            return next();
        } catch (error) {
            if (error.name === 'TokenExpiredError') {
                // 리프레시 토큰 검증 및 재발급
                const refreshToken = req.body.refreshToken || req.cookies?.refreshToken;
                if (!refreshToken) {
                    return res.status(StatusCodes.UNAUTHORIZED).json({ error: '리프레시 토큰이 필요합니다.' });
                }

                try {
                    const newAccessToken = await authService.verifyRefreshTokenAndGenerateAccessToken(refreshToken);
                    res.cookie('authToken', newAccessToken, {
                        httpOnly: true,
                        secure: process.env.NODE_ENV === 'production',
                        maxAge: 15 * 60 * 1000, // 15분
                    });
                    req.userId = jwt.decode(newAccessToken).id;
                    return next();
                } catch (refreshError) {
                    return res.status(StatusCodes.UNAUTHORIZED).json({ error: '리프레시 토큰 검증 실패' });
                }
            } else {
                return res.status(StatusCodes.UNAUTHORIZED).json({ error: '유효하지 않은 인증 토큰입니다.' });
            }
        }
    } catch (generalError) {
        return res.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ error: '서버 에러가 발생했습니다.' });
    }
};

기능

  • API 요청 시 액세스 토큰의 유효성을 검사합니다.

동작 순서

  • 요청이 들어오면 미들웨어(verifyToken) 실행.

액세스 토큰 추출:

  • 쿠키 또는 Authorization 헤더에서 토큰을 가져옵니다.

토큰 검증:

  • jwt.verify로 토큰의 유효성을 확인.
  • 유효한 경우 req.userId에 디코딩된 사용자 ID를 추가.

만료된 경우(TokenExpiredError):

  • 리프레시 토큰 검증 및 새 액세스 토큰 발급을 시도.
  • 유효하지 않은 경우 요청 거부.



TokenExpiredError



authController.js + authService.js

exports.refreshToken = async (req, res) => {
    try {
        const { refreshToken } = req.body;

        if (!refreshToken) {
            return res.status(StatusCodes.BAD_REQUEST).json({ error: '리프레시 토큰이 필요합니다.' });
        }

        // 서비스 호출
        const newAccessToken = await authService.verifyRefreshTokenAndGenerateAccessToken(refreshToken);

        // 새로운 액세스 토큰을 쿠키에 설정
        res.cookie('authToken', newAccessToken, {
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
            maxAge: 15 * 60 * 1000, // 15분
        });

        return res.status(StatusCodes.OK).json({ accessToken: newAccessToken });
    } catch (error) {
        return res.status(StatusCodes.UNAUTHORIZED).json({ error: error.message });
    }
};

exports.verifyRefreshTokenAndGenerateAccessToken = async (refreshToken) => {
    try {
        // 리프레시 토큰 검증
        const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET_KEY);

        // 데이터베이스에서 리프레시 토큰 유효성 확인
        const query = 'SELECT * FROM users WHERE id = ? AND refresh_token = ?';
        const [rows] = await db.execute(query, [decoded.id, refreshToken]);

        if (rows.length === 0) {
            throw new Error('유효하지 않은 리프레시 토큰입니다.');
        }

        // 새로운 액세스 토큰 생성
        return jwt.sign({ id: decoded.id }, process.env.JWT_SECRET_KEY, { expiresIn: '15m' });
    } catch (error) {
        if (error.name === 'TokenExpiredError') {
            throw new Error('리프레시 토큰이 만료되었습니다. 다시 로그인하십시오.');
        }
        throw new Error('리프레시 토큰 검증 실패');
    }
};

기능

  • 만료된 액세스 토큰을 대체할 새 토큰을 발급합니다.

동작 순서

API를 통해 명시적 요청:

  • 클라이언트에서 /refresh-token 엔드포인트를 호출.
  • 요청 본문에서 리프레시 토큰을 전달.

컨트롤러(authController.refreshToken):

  • 리프레시 토큰이 있는지 확인.
  • authService.verifyRefreshTokenAndGenerateAccessToken 호출:
    - 리프레시 토큰의 유효성을 확인(jwt.verify).
    - 데이터베이스에서 리프레시 토큰의 유효성을 검증.
    - 유효하다면 새 액세스 토큰 생성.
    - 새 액세스 토큰을 응답으로 반환.

미들웨어를 통해 자동 처리:

  • 액세스 토큰 만료 시 미들웨어에서 동일한 로직으로 리프레시 토큰을 검증하고 새 액세스 토큰을 발급.
  • 사용자 요청이 끊기지 않도록 처리.



2. 주요 동작 원리

  • 이전 포스팅에 설명했던 내용이지만..

  • 액세스 토큰: 사용자 인증 정보를 담은 짧은 만료 시간의 JWT. 빠른 검증과 요청 보호를 위해 사용.

  • 리프레시 토큰: 더 긴 만료 시간을 가지며, 만료된 액세스 토큰을 갱신하기 위해 사용.



동작 순서 요약

1. 사용자가 로그인 → 액세스 토큰과 리프레시 토큰 생성.

V

2. 클라이언트는 요청마다 액세스 토큰을 전송.

V

3. 서버:

3-1. 액세스 토큰 검증 및 사용자 인증.

3-2. 만료된 경우 리프레시 토큰을 검증하고 새 액세스 토큰 발급.

V

4. 클라이언트는 새 액세스 토큰으로 작업을 이어감.








profile
프론트엔드 개발자를 준비하고 있습니다.

0개의 댓글