본문 바로가기

Node.js

Node.js - API 서버를 위한 도메인 등록 (Domain for API Server)

도메인과 CORS

API 서버 쪽에선 원하지 않는 사용자 또는 서비스가 자신의 API 서비스에 접속하는 것을 막기 위하여 도메인을 등록할 수 있다. 웹 브라우저에서 요청을 보낼 때, 응답을 하는 곳과 도메인이 다르면 CORS (Cross-Origin Resource Sharing) 에러가 발생할 수 있다. CORS 문제를 해결하려면 API 서버 쪽에서 미리 허용할 도메인을 등록해야 한다. 서버에서 서버로 요청을 보내는 경우엔 CORS 문제가 발생하지 않는다. CORS는 브라우저에서 발생하는 에러이기 때문이다.

 

NodeBird의 경우를 예로 들어 사용해보자. 먼저 models/domain.js를 만들어준다.

 

const Sequelize = require('sequelize');

class Domain extends Sequelize.Model {
    static init(sequelize) {
        return super.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;

 

도메인은 유저와 유저:도메인 => 1:N 관계의 데이터가 될 것이다. 한 사용자가 여러 도메인을 소유할 수도 있기 때문에 일대다 관계로 설정해두었다. host는 호스트 이름 (인터넷 주소), type은 도메인 종류 (free와 premium), 그리고 clientSecret은 클라이언트 비밀 키이다. type에 free와 domain 두 가지 종류를 넣은 이유는 이후에 타입에 따라 사용량을 제한하기 위함이다.

 

clientSecret (비밀 키) 은 직접 만들어 줄 수도 있지만, uuid 패키지를 통해서 생성하면 충돌 가능성이 지극히 적은 UUID 데이터 타입 (문자열) 을 넣어줄 수 있다. 예를 들어 uuid 패키지의 v4 함수를 사용하면 bfae4486-5656-483d-93e2-bef61fc970ee 와 같은 36자리 문자열이 생긴다. 참고로 위는 16진법을 사용하는 문자열이다. 36자리 문자열이므로 같은 문자열이 생성되어 충돌할 확률은 매우 매우 적다.

 

도메인 등록을 한 후 (내 경우 localhost:4000) 비밀 키를 발급 받으면 이제 localhost:4000 서비스에서 API를 호출할 때 인증 용도로 사용할 수 있다. 비밀 키가 유출되면 다른 사람이 마치 내가 호출한 것처럼 API를 사용할 수 있으므로 조심해야한다.

 

JWT 토큰을 사용한 인증

JWT 토큰이란 JSON Web Token의 약어로, JSON 형식의 데이터를 저장하는 토큰이다. 토큰 방식의 인증과 세션 방식의 인증은 다음 포스트를 참고하도록 한다.

https://lgphone.tistory.com/94

 

Web - 세션 기반 인증과 토큰 기반 인증 (Session and Token Authentication)

https://lgphone.tistory.com/65 Web - 쿠키와 세션 (Cookie and Session) 클라이언트에서 보내는 요청에는 큰 단점이 존재한다. 바로 누가 요청을 보내는지 모른다는 것이다. 물론 요청을 보내는 IP 주소나 브라.

lgphone.tistory.com

 

JWT는 다음과 같이 세 부분으로 구성되어 있다.

  • 헤더 (HEADER): 토큰 종류와 해시 알고리즘 정보가 들어있다.
  • 페이로드 (PAYLOAD): 토큰의 내용물이 인코딩되어 있다.
  • 시그니쳐 (SIGNATURE): 일련의 문자열로, 시그니처를 통해 토큰이 변조되었는지 여부를 확인할 수 있다.

시그니처는 JWT 비밀 키로 만들어진다. 이 비밀 키가 노출되면 JWT 토큰을 위조할 수 있으므로 비밀 키는 철저히 숨겨져야 한다. 시그니처 자체는 숨길 필요가 없다.

 

JWT 토큰을 사용하는 이유는 JWT 비밀 키를 알지 않는 이상 변조가 불가능하기 때문이다. 변조한 토큰은 시그니처를 비밀 키를 통해 검사할 때 들통이 나기 때문이다. 변조할 수 없으므로 내용물이 바뀌지 않았는지 걱정할 필요가 없다. 즉, 내용물을 믿고 사용할 수 있다는 뜻이다. 가령 비밀번호 같은 민감한 개인 정보들을 제외하고 사용자의 이메일, 권한 등을 넣어두면 데이터베이스 조회 없이 그 사용자를 믿고 권한을 줄 수 있다.

 

그러나 JWT의 단점은 용량이 크다는 것이다. 내용물이 들어 있으므로 랜덤한 토큰을 사용할 때와 비교하여 용량이 더 클 수밖에 없다. 매 요청 시 토큰이 오고 가기 때문에 데이터양이 증가한다. 따라서 장단점과 비용을 따져 비교하여 JWT 인증 방식을 구현할지 결정하면 된다. 랜덤 스트링을 사용해서 매번 사용자 정보를 조회하는 작업의 비용이 더 큰지, 아니면 내용물이 들어 있는 JWT 토큰을 사용해서 발생하는 데이터 비용이 더 큰지 비교해보고 사용하면 된다.

 

이제 웹 서버에 JWT 토큰 인증 과정을 구현해보자. 먼저 JWT 모듈을 설치한다.

 

PS C:\Users\bumsu\nodejs-projects\노드js교과서\nodebird-api> npm i jsonwebtoken

 

다른 사용자가 API를 사용하기 위해선 JWT 토큰을 발급받고 인증받아야 한다. 이는 대부분의 라우터에 공통적으로 해당하는 부분이기 때문에 미들웨어로 만들어두는 것이 좋다.

 

.env 파일에 JWT_SECRET이라는 속성을 추가해주자.

 

COOKIE_SECRET=cookiesecret
KAKAO_ID=..........................
JWT_SECRET=jwtSecret

 

미들웨어도 다음과 같이 추가해준다.

 

const jwt = require('jsonwebtoken');

...

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

 

위 미들웨어에선 먼저 jwt.verify 메서드를 사용하여 req.decoded에 데이터를 넣는다. 만약 인증 과정에서 에러가 생길 경우, catch문으로 이동하는데, 만약 에러가 'TokenExpiredError'일 경우, 즉 토큰의 유효기간이 만료되었을 경우, 419 상태 코드를 응답하는데, 419는 임의의 숫자이며, 코드는 400번 대 숫자 중에서 아무 숫자나 골라도 된다.

 

req.decoded를 사용하여 이제 토큰의 내용물을 사용할 수 있다. routes/v1.js 파일을 만들어 다음과 같이 사용해보자.

 

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

const { verifyToken } = require('./middlewares');
const { Domain, User } = require('../models');

const router = express.Router();

router.post('/token', async (req, res, next) => {
    const { clientSecret } = req.body;
    try {
        const domain = await Domain.findOne({
            where: { clientSecret },
            include: {
                model: User,
                attributes: ['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',
            issuer: 'nodebird'
        });
        return res.json({
            code: 200,
            message: '토큰이 발급되었습니다.',
            token,
        });
    } catch (err) {
        console.error(err);
        return res.status(500).json({
            code: 500,
            message: '서버 에러'
        });
    }
});

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

module.exports = router;

 

POST /v1/token 은 토큰을 발급하며, GET /v1/token 라우터는 토큰을 테스트해볼 수 있다.

 

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

 

POST /v1/token 라우터는 전달받은 클라이언트 비밀 키로 도메인이 등록된 것인지를 먼저 확인한다. 등록되지 않은 도메인이라면 에러 메세지로 응답하고, 등록된 도메인이라면 토큰을 발급하여 응답한다. 토큰은 jwt.sign 메서드로 발급받는다.

 

sign 메서드의 첫 번째 인수는 토큰의 내용이다. 사용자의 아이디와 닉네임을 넣어두었다. 두 번째 인수는 토큰의 비밀 키이다. 비밀키는 유출되어선 안되므로 .env 파일에 넣어두었다. 세 번째 인수는 토큰의 설정이다. 유효기간을 1분으로, 발급자를 nodebird로 적어두었다. 유효기간은 60 * 1000 과 같이 밀리초 단위로 적어도 된다. 발급되고 나서 1분이 지나면 토큰이 만료되므로 만료되는 경우 토큰을 재발급받아야 한다.

 

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

 

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

 

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

 

방금 만든 라우터를 서버에 연결하고 사용해주자.

 

 

출처

Node.js 교과서 개정 2판 - 길벗, 조현영