jwt / refresh token
Introduction
json web token 보안방법에서 refresh token에 대해서 차근차근 포스팅하겠다. 처음부터 와라라락 써버리면 읽는 사람들은 머리가 터질것이기 때문.
공부 목적이길래 코드가 매우 더러울것이다. 누군가가 jwt인증을 쓰고싶어서 이 포스팅을 본 후, 이 코드를 복붙해 가지 말길 바란다. 원래 이렇게 안쓰니까. 제발
# refresh token은 어떻게 생겼을까?
난 처음에는 access token과 refresh token은 다르게 생긴줄 알았다.
하지만 두개는 각기 다른 토큰일뿐 생성 및 방식 자체는 아예 똑같다.
const jwt = require("jsonwebtoken");
const dotenv = require("dotenv");
dotenv.config();
const make_new_access_token = (payloadInfo) => {
const accessTokenSecret = process.env.ACCESS_TOKEN_KEY;
const accessToken = jwt.sign({ info: payloadInfo }, accessTokenSecret, {
expiresIn: "15m",
});
return accessToken;
};
const make_new_refresh_token = (payloadInfo) => {
const refreshTokenSecret = process.env.REFRESH_TOKEN_KEY;
const refreshToken = jwt.sign({ info: payloadInfo }, refreshTokenSecret, {
expiresIn: "7d",
});
return refreshToken;
};
console.log(make_new_access_token("무찌똥"));
console.log(make_new_refresh_token("무찌똥"));
각각 refresh token과 access token을 만들어주는 함수를 만들었고 그걸 출력해봤다.
잘 만들어준다.
refresh토큰을 만드는 방법이나, access token을 만드는 방법이나 둘다 만드는 방법은 똑같은 시스템이다. jtw.sign메서드로 만들어진다.
# 그럼 일단 두 토큰의 차이점을 코드에서 확인하고 가보자
const make_new_access_token = (payloadInfo) => {
const accessTokenSecret = process.env.ACCESS_TOKEN_KEY; // 토큰키를 따로만듦
const accessToken = jwt.sign({ info: payloadInfo }, accessTokenSecret, {
expiresIn: "15m", //유효시간이 짧음
});
return accessToken;
};
const make_new_refresh_token = (payloadInfo) => {
const refreshTokenSecret = process.env.REFRESH_TOKEN_KEY; // 토큰키를 따로만듦
const refreshToken = jwt.sign({ info: payloadInfo }, refreshTokenSecret, {
expiresIn: "7d", // 유효시간이 김
});
return refreshToken;
};
두 코드의 차이점은 일단 각 jwt sign메서드에서 서로 다른 키값을 사용하여 토큰을 생성한다는 점이다. 때문에 서로 다른 별개의 인증절차를 밟는다. 즉 access token을 refresh토큰으로 쓸수없고 refresh token을 access token으로 쓸수 없게 개별적으로 관리한다는 소리임.
그리고 유효시간이 서로 다르다. access token은 유효시간이 대게 짧고, refresh 토큰은 유효시간이 길다.
# refresh 토큰은 그럼 왜 쓰는데?
솔찍히 이건 유저 편의성 때문에 쓰는거다. 유저가 access token이 만료됐어도 로그인을 유지할수 있게 하는 기능임.
(세션관리측면에서 보안관리도 좀 있지만 난 그냥 유저편의성이라고 생각함)
플로우를 그림으로 보면 단순하다(코드로 보면 ㅈ같지만)
첫번쨰로 사용자가 인증이 필요한 api요청을 헤더에 access token을 담아서 요청하면, 서버가 요청을 받는다.
근데 이 토큰은 만료시간 (위 코드에서 15분)이 지난 토큰이다. 서버는 만료시간이 지났다는걸 확인하고 클라이언트한테 토큰이 만료됐다는 응답을 준다.
그럼 클라이언트는 서버에게 아까 만료된 access token과 refresh토큰(만료시간 7일)을 함께 보낸다
서버는 다시 이 토큰들을 검증하는데, access token은 만료되었어도 refresh token이 유효하므로, 새로운 access 토큰을 발급해준다. 그럼 사용자는 계속 인증이 필요한 서비스를 이용할 수 있는것이다.
여기서 핵심은 access토큰이 '만료'됐다는 조건이 중요하다. access token이 만료되어 서버에서 refresh토큰을 발급하는 조건은 단지 refresh토큰이 유효하다는것이 아니라, access token도 만료되었다는 조건도 같이 and조건으로 묶여야 refresh이 유효함으로 인한 access token 재발급이 이뤄진다는거다.
# 왜 만료시간 확인하고 access token과 refresh토큰을 같이 보낼까?
서버가 refresh토큰 검증하려고 달라했으니 refresh token만 주면 되는거 아냐? 하는데
만약 해커가 유효기간이 긴(7일짜리)를 탈취했으면, 백단 서버의 refresh토큰 검증하는 api에다가 refresh 토큰만 보내면 바로 쌩썡한 access token을 무한정 발급받을 수 있다.
보안적으로 생각하면 토큰의 payload와 db유저정보 등등도 다 체크해야하긴함.
암튼 그래서 만료된 access token도 같이 보내서 유효기간 지난것도 검증을 함께 하는것이다.
사실 jwt토큰 검증 flow면에서 각기 다른 성향이 적용된다. 예를들어 토큰값과 발급시간등등 정보를 db에 넣어놔서 더욱 보안적인 측면을 신경쓰기도 하고.. 누구는 모든 요청에 access token과 refresh토큰을 함꼐 보내기도 하고..
그냥 모든 요청 받을떄마다 새로운 access token과 refresh 토큰을 발급해주기도하고.. 누군 이건 트래픽낭비라고도하고
개발자마다 다 다르게 운영함
암튼 이번 포스팅에선 최대한 쉽게 써보려고함
# 만약 access token과 refresh token둘다 만료가 됐으면?
걍 로그인 새로하라고 해야함
# 자 이제 차근차근 코드를 써보자.
먼저 프론트단에 로그인버튼을 눌렀다. 아이디 비번 적는건 귀찮아서 생략.
이 버튼 누르면 /login이라는 api요청을 백단쪽으로 하게된다.
const login = () => {
axios.post("/api/login", { id: "무찌", pw: "엉덩이" }).then((res) => {
localStorage.setItem("access_token", res.data.access_token);
localStorage.setItem("refresh_token", res.data.refresh_token);
});
};
저 버튼에 연결한 login함수는 이렇게 생겼다.
axios login요청을 한다.
const express = require("express");
const app = express();
const bodyParser = require("body-parser");
const jwt = require("jsonwebtoken");
const dotenv = require("dotenv");
dotenv.config();
app.use(bodyParser.json());
const port = process.env.PORT;
const make_new_access_token = (payloadInfo) => {
//원래는 여기서 payload에넣을거 정해서 밑에 생략한데다가 너줘야함
//보안 더 검증하려면 해당 payload정보가 db에 있는지도 검색
const accessTokenSecret = process.env.ACCESS_TOKEN_KEY;
const accessToken = jwt.sign({ info: "생략" }, accessTokenSecret, {
expiresIn: "15m",
});
return accessToken;
};
const make_new_refresh_token = (payloadInfo) => {
//원래는 여기서 payload에넣을거 정해서 밑에 생략한데다가 너줘야함
//보안 더 검증하려면 해당 payload정보가 db에 있는지도 검색
const refreshTokenSecret = process.env.REFRESH_TOKEN_KEY;
const refreshToken = jwt.sign({ info: "생략" }, refreshTokenSecret, {
expiresIn: "7d",
});
return refreshToken;
};
app.post("/api/login", (req, res, next) => {
//원래는 여기서 요청을 받은 아이디랑 비번가지고 db검색하고
//payload에 유저 이멜이던가 닉네임같은 정보를 넣어주어야함
const access_token = make_new_access_token("페이로드정보");
const refresh_token = make_new_refresh_token("페이로드정보");
res.json({
access_token: access_token,
refresh_token: refresh_token,
});
});
app.listen(port, () => {
console.log("서버시작");
});
백단코드는 이렇게생겼다. 어휴씨발
아까 설명했던 새로운 access token, refresh token함수를 그대로 썼고 서버로 동작하게 express로 헀음
그리고 프론트에서 /login으로 api요청을 받았으니 백단에서 app.post로 요청처리하게 했음.
응답으론 아까 토큰 만드는 함수로 access token과 refresh token을 만들어서 보내줌.
const login = () => {
axios.post("/api/login", { id: "무찌", pw: "엉덩이" }).then((res) => {
localStorage.setItem("access_token", res.data.access_token);
localStorage.setItem("refresh_token", res.data.refresh_token);
});
};
프론트 로그인 함수를 다시보면 응답으로 받은 access token과 refresh토큰을 로컬스토리지에 저장한다.
뭐 세션이나 쿠키에 저장할거면 그렇게해도됨.
저기 주석으로 주절주절 달아놨는데 원래는 login api요청을 받을때 사용자 아이디 비번을 받으니까 이걸가지고 db검색해서 맞는지 확인하고, 맞다면 사용자의 덜민감한 정보(닉네임이나 이메일같은거)를 payload에 정리해서 담아서 토큰을 생성한다.
이 토큰을 보내주는거임.
이렇게하면 다음 토큰 검증때 좀더 깐깐하게 할 수 있으니까.
# 자이제 access token을 검증하는 코드를 만들어보자
프론트에 mypage button을 만들어줬음. my page가니까 인증이 필요하겠지?
const mypage = () => {
const access_token = localStorage.getItem("access_token");
axios
.get("/api/mypage", {
headers: {
Authorization: JSON.stringify({ access_token: access_token }),
},
})
.then((res) => {
alert(res.data.message);
})
.catch((error) => {
alert(error.response.data.message);
});
};
그리고 저 버튼엔 이렇게 axios get 요청을 accesstoken을 헤더값에 담아서 보내는 함수를 연결함
app.get("/api/mypage", (req, res, next) => {
verifyAT(req, res);
});
const verifyAT = (req, res) => {
const token = JSON.parse(req.headers.authorization).access_token;
const token_key = process.env.ACCESS_TOKEN_KEY;
// 토큰이 없으면
if (!token) {
return res.status(401).json({
code: 401,
message: "Access Token이 없습니다.",
});
}
try {
console.log(jwt.verify(token, token_key));
res.json({ message: "valid token" });
} catch (error) {
console.log("토큰에러야");
if (error.name === "TokenExpiredError") {
return res.status(419).json({
code: 419,
message: "Access Token이 만료되었습니다.",
});
} else if (error.name === "JsonWebTokenError") {
return res.status(401).json({
code: 401,
message: "유효하지 않은 토큰입니다.",
});
} else {
return res.status(500).json({
code: 500,
message: "서버 내부 오류입니다.",
});
}
}
};
ㅅㅂ또
백단에 /mypage요청받는 핸들러 하나 추가해주고, 이 핸들러에서는 헤더값에 담긴 access token을 검증하는 verify함수가 연결되었음.
토큰을 다 지우고 mypage버튼을 누르면 토큰없다는 에러를catch해서 alert로 띄워줌
로그인버튼으로 새로 토큰 받고 다시 mypage버튼 누르면?
정상 동작하는 토큰이래
# 토큰 만료이벤트를 만들어보자.
const make_new_access_token = (payloadInfo) => {
const accessTokenSecret = process.env.ACCESS_TOKEN_KEY;
const accessToken = jwt.sign({ info: "생략" }, accessTokenSecret, {
expiresIn: "15m", // 여기 토큰 유효시간을 바꾸자.
});
return accessToken;
};
백엔드에서 access 토큰 발급받는 함수에서 발급시간을 대폭 줄여보자. 3초로 해볼거임.
const make_new_access_token = (payloadInfo) => {
const accessTokenSecret = process.env.ACCESS_TOKEN_KEY;
const accessToken = jwt.sign({ info: "생략" }, accessTokenSecret, {
expiresIn: "3s", // 여기 토큰 유효시간을 바꾸자.
});
return accessToken;
};
완료. 이제 access token의 유효시간은 3초임
이제 클라이언트에서 로그인버튼 눌러서 토큰 바로 발급받고 mypage버튼 누르면?
3초안에 클릭한거니까 토큰이 정상인데, 좀만 더 기다렸다가 (유효시간 3초 지나서) 또 다시 누르면?
만료됐다고함.
# 그럼 여기서 access token만료를 확인 후 자동으로 refresh 토큰을 발급받게 해보자.
이제부터 함수가 매우 드럽고 불결해질거임.
const mypage = () => {
const access_token = localStorage.getItem("access_token");
axios
.get("/api/mypage", {
headers: {
Authorization: JSON.stringify({ access_token: access_token }),
},
})
.then((res) => {
alert(res.data.message);
})
.catch((error) => {
if (error.response.data.code == 419) {
const refresh_token = localStorage.getItem("refresh_token");
alert("만료에러");
axios
.get("/api/refresh", {
headers: {
Authorization: JSON.stringify({
access_token: access_token,
refresh_token: refresh_token,
}),
},
})
.then((res) => {
alert(res.data.message);
localStorage.setItem("access_token", res.data.access_token);
localStorage.setItem("refresh_token", res.data.refresh_token);
})
.catch((error) => {
alert(error.response.data.code);
});
} else {
alert(error.response.data.code);
}
});
};
프론트에서 아까 썼던 mypage쪽 함수인데
access토큰이 만료된 에러코드 419을 받을경우 또다른 axios요청을 하게된다.
이 axios 요청 경로는 /refresh이고 이번엔 refresh token도 같이 보내기로 함.
app.get("/api/refresh", (req, res, next) => {
verifyRT(req, res);
});
이 refresh api요청은 새로만든 verifyRT라는 함수가 호출되는데
const verifyRT = (req, res) => {
const access_token = JSON.parse(req.headers.authorization).access_token;
const refresh_token = JSON.parse(req.headers.authorization).refresh_token;
const access_token_key = process.env.ACCESS_TOKEN_KEY;
const refresh_token_key = process.env.REFRESH_TOKEN_KEY;
// 토큰이 없으면
if (!access_token || !refresh_token) {
return res.status(401).json({
code: 401,
message: "Token둘다 보냈니?",
});
} else {
try {
console.log(jwt.verify(access_token, access_token_key));
} catch (error) {
if (error.name == "TokenExpiredError") {
// 여기서 refresh토큰 검증 플로우
try {
jwt.verify(refresh_token, refresh_token_key);
const new_access_token = make_new_access_token("페이로드 정보");
const new_refresh_token = make_new_refresh_token("페이로드 정보");
res.json({
access_token: new_access_token,
refresh_token: new_refresh_token,
message: "새로운 access토큰이랑 뽀너스로 새로운 refresh토큰도 받아",
});
} catch (error) {
console.log("refresh 토큰에러야");
if (error.name === "TokenExpiredError") {
return res.status(419).json({
code: 419,
message: "로그인하세여",
});
} else if (error.name === "JsonWebTokenError") {
return res.status(401).json({
code: 401,
message: "유효하지 않은 refresh 토큰입니다.",
});
} else {
return res.status(500).json({
code: 500,
message: "서버 내부 오류입니다.",
});
}
}
} else {
return res.status(500).json({
code: 500,
message: "야 꺼져 그냥",
});
}
}
}
};
ㅅㅂ
일단 먼저 request 헤더에서 받은 access token이 진짜 만료된토큰인지 확인한다.
그리고 access token검증에서 419에러가 뜨면 refresh토큰을 검증하는데 만약 문제가 없다면 새로운 access 토큰이랑 refresh토큰을 발급해서 응답을 줌.
만약 refresh 토큰도 만료가 뜨면 로그인하라는 메세지를 응답, 아닌건 뭐 그대로.. 그리고 access 토큰검증하는데 419에러가 아니면 클라이언트가 유효한 access token으로 /refresh api요청을 보낸거니까 애초에 조작된 요청임. 그래서 그냥 꺼지라고 욕박아줌
//프론트단 다시 /mypage쪽 axios요청에서
.then((res) => {
alert(res.data.message);
localStorage.setItem("access_token", res.data.access_token);
localStorage.setItem("refresh_token", res.data.refresh_token);
})
프론트단 /mypage안에 /refresh요청쪽 코드만 다시 보면
제대로 응답 받으면 새로운 토큰을 로컬스토리지에 저장하게 해줬음.
이걸 UI에서 다시 확인해보면
mypage버튼을 누르자 맨처음 access token이 만료됐다고 함.
바로 다음 토큰을 잘 받아왔다고 응답을 받았음
access token이 만료된걸 확인하고 /refresh쪽으로 요청해서 새로운 토큰들을 잘 받아온거임
다시 3초안에 mypage버튼을 누르면 인증이 문제없게됨
# 전체 코드
<template>
<router-view></router-view>
<button v-on:click="login()">login button</button>
<button v-on:click="mypage()">mypage button</button>
</template>
<script>
import { reactive } from "vue";
import axios from "axios";
export default {
setup() {
///여기가 데이터
const state = reactive({
data: [],
});
///여기가 데이터
///여기가 메서드
const login = () => {
axios.post("/api/login", { id: "무찌", pw: "엉덩이" }).then((res) => {
localStorage.setItem("access_token", res.data.access_token);
localStorage.setItem("refresh_token", res.data.refresh_token);
alert(res.data.message);
});
};
const mypage = () => {
const access_token = localStorage.getItem("access_token");
axios
.get("/api/mypage", {
headers: {
Authorization: JSON.stringify({ access_token: access_token }),
},
})
.then((res) => {
alert(res.data.message);
})
.catch((error) => {
if (error.response.data.code == 419) {
const refresh_token = localStorage.getItem("refresh_token");
alert("만료에러");
axios
.get("/api/refresh", {
headers: {
Authorization: JSON.stringify({
access_token: access_token,
refresh_token: refresh_token,
}),
},
})
.then((res) => {
alert(res.data.message);
console.log(res.data.access_token);
localStorage.setItem("access_token", res.data.access_token);
localStorage.setItem("refresh_token", res.data.refresh_token);
})
.catch((error) => {
alert(error.response.data.code);
});
} else {
alert(error.response.data.code);
}
});
};
///여기가 메서드
return {
state,
login,
mypage,
};
},
// 여기에 컴포넌트
};
</script>
<style lang="scss" scoped>
.sassTest {
p {
color: blue;
}
}
</style>
const express = require("express");
const app = express();
const bodyParser = require("body-parser");
const jwt = require("jsonwebtoken");
const dotenv = require("dotenv");
dotenv.config();
app.use(bodyParser.json());
const port = process.env.PORT;
const make_new_access_token = (payloadInfo) => {
const accessTokenSecret = process.env.ACCESS_TOKEN_KEY;
const accessToken = jwt.sign({ info: "생략" }, accessTokenSecret, {
expiresIn: "3s", // 여기 토큰 유효시간을 바꾸자.
});
return accessToken;
};
const make_new_refresh_token = (payloadInfo) => {
//원래는 여기서 payload에넣을거 정해서 밑에 생략한데다가 너줘야함
//보안 더 검증하려면 해당 payload정보가 db에 있는지도 검색
const refreshTokenSecret = process.env.REFRESH_TOKEN_KEY;
const refreshToken = jwt.sign({ info: "생략" }, refreshTokenSecret, {
expiresIn: "7d",
});
return refreshToken;
};
app.get("/api/test", (req, res) => {
console.log("testtestsetset");
res.json("hello axios test");
});
app.post("/api/login", (req, res, next) => {
//원래는 여기서 요청을 받은 아이디랑 비번가지고 db검색하고
//payload에 유저 이멜이던가 닉네임같은 정보를 넣어주어야함
const access_token = make_new_access_token("페이로드정보");
const refresh_token = make_new_refresh_token("페이로드정보");
res.json({
access_token: access_token,
refresh_token: refresh_token,
message: "토큰 잘 받아왔어",
});
});
app.get("/api/mypage", (req, res, next) => {
verifyAT(req, res);
});
const verifyAT = (req, res) => {
const token = JSON.parse(req.headers.authorization).access_token;
const token_key = process.env.ACCESS_TOKEN_KEY;
// 토큰이 없으면
if (!token) {
return res.status(401).json({
code: 401,
message: "Access Token이 없습니다.",
});
}
try {
console.log(jwt.verify(token, token_key));
res.json({ message: "valid token" });
} catch (error) {
console.log("토큰에러야");
if (error.name === "TokenExpiredError") {
return res.status(419).json({
code: 419,
message: "Access Token이 만료되었습니다.",
});
} else if (error.name === "JsonWebTokenError") {
return res.status(401).json({
code: 401,
message: "유효하지 않은 토큰입니다.",
});
} else {
return res.status(500).json({
code: 500,
message: "서버 내부 오류입니다.",
});
}
}
};
const verifyRT = (req, res) => {
const access_token = JSON.parse(req.headers.authorization).access_token;
const refresh_token = JSON.parse(req.headers.authorization).refresh_token;
const access_token_key = process.env.ACCESS_TOKEN_KEY;
const refresh_token_key = process.env.REFRESH_TOKEN_KEY;
// 토큰이 없으면
if (!access_token || !refresh_token) {
return res.status(401).json({
code: 401,
message: "Token둘다 보냈니?",
});
} else {
try {
console.log(jwt.verify(access_token, access_token_key));
} catch (error) {
if (error.name == "TokenExpiredError") {
// 여기서 refresh토큰 검증 플로우
try {
jwt.verify(refresh_token, refresh_token_key);
const new_access_token = make_new_access_token("페이로드 정보");
const new_refresh_token = make_new_refresh_token("페이로드 정보");
res.json({
access_token: new_access_token,
refresh_token: new_refresh_token,
message: "새로운 access토큰이랑 뽀너스로 새로운 refresh토큰도 받아",
});
} catch (error) {
console.log("refresh 토큰에러야");
if (error.name === "TokenExpiredError") {
return res.status(419).json({
code: 419,
message: "로그인하세여",
});
} else if (error.name === "JsonWebTokenError") {
return res.status(401).json({
code: 401,
message: "유효하지 않은 refresh 토큰입니다.",
});
} else {
return res.status(500).json({
code: 500,
message: "서버 내부 오류입니다.",
});
}
}
} else {
return res.status(500).json({
code: 500,
message: "야 꺼져 그냥",
});
}
}
}
};
app.get("/api/refresh", (req, res, next) => {
verifyRT(req, res);
});
app.listen(port, () => {
console.log("서버시작");
});
Conclusion
# 생각해보자
백단에서 단순히 mypage api요청만 있는게 아니다.
각 api요청마다 해야할 작업도 다르고 응답해줄 데이터도 다를것이다.
그럴떄마다 새로운 verifyAT verifyRT함수를 정의해서 다르게 호출할까?
아니면 인수로 콜백을 받게해서 정상적일때는 그 콜백함수로 정상적인 데이터 응답하게 해줄까?
뭐가됐든 가독성은 개쓰레기고 코드도 매우 복잡해진다.
또한 프론트는 어떤가?
모든 인증이 필요한 axios요청에서 저렇게 에러코드가 419인걸 확인하고 /refresh api요청을 또 하게 된다면?
코드는 아주 개 더럽고 불결해질것이다.
이 부분을 전체 모듈화하고 프론트에선 인터셉터, 백단에선 미들웨어를 활용햇 간소화할 수 있다.
나도 처음배울때는 jwt플로우도 잘 이해 안됐지만 처음 보는 구글링한 코드들이 전부 모듈화, 인터셉터화, 미들웨어화 되어있어서 오히려 더 이해가 안됐다.
이미 코드자체가 더러워서 이번 포스팅이 많이 길어졌기 때문에
이렇게 jwt토큰사용법을 간결하게하는 모듈화, 인터셉터, 미들웨어 는 바로 이어서 다음포스팅에 따로 하나 더 하겠다.