본문 바로가기

Server/Node.js

JWT의 개념, 프론트엔드에서 해야 할 일

진행중인 프로젝트에서 기존의 SessionStorage를 사용하는 방법과 달리, JWT Token을 통해 사용자 인증을 진행할 것을 요구했다. JWT라는 것이 여러 글들을 살펴봐도 다소 장황하게 설명되어있고, 그냥 이대로 따라해라!는 내용도 많아서 개발을 진행하면서 어떤 이점들이 있는지 생각해봤고, 개인적인 생각을 글로 남겨 놓으려 한다. 이 글의 내용이 무조건 맞는 것은 아니며, 다른 점이 있다면 차차 개선해나갈 예정이다.

 

JWT 를 통한 인증 절차

1. [프론트엔드] ID와 비밀번호를 준다.

2. [백엔드] ID와 비밀번호를 검증하고 AccessToken과 RefreshToken, AccessToken의 만료시간을 반환해준다. 이 때 생성한 RefreshToken은 DB에 {ID,RefreshToken}으로 저장한다.

3. [프론트엔드] 반환받은 AccessToken을 매 api 호출마다 헤더에 붙여서 전송한다.

4. [백엔드] api호출시 헤더의 AccessToken을 확인하고 유효한지, 만료기간이 지났는지를 체크 후 api를 동작시킨다.

5. [프론트엔드] AccessToken의 만료 기간이 지나거나, 30초 미만으로 남았다면, 백엔드에 RefreshToken을 붙여 Reissue 요청을 보낸다.

6. [백엔드] Reissue요청이 들어올 경우, RefreshToken이 DB에 있는 것인지 확인한 후, 맞다면 AccessToken과 새로운 AccessToken 만료 시간을 반환한다.

7. [프론트엔드] Reissue결과 반환된 AccessToken과 만료기간을 저장하여 다음 api호출에 사용한다.

JWT 개념 처음부터 잡기

절차만 써놓으니 상당히 복잡하다. 그러니 아주 기본적인 개념부터 차차 살을 붙여보자. 핵심은 AccessToken이다. RefreshToken과 AccessToken 만료시간은 일단 잊자. AccessToken만 생각했을떄 개념은 아주 간단하다. 로그인시 백엔드에서 암호화된 AccessToken을 주고, 프론트에선 api요청마다 AccessToken을 헤더에 첨부하여 내가 이런 사용자다~ 하고 백엔드에 알려주는것이다. 

 

1. [프론트엔드] ID와 비밀번호를 준다.

2. [백엔드] ID와 비밀번호를 검증하고 AccessToken을 반환한다.

3. [프론트엔드] AccessToken을 받아 다음 api호출부터 헤더에 붙여준다.

4. [백엔드] api호출시 AccessToken이 유효한지 확인하여 처리한다.

 

이렇게 간단하게 구현한 인증방식에는 허점이 있다. AccessToken이 한번 탈취당할 경우, 공격자가 무제한으로 api에 접근하여 정보를 채갈 수 있다. 그래서 만료시간이라는 개념이 등장한다. AccessToken을 발급할 때, 10분 혹은 30분으로 제한 시간을 둬서 시간이 지난 후에는 해당 AccessToken으로 접근할수 없게 하는 것이다.

1. [프론트엔드] ID와 비밀번호를 준다.

2. [백엔드] ID와 비밀번호를 검증하고 AccessToken을 반환한다. 이 때, 만료시간을 설정한다.

3. [프론트엔드] AccessToken을 받아 다음 api호출부터 헤더에 붙여준다.

4. [백엔드] api호출시 AccessToken이 유효한지, 만료시간이 지나지 않았는지 확인하여 처리한다.

 

그러나, 이 경우 사용자가 10분,30분마다 재 로그인을 해야 한다는 문제가 생긴다. 이를 보완하기 위하여 RefreshToken이라는 개념이 등장한다. 만료시간이 지났을 경우 RefreshToken을 통해 AccessToken을 재발급받을수 있도록 구현하는 것이다.

 

여기서 의문이 생길 수 있다. 그럼 RefreshToken을 탈취당하면 무제한으로 AccessToken을 발급받을 수 있는 것 아닌가? 맞다. 이에 대해서 내가 찾아본 글들에서는 RefreshToken을 더 안전한 곳에 보관할 것을 권장하고 있다. 그럼 여기서 또 질문이 생긴다. 그럴거면 AccessToken도 안전한 곳에 보관하면 똑같은것 아닌가?

 

이에 대한 명확한 답은 찾지 못했지만, 개인적인 생각으로는 AccessToken의 경우 모든 로그인이 필요한 api마다 사용하게 되어 보안적으로 덜 안전하고, RefreshToken은 재발급 시에만 한번씩, 즉 30분에 한번 사용할까 말까 한 Token이기 때문에 비교적 안전하다는게 분리하는 이유라고 생각한다. 즉 OTP처럼 탈취 위험성이 높은 키를 일회성으로 사용하고, 패스워드를 만드는 토큰은 안전하게 두는 것이 핵심이다.

 

