velog
graduation
JWT

Intro

졸업프로젝트를 진행하면서 사용자 인증 방식에 관한 기술적인 고민을 하였습니다. 처음에는 브라우저의 쿠키를 활용하여 인증하는 방식을 선택했지만, 보안과 관련된 문제가 발생할 수 있다는 점을 인지하고 수정하게 되었습니다. 이에 백엔드 팀원들과의 토의를 통해 JWT(JSON Web Token) 인증 방식으로 전환하게 되었습니다. 두 가지 인증 방식의 차이점을 다시 한번 복습하고자 합니다.


인증(Authentication)과 인가(Authorization)

인증(Authentication)

서비스를 이용하려는 사용자가 해당 서비스에 등록된 회원인지를 검증하는 절차

인가(Authorization)

인증 이후의 과정으로, 인증된 유저가 어떠한 리소스에 접근이 가능한지 확인하는 절차


HTTP 프로토콜 통신의 특징

무상태 프로토콜 (Stateless)

HTTP에서는 서버가 클라이언트의 상태를 보존하지 않습니다. 응답과 요청이 독립적으로 동작하기 때문에 클라이언트가 서버에 추가적인 데이터를 전송해야 합니다.

비연결성 지향 (connectionless)

HTTP는 클라이언트가 서버에 요청을 보내고, 서버가 클라이언트의 요청에 부합하는 응답을 보내고 난 후 TCP/IP 연결을 끊는 특성이 있습니다.

이러한 환경적 특성 때문에 단순한 요청만으로는 서버가 클라이언트를 구별할 수 없습니다.따라서, 로그인과 같이 특정 상태를 유지해야한다면, 브라우저 쿠키 / 서버 세션 / 토큰 등의 추가적인 방식을 적용해야 합니다.


쿠키 (Cookie)

서버가 사용자의 웹 브라우저에 전송하는 작은 데이터 조각으로 Key-Value 형식의 문자열로 구성되어 있습니다.

세션 (Session)

서버측에서 관리하는 정보로, 서버에서 클라이언트를 구분하기 위해 세션 ID를 부여하고, 웹 브라우저가 서버에 접속해서 브라우저를 종료할 때까지 해당 인증 상태를 유지하고자 합니다.

JWT (JSON Web Token)

인증에 필요한 정보들을 암호화시킨 JSON 토큰을 의미합니다. JWT 토큰을 HTTP 헤더에 담아서 전송하는 방식으로 이루어집니다.


실제 프로젝트에서 사용하였던 과정

졸업프로젝트를 수행하면서 기본적으로 axios 라이브러리를 활용하여 서버와의 통신 과정을 수행하였습니다.

1. 쿠키를 사용한 인증 방식

import axios from 'axios';
 
const login = async (username, password) => {
  try {
    
    const postToServer = {
      username,
      password
    };
    
    const response = await axios.post('서버 url', postToSever, {
      withCredentials: true
    });
    
  } catch(error) {
    console.error('로그인에 실패하였습니다.', error);
  }
};
 

withCredentials 옵션을 true로 설정하였고, 클라이언트가 서버에 요청을 보낼때마다 세션 쿠키를 함께 보낼 수 있게 되어 손쉽게 사용자 인증 방식을 구현할 수 있었습니다.

하지만, 쿠키 인증방식을 사용할 때 발생할 수 있는 문제에 대해서 알아보게 되었습니다.

가장 큰 문제는 보안에 취약하다는 점입니다.

쿠키를 사용하여 인증 방식을 구현한다면, 대표적으로 다음의 두 공격에 취약하게 됩니다.

1. CSRF(Cross-Site Request Forgery) 공격

사용자가 인증된 상태에서 웹 애플리케이션에 악의적인 요청을 보내는 공격입니다.

2. XSS(Cross-Site Scripting) 공격

웹 애플리케이션에 악성 스크립트가 삽입되는 공격입니다.

보안에 취약하다면 서비스를 사용하게 될 사용자의 중요한 개인 정보가 노출되어 위험해지는 경우가 발생하기 때문에 좀 더 안정적인 인증 방식을 생각하게 되었습니다.


2. JWT를 사용한 인증 방식

보다 안정적인 인증 방식을 적용하기 위해, 저희는 JWT(JSON Web Token)을 도입했습니다. 사용자가 로그인하면, 서버에서 accessToken과 refreshToken을 발급합니다.

...
 public String createAccessToken(String email) {
        Date now = new Date();
        Date validity = new Date(now.getTime() + 3_600_000); // 한시간 동안 유효
 
        return JWT.create()
                .withIssuer(email)
                .withIssuedAt(now)
                .withExpiresAt(validity)
                .sign(Algorithm.HMAC256(secretKey));
    }
...

accessToken은 클라이언트에서 전역적으로 상태를 관리하기 위해 Redux 상태관리 라이브러리를 활용하였고, 페이지 새로고침 등의 상황에서도 로그인 상태를 유지하기 위하여 sessionStorage에 임시 저장하였습니다.

refreshToken의 경우에는 localStorage에 저장하였습니다.

Redux-toolkit 활용하여 accessToken의 상태를 저장하는 tokenSlice 구현
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
 
interface tokenState {
  token: string;
}
 
const initialState: tokenState = {
  token: '',
};
 
export const tokenSlice = createSlice({
  name: 'token',
  initialState,
  reducers: {
    setToken: (state, action: PayloadAction<string>) => {
      state.token = action.payload;
    },
  },
});
 
export const { setToken } = tokenSlice.actions;
export default tokenSlice.reducer;
redux-persist 활용하여 sessionStorage에 accessToken을 저장하도록 설정
import { combineReducers } from 'redux';
import { configureStore } from '@reduxjs/toolkit';
import { persistReducer, FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER } from 'redux-persist';
import storage from 'redux-persist/lib/storage/session';
import logger from 'redux-logger';
 
import tokenReducer from '@/store/token';
import preferenceTypeReducer from '@/store/preferenceTypes';
import notificationReducer from '@/store/notification';
 
const persistConfig = {
  key: 'root',
  storage,
  whiteList: ['token'],
};
 
const rootReducer = combineReducers({
  token: tokenReducer,
  types: preferenceTypeReducer,
  notification: notificationReducer,
});
 
const persistedReducer = persistReducer(persistConfig, rootReducer);
 
const store = configureStore({
  reducer: persistedReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: {
        ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
      },
    }).concat(logger),
  devTools: process.env.NODE_ENV !== 'production',
});
 
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
 
export default store;

구현 과정은 쿠키를 통한 인증 방식보다 다소 복잡해졌지만, 사용자의 인증 관리를 보다 안전하게 관리할 수 있게 되었습니다.


구현 과정을 통해 알게된 점


참고 문헌

블로그 - JWT 토큰 인증이란? (opens in a new tab)

블로그 - 인증 방식(쿠키,세션,JWT) (opens in a new tab)

블로그 - 쿠키와 세션의 개념 (opens in a new tab)