프로그래밍 언어/NODE JS

JWT 토큰으로 인증하기

· 코딩마이데이

웹 서버에 JWT 토큰 인증 과정을 구현해 보겠습니다. 먼저 JWT 모듈을 설치합니다.

$ npm i jsonwebtoken

 

이제 JWT를 사용해서 API를 만들어보겠습니다. 다른 사용자가 API를 쓰려면 JWT 토큰을 발급받고 인증받아야 합니다. 이는 대부분의 라우터에 공통적으로 해당하는 부분이므로 미들웨어로 만들어두는 게 좋습니다.

 

nodebird-api/.env

COOKIE_SECRET=nodebirdsecret
KAKAO_ID=03216323ce22d651877427baebd91267
JWT_SECRET=jwtSecret

 

nodebird-api/routes/middlewares.js

const jwt = require("jsonwebtoken");

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: "유효하지 않은 토큰입니다",
    });
  }
};

 

요청 헤더에 저장된 토큰(req.headers.authorization)을 사용합니다. 사용자가 쿠키처럼 헤더에 토큰을 넣어 보낼 것입니다. jwt.verify 메서드로 토큰을 검증할 수 있습니다. 메서드의 첫 번째 인수로는 토큰을, 두 번째 인수로는 토큰의 비밀 키를 넣습니다.

토큰의 비밀 키가 일치하지 않는다면 인증을 받을 수 없습니다. 그런 경우에는 에러가 발생하여 catch문으로 이동하게 됩니다. 또한, 올바른 토큰이더라도 유효 기간이 지난 경우라면 역시 catch문으로 이동합니다. 유효 기간 만료 시 419 상태 코드를 응답하는데, 코드는 400번 대 숫자 중에서 마음대로 정해도 됩니다.

인증에 성공한 경우에는 토큰의 내용이 반환되어 req.decoded에 저장합니다. 토큰의 내용은 조금 전에 넣은 사용자 아이디와 닉네임, 발급자, 유효 기간 등입니다. req.decoded를 통해 다음 미들웨어에서 토큰의 내용물을 사용할 수 있습니다.

 

nodebird-api/routes/v1.js

const express = require("express");
const jwt = require("jsonwebtoken");

const { veifyToken } = require("./middlewares");
const { Domain, User } = 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: "토큰이 발급되었습니다",
    });
  } catch (error) {
    console.error(error);
    return res.status(500).json({
      code: 500,
      message: "서버 에러",
    });
  }
});

router.get("/test", veifyToken, (req, res) => {
  res.json(req.decoded);
});

module.exports = router;

 

 

토큰을 발급하는 라우터(POST /v1/token)와 사용자가 토큰을 테스트해볼 수 있는 라우터(GET /v1/test)를 만들었습니다.

라우터의 이름은 v1으로, 버전 1이라는 뜻입니다. 버전은 1.0.0처럼 SemVer식으로 정해도 됩니다. 라우터에 버전을 붙인 이유는, 한 번 버전이 정해진 후에는 라우터를 함부로 수정하면 안 되기 때문입니다. 다른 사람이 나 서비스가 기존 API를 쓰고 있음을 항상 염두에 두어야 합니다. API 서버의 코드를 바꾸면 API를 사용 중인 다른 사람에게 영향을 미칩니다. 특히 기존에 있던 라우터가 수정되는 순간 API를 사용하는 프로그램들이 오작동할 수 있습니다. 따라서 기존 사용자에게 영향을 미칠 정도로 수정해야 한다면, 버전을 올린 라우터를 새로 추가하고 이전 API를 쓰는 사람들에게는 새로운 API가 나왔음을 알리는 것이 좋습니다. 이전 API를 없앨 때도 어느 정도 기간을 두고 미리 공지하여 사람들이 다음 API로 충분히 넘어갔을 때 없애는 것이 좋습니다.

 

POST /v1/token 라우터에서는 전달받은 클라이언트 비밀 키로 도메인이 등록된 것인지를 먼저 확인합니다. 등록되지 않은 도메인이라면 에러 메시지로 응답하고, 등록된 도메인이라면 토큰을 발급해 응답합니다. 토큰은 jwt.sign 메서드로 발급받을 수 있습니다. 다음 코드를 살펴봅시다.

 

const token = jwt.sign({
  id: domain.user_id,
  nick: domain.user_nick,
}, process.env.JWT_SECRET, {
  expiresIn: '1m', // 유효 기간
  issuer: 'nodebird', // 발급자
});

 

sign 메서드의 첫 번째 인수는 토큰의 내용입니다. 사용자의 아이디와 닉네임을 넣었습니다. 두 번째 인수는 토큰의 비밀 키입니다. 이 비밀 키가 유출되면 다른 사람이 NodeBird 서비스의 토큰을 임의로 만들여낼 수 있으므로 조심해야 합니다. 세 번째 인수는 토큰의 설정입니다. 유효 기간을 1분으로, 발급자는 nodebird로 적었습니다. 1m으로 표기된 부분은 zeit/ms(https://github.com/zeit/ms)의 형식을 사용한 것인데, 그냥 60 * 1000처럼 밀리초 단위로 적어도 됩니다. 발급되고 나서 1분이 지나면 토큰이 만료되므로, 만료되었다면 토큰은 재발급받아야 합니다. 유효 기간은 서비스 정책에 따라 얼마인지 정하면 됩니다.

GET /v1/test 라우터에서는 사용자가 발급받은 토큰을 테스트해볼 수 있는 라우터입니다. 토큰을 검증하는 미들웨어를 거친 후, 검증이 성공했다면 토큰의 내용물을 응답으로 보냅니다.

라우터의 응답을 살펴보면 모두 일관된 형식을 갖추고 있습니다. JSON 형태에 code, message 속성이 존재하고, 토큰이 있는 경우에는 token 속성도 존재합니다. 이렇게 일관된 형식을 갖춰야 응답받는 쪽에서 처리하기가 좋습니다. code는 HTTP 상태 코드를 사용해도 되고, 임의로 숫자를 부여해도 됩니다. 일관성만 있다면 문제없습니다. 사용자가 code만 봐도 어떤 문제인지 알 수 있게 하면 됩니다. code를 이해하지 못할 경우를 대비하여 message도 같이 보냅니다.

code가 200번대 숫자가 아니면 에러이고, 에러의 내용은 message에 담아 내보내는 것으로 현재 API 서버의 규칙을 정했습니다.

방금 만든 라우터를 서버에 연결합니다.

 

nodebird-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");

dotenv.config();
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("/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"), "번 포트에서 대기중");
});