HTTP 프로토콜의 특성

HTTP 프로토콜은 stateless(비상태성) 한 특성을 갖는다.

서버는 클라이언트의 상태를 저장하지 않으며, 직전에 진행한 통신에 관해 기억하지 않는다.

Untitled

따라서 HTTP는 요청한 클라이언트가 이전에 어떠한 인증 과정 (로그인 등을 진행하였는가?) 을 거쳤는지 알 수가 없다.

그렇다고 매 순간 로그인이 필요한 요청 호출에 유저에게 로그인을 해달라고 요구하는 것은 UX를 전혀 고려하지 않는 방식이다.

웹 어플리케이션에서 위의 문제점을 해결하기 위해 세션 기반 인증과 토큰 기반 인증 이라는 기술을 통해 해결한다.

이 글은 토큰 기반 인증 을 A to Z 까지 프론트엔드백엔드 를 동시에 구현해보며 flow와 구현 방법을 이해해 보는 것을 회고한 글이다.

토큰 기반 인증

  • 토큰 기반 인증은 인증 정보를 클라이언트가 직접 가지고 있다. (이때 인증 정보는 토큰의 형태로 브라우저의 LocalStorage 및 Cookie 에 저장한다)
  • 토큰의 종류에 따라 다르겠지만, 대표적인 토큰인 JWT의 경우 디지털 서명이 존재하므로 토큰이 위변조 뒤었는지 서버 측에서 확인할 수 있다.
  • 토큰 기반 인증에서는 사용자가 가지고 있는 토큰을 HTTP의 Authorization 헤더에 실어 보낸다.
  • 해당 헤더를 수신한 서버는 토큰이 위변조 되었거나, 만료 시각이 지나지 않은지 확인한 이후 토큰에 담겨있는 사용자 인증 정보를 확인해 사용자를 인가한다.

구현 시작 전에

어떻게 구현할까?

일단 필자는 프론트엔드와 백엔드를 같이 구현하며 진행할 것이다.

인증과 인가에 관련된 기능이니 로그인과 회원가입 그리고 토큰 만료 시 리프레시 등을 구현할 생각이다.

필요한 재료를 생각해본다면 간단한 DB도 설계해야 한다.

스터디용 토이 프로젝트니까 디자인은 신경쓰지 않고 기능에만 집중해보자.

진행 전 기술 스펙 명세

Server

서버는 로컬에서 진행해도 되지만 놀고 있는 EC2 인스턴스가 있기 때문에 이번 기회에 조금이라도 일을 시켜볼 생각이다.

Untitled

Database

설치해서 간단하게 사용 가능한 MySQL 을 EC2에 설치해서 사용하기로 했다.

Front-end & Back-end

놀고 있는 EC2에 만들었던 Next JS 프로젝트가 있다.

테스트용 페이지랑 API를 구현하느라 만들었는데 놀고 있으니까 이 친구를 활용해보겠다.

덤으로 Next API를 사용하면 API 서버까지 구현할 필요 없으니 초기 세팅이 줄어서 기분이 좋아진다.

그리고 타입스크립트가 없으면 살 수 없는 몸이 되어서 타입스크립트도 사용하겠다.

Package.json

{
  "name": "my-next-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@tanstack/react-query": "^5.22.2",
    "crypto": "^1.0.1",
    "jsonwebtoken": "^9.0.2",
    "mysql": "^2.18.1",
    "mysql2": "^3.9.1",
    "next": "14.0.4",
    "nodemailer": "^6.9.9",
    "react": "^18",
    "react-dom": "^18"
  },
  "devDependencies": {
    "@types/jsonwebtoken": "^9.0.5",
    "@types/mysql": "^2.15.25",
    "@types/node": "^20",
    "@types/nodemailer": "^6.4.14",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "eslint": "^8",
    "eslint-config-next": "14.0.4",
    "typescript": "^5"
  }
}

간단한 DB 설계

ERD 설계해서 공유하거나 할 때 편한 사이트를 이용해보았다.

나도 지인한테 추천을 받았으니 사용해보시고 입맛에 맞으면 써보는거 추천 드린다.

https://dbdiagram.io/

Untitled

간단하게 만들어본 ERD이다.

설명을 조금 해보자면 users 테이블 외에는 현재 글과는 무관한 내용이므로 무시해도 상관없다.

users 테이블 내부에 저장할 유저 정보들과 토큰 구현 후에 저장해 줄 refresh_token 필드를 만들었다.

