accessToken을 localStorage에 저장하지 말라고?

허재원·2022년 11월 24일
3

글 작성 계기

이전에 쇼핑몰 프로젝트를 한 경험이 있다. 이 때 백엔드 분이 jwt 방식으로 accessToken은 json으로 보내주셨는데 이 처리를 어떻게 해야 할지 모르겠어서 그냥 localStorage에 저장했다. 하지만 이러한 방식은 XSS 취약점을 통해 그 안에 담긴 값을 불러오거나, 불러온 값을 이용해 API 콜을 위조할 수 있어 보안에 취약하다.

공부한 레포지토리

그럼 어떻게 해야하는데?

refreshTokensecure, httpOnly 쿠키로 저장해 JavaScript 내에서 접근이 불가능도록 만들어 XXS 취약점 공격을 방어하고, accessToken을 받아오는 방식으로 accessToken을 스크립트에 삽입할 수 없어 CSRF 공격도 방어할 수 있다. 자세한 내용은 프론트에서 안전하게 로그인 처리하기 이 분이 되게 정리를 잘해주셔서 추천한다.
대신 나는 코드로 accessTokenrefreshToken에 대해 공부하려고 한다.

사용자 로그인 API를 만들어보자

const User = require("../model/User");
const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");

const handleLogin = async (req, res) => {
  const cookies = req.cookies;
  console.log(`cookie avaiable at login: ${JSON.stringify(cookies)}`);
  const { user, pwd } = req.body;
  if (!user || !pwd) return res.status(400).json({ "message": "Username and password are required. "});

  const foundUser = await User.findOne({ username: user }).exec();
  if (!foundUser) return res.sendStatus(401); // Unauthorized
  
  // 패스워드 매칭
  const match = await bcrypt.compare(pwd, foundUser.password);
  if (match) {
    const roles = Object.values(foundUser.roles).filter(Boolean);

    const accessToken = jwt.sign(
      {
        "UserInfo": {
          "username": foundUser.username,
          "roles": roles,
        }
      },
      process.env.ACCESS_TOKEN_SECRET,
      { expiresIn: "10s" }
    );
    
    const newRefreshToken = jwt.sign(
      { "username": foundUser.username },
      process.env.REFRESH_TOKEN_SECRET,
      { expiresIn: "1d" }
    );

    // Changed to let keyword
    let newRefreshTokenArray = !cookies?.jwt
      ? foundUser.refreshToken
      : foundUser.refreshToken.filter(rt => rt !== cookies.jwt);

    if (cookies?.jwt) {
      const refreshToken = cookies.jwt;
      const foundToken = await User.findOne({ refreshToken }).exec();

      // Detected refresh token reuse!
      if (!foundToken) {
        console.log("attempted refresh token resuse at login!");
        newRefreshToken = [];
      }

      res.clearCookie("jwt", { HttpOnly: true, SameSite: "None", secure: true });
    }

    foundUser.refreshToken = [...newRefreshTokenArray, newRefreshToken];
    const result = await foundUser.save();
    console.log(result);

    // refrshToken은 쿠키로
    res.cookie("jwt", newRefreshToken, { HttpOnly: true, secure: true, SameSite: "None", maxAge: 24 * 60 * 60 * 1000 });
    res.json({ roles, accessToken }); // accessToken은 json으로
  } else {
    res.sendStatus(401); // Unauthorized
  }
};

module.exports = { handleLogin };
  • 로그인 시 if (!cookie?.jwt) { ... }로 jwt 쿠키가 있는지 검사하는 이유
    - 사용자가 로그인해서 refreshToken을 한 번도 사용하지 않고 로그아웃하지 않은 경우
    • refreshToken을 도둑받은 경우
    • 이러한 경우에는 재사용된 refreshToken을 모두 제거해줄 필요가 있다.
  • 그리고 accessTokenexpiresIn: "10s"로 설정해서 사용자 로컬 변수에 저장하고 10초가 지나면 /refresh API를 통해서 쿠키에 저장된 refreshToken을 사용해서 다시 받아올 수 있도록 해야 한다.
  • refreshTokenexpiresIn: "1d"로 설정했다.
    • 이렇게 설정하면 사용자가 로그아웃을 자리를 비우더라도 하루가 지나면 token 검사가 필요한 요청을 하는 경우 invalid해지기 때문에 403을 받게 된다.

