본문 바로가기

BackEnd/Node

섹션 9. 10장 API 서버 만들기(JWT, CORS)

10.1 API서버 이해하기

 ⇒ API : Application Programing Interface

  - 다른 애플리케잇현에서 현재 프로그램의 기능을 사용할 수 있게 허용하는 접점을 의미한다.

  - 웹 API는 다른 웹 서비스의 기능을 사용하거나 자원을 가져올 수 있는 창구이다.

  - 다른 사람에게 제공하고 싶은 부분만 API로 허용하고, 제공하고 싶지 않은 부분은 제외한다.

  - API에 제한을 걸어 일정 횟수, 시간 내에서만 권한허용이 가능하다.

 

10.2 API 서버 프로젝트 구성하기

 1. lecture-api 프로젝트

// middlewares/index.js

// 토큰 유효성 검증 미들웨어
exports.verifyToken = (req, res, next) => {
    try {
        // api 사용자가 토큰을 넣는 위치 : req.headers.authorization
        res.locals.decoded = jwt.verify(req.headers.authorization, process.env.JWT_SECRET);
        return next();
    } catch (error) {
        if(error.name === 'TokenExpiredError') {
            res.status(419).json({
                code : 419,
                message : '토큰이 만료 되었습니다.',
            });
        } else {
            return res.status(401).json({
                code : 401,
                message : '유효하지 않은 토큰입니다.',
            });
        }
    }
};

// api의 버전이 변경 되었을 경우 유효성 체크
exports.deprecated = (req, res) => {
    res.status(410).json({
        code : 410,
        message : '새로운 버전이 나왔습니다.  새로운 버전을 사용하세요.'
    });
};

 

// routers/v1.js

const express = require('express');
const {verifyToken, deprecated} = require("../middlewares");
const {createToken, getMyPosts, getPostsByHashtag } = require('../controllers/v1');
const router = express.Router();

// v1 router 모든 호출에 현재 버전을 사용하지 않음을 공통적으로 처리한다.
router.use(deprecated);   

router.post('/token', createToken);   // req.body.clientSecret이 필요하다
router.get('/posts/my', verifyToken, getMyPosts);
router.get('/posts/hashtag/:title', verifyToken, getPostsByHashtag);

module.exports = router;

 

// middlewares/index.js

// api 호출 전에 파라미터 설정을 위한 미들웨어 확장패턴이다.
// 해당 패턴은 선처리가 필요할 경우 활용할 수 있다.
exports.apiLimiter = async (req, res, next) => {
    // let user;
    // if(res.locals.decoded) {
    //     user = User.findOne({where : {id : res.locals.decode.id}});
    // }
    rateLimit({
        windowMs : 60 * 1000,   // 1분
        // max : user?.type === 'premium' ? 1000 : 10,    // 최대건수
        max : 10,                // 최대건수
        handler(req, res) {     // 지정요건 초과 시 핸들러 수행
            res.status(this.statusCode).json({
                code : this.statusCode,
                message : '1분에 한 번만 요청할 수 있습니다.',
            });
        }
    })(req, res, next);
};

// 브라우저 요청에 의한 cors 패키지를 통한 header설정
exports.corsWhenDomainMatches = async (req,res,next) => {
    const domain = await Domain.findOne({
        where : {
            host : new URL(req.get('Origin')).host
        },
    });

    if(domain) {
        console.log(req.get('Origin'));

        cors({
            // origin : true,   // 모든 요청을 받는다
            origin : req.get('Origin'),
            // credentials이 true일 경우 origin : '*'속성은 사용할 수 없다.
            // origin에 직접 url을 넣거나 true로 설정을 해야한다.
            credentials : true,  // 쿠키요청 허용여부
        })(req,res,next);
    }else {
        next();
    }
}

 

// routes/v2.js

const express = require('express');
const {verifyToken, apiLimiter, corsWhenDomainMatches} = require("../middlewares");
const {createToken, getMyPosts, getPostsByHashtag } = require('../controllers/v2');
const router = express.Router();
const cors = require('cors');

// cors패키지를 사용하지 않으면 해더정보를 추가해야 한다.
// 그리고 쿠키, https 등 다양한 케이스의 변수가 존재하므로 cors를 사용한다.
// cors 문제를 해결하는 다른방법은 프록시(대리인) 서버를 사용하는 것이다.
// 서버에서 서버로 요청을 보낼 때는 CORS 문제가 발생하지 않기 때문이다.

// cors를 사용하지 않고 브라우져에서 CORS오류를 해소하는 예시
// router.use((req,res, next) => {
    // res.setHeader('Access-Control-Allow-Origin', 'http://localhost:4000');
    // res.setHeader('Access-Control-Allow-Headers', 'content-type');
    // next();
// });

// 브라우저에서 서버요청을 위한 CORS 미들웨어 설정
router.use(corsWhenDomainMatches);



// v2/token
// apiLimiter 미들웨어는 api의 요청제한설정을 위해 추가한다.
router.post('/token', apiLimiter, createToken);   // req.body.clientSecret이 필요하다

