JWT에 대하여

hoifoi·2023년 11월 24일
0
post-thumbnail

들어가기

이제 나는 로그인 API도 만들었고, 비밀번호 중개사(?) 자격증도 취득했다!
그렇다고 또 거만해지면 안된다.. 아직 갈 길이 멀기 때문!
로그인에 성공 했다면 이후 부터는 로그인한 유저가 사용할 수 있는 기능을 누리게 되는데
그 때마다 계속해서 비밀번호를 요구할 수도 없다
그렇다면 비밀번호는 다른 곳에 따로 저장해두는 걸까?
따로 저장은 해두지만 비밀번호를 저장하면 또 위험해진다..
아무리 암호화했다 치더라도 우리 정보를 탈취해가는 그 분들은 늘 그렇듯.. 답을 찾아낸다..
그럴 때 저장해두는 것이 바로 토큰이다!
비유하자면 입장권이라고 생각하면 된다!
(전시회나 놀이동산이나 클럽 등에서 채워주는 팔찌 같은 느낌?)

토큰을 사용해야 하는 이유?

앞서 포스팅한 바와 같이 HTTP는 기본적으로 state-less를 지향한다

간단하게 정리하자면
서버-클라이언트 구조에서 서버는 클라이언트의 상태 기억하고 있지 못하는 것

이때의 장단점을 따져보자면

  • 장점
    서버의 확장성이 높으며 대량의 트래픽이 발생해도 대처가 가능하다
  • 단점
    state-ful 방식보다 비교적 많은 양의 데이터가 반복적으로 전송되므로
    네트워크 성능저하가 생길 수 있고, 데이터의 노출로 인한 보안의 문제가 생긴다

이 때의 단점을 보완하기 위해 토큰을 사용하는 것이고
그 토큰 중에서도 제일 대중적이고 유명한 라이브러리가 JWT 이다

JWT 구조

JWT는 세 부분으로 이루어져 있다

header.payload.signature
// header
// JWT의 종류와 해시 알고리즘 등의 정보를 담고 있는 부분
{
  "alg": "HS256",	// Signature을 만드는데 사용한 알고리즘 정보
  "typ": "JWT"		// Token의 타입
}

// payload
// 실제로 전달하려는 정보가 담긴 부분으로 위에서 예시로 든 티켓에 다시 비유하자면
// 그 티켓에 사용자의 정보(누구의 티켓인지)를 넣어두어 이용자를 특정한다
{
    "sub": "1234567890",	// 담긴 데이터 내용 1
    "name": "John Doe",		// 담긴 데이터 내용 2
    "iat": 1516239022,		// 토큰 발급 시간
	"exp": 1516239082		// 토큰 만료 시간
}

// Signature
// Header와 Payload를 인코딩하고, 시크릿 키를 사용하여 서명한 값
// 이를 통해 토큰이 변조되지 않았음을 확인할 수 있다
 HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  secret		// 시크릿키
)

여기까지는 설명이고 나는 뭐든지 실물을 봐야 더 잘 이해를 한다
바로 보여드리겠다

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwiZW1haWwiOiJ5b2tpZGRkZGRAbmF2ZXIuY29tIiwic3RhdHVzIjowLCJpYXQiOjE3MDQ4NjM5NDAsImV4cCI6MTcwNDg2NzU0MH0.JM9gwC-kG07N_AR3RaKOtYaRDHWFUK7z71oVlNxH36s

이런 식의 아주 긴 문자열이 나오는데
어떻게 보면 앞서 설명했던 bcrypt와 비슷하다고 볼 수 있다
중간 중간 마침표가 찍혀 있는 것을 볼 수 있는데
이 마침표로 header와 payload, signature가 구별된다고 볼 수 있다
그냥 문자열 하나일 뿐인데 여기에 우리 유저의 귀중한 정보가 담긴 것이니 잘 들고 있어야 한다.

프론트에게 물어보니 이 토큰이 유저의 로컬스토리지나 쿠키에 저장이 된다고 한다.
(이 부분에 대해서는 나중에 프론트 파트도 따로 공부할 터이니 그때 알아보자.. 꼭 :D)

그리고 유저의 이동이 있을 때마다 그곳에 저장되어 있는 토큰을 같이 보내는 것이지
그렇게 함으로써 유저의 정보(계정이나 비밀번호)는 계속 재 입력이 필요 없어 지는 것

