쿠키와 세션은 HTTP의 '비연결성'으로 인한 한계를
해결하기 위해 탄생한 기술입니다
HTTP의 비연결성?
- HTTP는 TCP를 기반으로 하며, 요청과 응답 과정이 1대1로 이루어집니다
- HTTP 통신은 요청에 대한 응답이 이루어지면 연결을 끊습니다
- 요청을 건넨 브라우저를 특정할 수 없습니다
(IP 주소는 식별성이 부족해요..)
로그인 기능을 구현하려면 어떤 클라이언트가 요청을 건넸으며 서버 측에서도
어떤 사용자에게 응답을 전달해야 하는지를 식별할 수 있어야 합니다
그리고 페이지를 이동할 때에도 그 식별 데이터가 유지되어야 합니다
이를 위해 브라우저가 제공하는 기능이 쿠키입니다
쿠키에는 사용자에 관한 정보가 key와 value 형태로 텍스트 파일에 담기며
브라우저의 저장소(작은 데이터베이스)에 저장되어 일정 기간동안만 보관됩니다
그리고 서버는 쿠키에 담긴 사용자 정보를 통해 클라이언트를 특정하고
올바른 대상에게 응답을 줄 수 있게 됩니다
하지만 쿠키에는 두 가지 큰 단점이 있는데요
하나는 사용자 정보가 브라우저에 저장되는 만큼 보안에 취약다는 점과
두번째는 데이터 허용 용량이 매우 작다는 것입니다
이러한 쿠키의 취약점을 극복하기 위해 탄생한 것이 바로 세션입니다
세션은 데이터를 브라우저에 쿠키를 저장하는 것과는 달리 사용자 정보를
서버에서 관리하며, 브라우저를 닫더라도 세션은 일정 기간 유지됩니다
외부의 웹 서버를 사용하는 만큼 쿠키보다 더 많은 데이터를 저장할 수 있습니다
그런데 세션 방식도 쿠키를 이용해서 브라우저 안에 세션 ID를 보관하기 때문에,
(브라우저에 저장된 세션 ID로 사용자를 식별하는 것으로부터 로그인이 이루어집니다)
브라우저 속 세션 ID를 탈취당할 우려가 있다는 보안상의 취약점이 있습니다
그리고 사용자에 대한 다량의 데이터들이 모두 서버 측에 담겨있기 때문에
동시에 많은 사용자의 요청을 처리하려면 그만큼 서버 쪽의 부하도 커지게 된다는 문제점도 있습니다
반면 쿠키를 사용하는 방식은 모든 정보가 사용자 측의 브라우저에 저장되기 때문에
서버의 자원을 거의 사용하지 않죠
(요청에 대한 부하는 다소 늘어날 수 있지만요)
그래서 최근의 트렌드는 세션에서 다시 사용자 브라우저의 역할 비중이 높아지는 쪽으로 흘러가고 있으며
이런 배경 속에서 등장한 것이 바로 JWT입니다
JSON Web Token
JWT(JSON Web Token)는 이름 그대로
JSON 형식을 이용하여 웹에서 사용할 수 있는 토큰을 뜻하며,
사용자에 대한 인증 정보를 '암호화'하여 전달하는 기능을 제공합니다
그리고 여기서 말하는 토큰이란 일종의 규격화된 쿠키입니다
JWT는 암호화한 유저 정보를 토큰에 담아서 HTTP 헤더로 전달하기 때문에
세션 방식보다 가볍게 데이터를 주고받을 수 있다는 장점이 있습니다
JWT라는 새로운 표준의 등장으로 인해 현재는 다수의 사이트가
같은 규격의 쿠키 데이터를 사용하게 되었습니다
이는 이제는 대세가 된 간편 로그인의 탄생 배경이기도 합니다
JWT는 다음과 같은 세 가지 섹션으로 구성됩니다
1) Header
: JWT의 타입과 암호화 알고리즘을 나타내는 부분입니다
{ "alg": "HS256", "typ": "JWT" }
(req.header와 유사합니다)
2) payload
: 실제 인증과 관련된 정보를 담고 있는 부분입니다. 보통은 JSON 형식으로 인코딩 됩니다
{ "sub": "1234567890", "name": "John Doe", "iat": 1516239022 }
(req.body 영역과 유사하며, 헤더와 달리 사이트마다 구성이 천차만별...)
3) signature
: Header와 Payload를 합쳐 암호화된 부분입니다
이를 통해 JWT의 무결성을 검증할 수 있습니다
HMACSHA256( base64UrlEncode(header) + "." + base64UrlEncode(payload), [!])
그리고 JWT는 기존의 HTTP 요청방식과 달리 엔터(빈 줄)을 쓰지 않고
각 섹션이 점으로 구분된다는 특징이 있습니다
(HEADER
.PAYLOAD
.SIGNATURE
)
const crypto = require("crypto");
// Node에서 제공하는 기본 암호화 라이브러리입니다
class JWT {
constructor({ crypto }) {
this.crypto = crypto
}
sign(data, options = {}) {
console.log("sign :", data)
const header = this.encode({typ: "JWT", alg: "HS256"})
const payload = this.encode({ ...data, ...options }) // 옵션은 필요한 경우에 추가적으로 사용할 수 있습니다
const signature = this.createSignature([header, payload])
// 인자를 줄이기 위해 배열로 한번에 보냈습니다
return `${header}.${payload}.${signature}`
// return [header, payload, signature].join(".")
}
// 백엔드에서도 jwt.io와 같은 검증용 코드가 필요합니다 (token과 salt 인자가 필요)
// token:string, salt:string
verify(token, salt) {
// eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyaWQiOiJ3ZWI3NzIyIiwidXNlcm5hbWUiOiJ1c2VyMSJ9.VHJCVUK6TibPAW49ytfrRz6YT4ItfZkKb6t8RgjBkxg
// header, payload > hash화 진행후 기존 hash === 새로운 hash일 때 true 반환 (payload 변조여부를 판단)
const [header, payload, signature] = token.split('.')
const newSignature = this.createSignature([header, payload], salt)
if(newSignature !== signature) {
throw new Error ('토큰이 변조되었습니다')
// 주로 페이로드에서 토큰이 저장된 시간을 판단해서 에러 처리를 할 경우가 많습니다
}
return this.decode(payload)
}
encode(obj) {
return Buffer.from(JSON.stringify(obj)).toString('base64url')
}
// base64(encode 함수의 결과) > utf8 > 객체화
decode(base64) {
return JSON.parse(Buffer.from(base64, 'base64').toString('utf-8'))
}
createSignature(base64urls, salt="web7722"){
// JWT의 signature 섹션을 만들기 위한 메서드입니다
//base64urls = [], header.payload 형태를 만들기 위해 join 메서드 사용하기
const data = base64urls.join(".")
return this.crypto.createHmac('sha256', salt).update(data).digest("base64url")
}
}
const jwt = new JWT({ crypto });
const token = jwt.sign({userid: "web7722", username: "user1"}) // JWT
// console.log(token)
// eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyaWQiOiJ3ZWI3NzIyIiwidXNlcm5hbWUiOiJ1c2VyMSJ9.VHJCVUK6TibPAW49ytfrRz6YT4ItfZkKb6t8RgjBkxg
const payload = jwt.verify('eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyaWQiOiJ3ZWI3NzIyIiwidXNlcm5hbWUiOiJ1c2VyMSJ9.VHJCVUK6TibPAW49ytfrRz6YT4ItfZkKb6t8RgjBkxg')
console.log(payload)
// { userid: 'web7722', username: 'user1' }
salt?
salt
는 암호화 키를 생성할 때 사용되는 문자열을 말합니다
보통 JWT를 생성할 때는 모든 사용자가 같은 salt를 사용하는데,
일반적으로는 서버에서만 알고 있는 값을 사용하며 안전한 장소에서 보관해야 합니다
즉 클라이언트에서는 salt 값을 절대로 알 수 없도록 해야 합니다
아래는 salt 생성용 예제 코드입니다
const crypto = require("crypto");
const salt = crypto.randomBytes(32).toString("hex");
process.env.JWT_SALT = salt;
암호 관련 용어 정리
- 평문 : 암호화되지 않은 정보. 즉, 사람의 눈으로 읽을 수 있는 문자를 뜻합니다
ex) apple1234
- 복호화 : 암호화된 암호를 평문으로 만드는 과정을 뜻합니다
키(key)값이 없으면 암호를 탈취하더라도 복호화를 할 수 없습니다
- 대칭키 : 하나의 키값을 클라이언트와 서버가 같이 사용하는 것을 뜻합니다
둘 중 한 쪽이라도 그 키가 노출되면 암호는 안전을 보장할 수 없습니다- 비대칭키 : 암호화할 때 사용하는 키와 복호화할 때 사용하는 키가 다른 암호화 방식을 뜻합니다
- 단방향 암호화 : 복호화할 수 없는 암호화를 뜻합니다
- 양방향 암호화 : 복호화 가능한 암호화를 뜻합니다
여기서 단방향 암호화는 복호화는 불가능하지만 사용자 인증(authentication)에 사용될 수 있습니다
예시로 비밀번호를 암호화하여 데이터베이스에 저장한다고 가정합니다
사용자가 로그인할 때는 사용자가 입력한 비밀번호를 암호화하여 DB에 저장된 값과 비교하면 됩니다
이렇게 하면 비밀번호를 알 수 없는 상태로 저장하면서도 사용자 인증이 가능해집니다
*블록체인 기술은 단방향 암호화와 비대칭 방식을 함께 사용합니다 (보안성이 높은 이유)