양파개발자 실바의 블로그

JWT 기반 인증을 사용하는 API 호출용 모듈들

JWT 기반 인증을 사용하는 API 호출용 모듈들입니다. axios를 사용하며, 인증이 필요한 API와 인증이 필요 없는 API를 구분하여 사용할 수 있습니다.


1. 비인증 API 호출용 모듈

인증이 필요 없는 API를 호출할 때 사용되는 기본 http client object입니다. axios 기반입니다.

import axios from 'axios'
const PureApiController = axios.create({
  baseURL: process.env.NEXT_PUBLIC_EXTERNAL_API_URL,
  timeout: 60000,
  withCredentials: false,
})

export default PureApiController

2. 인증 API 호출용 모듈

인증이 필요한 대부분의 API-v2 API 호출 시 사용되는 기본 http client object입니다.

/**
 * 인증이 필요한 대부분의 API-v2 API 호출할때 사용되는 기본 http client object 를 설정
 * 통지 서비스용 NotificationApiController 는 별도로 관리
 */
import axios from 'axios'
import { checkAuthTokens } from 'libraries/AuthProvider'

const ApiController = axios.create({
  baseURL: process.env.NEXT_PUBLIC_EXTERNAL_API_URL,
  timeout: 60000,
  withCredentials: false,
})

ApiController.getErrorMessage = (baseURL, error) => {
  const defaultErrorMessage = '오류가 발생했습니다. 관리자에게 문의하세요.'
  const errorMessage = error.response?.data?.message || defaultErrorMessage

  if (errorMessage == defaultErrorMessage) {
    console.error(`API failed: ${baseURL}`)
    console.error(error)
  } else {
    console.error(`API failed: ${baseURL} - ${errorMessage}`)
  }

  return errorMessage
}

ApiController.interceptors.request.use(async (config) => {
  if (typeof window === 'undefined') return config

  // CSR 페이지로부터의 접근일때
  let accessToken = window.__NEXT_DATA__.props?.accessToken
  let refreshToken = window.__NEXT_DATA__.props?.refreshToken
  if (!refreshToken) return config

  // 이미 로그인한 상황일 경우
  try {
    const checkTokenResult = await checkAuthTokens(accessToken, refreshToken)
    if (!checkTokenResult.isAuthorized) {
      throw Error('ApiController - 인증 오류')
    }

    if (checkTokenResult.isRenewed) {
      accessToken = checkTokenResult.newAccessToken
      window.__NEXT_DATA__.props.accessToken = checkTokenResult.newAccessToken
      window.__NEXT_DATA__.props.refreshToken = checkTokenResult.newRefreshToken
    }
  } catch (error) {
    window.location.href = '/auth/login'
    return Promise.reject(error)
  }

  config.headers.Authorization = `Baro ${accessToken}`
  return config
})

ApiController.interceptors.response.use(
  (res) => res,
  function (error) {
    const isBrowser = typeof window != 'undefined'

    if (isBrowser) {
      if (error.response?.status == 401) {
        window.location.href = '/auth/login'
      }
    }

    return Promise.reject(error)
  }
)

export default ApiController

3. JWT 인증의 토큰 발급 및 만료 전 재발급 처리용 모듈

JWT 토큰의 발급 및 만료 전 재발급을 처리하는 모듈입니다.

import dayjs from 'dayjs'
import AuthService from 'services/AuthService'
import ApiController from 'libraries/ApiController'
import { createContext, useState, useContext } from 'react'

const AuthContext = createContext({
  user: {},
  accessToken: null,
  refreshToken: null,
})

const AuthProvider = ({ children, session, accessToken, refreshToken }) => {
  const [_accessToken] = useState(accessToken)
  const [_refreshToken] = useState(refreshToken)
  const [user, setUser] = useState(session?.user)
  const [pages] = useState(session?.pages)

  return (
    <AuthContext.Provider
      value={
        accessToken: _accessToken,
        refreshToken: _refreshToken,
        session: {
          user: user,
          setUser: setUser,
          pages: pages,
        },
      }
    >
      {children}
    </AuthContext.Provider>
  )
}

const useSession = () => useContext(AuthContext)

/**
 * 제공된 JWT 토큰에서 정보를 추출하여 토큰의 만료 시간 및 유효성을 확인합니다.
 *
 * @param {string} jwtToken - 검사할 JWT 토큰
 * @returns {object} 토큰의 만료 정보를 포함하는 객체를 반환합니다.
 * {
 *   maxAge: {number}, // 토큰의 남은 유효 시간(초)
 *   isExpired: {boolean}, // 토큰이 이미 만료되었는지 여부
 *   willExpireSoon: {boolean} // 토큰이 24시간 이내에 만료될 예정인지 여부
 * }
 */