토큰 refresh API를 만들어 보자

const User = require("../model/User");
const jwt = require("jsonwebtoken");

const handleRefreshToken = async (req, res) => {
  const cookies = req.cookies;
  if (!cookies?.jwt) return res.sendStatus(401); // Unauthorized
  const refreshToken = cookies.jwt;
  res.clearCookie("jwt", { httpOnly: true, sameSite: "None", secure: true });

  const foundUser = await User.findOne({ refreshToken }).exec();
  if (!foundUser) {
    // 허가되지 않은 refreshToken을 해킹된 사용자로 하여금 요청되었으므로 사용자의 refreshToken을 비워줘야 한다.
    jwt.verify(
      refreshToken,
      process.env.REFRESH_TOKEN_SECRET,
      async (err, decoded) => {
        if (err) return res.sendStatus(403); // Forbidden
        console.log("attempted refresh token reuse!");
        const hackedUser = await User.findOne({ username: decoded.username }).exec();
        hackedUser.refreshToken = [];
        const result = await hackedUser.save();
        console.log(result);
      }
    )

    return res.sendStatus(403); // Forbidden
  }

  const newRefreshTokenArray = foundUser.refreshToken.filter(rt => rt !== refreshToken);

  // jwt evaluate하기
  jwt.verify(
    refreshToken,
    process.env.REFRESH_TOKEN_SECRET,
    async (err, decoded) => {
      if (err || foundUser.username !== decoded.username) return res.sendStatus(403); // Forbidden
      const roles = Object.values(foundUser.roles).filter(Boolean);
      const accessToken = jwt.sign(
        { 
          "UserInfo": {
            "username": decoded.username,
            "roles": roles,
          }
        },
        process.env.ACCESS_TOKEN_SECRET,
        { expiresIn: "30s" }
      );
      const newRefreshToken = jwt.sign(
        { "username": foundUser.username },
        process.env.REFRESH_TOKEN_SECRET,
        { expiresIn: "1d" }
      );
      // 현재 사용자DB에 refreshToken 저장
      foundUser.refreshToken = [...newRefreshTokenArray, newRefreshToken];
      await foundUser.save();

      // httpOnly, secure refreshTOken을 보내주자!
      res.cookie("jwt", newRefreshToken, { httpOnly: true, secure: true, sameSite: "None", maxAge: 24 * 60 * 60 * 1000 });
      res.json({ roles, accessToken });
    }
  );
}

module.exports = { handleRefreshToken };
  • accessToken이 10초이기 때문에 403을 받게 되면 이 API를 통해서 다시 accessToken을 받아와 다시 요청할 수 있도록 할 수 있다.

어디서 403을 응답하는데?

// middleware/verifyJWT.js
const jwt = require("jsonwebtoken");

const verifyJWT = (req, res, next) => {
  const authHeader = req.headers.authorization || req.headers.Authorization;
  if (!authHeader?.startsWith("Bearer ")) return res.sendStatus(401);

  const token = authHeader.split(' ')[1];
  jwt.verify(
    token,
    process.env.ACCESS_TOKEN_SECRET,
    (err, decoded) => {
      if (err) return res.sendStatus(403); // invalid token
      req.user = decoded.UserInfo.username;
      req.roles = decoded.UserInfo.roles;
      next();
    }
  );
}

