이전에 만들었던 sns(Git [ch9/sns5]) 서비스의 API(sns-api
)를 만들어보겠다. (데이터베이스를 sns 서비스와 공유하겠다.) 또한 snsplus
라는 sns-api를 이용하는 서비스도 간단하게 만들어보겠다.
책 Node.js 교과서(개정 2판) 책의 10장의 내용을 참고했다.
전체 클라이언트 서버 관계
sns5
: 나의 앱(localhost:8001)sns-api
: sns5의 API 앱(localhost:8002)snsplus
: sns-api를 이용하여 데이터를 가져오는 제 3자 앱(localhost:4000)
Github 주소1(sns-api
, snsplus
): https://github.com/delay-100/study-node/tree/main/ch10
Github 주소2(sns5
): https://github.com/delay-100/study-node/tree/main/ch9/sns5
API란?
API(Application Programming Interface)
- 다른 애플리케이션에서 현재 프로그램의 기능을 사용할 수 있게 허용하는 접점/창구
웹 API서버
: 서버에 API를 올려서 URL을 통해 접근할 수 있게 만든 것- 다른 서비스에서 내 웹 사이트의 정보를 가져갈 수 있음(물론 공개해도 되는 정보들만 API로 만듦)
- 정보를 모든 사람이 아니라, 인증된 사용자에게만 제공
+크롤링(crawling)
: 웹 사이트가 자체적으로 제공하는 API가 없거나 API 이용에 제한이 있을 때 사용하는 방법
1. 웹 API 서버 만들기(sns-api 앱)
데이터베이스를 sns5 앱과 공유하고 있기 때문에, .env, config, models, passport, routes/auth.js, routes/middlewares.js를 그대로 붙혀넣어 사용!
+해당 코드는 Github에서 갖다 쓰면 된다.
sns-api 실행화면
- http://localhost:8002/ - login페이지(
get /
이 login.html을 실행시킴)
1. npm 패키지 설치하기(package.json)
Git [sns-api/package.json
] - npm의 패키지 설치
{
"name": "sns-api",
"version": "1.0.0",
"description": "sns API 서버",
"main": "app.js",
"scripts": {
"start": "nodemon"
},
"author": "delay100",
"license": "ISC",
"dependencies": {
"bcrypt": "^5.0.1",
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"dotenv": "^16.0.0",
"express": "^4.17.2",
"express-rate-limit": "^5.1.1",
"express-session": "^1.17.2",
"jsonwebtoken": "^8.5.1",
"morgan": "^1.10.0",
"mysql2": "^2.3.3",
"nunjucks": "^3.2.3",
"passport": "^0.5.2",
"passport-kakao": "^1.0.1",
"passport-local": "^1.0.0",
"sequelize": "^6.16.1",
"uuid": "^8.3.2"
},
"devDependencies": {
"nodemon": "^2.0.15"
}
}
몇몇 패키지를 소개하겠다. 나머지는 이전 포스팅들에서 많이 다뤘기 떄문에 블로그에서 검색하면 나올 것이다!
uuid
: 고유한 랜덤 문자열을 만들 때 사용jsonwebtoken
: Json Web Token, Json 포맷을 이용하여 사용자에 대한 속성을 저장express-rate-limit
: api 사용량 제한, 보통 레디스가 많이 사용됨, 단, express-rate-limit은 db와 연결하는 것을 지원하지 않으므로 npm에서 새로운 패키지를 찾거나 직접 구현해야 함cors
: 요청을 보내는 클라이언트와 요청을 받는 서버의 도메인이 다른 경우 발생하는 에러를 해결하기 위한 패키지
2. 기본 설정(sns-api/app.js
, sns-api/views/error.html
)
Git [sns-api/app.js
]
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(); // .env 파일을 쓸 수 있게 함
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'), '번 포트에서 대기 중');
});
Git [sns-api/views/error.html
]
<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>
3. 도메인(domain) 모델 추가하기
Git [sns-api/models/domain.js
]
const Sequelize = require('sequelize');
module.exports = class Domain extends Sequelize.Model {
static init(sequelize) {
return super.init({
host: { // 인터넷 주소(host)
type: Sequelize.STRING(80),
allowNull: false,
},
type: { // 도메인 종류(type)
type: Sequelize.ENUM('free', 'premium'), // ENUM: 넣을 수 있는 값을 제한하는 데이터 형식, [무료: free, 프리미엄: proemium] 중 하나의 종류만 선택 가능-> 어기면 에러
allowNull: false,
},
clientSecret: { // 클라이언트 비밀 키(clientSecret) - 다른 개발자들이 sns의 API를 사용할 때 필요한 비밀 키
type: Sequelize.UUID, // UUID: 충돌 가능성이 매우 적은 랜덤한 문자열
allowNull: false,
},
}, {
sequelize,
timestamps: true,
paranoid: true,
modelName: 'Domain',
tableName: 'domains',
});
}
static associate(db) {
db.Domain.belongsTo(db.User); // User(1): Domain(N)으로, 일대다 관계
}
}
+Git [sns-api/models/index.js
- 생성한 domain 모델 추가하기
const Domain = require('./domain');
...
db.Domain = Domain;
...
Domain.init(sequelize);
...
Domain.associate(db);
...
+Git [sns-api/models/user.js
] - domain 모델과 1:N 관계이기 때문에 추가해주어야 함
...
db.User.hasMany(db.Domain);
4. API서버의 메인화면(html) + 도메인을 등록하는 화면 포함
Git [sns-api/views/login.html
] - 간단하게 동작만 들어있는 html 작성
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>API 서버 로그인</title>
<style>
.input-group label { width: 200px; display: inline-block; }
</style>
</head>
<body>
{% if user and user.id %}
<span class="user-name">안녕하세요! {{ user.nick }}님</span>
<a href="/auth/logout">
<button>로그아웃</button>
</a>
<fieldset>
<legend>도메인 등록</legend>
<form action="/domain" method="post">
<div>
<label for="type-free">무료</label>
<input type="radio" id="type-free" name="type" value="free">
<label for="type-premium">프리미엄</label>
<input type="radio" id="type-premium" name="type" value="premium">
</div>
<div>
<label for="host">도메인</label>
<input type="text" id="host" name="host" placeholder="ex) zerocho.com">
</div>
<button>저장</button>
</form>
</fieldset>
<table>
<tr>
<th>도메인 주소</th>
<th>타입</th>
<th>클라이언트 비밀키</th>
</tr>
{% for domain in domains %}
<tr>
<td>{{domain.host}}</td>
<td>{{domain.type}}</td>
<td>{{domain.clientSecret}}</td>
</tr>
{% endfor %}
</table>
{% else %}
<form action="/auth/login" id="login-form" method="post">
<h2>NodeBird 계정으로 로그인하세요.</h2>
<div class="input-group">
<label for="email">이메일</label>
<input id="email" type="email" name="email" required autofocus>
</div>
<div class="input-group">
<label for="password">비밀번호</label>
<input id="password" type="password" name="password" required>
</div>
<div>회원가입은 localhost:8001에서 하세요.</div>
<button id="login" type="submit">로그인</button>
</form>
<script>
window.onload = () => {
if( new URL(location.href).searchParams.get('loginError')){
alert(new URL(location.href).setParams.get('loginError'));
}
};
</script>
{% endif %}
</body>
</html>
5. 기본 라우터 세팅(GET /, 도메인 등록)
Git [sns-api/routes/index.js
]
const express = require('express');
const { v4: uuidv4 } = require('uuid'); // uuidv4를 가져오면서 v4로 이름을 바꾸며 가져옴
const {User, Domain } = require('../models');
const { isLoggedIn } = require('./middlewares');
const router = express.Router();
// GET / 요청 - 접속 시 로그인 화면을 보여줌
router.get('/', async (req, res, next) => {
try {
const user = await User.findOne({
where: { id: req.user && req.user.id || null},
include: { model: Domain }, // 해당 Domain을 가진 User가 있는지 검색
});
res.render('login', {
user,
domains: user && user.Domains, // user 객체가 있는지 체크해야 서버에서 오류가 나지 않기때문에 user으로 확인을 먼저 해줌
});
} catch (err) {
console.error(err);
next(err);
}
});
// POST /domain 요청 - 도메인 등록 라우터
router.post('/domain', isLoggedIn, async (req, res, next) => {
try {
await Domain.create({
UserId: req.user.id,
host: req.body.host,
type: req.body.type,
clientSecret: uuidv4(), // clientSecret 값을 uuid 패키지(버전 4-36자리 문자열 형식으로 생김)를 통해 생성, 세 번째 마디의 첫 번째 숫자가 4
});
res.redirect('/');
} catch(err){
console.error(err);
next(err);
}
});
module.exports = router;
2. JWT 토큰으로 인증하기
JWT(JSON Web Token)
JSON 형식의 데이터를 저장하는 토큰
JWT 비밀 키를 알지 않는 이상 변조가 불가능 함
공식 홈페이지: https://jwt.io/
구성
헤더(header)
: 토큰 종류와 해시 알고리즘 정보페이로드(payload)
: 토큰의 내용물이 인코딩된 부분시그니처(signature)
: 일련의 문자열, 시그니처를 통해 토큰이 변조되었는지 여부를 확인/JWT 비밀키로 만들어짐
http://localhost:4000/test - 토큰 테스트
jsonwebtoken 설치
npm i jsonwebtoken
Git [sns-api/.env
] 中 jwt secret key 설정
...
JWT_SECRET=여기
1. 토큰 검증 미들웨어 생성
Git [sns-api/routes/middlewares.js
] 中 토큰 검증 미들웨어 생성
const jwt = require('jsonwebtoken'); // Json Web Token, Json 포맷을 이용하여 사용자에 대한 속성을 저장
...
// 토큰 검증 미들웨어
exports.verifyToken = (req, res, next) => {
try {
req.decoded = jwt.verify(req.headers.authorization, process.env.JWT_SECRET); // 요청 헤더에 저장된 토큰(req.headers.authorization) 사용, jwt.verify: 토큰 검증
// 첫 번째 인수: 토큰(토큰의 내용: 사용자 아이디, 닉네임, 발급자, 유효 기간), 두 번째 인수: 토큰의 비밀 키
// 토큰의 비밀 키가 일치하지 않으면/유효기간이 지난 경우 catch문으로 이동
return next(); // 다음 미들웨어에서 req.decoded를 통해 토큰의 내용물을 사용할 수 있음
} catch (error) {
if (error.name == 'TokenExpiredError') { // 유효 기간 초과
return res.status(419).json({ // 코드는 400번대 중 마음대로 정해도 됨
code: 419,
message: '토큰이 만료되었습니다',
});
}
return res.status(401).json({ // 토큰의 비밀 키가 일치하지 않는 경우
code: 401,
message: '유효하지 않은 토큰입니다',
});
}
};
...
2. 구버전(v1) 만들기
Git [sns-api/routes/v1.js
] 中 post('/token')
: 도메인 등록 확인 라우터, get('/test')
: 사용자가 발급 받은 토큰 테스트 라우터
// 라우터의 이름: v1(버전 1)
const express = require('express');
const jwt = require('jsonwebtoken'); // JWT(JSON Web Token) 토큰 인증
// const { verifyToken, deprecated } = require('./middlewares');
const { verifyToken } = require('./middlewares');
const { Domain, User, Post, Hashtag } = require('../models');
const router = express.Router();
// router.use(deprecated); // 라우터 앞에 deprecated 미들웨어를 추가하여 v1으로 접근한 모든 요청에 deprecated 응답을 보냄
// 토큰을 발급하는 라우터
router.post('/token', async (req, res) => {
const { clientSecret } = req.body; // snsplus/routes/index.js에서 clientSecret: process.env.CLIENT_SECRET
try {
const domain = await Domain.findOne({ // 전달받은 클라이언트 비밀 키로 도메인이 등록된 것인지를 확인
where: { clientSecret },
include: {
model: User,
attribute: ['nick', 'id'],
},
});
if(!domain) {
return res.status(401).json({
code: 401, // HTTP 상태 코드를 사용해도 되고, 임의로 숫자를 부여해도 됨
message: '등록되지 않은 도메인입니다. 먼저 도메인을 등록하세요.', // 사용자가 어떤 문제인지 알 수 있게 message도 보냄
});
}
const token = jwt.sign({ // jwt.sign의 첫 번째 인수: 토큰의 내용
id: domain.User.id, // 사용자의 id
nick: domain.User.nick, // 사용자의 nickname
}, process.env.JWT_SECRET, { // jwt.sign의 두 번째 인수: 토큰의 비밀 키, 세 번째 인수: 토큰의 설정
expiresIn: '1m', // 토큰의 유효 기간을 1분으로 설정, 60*1000처럼 밀리초 단위로 적어도 됨
issuer: 'sns', // 발급자
});
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) => { // verifyToken: routes/middlewares.js 에서 토큰 검증
res.json(req.decoded); // 검증 성공 시 토큰의 내용물을 응답으로 보냄, json 형태: code, message 속성 존재(+token이 있으면 token 속성도 존재)
});
// 내가 올린 포스트를 가져오는 라우터
router.get('/posts/my', verifyToken, (req, res) => {
Post.findAll({where: { userId: req.decoded.id }})
.then((posts) => {
console.log(posts);
res.json({
code: 200,
message: 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;
3. API 서버 사용하기(snsplus 앱 생성)
위에서 만든 API를 사용하는(테스트 용) snsplus 앱을 간단하게 만들어보자!
- http://localhost:4000/search/해시태그 ("#해시태그"가 들어있는 게시글 존재 시 )
- http://localhost:4000/search/ㅁ ("#ㅁ"이 들어있는 게시글 미 존재 시)
1. npm 패키지 설치하기(package.json)
Git [snsplus/package.json
]
{
"name": "snsplus",
"version": "1.0.0",
"description": "",
"main": "app.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "nodemon app"
},
"author": "delay100",
"license": "ISC",
"dependencies": {
"axios": "^0.26.0",
"cookie-parser": "^1.4.6",
"dotenv": "^16.0.0",
"express": "^4.17.2",
"express-session": "^1.17.2",
"morgan": "^1.10.0",
"nunjucks": "^3.2.3"
},
"devDependencies": {
"nodemon": "^2.0.15"
}
}
2. 기본 설정(snsplus/app.js, snsplus/views/error.html)
Git [snsplus/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'), '번 포트에서 대기 중');
});
Git [snsplus/views/error.html
]
<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{error.stack}}</pre>
Git [snsplus/.env
] - sns-api 실행화면에서 발급 받았던 클라이언트 비밀키를 적음
CLIENT_SECRET=c817989f-6d93-49ff-bff9-6948ad9d452b
Git [snsplus/routes/index.js
]
const express = require('express');
const axios = require('axios');
const router = express.Router();
const URL = 'http://localhost:8002/v1';
// const URL = 'http://localhost:8002/v2';
axios.defaults.headers.origin = 'http://localhost:4000'; // 요청의 헤더 origin값을 localhost:4000으로 설정 - 어디서 요청하는지 파악하기 위해 사용, 주소가 바뀌면 이 값도 바꾸면 됨
// request 함수: sns API에 요청을 보내는 함수
const request = async (req, api) => {
try {
if(!req.session.jwt) { // 세션에 토큰이 없으면
const tokenResult = await axios.post(`${URL}/token`, { // sns-api/routes/v1.js의 post /token 실행
clientSecret: process.env.CLIENT_SECRET, // clientSecret을 사용해 토큰을 발급받는 요청을 보냄
});
req.session.jwt = tokenResult.data.token; // 토큰을 재사용하기 위해 세션에 토큰 저장
}
return await axios.get(`${URL}${api}`, {
headers: { authorization: req.session.jwt},
}); // 토큰을 이용해 API 요청 - sns-api/routes/v1.js의 get /api값 실행
} catch (error) {
if (error.response.status === 419) { // 토큰 만료 시 419 에러가 발생하는데, 419: sns-api/routes/middlewares.js의 verifyToken내에 정의됨
delete req.session.jwt; // 토큰을 지우고
return request(req, api); // 토큰 재발급 받기(만료 시 계속 재귀적으로 실행)
} // 419 외의 다른 에러면
return error.response;
}
};
// API를 사용해 자신이 작성한 포스트를 JSON 형식으로 가져오는 라우터 - 현재는 JSON으로만 응답하지만 템플릿 엔진으로 화면을 렌더링 가능
router.get('/mypost', async (req, res, next) => {
try {
const result = await request(req, '/posts/my'); // 위에 선언한 reqquest 함수 실행
res.json(result.data);
} catch (error) {
console.error(error);
next(error);
}
});
// API를 사용해 해시태그가 달린 게시글들을 검색를 검색하는 라우터
router.get('/search/:hashtag', async (req, res, next) => {
try {
const result = await request(
req, `/posts/hashtag/${encodeURIComponent(req.params.hashtag)}`, // 위에 선언한 reqquest 함수 실행
);
res.json(result.data);
} catch (error){
if (error.code) {
console.error(error);
next(error);
}
}
});
// nodeplus 서비스가 토큰 인증 과정을 테스트해보는 라우터
router.get('/test', async (req, res, next) => {
try {
if (!req.session.jwt) { // 세션에 토큰이 없으면 토큰 발급, jwt는 sns-api/routes/v1.js에서 const token = jwt.sign({ 로 만들어줌
const tokenResult = await axios.post('http://127.0.0.1:8002/v1/token', {
clientSecret: process.env.CLIENT_SECRET, // HTTP 요청 본문에 클라이언트 비밀 키를 실어 보냄, const { clientSecret } = req.body;
});
if (tokenResult.data && tokenResult.data.code === 200) { // 토큰 발급 성공, sns-api/routes/v1.js의 200 코드가 return 된 경우
req.session.jwt = tokenResult.data.token; // 세션에 토큰 저장
} else { // 토큰 발급 실패
return res.json(tokenResult.data); // 발급 실패 사유 응답
}
}
// 발급 받은 토큰이 유효한지 테스트
const result = await axios.get('http://127.0.0.1:8002/v1/test', {
headers: { authorization: req.session.jwt }, // 인증용 토큰 헤더는 보통 요청 본문 대신에, authorization에 넣어 전송함
// 본문에 객체를 붙인 포스트 요청을 보내고
// 추가적으로 헤더 이름 'Authorization'을 추가하고 로그인 후 sessionStorage에 저장된 jwt 토큰 값을 제공하여 헤더 정보를 전달
// 출처 : https://medium.com/geekculture/how-to-implement-user-authentication-using-jwt-json-web-token-in-nodejs-and-maintain-user-c5850aed8839
});
return res.json(result.data);
}
catch (error){
console.error(error);
if(error.response.status === 419) {// 토큰 만료 시, sns-api/routes/middlewares.js의 419 코드
return res.json(error.response.data);
}
return next(error);
}
});
module.exports = router;
4. 버전 바꾸기 (v1 -> v2)
v1 -> v2로 바꾸기
사용량 제한을 추가한 v2를 만들어보자!
- http://localhost:4000/search/ㅇㅅㅇ (버전 v1-> v2인데 v1을 쓴 경우, "#ㅁ"이 들어있는 게시글 미 존재 시)
- http://localhost:4000/search/ㅇㅅㅇ (v2쓴 경우, "#ㅁ"이 들어있는 게시글 미 존재 시)
express-rate-limit 설치
npm i express-rate-limit
Git [sns-api/routes/middlewares.js
]
const RateLimit = require('express-rate-limit'); // api 사용량 제한, sns-api 서버가 재시작되면 사용량이 초기화되므로 실제 서비스에서 사용량을 저장할 데이터베이스를 따로 마련하는 것이 좋음
...
// console.log(RateLimit);
// apiLimiter 미들웨어 - 라우터에 넣으면 라우터에 사용량 제한이 걸림
exports.apiLimiter = new RateLimit({
windowMs: 60 * 1000, // 기준 시간 - 1분 (1분에 1번 호출 가능)
max: 10, // 허용 횟수 - 10번
handler(req, res) { // 제한 초과 시 상태 코드(code)와 함께 허용량을 초과했다는 응답(message)을 전송하는 콜백 함수
res.status(this.statusCode).json({
code: this.statusCode, // 기본값 429
message: '1분에 한 번만 요청할 수 있습니다.',
});
},
});
// deprecated 미들웨어 - 사용하면 안 되는 라우터에 붙혀줌
exports.deprecated = ( req, res ) => {
res.status(410).json({ // 410 코드와 함께 새로운 버전을 사용하라는 메세지를 응답함
code: 410,
message: '새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.',
});
};
Git [sns-api/routes/v2.js
]
// api 사용량 제한을 추가한 v2 라우터
const express = require('express');
const jwt = require('jsonwebtoken');
const cors = require('cors');
...
const { verifyToken, apiLimiter } = require('./middlewares');
const { Domain, User, Post, Hashtag } = require('../models');
const router = express.Router();
...
// // 응답에 Access-Control-Allow-Origin 헤더가 추가됨
// // v2의 모든 라우터에 적용
// router.use(cors({
// credentials: true, // true: 다른 도메인과 쿠키가 공유됨, 서버 간의 도메인이 다른 경우에는 이 옵션을 활성화하지 않으면 로그인이 되지 않을 수 있음
// // axios에서도 도메인이 다른데, 쿠키를 공유해야 하는 경우 withCredentials: true 옵션으로 요청을 보내야 함
// }));
router.post('/token', apiLimiter, async (req, res) => { // apiLimiter: routes/middlewares.js의 사용량 제한 미들웨어 추가
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: 'sns',
});
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.decode.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;
v2로 바꿨으므로, 기존의 Git [sns-api/routes/v1.js
]는 사용을 하지 말라고 에러를 보내줘야 한다.
Git [sns-api/routes/v1.js
]에 추가
// deprecated를 추가한다
const { verifyToken, deprecated } = require('./middlewares');
...
router.use(deprecated); // 라우터 앞에 deprecated 미들웨어를 추가하여 v1으로 접근한 모든 요청에 deprecated 응답을 보냄
...
Git [sns-api/routes/index.js
]에서 기존의 v1을 지우고 v2를 작성
// const URL = 'http://localhost:8002/v1';
const URL = 'http://localhost:8002/v2';
5. CORS 문제
CORS 문제
- Cross-Origin Resource Sharing
- 요청을 보내는 클라이언트와 요청을 받는 서버의 도메인이 다른 경우 발생
- 브라우저에서 서버로 요청을 보낼 때만 발생(서버->서버 요청은 발생X)
- ex) 클라이언트(localhost:4000 -요청> localhost:8002)
1. cors 문제 발생 상황
Git [snsplus/routes/index.js
] - 프런트엔드 화면(Get /
을 렌더링하는 부분 추가
router.get('/', (req, res) => {
res.render('main', { key: process.env.CLIENT_SECRET});
});
module.exports = router;
Git [snsplus/views/main.html
] - 프런트엔드 화면(Get /
을 렌더링하는 부분 추가
<!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>
- http://localhost:4000/ - CORS 문제(
Access-Control-Allow-Origin
)가 발생!
2. cors 해결 방법
해결 방법
- 응답 헤더에 Access-Control-Allow-Origin 헤더를 넣어야 함
Access-Control-Allow-Origin 헤더
: 클라이언트 도메인의 요청을 허락 - npm의 cors 패키지로 해결
Git [sns-api/routes/v2.js
]
...
const url = require('url');
...
router.use(async (req, res, next) => {
// console.log(req.get('origin'));
// console.log('==============origin==========')
const domain = await Domain.findOne({ // 도메인 모델로 클라이언트의 도메인(req.get('origin'))과 호스트가 일치하는 것이 있는지 검사
where: { host: url.parse(req.get('origin')).host}, // http나 https 같은 프로토콜을 떼어낼 때는 url.parse 메서드를 사용
});
if (domain) {
cors({ // 일치하는 것이 있다면 CORS를 허용해서 다음 미들웨어로 보냄
origin: req.get('origin'), // origin속성: 허용할 도메인을 적음, *처럼 모든 도메인을 허용하지 않고 기입한 도메인만 허용, 여러 개의 도메인은 배열을 사용
// 특정한 도메인만 허용하므로 허용되지 않은 도메인에서 요청을 보내는 것을 차단
credentials: true,
}) (req, res, next);
// 다음의 두 코드는 같은 역할을 함
// 1. router.user(cors());
// 2. router.use((req, res, next) => {
// cors()(req, res, next);
// });
} else { // 일치하는 것이 없으면 CORS 없이 next 호출
next();
}
});
...
실행화면 - 매우 긴 토큰이 발급된다
{"code":200,"message":"토큰이 발급되었습니다","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MSwibmljayI6IuqwkOyekOqwnOuwnOyekCIsImlhdCI6MTY0NDg0OTc3NiwiZXhwIjoxNjQ0ODUxNTc2LCJpc3MiOiJub2RlYmlyZCJ9.AE9QKTccfl7ZvVqFHKWvPJ_TxZzIpPhKYU_BcAXiih0"}
- OPTIONS 메서드: 실제 요청을 보내기 전 서버가 이 도메인을 허용하는지 체크
- 응답 헤더에서
Access-Control-Allow-Origin: *
로 되어있음*
: 모든 클라이언트의 요청을 허용한다는 뜻
cors 설치
npm i cors
Git [sns-api/routes/v2.js
] - cors 관련 내용 추가
const url = require('url');
...
router.use(async (req, res, next) => {
const domain = await Domain.findOne({ // 도메인 모델로 클라이언트의 도메인(req.get('origin'))과 호스트가 일치하는 것이 있는지 검사
where: { host: url.parse(req.get('origin')).host}, // http나 https 같은 프로토콜을 떼어낼 때는 url.parse 메서드를 사용
});
if (domain) {
cors({ // 일치하는 것이 있다면 CORS를 허용해서 다음 미들웨어로 보냄
origin: req.get('origin'), // origin속성: 허용할 도메인을 적음, *처럼 모든 도메인을 허용하지 않고 기입한 도메인만 허용, 여러 개의 도메인은 배열을 사용
credentials: true,
}) (req, res, next);
// 다음의 두 코드는 같은 역할을 함
// 1. router.user(cors());
// 2. router.use((req, res, next) => {
// cors()(req, res, next);
// });
} else { // 일치하는 것이 없으면 CORS 없이 next 호출
next();
}
});
...
- 응답 헤더가
Access-Control-Allow-Origin: http://localhost:4000
으로 변경됨
현재는 클라이언트와 서버에서 같은 비밀 키를 써서 문제가 될 수 있다. 카카오 처럼 키를 여러개 두어서 각각 용도에 맞게 사용할 수 있게 해주면 된다.
+카카오의 키
- REST API: 서버용 비밀 키
- JavaScript 키: 클라이언트용 비밀 키
잘못된 정보 수정 및 피드백 환영합니다!!
'Study > Node.js' 카테고리의 다른 글
23 - 웹 소켓으로 익명 채팅 만들기(GIF파일 전송 가능) (0) | 2022.06.13 |
---|---|
22 - 웹 소켓(Web Socket) 이용하기(with. ws 모듈, Socket.IO) (0) | 2022.06.13 |
20 - SNS 만들기 동작별 흐름 이해하기 -3(with Node, MySQL, Nunjucks) ★ (0) | 2022.06.13 |
19 - SNS 만들기 동작별 흐름 이해하기 -2(with Node, MySQL, Nunjucks) ★ (0) | 2022.06.13 |
18 - SNS 만들기 동작별 흐름 이해하기 -1(with Node, MySQL, Nunjucks) ★ (0) | 2022.06.13 |