이 글에서 사용한 기술 스택 버전
서버
Spring boot 3.0.2
Spring Security 6.0.1
JJWT 0.9.1
클라이언트
React 18.2.0
axios 1.6.7
기존에는 로그인 후 발급받은 액세스 토큰을 로컬 스토리지에, 리프레시 토큰은 쿠키에 각각 저장해서 사용했다.
브라우저의 개발자 도구에서 바로 확인할 수 있으므로 개발상의 편의를 위해 택한 방법이었으나, 이번에 배포를 해보게 되면서 저장 방식을 변경해야 할 필요성을 느꼈다.
그래서 액세스 토큰은 리액트의 state로, 리프레시 토큰은 HTTPOnly 쿠키로 저장 방법을 각각 변경하기로 했다.
1. 리프레시 토큰
1. HTTPOnly 쿠키로 변경
먼저 서버에서 리프레시 토큰을 발급하여 응답 쿠키에 저장하는 부분을 변경한다.
1. 스프링 부트에서
다음은 쿠키를 관리하는 유틸리티 클래스 CookieUtil.java의 쿠키 생성 메서드이다.
CookieUtil.java (수정 전)
public class CookieUtil {
// 요청값(이름, 값, 만료기간)으로 쿠키 추가
public static void addCookie(HttpServletResponse response, String name, String value, int maxAge) {
Cookie cookie = new Cookie(name, value);
cookie.setPath("/"); // 경로 - 쿠키가 모든 경로에서 사용될 수 있음.
cookie.setMaxAge(maxAge);
response.addCookie(cookie);
}
}
이름, 값, 만료기간을 받아 응답에 쿠키를 추가하는 기능을 한다.
여기에 HttpOnly 옵션을 추가하기 위해 다음과 같이 변경했다.
CookieUtil.java (수정 후)
public class CookieUtil {
// 파라미터 추가 - boolean 타입, HTTPOnly 여부
public static void addCookie(HttpServletResponse response, String name, String value, int maxAge, boolean httpOnly) {
Cookie cookie = new Cookie(name, value);
cookie.setPath("/");
cookie.setMaxAge(maxAge);
cookie.setHttpOnly(httpOnly); // HTTPOnly 설정
response.addCookie(cookie);
}
}
함수 호출시 HTTPOnly 여부를 받아 설정한다.
리프레시 토큰을 쿠키에 저장하기 위해 addCookie를 호출할 때, httpOnly를 true로 보내주면 된다.
2. 액세스 토큰 재발급 API 변경
클라이언트에서 액세스 토큰을 재발급받기 위해 서버로 재발급 API를 호출하는 부분을 수정한다.
1. 리액트에서
다음은 리액트에서 액세스 토큰 재발급 API를 호출하는 부분이다.
액세스 토큰 재발급 요청 (수정 전)
async function getNewAccessToken() {
const res = await axios({
url: `${process.env.REACT_APP_API_ROOT}/api/token`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: JSON.stringify({
refreshToken: getCookie("refresh_token"),
}),
})
// ... 생략
}
// 쿠키를 가져오는 함수
function getCookie(key) {
var result = null;
var cookie = document.cookie.split(";");
cookie.some(function (item) {
item = item.replace(" ", "");
var dic = item.split("=");
if (key === dic[0]) {
result = dic[1];
return true;
}
});
return result;
}
기존에는 axios로 요청을 보낼 때 쿠키에서 리프레시 토큰을 가져와 json으로 data에 넣는다.
리프레시 토큰을 data로 넣지 않고, 대신 쿠키를 그대로 요청에 포함하도록 변경한다.
액세스 토큰 재발급 요청 (수정 후)
async function getNewAccessToken() {
const res = await axios({
url: `${process.env.REACT_APP_API_ROOT}/api/token`,
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: null, // data는 보내지 않음
withCredentials: true, // 대신 쿠키를 포함하여 요청
})
// ... 생략
}
// 쿠키를 가져오는 함수인 getCookie는 삭제
요청 형식을 변경했으니 서버측 코드도 변경해준다.
2. 스프링 부트에서
다음은 액세스 토큰 재발급 요청을 받는 부분이다.
TokenApiController.java (수정 전)
@RequiredArgsConstructor
@RestController
public class TokenApiController {
private final TokenService tokenService;
@PostMapping("/api/token")
public ResponseEntity<AccessTokenCreateResponse> createNewAccessToken(@RequestBody AccessTokenCreateRequest request) {
String newAccessToken = tokenService.createNewAccessToken(request.getRefreshToken());
return ResponseEntity.status(HttpStatus.CREATED)
.body(new AccessTokenCreateResponse(newAccessToken));
}
}
RequestBody로 리프레시 토큰을 받아 액세스 토큰을 재발급한다.
클라이언트에서 data 대신 쿠키를 함께 보내는 방법으로 변경했으므로 서버측에서도 요청의 쿠키에서 리프레시 토큰을 찾아 액세스 토큰을 재발급하도록 수정해준다.
TokenApiController.java (수정 후)
@RequiredArgsConstructor
@RestController
public class TokenApiController {
private final TokenService tokenService;
@PostMapping("/api/token")
public ResponseEntity<AccessTokenCreateResponse> createNewAccessToken(HttpServletRequest request) {
String refresh_token = CookieUtil.getCookie("refresh_token", request);
String newAccessToken = tokenService.createNewAccessToken(refresh_token);
return ResponseEntity.status(HttpStatus.CREATED)
.body(new AccessTokenCreateResponse(newAccessToken));
}
}
CookieUtil.java - getCookie 함수
public class CookieUtil {
public static String getCookie(String name, HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if(cookies == null) {
return null;
}
for(Cookie cookie : cookies) {
if(cookie.getName().equals(name)) {
return cookie.getValue();
}
}
return null;
}
}
@RequestBody로 데이터를 받는 부분은 제거하고, HttpServletRequest에서 "refresh_token" 쿠키의 값을 조회하여 액세스 토큰을 재발급하도록 변경했다.
2. 액세스 토큰
1. 액세스 토큰 저장 방법 변경
서버에서 액세스 토큰을 발급하여 응답해주는 방식은 유지하였으며, 클라이언트에서 액세스 토큰을 저장하는 방식을 수정했다.
아래는 모두 리액트에서의 변경사항이다.
로그인 성공시, 액세스 토큰 발급시 (수정 전)
const loginAPI = async () => {
const response = await axios({
// 요청 및 에러 처리 생략
});
// 요청 결과를 json 객체로 저장
const userdata = {
accessToken: response.data.accessToken,
nickname: response.data.nickname,
id: response.data.id,
role: response.data.role,
};
// 로컬 스토리지에 "userdata"라는 이름으로 저장
localStorage.setItem("userdata", JSON.stringify(userdata));
}
기존에는 요청 결과로 받은 액세스 토큰 및 로그인 유저 정보를 로컬 스토리지에 저장했다.
이를 애플리케이션 전역에서 상태관리할 수 있도록 저장방식을 변경한다.
먼저 사용자 정보를 저장할 userInfo의 상태관리를 위한 UserContext를 추가한다.
UserContext.js (신규 생성)
const UserContext = createContext();
export const useUser = () => useContext(UserContext);
export const UserProvider = ({ children }) => {
const [userInfo, setUserInfo] = useState({});
return (
<UserContext.Provider value={{ userInfo, setUserInfo }}>
{children}
</UserContext.Provider>
);
};
App.js
import { UserProvider } from "./UserContext";
function App() {
return (
<UserProvider>
// 기존 App 내용 (생략)
</UserProvider>
);
}
이렇게 하면 애플리케이션 전역에서 userInfo에 접근할 수 있다.
그리고 로그인, 액세스 토큰 재발급 후 정보를 저장할 때나, 저장된 사용자 정보를 꺼내서 쓸 때에는 useUser()를 이용해 userInfo와 setUserInfo를 받아와 사용한다.
로그인 성공시, 액세스 토큰 발급시 (수정 후)
const { userInfo, setUserInfo } = useUser(); // useUser로 userInfo와 setUserInfo 가져오기
const loginAPI = async () => {
const response = await axios({
// 요청 및 에러 처리 생략
});
// setUserInfo로 userInfo 갱신
setUserInfo({
nickname: response.data.nickname,
id: response.data.id,
role: response.data.role,
accessToken: response.data.accessToken,
});
}
2. 액세스 토큰 조회 방법 변경
인증 정보가 필요한 요청을 서버에 보낼 때 Header에 액세스토큰을 넣는 방법을 변경한다.
인증 정보 포함 요청 (수정 전)
// 로컬 스토리지에 저장된 액세스 토큰을 가져옴
const authorization = JSON.parse(localStorage.getItem("userdata")).accessToken;
const response = await axios({
url: `${process.env.REACT_APP_API_ROOT}` + url,
method: method,
headers: {
"Content-Type": "application/json",
Authorization: authorization,
},
data: data,
})
//... 생략
기존에는 로컬 스토리지에 저장된 userdata에서 액세스토큰을 가져와 header에 포함시켰다.
이를 userInfo에서 가져오도록 변경한다.
인증 정보 포함 요청 (수정 후)
const { userInfo, setUserInfo, logoutAPI } = useUser();
// userInfo에 저장된 액세스토큰 가져옴
const authorization = "Bearer " + userInfo.accessToken;
const axiosResponse = await axios({
url: `${process.env.REACT_APP_API_ROOT}` + url,
method: method,
headers: {
"Content-Type": "application/json",
Authorization: authorization,
},
data: data,
})
// ... 생략
'책먹는 악어' 카테고리의 다른 글
[AWS Elastic Beanstalk] 환경 상태 - Severe, Environment health has transitioned from Ok to Severe. 100.0 % of the requests are erroring with HTTP 4xx (0) | 2024.04.27 |
---|---|
[GitHub Actions][Spring Boot] CI 시도하기 + build 실패 트러블슈팅 (1) | 2024.04.24 |
[React] GitHub Pages에 배포하기 (0) | 2024.04.18 |
[IntelliJ] Port 8080 was already in use - 포트 사용 중인 프로세스가 없을 때 (0) | 2024.04.09 |
토큰 인증방식과 JWT (0) | 2024.03.21 |