// GET v2/posts/my : 나의 작성 게시글 불러오기
router.get('/posts/my', verifyToken, apiLimiter, getMyPosts);

// GET v2/posts/hashtag/:title : 해시태그 기준 게시글 불러오기
router.get('/posts/hashtag/:title', verifyToken, apiLimiter, getPostsByHashtag);

module.exports = router;

 

// controller/v2.js

const { Domain, User, Post, Hashtag } = require('../models');
const jwt = require('jsonwebtoken');

// 토큰발급 시 일반적으로 브라우저키와 서버키를 별도로 발행해야 한다.
// 브라우저 토큰은 화면에 들어나므로 보안에 취약하다
// 클라이언트 시크릿과 프론트 시크릿을 별도로 구분해서 구성하는 것이 이번 챕터의 과제이다.
exports.createToken = async (req, res) => {
    const { clientSecret } = req.body;
    try {
        const domain = await Domain.findOne({
           where : {clientSecret},
           include : {
               model : User,
               attributes : ['id', 'nick'],
           }
        });

        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: '5m',     // 유효기간
            issuer : 'nodebird', // 발급자
        });

        return res.json({
            code : 200,
            message : '토큰 발급되었습니다.',
            token,
        });
    } catch (error) {
        console.error(error);
        return res.status(500).json({
            code : 500,
            message : '서버 에러',
        });
    }
}

// 내가 작성한 게시물 조회
exports.getMyPosts = (req, res) => {
    Post.findAll({where : { userId : res.locals.decoded.id}})
        .then((posts) => {
            res.json({
                code : 200,
                payload : posts,
            });

        })
        .catch((error) => {
            return res.status(500).json({
                code : 500,
                message : '서버 에러',
            });
        });
};

// 해시태그 검색을 통한 게시물 불러오기
exports.getPostsByHashtag = async (req, res) => {
    try {
        const hashtag = await Hashtag.findAll({where : {title : req.params.title}});
        if(!hashtag) {
            return res.status(404).json({
                code : 404,
                message : '검색결과가 없습니다.',
            })
        }
        const posts = await hashtag.getPosts();
        if(posts.length === 0) {
            return res.status(404).json({
                code : 404,
                message : '검색결과가 없습니다.',
            });
        }
        res.json({
            code : 200,
            payload : posts,
        })
    } catch(err) {
        return res.status(500).json({
            code : 500,
            message : '서버 에러',
        });
    }
};

 2. lecture-call 프로젝트

// .env
COOKIE_SECRET=nodecat
CLIENT_SECRET=691e54df-d209-47c9-901d-d860176caa5f
ORIGIN=http://localhost:4000
API_URL=http://localhost:8002/v2

 

// routes/index.js

const express = require('express');
const router = express.Router();
const { getMyPosts, searchByHashtag, renderMain} = require('../controllers/index');

// GET /myposts : 나의 게시물 불러오기
router.get('/myposts', getMyPosts);

// GET /search/:hashtag 해시태그 검색에 따른 게시물 불러오기
router.get('/search/:hashtag', searchByHashtag);

// GET / : CORS테스트를 위한 브라우저에서 api서버 호출
router.get('/', renderMain);

module.exports = router;

 

// controllers/index.js

const axios = require('axios');
const URL = process.env.API_URL;
axios.defaults.headers.common.origin = process.env.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;
        }
        // 토큰을 통해 api 호출처리
        return await axios.get(`${URL}${api}`, {
            headers: {authorization : req.session.jwt},
        });
    } catch (error) {
        if(error.response?.status == 419) {  // 세션이 만료 시 재귀함수로 토큰을 재발급 처리
            delete req.session.jwt;
            return request(req, api);
        } else {
            throw error.response;   // 에러가 발생시 throw를 통해 에러처리 미들웨어로 전달
        }
    }
};

// 나의 게시글보기 api 호출
exports.getMyPosts = async (req, res, next) => {
    try {
        const result = await request(req, '/posts/my');
        res.json(result.data);
    } catch (error) {
        next(error);
    }
};

// 해시태그를 통한 게시물 검색 api 호출
exports.searchByHashtag = async (req, res, next) => {
    try {
        const result = await request(req, `/posts/hashtag/${req.params.hashtag}`);
        res.json(result.data);
    } catch (error) {
        next(error);
    }
};

// 브라우저 axios 호출을 위한 main화면 랜더링(CORS)
exports.renderMain = (req, res) => {
    res.render('main', {key : process.env.CLIENT_SECRET});
}

 

// views/main.html

<!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호출(CORS이슈가 발생하므로 백엔드에서 CORS 패키지를 활용해라)
    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>

