사용량 제한
일차적으로 인증된 사용자만 API를 사용할 수 있게 필터를 두긴 했지만, 아직 완성은 아니다.
인증된 사용자라고 해도 과도하게 API를 사용하면 API 서버에 무리가 간다. 따라서 일정 기간 내에 API를 사용할 수 있는 횟수를 제한하여 서버의 트래픽을 줄이는 것이 좋다.
이러한 기능은 express-rate-limit패키지에서 제공한다.
npm i express-rate-limit
verifyToken 미들웨어 아래에 apiLimiter 미들웨어와 deprecated 미들웨어를 추가하자.
const jwt = require('jsonwebtoken')
const RateLimiter = require('express-rate-limit');
exports.isLoggedIn = (req, res, next) => {
if(req.isAuthenticated()){
next();
}else{
res.status(403).send('로그인 필요');
}
};
exports.isNotLoggedIn = (req, res, next) => {
if(!req.isAuthenticated()){
next();
}else{
const message = encodeURIComponent('로그인한 상태입니다.');
res.redirect(`/?error=${message}`);
}
};
exports.verifyToken = (req, res, next) => {
try{
req.decoded = jwt.verify(req.headers.authorization, process.env.JWT_SECRET);
return next();
}catch(err){
if(err.name === 'TokenExpiredError'){
return res.status(419).json({
code: 419,
message: '토큰이 만료되었습니다.',
});
}
return res.status(401).json({
code: 401,
message: '유효하지 않은 토큰이다.'
});
}
};
exports.apiLimiter = new RateLimiter({
windowMs: 60 * 1000, //1분
max: 1,
handler(req, res) {
res.status(this.statusCode).json({
code: this.statusCode, //기본값 429
message: '1분에 한 번만 요청 가능합니다.',
});
},
});
exports.deprecated = (req, res) => {
res.status(410).json({
code: 410,
messgae: '새로운 버전이 나왔습니다. 새로운 버전을 사용하세요,',
});
};
nodebird-api/routes/middlewares.js
apiLimiter 미들웨어를 라우터에 넣으면 사용량 제한이 걸린다.
deprecated 미들웨어는 사용하면 안 되는 라우터에 붙여줄 용도이다.
사용량 제한이 추가되었으므로 기존 API 버전과 호환되지 않는다. 따라서 v2라우터를 만들어보자.
const express = require('express');
const jwt = require('jsonwebtoken');
const { verifyToken, apiLimiter } = require('./middlewares');
const { Domain, User, Post, Hashtag } = require('../models');
const router = express.Router();
router.post('/token', apiLimiter, async (req, res) => {
const { clientSecret } = req.body;
try {
const domain = await Domain.findOne({
where: { clientSecret },
include: {
model: User,
attribute: ['nick', 'id'],
},
});
if (!domain) {
return res.status(401).json({
code: 401,
message: '등록되지 않은 도메인입니다. 먼저 도메인을 등록하세요',
});
}
const token = jwt.sign({
id: domain.User.id,
nick: domain.User.nick,
}, process.env.JWT_SECRET, {
expiresIn: '30m', // 30분
issuer: 'nodebird',
});
return res.json({
code: 200,
message: '토큰이 발급되었습니다',
token,
});
} catch (error) {
console.error(error);
return res.status(500).json({
code: 500,
message: '서버 에러',
});
}
});
router.get('/test', verifyToken, apiLimiter, (req, res) => {
res.json(req.decoded);
});
router.get('/posts/my', apiLimiter, verifyToken, (req, res) => {
Post.findAll({where: {userID: req.decoded.id}})
.then((posts) => {
console.log(posts);
res.json({
code: 200,
payload: posts,
});
})
.catch((err) => {
console.error(err);
return res.status(500).json({
code: 500,
message: '서버 에러',
});
})
});
router.get('/posts/hashtag/:title', verifyToken, apiLimiter, async (req, res) => {
try{
const hashtag = await Hashtag.findOne({ where: {title: req.params.title}});
if(!hashtag){
return res.status(404).json({
code: 404,
message: '검색 결과가 없습니다.',
});
}
const posts = await hashtag.getPosts();
return res.json({
code:200,
payload: posts,
});
}catch(err){
console.error(err);
return res.status(500).json({
code: 500,
message: '서버 에러',
});
}
});
module.exports = router;
nodebird-api/routes/v2.js
토큰의 유효 기간을 30분으로 늘렸고, 사용량 제한 미들웨어를 추가했다.
v1에 사용하지 않는 라우터에 deprecated 미들웨어를 달아주자.
const express = require('express');
const jwt = require('jsonwebtoken');
const { verifyToken, deprecated } = require('./middlewares');
const { Domain, User, Post, Hashtag } = require('../models');
const router = express.Router();
router.use(deprecated);
router.post('/token', async (req, res) => {
const { clientSecret } = req.body;
try {
const domain = await Domain.findOne({
where: { clientSecret },
include: {
model: User,
attribute: ['nick', 'id'],
},
});
if (!domain) {
return res.status(401).json({
code: 401,
message: '등록되지 않은 도메인입니다. 먼저 도메인을 등록하세요',
});
}
const token = jwt.sign({
id: domain.User.id,
nick: domain.User.nick,
}, process.env.JWT_SECRET, {
expiresIn: '1m', // 1분
issuer: 'nodebird',
});
return res.json({
code: 200,
message: '토큰이 발급되었습니다',
token,
});
} catch (error) {
console.error(error);
return res.status(500).json({
code: 500,
message: '서버 에러',
});
}
});
router.get('/test', verifyToken, (req, res) => {
res.json(req.decoded);
});
router.get('/posts/my', verifyToken, (req, res) => {
Post.findAll({where: {userID: req.decoded.id}})
.then((posts) => {
console.log(posts);
res.json({
code: 200,
payload: posts,
});
})
.catch((err) => {
console.error(err);
return res.status(500).json({
code: 500,
message: '서버 에러',
});
})
});
router.get('/posts/hashtag/:title', verifyToken, async (req, res) => {
try{
const hashtag = await Hashtag.findOne({ where: {title: req.params.title}});
if(!hashtag){
return res.status(404).json({
code: 404,
message: '검색 결과가 없습니다.',
});
}
const posts = await hashtag.getPosts();
return res.json({
code:200,
payload: posts,
});
}catch(err){
console.error(err);
return res.status(500).json({
code: 500,
message: '서버 에러',
});
}
});
module.exports = router;
nodebird-api/routes/v1.js
하지만 실제 서비스에서는 v2가 나왔다고 바로 v1에 deprecated를 달아버리는 것은 좋지 않다.
이제 새로 만든 라우터를 서버와 연결하자.
const express = require('express');
const path = require('path');
const cookieParser = require('cookie-parser');
const passport = require('passport');
const morgan = require('morgan');
const session = require('express-session');
const nunjucks = require('nunjucks');
const dotenv = require('dotenv');
const v1 = require('./routes/v1');
const v2 = require('./routes/v2');
dotenv.config();
const authRouter = require('./routes/auth');
const indexRouter = require('./routes');
const {sequelize} = require('./models');
const passportConfig = require('./passport');
const app = express();
passportConfig();
app.set('port', process.env.PORT || 8002);
app.set('view engine', 'html');
nunjucks.configure('views',{
express: app,
watch: true,
});
sequelize.sync({ force: false})
.then(() => {
console.log('데이터베이스 연결 성공');
})
.catch((err) => {
console.error(err);
});
app.use(morgan('dev'));
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
app.use(express.urlencoded({extended: false}));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(session({
resave: false,
saveUninitialized: false,
secret: process.env.COOKIE_SECRET,
cookie:{
httpOnly: true,
secure: false,
},
}));
app.use(passport.initialize());
app.use(passport.session());
app.use('/v1', v1);
app.use('/v2', v2);
app.use('/auth', authRouter);
app.use('/', indexRouter);
app.use((req, res, next) => {
const error = new Error(`${req.method} ${req.url} 라우터가 없습니다. `);
error.status = 404;
next(error);
});
app.use((err, req, res, next) => {
res.locals.message = err.message;
res.locals.error = process.env.NODE_ENV !== 'production' ? err : {};
res.status(err.status || 500);
res.render('error');
});
app.listen(app.get('port'), () => {
console.log(app.get('port'), '번 포트에서 대기 중...');
});
nodebird-api/app.js
사용자 입장으로 돌아와 새로 생긴 버전을 호출해보자.
const express = require('express');
const axios = require('axios');
const router = express.Router();
const URL = 'http://localhost:8002/v2';
axios.defaults.headers.origin = 'http://localhost:4000'; // origin 헤더 추가
const request = async (req, api) => {
try{
if(!req.session.jwt) { //세션에 토큰이 없으면
const tokenResult = await axios.post(`${URL}/token`, {
clientSecret: process.env.CLIENT_SECRET,
});
req.session.jwt = tokenResult.data.token; //세션에 토큰 저장
}
return await axios.get(`${URL}${api}`,{
headers: { authorization: req.session.jwt},
}); //API 요청
}catch(err){
if(err.response.status === 419){ //토큰 만료시
delete req.session.jwt;
return request(req, api);
}//419 외의 다른 에러
return err.response;
}
};
router.get('/mypost', async(req, res, next) => {
try{
const result = await request(req, '/posts/my');
res.json(result.data);
}catch(err){
console.error(err);
next(err);
}
});
router.get('/search/:hashtag', async (req, res, next) => {
try{
const result = await request(
req, `/posts/hashtag/${encodeURIComponent(req.params.hashtag)}`,
);
res.json(result.data);
}catch(err){
if(err.code){
console.error(err);
next(err);
}
}
});
router.get('/test', async (req, res, next) => { //토큰 테스트 라우터
try{
if(!req.session.jwt){
const tokenResult = await axios.post('http://localhost:8002/v1/token', {
clientSecret: process.env.CLIENT_SECRET,
});
if(tokenResult.data && tokenResult.data.code === 200){ //토큰 발급 성공
req.session.jwt = tokenResult.data.token; // 토큰 저장
}else{//토큰 발급 실패
return res.json(tokenResult.data);
}
}
//토큰 테스트
const result = await axios.get('http://localhost:8002/v1/test', {
headers: {authorization: req.session.jwt},
});
return res.json(result.data);
}catch(err){
console.error(err);
if(err.response.status === 419){// 토큰 만료 시
return res.json(err.response.data);
}
return next(err);
}
});
module.exports = router;
nodecat/routes/index.js
만약 v2로 URL을 바꾸지 않고 실행한다면 410에러가 발생한다.
또한, 1분에 한 번보다 많이 API를 호출하면 429에러가 발생한다.
실제 서비스 정책에 맞게 사용량은 조절하면 된다.