진행중인 프로젝트에 로그인 기능을 구현하기 위해 JWT를 사용하여 refresh token/ access token을 구현했다.
글이 길어져 서버 구현 / 클라이언트 구현으로 나누어 작성한다.
또한 JWT의 개념적인 내용보다 구현에 중심을 두어 글을 작성했다.
JWT를 이용한 로그인 과정
1. 사용자가 id, pw를 입력한다.
2. 클라이언트는 사용자의 id와 pw를 서버에 POST한다.
3. 서버는 회원 DB를 통해 사용자 id와 pw가 일치한지 확인한 후 accessToken과 refreshToken을 발급한다.
4. 토큰 DB에 사용자 id(FK, PK), refresh Token을 저장한다.
5. 생성된 accessToken과 refreshToken을 클라이언트에 반환한다.
accessToken 재발급 과정
그림이 조금 이상하지만..
클라이언트는 로그인 화면을 보여주기 전에 spalsh화면을 띄워 사용자의 token값이 있는지, 그 값이 유효한지 검사한다.
그 중 token값이 유효한지 확인하기 위해 서버에 refresh 요청을 보낸다. 그 과정은 아래와 같다.
1. 클라이언트에서 재발급 요청을 보낸다. 이때 헤더에 accessToken과 refreshToken을 담아서 보낸다.
2. 서버는 받은 accessToken 값을 검증한다. 이때 accessToken값이 조작되거나 분실되어 decode값이 존재하지 않는다면 권한이 없다는 에러 메세지를 클라이언트에게 보낸다.
3. 클라이언트에게 받은 refreshToken값을 검증한다.
4. 새로운 accessToken을 발급될 조건이라면 새로운 accessToken을 발급한다.(조건은 아래에서 설명한다.)
5. 발급된 accessToken을 클라이언트에게 전송한다.
새로운 accessToken이 발급 시 시나리오
1. accessToken 만료되지 않은 경우 = 토큰을 새로 발급 받을 필요 X
2. accessToken이 만료되고 refreshToken도 만료된 경우 = refresh토큰도 새로 발급 받아야 되기 때문에 새로 로그인해야 함
3. accessToken이 만료되고 refreshToken은 만료되지 않은 경우 = 새로운 accessToken 발급
DB 테이블
token 테이블
varchar(200) | NOT NULL | PK | |
Token | varchar(5000) | NOT NULL |
user 테이블
varchar(200) | NOT NULL | PK | |
UserName | varchar(10) | NOT NULL | |
PassWord | varchar(30) | NOT NULL | |
AuthStatus | tinyint(1) | NOT NULL | |
AuthToken | varchar(5000) | NULL |
node.js를 이용한 JWT 발급 과정 예제
1. 라이브러리 설치
# package.json 설치
npm init -y
# .env 파일 사용
npm install dotenv
# jwt 사용
npm install jsonwebtoken
# express 사용
npm install express
# mysql2 사용
npm install mysql2
2. 폴더 구조
controller: 메인 로직 작성
utils: 메인 로직에 필요한 모듈 작성
server.js: 라우터
.env: 시크릿 키 보관
mail과 관련된 파일은 회원가입 시 이메일 인증에 관한 로직으로 추후 글을 작성할 예정이다.
3. .env
ACCESS_TOKEN_SECRET=defaultkey
.env 파일에 SECRET_KEY를 저장한다. 시크릿 키로 사용자 정보를 변환할 수 있기 때문에 절대 외부로 유출되면 안된다.
4. server.js
const user = require('./controller/user'); # controller import
const express = require('express'); # express import
const app = express()
const port = 3000
// body 데이터를 json형식으로 사용
app.use(express.json())
// 3000번 포트로 서버 실행
app.listen(port, () =>{
console.log(`app listening on port ${port}`)
})
// 기본 경로
app.get('/', (req, res) =>{
res.send('Hello world!')
})
// 로그인 경로
app.post('/login',user.login)
// access 토큰 재발급 경로
app.get('/refresh',user.refresh)
로그인 시 IP/login 경로를 사용하고, 토큰 재발급 시 IP/refresh 경로를 사용한다.
5.utils/tokenUtils.js
require('dotenv').config(); # dotenv import
const getConnection = require('../utils/DbUtils');
const jwt = require('jsonwebtoken'); # jsonwebtoken(jwt) import
const JWT_KEY = process.env.ACCESS_TOKEN_SECRET # SECRET KEY import
// accessToken 발급 함수
exports.makeToken = (Object) =>{
const token = jwt.sign(
Object,
JWT_KEY,
{expiresIn: "2m"}
);
console.log(token)
return token;
};
// refreshToken 발급 함수
exports.makeRefreshToken = () =>{
const refreshToken = jwt.sign(
{},
JWT_KEY,
{
algorithm: "HS256",
expiresIn: "10m"
}
);
console.log(refreshToken)
return refreshToken;
};
// refresh token 유효성 검사
exports.refreshVerify = async (token, userId) => {
const sql = (email) =>{
return `select token from token where Email = '${email}';`
}
try {
// db에서 refresh token 가져오기(DB에 userID로 조회)
const result = await getConnection(sql(userId));
//받은 refreshToken과 DB에서 조회한 값이 일치하는지 확인
if (token === result['row'][0].token) {
try {
jwt.verify(token, JWT_KEY);
return true;
// refreshToken 검증 에러
} catch (err) {
return false;
}
} else {
return false;
}
// DB 에러
} catch (err) {
console.log(err);
return false;
}
};
// access token 유효성 검사
exports.verify = (token) => {
try {
const decoded = jwt.verify(token, JWT_KEY);
return {
ok: true,
id: decoded.id
};
} catch (error) {
return {
ok: false,
message: error.message,
};
}
};
- makeAccessToken(Object)
AccessToken을 만드는 함수로 회원정보(Object)를 인자로 받아 시크릿 키, 유효기간을 인자로 jwt.sign() 함수를 호출한다.
jwt.sign() 함수는 jsonwebtoken 라이브러리의 토큰을 발급하는 함수이다.
- makeRefreshToken()
RefreshToken을 만드는 함수이다. RefreshToken은 회원 ID와 함께 DB에 저장되므로 payload에 빈 객체를 할당한다.
따라서 빈 객체, 시크릿 키, 해싱 알고리즘 정보와 유효기간을 인자로 jwt.sign() 함수를 호출한다.
- refreshVerify(token, userId)
refresh token의 유효성을 검사하는 함수이다. userID로 DB에서 refreshToken을 조회한 후 조회된 토큰값과 인자로 받은 토큰값을 비교하여 값이 일치하면 jwt.verify()를 통해 refreshToken이 유효한지 확인한다.
- verify(token)
access token의 유효성을 검사하는 함수이다. 인자로 받은 accessToken을 시크릿 키와 함께 jwt.verify에 넣어 호출하여 회원 정보를 얻는다. token값이 유효하다면 디코딩된 userID 를 리턴하고 유효하지 않다면 에러 메세지를 리턴한다.
6. utils/Dbutils.js
const config = require('../db_config.json')
const mysql = require('mysql2/promise');
const pool = mysql.createPool(config);
async function getConnection(sql, params) {
try{
let result = {};
const connection = await pool.getConnection(async conn => conn);
try{
const [rows] = await connection.query(sql, params);
result.state = true;
result.row = rows;
connection.release();
return result;
}
catch (err){
console.log(err);
result.state = false;
result.err = err;
connection.release();
return result;
}
}
catch(err){
console.log(err);
result.state = false;
result.err = err;
return result;
}
}
module.exports = getConnection;
db_config.js
{
"host": "127.0.0.1",
"port": "3306",
"user": "USER NAME",
"password": "DB PW",
"database": "DB NAME",
"connectionLimit": 30
}
폴더의 최상위 위치해 있는 db_config.json 파일에 DB의 기본설정을 저장하고 불러와 pool을 생성한다.
- getConnection(sql, params)
실행될 sql문과 insert, update 시 사용할 파라미터를 인자로 받는다. pool에서 connecton을 하나 가져오고 인자로 받은 sql, params를 인자로 넣어 sql문을 실행한다. sql문이 정상적으로 실행되면 상태값과 반환된 rows를 결과값에 저장하여 클라이언트에게 보내고 connection을 닫는다. 정상적으로 실행되지 않는다면 상태값과 에러 메세지를 결과값에 저장하여 클라이언트에게 보내고 connection을 닫는다.
7. controller/user.js
require('dotenv').config();
const getConnection = require('../utils/DbUtils');
const { sendVerifyEmail } = require('../utils/mailUtils');
const TokenUtils = require('../utils/tokenUtils');
const jwt = require('jsonwebtoken');
exports.login = async (req, res) =>{
const {userId, userpw} = req.body;
const SelectUsersql = (userId) =>{
return `select Email, PassWord, AuthStatus from user where Email = '${userId}'`;
}
const InsertTokenSql = (token) =>{
return `insert into token values(?, ?) ON DUPLICATE KEY UPDATE token='${token}';`
}
// [EXCEPTION] DB에서 ID 찾기
const result_id = await getConnection(SelectUsersql(userId));
// [EXCEPTION] DB 에러
if (result_id.state === false) return res.status(401).send("DB 에러 관리자에게 문의하세요.");
// [EXCEPTION] ID 불일치
if (result_id.row.length === 0) return res.status(401).send("아이디가 일치하지 않습니다.");
// [EXCEPTION] PW 불일치
if (userpw !== result_id.row[0].PassWord) return res.status(401).send("비밀번호가 일치하지 않습니다.");
// [EXCEPTION] 이메일 인증이 완료되지 않았을 때
if (result_id.row[0].AuthStatus !== 1) return res.status(401).send("이메일 인증을 완료해 주세요.");
// ID, PW 같을 경우 토큰 발급
const accessToken = TokenUtils.makeAccessToken({id: userId});
const refreshToken = TokenUtils.makeRefreshToken();
// refreshToken, id DB에 저장
const result_insert = await getConnection(InsertTokenSql(refreshToken), [userId,refreshToken]);
// [EXCEPTION] DB에 저장 실패
if (result_insert.state === false) return res.status(401).send("DB 에러 관리자에게 문의하세요.");
return res.status(200).send({userId,accessToken, refreshToken})
};
const successResponse = (code, data) =>{
return({
code: code,
data: data,
})
}
const failResponse = (code, message) =>{
return({
code: code,
message: message,
})
}
exports.refresh = async (req, res)=>{
// access, refresh 토큰이 헤더에 담겨 온 경우
if (req.headers["authorization"] && req.headers["refresh"]){
const accessToken = req.headers["authorization"].split(" ")[1];
const refreshToken = req.headers["refresh"];
// access token 검증 -> expired여야 함.
const authResult = TokenUtils.verify(accessToken);
// access token 디코딩하여 userId를 가져온다.
const decoded = jwt.decode(accessToken);
// 디코딩 결과가 없으면 권한이 없음을 응답.
if (!decoded) {
res.status(401).send(failResponse(401,"No authorized!"));
}
// access 토큰 만료 시
if (authResult.ok === false && authResult.message === "jwt expired") {
// 1. access token이 만료되고, refresh token도 만료 된 경우 => 새로 로그인해야합니다.
const refreshResult = await TokenUtils.refreshVerify(refreshToken, decoded.id);
if (refreshResult === false) {
res.status(401).send(failResponse(401,"No authorized! 다시 로그인해주세요."));
} else {
// 2. access token이 만료되고, refresh token은 만료되지 않은 경우 => 새로운 access token을 발급
const newAccessToken = TokenUtils.makeAccessToken({ id: decoded.id });
res.status(200).send(successResponse(
200,{
accessToken: newAccessToken,
refreshToken,
}
));
}
} else {
// 3. access token이 만료되지 않은경우 => refresh 할 필요가 없습니다.
res.status(400).send(failResponse(400,"Acess token is not expired!"));
}
} else {
// access token 또는 refresh token이 헤더에 없는 경우
res.status(401).send(failResponse(400,"Access token and refresh token are need for refresh!"));
}
};
- login()
위에서 언급한 "JWT를 이용한 로그인 과정"에 대한 로직이다.
1. 클라이언트로부터 userId와 userPw를 받으면 DB에 userId와 userPw가 있는지 확인한다.
2. DB 에러, ID 불일치, PW 불일치, 이메일 미인증의 경우에 클라이언트에게 에러 메세지를 전송한다.
3. 위에서 언급한 에러가 없다면 accessToken과 refreshToken을 발급한다.
4. 발급된 refreshToken과 id를 DB에 저장한 후 클라이언트에게 accessToken과 refreshToken을 전송한다.
- refresh()
위에서 언급한 ""에 대한 로직이며 클라이언트가 헤더에 accessToken과 refreshToken을 담아 보내게 된다.
1. accessToken과 refreshToken이 헤더에 존재하면 TokenUtils.verify()를 이용하여 access토큰을 검증하고 jwt.decode()를 사용하여 accessToken을 디코딩한다.
2.1 디코딩 결과가 없다면 클라이언트에게 권한이 없다는 에러 메세지를 전송한다.
2.2 accessToken이 만료되지 않았으면 클라이언트에게 refresh할 필요 없다는 메세지를 전송한다.
2.3 accessToken이 만료되었고 refreshToken이 만료된 경우 클라이언트에게 새로 로그인해야 한다는 메세지를 전송한다.
2.4 accessToken이 만료되었지만 refresToken이 만료되지 않은경우 새로운 accessToken을 발급받아 클라이언트에게 전송한다.
시간이 얼마 남지 않아 급하게 코드를 작성하다 보니 코드가 더럽다... 나중에 시간이 남으면 코드를 한 번 정리해야겠다.
또한 코드 발급, 인증에 관한 코드는 아래 글을 참고했으니 아래 글을 한 번 보면 좋을 것 같다.
참고
[Node.js+Express] Refresh Token 구현