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

My_Code·2024년 2월 27일

Node.js 교과서

목록 보기
11/11
post-thumbnail

다음 내용은 인프런에서 공부한 내용을 복습하는 차원에서 기록한 것입니다.
출처 : https://www.inflearn.com/course/%EB%85%B8%EB%93%9C-js-%EA%B5%90%EA%B3%BC%EC%84%9C


💻 10.1 API 서버 이해하기

📌 NodeBird SNS 서비스

✏️ API: Application Programming Interface

  • 다른 애플리케이션에서 현재 프로그램의 기능을 사용할 수 있게 함
  • 웹 API: 다른 웹 서비스의 기능을 사용하거나 자원을 가져올 수 있게 함 (일종의 창구)
  • 다른 사람에게 정보를 제공하고 싶은 부분만 API를 열고, 제공하고 싶지 않은 부분은 API를 만들지 않으면 됨
  • API에 제한을 걸어 일정 횟수 내에서만 가져가게 할 수도 있음
  • NodeBird에서는 인증된 사용자에게만 정보 제공


💻 10.2 프로젝트 구조 갖추기

📌 도메인 모델 생성하기

✏️ nodebird-api/models/domain.js 작성

  • API를 사용할 도메인(또는 호스트)을 저장하는 모델
  • ENUM type으로 free나 premium만 쓸 수 있게 제한

✏️ nodebird-api/models/domain.js

const Sequelize = require('sequelize');

class Domain extends Sequelize.Model {
    static initiate(sequelize) {
        Domain.init({
            host: {
                type: Sequelize.STRING(80),
                allowNull: false,
            },
            type: {
                type: Sequelize.ENUM('free', 'premium'),
                allowNull: false,
            },
            clientSecret: {
                type: Sequelize.UUID,
                allowNull: false,
            }
        }, {
            sequelize,
            timestamps: true,
            paranoid: true,
            modelName: "Domain",
            tableName: "domains",
        })
    }

    static associate(db) {
        db.Domain.belongsTo(db.User);
    }
}

module.exports = Domain;

📌 도메인 등록 라우터

✏️ nodebird-api/routes/index.js에서 도메인 등록 라우터 생성

  • uuid 패키지로 사용자가 등록한 도메인에 고유한 비밀번호(비밀키) 부여
  • uuid는 충돌(고유하지 않은 상황) 위험이 있지만 매우 희박
  • 비밀번호가 일치하는 요청만 API 응답

✏️ nodebird-api/routes/index.js

const express = require('express');
const { isLoggedIn } = require('../middlewares');
const { renderLogin, createDomain } = require('../controllers');
const router = express.Router();

router.get('/', renderLogin);
router.post('/domain', isLoggedIn, createDomain);


module.exports = router;

✏️ nodebird-api/controllers/index.js

const { User, Domain } = require('../models');
const { v4: uuidv4 } = require('uuid');

exports.renderLogin = async (req, res, next) => {
    try {
        const user = await User.findOne({ where: { id: req.user?.id || null }, include: { model: Domain } });
        res.render('login', {
            user,
            domains: user?.Domains,
        })
    } catch (error) {
        console.error(error);
        next(error);
    }
};

exports.createDomain = async (req, res, next) => {
    try {
        await Domain.create({
            UserId: req.user.id,
            host: req.body.host,
            type: req.body.type,
            clientSecret: uuidv4()
        })
        res.redirect('/');
    } catch (error) {
        console.error(error);
        next(error);
    }
};

📌 클라이언트 도메인 등록

✏️ 등록한 도메인만 API 사용 가능



💻 10.3 JWT 토큰으로 인증하기

📌 인증을 위한 JWT

✏️ JWT (Json Web Token)

  • 헤더, 페이로드, 시그니처로 구성됨
  • 헤더: 토큰 종류와 해시 알고리즘 정보가 들어있음
  • 페이로드: 토큰의 내용물이 인코딩된 부분
  • 시그니처: 일련의 문자열로, 시그니처를 통해 토큰이 변조되었는지 여부 확인
  • 시그니처는 JWT 비밀키로 만들어지고, 비밀키가 노출되면 토큰 위조 가능

✏️ JWT를 사용하는 이유

  • HTTP는 기본적으로 stateless를 지향함
  • stateless는 서버가 클라이언트의 상태를 기억하지 않는 것을 의미함
  • 하지만 필요한 모든 정보를 한 객체에 담아서 전달하기 때문에 JWT 한 가지로 인증을 마칠 수 있음
  • 웹 표준을 따르기 때문에 대부분의 언어가 이를 지원함

📌 JWT 사용 시 주의점

✏️ JWT에 민감한 내용을 넣으면 안됨

  • 페이로드 내용을 볼 수 있음
  • https://jwt.io/ 사이트를 통해서 인코딩 가능
  • 내용물이 들어있으므로 데이터베이스 조회를 하지 않을 수 있음(데이터베이스 조회는 비용이 큰 작업)
  • 노출되어도 좋은 정보만 넣어야 함
  • 용량이 커서 요청 시 데이터 양이 증가한다는 단점이 있음

