프로그래밍 언어/NODE JS

CORS 이해하기

· 코딩마이데이

NodeCat이 nodebird-api를 호출하는 것은 서버에서 서버로 API를 호출하는 것입니다. 만약 Nodecat의 프런트에서 nodebird-api의 서버 API를 호출하면 어떻게 될까요?

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);
    }
  }
});

router.get("/", (req, res) => {
  res.router("main", { key: process.env.CLIENT_SECRET });
});

module.exports = router;

 

프런트 화면도 추가합니다.

<!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>

 

 

clientSecret의 {{key}} 부분이 넌적스에 의해 실제 키로 치환돼서 렌더링됩니다. 단, 실제 서비스에서는 서버에서 사용하는 비밀 키와 프런트에서 사용하는 비밀 키를 따로 두는 게 좋습니다. 보통 서버에서 사용하는 비밀 키가 더 강력하기 때문입니다. 프런트에서 사용하는 비밀 키는 모든 사람에게 노출되는 단점도 따릅니다. 데이터베이스에서 clientSecret 외에 frontSecret 같은 컬럼을 추가해서 따로 관리하는 것을 권장합니다.

http://localhost:4000에 접속하면 에러가 발생하며 제대로 동작하지 않습니다. 브라우저 콘솔 창을 보면 에러를 확인할 수 있습니다.

Access-Control-Allow-Origin이라는 헤더가 없다는 내용의 에러입니다. 이처럼 브라우저와 서버의 도메인이 일치하지 않으면, 기본적으로 요청이 차단됩니다. 이 현상은 브라우저에서 서버로 요청을 보낼 때만 발생하고, 서버에서 서버로 요청을 보낼 때는 발생하지 않습니다. 현재 요청을 보내는 클라이언트(localhost:4000)와 요청을 받는 서버(localhost:8002)의 도메인이 다릅니다. 이 문제를 CORS(Cross-Origin Resource Sharing) 문제라고 부릅니다.

📌 CORS 동작 흐름

브라우저는 POST 요청을 보내기 전에 OPTIONS(preflight) 요청을 먼저 보냄.

  • OPTIONS 요청:
    서버가 이 도메인의 요청을 허용하는지 사전 확인하는 역할.
  • 서버가 적절한 CORS 헤더를 보내지 않으면 브라우저가 본 요청을 차단.

Network 탭을 보면 Method가 POST 대신 OPTIONS로 표시됩니다. OPTIONS 메서드는 실제 요청을 보내기 전에 서버가 이 도메인을 허용하는지 체크하는 역할을 합니다.

📌 Network 탭에서 확인되는 내용

  • POST 요청을 보내도 실제로는 먼저 OPTIONS 요청이 표시됨.
  • 요청 메서드: OPTIONS
  • 경로: /v2/token

NodeBird API 서버 콘솔에도 OPTIONS 요청이 기록됩니다.

 

NodeBird API 콘솔

OPTIONS /v2/token 200 0.302 ms – 4

 

CORS 문제를 해결하기 위해서는 응답 헤더에 Access-Control-Allow-Origin 헤더를 넣어야 합니다. 이 헤더는 클라이언트 도메인의 요청을 허락하겠다는 뜻을 가지고 있습니다. res.set 헤더로도 직접 넣어도 되지만, npm에는 편하게 설치할 수 있는 패키지가 있습니다. 바로 cors입니다.

 

응답 헤더를 조작하려면 NodeCat이 아니라 Nodebird API 서버에서 바꿔야 합니다. 응답은 API 서버가 보내는 것이 때문입니다. NodeBird API에 cors 모듈을 설치하면 됩니다.

 

NodeBird API 콘솔

$ npm i cors

 

cors 패키지를 설치한 후 v2.js에 적용합니다.

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();

router.use(
  cors({
    credentials: true,
  })
);

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;

 