1. [프론트엔드] ID와 비밀번호를 준다.

2. [백엔드] ID와 비밀번호를 검증하고 AccessToken, RefreshToken을 반환한다. 이 때 AccessToken의 만료시간을 설정한다.

3. [프론트엔드] AccessToken을 받아 다음 api호출부터 헤더에 붙여준다. RefreshToken은 안전한 곳에 보관한다.

4. [백엔드] api호출시 AccessToken이 유효한지, 만료시간이 지나지 않았는지 확인하여 처리한다.

5. [프론트엔드] 만약 AccessToken이 만료되어 api 동작이 실패하였다면, RefreshToken을 백엔드에 줘서 Reissue를 건다.

6. [백엔드] Reissue요청이 오면, AccessToken을 새로 만들어 반환한다.

 

여기에 살을 더 붙여보자. RefreshToken을 사용할때의 가장 큰 문제점은 RefreshToken이 탈취될 경우 공격자가 무제한으로 AccessToken을 재발급받을 수 있다는 것이다. 그래서 가장 최신의 RefreshToken을 DB에 저장하는 방식을 채택한다. 로그인할때 새로 RefreshToken을 발급하고, 동시에 DB에 저장한다. Reissue시에는 RefreshToken이 DB에 저장된 것과 같은지 비교한다. 이렇게 하면 다른 환경에서 새로 로그인할 경우, 기존의 RefreshToken은 설령 만료 기간이 남았더라도 사용할 수 없게 된다. 

 

1. [프론트엔드] ID와 비밀번호를 준다.

2. [백엔드] ID와 비밀번호를 검증하고 AccessToken, RefreshToken을 반환한다. 이 때 AccessToken의 만료시간을 설정한다. 
RefreshToken의 경우 DB에 {ID, RefreshToken} 식으로 저장한다.

3. [프론트엔드] AccessToken을 받아 다음 api호출부터 헤더에 붙여준다. RefreshToken은 안전한 곳에 보관한다.

4. [백엔드] api호출시 AccessToken이 유효한지, 만료시간이 지나지 않았는지 확인하여 처리한다.

5. [프론트엔드] 만약 AccessToken이 만료되어 api 동작이 실패하였다면, RefreshToken을 백엔드에 줘서 Reissue를 건다.

6. [백엔드] Reissue요청이 오면, RefreshToken이 DB에 있는 것과 같은지 비교하고, 맞다면 AccessToken을새로 만들어 반환한다.

 

이제 모두 해결이 된것같지만 아직도 문제점이 남았다. 이 방식에서는 api를 보내보고 실패할 경우 > reissue를 거는 방식이라 불필요한 api호출이 최소 한 번은 일어나게 된다. 그렇다면 AccessToken의 만료시간을 백엔드로부터 전달받아 만료시간이 다 되었다면 api를 호출하지 않고 Reissue를 먼저 호출한 후 api를 호출하도록 구현할 수 있다.

 

1. [프론트엔드] ID와 비밀번호를 준다.

2. [백엔드] ID와 비밀번호를 검증하고 AccessToken과 RefreshToken, AccessToken의 만료시간을 반환해준다. 이 때 생성한 RefreshToken은 DB에 {ID,RefreshToken}으로 저장한다.

3. [프론트엔드] 반환받은 AccessToken을 매 api 호출마다 헤더에 붙여서 전송한다.

4. [백엔드] api호출시 헤더의 AccessToken을 확인하고 유효한지, 만료기간이 지났는지를 체크 후 api를 동작시킨다.

5. [프론트엔드] AccessToken의 만료 기간이 지났다면, 백엔드에 RefreshToken을 붙여 Reissue 요청을 보낸다.

6. [백엔드] Reissue요청이 들어올 경우, RefreshToken이 DB에 있는 것인지 확인한 후, 맞다면 AccessToken과 새로운 AccessToken 만료 시간을 반환한다.

7. [프론트엔드] Reissue결과 반환된 AccessToken과 만료기간을 저장하여 다음 api호출에 사용한다.

 

그래서 최종적으로는 이런 식이 된다. 하지만 아직도...문제가 더 남는다. 쉽게 빼먹을 수 있는 부분이지만, 프론트엔드에서 백엔드로 api를 요청하는 데는 시간이 걸린다. 그럼 이런 경우를 생각해보자. AccessToken의 만료 시간은 1:10이다. 사용자는 1:09에 api를 호출했다. 프론트엔드에서는 1:10이 지나지 않았기 때문에 Reissue를 하지 않고 그대로 백엔드로 보낸다. 그러나 백엔드에서는 1:10에 api요청을 받는다. 백엔드 입장에선 AccessToken이 만료되었기 때문에 이 api요청은 거부된다. 이 문제를 해결하기 위하여, 프론트엔드에서는 단순히 AceessToken의 만료시간이 지났는지를 기준으로 삼는 것이 아니라, 만료시간이 적게 남았을 경우 + 만료시간이 지났을 경우로 조건을 바꾸어 주어야 한다.

 

