Lsiron
HELLO FOLIO(express, mongoose) - 6(jwt 을 이용한 토큰 검증 미들웨어 구현) 본문
HELLO FOLIO(express, mongoose) - 6(jwt 을 이용한 토큰 검증 미들웨어 구현)
Lsiron 2024. 7. 2. 18:48현재 폴더 구조
web_project/
├── db/
│ ├── model # 각종 모델 폴더
│ ├── schemas # 각종 스키마 폴더
│ └── index.js # db 연결파일
├── middleware/ # 각종 middleware 폴더
├── passport/ # 로그인 관련 passport 폴더
│ ├── strategies # 전략 구성
│ └── index.js # 전략 exports 파일
├── routes/ # 라우트 관련 폴더
│ └── index.js # 라우트 exports 파일
├── services/ # 유저 관련 service 폴더
├── views/ # EJS 템플릿 폴더
├── public/ # static 파일 폴더
│ ├── css/ # CSS 파일들
│ ├── js/ # JS 파일들
│ └── images/ # 이미지 파일들
├── .env # 환경 변수 파일
├── .gitignore # Git 무시 파일
├── package.json # 프로젝트 메타데이터 및 종속성 목록
├── app.js # 애플리케이션 진입점
├── index.js # 서버 실행 파일
└── README.md # 프로젝트 설명 파일
현재 설치한 모듈
"dependencies": {
"bcrypt": "^5.1.1",
"cookie-parser": "^1.4.6",
"dotenv": "^16.4.5",
"ejs": "^3.1.10",
"express": "^4.19.2",
"jsonwebtoken": "^9.0.2",
"mongodb": "^6.7.0",
"mongoose": "^8.4.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"passport-local": "^1.0.0"
},
app.js
const express = require('express');
const app = express();
const bcrypt = require('bcrypt');
const passport = require('passport');
const { userRouter, myPageRouter } = require("./routes");
require('./passport')();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(passport.initialize());
app.use('/', userRouter);
app.use('/my-page', authMiddleware ,myPageRouter)
module.exports = { app };
.env
# DB 접속 URL
DB_URL="디비주소"
# DB 프로젝트 이름
DB_NAME="디비이름"
# 서버 포트 번호
PORT=3000
# JWT토큰 발급 키
SECRET="secret"
# REFRESH 토큰 발급 키
REFRESH_SECRET="toosecret"
전 날에는 토큰을 발급하는 코드를 짰다.
이제는 이 토큰을 검증하는 코드를 짤 차례이다.
생각을 해보자. 로그인을 하면 토큰이 주어진다. 이 토큰이 있어야 페이지로 들어갈 수 있다.
그러면 경로를 요청하는 것 까진 돼야한다. 그래야 검증을 할 수 있을테니, 그러면 ? 보통 경로를 요청하면 라우터에 연결이 되는데, 이 라우터에 연결되는건 막아야한다.
그러면 경로와 라우터 사이에 검증하는 시스템을 넣으면 되지않을까?
이때 경로와 라우터 사이에 검증하는 코드 즉 미들웨어를 넣으면 되겠다.
헌데 모든 라우터에 토큰을 검증하면 안될 것이다. 이게 무슨 말이냐면 적어도 회원가입과 로그인은 토큰이 없이 진행 돼야 한다는 것.
그러면 한번 미들웨어부터 구현해보자.
middleware 폴더로 가서 authMiddleware.js 파일을 하나 만들어주자.
그리곤 미들웨어로 만들 것 이기 때문에 변수로 함수를 선언해준다.
const authMiddleware = (req, res, next) => {
};
module.exports = authMiddleware;
먼저 우리가 ejs 즉, 클라이언트에서 입력한 email이나 password를 가져올때는 const email = req.body.email 이런식으로 가져왔었다. 그러면 쿠키를 가져올 땐 어떻게 해야할까?
똑같이 const 쿠키에서가져올것 = req.cookies.쿠키에서가져올것 이렇게 적으면 된다.
이는 즉 const 쿠키에서가져올것 = req.cookies; 로 적어도 된다.
우리는 accessToken과 refreshToken을 쿠키에서 가져와야 하기 때문에 이 둘을 빼주는 변수를 만들어주자.
( 쿠키에 담겨있는 토큰을 가져와서 검증을 해야하기 때문)
const authMiddleware = (req, res, next) => {
// 쿠키에서 accessToken과 refreshToken 추출
const { accessToken, refreshToken } = req.cookies;
};
module.exports = authMiddleware;
자 이제 쿠키에서 토큰도 가져왔으니 검증을 한번 해 보자.
총 두 개의 토큰이 있으니, 둘 다 없을 때와 둘 중 하나만 있을 때 이 3개의 경우의 수를 적용해야겠다.
먼저 둘 다 없을때는 당연하게도 접근이 안 된다.
쿠키에 accessToken 과 refreshToken이 없으니, 로그인 조차 하지 않은 경우라고 볼 수 있다.
그러므로 재로그인을 하도록 유도해주자.
const authMiddleware = async (req, res, next) => {
const { accessToken, refreshToken } = req.cookies;
// accessToken과 refreshToken 둘 다 없을 때
if (!accessToken && !refreshToken) {
return res.redirect('/login');
}
// accessToken이 있을 때
if (accessToken) {
}
// refreshToken이 있을 때
if (refreshToken) {
}
};
module.exports = authMiddleware;
다음으로, accessToken만 있고 refreshToken이 없는 경우는 refreshToken을 다시 받도록 재로그인을 하도록 유도하고, refreshToken이 있고 accessToken이 없는 경우에는 accessToken을 재발급 해 주도록 하자.
허나 accessToken과 refreshToken을 가지고 있는 경우에 이를 검증해야하는데 이 검증하는 코드를 npm 사이트에서 긁어와야한다. https://www.npmjs.com/package/jsonwebtoken
여기에서 jwt.verify() 코드를 긁어와서 입력하고 jwt를 사용하고 있으니 jwt 라이브러리를 불러와준다.
const jwt = require('jsonwebtoken');
const authMiddleware = async (req, res, next) => {
const { accessToken, refreshToken } = req.cookies;
// accessToken과 refreshToken 둘 다 없을 때
if (!accessToken && !refreshToken) {
return res.redirect('/login');
}
// accessToken이 있을 때
if (accessToken) {
var decoded = jwt.verify(token, 'shhhhh');
}
// refreshToken이 있을 때
if (refreshToken) {
var decoded = jwt.verify(token, 'shhhhh');
} return res.redirect('/login'); //refreshToken이 없을 때
};
module.exports = authMiddleware;
이렇게 긁어 왔으니, 첫 번째 인자 token에는 해당 토큰을 입력 해 주고, 두 번째 인자의 키에는 .env 에 설정한 키 값들을 입력해주자.
const jwt = require('jsonwebtoken');
const authMiddleware = async (req, res, next) => {
const { accessToken, refreshToken } = req.cookies;
if (!accessToken && !refreshToken) {
return res.redirect('/login');
}
if (accessToken) {
const decodedAccess = jwt.verify(accessToken, process.env.SECRET);
}
if (refreshToken) {
const decodedRefresh = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
} return res.redirect('/login');
};
module.exports = authMiddleware;
추가한 코드의 뜻은, 클라이언트로부터 token을 받아와서 비밀 키로 토큰을 검증하고, 디코딩된 토큰의 정보를 변수에 담는 것이다.
즉 jwt.verify()가 검증과 동시에 유저의 정보를 토큰으로부터 디코딩 함.
다시말해서,
변수 decodedAccess 에는 AccessToken에서 디코딩된 정보가 담긴다. 여기에는 사용자 ID(_id) 등이 포함된다.
변수 decodedRefresh 에는 RefreshToken에서 디코딩된 정보가 담긴다. 여기에는 사용자 ID(_id) 등이 포함된다.
그러면 이 디코딩된 정보로 데이터베이스에 유저 정보가 있는지 찾는 코드를 만들어보자. ( 토큰에서 추출한 정보로 데이터베이스에서 찾으면, 토큰에 유저정보가 정상적으로 담겼다는 것이다.)
const jwt = require('jsonwebtoken');
const UserModel = require('../db/model/userModel');
const authMiddleware = async (req, res, next) => {
const { accessToken, refreshToken } = req.cookies;
if (!accessToken && !refreshToken) {
return res.redirect('/login');
}
if (accessToken) {
const decodedAccess = jwt.verify(accessToken, process.env.SECRET);
const user = await UserModel.findOne({ _id: decodedAccess._id });
}
if (refreshToken) {
const decodedRefresh = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
const user = await UserModel.findOne({ _id: decodedRefresh._id, refreshToken });
} return res.redirect('/login');
};
module.exports = authMiddleware;
유저 정보를 찾는 것만 변수에 담아선 무엇을 처리할 수가 없다.
accessToken은 정보가 데이터베이스의 유저정보와 일치한다면 그냥 사용자 정보를 요청 객체에 설정해주고 다음 단계로 그냥 넘겨주면 된다.
refreshToken은 쿠키와 데이터베이스에 함께 담겨있기 때문에 디코딩한 정보가 데이터베이스의 유저정보와 일치하는지 확인할 때, 데이터베이스에 refreshToken이 있는지 확인해야한다.
만약 일치 한다면 똑같이 사용자 정보를 요청 객체에 설정해주고 다음 단계로 넘어가는데,
단, refreshToken은 accessToken을 재발급 해주는 과정을 추가해야한다.
다섯째날에 로그인 할 때 발급해주던 accessToken 발급 과정을 그대로 가져다 쓰자.
const jwt = require('jsonwebtoken');
const UserModel = require('../db/model/userModel');
const authMiddleware = async (req, res, next) => {
const { accessToken, refreshToken } = req.cookies;
if (!accessToken && !refreshToken) {
return res.redirect('/login');
}
if (accessToken) {
const decodedAccess = jwt.verify(accessToken, process.env.SECRET);
const user = await UserModel.findOne({ _id: decodedAccess._id });
req.user = user; // 사용자 정보를 요청 객체에 설정
return next();
}
if (refreshToken) {
const decodedRefresh = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
const user = await UserModel.findOne({ _id: decodedRefresh._id, refreshToken });
const newAccessToken = jwt.sign({ _id: refreshPayload._id }, process.env.SECRET, { expiresIn: '1h' });
res.cookie('accessToken', newAccessToken, { httpOnly: true });
req.user = user; // 사용자 정보를 요청 객체에 설정
return next();
} return res.redirect('/login');
};
module.exports = authMiddleware;
req.user에 인증된 사용자 정보가 설정되어 있기 때문에, 이후의 라우트 핸들러에서 req.user를 통해 사용자 정보를 쉽게 접근할 수 있다.
이제 거의 다 마무리 되었다. if로 데이터베이스에 user 정보가 없을 때를 처리해주자.
const jwt = require('jsonwebtoken');
const UserModel = require('../db/model/userModel');
const authMiddleware = async (req, res, next) => {
const { accessToken, refreshToken } = req.cookies;
if (!accessToken && !refreshToken) {
return res.redirect('/login');
}
if (accessToken) {
const decodedAccess = jwt.verify(accessToken, process.env.SECRET);
const user = await UserModel.findOne({ _id: decodedAccess._id });
if (!user) {
return res.status(403).send('사용자를 찾을 수 없습니다');
}
req.user = user;
return next();
}
if (refreshToken) {
const decodedRefresh = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
const user = await UserModel.findOne({ _id: decodedRefresh._id, refreshToken });
if (!user) {
return res.status(403).send('사용자를 찾을 수 없습니다');
}
const newAccessToken = jwt.sign({ _id: refreshPayload._id }, process.env.SECRET, { expiresIn: '1h' });
res.cookie('accessToken', newAccessToken, { httpOnly: true });
req.user = user;
return next();
} return res.redirect('/login');
};
module.exports = authMiddleware;
마지막으로 try / catch 문으로 예외처리를 해주자. 단, 주의할 점이 있다.
예외처리를 할 땐, 유효한 토큰인지를 확인 해야한다.
이미 if조건문으로
Token이 둘 다 없을 땐 res.redirect('/login') 으로 재로그인을 하도록 했고,
accessToken이 없을 땐 refreshToken을 검증으로 넘어가서, 있다면 accessToken을 재발급 하도록 했고,
refreshToken이 없을 땐 res.redirect('/login') 으로 재로그인을 하도록 했다.
그러면 try / catch 문으로는 유효성을 검증해야한다.
즉, accessToken이 있다면 유효성이 있을땐 try로 처리, 없을땐 catch로 처리 ,
refreshToken이 있다면 유효성이 있을 땐 try로 처리 없을땐 catch로 처리를 해야한다.
여기서 각 토큰의 검증 차이점이 나온다.
accessToken이 유효하지 않을 땐, 단순히 유효기간이 만료됐을 때와 아닌 경우로 나뉜다.
(accessToken은 만료된 경우와 유효하지 않은 경우를 구분하여 처리하는 것이 중요하다.)
refreshToken이 유효하지 않을 땐, 가지고있지 않을 때와 같이 재로그인을 하도록 한다.
(refreshToken은 만료된 경우와 유효하지 않은 경우 보안상의 이유로 사용자를 모두 재로그인 시키는 것이 일반적이다. 따라서 두 경우를 구분할 필요가 없이 그냥 둘 다 재로그인을 시켜주면 된다.)
아래와 같이 코드를 짜주면 되겠다.
const jwt = require('jsonwebtoken');
const UserModel = require('../db/model/userModel');
const authMiddleware = async (req, res, next) => {
try{
const { accessToken, refreshToken } = req.cookies;
if (!accessToken && !refreshToken) {
return res.redirect('/login');
}
if (accessToken) {
try{
const decodedAccess = jwt.verify(accessToken, process.env.SECRET);
const user = await UserModel.findOne({ _id: decodedAccess._id });
if (!user) {
return res.status(403).send('사용자를 찾을 수 없습니다');
}
req.user = user;
return next();
} catch (err) {
// 유효한 accessToken이 없을 때
if(err.name !== 'TokenExpiredError') {
return res.status(403).send('AccessToken이 유효하지 않습니다.')
// TokenExpiredError인 경우 아래에서 처리(access token 만료)
}
}
}
if (refreshToken) {
try{
const decodedRefresh = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
const user = await UserModel.findOne({ _id: decodedRefresh._id, refreshToken });
if (!user) {
return res.status(403).send('사용자를 찾을 수 없습니다');
}
const newAccessToken = jwt.sign({ _id: refreshPayload._id }, process.env.SECRET, { expiresIn: '1h' });
res.cookie('accessToken', newAccessToken, { httpOnly: true });
req.user = user;
return next();
} catch (err) {
// 유효한 refreshToken이 없을 때, 재로그인 유도
return res.redirect('/login');
}
}
} return res.redirect('/login');
} catch (err) {
return res.status(500)send('내부 서버 오류발생')
}
};
module.exports = authMiddleware;
if (err.name !== 'TokenExpiredError') 조건문으로 만료된 경우와 유효하지 않은 경우를 구분 해 주었다.
만료된 경우엔 if 조건문을 탈출 한 뒤, accessToken이 없을 때와 똑같이 refreshToken을 검증한 뒤에 refreshToken이 있다면 accessToken을 재발급 해 주는것이다.
TokenExpiredError는 외우고 써야하는 것인가? 아니다 https://www.npmjs.com/package/jsonwebtoken
위 사이트에 Error Object가 있다. 그대로 긁어오면 된다.
이제 검증하는 미들웨어까지 작성 해 주었다.
정말 마무리 단계만 남았다. 이 미들웨어를 적용해주면 된다.
app.js로 가서 미들웨어를 적용시켜주자.
const express = require('express');
const app = express();
const bcrypt = require('bcrypt');
const passport = require('passport');
const { userRouter, myPageRouter } = require("./routes");
require('./passport')();
const authMiddleware = require('./middleware/authMiddleware'); // 인증 미들웨어 불러오기
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(passport.initialize());
app.use('/', userRouter);
app.use('/my-page', authMiddleware ,myPageRouter); // myPageRouter에 authMiddleware 미들웨어 적용
module.exports = { app };
라우터에 미들웨어를 적용하고나서 이제 로그인 과정이 모두 끝이났다!
전에도 언급했듯이 userRouter에는 적용을 하면 안된다.
'개발일지 > HELLO FOLIO' 카테고리의 다른 글
HELLO FOLIO(express, mongoose)- 8(Nodemailer 를 이용한 비밀번호 재설정 구현 2) (0) | 2024.07.13 |
---|---|
HELLO FOLIO(express, mongoose)- 7(Nodemailer 를 이용한 비밀번호 재설정 구현) (0) | 2024.07.08 |
HELLO FOLIO(express, mongoose)- 5(jwt를 이용한 토큰 발급 로그인 구현) (0) | 2024.07.02 |
HELLO FOLIO(express, mongoose)- 4(passport를 이용한 로그인 전략 구현) (0) | 2024.06.29 |
HELLO FOLIO(express, mongoose)- 3(bcrypt를 이용한 회원가입 구현) (0) | 2024.06.27 |