* 위 내용을 정리하였음
1. 다른 서비스에서 호출하기
- api를 제공하는 서버를 만들어보았다.
- 이번에는 이 api를 사용하는 서비스를 만들어보자.
- 이전에 만든 rest api 서버는 nodebird-api 서버이다.
- 이 api를 사용해서 2차 서비스를 제공하는 nodecat이라는 서비스를 만들어보자.
// package.json
{
"name": "newservice",
"version": "0.0.1",
"description": "new service using api",
"main": "app.js",
"scripts": {
"start": "nodemon app"
},
"author": "jsdysw",
"license": "ISC",
"dependencies": {
"axios": "^0.21.1",
"cookie-parser": "^1.4.5",
"dotenv": "^8.2.0",
"express": "^4.17.1",
"express-session": "^1.17.1",
"morgan": "^1.10.0",
"nunjucks": "^3.2.1"
},
"devDependencies": {
"nodemon": "^2.0.3"
}
}
- 이 2차 서비스는 1차 서비스의 api를 통해 데이터(json)를 가져오는 것이 주 목적이다.
// nodecat/app.js
const express = require('express');
const morgan = require('morgan');
const cookieParser = require('cookie-parser');
const session = require('express-session');
const nunjucks = require('nunjucks');
const dotenv = require('dotenv');
dotenv.config();
const indexRouter = require('./routes');
const app = express();
app.set('port', process.env.PORT || 4000);
app.set('view engine', 'html');
nunjucks.configure('views', {
express: app,
watch: true,
});
app.use(morgan('dev'));
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('/', 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'), '번 포트에서 대기중');
});
- api를 사용하기 위해 사용자 인증을 해야 한다. 이를 테스트 하는 라우터를 만들어보자.
COOKIE_SECRET = nodecat
CLIENT_SECRET =418fad26-ebfd-4d74-9f7a-271071febd02
// nodecat/routes/index.js
const express = require('express');
const axios = require('axios');
const router = express.Router();
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 (error) {
console.error(error);
if (error.response.status === 419) { // 토큰 만료 시
return res.json(error.response.data);
}
return next(error);
}
});
module.exports = router;
- 토큰이 세션에 없으면 토큰을 발급을 요청한다.
- 토큰 발급이 성공하면 세션에 토큰을 저장하고 다시 test라우터로 토큰을 검증한다. 이때 보통 토큰을 요청 본문 대신 authorization 헤더에 넣는다.
- api 서버를 8002번에 띄워놓고 클라이언트의 서버를 4000번에 띄워서 발급받은 토큰을 검증해보자.
- 브라우저에서 localhost:4000/test를 호출한다.
- 1분이 지나면 토큰이 만료되기 때문에 토큰을 갱신하는 작업이 필요하다.
2. sns api 서버 만들기
- api 제공자 (8002번)의 나머지 API 라우터를 완성해보자.
// nodebird-api/routes/v1.js
const express = require('express');
const jwt = require('jsonwebtoken');
const { verifyToken } = require('./middlewares');
const { Domain, User, Post, Hashtag } = require('../models');
const router = express.Router();
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((error) => {
console.error(error);
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 (error) {
console.error(error);
return res.status(500).json({
code: 500,
message: '서버 에러',
});
}
});
module.exports = router;
- GET /posts/my, 내 게시물 반환
- GET /posts/hashtag/:title , 해시태그 검색 결과 반환
- 자 그럼 이 api를 사용자가 사용해보자.
nodecat/routes/index.js
const express = require('express');
const axios = require('axios');
const router = express.Router();
const URL = 'http://localhost:8002/v1';
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 (error) {
if (error.response.status === 419) { // 토큰 만료시 토큰 재발급 받기
delete req.session.jwt;
return request(req, api);
} // 419 외의 다른 에러면
return error.response;
}
};
router.get('/mypost', async (req, res, next) => {
try {
const result = await request(req, '/posts/my');
res.json(result.data);
} catch (error) {
console.error(error);
next(error);
}
});
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 (error) {
if (error.code) {
console.error(error);
next(error);
}
}
});
module.exports = router;
- request 함수는 NodeBird API에 요청을 보낸다.
- 토큰이 없으면 토큰을 발급해서 api에 요청으로 함께 보내고 만약 토큰이 만료되면 세션의 구 토큰을 지우고 새롭게 재귀적으로 request를 호출한다.
- GET /mypost라우터는 api를 사용해 json으로 받아오라는 라우터이다.
- 클라이언트 비밀키가 유출되면 다른사람이 나의 게시글을 가져갈 수 있는 것이다.
- 1분뒤 요청이 만료되어도 다시 요청하면 토큰을 재발급 한다.
3. 사용량 제한하기
- 토큰을 발급받았다고 무한정 api를 호출하게 해서 서버에 무리가 되면 안된다.
- 한시간에 열변만 호출을 허용한다던지 유료, 무료 에 따라 호출 횟수를 다르게 할 수 있다.
- express-rate-limit 패키지로 이를 구현해보자.
- api 서버에서 이 패키지를 설치한다.
npm i express-rate-limit
// nodebird-api/routes/middlewares.js
const jwt = require('jsonwebtoken');
const RateLimit = 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 {
res.redirect('/');
}
};
exports.verifyToken = (req, res, next) => {
try {
req.decoded = jwt.verify(req.headers.authorization, process.env.JWT_SECRET);
return next();
} catch (error) {
if (error.name === 'TokenExpiredError') { // 유효기간 초과
return res.status(419).json({
code: 419,
message: '토큰이 만료되었습니다',
});
}
return res.status(401).json({
code: 401,
message: '유효하지 않은 토큰입니다',
});
}
};
exports.apiLimiter = new RateLimit({
windowMs: 60 * 1000, // 1분
max: 10,
delayMs: 0,
handler(req, res) {
res.status(this.statusCode).json({
code: this.statusCode, // 기본값 429
message: '1분에 10 번만 요청할 수 있습니다.',
});
},
});
exports.deprecated = (req, res) => {
res.status(410).json({
code: 410,
message: '새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.',
});
};
- apiLimiter 미들웨어를 라우터 안에 넣으면 이제 라우터에 사용량 제한이 걸린다.
- windowMs(기준시간), max(허용횟수), handler(제한 초과 시 콜백)
- deprecated 미들웨어는 사용하면 안되는 라우터에 넣어줄 것이다.
응담 코드 | 메시지 |
200 | json 데이터입니다 |
401 | 유효하지 않은 토큰 |
410 | 새로운 버전이 나옴 |
419 | 토큰 만료 |
429 | 1분에 10번만 요청 가능 |
500~ | 기타 서버에러 |
- 사용량 제한이 추가 되었으므로 새로운 v2 라우터를 만든다.
// nodebird-api/routes/v2.js
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((error) => {
console.error(error);
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 (error) {
console.error(error);
return res.status(500).json({
code: 500,
message: '서버 에러',
});
}
});
module.exports = router;
// nodebird-app/routes/v1.js
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);
......
- 이제 예전 버전인 v1 라우터에 대해서는 경고를 띄워 주자.
- 이렇게 순차적으로 사용자가 v2로 넘어가면 v1을 지워주는게 좋다.
- 이제 v2를 서버에 연결한다.
// nodebird-app/app.js
const v2 = require('./routes/v2');
...
app.use('/v2', v2);
...
- 이제 사용자 입장에서 v2를 호출해보자.
- 너무 많이 요청하면 이렇게 나온다.
- v1으로 호출하면 새로운 버전 안내가 나온다.
- 지금은 nodebird-api가 재시작되면 사용량이 초기화 된다. 따라서 실제 서비스 에서는 사용량을 저장할 데이터 베이스를 마련하는 것이 좋고 레디스가 보통 많이 사용된다.
4. CORS 이해하기
- 이전까지는 Nodecat의 서버가 nodebird-api를 호출했다. (서버에서 서버로 API 호출)
// nodecat/routes/index.js
router.get('/', (req, res) => {
res.render('main', { key: process.env.CLIENT_SECRET });
});
- 이번에는 nodecat의 front에서 nodebird-api의 서버 API를 호출해서 렌더링 해보자.
<!DOCTYPE html>
<html>
<head>
<title>프론트 API 요청</title>
</head>
<body>
<div id="result"></div>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
axios.post('http://localhost:8002/v2/token', {
clientSecret: '{{key}}',
})
.then((res) => {
document.querySelector('#result').textContent = JSON.stringify(res.data);
})
.catch((err) => {
console.error(err);
});
</script>
</body>
</html>
- 넌적스가 key를 치환하여 렌더링한다.
- 막상 서버를 실행하면 다음과 같이 에러가 나온다.
- 이 에러는 위처럼 front(localhost:4000)에서 요청을 받은 서버(localhost:8002)의 도메인이 다르기 때문에 발생한 것이다.
- 이 문제를 CORS(Cross-Origin Resource Sharing) 문제라고 한다.
- 참고로 서버에서 서버로 요청을 보낼때는 발생하지 않는다.
- 네트워크 탭을 보면 가장먼저 OPTIONS Method로 서버가 이 도메인을 허용하는지 체크하는 역할을 한다.
- 이 문제를 해결하기 위해서는 응답 헤더에 Access-Control-Allow-Origin 헤더를 넣어야 한다. (클라이언트 도메인의 요청을 허락하겠다는 뜻)
npm i cors
- 응답을 해주는 API 서버에서 응답을 조작하기 위해 cors 패키지를 설치한다.
// nodebird-api/routes/v2.js
const express = require('express');
const jwt = require('jsonwebtoken');
const cors = require('cors');
const url = require('url');
const { verifyToken, apiLimiter } = require('./middlewares');
const { Domain, User, Post, Hashtag } = require('../models');
const router = express.Router();
router.use(async (req, res, next) => {
const domain = await Domain.findOne({
where: { host: url.parse(req.get('origin')).host },
});
if (domain) {
cors({
origin: req.get('origin'),
credentials: true,
})(req, res, next);
} else {
next();
}
});
....
- v2 모든 라우터에 적용한다.
- 호스트와 비밀키가 일치하는 경우에만 CORS 요청을 허용해준다.
- url.parse로 http, https 같은 프로토콜을 떼어낸다. 이 주소로 DB에 등록이 되어있는 도메인인지 확인한다.
- 이렇게 하면 응답에 Access-Control-Allow-Origin 헤더가 추가되어 나간다.
- credentials: true 를 활성화하면 다른 도메인간 쿠키가 공유된다. 만약 서버간의 도메인이 다른 경우 이 옵션이 false면 로그인이 되지 않을 수 있다.
- 또한 axios에서도 도메인이 다른 경우 쿠키를 공유할때 withCredentials:true 옵션을 주어서 요청을 보내야 한다.
router.use(cors());
router.use((req,res,next) => {
cors()(req,res,next);
});
- 참고로 이런식으로 미들웨어를 확장하는 사용법은 잊지말자.
- 보면 응답 헤더에 Access-Control-Allow-Origin에 요청자의 주소와 Credentials가 true로 설정되어있다.
- 등록되지 않은 도메인의 경우는 요청을 차단한다.
- 지금은 클라이언트에서 사용하는 비밀키와 서버에서 사용하는 비밀키가 같다.
- 보통은 각각 환경별로 여러개의 키를 발급하여 구분해서 사용한다.
- 이렇게 CORS 문제를 해결했지만 다른 방법도 있다.
- 클라이언트의 브라우저 도메인과 동일한 프록시 서버를 api 서버 사이에 두는 것이다. 브라우저의 요청은 프록시서버로 가고 프록시 서버가 api서버에게 요청하는 것이다. 서로 다른 도메인의 서버끼리의 통신이라 CORS가 문제되지 않는다.
'ComputerScience > NodeJs' 카테고리의 다른 글
node - 10.1 웹 API 서버 만들기 (프로젝트 구조, jwt토큰) (0) | 2022.02.08 |
---|---|
node - 9.4 익스프레스로 SNS서비스 만들기 (이미지 업로드, 팔로잉, 해시태그 구현) (0) | 2022.02.07 |
node - 9.3 익스프레스로 SNS 서비스 만들기(로그인 구현) (0) | 2022.02.06 |
node - 9.2 익스프레스로 SNS 서비스 만들기(SQL DB) (0) | 2022.02.05 |
node - 9.1 익스프레스로 SNS 서비스 만들기(프로젝트 구조) (0) | 2022.02.05 |