const getTokenInfo = (jwtToken) => {
  const retData = {
    maxAge: 0,
    isExpired: true,
    willExpireSoon: false,
  }
  try {
    const now = dayjs()
    let payload = JSON.parse(
      Buffer.from(jwtToken.split('.')[1], 'base64').toString()
    )
    let expiresAt = dayjs.unix(payload.exp)

    retData.maxAge = payload.exp - dayjs().unix()
    retData.isExpired = expiresAt.isBefore(now)
    // 24시간 이내 만료될 예정일 경우 true
    retData.willExpireSoon = expiresAt.isBefore(now.add(1, 'day'))
  } catch (error) {
    // jwtToken 이 비어있는 경우
    console.log('Invalid JWT token')
  }
  return retData
}

/**
 * 주어진 액세스 토큰과 리프레시 토큰의 유효성을 검사하고, 필요에 따라 새 토큰을 발급받습니다.
 *
 * @param {string} accessToken - 현재 사용자의 액세스 토큰
 * @param {string} refreshToken - 현재 사용자의 리프레시 토큰
 * @returns {Promise<object>} 검증 결과를 담은 객체를 반환합니다.
 * {
 *   isAuthorized: {boolean}, // 토큰이 유효한지 여부
 *   isRenewed: {boolean}, // 토큰이 갱신되었는지 여부
 *   newAccessToken: {string | null}, // 갱신된 액세스 토큰, 갱신되지 않았다면 null
 *   newRefreshToken: {string | null} // 갱신된 리프레시 토큰, 갱신되지 않았다면 null
 * }
 */
const checkAuthTokens = async (accessToken, refreshToken) => {
  const retData = {
    isAuthorized: false,
    isRenewed: false,
    newAccessToken: null,
    newRefreshToken: null,
  }

  // accessToken 만료여부 확인
  const accessTokenInfo = getTokenInfo(accessToken)
  const refreshTokenInfo = getTokenInfo(refreshToken)
  const needToRefreshTokens =
    accessTokenInfo.isExpired || accessTokenInfo.willExpireSoon

  if (needToRefreshTokens) {
    // 토큰만료가 임박했을때도 미리 갱신처리
    if (!refreshTokenInfo.isExpired) {
      // 토큰 refresh
      try {
        const { access, refresh } = await AuthService.refreshTokens(
          refreshToken
        )
        retData.isAuthorized = true
        retData.isRenewed = true
        retData.newAccessToken = access
        retData.newRefreshToken = refresh
      } catch (error) {
        console.log(error)
        console.log('인증 토큰 갱신 실패')
      }
    } else {
      console.log('AuthProvider - All tokens are expired...')
    }
  } else {
    retData.isAuthorized = true
  }

  return retData
}

const redirectToLoginPage = async (app) => {
  const accessTokenName = process.env.NEXT_PUBLIC_ACCESS_TOKEN_NAME
  const refreshTokenName = process.env.NEXT_PUBLIC_REFRESH_TOKEN_NAME
  const secure =
    process.env.NEXT_PUBLIC_COOKIE_SECURE == 'true' ? ' Secure;' : ''

  app.ctx.res.setHeader('Set-Cookie', [
    `${accessTokenName}=deleted; path=/; max-age=0; httpOnly; ${secure}`,
    `${refreshTokenName}=deleted; path=/; max-age=0; httpOnly; ${secure}`,
  ])
  app.ctx.res.writeHead(302, {
    Location: '/auth/login',
  })
  app.ctx.res.end()
}