10.3 JWT 토큰으로 인증하기

 1. 인증을 위한 JWT

  ⇒ NodeBird가 아닌 다른 클라이언트가 데이터를 가져가게 하려면 인증 과정이 필요하다.

  ⇒ JWT(JSON Web Token)을 사용한다.

   - 헤더, 페이로드, 시그니처로 구성되어 있다.

   - 헤더 : 토큰 종류와 해시 알고리즘 정보가 들어있다.

   - 페이로드 : 토큰의 내용물이 인코딩된 부분이다.

   - 시그니처 : 일련의 문자열로, 시그니처를 통해 토큰이 변조 되었는지 여부를 확인한다.

   - 시그니처는 JWT 비밀키로 만들어지고, 비밀키가 노출되면 토큰의 위조가 가능하다.

 2. JWT 사용 시 주의점

  ⇒ JWT에 민감한 내용을 넣으면 안된다.

   - 페이로드 내용을 볼 수 있지만 토큰 변조가 불가능하고, 내용물을 담을 수 있기 때문에 사용한다.

    그렇기 때문에 내용물은 노출되어도 좋은 정보만 넣어야 한다.

   - 용량이 커서 요청 시 데이터 양이 증가하는 단점이 있다.

노드교과서 교육자료

10.6 사용량 제한 구현하기

 1. 사용량 제한 구현하기

  ⇒ DOS 공격 등을 대비해야 한다.

   - 일정 시간동안 횟수 제한을 두어 무차별적인 요청을 막을 필요가 있다.

   - 미들웨어 설치 : npm i express-rate-limit 

   - apiLimiter : windowMS(기준 시간), max(허용 횟수), handler(제한 초과 시 콜백 함수)

10.7 CORS 이해하기

 1. 프런트에서 서버로의 요청

  ⇒ 요청을 보내는 프런트(localhost:4000), 요청을 받는 서버(localhost:8002)가 다르면 오류가 발생한다.

   - CORS : Cross-Origin Resource Sharing 문제

   - POST 대신 OPTIONS 요청을 먼저 보내서 서버가 도메인을 허용하는지 미리 체크할 수 있다.

 2. CORS 문제 해결 방법

  ⇒ Access-Control-Allow-Origin 응답 헤더를 넣어주어야 CORS 문제를 해결할 수 있다.

   - res.set 메서드에 직접 선언해도 되지만 패키지를 사용하는게 편리하다.

   - npm i cors

   - v2 라우터에 적용, credentials : true를 해야 프런트와 백엔드 간 쿠키가 공유된다.

 9. 프록시 서버

  ⇒ CORS 문제에 대한 또다른 해결책

   - 서버-서버 간의 요청/응답에는 CORS 문제가 발생하지 않는것을 활용해 프록시서버를 구성한다.

   - 직접 구현해도 좋지만 http-proxy-middleware 같은 패키지로 손쉽게 연동이 가능하다.

 

노드교과서 교육자료

10.8.1 스스로해보기

 - 팔로워나 팔로잉 목록을 가져오는 API 만들기(nodebird-api에 새로운 라우터 추가)
 - 무료인 도메인과 프리미엄 도메인 간의 사용량 제한을 다르게 적용하기
  (apiLimiter를 두 개 만들어서 도메인 별로 다르게 적용.  9.3.1절의 POST /auth/login 라우터 참조)
 - 클라이언트용 비밀 키와 서버용 비밀 키를 구분해서 발급하기(Domain 모델 수정)
 - 클라이언트를 위해 API 문서 작성하기(swagger나 apidoc 사용)

10.8.2 핵심정리

  - API는 다른 어플리케이션의 기능을 사용할 수 있게 해주는 창구이다.  

    현재 lecture-call이 lecture-api의 api를 사용하고 있다.
  - 모바일 서버를 구성할 때 서버를 REST API 방식으로 구현하면 된다.
  - API 사용자가 API를 쉽게 사용할 수 있도록 사용 방법, 요청 형식, 응답 내용에 관한 문서를 준비해야 한다.
  - JWT 토근의 내용은 공개되며 변조될 수 있다.  단, 시그니처를 확인하면 변조유무를 확인할 수 있다.
  - 토큰을 사용해 API의 오남용을 막는다.  요청 헤더에 토큰이 있는지를 항상 확인해야 한다.
  - app.use 외에도 router.use를 활용해 라우터 간에 공통되는 로직을 처리할 수 있다.
  - cors나 passport.authenticate처럼 미들웨어 내에서 미들웨어를 실행할 수 있다.  미들웨어를 선택적으로 적용하거나
   커스터마이징할 때 이 기법을 사용한다.(미들웨어확장기능)
  - 브라우저와 서버의 도메인이 다르면 요청이 거절된다는 특성(CORS)를 이해해야 한다.
   서버와 서버간의 요청에서는 CORS 요청이 발생하지 않는다.

10.8.3 함께보면 좋은 자료

- API 설명 : https://ko.wikipedia.org/wiki/API
- JWT 토큰 설명 : https://jwt.io
- JSONWebToken 공식 문서 : https://www.npmjs.com/package/jsonwebtoken
- axios 공식 문서 : https://github.com/axios/axios
- CORS 공식 문서 : https://www.npmjs.com/package/cors
- express-rate-limit 공식 문서 : https://www.npmjs.com/package/express-rate-limit
- UUID 공식 문서 : https://www.npmjs.com/package/uuid
- ms 공식 문서 : https://github.com/vercel/ms