JWT JSON Web Token

mh·2022년 5월 11일
0

nodeJS

목록 보기
5/5
post-thumbnail

Mo 님의 사진, 출처: Pexels

jwt.io

jwt vs session

session을 이용한 인증/권한부여

클라: 클라이언트, 서버: 서버

클라:로그인요청 ->
서버: 서버메모리 세션에 유저정보 저장 ->
서버: 세션ID를 쿠키로 보냄 ->
클라: 쿠키스토리지(아니면 브라우저가 정하는 스토리지)에 쿠키를 저장하고 세션아이디 쿠키를 이용해서 요청을 보냄 ->
서버: 세션아이디로 유저가 서버에 존재하는지 찾고 검증(verify) 함 ->
서버: 클라에게 응답

jwt를 이용한 인증/권한부여

클라: 로그인요청(이메일,패스워드)
-> 서버: 시크릿키를 가지고 해당 유저용 jwt발행
(차이점: jwt방식에서는 서버에 유저정보를 저장하지 않는다)
jwt에는 사용자에대한 모든 정보가 내장되어있음
-> 서버: 브라우저(클라)에게 jwt전달
-> 클라: 마찬가지로 브라우저가 원하는 스토리지에 jwt 저장하고 jwt를 가지고 요청수행
-> 서버:jwt signature(서명) 검증 및 jwt에서 유저정보를 가져옴
(모종의 이유로 클라이언트나 다른곳 에서 jwt값이 변조되었다면 invalid signature가 됨) jwt를 deserialiezd 해서 해당 유저에게 권한 부여
-> 서버: 응답

세션기반인증에서는 세션ID를 기반으로 사용자를 찾기 위해 조회를 수행해야함,
하지만 JWT에선 사용자 정보가 클라이언트(실제론 JWT토큰)에 저장되기 때문에 서버는 아무것도 저장하지 않아도 됨, 즉 서버가 유저정보를 저장하지 않기때문에 세션이 다른 서버에서도 jwt signature 검증만 하면 인가/인증이 가능해진다.

구조

왼쪽은 인코딩된 JWT 오른쪽은 디코딩된 JWT이다.

HEADER : 인코딩과 디코딩을 담당하는 알고리즘을 결정하는 부분
PAYLOAD : 토큰에 저장하는 모든 정보
VERIFY SIGNATURE: 서명, 토큰의 위/변조가 되었는지 응답을 보내기 전에 확인

첫번째부분 (Header)
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
기본 base 64로 인코딩 됨, jwt 토큰타입 결정

두번째부분 (Payload)
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ
여기서 sub는 id일 역할, name:이름 이렇게 아무거나 원하는 데이터를 넣을 수 잇음
iat: 토큰 발행 시점 exp ,e8e :토큰 만료시점
누군가가 토큰을 탈취할수도있기 때문에 만료시간을 정함

세번째부분 (Signature)
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
사용자가 토큰을 변조하지 않았는지 확인
헤더를 가져와서 base64로 인코딩함 + . + 페이로드를 가져와서 base64로 인코딩
your-256-bit-secret
인증서버에서 정한 비밀 키를 소금으로 추가
이후 헤더에 정의되어 있는 알고리즘으로 해시를 만들고 키의 마지막 부분과 일치하는지 확인
인증서버의 비밀키는 서버의 환경변수 등으로 안전하게 저장되어있다.

준비

npm init -y
npm i express jsonwebtoken dotenv
npm i --save -dev nodemon

env .env에 jwt 비밀 키를 포함할 것이므로 필요

메인 서버 파일이 될 server.js 생성

package.json에 개발을 위한 노드몬 시작 스크립트 입력

npm run dev 로 실행

  • express setting
    express를 불러오고 app에 할당한 후 3000번 포트를 연다

테스트를 위한 posts 배열을 생성해주고 밑에서 바로 요청라우터를 생성한다.

const express = require('express');
const app = express()

const posts = [
    {
        username: 'KIM',
        title: '게시물 1'
    },
    {
        useranme: 'PARK',
        title: '게시물 2'
    }
]

app.get('/posts',(req,res) => {
    res.json(posts)
})

app.listen(3000);

REST Client extenstion으로 요청확인

포스트맨을 이용할 수 있지만 이런방법도 있음

REST Client 설치

request.rest 파일 생성

request.rest에 다음과 같이 입력하고 Send Request를 누르면 요청을 보낼 수 있다.

//request.rest
GET http://localhost:3000/posts

결과 확인

