일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 블록체인
- JavaScript
- github
- 이더리움
- tcp
- truffle
- erc
- blockchain
- ERC20
- Programming
- ethers
- geth
- Docker
- 스마트 컨트랙트
- git
- web
- MySQL
- Ethereum
- server
- web3
- 트랜잭션
- web3.js
- erc721
- solidity
- NFT
- 제어의역전
- Python
- 솔리디티
- ERC165
- 네트워크
- Today
- Total
멍개의 연구소
[서버] 인증에 활용하는 JWT는 어떻게 보안을 유지할 수 있는가? 본문
JWT는 보안 측면에서 2가지 문제점을 제시할 수 있습니다.
1. 악성 사용자가 생성하는 JWT
2. JWT 탈취
JWT 탈취는 인증/인가 부분에서 다루는 내용중 하나가 엑세스 토큰이 만료되어 해당 엑세스 토큰을 가지고 리프레시 토큰을 받아와서 리프레시 토큰으로 다시 엑세스 토큰을 발급받을 수 있다면, 악성 사용자가 다른 사용자의 만료된 엑세스 토큰을 탈취하여 다른 사용자의 정보를 가진 엑세스 토큰을 사용할 수 있습니다
● 악성사용자가 생성하는 JWT
첫 번째 문제는 악성사용자가 JWT를 생성하는 경우입니다.
이 경우는 일어나기 매우 어렵습니다.
이해하기 위해선 JWT의 구조를 이해해야 합니다.
· JWT 구조
JWT는 3개의 부분으로 이루어져 있습니다.
header.payload.signature
헤더 . 내용 . 서명
▶ 헤더
헤더는 타입과 해시 알고리즘 정보를 가진 JSON을 Baes64로 인코딩한 값입니다
{
"typ": "JWT",
"alg": "HS256"
}
▶ 페이로드
페이로드는 토큰의 정보를 가지고 있습니다. 페이로드도 헤더처럼 JSON을 인코딩한 값입니다. 이때 키-값의 한 쌍을 클레임(Claim)이라고 부릅니다. 클레임은 등록된 클레임, 공개 클레임, 비공개 클레임으로 나뉩니다.
다음은 등록된 클레임입니다. 등록된 클레임은 토큰의 정보를 가지는 이미 정해진 클레임입니다.
{
iss: 토큰 발급자,
sub: 클레임의 제목,
aud: 수신자,
exp: 만료시간,
nbf: Not Before를 의미. NumericDate 형식으로 지정된 날짜 이전에는 해당 토큰을 처리하지 않음,
iat: 토큰 발급시간,
jti: JWT ID, JWT 고유값,
}
공개 클레임은 충돌이 방지된 이름을 가집니다. 일반적으로 URI 형태를 가집니다.
{
"URI": true
}
비공개 클레임은 클라이언트와 서버간 합의된 내용을 가집니다.
{
uid: 1,
name: 'pjt'
}
등록된 클레임, 공개 클래임, 비공개 클레임을 하나의 JSON으로 만들어 Base64로 인코딩한 값입니다.
▶ 서명
JWT에서 가장 중요한 부분입니다. 서명은 토큰을 인코딩하고 유효성 검증시 사용합니다. 서명의 생성 과정은 다음과 같습니다.
1. 헤더와 페이로드를 Base64로 인코딩
2. 헤더에서 정의한 알고리즘을 이용하여 비밀키를 이용하여 해싱
3. 해싱된 결과를 Base64로 인코딩
· 구현하기
JWT를 직접 만들어 보겠습니다.
▶ 헤더와 페이로드 base64 인코딩
const header = {
alg: "HS512",
typ: "JWT"
}
const payload = {
sub: "1234567890",
iat: "1648859796032",
name: "John Doe",
admin: true,
}
function base64(obj) {
return Buffer
.from(JSON.stringify(obj))
.toString('base64')
.replace(/=/gi, "") // replaceAll('=', '')
}
const didEncodeHeader = base64(header)
const didEncodePayload = base64(payload)
console.log('header: ',didEncodeHeader);
console.log('payload: ',didEncodePayload);
▶ 서명
이제 이를 이용하여 서명을 해보겠습니다.
const crypto = require('crypto')
const SECRET_KEY = 'mmmm123';
const signature = crypto
.createHmac("sha512", SECRET_KEY)
.update(`${didEncodeHeader}.${didEncodePayload}`) // 해싱
.digest("base64") // 해싱된 결과 base64 인코딩
.replace(/=/gi, "") // replaceAll('=', '')
console.log(signature)
여기서 핵심은 SCRET_KEY입니다.
5+pI/iAYN1KRWvxsvSUdB593Q3EtwyvI1FcqBY/1UHu9rAh1vlU9dBkKZwfOdCvsa1QNe7izNQEVDPgeil75bw
이렇게 생성된 헤더, 페이로드, 서명을 마침표(.)로 이어주면 됩니다.
const jwt = `${didEncodeHeader}.${didEncodePayload}.${signature}`
console.log(jwt)
eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoiMTY0ODg1OTc5NjAzMiIsIm5hbWUiOiJKb2huIERvZSIsImFkbWluIjp0cnVlfQ.5+pI/iAYN1KRWvxsvSUdB593Q3EtwyvI1FcqBY/1UHu9rAh1vlU9dBkKZwfOdCvsa1QNe7izNQEVDPgeil75bw
· verify 구현하기
그렇다면 JWT를 어떻게 검증할까요? 이 토큰이 정상적으로 만들어진 토큰인지 아닌지
바로 전달받은 JWT에서 헤더와 페이로드를 분리한 후 시크릿키를 이용하여 서명을 하면 됩니다. 이때 전달받은 JWT의 서명과 새롭게 만든 서명이 동일하다면 정상적인 토큰이며 그렇지 않을경우 비정상적인 토큰이 됩니다. 시크릿키의 경우는 서버에 보관됩니다. 그렇기 때문에 악의적인 사용자는 시크릿키를 알 수 없으므로 정상적인 서명정보를 만들수 없습니다.
const crypto = require('crypto')
function verify(jwt, secret) {
const [header, payload, signature] = jwt.split('.');
const signatureRecover = crypto
.createHmac('sha512', secret)
.update(`${header}.${payload}`) // 해싱
.digest("base64") // 해싱된 결과 base64 인코딩
.replace(/=/gi, "") // replaceAll('=', '')
return signatureRecover === signature;
}
const jwt = 'eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoiMTY0ODg1OTc5NjAzMiIsIm5hbWUiOiJKb2huIERvZSIsImFkbWluIjp0cnVlfQ.5+pI/iAYN1KRWvxsvSUdB593Q3EtwyvI1FcqBY/1UHu9rAh1vlU9dBkKZwfOdCvsa1QNe7izNQEVDPgeil75bw'
const SECRET_KEY = 'mmmm123';
const rst1 = verify(jwt, SECRET_KEY)
const rst2 = verify(jwt, '1')
console.log(rst1) // true
console.log(rst2) // false
지금 여기서는 해시 알고리즘을 sha512로 고정했는데 이 부분은 헤더의 alg에 따라 변경해주면 됩니다.
function verify(jwt, secret) {
const [header, payload, signature] = jwt.split('.');
const decodeHeader = JSON.parse(Buffer.from(header, "base64").toString('utf8'))
const algMap = {
HS512: 'sha512'
}
const signatureRecover = crypto
.createHmac(algMap[decodeHeader.alg], secret)
.update(`${header}.${payload}`) // 해싱
.digest("base64") // 해싱된 결과 base64 인코딩
.replace(/=/gi, "") // replaceAll('=', '')
return signatureRecover === signature;
}
이런느낌으로 만들면 됩니다.
다음과 같이 모듈화 할 수 있습니다.
const crypto = require('crypto');
const { threadId } = require('worker_threads');
class JWT {
constructor(secret) {
this.secret = secret;
this.token = '';
}
decode() {
if (!this.token) throw new Error('token is not empty');
const [header, payload, signature] = this.token.split('.');
return {
header: decodeBase64(header),
payload: decodeBase64(payload),
}
}
sign (payload, options = {iat: new Date().getTime()}) {
const header = {
alg: "HS512",
typ: "JWT",
};
const didEncodeHeader = encodeBase64(header);
const didEncodePayload = encodeBase64({...payload, ...options});
const signature = crypto
.createHmac("sha512", this.secret)
.update(`${didEncodeHeader}.${didEncodePayload}`)
.digest("base64") // 해싱된 결과 base64 인코딩
.replace(/=/gi, ""); // replaceAll('=', '');
const token = `${didEncodeHeader}.${didEncodePayload}.${signature}`;
this.token = token;
return token
}
verify(secret, token = this.token) {
if(!this.token) throw new Error ('token is not empty');
const [didEncodeHeader, didEncodePayload, signature] = token.split(".");
const isValid = crypto
.createHmac("sha512", secret)
.update(`${didEncodeHeader}.${didEncodePayload}`)
.digest("base64") // 해싱된 결과 base64 인코딩
.replace(/=/gi, "") // replaceAll('=', '');
return isValid === signature;
}
}
function encodeBase64(obj) {
return Buffer
.from(JSON.stringify(obj))
.toString('base64')
.replace(/=/gi, "") // replaceAll('=', '')
}
function decodeBase64(str) {
return JSON.parse(Buffer.from(str, "base64").toString('utf8'))
}
exports.JWT = JWT;
다음과 같이 사용할 수 있습니다. 물론 jwt는 잘 만들어진 라이브러리가 이미 있으므로 npm에서 jwt를 가져다 사용하면 됩니다.
const { JWT } = require('./jwt');
const SECRET_KEY = "mmmm123";
const jwt = new JWT(SECRET_KEY);
const payload = {
name: '멍개',
age: 30
}
const token = jwt.sign(payload);
console.log(token)
console.log(jwt.decode())
const verify1 = jwt.verify(SECRET_KEY, token);
const verify2 = jwt.verify('adsfsadf', token);
console.log(verify1) // true
console.log(verify2) // false
● JWT 탈취
두 번째 문제는 JWT 형태로 만들어진 엑세스 토큰이 탈취되는 경우입니다.
먼저, 엑세스 토큰이 재발급 되는 경우는 다음과 같습니다.
1. 로그아웃을 했을 때
2. 만료시간이 지났을 때
사용자가 로그아웃했다면 해당 엑세스 토큰은 더 이상 사용되면 안됩니다. 하지만 JWT기반으로 만들어진 엑세스 토큰은 자체적으로 로그아웃이 되었는지 안 되었는지 알 수 없습니다. 여기서 발생된 문제는 다음과 같습니다.
A 사용자가 로그아웃을 함.
B 사용자가 A 사용자의 로그아웃 했을때 엑세스 토큰 탈취
B 사용자가 A 사용자의 액세스 토큰을 가지고 리프레쉬 토큰 요청 후 A 사용자 권한을 가진 엑세스 토큰 발급
B 사용자가 A 사용자인 첫 서버 호출
이를 해결하기 위해 서버측에선 추가적인 작업이 필요합니다 바로 블랙리스트 테이블을 별도로 구성하여 로그아웃 또는 만료시간이 지났을 때 해당 엑세스을 블랙리스트 항목에 추가
서버는 엑세스 토큰 재발급을 위해 리프레시 토큰 조회 요청이 들어오면 엑세스 토큰의 유효성 검사와 함께 블랙리스트에 포함됐는지 확인합니다. 여기서 문제가 있습니다. 바로 액세스 토큰을 재발급 주기가 상당히 짧다는 것 입니다. 그렇기 때문에 서비스가 조금만 운영되어도 블랙리스트에 등록된 엑세스 토큰은 상당히 많아집니다.
이를 위해 추가적으로 제약을 더 넣어줍니다. 리프레시 토큰 요청이 들어오면 전달된 엑세스 토큰 만료시간이 T시간 지났다면 유효성 실패로 간주합니다. 그리고 블랙리스트는 T시간마다 배치를 돌려 비워줍니다. 그리고 엑세스 토큰 블랙리스트는 인메모리 디비를 활용하여 조회 속도를 높일 수 있습니다.
추가적으로 리프레시 토큰도 만료가 되었가나 더 이상 사용이 불가능하다면 이를 블랙리스트로 넣어둬야 합니다.
'서버' 카테고리의 다른 글
[kitura-swift] swift server side - Kitura 맛보기 (0) | 2022.08.27 |
---|---|
ssh-keygen을 이용한 ssh 키 파일 생성 (0) | 2017.04.28 |
azure-cli를 이용하여 azure 사용하기 (0) | 2017.04.27 |