토큰 기반 인증 구현 플로우

Untitled

이미지 출처 : https://www.bezkoder.com/jwt-refresh-token-node-js/

해당 이미지가 토큰 기반 인증 플로우를 정말 잘 설명한 이미지라 생각해서 첨부해보았다.

하나하나 번호로 풀이 해보자면 다음과 같다.

[유저가 로그인 할 경우 - Create Token]

  1. Client 에서 유저가 로그인 요청을 할 경우 API이다.
  2. Server는 Client에서 전달 받은 정보를 가지고 JWT 토큰을 생성한다.
  3. Server는 생성된 JWT 토큰과 Client에 전달할 유저 정보를 담아서 Client에 전달한다.

[토큰 기간이 만료될 경우 - Expired Token]

  1. Client는 인증이 필요한 API Request 마다 HTTP Header에 AccessToken을 담아서 요청하게 된다.
  2. Server는 Client가 전달한 토큰을 항상 만료되었는지 확인하는 로직을 거치고, 토큰 기간이 만료되었다면 Client에 Token이 만료되었음을 알리는 Response를 전달한다.
  3. Client는 Server로 부터 Token이 만료되었음을 전달 받는다.

[토큰 기간 만료 이후 토큰 Refresh]

  1. 6번에서 전달 받은 Reponse 정보를 토대로 Client는 AccessToken이 만료되었음을 알게 되고 RefreshToken 을 Server에 전달하면서 새로운 AccessToken을 발급해달라는 요청을 하게 된다.
  2. Server는 전달 받은 RefreshToken 을 검증하는 절차를 거친다. (토큰 기간이 만료되었는가? 혹은 토큰이 위변조 되지는 않았는가?) 그 후 서버는 새로운 AccessToken 을 생성하여 Client에게 전달하게 된다.
  3. Client는 수신 받은 새로운 AccessToken과 RefreshToken 정보를 다시 저장한다.

로그인 구현 시작 ( 토큰 기반 인증 플로우 1 ~ 3 번)

JWT 생성 및 검증 모듈 구현 (Back-end)

File Name : jsonwebtoken.ts

  • fs 모듈을 사용해서 생성해둔 비밀키와 공개키를 불러오고 jsonwebtoken 모듈을 사용해서 토큰을 만들거나 검증한다.
  • RefreshToken의 경우 보통 AccessToken보다 생명 주기가 길고, AccessToken은 생명 주기가 짧다. 이 부분은 토큰이 탈취 당했을 시 보안에 관계되는 부분이다.
import fs from 'fs';

export const createAccessToken = (userId: string) => {
    const privateKey = fs.readFileSync('private.key', 'utf-8');
    const jwt = require('jsonwebtoken');
    const accessToken = jwt.sign({ userId: userId }, privateKey, {
        algorithm: 'RS256',
        expiresIn: '1h',
        // expiresIn: '3000', // test (3000ms)
    });

    return accessToken;
}

export const verifyAccessToken = (accessToken: string) => {
    const publicKey = fs.readFileSync('public.key', 'utf-8');
    const jwt = require('jsonwebtoken');
    try {
        const verified = jwt.verify(accessToken, publicKey)
        return verified;
    } catch (error) {
        throw error
    }
}

export const createRefreshToken = (userId: string) => {
    const privateKey = fs.readFileSync('private.key', 'utf-8');
    const jwt = require('jsonwebtoken');
    const refreshToken = jwt.sign({ userId: userId}, privateKey, {
        algorithm: 'RS256',
        expiresIn: '7d',
    });

    return refreshToken;
}

export const verifyRefreshToken = (refreshToken: string) => {
    const publicKey = fs.readFileSync('public.key', 'utf-8');
    const jwt = require('jsonwebtoken');
    try {
        const verified = jwt.verify(refreshToken, publicKey)
        return verified;
    } catch (error) {
        throw error
    }
}

로그인 API 구현 (Back-end)

