[Spring Security] (13) Spring Security x React로 유저 인증 구현하기-7

Park Yeongseo·2024년 6월 26일
0

Spring Security

목록 보기
13/13
post-thumbnail
  1. 사용자 정보를 담을 Member 엔티티 관련 기본 구현
  2. SecurityConfig 작성
  3. 예제에서 사용할 간단한 프론트 페이지 구현
  4. 이메일 인증 기능 추가
  5. 사용자 인증을 위한 UserDetails
  6. JWT 생성, 검출, 발급, 검증 구현
  7. 액세스 토큰
  8. 리프레시 토큰
  9. 로그인 유지

오늘은 리액트만 합니다.

1. Introduction

저번 글에서 버튼을 눌러서 토큰을 갱신하는 데에는 성공했다. 오늘은 axios의 인터셉터를 이용해 인증이 필요한 API 호출에 실패했을 때 자동으로 액세스 토큰을 갱신해보도록 하자.

2. Access Token 관리

지금은 액세스 토큰을 저장하기 위해 useState('')를 이용했다. 이제는 Redux를 이용해 액세스 토큰을 전역으로 관리해보도록 하자.

Redux Toolkit 설치

 npm install @reduxjs/toolkit react-redux

Slice & Store 생성

// 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를 따로 만드는 게 좋겠지만 그냥 한 파일에 만들어줬다.

Store 사용을 위한 설정

// 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>
);

3. Axios 세팅

  1. 요청 인터셉터에서는 인증이 필요한 요청의 헤더에 리덕스 스토어에서 가져온 액세스 토큰을 담아줄 것이다.
  2. 응답 인터셉터에서는 액세스 토큰 인증 실패를 이유로 실패한 모든 요청을 배열에 넣는다.
    1. 새로운 토큰을 발급받지 않은 경우, 리프레시 요청으로 새로운 토큰을 받아온다.
    2. 새로운 토큰을 받아오고 나서는 배열에 들어있는 모든 요청들을 새로 받은 액세스 토큰으로 다시 요청한다.

차근차근 알아보자.

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);
        }
    }
	// ...

4. API 분리 및 테스트

인증이 필요한 여러 요청들이 모두 실패하는 경우를 확인하기 위해 테스트용 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 버튼을 누르면

마찬가지로 실패한 후 다시 토큰을 받아와 재요청을 보낸다. 잘 동작한다.

0개의 댓글