module.exports = verifyJWT;
  • 여기서 사용자 요청 헤더에 Bearer ${accessToken}을 담아서 보내면 비교해서 token이 invalid하다면 403을 받게 된다 그리고 403을 받으면 위 refresh API를 통해서 다시 accessToken을 받아와 한 번 더 요청하도록 만들어야 한다. 이 뒤에 axios를 그렇게 만들것이다.

Login 페이지 만들기

import React, { useRef, useState, useEffect } from "react";
import useAuth from "../hooks/useAuth";
import { Link, useNavigate, useLocation } from "react-router-dom";

import axios from "../api/axios";
const LOGIN_URL = "/auth";

function Login() {
  const { setAuth } = useAuth();

  const navigate = useNavigate();
  const location = useLocation();
  console.log(location);

  // 원래 있던 페이지로 돌아가기
  const from = location.state?.from?.pathname || "/";

  const userRef = useRef();
  const errRef = useRef();

  const [user, setUser] = useState("");
  const [pwd, setPwd] = useState("");
  const [errMsg, setErrMsg] = useState("");

  useEffect(() => {
    userRef.current.focus();
  }, []);

  useEffect(() => {
    setErrMsg("");
  }, [user, pwd]);

  const handleSubmit = async (e) => {
    e.preventDefault();

    try {
      const response = await axios.post(
        LOGIN_URL,
        JSON.stringify({ user, pwd }),
        {
          headers: { "Content-Type": "application/json" },
          withCredentials: true,
        }
      );
      const accessToken = response?.data?.accessToken;
      const roles = response?.data?.roles;
      setAuth({ user, pwd, roles, accessToken });
      setUser("");
      setPwd("");
      navigate(from, { replace: true });
    } catch (err) {
      if (!err?.response) {
        setErrMsg("No Server Response");
      } else if (err.response?.status === 400) {
        setErrMsg("Missing Username or Password");
      } else if (err.response?.status === 401) {
        setErrMsg("Unauthorized");
      } else {
        setErrMsg("Login Failed");
      }
      errRef.current.focus();
    }
  };

  return (
    <section>
      <p
        ref={errRef}
        className={errMsg ? "errmsg" : "offscreen"}
        aria-live="assertive"
      >
        {errMsg}
      </p>
      <h1>Sign In</h1>
      <form onSubmit={handleSubmit}>
        <label htmlFor="username">Username:</label>
        <input
          type="text"
          id="username"
          ref={userRef}
          autoComplete="off"
          onChange={(e) => setUser(e.target.value)}
          value={user}
          required
        />

        <label htmlFor="password">Password:</label>
        <input
          type="password"
          id="password"
          onChange={(e) => setPwd(e.target.value)}
          value={pwd}
          required
        />
        <button>Sign In</button>
      </form>
      <p>
        Need an Account?
        <br />
        <span className="line">
          <Link to="/register">Sign Up</Link>
        </span>
      </p>
    </section>
  );
}

export default Login;
  • 여기 부분은 엄청 설명할 것은 없지만 여기서 로그인하면 위에서 봤던 /auth API를 통해서 accessToken은 로컬 변수에 저장하도록 만들었다.

axios는 어떻게 훅으로 만들었을까?

import axios from "axios";
const BASE_URL = "http://localhost:3500";

export default axios.create({
  baseURL: BASE_URL,
});

export const axiosPrivate = axios.create({
  baseURL: BASE_URL,
  headers: { "Content-Type": "application/json" },
  withCredentials: true,
});
// hooks/useAxiosPrivate.js
import { axiosPrivate } from "../api/axios";
import { useEffect } from "react";
import useRefreshToken from "./useRefreshToken";
import useAuth from "./useAuth";