JWT의 적용

일단은 설치(터미널 이용)

npm install jsonwebtoken

이후 코드를 작성 해보자

##javascript

// 토큰 발급
const jwt = require('jsonwebtoken');

// signatur생성에 필요한 암호화 키(bcrypt의 솔트와 비슷한 역할)
const secretKey = 'mySecretKey';

// 발급된 토큰에 담기게 될 유저 식별 정보
// 이메일이나, 이름, 전화번호 등은 위험한 정보이므로
// 보통은 유저 식별에 필요한 식별데이터와 어드민 계정 구별을 위한 상태값을 담는다
// 꼭 객체 형태로 담아줘야 한다
const payload = {
  userId: '123456',
  role: 'admin'
};

// 토큰을 생성
// jwt.sign(payload, secretKey)정도가 제일 기본 정보인데
// 로컬에 저장되는 부분이기때문에 만료기간 미설정시 언제든 탈취 가능(?) 상태가 된다
// 그러므로 꼭 만료시간을 지정해야 한다(보통 1시간으로 정한다)
// 숫자로 입력시 초단위, 문자로 입력시 1s, 1m, 1h등으로 입력하면 된다(3600 = 1m)
const token = jwt.sign(payload, secretKey, { expiresIn: '1h' });

// 이후 내가 확인하거나 프론트에게 이 토큰을 보내주면 된다
console.log('Generated Token:', token);

...

// 프론트에게 받은 토큰
const token = '...'; // 받은 토큰

// 토큰 검증
const verify = jwt.verify(token, secretKey)

// 검증된 토큰을 확인해보면 이전에 내가 담은 내용을 볼 수 있다
console.log(verify)

// 콘솔 값
{
  userId: '23456',
  role: 'not admin'
  iat: 1704863940,
  exp: 1704867540
}
// exp - iat = 3600 즉, 토큰 유효시간은 1시간인 것을 알 수 있다

JWT의 장점과 단점

장점

  • 간편성
    토큰은 보통 HTTP 헤더를 통해 전송되며 간편하게 사용이 가능하다
  • 자가 포함
    필요한 정보를 토큰 안에 담을 수 있어
    서버에서 별도의 상태를 저장할 필요가 없다

단점(또는 주의사항)

  • 보안
    탈취가 어렵지 않으므로(?) 시크릿 키를 안전하게 보관하고
    HTTPS를 통한 통신을 유지하는 등 보안에 주의해야 한다
  • 토큰 크기
    토큰이 클 경우 네트워크 부하가 발생할 수 있으므로
    필요한 정보만을 담는 것이 좋다
    (그래봤자 문자열이라지만 개발자에겐 한바잍, 한바잍이 중요하기 때문에
    무엇이든 허투루 담지 않는 것이 좋다. 보통은 두개 정도의 값만 담는다 혹은 한개!)

주의사항(정말 정말 이것만은 꼭 읽어줘..)

탈취된 토큰은 그냥 아주 자동문 그자체다..
그러니 정말 조심한다고 하더라도 항상 나보다 멍청이보다는 똑똑이가 더 많은 세상이다..ㅜ

탈취가 된 경우

JWT 공식홈페이지에서 위에서 내가 생성했던 토큰을 입력해보자

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwiZW1haWwiOiJ5b2tpZGRkZGRAbmF2ZXIuY29tIiwic3RhdHVzIjowLCJpYXQiOjE3MDQ4NjM5NDAsImV4cCI6MTcwNDg2NzU0MH0.JM9gwC-kG07N_AR3RaKOtYaRDHWFUK7z71oVlNxH36s


ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ
ㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋㅋ
내가 만든 토큰이 간단하게 해제가 되어버린다 XD

난 이것을 보고 정말 충격을 먹었다..
않이.. 이게 이렇게 되면 안되는거 아닙니까?

나 같은 경우 이전 프로젝트에서 저정도의 양의 데이터를 넣었는데
(유저 식별을 위한 유저 고유 id, 교차 검증을 위한 이메일(만약을 위해), 관리자 여부)
다른 팀 같은 경우에는 페이로드 값에 이후 사용할 유저 정보를 모두 다 넣어버렸다
(이름, 성별, 핸드폰 번호, 집주소 등.. 심지어 이 팀은 배포도 했고 서비스 중이다..)

