본문 바로가기

programming/Web

JWT 로 로그인 인증 구현하기

로그인 JWT(accessToken, refreshToken)

 

플로우

  • websequencediagrams 스크립트
title JWT login process (evaluation-iic)

frontend->backend: login (/auth/access_token)
backend -> backend : 유저 확인 (access_token, refresh_token 발급)
backend->frontend: login 응답 (access_token, refresh_token)
frontend -> frontend : localStorage에 저장(access_token, refresh_token)
frontend-> backend: 데이터 요청 (ex) GET /evaluation/group) with access_token
backend -> backend : access_token 검증
alt if access_token valid
backend -> frontend : 데이터 응답
else else access_token invalid
backend -> frontend : 401 Error 응답
frontend -> backend : 토큰 재발급 요청(/auth/refresh_token) with refresh_token
backend -> backend : refresh_token 검증
alt if refresh_token valid
backend -> frontend : 토큰 재발급 응답(access_token, refresh_token)
frontend -> frontend : localStroage 저장(access_token, refresh_token)
else else refresh_token invalid
backend -> frontend : 401 Error 응답
frontend -> frontend : 로그아웃 처리 및 redirect to login 페이지

 

사전준비

  • msw 를 사용하여 401 응답을 가정
  • axios 로 비동기 요청 처리
    • axios instance 를 생성하여, interceptor 정의 하여 사용
    • src/axiosInstance.js
    import axios from 'axios';
    
    const axiosInstance = axios.create({
    	baseURL: 'https://myapp-dev-api.myapp.com',
    	headers: {
    		'Content-Type': 'application/json',
    	},
    });

 

  • 로그인 로직에서는 pure axios를 사용 → 로그인 요청에는 access_token을 체크하지 않기 때문
import axios from 'axios';

axios.defaults.baseURL = 'https://myapp-dev-api.myapp.com';

// login 시는 accessToken 체크 안함
export const postLogin = ({ id, password }) => {
	return axios.post('/auth/access_token', {
		id,
		password,
	});
};

 

  • 로그인 성공시 localStorage에 응답받은 토큰(access_token, refresh_token) 담기
const loginMutation = useMutation({
	mutationFn: postLogin,
	onSuccess: ({ data }) => {
		setUserInfoState({
			...data,
			userId: formData.id,
			accessLv: getAccessLv(data.scope),
		});
		localStorage.setItem('accessToken', data.access_token);
		localStorage.setItem('refreshToken', data.refresh_token);

		toast.success(`Logged in as ${formData.id}`, {
			theme: 'colored',
		});

		navigate('/main');
	},
	onError: () => {
		console.log('실패');
	},
});

 

토큰 플로우 테스트

1. 토큰 테스트를 위한 임시 테스트 버튼 생성

  • 버튼 온클릭 함수
    • 데이터 get 요청 보내는 함수
    • 로그인 이후 모든 request 에서는 axiosInstance 를 사용
const tokenTest = async () => {
		const result = await axiosInstance.get('/mydata/question/apply/');
		console.log(result);
	};

 

2. axiosInstance.js 의 baseURL 을 잠시 localhost:3000으로 변경

  • msw 사용하기 위해 변경
const axiosInstance = axios.create({
	// baseURL: 'https://myapp-dev-api.myapp.com',
	baseURL: 'http://localhost:3000',
	headers: {
		'Content-Type': 'application/json',
	},
});
  • axios 의 baseURL 도 localhost:3000으로 변경 → msw 사용하기 위해
axios.defaults.baseURL = '<http://localhost:3000>'
  • 현재는 request에 interceptor를 통해서 access_token 을 헤더에 담기 처리
axiosInstance.interceptors.request.use(
	function (config) {
		// 요청시 AccessToken 을 헤더에 담기
		const accessToken = localStorage.getItem('accessToken');

		if (accessToken) {
			config.headers.Authorization = `Bearer ${accessToken}`;
		} else {
			localStorage.removeItem('user');
			localStorage.removeItem('accessToken');
			localStorage.removeItem('refreshToken');
			window.location.href = '/login';
		}
		return config;
	},
	function (error) {
		// return request error
		return Promise.reject(error);
	}
);

 

3. msw handlers.js 에 작업