jwt를 이용해서 특정 사용자만 게시물에 접근 할 수 있게 만들기

테스트를 위한 로그인 라우터를 새로 작성한다.

app.post('/login', (req,res) => {
    const username = req.body.username
    const user = { name: username }

    jwt.sign(user, process.env.ACCESS_TOKEN_SECRET)
})

추가로.env환경변수도 로드하도록 한다.

require('dotenv').config()

jwt를 불러온다.

req.body데이터를 읽기위한 bodyparser도 불러온다.

app.use(express.json())

jwt.sign()

sing()의 첫번째 인수로는 직렬화(serialized)할 전송데이터(payload)가 들어간다.

여기서 user 객체를 직렬화해야하므로 user 객체를 만들어준다.

sign()의 두번째 인수로는 jwt생성과 증명에 필요한 비밀키가 들어간다. 노출되어선 안될 값이므로 .env에서 가져온다.

.env에 ACCESS_TOKEN_SECRET 값을 정하기 위해 nodejs crypt라이브러리를 사용해서 키값을 생성한다.

새 터미널을 열고 node를 입력해 노드를 실행한다.
다음과 같이 입력한다.
crypto로 랜덤한 64바이트를 16진수 문자열로 변환한다.

require('crypto').randomBytes(64).toString('hex')

Refresh token값에 쓸 키를 포함해서 총 2개의 키를 생성한다.

생성된 키를 .env에 입력한다.

아직 리프레쉬토큰을 줄 방법이 없으므로 만료일자를 정하지 않고, 응답값으로 이 엑세스토큰값을 주도록 한다.

이제 rest파일로 돌아가서 요청을 테스트한다.

rest 파일에서 ###를 입력하면 새 요청을 보낼 수 있다.

GET http://localhost:3000/posts

###

POST http://localhost:3000/login
Content-Type: application/json

{
    "username": "KIM"
}

요청결과 새로생성된 jwt값을 확인할 수 있다.

이제 실제로 인증이 가능한지 확인하기 위해 인증용 미들웨어를 만들어 테스트한다.

function authenticateToken(req, res, next) {
    
}

이 미들웨어에서 사용자 인증을 거친뒤 그 반환값을 이용하여 포스트에 접근 할 것이기 때문에
포스트 라우터에 해당함수를 사용한다.

app.get('/posts', authenticateToken,(req,res) => {
    res.json(posts)
})

이제 헤더에서 이 토큰을 가져와야 하는데 Bearer라고 표시된다.
다음과 같이 입력하면 해당 값을 가져올 수 있다.

function authenticateToken(req, res, next) {
    Bearer TOKEN
}

accessToken 뒤의 키값은 Bearer 키워드 뒤(TOKEN)에 오게된다.
이값은 헤더에 있으므로 헤더값을 불러온다.

function authenticateToken(req, res, next) {
    const authHeader = req.headers['authorization']
    const token = authHeader.split(' ')[1]
    Bearer TOKEN
}

authHeader에 있는 토큰은 Bearer 암호화된값asdfasdf처럼 공백으로 구분되어있다. split으로 공백으로 나누어준뒤 두번째 값만 token값으로 지정한다.

이 과정에서 authHeader가 있는 경우만 토큰값을 제공하고 그렇지 않을경우 undefined를 반환하게 한다.
그리고 만약 token값이 없다면 401상태를 응답하도록 한다.

function authenticateToken(req, res, next) {
    const authHeader = req.headers['authorization']
    const token = authHeader && authHeader.split(' ')[1]
    if(token == null) return res.sendStatus(401);
}

verify()

마지막으로 jwt 토큰이 유효한지 테스트하기 위해 verify()를 사용한다.

verify()의 첫번째 인수에는 token값이 들어가고 두번째는 sign()에서 사용했던 비밀환경변수가 들어간다. 세번째로는 에러처리와 증명성공시 디코드된 문자열이 콜백으로 들어간다.

function authenticateToken(req, res, next) {
    const authHeader = req.headers['authorization']
    const token = authHeader && authHeader.split(' ')[1]
    if(token == null) return res.sendStatus(401);

    jwt.verify(token,process.env.ACCESS_TOKEN_SECRET,(err, user) => {
        if (err) return res.sendStatus(403)
        req.user = user
        next()
    })
}

콜백으로 err, user를 인수로 넣어주고,
err일시에 403상태를 응답, 토큰을 가지고 있지만 더이상 유효하지 않음을 알려준다.
그리고 verify가 끝나면 user가 증명되었으므로 user값을 들고 next()를 이용해 미들웨어에서 라우터로 넘어가게 해준다.