정말 나는 운이 좋게도 검증된 토큰에서 id만 쏙 뽑아
그에 해당하는 유저 정보를 뽑아내는 함수를 따로 만들어 이후에 사용했다
(다른 생각 없이 그냥 그렇게 한것이기 때문에 정말 나는 그냥 운이 좋았던 것이다)

그럼 시크릿 키는 왜 숨겨두었고, 왜 뭐 안전하다고 한거지?
심지어 공식홈페이지에서 바로 저렇게 자동문을 만들어뒀는데?
(심지어 토큰이 만료되어도 페이로드 값을 볼 수 있다 이건 jwt라이브러리의 decode함수를 써도 가능한 부분)

공식홈페이지에서는 시크릿키 조차 안다면
저기서 값을 바꿔 토큰을 재생성 할 수 있다!
하지만 시크릿키는 노출되지 않았으니 그나마 다행이라고 할 수도 있겠다..
(토큰 개봉은 쉽지만 재생성은 같은 시크릿키가 있어야 하기 때문에
탈취된 정보는 쉽게 노출되나 변조 및 재생성은 아주 어렵다!)
시크릿키와 관련된 민감한 정보를 숨기는 방법은 여기로!

그래도 페이로드는 털리기때문에 대책은 세워야 한다!

대책 1

일단 내가 할 수 있는 방법은
민감한 정보, 누군가를 특정할 수 있는 값을 넣지 않는 것이다
그럼 id도 담을 수 없는 것일까? 당연히 그러면 안된다
저기 id는 정말 그 유저의 유일한 식별값이다
행여나 변조가 가능하다면 저 id를 다른 값(234)으로 바꿔서 사용하면
234번 유저의 비밀번호를 모르더라도 234번 유저로 로그인해서 기능을 쓸 수 있다!
아 그럼 어쩌란 말인가..
페이크를 넣자는 것이다

##javascript
// 담으려는 ID의 원본
const plainUserId = 5
console.log(plainUserId)

// 길이를 받아 그에 맞는 랜덤 문자열을 만들어 내는 함수
const ranStr = (len) => {
  const set = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()'
  let str = ''
  for(i=0; i<len; i++){
    str += set.charAt(Math.floor(Math.random()*set.length))
  }
  return str
}

const makeFakeId = (id) => {
  return ranStr(3) + '-' + ranStr(3) + '-' + id + '-' + ranStr(3) + '-' + ranStr(8)
}

// 페이로드에 담기는 페이크ID
const payloadId = makeFakeId(plainUserId)
console.log(payloadId)
// 'MSe-7er-5-9*Y-P3*r8iRD'

// 토큰에 담긴 페이크ID를 다시 복구
console.log(payloadId.split('-')[2])
// 5

이렇게 페이크를 친 뒤 페이로드에 담는 것이다

## payload
{
  "fake_id": MSe-7er-5-9*Y-P3*r8iRD,
  "status": 0,
  "iat": 1704863940,
  "exp": 1704867540
}

당장 떠오르는 방법으로는 이렇게 하는 방법이 있을 것이고,
더 좋은 방법도 많을 것이다(이 부분은 앞으로 내가 더 탐구해봐야 할 문제)

대책2

앞서 말한 것처럼 내가 토큰을 발행해서 받은 프론트가
로컬스토리지든 쿠키든 저장을 하는 방식을 조금 조심히 다루거나
탈취가 당하지 않게끔 해야 하는 것
(이 부분도 결과적으로 풀스택이 될거라 내가 알아야 할 부분이고
아마 백엔드가 배포를 담당하게 될 터이니 더 방법이 있을 것 같다)

마무리

JWT는 간편하면서도 안전한 방법으로
사용자 인증 및 권한 부여를 처리하는 데에 활용할 수 있다
따라서 적절한 보안 조치를 취하고, 필요한 정보만을 담아 사용한다면
아주 강력하고 효과적으로 활용할 수 있는 도구가 될 수 있을 것이다 :)!

++
발돋움 중인 예비 개발자 입니다.
태클 및 의견 공유 언제나 환영 :D

profile
컨텐츠 기획자 출신 백엔드 개발자 :D

0개의 댓글