router.use로 v2의 모든 라우터에 적용했습니다. 이제 응답에 Access-Control-Allow-Origin 헤더가 추가되어 나갑니다. credentials: true라는 옵션도 주었는데, 이 옵션을 활성화해야 다른 도메인 간에 쿠키가 공유됩니다. 서버 간의 도메인이 다른 경우에는 이 옵션을 활성화하지 않으면 로그인되지 않을 수 있습니다. 참고로 axios에서도 도메인이 다르면, 쿠키를 공유해야 하는 경우
withCredentials: true 옵션을 줘서 요청을 보내야 합니다.

다시 http://localhost:4000에 접속해보면 토큰이 발급된 것을 볼 수 있습니다. 이 토큰을 사용해서 다른 API 요청을 보내면 됩니다. 토큰이 발급되지 않고 429 에러가 발생한다면, 이전 절에서 적용한 사용량 제한 때문에 그런 것이므로 제한이 풀릴 때 다시 시도하면 됩니다.

 

응답 헤더를 보면 Access-Control-Allow-Origin이 *로 되어 있습니다. *는 모든 클라이언트의요청을 허용한다는 뜻입니다. credentials: true 옵션은 Access-Control-Allow-Credentials 헤더를 true로 만듭니다.

하지만 이것 때문에 새로운 문제가 생겼습니다. 요청을 보내는 주체가 클라이언트라서 비밀 키(process.env.CLIENT_SECRET)가 모두에게 노출됩니다. 방금 CORS 요청도 허용했으므로 이 비밀 키를 가지고 다른 도메인들이 API 서버에 요청을 보낼 수 있습니다.
이 문제를 막기 위해 처음에 비밀 키 발급 시 허용한 도메인을 적게 했습니다. 호스트와 비밀 키가 모두 일치할 때만 CORS를 허용하게 수정하면 됩니다.

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(
  cors({
    credentials: true,
  })
);

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();
  }
});

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;

 

먼저 도메인 모델로 클라이언트의 도메인(req,get('origin'))과 호스트가 일치하는 것이 있는지 검사합니다. http나 https 같은 프로토콜을 떼어낼 때는 url.parse 메서드를 사용합니다. 일치하 는 것이 있다면 CORS를 허용해서 다음 미들웨어로 보내고, 일치하는 것이 없다면 CORS 없이 next를 호출합니다.
cors 미들웨어에 옵션 인수를 주었는데요. origin 속성에 허용할 도메인만 따로 적으면 됩니다. * 처럼 모든 도메인을 허용하는 대신 기입한 도메인만 허용합니다. 여러 개의 도메인을 허용하고 싶 다면 배열을 사용하면 됩니다.
또 하나 특이한 점이 있습니다. passport.authenticate 미들웨어처럼 cors 미들웨어에도 (req, res, next) 인수를 직접 줘서 호출했습니다. 이는 미들웨어의 작동 방식을 커스터마이징하고 싶을 때 사용하는 방법이라고 설명했습니다. 다음 두 코드가 같은 역할을 한다는 것을 기억해두면 다양하게 활용할 수 있습니다.

 

router.use(cors());

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

 

다시 http://localhost:4000에 접속하면 성공적으로 토큰을 가져옵니다. 응답의 헤더를 확인해 보면 Access-Control-Allow-Origin이 * 대신 http://localhost:4000으로 적용되어 있습니다.
이렇게 특정한 도메인만 허용하므로 허용되지 않은 다른 도메인에서 요청을 보내는 것을 차단할 수 있습니다.

현재 클라이언트와 서버에서 같은 비밀 키를 써서 문제가 될 수 있습니다. 따라서 그림 10-19와 같이 다양한 환경의 비밀 키를 발급하는 카가오처럼 환경별로 키를 구분해서 발급하는 것이 바람 직합니다. 카카오의 경우 REST API 키가 서버용 비밀 키고, 자바스크립트 키가 클라이언트용 비 밀 키입니다. 이렇게 여러 키를 발급하는 것을 직접 구현해보길 바랍니다.