이전에 쇼핑몰 프로젝트를 한 경험이 있다. 이 때 백엔드 분이 jwt 방식으로 accessToken은 json으로 보내주셨는데 이 처리를 어떻게 해야 할지 모르겠어서 그냥 localStorage에 저장했다. 하지만 이러한 방식은 XSS 취약점을 통해 그 안에 담긴 값을 불러오거나, 불러온 값을 이용해 API 콜을 위조할 수 있어 보안에 취약하다.
refreshToken
을 secure
, httpOnly
쿠키로 저장해 JavaScript 내에서 접근이 불가능도록 만들어 XXS 취약점 공격을 방어하고, accessToken
을 받아오는 방식으로 accessToken
을 스크립트에 삽입할 수 없어 CSRF 공격도 방어할 수 있다. 자세한 내용은 프론트에서 안전하게 로그인 처리하기 이 분이 되게 정리를 잘해주셔서 추천한다.
대신 나는 코드로 accessToken
과 refreshToken
에 대해 공부하려고 한다.
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 };
accessToken
은 expiresIn: "10s"
로 설정해서 사용자 로컬 변수에 저장하고 10초가 지나면 /refresh
API를 통해서 쿠키에 저장된 refreshToken
을 사용해서 다시 받아올 수 있도록 해야 한다.refreshToken
은 expiresIn: "1d"
로 설정했다.invalid
해지기 때문에 403
을 받게 된다.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 };
// 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를 그렇게 만들것이다.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
은 로컬 변수에 저장하도록 만들었다.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;
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;
/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
로 요청할 필요없이 바로 이 렌더링된다.
너무 코드만 올린거 같은데,,, 개인적으로는 큰 공부가 되었따..ㅎㅎ 물론 개념으로 공부하는 것도 좋지만 뭔가 추상적이여서 코드로 공부해야지만 이해가 되기 때문에 백엔드 로직까지 공부해본 내 자신 대단해...💫