const useAxiosPrivate = () => {
  const refresh = useRefreshToken();
  const { auth } = useAuth();

  useEffect(() => {
    const requestIntercept = axiosPrivate.interceptors.request.use(
      config => {
        if (!config.headers['Authorization']) {
          config.headers['Authorization'] = `Bearer ${auth?.accessToken}`;
        }

        return config;
      }, (error) => Promise.reject(error)
    );

    const responseIntercept = axiosPrivate.interceptors.response.use(
      response => response,
      async (error) => {
		// 위에서 설명했듯이 accessToken의 timespan이 짧기 때문에
		// accessToken이 만료되면 async error handler를 통해서 다시 accessToken을 받아올 수 있도록 하자
        const prevRequest = error?.config;
        if (error?.response.status === 403 && !prevRequest?.send) {
          prevRequest.sent = true;
          const newAcessToken = await refresh();

          prevRequest.headers = { ...prevRequest.headers };

          prevRequest.headers["Authorization"] = `Bearer ${newAcessToken}`;
          return axiosPrivate(prevRequest);
        }
        return Promise.reject(error);
      }
    );

    return () => {
      axiosPrivate.interceptors.request.eject(requestIntercept);
      axiosPrivate.interceptors.response.eject(responseIntercept);
    }
  }, [auth, refresh]);

  return axiosPrivate;
}

export default useAxiosPrivate;
  • axios를 useEffect를 사용해서 커스텀 훅을 만들 수 있는게 대박 신기...

axios에서 사용되는 useRefreshToken에 대해 살펴보기

import axios from "../api/axios";
import useAuth from "./useAuth";

function useRefreshToken() {
  const { setAuth } = useAuth();

  const refresh = async () => {
    const response = await axios.get("/refresh", {
      withCredentials: true,
    });
    setAuth((prev) => {
      return { ...prev, accessToken: response.data.accessToken };
    });
    return response.data.accessToken;
  };

  return refresh;
}

export default useRefreshToken;
  • refreshToken을 다시 받아올 수 있는 커스텀 훅을 만들었다.
  • /refresh에 요청해서 valid한 accessToken을 받아올 수 있다. 그리고 위에서 다시 살펴보면 cookie에 있는 refreshToken을 통해서 받아올 수 있는 로직을 확인할 수 있다.

로컬에 저장하면 새로고침 시 풀리는데?

걱정할 필요없다! 왜냐면 cookie에 refreshToken이 있기 때문이다!! ㅎㅎ하핳!

import { Outlet } from "react-router-dom";
import { useState, useEffect } from "react";
import useRefreshToken from "../hooks/useRefreshToken";
import useAuth from "../hooks/useAuth";

function PersistLogin() {
  const [isLoading, setIsLoading] = useState(true);
  const refresh = useRefreshToken();
  const { auth } = useAuth();

  useEffect(() => {
    let isMounted = true;
    
    const verifyRefreshToken = async () => {
      try {
        await refresh();
      } catch (err) {
        console.error(err);
      } finally {
        isMounted && setIsLoading(false);
      }

    }
    // avoids unwanted call to verifyRefreshToken
    !auth?.accessToken ? verifyRefreshToken() : setIsLoading(false);

    return () => isMounted = false;
  }, []);

  return (
    <>
      {isLoading
          ? <p>Loading...</p>
          : <Outlet />
      }
    </>
  )
}

export default PersistLogin;

새로고침하면 auth의 상태는 빈 객체({})가 되기 때문에 !auth?.accessToken이 true가 되어 verifyRefreshToken()을 실행해서 /refresh에 요청을 보내 auth를 현재 사용자로 저장할 수 있도록 만들었다. 만약 그냥 사이트 내 페이지 이동이라면 바로 setIsLoading(false)로 /refresh로 요청할 필요없이 바로 이 렌더링된다.

후기

너무 코드만 올린거 같은데,,, 개인적으로는 큰 공부가 되었따..ㅎㅎ 물론 개념으로 공부하는 것도 좋지만 뭔가 추상적이여서 코드로 공부해야지만 이해가 되기 때문에 백엔드 로직까지 공부해본 내 자신 대단해...💫

0개의 댓글