File Name : loginUser.ts

  • NextApi는 정말 편하다 굳이 API 서버를 만들 필요 없으니 말이다.
  • Next api의 endpoint는 api 폴더 아래에서 생성한 파일 이름과 디렉토리 이름에 영향을 받으므로 적당하게 생성하길 바란다. (필자는 api/loginUser.ts 경로에 생성)
  • API에 관한 설명
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next'
import { handleMySQLQuery } from '@/db/dbConn';
import { createAccessToken, createRefreshToken } from '@/utils/jsonwebtoken';
type Data = {
    status: string,
    code: number,
    message: string,
    result?: {
        accessToken : string,
        refreshToken : string,
        userInfo : {
          userId: string,
          username: string,
          role: string,
          create_at: string,
        }
    }
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  try {
    let findUserInfoResult = await handleMySQLQuery({
      sqlStr: `SELECT * FROM TestDB.users WHERE user_id='${req.body.userId}';`
    });
    
    let userData = {
      userId: '',
      username: '',
      role: '',
      createAt: '',
    }

    if(findUserInfoResult && Array.isArray(findUserInfoResult) && findUserInfoResult.length !== 0){
      if(findUserInfoResult[0].password !== req.body.password){
        res.status(422).json({
          status: 'Unprocessable Content',
          code: 422,
          message: 'Password Wrong'
        });
      }

      userData.userId = findUserInfoResult[0].user_id;
      userData.username = findUserInfoResult[0].username;
      userData.role = findUserInfoResult[0].role;
      userData.createAt = findUserInfoResult[0].create_at;
      
    } else {
      res.status(401).json({
        status: 'Unauthorized',
        code: 401,
        message: 'UserId is not defined'
      });
    }

    const accessToken = createAccessToken(req.body.userId);
    const refreshToken = createRefreshToken(req.body.userId);

    let updateRefreshTokenResult = await handleMySQLQuery({
      sqlStr: `UPDATE TestDB.users SET refresh_token='${refreshToken}' WHERE user_id='${req.body.userId}'`
    })

    res.status(200).json({
      status: 'Success',
      code: 200,
      message: 'Login Success',
      result: {
        accessToken,
        refreshToken,
        userInfo:{
          userId: userData.userId,
          username: userData.username,
          role: userData.role,
          create_at: userData.createAt,
        }
      }
    });
  } catch (error) {
    console.error(error)

    res.status(500).json({
      status: 'Failed',
      code: 500,
      message: 'Server Error'
    });
  }
}

로그인 기능 구현 (Front-end)

File Name : LoginUserForm.tsx

  • 간단한 유저의 로그인 폼 컴포넌트를 만들었다.
  • React Query의 useMutation을 사용해 API를 사용하는 방식으로 코드를 만들어보았다.
  • 위에서 생성한 API를 토대로 프론트엔드 코드를 구현해보자
'use client';
import React, { useState } from "react";
import styles from './LoginUserForm.module.css';
import { useMutation } from "@tanstack/react-query";
import postLoginUser from "@/queries/postLoginUser";

const ACCESS_TOKEN_STORAGE_KEY = 'AccessToken';
const REFRESH_TOKEN_STORAGE_KEY = 'RefreshToken';

function LoginUserForm(){
    const [idVal, setIdVal] = useState('');
    const [pwVal, setPwVal] = useState('');
    const {mutate: loginUser} = useMutation({
        mutationFn: postLoginUser,
        onError: (error) => {
            console.log(error)

            alert('로그인 실패');
        },
        onSuccess: (data) => {
            if(data.code === 200){
                const {accessToken, refreshToken} = data.result;

                setIdVal('');
                setPwVal('');
                
                localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, accessToken)
                localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, refreshToken)

                alert('로그인 완료');
            } else if(data.code === 401){
                setIdVal('');
                setPwVal('');
                alert('아이디가 존재하지 않음');
            } else if(data.code === 422){
                setPwVal('');
                alert('비밀번호가 틀림');
            } else {
                alert('로그인 실패');
            }
        }
    })

    const loginBtnClickHandler = (event: React.MouseEvent<HTMLButtonElement>) => {
        event.preventDefault();
        
        loginUser({
            userId: idVal,
            password: pwVal,
        })
    }
    
    const logoutBtnClickHandler = (event: React.MouseEvent<HTMLButtonElement>) => {
        event.preventDefault();

        localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY)
        localStorage.removeItem(REFRESH_TOKEN_STORAGE_KEY)

        alert('로그아웃 완료');
    }

    return(
        <div className={styles.login_user_form_root}>
            <input 
                onChange={(event: React.ChangeEvent<HTMLInputElement>) => {setIdVal(event.target.value)}} 
                placeholder="아이디"  
                value={idVal}/>
            <input 
                onChange={(event: React.ChangeEvent<HTMLInputElement>) => {setPwVal(event.target.value)}} 
                placeholder="비밀번호" 
                value={pwVal}/>
            <button onClick={loginBtnClickHandler}>로그인</button>
            <button onClick={logoutBtnClickHandler}>로그아웃</button>
        </div>
    )
}

