이번 포스팅에서는 JWT 방식을 이용해 로그인 인증을 구현할 때 사용되는 Buffer와 Hash, 그리고 JWT의 개념에 대해 짚고 넘어가 보고자 한다.
< 목차 >
- Buffer??
- Hash??
- JWT
1. Buffer ??
우리가 사용하는 컴퓨터는 이진수로 데이터를 저장하고 표현한다. 예를 들어 문자 "A" 라는 값을 표현할 때 A 그대로를 표현하는 것이 아닌 이진수 형태로 A를 표현하게 되는 것이다. 이진수에서 1 혹은 0으로 되어있는 자리를 bit(비트)라고 하는데 1byte=8bit를 의미하고 8bit로는 256가지(2^8=256)의 데이터를 표현하는 것이 가능하다. 1nibble(니블)=4bit를 의미하고 있기 때문에 1 byte = 8 bit = 2 nibble의 관계가 된다. 4bit를 이용하면 16가지의 데이터를 표현할 수 있으므로 1nibble을 16진수로 표현하는 것이 가능해진다. 정리하면, 1byte의 내용은 2nibble, 16진수 두자리로 표현이 가능해진다.
이렇게 컴퓨터가 저장하거나 표현하는 데이터를 16진수로 나타낼 수 있는 것이 바로 Buffer이다. Buffer란 Node.js에서 이진 데이터를 담을 수 있는 객체를 의미하는데 실제로 컴퓨터가 데이터를 저장하는 형태로 데이터를 담고 있다. 코드를 통해 살펴보도록 하자.
현재 name이라는 변수에 'bitkunst'라는 글자가 담겨 있는 상황이다. 'bitkunst'는 알파벳 8글자이므로 8 byte로 표현이 된다. 1 byte = 2 nibble 이기에 16진수 두자리로 1byte를 표현할 수 있다. 결국 'bitkunst'라는 문자열은 62 69 74 6b 75 6e 73 74 와 같이 2자리의 16진수 8개로 표현될 수 있는 것이다. 또한 실제로 Buffer가 메모리에 담고 있는 값은 이진수이지만 위와 같이 콘솔에는 16진수로 표기가 되는 것을 알 수 있다.
문자 코드(ASCII코드 , 유니코드 등)를 기준으로 문자를 코드로 변환하는 것을 문자 인코딩(encoding)이라 하고 코드를 문자로 변환하는 것을 문자 디코딩(decoding)이라 한다. Buffer의 디폴트 인코딩 값은 utf-8이지만 다음과 같이 인자값으로 인코딩 값을 전달해 변경할 수도 있다. (일반적인 string 데이터는 utf-8 방식으로 인코딩 되어 메모리에 저장된다.) 다음은 "base64" 방식으로 인코딩을 한 결과를 보여준다.
그리고 디코딩을 할 때에는 다음과 같이 toString( ) 메소드를 사용해서 다시 원래의 문자열로 되돌려 줄 수 있다.
2. Hash ??
암호화를 하는 방식에는 크게 두가지가 있다. 바로 양방향 암호화와 단방향 암호화이다. 양방향 암호화란 암호화 한 방식을 알고 있을 때 암호화된 결과물을 가지고 다시 복호화를 할 수 있는 암호화 방식을 일컫는다. 이와 다르게 단방향 암호화는 복호화가 안되는 방식의 암호화 기법을 일컫는다. 단방향 암호화를 하기위한 여러가지 방법들이 존재하는데 우리가 살펴볼 Hash(해시)는 이러한 단방향 암호화에서 사용되는 용어 중 하나이다.
Hash(해시)란 단방향 암호화 기법으로 해시함수(해시 알고리즘)를 이용하여 고정된 길이의 암호화된 문자열로 변환하는 것을 일컫는다. 여기서 해시함수(Hash Function)는 임의의 길이의 데이터를 고정된 길이의 데이터로 매핑하는 함수를 얘기한다. 이때 매핑 전 원래의 데이터 값을 키(key), 매핑 후 데이터의 값을 해시값(hash value), 매핑하는 과정을 해싱(hashing)이라고 한다.
우리는 "sha-256"이라는 해시 알고리즘을 이용해서 단방향 암호화를 진행해 볼 것이다. Node.js에 있는 "crypto"라는 내장 모듈을 사용하면 sha-256 방식으로 데이터들을 hashing(해싱)할 수 있다. 사용방식은 다음과 같다.
const crypto = require('crypto');
const hash = crypto.createHash('해시 알고리즘').update('해싱할 문자열').digest('인코딩');
위의 코드를 해석해보자면 name이라는 변수에 있는 'bitkunst'라는 값을 sha-256 방식을 사용해서 해시값으로 만들겠다는 의미이다. 그리고 인코딩 값으로는 'hex'를 사용했다. 또 한가지 주목할 점은 name2라는 변수에 임의의 길이의 문자열을 집어넣은 후 해싱을 한 결과 역시 'bitkunst' 와 같은 길이의 해시값을 갖게 되었다는 것이다.
하지만 이렇게 해시값을 사용해서 암호화를 할지라도 완벽하게 보안이 되는 것은 아니다. 해커가 무차별적으로 임의의 값을 입력해서 원래 입력된 값이 무엇인지 알아낼 수 있기 때문이다. 즉, 무수히 많은 값들을 하나하나 대입해 봄으로써 키값을 알아낼 수 있다는 말이다. 이러한 점을 보완하기 위해 키값에 소금같은 존재인 salt 라는 특정한 값을 넣어서 해싱하는 방법을 채택하게 되었다. 다시말해, 암호화를 할 때 특정한 값을 넣어서 암호화의 값을 바꾸는 방식을 의미한다. 사용 방식은 다음과 같다.
const crypto = require('crypto');
const salt = '임의의 salt값';
const name = 'bitkunst';
const hash2 = crypto.createHmac('sha256', Buffer.from(salt)).update(name).digest('hex');
salt 값을 인자값으로 전달할 때 Buffer.from(salt)로 전달하는 이유는 다른 character-set일 경우 salt값으로 다른 값이 들어갈 수 있기 때문에 Buffer.from( )을 사용해서 컴퓨터가 저장하는 형태의 값으로 salt값을 전달해준 것이다. 그리고 위에 보이는 것과 같이 salt값을 넣었을 때 해시값이 다르게 나오는 것을 확인할 수 있다. salt값을 모르고 있는 한 똑같은 해시값을 만들어내는 것은 거의 불가능에 가깝기 때문에 한층 더 보안이 강화된 방법이다. 향후 서버를 만들고 배포를 할 경우 salt 값은 .env 파일 같은 곳에 넣어서 보관하도록 하자.
3. JWT
Buffer와 Hash의 개념에 대해 이해했다면 이제 우리의 최종 목표인 JWT를 이해할 수 있는 준비가 끝난 것이다.
JWT란 Json Web Token의 약자로 json 포맷을 이용해서 사용자의 정보를 저장하는 암호화된 토큰이다. 모바일이나 웹에서 사용자 인증을 위해 사용된다. 사용자를 인증하는 과정에서 우리는 JWT가 아닌 session을 사용할 수도 있다. (해당 내용은 아래 글을 참고 바람.)
2022.02.12 - [Node.js/express] - Node.js - express (10) express-session 사용하기
하지만 세션의 경우 로그인의 주체가 서버이다 보니 여러대의 서버를 돌리는 상황에서는 서버 간에 사용자 정보를 주고받을 수 있도록 연결이 필요해지는 이슈가 발생하게 된다. 또한 동시에 수많은 사용자가 로그인을 했을 경우 서버 쪽에서는 수많은 데이터를 세션에 담아서 저장하고 있어야 한다. 리소스를 많이 잡아먹게 되어 비효율성이 야기되는 상황이 발생할 수 있는 것이다. 이러한 문제를 해결하기 위해 클라이언트가 사용자 인증 정보를 가지고 있게 하도록 한 것이 바로 JWT 방식이다. 즉, 브라우저의 쿠키에 사용자 인증 정보를 담아서 저장해 놓는 방식인 것이다. 단순히 쿠키에 사용자 인증 정보를 담아서 저장한다면 해당 정보들을 조작해서 변조할 수 있게 되어 보안상의 이슈가 발생한다. 하지만 JWT 방식은 이러한 이슈를 해싱을 통해 해결하였다.
JWT는 Header, Payload, Signature 세부분으로 이루어져 있으며 json 형태인 각 부분은 "base64"로 인코딩 되어 표현된다. 각각의 부분에 어떠한 값들이 들어가는지 파악한 후에 코드를 살펴보면서 이해해보도록 하자.
(1) Header : 토큰의 헤더는 typ와 alg이라는 두가지 정보로 구성된다. typ의 값으로는 토큰의 타입이 들어가고 alg에는 사용된 해시 알고리즘이 들어가게 된다.
const header = {
alg: 'HS256',
typ: 'JWT'
}
(2) Payload : 토큰의 페이로드에는 토큰에 담을 정보들이 들어가게 된다. 여기에 담긴 정보의 한조각을 클레임(claim)이라고 부른다.
const payload = {
userid: 'bitkunst',
username: '비트쿤스트'
}
(3) Signature : 서명은 토큰의 유효성을 검증할 때 사용되는 암호화 코드이다. 만약 헤더 또는 페이로드의 정보가 클라이언트에 의해 변경된다면 서명이 무효화된다. 브라우저의 쿠키에 저장된 데이터의 변조 유무를 파악하는데 핵심이 되는 signature는 헤더와 페이로드의 값을 각각 base64로 인코딩 하고, 인코딩한 값을 salt값과 함께 해싱한 다음, 이 값을 다시 base64로 인코딩해서 생성한다.
Header, Payload, Signature를 이용해 생성되는 JWT는 최종적으로 다음의 형태를 띠고 있다. 각각의 부분들 모두 인코딩 된 형태로 들어가게 되며 ' . ' 을 구분자로 해서 Header, Payload, Signature를 구분한다.
JWT가 생성되는 과정의 코드를 살펴보면 다음과 같다.
const crypto = require('crypto');
const header = {
alg: 'HS256',
typ: 'JWT'
}
const payload = {
userid: 'bitkunst',
username: '비트쿤스트'
}
const encodingHeader = Buffer.from(JSON.stringify(header)).toString('base64').replace(/[=]/g, '');
const encodingPayload = Buffer.from(JSON.stringify(payload)).toString('base64').replace(/[=]/g, '');
const signature = crypto.createHmac('sha256', Buffer.from('qwer1234'))
.update(`${encodingHeader}.${encodingPayload}`)
.digest(`base64`)
.replace(/[=]/g, '');
// 실제로 사용할 때는 salt값이 공개되지 않도록 .env 파일 같은 곳에 저장해서 사용하도록 하자.
const jwt = `${encodingHeader}.${encodingPayload}.${signature}`
서버는 이러한 과정을 거쳐 JWT 를 생성하고 쿠키에 토큰을 담아 클라이언트에 저장한다. 이후 클라이언트에서 사용자 인증이 필요한 요청이 올 때마다 쿠키에 담겨있는 인코딩된 header와 payload, 그리고 서버쪽만 알고 있을 salt값을 이용해 다시 signature를 만들게 된다. 그렇게 생성된 signature와 쿠키에 담겨있는 signature 값을 비교해서 일치하는지 여부를 통해 검증하는 과정을 거친다. 만약 클라이언트 쪽에서 payload에 담겨있는 정보들을 조작하거나 변조했다면 서버쪽에서 진행되는 검증과정을 통과하지 못할 것이다.
만약 클라이언트의 쿠키에 다음과 같은 형식으로 JWT가 저장되어 있다고 가정해보자.
const cookie = {
token: jwt
}
서버쪽에서는 아래의 과정을 거쳐 JWT를 검증하게 될 것이다.
const [head, pay, sign] = cookie.token.split('.');
const designature = crypto.createHmac('sha256', Buffer.from('qwer1234'))
.update(`${head}.${pay}`)
.digest('base64')
.replace(/[=]/g, '')
// 클라이언트는 salt값을 모르기 때문에 변조를 할 수가 없다.
// 앞서 얘기한 바와 같이 salt값은 .env 파일 같은 곳에 저장해 놓아야 한다.
console.log(designature === sign)
// false가 나온다면 변조가 된것
// true가 나온다면 변조가 되지 않은 것
토큰 검증이 완료되고 난 후 인코딩된 payload에서 사용자의 정보를 가져다 사용하고 싶다면 아래와 같은 방식으로 디코딩 작업을 수행해주면 된다.
const decodingPayload = JSON.parse(Buffer.from(head, 'base64').toString())
// toString()에 인자값이 없다면 디폴트값은 utf-8
이로써 Buffer, Hash, JWT의 개념에 대해 알아보았다. 다음 포스팅에서는 JWT를 사용해서 실제로 로그인 기능을 구현해보는 시간을 가져보도록 하자.
'Node > Express' 카테고리의 다른 글
Node.js - express (14) Ajax - XMLHttpRequest( ) (0) | 2022.03.06 |
---|---|
Node.js - express (13) JWT 로그인 인증 (0) | 2022.03.05 |
Node.js - express (11) express.json() (0) | 2022.02.15 |
Node.js - express (10) express-session 사용하기 (2) | 2022.02.12 |
Node.js - express (9) express.Router() 사용하기 (0) | 2022.02.10 |