rest.get('/mydata/question/apply/', async (req, res, ctx) => {
	return res(
		ctx.status(200),
		ctx.json({
			result: false,
		})
	);
}),
  • 일반적인 get 요청을 하는데, axiosInstance 로 요청을 하면, request.interceptor를 거쳐서 로컬 스토리지에 있는 access_token 을 헤더에 담아서 요청하게 됨

 

4. 버튼을 눌러서 정상 동작 확인

  • header 에 access_token 이 담겨 있는 것을 확인

 

5. access_token이 만료되었다는 fake 응답을 리턴

  • msw handlers.js 에 작업
    • 이후 /auth/refresh_token 요청에서 msw 로 새로운 토큰을 ‘test-access-token’ 으로 발급해줄 예정
    • 현재 토큰은 만료되었다고 가정, 이후 ‘test-access-token’이 유효한 토큰이라고 가정
    • 일단 현재시점의 요청으로는 401을 리턴
rest.get('/mydata/question/apply/', async (req, res, ctx) => {
	console.log('msw-get-request', req.headers.get('authorization'));
	const authCheck = req.headers.get('authorization');
	if (authCheck === 'Bearer test-access-token') {
		return res(
			ctx.status(200),
			ctx.json({
				result: false,
			})
		);
	}

	return res(ctx.status(401));
}),

 

6. response.interceptor 지정

  • 401 받으면 바로 이어서 refresh_token으로 토큰 재발행 요청
  • 200 성공시 localStorage 새로운 토큰으로 업데이트
  • 기존에 요청했던 작업 재요청 → failedQueue에 담겨있는 작업 처리
let isRefreshing = false; // 중복 발행 방지 flag
let failedQueue = []; // 할 일 리스트

// 할 일 처리하는 큐
const processQueue = (error, token = null) => {
	failedQueue.forEach((taskPromise) => {
		if (error) {
			taskPromise.reject(error);
		} else {
			taskPromise.resolve(token);
		}
	});
	failedQueue = [];
};

axiosInstance.interceptors.response.use(
	function (response) {
		return response;
	},
	async (error) => {
		const {
			config,
			response: { status },
		} = error;

		const originalRequest = config;

		// 401 access 권한 에러 발생시
		if (status === 401 && !originalRequest._retry) {
			if (isRefreshing) {
				return new Promise((resolve, reject) => {
					failedQueue.push({ resolve, reject });
				})
					.then((token) => {
						originalRequest.headers.Authorization = `Bearer ${token}`;
						return axios(originalRequest);
					})
					.catch((err) => {
						return err;
					});
			}

			originalRequest._retry = true;
			isRefreshing = true;

			const refreshToken = localStorage.getItem('refreshToken');

			return new Promise((resolve, reject) => {
				axios
					.post('/auth/refresh_token', { refreshToken: refreshToken })
					.then(({ data }) => {
						localStorage.setItem('accessToken', data.access_token);
						localStorage.setItem('refreshToken', data.refresh_token);
						originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
						resolve(axios(originalRequest));
						processQueue(null, data.accessToken);
					})
					.catch((err) => {
						processQueue(err, null);
						reject(err);
						localStorage.removeItem('accessToken');
						localStorage.removeItem('refreshToken');
						window.location.href = '/login';
					})
					.then(() => {
						isRefreshing = false;
					});
			});
		}
		// return other error
		return Promise.reject(error);
	}
);
  • 로컬 스토리지 변화 확인 : accessToken / refreshToken 값 확인

  • 요청 콘솔 로그
    • 데이터 get 요청 (/evaluation/question/apply/) Failed 401
    • 토큰 재발행 요청 (/auth/refresh_token) 200 성공
    • 새로 발급받은 access_token 으로 데이터 get 재요청(/evaluation/question/apply/) 200 성공

 

7. 만약 refresh_token 도 만료라면?

  • /auth/refresh_token 에서 401 을 또 주는 상황
  • 그럼 axiosInstance.interceptors.response의 catch 에서 잡히기 때문에, 로컬 스토리지의 모든 토큰을 지우고 login 페이지로 리다이렉트
rest.post('/auth/refresh_token', async (req, res, ctx) => {
		console.log('msw-refresh-token-request', req);
		return res(ctx.status(401));
	})
  • 로컬 스토리지 확인 : accessToken, refreshToken 키값 삭제됨

 

8. 테스트 완료후 baseUrl 돌려놓기