-
5. Hash와 JWTNode.js 2022. 3. 3. 00:15
<목차>
1. 사전지식(1) : Buffer
2. 사전지식(2) : 암호화와 Hash
3. JWT
3-1. 개념
3-2. JWT로 로그인하기1. Buffer
컴퓨터는 0과 1을 알아듣는다. 사람은 프로그래밍 언어로 코드를 짠다.
문자를 바이너리 데이터로 변환하는 것을 인코딩, 반대로 바이너리 데이터를 문자로 변환하는 것을 디코딩이라고 한다.
1byte = 2nibble = 8bit, 1nibble = 4bit
이 니블이라는 단위가 중요한 것은 1니블은 4bit, 즉 크기가 2^4로 16이기 때문이다.
1니블은 하나의 16진수로 변환이 가능하고 따라서 HEX코드를 나타내는 하나의 단위가 된다.(0~f)
buffer의 개념역시 이와 관련되어 있다.
Buffer란 Node.js 에서 제공하는 Binary 데이터를 담을 수 있는 객체이다. 버퍼는 실제로는 바이너리 데이터를 담고 있지만 콘솔에는 16진수로 표현된다.(👈 default인 utf-8사용시. 'base64' 등을 사용하여 다른 방식으로도 변환 가능함)
Buffer는 별도의 라이브러리 설치가 필요없이 node.js에서 바로 사용이 가능한데 이는 node.js가 존재하는 이유 상 당연한 것이다.
기존에는 브라우저에서만 사용이 가능했던 언어인 자바스크립트를 브라우저가 아닌 컴퓨터를 조작하기 위해서 나온 런타임이 node.js이고,
컴퓨터를 조작한다는 것에는 필수적인 것이 이 bit, byte 컨트롤이기 때문이다.
당연하게도 브라우저에서 동작하는 자바스크립트 파일에서는 Buffer의 사용이 불가능하다.
// ❗️ 디폴트 인코딩 값인 UTF-8 적용 const buff1 = Buffer.from('이름') // <Buffer ec 9d b4 eb a6 84> const buff2 = Buffer.from('이') // <Buffer ec 9d b4> const buff3 = Buffer.from('name') // <Buffer 6e 61 6d 65> buff1.toString() // 이름 buff2.toString() // 이 buff3.toString() // name
여기서
e나 c, 9와 d와 같은 한자리가 16진수이며 (2^4, 4bit, 1nibble)
ec, 9d, b4와 같은 한 단위가 1byte (2자리의 16진수, 8bit)
(❗️실제로 컴퓨터 메모리에 저장되는 것은 2진수 11101100(ec), 10011101(9d) 임)
한글은 한 글자에 총 3 byte, 영어는 알파벳 하나에 1byte가 된다.
정리
버퍼란 바이너리로 데이터를 담는 공간!
모오오든 문자는 인코딩 과정을 거쳐 2진수로 변환된다!
2. 암호화와 Hash
서버를 만들 때 유의해야 할 점 중 하나는 비밀번호를 입력받은 그대로 DB에 저장하는 것은 절대절대 해서는 안된다는 것이다. DB가 해킹당하는 순간 고객들의 비밀번호도 전부 해커 손에 넘어가기 때문이다. 비밀번호는 반드시 암호화해서 저장해야 한다.
암호화는 크게 단방향과 양방향으로 나눠진다. 말 그대로 암호로 바꾼 문자열을 다시 문자열로 복구할 수 있다면 양방향, 한번 암호로 바꾸면 기존의 문자열로는 되돌릴 수 없는 것이 단방향이다.
단방향 암호화의 가장 간단한 방법은 해시함수를 사용하는 것이다.
(1) crypto.createHash()
const crypto = require('crypto') // node.js에서 기본적으로 제공하는 크립토 모듈 const hash = crypto.createHash('sha256').update('password').digest('hex')
1. createHash() 안의 'sha256' 혹은 'sha512' 등은 암호화 알고리즘을 의미한다. 512가 256보다 더 길지만 더 안전하다.
2. update() 안의 값은 사용자가 입력한 실제 패스워드이다.
3. digest() 안에는 암호화된 문자열을 표시해줄 인코딩 방식이다. 'hex', 'base64' 등이 있다.
문제는
암호화 알고리즘과 인코딩 방식이 정해져 있으니 입력한 값에 대한 출력값을 누구든지 알 수 있다는 것이다.
어떤 해커가 마음먹고 모든 경우의 수에 해당하는 암호를 전부 입력해본다면 시간은 오래 걸리겠지만 결국 비밀번호를 찾아낼 수 있다는 얘기이다. (이렇게 막무가내로 푸는게 해시를 원본값으로 돌리는 알고리즘을 찾아내는 것보다 차라리 더 빠르다고 한다.)
이를 방지하기 위해 장치를 하나 만들어둔다.
어떤 특정 문자열(salt)을 서버가 아닌 공개하지 않는 환경변수로 두고 이 문자가 암호화 하는데 포함되도록 하여 결과를 변형시키는 방식이다. 비밀번호에 salt값을 덧붙인 뒤 이를 수만번 반복해서 해시화 하여 암호화를 한다. (조금 복잡한 방식이지만 이 방식보다는 아래의 Hmac을 더 많이 사용할 것 같아 짧게 정리만 해둔다)
(참고) https://www.zerocho.com/category/NodeJS/post/593a487c2ed1da0018cff95d
(NodeJS) crypto 모듈을 사용한 암호화
안녕하세요. 이번 시간에는 crypto 모듈을 사용해서 비밀번호를 암호화하는 방법에 대해 알아보겠습니다. 예전 패스포트 강좌에서는 패스포트 기능 설명에 중점을 두었기 때문에 비밀번호는 그
www.zerocho.com
(2) crypto.createHmac( 알고리즘, key )
const hash = crypto.createHmac('sha256', Buffer.from(salt)).update(name).digest('hex')
HMAC (Hash based Message Authentication Code). 이 기술의 핵심은 해싱과 공유키이다.
위의 salt를 사용한 방법과 유사하게 어떤 특수한 키값을 암호생성자(송신자)/확인자(수신자)가 공유하고 이를 사용하여 암호의 유효성을 확인하는 방식이다.
순서는 다음과 같다.
1. 송신자와 수신자 사이에 암호로 사용할 키 값을 공유한다. (동일한 키 값)
2. 송신자는 key를 사용하여 원본 메시지를 해싱한다. 이 해싱된 값이 MAC이다.
3. 송신자는 원본메시지와 MAC을 수신자에게 전달한다.
4. 수신자는 key를 사용해 원본 메시지를 해싱하고 그 값이 전달받은 MAC과 동일한지 비교한다.
5. 동일하다면 원본메시지와 MAC이 위조되지 않은 신뢰할 수 있는 값으로 판단할 수 있다.
그러면 이 암호화 방식이 JWT에서는 어떻게 적용되고 있을까?
아래에서 알아보자.
3. JWT
3-1. 개념
JWT(JSON Web Token).
Json 포맷을 이용하여 사용자 정보를 토큰화 하여 저장하는 방식.이전에 웹서버 통신에서 사용자 정보를 저장하는 방식으로 쿠키와 세션을 배웠다.
각각의 방식에는 장단이 있지만 가장 큰 특징으로는
- 쿠키 : 서버가 아닌 브라우저에 정보를 저장하여 서버의 자원부담을 줄여줌. 클라이언트 측에서 쿠키의 정보가 다 노출이 되어 보안상 취약함.
- 세션 : 쿠키의 보안 상 취약한 점(임의로 수정,삭제 가능)을 보완하기 위해 중요정보를 서버측의 세션에 저장해두고 세션ID를 생성해 쿠키에 이를 대신 담아서 전송. 사용자 수가 늘어나면 세션에 저장할 데이터가 늘어나 자원이 소모됨
이 정도가 있다.
여기서 각각의 장점(서버의 자원부담 감소, 보안 강화)만을 담은 인터넷 표준 인증 방식이 바로 JWT이다.
JWT의 구조는 Header . Payload . Signature 로 구성되어 있다. 각각은 점(.)으로 구분된다.
- Header : 토큰의 타입, 암호화에 사용된 알고리즘 정보를 담음
- Payload : 사용자의 정보를 객체로 담음
- Signature : Header와 Payload의 정보를 이용해 암호화 함. (HMAC 사용)
아래는 Payload 부분에 대한 표준 스펙인데 일반적으로 이러한 내용을 담으면 되고 상황에 따라 필요한 항목만 골라 담는 식으로 써도 무방하다. 아래의 포맷을 전혀 따르지 않아도 문제는 없다
- iss : 토큰 발급자
- sub : 토큰 제목 (사용자에 대한 식별 값)
- aud : 토큰 대상자
- exp : 토큰 만료 시간
- nbf : 토큰 활성 날짜 ( 이 날짜 이전의 토큰은 발급되었어도 활성화 되지 않음)
- iat : 토큰 발급 시간
- jti : JWT 토큰 식별자 (발급자가 여러명인 경우 구분을 위해)
사실 나도 완벽히는 잘 모르겠고.. 지금은 이 항목들을 전부 설정해서 사용할 일도 없을 것 같긴 하다.
일단 사용자를 식별할 수 있는 값과 토큰의 만료시간 정도만 꼭 담아서 저장해두면 좋을 듯 하다.
한가지 유의해야 할 점은 위의 페이로드 내용은 사실 전혀! 보안이 되지 않는다.
JWT.IO
JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.
jwt.io
여길 가서 JWT 값을 넣어보면 헤더와 페이로드 내용은 누구나 다 볼 수 있다.
원본 메세지를 암호화 하지 않고 거의 그대로(인코딩만 된 상태로) 담겨있기 때문이다.
누구나 볼 수 있는 원본메세지를 그대로 통신에 사용한다는 것이 처음에는 이해가 잘 되지 않았다.
보안을 위해 사용하는 기술이 아닌가?
그런데 다시 생각해보니 '보안'이라는 말이 아무도 못보게 숨김(암호화)가 아니라 보이는 정보가 조작되지 않음(무결함)을 뜻하는 것이라는 걸 알았고 이 둘을 잘 구별해야 한다는 걸 알았다. 어차피 봐도 조작하지 못한다면 JWT는 안전한 인증방식이니까.
지켜줘야 할 것은 payload에는 민감한 정보를 담지 않는다! 이거 하나다.
3-2. JWT로 로그인하기
위에서 HMAC을 사용한 암호화 순서를 정리했다. 이것을 JWT를 이용한 로그인 과정으로 다시 설명해보겠다.
1. 송신자와 수신자 사이에 암호로 사용할 키 값을 공유한다. (동일한 키 값)
👉 키를 사용하여 암호화하고 대조하는 것은 서버에서 한다. 동일 서버 내에서도 할 수 있으며, 서버가 나눠져 있더라도 키 값을 공유한 상태면 가능.
2. 송신자는 key를 사용하여 원본 메시지를 해싱한다. 이 해싱된 값이 MAC이다.
👉 로그인 발생 시 post 요청을 처리하는 라우터에서 사용자가 입력한 id, pw와 동일한 db의 유저데이터를 검색한다.
일치하는 정보가 있다면 해당 사용자의 ID(혹은 다른 방식의 식별값)을 payload에 담는다.
원본메시지(헤더와 페이로드)를 이용해 signature 부를 해싱한다. (signature부가 MAC)
3. 송신자는 원본메시지와 MAC을 수신자에게 전달한다.
👉 JWT를 쿠키에 담아 클라이언트 측으로 보낸다. 클라이언트는 로그인 정보가 필요한 페이지에서 해당 값을 서버로 보낸다.
로그인 후 넘어간 메인 화면에서 사용자의 닉네임을 띄워야 해 유저데이터가 필요하다고 해보자
4. 수신자는 key를 사용해 원본 메시지를 해싱하고 그 값이 전달받은 MAC과 동일한지 비교한다.
👉 메인화면의 get 요청 라우터에서 JWT 검증 함수가 실행된다.
이 함수 내에서 JWT의 헤더와 페이로드를 서버가 가지고 있는 키값을 이용해 해싱한다. 그 값이 JWT의 시그니처와 동일한지 비교한다.
5. 동일하다면 원본메시지와 MAC이 위조되지 않은 신뢰할 수 있는 값으로 판단할 수 있다.
👉 동일하다고 판명된다면 해당 유저는 로그인 된 사용자임을 인증받게 된다.
그 후 페이로드를 디코딩하여 사용자의 식별값을 찾고 그 값으로 db나 세션에서 유저 정보를 불러와 필요한 처리를 한다.
이런 방식을 사용하면 서버A에서 로그인을 한 뒤 서버B에 있는 서비스를 이용하고자 할 때
로그인 정보 자체가 클라이언트에 저장되어 있으니 서버를 옮겨가더라도 로그인이 유지된다는 장점이 있다.
단순히 세션만 사용하는 방법에서는 이와같이 여러 서버를 왔다갔다하거나, 서버를 껐다가 키면 로그인이 유지되지 않는다.
(1주짜리 프로젝트 하면서 코드 수정하고 서버 다시 시작할 때마다 다시 로그인하고.. 이걸 엄청 반복했는데 이젠 그러지 않아도 될 듯 하다.)
이게 내가 이해한 JWT의 통신 및 토큰 인증 과정인데
사실 정확하게 이해한건진 모르겠다.
❗️Github : https://github.com/hb707/lecture/tree/main/220302
server.js
app.post('/login', (req, res) => { const { userid, userpw } = req.body if (userid == 'admin' && userpw == 'admin') { // 임시로 id=admin, pw=admin 유저만 const header0 = { "alg": "sha256", "typ": "JWT" } const payload0 = { userid: `${userid}`, name: '로그인된 사용자' } const encodingHeader0 = Buffer.from(JSON.stringify(header0)).toString('base64').replace(/[=]/g, '') const encodingPayload0 = Buffer.from(JSON.stringify(payload0)).toString('base64').replace(/[=]/g, '') const signature0 = crypto.createHmac('sha256', Buffer.from(salt)) .update(`${encodingHeader},${encodingPayload}`) .digest('hex') .replace(/[=]/g, '') const jwt0 = `${encodingHeader0}.${encodingPayload0}.${signature0}` res.setHeader('Set-Cookie', `jwt = ${jwt0}`) res.redirect('/') } else { res.redirect('/') } })
POST /login 👉 JWT 생성
app.get('/', (req, res) => { let login = 0 if (req.headers.cookie != undefined) { let cookies = req.headers.cookie let jwt = cookies.split('=') const [head, pay, sign] = jwt[1].split('.') const decodingPay = JSON.parse(Buffer.from(pay, 'base64').toString()) //decoding하면 string상태임. 이걸 다시 객체로 바꿔줘해서 json.parse()사용 const designature = crypto.createHmac('sha256', Buffer.from(salt)) .update(`${head},${pay}`) .digest('hex') .replace(/[=]/g) if (designature === sign) { login = 1 console.log(`${decodingPay.name}님이 로그인하셨습니다.`) // 페이로드 안의 내용 사용 가능 } else { login = 0 } res.render('index', { login }) } else { res.render('index', { login }) } })
GET / 👉 JWT 검증
'Node.js' 카테고리의 다른 글
4. HTTP 프로토콜, 쿠키와 세션 (0) 2022.02.14 3. Node.js - 로그인 기능 구현 (0) 2022.02.07 2. Node.js - 게시판 CRUD 구현 (0) 2022.02.07 1. Node.js 시작해보기 (0) 2022.01.26 0. Node.js 시작 전 설정하기 (0) 2022.01.25