export default LoginUserForm

File Name : postLoginUser.ts

  • Data Fetching에 사용되는 함수이다.
  • useMutation의 Fetch 함수 역할을 할 것이다.
  • 기본 fetch api를 사용하였다.
  • api의 end point는 위에 NextAPI 경로를 사용하면된다. (아주 편하다)

type Tparams= {
    userId: string,
    password: string,
}

async function postLoginUser({userId, password}: Tparams){
    let data = {
        userId: userId,
        password: password,
    }

    const response = await fetch(`/api/loginUser`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify(data)
    });

    return response.json();
}

export default postLoginUser;

이렇게 Front-end와 Back-end에서 간단한 토큰 기반 인증 로그인 구현이 끝났다. 위에서 설명한 토큰 기반 인증 플로우의 1 ~ 3 까지를 구현한 것이다.

박수 👏


AccessToken의 만료 ( 토큰 기반 인증 플로우 4 ~ 9 번)

토큰 체크 및 새로운 토큰 발급 API 구현 (Back-end)

File Name : tokenCheck.ts

  • jsonwebtoken.ts 파일에 만들어둔 함수 중 verifyAccessToken 함수를 사용해보겠다.
  • 이 API는 전달 받은 AccessToken이 유효한 상태인지 판단하고 해당 결과를 Client에게 전해준다.
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next'
import { verifyAccessToken } from '@/utils/jsonwebtoken';
import jwt from 'jsonwebtoken';
type Data = {
    status: string,
    code: number,
    message: string,
    result?: {
        accessToken : string,
        refreshToken : string,
    }
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Data>
) {
  try {
    let verifyResult;
    if(req.headers.authorization){
        const token = req.headers.authorization.split(' ')[1];
        verifyResult = verifyAccessToken(token);
    } else {
      res.status(401).json({
        status: 'Unauthorized',
        code: 401,
        message: 'jwt is not defined'
      });
    }

    res.status(200).json({
      status: 'Success',
      code: 200,
      message: 'jwt is fresh'
    });

  } catch (error) {
    console.error(error)

    if(error instanceof jwt.TokenExpiredError){
        res.status(401).json({
          status: 'Unauthorized',
          code: 401,
          message: 'jwt expired'
        });
    }

    if(error instanceof jwt.JsonWebTokenError){
      res.status(401).json({
        status: 'Unauthorized',
        code: 401,
        message: 'jwt is not valid'
      });
    }

    res.status(500).json({
      status: 'Failed',
      code: 500,
      message: 'Server Error'
    });
  }
}

File Name : refreshtoken.ts

  • Client 에게서 전달받은 Refresh Token을 토대로 AccessToken을 새로 발급해주는 API
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from 'next'
import { verifyRefreshToken, createAccessToken } from '@/utils/jsonwebtoken';
import { handleMySQLQuery } from '@/db/dbConn';
import jwt from 'jsonwebtoken';
type Tdata = {
    status: string,
    code: number,
    message: string,
    result?: {
        accessToken : string,
    }
}

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse<Tdata>
) {
  try {
    let refreshToken = req.body.refreshToken;
    let verifyResult;
    let newAccessToken;
    if(refreshToken){
        verifyResult = verifyRefreshToken(refreshToken);
        if(verifyResult && verifyResult.userId){
          let userInfo = await handleMySQLQuery({
            sqlStr: `SELECT refresh_token FROM TestDB.users WHERE user_id='${verifyResult.userId}'`
          })

          if(Array.isArray(userInfo) && userInfo[0].refresh_token === refreshToken){
            newAccessToken = createAccessToken(verifyResult.userId);
          } else {
            res.status(401).json({
              status: 'Unauthorized',
              code: 401,
              message: 'refreshtoken is not valid'
            });
          }
        }
    }
    if(newAccessToken){
      res.status(200).json({
        status: 'Success',
        code: 200,
        message: 'accessToken refreshed',
        result: {
          accessToken: newAccessToken,
        }
      });
    } else {
      throw new Error();
    }
  } catch (error) {
    console.error(error)
    if(error instanceof jwt.TokenExpiredError){
        res.status(401).json({
          status: 'Unauthorized',
          code: 401,
          message: 'refreshtoken expired'
        });
    }

    res.status(500).json({
      status: 'Failed',
      code: 500,
      message: 'Server Error'
    });
  }
}

