Member
엔티티 관련 기본 구현SecurityConfig
작성UserDetails
오늘은 리액트만 합니다.
저번 글에서 버튼을 눌러서 토큰을 갱신하는 데에는 성공했다. 오늘은 axios
의 인터셉터를 이용해 인증이 필요한 API 호출에 실패했을 때 자동으로 액세스 토큰을 갱신해보도록 하자.
지금은 액세스 토큰을 저장하기 위해 useState('')
를 이용했다. 이제는 Redux를 이용해 액세스 토큰을 전역으로 관리해보도록 하자.
npm install @reduxjs/toolkit react-redux
// redux/store.js
import { configureStore, createSlice } from "@reduxjs/toolkit";
const tokenSlice = createSlice({
name: 'accessToken',
initialState: { val: ''},
reducers: {
setAccessToken(state, action) {
state.val = action.payload
}
}
})
export const { setAccessToken } = tokenSlice.actions
export const store = configureStore({
reducer: {
accessToken: tokenSlice.reducer
},
})
Slice를 따로 만드는 게 좋겠지만 그냥 한 파일에 만들어줬다.
// index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { Provider } from 'react-redux';
import { store } from './redux/store';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
차근차근 알아보자.
import axios from "axios";
import { store } from "../../redux/store";
import { setAccessToken } from "../../redux/store";
const createAxiosInstance = (needAuthentication) => {
const instance = axios.create({
withCredentials: true,
baseURL: 'http://localhost:8080',
timeout: 10000,
});
//...
return instance;
}
export const request = createAxiosInstance(false)
export const authenticatedRequest = createAxiosInstance(true)
axios의 인스턴스를 만들어 반환한다. 액세스 토큰은 필요한 경우에만 보내는 것이 좋으므로, 인증이 필요한 api를 요청할 때와 그렇지 않은 경우 다른 인스턴스를 만들어 사용할 수 있도록 한다.
axios.create()
로 새로운 axios 객체를 만든다.withCredential: true
를 해줘야 쿠키를 주고 받을 수 있다.baseURL: ...
로 base url을 설정하면 간단하게 path만으로 요청을 보낼 수 있다. //요청 인터셉터 설정
instance.interceptors.request.use((req) => {
// 인증이 필요한 요청인 경우
if (needAuthentication) {
//redux 스토어에서 액세스 토큰의 값을 가져오자
const accessToken = store.getState().accessToken.val;
//요청 헤더에 담아주고
req.headers.Authorization = accessToken;
}
// 그대로 진행하자
return req;
})
//...
instance.interceptors.response.use(
// 성공한 경우
(res) => {
return res;
},
// 실패한 경우
async (err) => {
const { config, response } = err;
// 갱신 요청인데 실패한 경우, 인증 실패로 실패한 게 아닌 경우(401이 아닌 경우), 인증이 필요한 요청을 보내는 axios 인스턴스가 아닌 경우는 그대로 실패로 처리
if (config.url === "refresh" || response.status !== 401 || !needAuthentication) {
return Promise.reject(err);
}
// 갱신을 시도해보아야 하는 경우
return await reissueTokenAndRetry(err);
}
)
//...
아래에 나오는 new Promise
와 관련한 내용은 이 글을 참고하자
// ...
let retryCallbacks = [];//실패한 요청을 재시도하기 위한 콜백함수 배열
let isLocked = false;//갱신 요청을 한 번만 보내기 위한 락
const reissueTokenAndRetry = async (err) => {
try {
const { response } = err;
// 콜백함수를 만들어 배열에 집어넣는다. 새로 액세스 토큰을 받아오면 헤더에 넣어, 실패했던 요청들을 재시도할 것이다.
const retry = new Promise((resolve, reject) => {
retryCallbacks.push((accessToken) => {
try {
// 액세스 토큰을 헤더에 넣고
response.headers.authorization = accessToken;
// 재시도해본다.
resolve(instance(response.config));
} catch (err) {
reject(err);
}
});
});
// 락이 걸려있지 않은 경우
if (!isLocked){
//락을 걸고
isLocked = true;
//갱신 요청을 보낸다
await instance.get("refresh")
.then((res)=> {
//요청에 성공해 토큰을 받아왔다면
const reissuedToken = res.headers.authorization;
//저장하고
store.dispatch(setAccessToken(reissuedToken));
//이를 가지고 저장해놓은 콜백함수들을 모두 실행한다
retryCallbacks.forEach((callback) => callback(reissuedToken));
})
.finally(() => {
//갱신에 성공했다면 콜백함수들이 모두 실행됐을 것이므로 비워주고
//실패했다면 갱신이 불가능하므로 비워준다.
retryCallbacks= [];
//락도 풀어준다.
isLocked = false
})
}
return retry;
}
catch (err){
return Promise.reject(err);
}
}
// ...
인증이 필요한 여러 요청들이 모두 실패하는 경우를 확인하기 위해 테스트용 API도 몇 개 더 추가해줬다.
public class MemberController {
// ...
@GetMapping("/test2")
public ResponseEntity<String> authenticatedApi2() {
return new ResponseEntity<>("hello2", HttpStatusCode.valueOf(200));
}
@GetMapping("/test3")
public ResponseEntity<String> authenticatedApi3() {
return new ResponseEntity<>("hello3", HttpStatusCode.valueOf(200));
}
@GetMapping("/test4")
public ResponseEntity<String> authenticatedApi4() {
return new ResponseEntity<>("hello4", HttpStatusCode.valueOf(200));
}
프론트에서도 따로 분리해서 관리해주자.
// apis/api/user.js
import { request } from "../utils/axios";
export const register = async (body) => {
await request.post('member/register', body)
.then((res) => {
return res.data;
})
.catch((err) => {
console.log("register failed");
})
}
export const login = async (body) => {
await request.post('login', body)
.then((res) => {
store.dispatch(setAccessToken(res.headers.authorization));
return res.data;
})
.catch((err) => {
console.log("login failed");
})
}
// apis/api/test.js
import { authenticatedRequest } from "../utils/axios";
export const test = async () => {
await authenticatedRequest.get('member/test')
.then((res) => {
console.log("test1 success");
})
.catch((err) => {
console.log("test failed");
})
}
export const test2 = async () => {
await authenticatedRequest.get('member/test2')
.then((res) => {
console.log("test2 success");
return res.data;
})
.catch((err) => {
console.log("test2 failed");
})
}
// apis/api/test2.js
import { authenticatedRequest } from "../utils/axios";
export const test3 = async () => {
await authenticatedRequest.get('member/test3')
.then((res) => {
console.log("test3 success");
})
.catch((err) => {
console.log("test3 failed");
})
}
export const test4 = async () => {
await authenticatedRequest.get('member/test4')
.then((res) => {
console.log("test4 success");
})
.catch((err) => {
console.log("test4 failed");
})
}
훨씬 깔끔해졌다.
// component/SignupForm.js
import React, { useState } from 'react'
import { register, login } from '../apis/api/user';
import { test, test2 } from '../apis/api/test';
import { test3, test4 } from '../apis/api/test2';
function SignupForm() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
const body = {
username: username,
password: password
};
await register(body);
}
const handleLogin = async (e) => {
e.preventDefault();
const body = {
username: username,
password: password
};
await login(body);
}
const handleAuthenticatedApiButton = async (e) => {
e.preventDefault();
test();
test2();
test3();
test4();
}
// ...
}
export default SignupForm;
로그인하지 않고 누르면 실패한다.
로그인 후 api 버튼을 누르면 잘 동작한다.
jwt:
secret-key: 91sf8ha089v909asa9fhj09234whsdfjlag1asda903ihasf9as0219ha09shf09
access-token-expiration: 5000 #7200000
refresh-token-expiration: 86400000
테스트를 위해 액세스 토큰의 만료 기한도 5초로 줄여줘봤다. 5초 후 다시 api 버튼을 누르면
토큰이 만료되어 일단 한 번 실패하고, 자동으로 다시 토큰을 받아와서 재요청을 보낸다. 잘 동작한다.
새로고침을 눌러 저장된 액세스 토큰을 날려버리고 다시 api 버튼을 누르면
마찬가지로 실패한 후 다시 토큰을 받아와 재요청을 보낸다. 잘 동작한다.