1. [프론트엔드] ID와 비밀번호를 준다.

2. [백엔드] ID와 비밀번호를 검증하고 AccessToken과 RefreshToken, AccessToken의 만료시간을 반환해준다. 이 때 생성한 RefreshToken은 DB에 {ID,RefreshToken}으로 저장한다.

3. [프론트엔드] 반환받은 AccessToken을 매 api 호출마다 헤더에 붙여서 전송한다.

4. [백엔드] api호출시 헤더의 AccessToken을 확인하고 유효한지, 만료기간이 지났는지를 체크 후 api를 동작시킨다.

5. [프론트엔드] AccessToken의 만료 기간이 지나거나, 30초 미만으로 남았다면, 백엔드에 RefreshToken을 붙여 Reissue 요청을 보낸다.

6. [백엔드] Reissue요청이 들어올 경우, RefreshToken이 DB에 있는 것인지 확인한 후, 맞다면 AccessToken과 새로운 AccessToken 만료 시간을 반환한다.

7. [프론트엔드] Reissue결과 반환된 AccessToken과 만료기간을 저장하여 다음 api호출에 사용한다.

 

자, 내가 이해한 대로면 이제 JWT Token을 이용한 사용자 인증 절차가 완성되었다.

 

프론트엔드 측면에서 구현하기

내가 해당 프로젝트에서 담당한 것은 프론트엔드 개발이기 때문에 프론트엔드 측면에서 구현한 부분만 다루고자 한다. Vue.js와 Vuex 기반으로 개발하였으니 당연히 Javascript ES6으로도 같은 방식으로 구현할 수 있다.

 

로그인 메소드

  async loginToken (state, val) {
    await axios.post(process.env.BACKEND_URL+'/api/users/login', val).then(
      (res) => {
        localStorage.setItem('accessToken', res.data.data.accessToken)
        localStorage.setItem('refreshToken', res.data.data.refreshToken)
        localStorage.setItem('expiredTime', res.data.data.cur_time)
        axios.defaults.headers.common['x-access-token'] = res.data.data.accessToken
      },
      (err) => {
      }
    )
  },

api를 보내 AccessToken과 RefresToken, 만료 기간을 반환받고 LocalStorage에 저장한다. AccessToken의 경우 axios 동작 시 헤더에 기본으로 붙도록 설정한다.

 

 

api호출 시 선호출될 메소드

  async setToken (state) {
    // HEADER에 토큰 설정
    axios.defaults.headers.common['x-access-token'] =  localStorage.getItem('accessToken')
    // 만료시간이 지났을 경우, RefreshToken을 이용하여 AccessToken 재발급
    var expiredTime = await this.$moment.utc(localStorage.getItem('expiredTime'))
    var diffTime = await this.$moment.duration(expiredTime.diff(this.$moment()))
    if (diffTime < 10000){
        axios.defaults.headers.common['x-refresh-token'] = localStorage.getItem('refreshToken')
        await axios.get(process.env.BACKEND_URL+'/api/users/reissue').then(
          (res) => {
            localStorage.setItem('accessToken', res.data.data.accessToken)
            localStorage.setItem('expiredTime', res.data.data.cur_time)
            axios.defaults.headers.common['x-access-token'] =  localStorage.getItem('accessToken')
          },
          (err) => {
              // Login 페이지로 리디렉션
          }
        ) 
    }
    return new Promise(function(resolve, reject) {
        resolve(true)
    });
  }

로그인이 필요한 api호출 직전에 해당 전역 메소드를 동작시켜 미리 만료시간에 가까운지 확인하고, 가까운 경우 재발급을 받아 다시 LocalStorage에 저장, 헤더로 설정한다.

 

 

HTML Side

            <input type="text" class="id field" v-model="userID" placeholder="이메일을 입력해주세요" >
            <input type="password" class="pw field" v-model="userPW" placeholder="비밀번호를 입력해주세요">
            <v-btn block class="login-btn box-btn" v-on:click="login">로그인</v-btn>
            <v-btn block class="login-btn box-btn" v-on:click="test1">테스트</v-btn>

Script Side

  methods: {
      async login () {
        const userID = this.userID
        const userPW = this.userPW
        var param = {'userID':userID, 'userPW':userPW}
        this.$store.dispatch('auth/loginToken', param)
      },
      async test1 () {
          // console.log(localStorage.getItem('accessToken'))
          await this.$store.dispatch('auth/setToken')
          await axios.get(process.env.BACKEND_URL + '/api/mypage/coupon/').then(
            (res) => {
              console.log(res.data.data)
            },
            (err) => {
              console.log(err)
            }
          )
      }
  },

이제 Vue쪽에서 각 버튼에 메소드를 할당한다. 

 

 

 

'Server > Node.js' 카테고리의 다른 글

Node.js Build시 Error 137 발생 원인 및 해결법  (0) 2020.02.04