토큰 검증을 위한 Custom Hook 구현 (Front-end)

File Name : useAuth.ts

  • AccessToken 체크를 요청하는 코드와 AccessToken 을 refresh 요청하는 코드를 React Hook 으로 구현하였다.
import React from 'react';
import { useMutation } from '@tanstack/react-query';
import getTokenCheck from '@/queries/getTokenCheck';
import postRefreshToken from '@/queries/postRefreshToken';

const ACCESS_TOKEN_STORAGE_KEY = 'AccessToken';
const REFRESH_TOKEN_STORAGE_KEY = 'RefreshToken';

type TresponseData = {
    code: number,
    message: string,
    result?: {
        accessToken: string
    },
    status: string,
}

function useAuth(){
    const {mutate: checkAccessToken} = useMutation({
        mutationFn: getTokenCheck,
        onSuccess: (data: TresponseData) => {
            console.log('data ', data);
            if(data.code === 401){
                // AccessToken이 만료되었으므로 RefreshToken으로 AccessToken 재발급 요청
                refreshAccessToken();
            }
        },
        onError: (error) => {
            console.log(error);
        }
    })

    const {mutate: reqPostRefreshToken} = useMutation({
        mutationFn: postRefreshToken,
        onSuccess: (data: TresponseData) => {
            console.log(data)
            if(data.code === 200 && data.result){
                localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, data.result.accessToken);
                console.log('refreshed access token');
            }

            if(data.code === 401){
                // Refresh Token이 만료되었으므로 다시 로그인 요청
                localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
                localStorage.removeItem(REFRESH_TOKEN_STORAGE_KEY);
                alert('Refresh Token expired, please login again');
            }
        },
        onError: (error) => {
            console.error(error);
        }
    })

    const validationAccessToken = () => {
        const accessToken = localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY);

        if(accessToken){
            checkAccessToken({accessToken: accessToken})
        }
    }

    const refreshAccessToken = () => {
        const refreshToken = localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY);

        if(refreshToken){
            reqPostRefreshToken({refreshToken})
        } else {
            localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
            localStorage.removeItem(REFRESH_TOKEN_STORAGE_KEY);
        }
    }

    return {
        validationAccessToken,
        refreshAccessToken
    }
}

export default useAuth;

File Name : getTokenCheck.ts

  • 토큰 검증을 요청하고 위에서 작성한 커스텀 훅에 사용되는 fetch 함수이다.

type Tparams = {
    accessToken: string,
}

async function getTokenCheck({accessToken}: Tparams){
    const response = await fetch(`/api/tokenCheck`, {
        method: 'GET',
        headers: {
            'Authorization': `Bearer ${accessToken}`,
            'Content-Type': 'application/json',
        }
    });

    return response.json();
}

export default getTokenCheck;

File Name : postRefreshToken.ts

  • 만료된 AccessToken을 새로 발급 요청하며 위에서 작성한 커스텀 훅에 사용되는 fetch 함수이다.

type Tprams = {
    refreshToken: string
}

async function postRefreshToken({refreshToken}: Tprams){
    let data = {
        refreshToken
    }

    const response = await fetch(`/api/refreshtoken`, {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify(data)
    });

    return response.json();
}

export default postRefreshToken;

이렇게 Front-end와 Back-end에서 AccessToken의 만료 체크와 만료 시 Refresh 해주는 구현까지 끝이 났다.

위에서 설명한 토큰 기반 인증 플로우의 4 ~ 9 까지를 구현한 것이다.

혹시 헷갈릴 수 있기 때문에 차근차근 풀어서 설명해보자면

  1. 인증이 필요한 API Request 시 useAuth 훅을 통해 validationAccessToken 함수를 타고 tokenCheck API 에 AccessToken 만료 체크를 >요청하게 된다.
  2. 만약 AccessToken이 만료되었다면 refreshtoken API 에 Refresh Token을 주며 AccessToken을 새로 발급해달라는 요청을 진행한다.
  3. 새로운 AccessToken이 발급되면 해당 토큰을 다시 Client에 저장하고 사용하면 된다.

인증 관련 부분은 항상 헷갈리고 어려운 부분이 많은 만큼 간단하게 나마 구현을 하며 정리해보았다.