📌 노드에서 JWT 사용하기

✏️ JWT 모듈 설치

  • npm i jsonwebtoken
  • JWT 비밀키(변조 확인을 위한 키) .env에 저장
  • JWT 토큰을 검사하는 verifyToken 미들웨어 작성
  • jwt.verify 메서드로 검사 가능(두 번째 인수가 JWT 비밀키)
  • JWT 토큰은 req.headers.authorization에 들어 있음
  • 만료된 JWT 토큰인 경우 419 에러 발생
  • 유효하지 않은 토큰인 경우 401에러 발생
  • res.locals.decoded에 페이로드를 넣어 다음 미들웨어에서 쓸 수 있게 함

✏️ nodebird-api/middlewares/index.js

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

...

exports.verfiyToken = (req, res, next) => {
    try {
        res.locals.decoded = jwt.verify(req.headers.authorization, process.env.JWT_SECRET);
        return next();
    } catch (error) {
        if (error.name === 'TokenExpireError') {
            return res.status(419).json({
                code: 419,
                message: '토큰이 만료되었습니다.',
            })
        }
        return res.status(401).json({
            code: 401,
            message: '유효하지 않은 토큰입니다.',
        })
    }
}

📌 JWT 토큰 발급 라우터 만들기

✏️ 라우터와 컨트롤러에 v1.js 작성

  • 버전 1이라는 뜻의 v1.js
  • 한 번 버전이 정해진 후에는 라우터를 함부로 수정하면 안 됨
  • 다른 사람이 기존 API를 쓰고 있기 때문(그 사람에게 영향이 감)
  • 수정 사항이 생기면 버전을 올려야 함

✏️ POST /token에서 JWT 토큰 발급

  • 먼저 도메인 검사 후 등록된 도메인이면 jwt.sign 메서드로 JWT 토큰 발급
  • 첫 번째 인수로 페이로드를 넣고, 두 번째 인수는 JWT 비밀키, 세 번째 인수로 토큰 옵션(expiresIn은 만료 시간, issuer은 발급자)
  • expiresIn은 1m(1분), 60 * 1000같은 밀리초 단위도 가능

✏️ nodebird-api/controllers/v1.js

const { User, Post, Domain } = 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: '1m',
            issuer: 'nodebird',
        })
        return res.json({
            code: 200,
            message: '토큰 발급되었습니다.',
            token,
        })
    } catch (error) {
        console.error(error);
        return res.status(500).json({
            code: 500,
            message: '서버 에러',
        })
    }
};


💻 10.4 호출 서버 만들기

📌 토큰 테스트용 라우터 만들기

✏️ 라우터와 컨트롤러에 index.js 작성

  • GET /test에 접근 시 세션 검사
  • 세션에 토큰이 저장되어 있지 않으면 POST http://localhost:8002/v1/token 라우터로부터 토큰 발급
  • 이 때 HTTP 요청 본문에 클라이언트 비밀키 동봉
  • 발급에 성공했다면 발급받은 토큰으로 다시 GET https://localhost:8002/v1/test 라우터 접근해서 토큰 테스트

✏️ nodebird-call/controllers/index.js

const axios = require('axios');

exports.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?.code === 200) {
                req.session.jwt = tokenResult.data.token;
            } else {
                return res.status(tokenResult.data?.code).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);
    }
};


💻 10.5 사용량 제한 구현하기

📌 사용량 제한 구현하기

✏️ DOS 공격 등을 대비해야 함

  • 일정 시간동안 횟수 제한을 두어 무차별적인 요청을 막을 필요가 있음
  • npm i express-rate-limit
  • apiLimiter 미들웨어 추가
    • windowMS(기준 시간), max(허용 횟수), handler(제한 초과 시 콜백 함수)
  • deprecated 미들웨어는 사용하면 안 되는 라우터에 붙여서 사용 시 경고
  • 하지만 rateLimit로 DOS 공격을 완벽히 막을 순 없음
  • 어쨋든 많은 요청 자체는 보낼 수 있기 때문에 과부화가 생길 수 있음
  • 그래서 보통 방패막이용 서버를 앞에 두고 사용함 (ex. AWS)

✏️ nodebird-api/middlewares/index.js

const { User, Post, Domain } = require('../models');
const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');

...

// 요청에 회수제한을 둠
// 무료랑 프리미엄을 나누기 위해 미들웨어 확장패턴 사용
exports.apiLimiter = async (req, res, next) => {
    let user;
    if (res.locals.decoded) {
        user = await User.findOne({ where: { id: res.locals.decoded.id } });
    }
    rateLimit({
        windowMs: 60 * 1000,
        max: user?.type === 'premium' ? 1000 : 10,
        handler(req, res) {
            res.status(this.statusCode).json({
                code: this.statusCode,
                message: '1분에 한 번만 요청할 수 있습니다.',
            });
        }
    }) (req, res, next);
} 

// 옛 버전 사용하지 말라고 에러 안내
exports.deprecated = (req, res) => {
    res.status(410).json({
        code: 410,
        message: '새로운 버전이 나왔습니다. 새로운 버전을 사용하세요.',
    });
}

