JWT
Json Web Token의 약자로 전자 서명 된 URL-safe JSON입니다.
JWT는 MSA의 인증, 인가에 사용할 수 있는 서명된 JSON이기 때문에 안성맞춤입니다.
서버측 부하를 낮출 수 있고 능률적인 접근 권한 관리를 할 수 있으며 분산환경에 더 잘 대응할 수 있습니다.
서버 기반 인증
기존의 인증 방식은 서버측에 유저 정보를 저장합니다. 대표적으로 세션이 이에 해당하고 필요에 의해 파일, DB등에도 저장할 수 있습니다. 일반적인 웹 어플리케이션 개발 시 이 방법을 사용합니다.
서버 기반 인증의 문제점
사용자 수가 늘어날 수록 세션에 담아야 할 정보가 함꼐 증가하기 때문에 메모리 과부화 문제가 발생합니다. 우리가 만드는 어플리케이션은 사요자가 많지 않기 때문에 문제가 될 것이 없지만 아주 많은 사용자가 이용하는 대규모 포털 서비스를 개발한다고 생각해 보면 문제의 소지가 있습니다. 많은 트래픽을 감당하기 위해 프
로세스를 늘리거나 서버 장비를 추가하는 경우 확장이 쉽지 않습니다. 쿠키는 단일 도메인 및 서브 도메인에서만 작동하기 때문에 여러 도메인에서 관리하는 것은 번거롭습니다.
토큰 기반 인증 원리
- 사용자가 로그인한다.
- 서버측에서 로그인을 인증하고 맞을 경우 클라이언트 측에 signed 토큰을 발급해 준다. ( signed란? 해당 토큰이 서버에서 정상적으로 발급된 토큰임을 증명하는 signature를 담고 있다는 것을 의미합니다. )
- 클라이언트측에서 서버로 부터 전달받은 토큰을 저장하고 서버에 요청할 때마다 해당 토큰을 함꼐 서버에 전달합니다.
- 서버는 요청이 올때 마다 토큰을 검증합니다.
토큰 기반 인증의 장점
- 확장성이 뛰어나다. 서버가 늘어나도 토큰을 인증하는 방식만 알고 있다면 영향이 없습니다.
- 클라이언트가 서버로 요청할 떄 더이상 쿠키를 전달하지 않기 때문에 쿠키를 사용함으로써 사용하는 취약점이 사라집니다, 물론 토큰을 사용하는 환경에서도 취약점이 존재할 수 있으니 대비해야 합니다.
- 다른 서비스에서도 권한을 공유할 수도 있습니다.
- CORS문제가 해결되나. 어떤 도메인에서도 토큰만 유효하다면 처리가 가능합니다.
정리
사용자가 이미 회원가입/로그인을 통해 session ID나 토큰 등을 발급받은 상태이고 이 인증도구와 함꼐 서버로 요청을 보냈다고 가정해 봅시다.
session방식은 저장소에 저장해뒀던 session ID를 찾아와 검증하는 절차를 거칩니다.
기존 토큰 인증은 토큰 자체를 서버에 저장하지는 않지만 토큰을 검증할 때 필요한 관련 정보들을 서버에 저장해두고 있기에 DB에 접근해야만 했습니다.
이 둘의 공통점은 인증 과정에서 DB를 거쳐야만 한다는 것입니다.
하지만 JWT는? 사용자 인증에 필요한 정보를 토큰 자체에 담고 있어 별도 저장소에 정보를 저장해둘 필요가 없습니다.
Header, Payload, Signature가 JWT의 구성요소 입니다.
이들이 JSON형태로 담겨져 있어서 Json Web Token입니다.
이들을 부호화하고 암호화시켜 만들어 낸 것이 오른쪽 형태입니다.
- Header에는 어떤 알고리즘으로 암호화 할 것인지, 토큰은 어떤 타입을 쓸 건지와 같은 토큰 관련 정보가 담깁니다.
- Payload에는 사용자 인증 관련 정보가 담깁니다. 수정이 가능하여 더 많은 정보를 추가해둘 수도 있습니다. 그러나 이 곳은 노출과 수정이 가능한 지점이기에 인증이 필요한 최소한의 정보만 담아야 합니다. 인증에 필요한 최소한의 정보란 아이디, 비밀번호, 개인정보 등이 아니라 이 토큰을 가졌을 때 권한의 범위, 토큰의 발급일과 만료일자 등을 의미합니다.
- Signature부분이 가장 중요한 역할을 합니다. 이 곳에서는 부호화시킨 Header와 Payload를 가지고 발급해준 서버가 지정한 secret key로 암호화 시킵니다. 이렇게 하면 토큰을 변조하기가 어려워집니다.
토큰 발급된 이후에 누군가 Payload정보를 수정한다고 해 봅시다. Payload는 조작된 정보가 들어가 있지만 Signature에는 변경되기 전 Payload내용을 기반으로 암호화된 결과가 저장되어 있습니다. 조작 후 Payload를 암호화한 결과와는 다른 값이 나오게 됩니다. 이러한 방시으로 비교하게 되면 서버는 토큰 조작 여부를 쉽게 알 수 있습니다.
HMAC SHA256
JWT에서는 HS256 즉 HMAC SHA256을 많이 씁니다. 이능 Hash-Based Message Authentication Code라는 뜻을 가지는데, 거짓행세에 대해 검출하고 차단하기 위해 SHA256으로 해싱된 메세지를 메세지 인증 코드(private key)로 암호화(서명)하여 송신하고 수신측에서는 동일하게 소유한 private key로 복호화 및 서명을 검증하는 방식입니다.
동일하게 소유했다는 것은 즉, HMAC SHA256 알고리즘이 대칭키 방식임을 알 수 있습니다.
인증 관련 정보를 DB에 저장한다면 이용자수가 늘어나는만큼 저장 공간이 더 많이 필요해진다. 또한 인증 시마다 DB를 사용하므로 인증이 몰리면 서버가 과부화 될 위험이 있습니다.
반면 JWT는 별도 저장소가 필요하지 않아 서바자원을 절약할 수 있고, 인증 과정에서 다른 곳을 거칠 필요가 없어 효율적입니다. 인증 정보를 가진 특정 서버에만 트래픽이 몰릴 일이 없습니다. 서버의 부하를 줄이기 매우 좋은 방식입니다.
기존의 방식을 또한 다중 서버에서 문제가 발생하기 쉽습니다.
session 방식의 경우 요청을 보낸 사용자의 session ID가 저장된 서버에서만 인증이 가능합니다. 같은 서비스임에도 서버에 따라 검증 가능 여부가 달라지는 것입니다. 이를 해결하기 위해 공용 session저장소를 만든다던가 sticky session이라 하여 사용자의 요청이 sessionID를 발급 받는 서버로만 가도록 하는 방법들이 고안되었습니다.
또한 기존 토큰 방식은 토큰 검증을 위해 인증 서버를 거치고 있어 인증 서버의 병목현상이 발생할 수 있습니다.
JWT는 이런 면들을 보완할 수 있습니다. 특정 서버에 인증에 필요한 정보를 저장해둔 것이 아니라 필요한 정보를 지닌 채로 서버에 도달하기 때문에 Signature암호화를 해독할 수 있는 secret key가 공유된 서버라면 토큰을 받은 서버가 검증까지 마칠 수 있습니다.
하지만 JWT도 단점이 없는 것은 아닙니다.
외부 공격자가 접근하기 쉬운 위치, 노출 가능한 정보로 인해 저장할 수 있는 정보가 제한적이라는 것입니다. 암호화가 풀릴 가능성을 배제할 수 없습니다. 그래서 암호화가 풀리더라도 토큰을 사용할 수 없도록 만료기간을 짧게 설정해야 합니다.
Node JWT 서버 구축
여기서는 간단한 로그인 페이지와 사용자 정보를 JWT토큰이 저장하고, 인증 서버에서 sign과 verify를 해서 미들웨어로 박는 과정을 거쳐보도록 하겠습니다.
/login.ejs
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="../css/login.css" rel="stylesheet">
<title>Login</title>
</head>
<body>
<div id="FormContainer">
<h1>Login Please</h1>
<form action="/" method="POST">
<div class="formData">
<label for="id">Id</label>
<input id="id" name="id" type="text" placeholder="ID">
</div>
<div class="formData">
<label for="pwd">Password</label>
<input id="pwd" name="pwd" type="password" placeholder="Password">
</div>
<button type="submit">Login</button>
</form>
</div>
</body>
</html>
여기에서 미리 설정해 둔 가짜 사용자 정보로 로그인하게 되면 인증 서버에서 위 정보를 가지고 sign을 해서 session에 저장하는 과정을 거치도록 했습니다.
/cookie.js
/* cookie/cookie.js */
const express = require("express");
const cookieParser = require("cookie-parser");
const session = require("express-session");
const FileStore = require("session-file-store")(session);
const bodyParser = require("body-parser");
const path = require("path");
const { authUtil } = require("./middlewares/auth.js");
const axios = require("axios");
require("dotenv").config();
const app = express();
app.use(bodyParser.urlencoded({ extended: false }));
app.use(express.json());
app.use(cookieParser("COOKIE-SECRET"));
app.use(
session({
secret: "COOKIE-SECRET",
resave: false,
saveUninitialized: true,
store: new FileStore(),
})
);
app.use(
"/css",
express.static(path.resolve(__dirname, "./css"))
);
app.set("view engine", "ejs");
app.set("views", "views");
app.set("PORT", 3000);
// verify token
app.use(authUtil);
let user = {
id: "123",
password: "123",
nick: "hyunseo",
};
app.get("/", (req, res) => {
if (req.session.logined) {
res.status(200).render("logout", {
data: {
token: req.session.jwtToken,
id: req.decoded.id,
nick: req.decoded.nick,
},
});
} else {
res.status(200).render("login");
}
});
app.post("/", async (req, res) => {
if (
user.id === req.body.id &&
user.password === req.body.pwd
) {
req.session.logined = true;
/**
* @property {String} userId
* @property {String} secretKey
*/
const postData = {
userId: req.body.id,
secretKey: process.env.JWT_SECRET_KEY,
nick: user.nick,
};
try {
const {
data: { token },
} = await axios.post(
"http://localhost:3008/sign",
postData,
{}
);
req.session.jwtToken = token;
res.status(200).render("logout", {
data: {
token: req.session.jwtToken,
id: req.decoded.id,
nick: req.decoded.nick,
},
});
} catch (error) {
console.error(error.message);
}
} else {
res.send(`<h1>Who are you?</h1> <a href="/">Back</a>`);
}
});
app.post("/logout", (req, res) => {
req.session.destroy();
res.redirect("/");
});
app.use((req, res, next) => {
const error = new Erorr(
`${req.method} ${req.url}라우터가 없습니다.`
);
error.status = 404;
next(error);
});
app.use((err, req, res, next) => {
res.send(err.message);
});
app.listen(app.get("PORT"), () => {
console.log(`*:: listening at ${app.get("PORT")}`);
});
다음과 같이 express-session을 사용하여 req.session.jwtToken에 token정보를 담아 두었습니다. 그리고 sign과정이 성공하면 logout.ejs로 가지게 됩니다.
/logout.ejs
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logout</title>
<style>
*, *:after, *:before {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
display: flex;
flex-direction: column;
justify-content: center;
height: 100vh;
}
table {
border: 2px solid;
border-collapse: collapse;
width: 500px !important;
}
tr {
width: 100%;
height: 100px;
}
th, td {
border: 1px solid black;
padding: 10px 20px;
}
th {
background-color:teal;
color: white;
}
</style>
</head>
<body>
<table>
<tr>
<th colspan="2" span style="color: white">사용자의 토큰 목록</th>
</th>
<tr>
<td>token1</td>
<td><%= data.token %></td>
</tr>
</table>
<br>
<h1>Hello!! <%= data.nick %></h1>
<form action="/logout" method="POST">
<button type="submit">Logout</button>
</form>
<script>
</script>
</body>
</html>
그럼 다음과 같이 사용자의 nick이 띄워지고 사용자가 발급받은 토큰이 화면에 보여지고, 맨 아래에는 session을 삭제해서 logout하는 버튼이 있는 것을 확인해 볼 수 있습니다.
토큰의 검증은 미들웨어로 authUtil을 활용해서 진행했습니다.
/middlewares/auth.js
const axios = require("axios");
exports.authUtil = async (req, res, next) => {
console.log("logined >> ", req.session.logined);
if (req.session.logined) {
try {
const { data } = await axios.post(
"http://localhost:3008/verify",
{},
{
headers: { authorization: req.session.jwtToken },
}
);
req.decoded = data.decoded;
} catch (error) {
console.error(error);
next(error);
}
}
next();
};
그리고 인증 서버를 보여드리도록 하겠습니다.
/main.js
// @ts-check
const express = require("express");
const jwt = require("jsonwebtoken");
const path = require("path");
const randToken = require("rand-token");
const { secretKey, options } = require("./config/options");
const app = express();
app.set("PORT", process.env.PORT || 3008);
// app.use(bodyParser.urlencoded({ extended: false }));
app.use(express.json());
/**
* user data
* @typedef {Object} userData
* @property {string} userId - userid
* @property {string} secretKey - secretKey for jwt
* @property {string} nick - nickname of user
* yea!
*/
app.post("/sign", (req, res) => {
/**
* @type {userData}
*/
const { userId, secretKey, nick } = req.body;
const payload = { id: userId, nick };
// @ts-ignore
jwt.sign(payload, secretKey, options, (err, token) => {
if (err) {
return res.json({
code: 500,
message: `토큰 발급에 실패하였습니다.`,
});
}
return res.status(201).json({
code: 200,
message: `토큰이 발급되었습니다.`,
...{
token,
refreshToken: randToken.uid(256),
},
});
});
});
app.post("/verify", (req, res, next) => {
const { authorization } = req.headers;
jwt.verify(authorization, secretKey, (err, decoded) => {
/**
* ! 토큰이 만료되면 토큰을 사용할 수 없다.
*/
if (err && err.name === "TokenExpiredError") {
return res.status(419).json({
code: 419,
message: "토큰이 만료되었습니다.",
});
} else if (err) {
return res.status(401).json({
/**
* ! 토큰의 비밀 키가 일치하지 않는다면 인증을 받을 수 없다.
*/
code: 401,
message: "유효하지 않은 토큰입니다.",
});
}
//@ts-ignore
return res.status(201).json({
code: 200,
message: "토큰이 성공적으로 검증되었습니다.",
decoded,
});
});
});
app.listen(app.get("PORT"), () => {
console.log(`*:: listening at ${app.get("PORT")}`);
});
/config/options.js
require("dotenv").config();
module.exports = {
secretKey: process.env.JWT_SECRET_KEY,
options: {
algorithm: "HS256", // 해싱 알고리즘
expiresIn: "30m", // 토큰 유효 기간
issuer: "issuer", // 발행자
},
};
또한 .env의 JWT_SECRET_KEY는 클라이언트와 서버가 공유하는 값입니다.
즉 클라이언트 ( secrey_key, userData ) -> 서버 ( sign ) -> 클라이언트 ( token, verify )이 방식입니다. 이에 대한 장점과 단점은 위에서 수 없이 살펴보았습니다.
JWT토큰은 이와 같이 JWT비밀키를 알지 않는 이상 ( 대칭키 ) 변조가 불가능합니다. verify하는 과정 즉 변조한 토큰은 시그니처를 비밀 키를 통해 검사할 때 들통나게 됩니다. 변조할 수 없으므로 내용물이 바뀌지 않았는지 걱정할 필요는 없습니다.
즉 JWT의 비밀키가 중요하게 되는데, 클라이언트 환경에서 이를 사용하게 되면 노출이 되게 됩니다. 즉 만약 사용해야 한다면 RSA암호화 같은 양방향 비대칭 암호화 알고리즘을 사용해야 합니다. 서버 환경에서는 비밀키를 사용하고 클라이언트 환경에서는 공개 키를 사용하는 방식으로 클라이언트에서 비밀 키가 노출되는 것을 막을 수 있습니다.
또한 만약 API를 브라우저 단에서 사용하려면 CORS처리도 해 주어야 합니다.
또한 현재 클아이너트와 서버에서 같은 비밀 키를 써서 문제가 될 수 있습니다. 따라서 다양한 환경의 비밀 키를 발급하는 카카오처럼 환경별로 키를 구분해서 발급하는 것이 바람직합니다.