const authWrapper = async (app) => {
  const isBrowser = typeof window !== 'undefined'

  if (!isBrowser) {
    if (app.ctx.req.url == '/health') {
      return app
    }

    if (app.ctx.req.url.search(/\/auth\/(login|user)/) == -1) {
      const accessTokenName = process.env.NEXT_PUBLIC_ACCESS_TOKEN_NAME
      const refreshTokenName = process.env.NEXT_PUBLIC_REFRESH_TOKEN_NAME
      const secure =
        process.env.NEXT_PUBLIC_COOKIE_SECURE == 'true' ? ' Secure;' : ''

      if (
        app.ctx.req.cookies[accessTokenName] === undefined &&
        app.ctx.req.cookies[refreshTokenName] === undefined
      ) {
        console.log(
          '인증토큰 정보 없음: app.ctx.req.cookies=',
          app.ctx.req.cookies
        )
        await redirectToLoginPage(app)
        return app
      } else {
        // 인증 token 정보(2개) 발견, 세션 정보 불러오기 시작
        try {
          const headers = app.ctx.req.headers

          let accessToken = app.ctx.req.cookies[accessTokenName]
          let refreshToken = app.ctx.req.cookies[refreshTokenName]

          const checkTokenResult = await checkAuthTokens(
            accessToken,
            refreshToken
          )

          if (!checkTokenResult.isAuthorized) {
            await redirectToLoginPage(app)
            return app
          }

          if (checkTokenResult.isRenewed) {
            // 새로 발급받은 토큰들로 갈아끼운다.
            accessToken = checkTokenResult.newAccessToken
            refreshToken = checkTokenResult.newRefreshToken

            // 두 개의 쿠키를 설정
            const newAccessTokenInfo = getTokenInfo(accessToken)
            const newRefreshTokenInfo = getTokenInfo(refreshToken)
            app.ctx.res.setHeader('Set-Cookie', [
              `${accessTokenName}=${accessToken}; path=/; max-age=${newAccessTokenInfo.maxAge}; httpOnly; ${secure}`,
              `${refreshTokenName}=${refreshToken}; path=/; max-age=${newRefreshTokenInfo.maxAge}; httpOnly; ${secure}`,
            ])
          }

          delete headers['host']
          delete headers['connection']
          delete headers['content-length']

          ApiController.defaults.headers = {
            ...headers,
            Authorization: `Baro ${accessToken}`,
          }

          const user = await ApiController.get('/auth/user').then(
            (response) => response.data
          )

          if (!user.is_staff) {
            throw Error('접근 권한이 없습니다.')
          }

          const pages = await ApiController.get('/auth/access').then(
            (response) => response.data
          )

          app.ctx = {
            ...app.ctx,
            accessToken: accessToken,
            refreshToken: refreshToken,
            session: { user: user, pages: pages },
          }
        } catch (error) {
          console.error('인증 token 유효성 검사 실패:', error.stack)
          await redirectToLoginPage(app)
        }
      }
    }
  }

  return app
}

export { AuthProvider, useSession, authWrapper, getTokenInfo, checkAuthTokens }

4. 프론트 로그인 API: login.js

프론트엔드에서 로그인을 처리하는 API 엔드포인트입니다.

import AuthService from 'services/AuthService'
import { getTokenInfo } from 'libraries/AuthProvider'

export const config = {
  api: {
    bodyParser: true,
  }
}

export default async (req, res) => {
  try {
    const { access, refresh } = await AuthService.getToken(req.body)
    const accessTokenName = process.env.NEXT_PUBLIC_ACCESS_TOKEN_NAME
    const refreshTokenName = process.env.NEXT_PUBLIC_REFRESH_TOKEN_NAME

    const accessTokenInfo = getTokenInfo(access)
    const refreshTokenInfo = getTokenInfo(refresh)
    const secure =
      process.env.NEXT_PUBLIC_COOKIE_SECURE == 'true' ? ' Secure;' : ''

    res.setHeader('Set-Cookie', [
      `${accessTokenName}=${access}; path=/; max-age=${accessTokenInfo.maxAge}; httpOnly; ${secure}`,
      `${refreshTokenName}=${refresh}; path=/; max-age=${refreshTokenInfo.maxAge}; httpOnly; ${secure}`,
    ])

    res.status(200).json({ access: access, refresh: refresh })
  } catch (error) {
    console.error(error)
    res.status(400).json({ message: '로그인 실패' })
  }
}

5. Service: API Controller를 사용하여 직접 endpoint 호출

API Controller를 사용하여 직접 endpoint를 호출하는 Service 모듈 예제입니다.

import ApiController from 'libraries/ApiController'

export default {
  baseURL: '/inventories',
  getErrorMessage(error) {
    return ApiController.getErrorMessage(this.baseURL, error)
  },
  async getList(params) {
    return await ApiController.get(this.baseURL, {
      params: params,
    }).then((response) => response.data)
  },
  async getDetail(id) {
    return await ApiController.get(`${this.baseURL}/${id}`).then(
      (response) => response.data
    )
  },
  async create(requestBody) {
    return await ApiController.post(this.baseURL, requestBody).then(
      (response) => response.data
    )
  },
  async update(id, requestBody) {
    return await ApiController.put(`${this.baseURL}/${id}`, requestBody).then(
      (response) => response.data
    )
  },
  async partialUpdate(id, requestBody) {
    return await ApiController.patch(`${this.baseURL}/${id}`, requestBody).then(
      (response) => response.data
    )
  },
  async bulkUpdate(id, requestBody) {
    return await ApiController.put(
      `${this.baseURL}/bulk-update`,
      requestBody
    ).then((response) => response.data)
  },
}