다음과 같이 포스트 라우터에 authenticateToken 미들웨어에서 받은 user객체를 이용해 포스트작성자와 user객체 안의 name이 일치하면 포스트를 갖고 오도록 한다.

app.get('/posts', authenticateToken,(req,res) => {
    res.json(posts.filter(post => post.username === req.user.name))
})

다음과 같이 username을 위에서 작성한 user객체에 맞춰 요청을 보내고 jwt키를 생성한뒤 그 키를 다시 Authoriztion: Bearer jwt키 로 다시 요청을 보내면 해당 유저의 포스트를 가져올 수 있다.

GET http://localhost:3000/posts
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiS0lNIiwiaWF0IjoxNjUyMjAwNjkzfQ.IhNFNpdYosEqc2y4-PLitwsJLJFS7fYhj0x_k94ozeE

###

POST http://localhost:3000/login
Content-Type: application/json

{
    "username": "KIM"
}

error) 로그인라우터에서 직렬화할 객체의 프로퍼티이름을 user로 보내는바람에 이렇게 설정됬다. name으로 고쳤다. 또 user스키마에서 username을 Kim으로 정했는데 KIM으로 요청을 보내는 바람에 빈배열[]을 응답으로 받았다.

jwt의 장점

jwt 강력한 장점 중 하나는 여러개의 서로 다른 서버에서도 사용가능하다는 것이다.

이번엔 포트넘버가 다른 서버를 하나 만들고 그곳에서도 인증이 되는지 테스트해보자.

준비
server.js의 내용을 복사한 후 server2.js 생성하고 붙여넣는다.
포트넘버를 4000으로 수정한다.
package.json에 시작스크립트 추가

새 터미널을 열고 npm run dev2로 시작

새 서버를 4000번 포트에서 시작한다.

request.rest 파일에서 '/login' 요청은 3000번 포트로
'/post' 요청은 4000번 포트로 보낸다.

3000번 포트로 login요청을 보내 token을 발행한다.

발행한 token을 '/post'요청으로 4000번 포트에서 보낸다.

서로 다른 서버임에도 토큰 인증이 정상적으로 진행됨을 확인할 수 있다.

이처럼 jwt는 서로 다른서버이지만 같은 ACCESS_TOKEN_SECRET(아래의 jwt생성과 증명을 담당하는 비밀키)를 공유한다면
다른 서버에서도 사용할 수 있다.

	const accessToken = jwt.sign(user, process.env.ACCESS_TOKEN_SECRET)
	
    //....
    
    jwt.verify(token,process.env.ACCESS_TOKEN_SECRET,(err, user) => {
        if (err) return res.sendStatus(403)
        console.log("유저",user);
        req.user = user
        next()
    })

이것이 세션 기반 인증과의 큰 차이점인데,
세션 기반 인증을 사용할 때는 세션이 특정 서버에 저장되지만 JWT를 사용하면 모든 정보가 토큰에 있으므로 여러 다른 서버에서 실제로 사용할 수 있다.

Refresh Token

이제 리프레쉬 토큰을 구현해보자 리프레쉬 토큰을 사용하여 인증서버를 가지고 외부의 제3의 서버에서도 사용할 수 있게 만들것이다.
즉, 하나의 서버에서는 토큰의 생성과 삭제, 리프레쉬를 관리하고(인증서버) 다른 하나의 서버에서는 인증을 제외한 다른 API처리를 담당하게 만들 것이다.

인증서버를 명시해주기 위해
server2.js의 이름을 authServer.js로 바꾼다. (오직 인증만 담당)

server.js는 토큰생성기능을 뺀 나머지 api요청을 담당할 것이다.

server.js에서 로그인 라우터를 삭제한다.
server에서는 사용자 증명 후 API요청을 처리한다.

require('dotenv').config()

const express = require('express');
const app = express()

const jwt = require('jsonwebtoken');

app.use(express.json())

const posts = [
    {
        username: 'KIM',
        title: '게시물 1'
    },
    {
        username: 'PARK',
        title: '게시물 2'
    }
]

app.get('/posts', authenticateToken,(req,res) => {
    res.json(posts.filter(post => post.username === req.user.name))
})

// app.post('/login', (req,res) => {
//     //Authenticate User
//     const username = req.body.username
//     const user = { name: username }

//     const accessToken = jwt.sign(user, process.env.ACCESS_TOKEN_SECRET)
//     res.json({ accessToken: accessToken })
// })