📌 응답 코드 정리

✏️ 응답 코드를 정리해서 어떤 에러가 발생했는지 알려주기

  • 일관성이 중요함
  • 클라이언트가 API 요청 시 미리 알려주는 것이 좋음

📌 새 라우터 버전 내놓기

✏️ 사용량 제한 기능이 추가되어 기존 API와 호환되지 않음

  • 이런 경우 새로운 버전의 라우터를 내놓으면 됨
  • v2 라우터, 컨트롤러 작성(apiLimiter 추가됨)
  • v1 라우터는 deprecated 처리(router.use로 한 번에 모든 라우터에 적용)
  • 라우터 별로 버전을 관리할 수 있음
  • 개발자는 그냥 간단하게 버전을 확인할 수 있으나 클라이언트에게 불편함


💻 10.6 CORS 이해하기

📌 프런트에서 요청 보내기

✏️ localhost:4000에 접속하면 에러 발생

  • 프런트에서 직접 localhost:8002로 axios 요청
<!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/v3/token', {
      clientSecret: '{{key}}',
    })
      .then((res) => {
        document.querySelector('#result').textContent = JSON.stringify(res.data);
      })
      .catch((err) => {
        console.error(err);
      });
  </script>
  </body>
</html>

✏️ 요청을 보내는 프런트(localhost:4000), 요청을 받는 서버(localhost:8002)가 다르면 에러 발생(서버에서 서버로 요청을 보낼때는 발생하지 않음)

  • CORS: Cross-Origin Resource Sharing 문제
  • POST 대신 OPTIONS 요청을 먼저 보내 서버가 도메인을 허용하는지 미리 체크

📌 CORS 문제 해결 방법

✏️ Access-Control-Allow-Origin 응답 헤더를 넣어주어야 CORS 문제 해결 가능

  • res.set 메서드로 직접 넣어주어도 되지만 패키지를 사용하는게 편리
  • npm i cors
  • v2 라우터에 적용
  • credentials: true를 해야 프런트와 백엔드 간에 쿠키가 공유됨

✏️ 클라이언트 환경에서는 비밀키가 노출됨

  • 도메인까지 같이 검사해야 요청 인증 가능
  • 호스트와 비밀키가 모두 일치할 때만 CORS를 허용
  • 클라이언트의 도메인(req.get(‘origin’))과 등록된 호스트가 일치하는 지 찾음
  • url.parse().host는 http같은 프로토콜을 떼어내기 위함
  • cors의 인자로 origin을 주면 * 대신 주어진 도메인만 허용할 수 있음
  • cors의 origin부분에는 'http://localhost:4000'도 사용 가능
  • 하지만 사람마다 다르기 때문에 클라이언트에서 요청할 때 보내는 req의 origin을 사용

✏️ nodebird-api/routes/v2.js

const express = require('express');
const { verfiyToken, apiLimiter, corsWhenDomainMatches } = require('../middlewares');
const { createToken, tokenTest, getMyPosts, getPostsByHashtag } = require('../controllers/v2');
const cors = require('cors');

const router = express.Router();

// 모든 라우터에 적용
router.use(corsWhenDomainMatches);

// /v2/token
router.post('/token', apiLimiter, createToken);  //req.body.clientSecret 을 이용해서 토큰 생성
router.get('/test', verfiyToken, apiLimiter, tokenTest);

router.get('/posts/my', verfiyToken, apiLimiter, getMyPosts);
router.get('/posts/hashtag/:title', verfiyToken, apiLimiter, getPostsByHashtag);

module.exports = router;

✏️ nodebird-api/middlewares/index.js

const { User, Post, Domain } = require('../models');
const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
const cors = require('cors');

...

// 미들웨어 확장 패턴 사용
// cors의 origin부분에는 'http://localhost:4000'도 사용 가능 (사람마다 다름)
// 하지만 사람마다 다르기 때문에 클라이언트에서 요청할 때 보내는 req의 origin을 사용
exports.corsWhenDomainMatches = async (req, res, next) => {
    const domain = await Domain.findOne({
        where: { host: new URL(req.get('origin'))?.host }  // localhost:4000
    });
    if (domain) {
        cors({
            origin: req.get('origin'),  // http://localhost:4000
            credentials: true,  // 쿠키요청 허용 (origin에 * 사용 불가)
        }) (req, res, next);
    } else {
        next();
    }
    
}

📌 유용한 미들웨어 패턴

✏️ 미들웨어 확장 패턴

  • 아래처럼 쓰면 미들웨어 위 아래로 임의의 코드를 추가할 수 있음
router.use(cors());

router.use((req, res, next) => {
  cors()(req, res, next);
});

📌 프록시 서버

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

  • 서버-서버 간의 요청/응답에는 CORS 문제가 발생하지 않는 것을 활용
  • 직접 구현해도 되지만 http-proxy-middleware같은 패키지로 손쉽게 연동 가능
profile
조금씩 정리하자!!!

0개의 댓글