드디어 JWT
까지 왔다.
쿠키 , 세션을 공부한 이유가 사실은 깃허브의 API
를 이용하기 위해 명세서를 읽다가
토큰에 대한 이야기가 나와서
말 나온김에 차례대로 공부해본 것이였는데 생각보다 무지 오래 걸렸다.
Jason Web Token
의 정의 Jason Web Token (JWT)
는 클라이언트와 서버 간 정보를 JSON
형태로 주고 받을 수 있는 방식이다.
정보가 담긴 JSON
객체들을 base64
형태로 인코딩 하여 쿠키에 문자열 형태로 클라이언트와 서버 간 주고 받게 된다.
이 때 주고 받는 쿠키를 token
이라고 한다.
Why JWT ?
이전 게시글에서 세션에 대해서 공부했을 때
세션은 서버 측에서 클라이언트의 정보를 기억하고 있는 , stateful
한 통신이였다.
세션은 서버 측에서 클라이언트의 state
를 중앙 통제 형태로 관리하기 때문에 도움이 되었지만
이는 사용자가 늘어남에 따라 서버 측은 사용자들의 세션 정보를 기억해두기 위한 부가적인 저장 장치가 필요했다.
서버측은 세션 정보와 클라이언트를 맵핑해둘 수 있는 저장 장치를 필요로 한다.
하지만 만약 클라이언트에게 인가만 받으면 되어서 세션 처럼 클라이언트들의 상태를 관리할 필요가 없는 경우에는 부가적인 저장 장치를 구비해두는 것이 비효율적이다.
이 때 사용하면 좋은것이 JWT
이다.
JWT
는 인가에 필요한 모든 정보가 Cookie
에 담긴 token
에만 존재한다.
이게 무슨 소린가 싶다.여태 쿠키에 정보를 담으면 해당 정보를 수정한다거나 탈취해서 악용하는 경우가 생긴다고 하지 않았는가
JWT
는 인가에 필요한 정보를 클라이언트 측에게 저장하게 하여 저장 공간을 효율적으로 사용했으면서도
쿠키의 단점인 정보가 수정 가능하다거나 , 탈취되었을 때의 문제를 우아한 방식으로 해결했다.
만약 인가에 필요한 정보 중 사용자의 id
와 로그인 상태 등을 이용했다고 해보자
쿠키의 경우에는
Cookie = userId : dongdong; loginStatus : true;
이런식으로 저장되어 서버 측으로 전송된다고 해보자
이 때 악의를 가진 사용자가 userId
를 admin
으로 변경해서 서버에 제출한다면
서버 측에서는 어드민으로 로그인을 시켜 줄 것이다.
이는 인가에 필요한 정보가 수정 가능했기 때문에 발생하는 문제이다.
JWT
는 인가에 필요한 정보가 수정되었을 경우엔 인가 자체를 하지 못하도록 signature
부분을 두고 있다.
jwt.io 에서 제공하는 토큰의 양식이다.
JWT
는 .
을 기준으로 하여 3개의 문자열의 조합으로 나뉘어져있다.
{header} . {payload} . {signature}
로 이뤄져있는데
header
부분에는 JWT
에 사용된 알고리즘과 타입을 적으며
payload
에는 토큰과 관련된 정보를 담는다. 이 때 header , payload
부분에 적힌 내용은 모두
그저 단순하게 전송에 편하게 base64
로 인코딩 되어 있기 때문에 쿠키 정보를 탈취하여 디코딩 하면 모두가 볼 수 있다. 그렇기 때문에 민감한 정보는 적지 않는 것이 좋다.
이제 중요한 부분은 signature
부분이다.
signaure
부분은 header + payload + 서버측만 알고 있는 비밀 문자열
을 특정 인코딩 방식으로 인코딩 한 문자열 값으로 이뤄져있다.
이는 사용자가 payload
부분을 임의로 수정할 경우 signature
부분의 문자열이 올바르게 변경되지 않으면
서버측은 사용자가 payload
부분을 임의로 수정했음을 알 수 있다.
악의를 가진 사용자가
payload
부분을 수정하고base64
로 인코딩하여 보라색 부분을 채워둔다고 해도signature
부분은 서버만 알고 있는 비밀 문자열을 알고있지 못하기 때문에 알아낼 수 없다.
JWT
는 쿠키에 저장되기 때문에 만약 탈취당했을 때
다른 사람들도 해당 토큰을 이용해 인가를 시도 할 수 있다.
이 때 JWT
의 payload
부분에서 토큰과 관련된 다양한 정보를 담는데
주로 사용하는 것은 토큰이 발행된 날짜와 만료기간을 설정하고 만료기간이 지났을 경우에는
해당 토큰이 유효하지 않은 토큰으로 간주하는 것이다.
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022,
"exp": 1516242622
}
이런식으로 PayLoad
를 지정해둔다면 만료기간이 지난 경우에는 인가를 허락하지 않을 수 있다.
이 부분은액세스 토큰 리프레쉬 토큰
등을 활용하여 더 많은 방법을 할 수 있지만 그건 나중에 ><
JWT
를 이용하는 서버에서 토큰을 이용해서 인증하는 방법큰 그림만 먼저 이해하고 라이브러리를 사용해보자
클라이언트의 토큰이 서버 측으로 전송된다 . {header} . {payload} . {siganture}
서버 측에서는 encoding ({header} + {payload} + {secret key}) === {signature}
인지 확인한다.
2.1 만약 이 때 두 값이 다르다면 사용자가 header
나 payload
부분을 수정하였거나 서버에서 제공하지 않은 signature
이기 때문에 인가를 허락하지 않는다.
만약 2번이 통과되어다면 payload
에 담긴 토큰의 정보를 확인하여 토큰의 유효성을 확인한
다.
토큰이 유효하다면 인가를 허락하고 , 유효하지 않다면 인가를 취소한다.
오늘도 고생해줄 나의 못생긴 프로토타입 화이팅
JWT
토큰 생성하기로그인이 일어났을 때 JWT
를 사용하여 사용자를 인가하는 과정은 다음과 같다.
Form
데이터를 제출 Form
데이터를 기반으로 데이터베이스를 통해 올바른 정보인지 확인JWT
토큰을 생성하고 [Client] 측으로 JWT
토큰을 쿠키에 담아 응답const users = require('../db');
const findUser = (req) => {
const requestId = req.body.userId || req.params.userId;
const requestPassword = req.body.password || req.params.password;
const user = users.find(({ userId, password }) => {
return userId === requestId && password === requestPassword;
});
if (!user) throw Error('아이디나 비밀번호를 확인해주세요');
return user;
};
module.exports = findUser;
const jwt = require('jsonwebtoken');
require('dotenv').config();
const SECERTE_KEY = process.env.SECERTE_KEY;
const createAccessToken = (user) => {
const payloader = {
// 토큰과 관련된 정보
userId: user.userId,
};
const secretKey = SECERTE_KEY; // 토큰 인코딩 시 사용할 시크릿 키
const options = { expiresIn: '1h' }; // 토큰의 option
const token = jwt.sign(payloader, secretKey, options);
return token;
};
jsonwebtoken
모듈을 이용해 웹토큰을 만들어줄 수 있다.
토큰을 생성하는 방법으론 sign
을 이용해주는데 첫 번째 인수로는 토큰의 정보를 담은 객체, 두 번째 인수로는 서명을 만들 때 사용할 시크릿 키 , 세 번째는 토큰의 옵션을 이용한다.
이렇게 해서 만들어진 토큰은 아까 말했듯 {header} . {payload} . {signature}
형태로 제출된다. 이따가 살펴보자
app.post('/login', (req, res) => {
try {
const user = findUser(req);
const AccessToken = createAccessToken(user);
const cookieOption = {
httpOnly: true,
secure: false,
};
res.cookie('accessToken', AccessToken, cookieOption);
res.status(200).json({ userId: user.userId });
} catch (e) {
// findUser 함수에서 정보를 찾지 못하면 에러를 발생시킨다.
res.status(401).json({ message: e.message });
}
});
이런식으로 사용자가 로그인에 성공하면 클라이언트 측으로 token
을 쿠키에 담아 보내준다.
보내진 토큰을 살펴보면 .
을 기준으로 아까 말한듯이 나눠진다.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. // header
eyJ1c2VySWQiOiJ1c2VyMSIsImlhdCI6MTcxMDk0NTUyNCwiZXhwIjoxNzEwOTQ5MTI0fQ. // payload
JVxw9lgJvfwEWgp961nmor4N5ZXnKdytuI4fL6bajEM // signature
이는 토큰 객체를 base64
로 인코딩 한 결과이며 디코딩해서 살펴보면
header , payload
부분은 쉽게 디코딩이 가능한 모습을 볼 수 있으며 signature
부분은 암호화 된 형태로 디코딩 되는 모습을 볼 수 있다.
JWT
토큰을 이용해 사용자 인가하기마이페이지에서 사용자의 개인정보를 가져온다고 생각해보자
이 때는 사용자의 개인정보를 가져오기 위해 인가가 필요하다.
JWT
를 이용해서 인가를 하는 과정을 생각해보자
token
을 제출한다.token
의 header , payload
부분과 secret key
를 이용해 나와야 하는 signature
를 계산하고 클라이언트가 제출한 token.signature
와 비교하여 맞는지 확인한다.signature
와 클라이언트가 제출한 signature
가 일치한다면 DB 조회 , 아니라면 오류를 발생시킨다.여기서 포인트는 Signature
정보가 서버에 저장되어 있는 것이 아닌 비밀키를 이용해 동적으로 조회하고 생성한단 것이다.
이를 통해 서버는 인가를 위한 별도의 저장 장소를 만들어둘 필요 없이 비밀키만으로 사용자 인가가 가능하다.
import { useLoaderData } from 'react-router-dom';
export function Mypage() {
const userInfo = useLoaderData();
const { firstName, lastName, gender, age, avatar } = userInfo;
return (
<section>
<img src={avatar} alt='img' style={{ width: '200px' }} />
<p>
fullName :{' '}
<i>
{firstName} {lastName}
</i>
</p>
<p>
gender : <i>{gender}</i>
</p>
<p>
age : <i>{age}</i>
</p>
</section>
);
}
export async function loader() {
const res = await fetch('/MyPage', { method: 'GET' });
const body = await res.json();
return body;
}
클라이언트는 단순히 마이페이지에 접근할 때는 서버 측에 GET
요청을 보낸다.
이 때 보낼 때 로그인 시 받은 Access Token
이 쿠키 형태로 담겨 서버로 제출된다.
const verifyToken = (token) => {
let decoded = null;
try {
decoded = jwt.verify(token, SECERTE_KEY);
/*
decoded = { userId: 'user1', iat: 1710940155, exp: 1710943755 }
payloader 로 설정한 값과 기본 payloader 정보들인 issuedAt , expiresIn 등의 정보가
담겨있다.
*/
return {
type: true,
payload: decoded,
};
} catch (e) {
throw Error(e.message);
}
};
jwt.verify(클라이언트의 토큰 , 서버의 시크릿 문자열)
을 이용하여
토큰의 정보를 디코딩 하는 것이 가능하다.
이 때 디코딩은 토큰의 payload
부분과 이전에 설정한 token option
으로 만들어지는 토큰의 정보등이
담긴 채로 반환된다.
app.get('/Mypage', (req, res) => {
try {
const token = req.cookies.accessToken;
const verifyResult = verifyToken(token); // token 유효성 검증
const userInfo = db.find(
// 유효성 검증 후 DB 조회
(user) => user.userId === verifyResult.payload.userId,
);
res.status(200).json(userInfo);
} catch (e) {
res.status(400).json({ message: e.message });
}
});
이후 서버에선 사용자의 토큰의 유효성을 검증한 후 DB
를 조회하여 정보를 제공한다.
페이지를 새로고침 하거나 브라우저를 껏다가 키고 해당 페이지에 다시 들어왔을 때
만약 사용자가 예전에 로그인했던 사람이라면 , 로그인 상태를 유지하는 방법은 없을까 ?
이는 사실 로그인 정보 뿐이 아니라, JWT
에 저장되어 있는 (특히 JWT.payload
) 클라이언트의 정보를
처음 접속 할 때 자동적으로 서버와 통신하여 불러오고 싶은 경우를 의미한다.
그러기 위해선 다음과 같은 과정이 필요하다.
JWT
가 브라우저에 저장될 기간을 정해놔야 한다.JWT
를 담은 쿠키는 Session Cookie
가 되어 브라우저를 닫는 순간 만료되어버릴 것이다.JWT
쿠키를 전송해 서버에게 본인의 상태를 보낸다.코드를 통해 살펴보자
app.get('/login/token', (req, res) => {
try {
const token = req.cookies.accessToken;
const verifyResult = verifyToken(token);
res.status(200).json({ userId: verifyResult.payload.userId });
} catch (e) {
res.status(400).json({ message: e.message });
}
});
서버측에서는 사용자가 페이지에 처음 접속 했을 때 자동으로 요청을 보낼 API
주소를 생성해둔다.
나는 자동 로그인을 설정하기 위해 다음처럼 시도하였다.
export default function Main() {
const [, setIsLogin] = useLogin();
const [, setUserInfo] = useUserInfo();
useEffect(() => {
// 첫 접속 시 쿠키에 토큰이 존재한다면 자동으로 로그인을 시도하는 useEffect
const checkGotToken = async () => {
const res = await fetch('/login/token', { method: 'GET' });
if (!res.ok) return; // 만약 상태코드가 200~299 이하면 빠르게 종료
const body = await res.json();
setIsLogin(true);
setUserInfo((userInfo) => ({ ...userInfo, userId: body.userId }));
};
checkGotToken();
}, []);
...
useEffect
를 이용하여 처음 접속 시 자동으로 해당 엔드포인트에 요청을 보내 클라이언트의 상태를
서버가 인지 할 수 있도록 한다.
사실 useEffect
내에서 state
를 변경시키는 것에 대해서 상당히 회의감이 있지만 최악보다는 차악을 선택하는게 낫다고
사실 리팩토링 하기 이전 나의 코드는 쓰레기 그 자체였다.
키킥
구웨에에엑 억지로 에러 핸들링을 통해 조건문을 쓰려고 했었는데
커밋하고 나니 굳이 그럴 필요가 없겠더라
러프하게 Node.js
를 이용해서 JWT
를 사용해봤다.
뭐 Access token
뿐이 아니라 refresh token
을 이용해서 엑세스 토큰을 재발급 하는 등의 경우들도 있지만
우선 나는 프로젝트를 먼저 만들기 위해서 이정도까지만 하고 넘어가야겠다.
다만 , 이렇게 백엔드에서 이뤄지는 일들을 직접 해보니 앞으로 이해가 더 잘 될것만 같다.
다만 요즘 고민인 것은 나의 정체성은 무엇일까 하는 생각이 든다.. 키킥 프론트만 빡세게 해도 부족할 거 같은데 말이다. 토이프로젝트를 할 때에는 좀 더 프론트스럽게 생각 할 수 있도록 해야겠다.