function authenticateToken(req, res, next) {
    const authHeader = req.headers['authorization']
    const token = authHeader && authHeader.split(' ')[1]
    if(token == null) return res.sendStatus(401);

    jwt.verify(token,process.env.ACCESS_TOKEN_SECRET,(err, user) => {
        if (err) return res.sendStatus(403)
        console.log("유저",user);
        req.user = user
        next()
    })
}

app.listen(3000);

authServer.js에서도 담당하지 않는 post 라우터를 삭제한다.

authServer.js

require('dotenv').config()

const express = require('express');
const app = express()

const jwt = require('jsonwebtoken');

app.use(express.json())

// const posts = [
//     {
//         username: 'KIM',
//         title: '게시물 1'
//     },
//     {
//         username: 'PARK',
//         title: '게시물 2'
//     }
// ]

// app.get('/posts', authenticateToken,(req,res) => {
//     res.json(posts.filter(post => post.username === req.user.name))
// })

app.post('/login', (req,res) => {
    //Authenticate User
    const username = req.body.username
    const user = { name: username }

    const accessToken = jwt.sign(user, process.env.ACCESS_TOKEN_SECRET)
    res.json({ accessToken: accessToken })
})

function authenticateToken(req, res, next) {
    const authHeader = req.headers['authorization']
    const token = authHeader && authHeader.split(' ')[1]
    if(token == null) return res.sendStatus(401);

    jwt.verify(token,process.env.ACCESS_TOKEN_SECRET,(err, user) => {
        if (err) return res.sendStatus(403)
        console.log("유저",user);
        req.user = user
        next()
    })
}

app.listen(4000);

왜 리프레쉬 토큰이 필요한가

우리가 처음 토큰을 만들었을때 토큰에는 유효기간이 없다.
이말은 즉 토큰을 가진사람이라면 어느누구나 요청을 마음대로 보낼 수 있으며
토큰을 가진한 영원히 해당 계정이나 요청에 접근할 수 있게 된다.

따라서 원본토큰에 짧은 유효기간을 설정하고 리프레쉬토큰을 이용해 새 토큰을 얻게된다. 만약 토큰이 누군가에게 탈취당할시에는 리프레쉬토큰과 일치하지 않는다면 로그인을 취소할 수 있게 만든다.

두번째로는 인증서버와 API서버를 분리할 수 있어서 서버스케일링 면에서도 유리해진다. 로그인요청이 많다면 API서버를 늘리지 않고 인증서버만 늘리면 된다.

리프레쉬 토큰 발행

function authenticateToken(req, res, next) {
    const authHeader = req.headers['authorization']
    const token = authHeader && authHeader.split(' ')[1]
    if(token == null) return res.sendStatus(401);

    jwt.verify(token,process.env.ACCESS_TOKEN_SECRET,(err, user) => {
        if (err) return res.sendStatus(403)
        console.log("유저",user);
        req.user = user
        next()
    })
}

먼저 verify를 담당하던 authenticateToken 미들웨어를 지우고
sign()을 이용해 새로 토큰을 발행해주는 미들웨어를 작성한다.

function generateAccessToken(user) {
    return jwt.sign(user, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15s' })
}


expiresIn이 토큰만료시간을 정해주는데 리프레쉬 토큰을 확인하기 위해서 15초로 설정해놓았다.

app.post('/login', (req,res) => {
    //Authenticate User
    const username = req.body.username
    const user = { name: username }

    const accessToken = generateAccessToken(user)
    res.json({ accessToken: accessToken })
})

function generateAccessToken(user) {
    return jwt.sign(user, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15s' })
}

로그인 라우터의 토큰발급 부분인 accessToken에 generateAccessToken(user)를 할당한다.

이제 15초 후엔 토큰이 만료되기때문에, 리프레쉬 토큰을 작성해야한다.
refreshToken 이란 이름으로 새 토큰을 발행한다.
차이점이라면 비밀키로 crypto로 생성했던 키 중에서 REFRESH_TOKEN_SECRET을 사용하는것 뿐이다.

const refreshToken = jwt.sign(user, process.env.REFRESH_TOKEN_SECRET)

마지막으로 리프레쉬 토큰을 토큰과 같이 반환한다.

app.post('/login', (req,res) => {
    //Authenticate User
    const username = req.body.username
    const user = { name: username }

    const accessToken = generateAccessToken(user)
    const refreshToken = jwt.sign(user, process.env.REFRESH_TOKEN_SECRET)
    res.json({ accessToken: accessToken ,refreshToken: refreshToken})
})

이제 request.rest로 이동해서 4000번 포트로 로그인 요청을 보낸다.

다음과 같이 accessToken과 refreshToken을 같이 반환받은 것을 볼 수 있다.
accessToken을 이용해 3000번 포트에서 포스트요청을 시도한다.

하지만 이글을 쓰는 사이에 15초가 지났기 때문에 Forbidden이 뜨며 접근이 제한된다.

  • 참고: 요청주소가 잘못되었을때 응답

다시 4000번 포트로 login요청을 보내고 재빠르게 3000번 포트로 post요청을 보낸다.

토큰이 만료 이전이므로 정상적으로 응답값을 받을 수 있다.

이제 만료시에 리프레쉬 토큰을 보내주는 라우터를 작성한다.

app.post('/token',(req,res) => {
    const refreshToken = req.body.token
})

요청예시

POST http://localhost:3000/token
Content-Type: application/json

{
    "token":"사용자가가지고있는리프레쉬토큰"
}

리프레쉬토큰 보관릉 위해 로컬스토리지를 활용한다.

리프레쉬토큰을 받기 위해 다음과 같이 빈배열을 선언한다. 좋은방법은 아니지만 연습용으로 사용하기 위함임

로그인 시 리프레쉬토큰을 빈 배열에 넣도록 해준다.

let refreshTokens = []

app.post('/login', (req,res) => {
    //Authenticate User
    const username = req.body.username
    const user = { name: username }

    const accessToken = generateAccessToken(user)
    const refreshToken = jwt.sign(user, process.env.REFRESH_TOKEN_SECRET)
    refreshTokens.push(refreshToken);
    res.json({ accessToken: accessToken ,refreshToken: refreshToken})
})

이제 토큰요청을 받았을때 예외처리를 작성해준다.
refreshToken의 값이 없을때(null) 401상태를 응답하고
현재 refreshTokens 배열에 refreshToken이 포함되어있지 않다면 403forbidden을 응답해준다.

app.post('/token',(req,res) => {
    const refreshToken = req.body.token
    if(refreshToken == null) return res.sendStatus(401)
    if(!refreshTokens.includes(refreshToken)) return res.sendStatus(403)
    jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, (err, user) => {
        if(err) return res.sendStatus(403)
        const accessToken = generateAccessToken({ name: user.name})
        res.json({ accessToken: accessToken })
    })
})

이제 jwt.verify로 리프레쉬토큰이 유효한지 증명한 뒤 err시에는 403응답을
유효한 리프레쉬토큰이라면 반환된 user객체에서 name만을 사용해서 (name을 통째로 넣으면 만료날짜 생성날짜 등 기타 정보가 들어가버리기 때문에) 새 accesssToken을 발행해준다. 마지막으로 발행한 토큰을 응답값에 넣어 보내준다.

request.rest에서 요청테스트

  1. 로그인으로 토큰 발급받고
    포스트에는 accesstoken token요청에는 refresh토큰 입력

  2. 만료될때까지 포스트 요청 보내기

  3. 만료된 후 token요청 보내서 새 토큰 발급받기

  4. 새 토큰값으로 포스트 요청 보내기

jwt 토큰은 이와같이 3000, 4000번 포트 두 곳에서(서로다른서버) 사용가능하다.

인증해제(로그아웃)

여기서 문제점은 유저가 세션을 종료했음에도 불구하고 리프레쉬토큰이 남아있다면 계속 새 토큰을 만들 수 있어 영원히 접근이 가능해진다는 점이다.

그렇기 때문에 리프레쉬 로그아웃 시 리프레쉬 토큰을 제거해야한다.

로그아웃 요청을 보냈을 때, 현재배열에 들어있는 리프레쉬토큰과 요청할때 담긴 리프레쉬 토큰이 다른것만 남겨놓고 성공적으로 제거되었다는 상태(204)응답을 보낸다.

app.delete('/logout', (req,res) => {
    refreshTokens = refreshTokens.filter(token => token !== req.body.token)
    res.sendStatus(204)
})

request.rest 요청 테스트
1. login 요청으로 새 토큰 발급

  1. token 요청에 refreshToken값 입력 logout요청에도 refreshToken값 입력

  2. logout 요청 후 token 요청 보내보기

  • logout 요청 시 데이터 사라짐

  • logout 후 token 요청 시 refreshToken 데이터가 없으므로 토큰 재발행 불가

profile
🤪🤪🤪🤪🤪

0개의 댓글