[프로그래머스] Node기반 REST API 구현(11)

Lina Hongbi Ko·2024년 10월 16일
0

Programmers_BootCamp

목록 보기
36/76
post-thumbnail

2024년 10월 16일

✏️ JWT API

: jwt 토큰을 발행하는 것을 테스트해보자.

// authorization-demo.js

const express = require('express');
const app = express();
const dotenv = require('dotenv');
dotenv.config();
const jwt = require('jsonwebtoken');

// 서버셋팅 : 포트넘버 1234로 셋팅
app.listen(process.env.PORT);

// 토큰 발행
app.get('/', function (req, res) {
	const token = jwt.sign({foo: 'bar'}, process.env.PRIVATE_KEY);
	
	res.cookie("jwt", token, {httpOnly: true});
	res.send('토큰 발행 완료!');
});

// 토큰 검증
app.get('/', function (req, res) {
	res.send('Hello World');
});

POSTMAN) GET + localhost:1234

  • 결과를 보았을때, response header의 set-cookies에 jwt가 잘 들어와있는 것을 볼 수 있다.

  • 그런데 request header의 cookie를 보면 jwt와 token을 볼 수 있다.

  • 이것은 무엇일까?
    - jwt를 자세히 보면, 이전에 response로 받은 쿠키값과 같은 것을 볼 수 있다. → 이전의 response 쿠키값을 그대로 받아아서 request에 던지고 있음
    - 그리고 token 또한 이전에 token 이라는 쿠키를 발행한 적이 있는 것과 같은 값이라는 것을 볼 수 있다.
    • 쿠키가 핑퐁쳐지는 것
    • 우리가 인터넷을 사용하다가 쿠키를 삭제하는 것을 종종 볼 수 있는데, 이것은 서버와 클라이언트가 기존에 가지고 있던 쿠키를 핑퐁치다가 똑같은 값이면 덮어썼고, 다른 값이 날라오면 추가를 했음
    • 그래서 쿠키에 토큰들을 계속 담아주면 쿠키는 길어지게 된다. 쿠키는 토큰의 이전 값을 계속 기억하고 있고, 새로운 값이 들어오면 덮어 쓴다. 그런데 덮어 쓰기가 많아져 제대로 값이 들어가지 않게 되면 쿠키에 값(ex_토큰)을 제대로 받아오기 위해 삭제를 해주었던 것이다.

✏️ authorization

  • 토큰을 검증해보자
    • 발행한 토큰을 header에 그냥 받아오면 보안상 위험하므로 포스트맨을 이용해서 authorization에 넣어서 받아오자.
// authorization-demo.js

const express = require("express");
const app = express();
const dotenv = require("dotenv");
dotenv.config();
const jwt = require("jsonwebtoken");

// 서버셋팅 : 포트넘버 1234로 셋팅
app.listen(process.env.PORT);

// GET + "/jwt" : 토큰 발행
app.get("/jwt", function (req, res) {
  const token = jwt.sign({ foo: "bar" }, process.env.PRIVATE_KEY);

  res.cookie("jwt", token, { httpOnly: true });
  res.send("토큰 발행 완료!");
});

// GET + "/jwt/decoded" : 토큰 검증
app.get("/jwt/decoded", function (req, res) {
  let receivedJwt = req.headers["authorization"];
  console.log("우리가 req로 전달받은 jwt : ", jwt);
  const decoded = jwt.verify(receivedJwt, process.env.PRIVATE_KEY);

  res.send(decoded);
});

jwt 토큰을 발행하고

그리고 보안으로 위해 Authorization에 jwt를 복사해서 붙여 넣어준다

payload와 발행기간이 나온것을 확인할 수 있다.

✏️ 좋아요 API에 JWT 구현

💡 좋아요 추가

  • API에 맞게 JWT를 구현해보자
// LikeController.js

const conn = require("../mariadb"); // db 모듈
const { StatusCodes } = require("http-status-codes"); // status code 모듈
const jwt = require("jsonwebtoken");
const dotenv = require("dotenv");
dotenv.config();

const addLike = (req, res) => {
  const { id } = req.params; // book_id
  // const { user_id } = req.body;

  let receivedJwt = req.headers["authorization"];
  console.log("received jwt : ", receivedJwt);

  let decodedJwt = jwt.verify(receivedJwt, process.env.PRIVATE_KEY);
  console.log(decodedJwt);
}

login을 먼저하고, 토큰을 복사해서 authorization에 넣어준다.

그리고 localhost:9999/likes/3 + authorizaiton : 복사한 토큰 → response로 준게 없기 때문에 계속 로딩중이지만, 콘솔창을 보면 받은 토큰(recieved jwt)를 확인할 수 있고, 복호화한 payload 정보들을 얻을 수 있다. (이메일, 발행시점, 유효시점, 발행자)

그런데 우리는 likes 테이블에 user_id도 넣어줘야하므로 토큰의 payload값에 id도 넣어주자.

// UserController.js
 
 ... 생략 ...
 if (loginUser && loginUser.password == hashPassword) {
      // 토큰 발행
      const token = jwt.sign(
        {
          id: loginUser.id,
          email: loginUser.email,
        },
... 생략 ...

다시 로그인하고 좋아요 POSTMAN으로 보내면

id값이 잘 들어간 것을 볼 수 있다.

이제 likes 테이블에 토큰을 통해 id를 insert해보자.

// LikeController.js

const conn = require("../mariadb"); // db 모듈
const { StatusCodes } = require("http-status-codes"); // status code 모듈
const jwt = require("jsonwebtoken");
const dotenv = require("dotenv");
dotenv.config();

const addLike = (req, res) => {
  const { id } = req.params; // book_id
  // const { user_id } = req.body;

  let receivedJwt = req.headers["authorization"];
  console.log("received jwt : ", receivedJwt);

  let decodedJwt = jwt.verify(receivedJwt, process.env.PRIVATE_KEY);
  console.log(decodedJwt);

  let sql = "INSERT INTO likes (user_id, liked_book_id) VALUES (?, ?)";
  let values = [decodedJwt.id, id];
  conn.query(sql, values, (err, results) => {
    if (err) {
      console.log(err);
      return res.status(StatusCodes.BAD_REQUEST).end();
    }

    return res.status(StatusCodes.OK).json(results);
  });
};

POSTMAN) POST + localhost:9999/likes/3 + authorization with token

좋아요 추가 전 데이터베이스 likes 테이블

좋아요 추가 후 데이터베이스 likes 테이블

💡 좋아요 취소(삭제)

  • 토큰을 가지고 좋아요 취소 API를 구현해보자
// LikeController.js

const conn = require("../mariadb"); // db 모듈
const { StatusCodes } = require("http-status-codes"); // status code 모듈
const jwt = require("jsonwebtoken");
const dotenv = require("dotenv");
dotenv.config();

const removeLike = (req, res) => {
  const { id } = req.params;
  
  let receivedJwt = req.headers["authorization"];
  console.log("received jwt : ", receivedJwt);

  let decodedJwt = jwt.verify(receivedJwt, process.env.PRIVATE_KEY);
  console.log(decodedJwt);

  let sql = "DELETE FROM likes WHERE user_id = ? AND liked_book_id = ?";
  let values = [decodedJwt.id, id];
  conn.query(sql, values, (err, results) => {
    if (err) {
      console.log(err);
      return res.status(StatusCodes.BAD_REQUEST).end();
    }

    return res.status(StatusCodes.OK).json(results);
  });
};

POSTMAN) DELETE + localhost:9999/likes/3

  • 좋아요 추가/취소 모두 jwt를 받아서 decode하는 코드가 중복되는 것을 알 수 있다. 함수로 모듈화 해보자.
const conn = require("../mariadb"); // db 모듈
const { StatusCodes } = require("http-status-codes"); // status code 모듈
const jwt = require("jsonwebtoken");
const dotenv = require("dotenv");
dotenv.config();

const addLike = (req, res) => {
  const book_id = req.params.id;

  let authorization = ensureAuthorization(req);

  let sql = "INSERT INTO likes (user_id, liked_book_id) VALUES (?, ?)";
  let values = [authorization.id, book_id];
  conn.query(sql, values, (err, results) => {
    if (err) {
      console.log(err);
      return res.status(StatusCodes.BAD_REQUEST).end();
    }

    return res.status(StatusCodes.OK).json(results);
  });
};

const removeLike = (req, res) => {
  const book_id = req.params.id;

  let authorization = ensureAuthorization(req);

  let sql = "DELETE FROM likes WHERE user_id = ? AND liked_book_id = ?";
  let values = [authorization.id, book_id];
  conn.query(sql, values, (err, results) => {
    if (err) {
      console.log(err);
      return res.status(StatusCodes.BAD_REQUEST).end();
    }

    return res.status(StatusCodes.OK).json(results);
  });
};

function ensureAuthorization(req) {
  let receivedJwt = req.headers["authorization"];
  console.log("received jwt : ", receivedJwt);

  let decodedJwt = jwt.verify(receivedJwt, process.env.PRIVATE_KEY);
  console.log(decodedJwt);

  return decodedJwt;
}

module.exports = { addLike, removeLike };

✏️ 장바구니 API에 JWT 구현

💡 장바구니 담기

// CartController.js

const conn = require("../mariadb"); // db 모듈
const { StatusCodes } = require("http-status-codes"); // status code 모듈
const jwt = require("jsonwebtoken");
const dotenv = require("dotenv");
dotenv.config();

const addToCart = (req, res) => {
  const { book_id, quantity } = req.body;

  let authorization = ensureAuthorization(req);

  let sql =
    "INSERT INTO cartItems (book_id, quantity, user_id) VALUES (?, ?, ?)";
  let values = [book_id, quantity, authorization.id];
  conn.query(sql, values, (err, results) => {
    if (err) {
      console.log(err);
      return res.status(StatusCodes.BAD_REQUEST).end();
    }

    return res.status(StatusCodes.OK).json(results);
  });
};

const getCartItems = (req, res) => {
  let { selected } = req.body; // selected = [1, 3]

  let authorization = ensureAuthorization(req);

  let sql = `SELECT cartItems.id, book_id, title, summary, quantity, price 
    FROM cartItems LEFT JOIN books 
    ON cartItems.book_id = books.id 
    WHERE user_id = ? AND cartItems.id IN (?)`;
  let values = [authorization.id, selected];
  conn.query(sql, values, (err, results) => {
    if (err) {
      console.log(err);
      return res.status(StatusCodes.BAD_REQUEST).end();
    }

    return res.status(StatusCodes.OK).json(results);
  });
};

const removeCartItem = (req, res) => {
  let cartItemId = req.params.id;

  let sql = "DELETE FROM cartItems WHERE id = ?";
  conn.query(sql, cartItemId, (err, results) => {
    if (err) {
      console.log(err);
      return res.status(StatusCodes.BAD_REQUEST).end();
    }

    return res.status(StatusCodes.OK).json(results);
  });
};

POSTMAN) 로그인 + POST + localhost:9999/carts + authorizaiton:복사한 token + { ”book_id” : 7, “quantity”: 1 }

POSTMAN) 로그인 + GET + localhost:9999/carts + authorizaiton:복사한 token + { “selected” : [4, 5] }

POSTMAN) 로그인 + DELETE + localhost:9999/carts /5

❗️ 해결해야할 문제들 발생

  1. jwt expired
  2. ensureAuthorization 파일마다 반복
  3. 장바구니 아이템 조회 = 내 장바구니 보기 → selected 없이 보내면 안됨 → 이 API 분리해줘야함

✏️ JWT expired(문제1 해결)

: 유효기간이 만료되면 에러가 난다. 이것은 사용자에게 좋지 않으므로 예외처리를 해줘야함!

💡 JWT 유효기간 만료 테스트

일단, 유효기간 만료되게 셋팅 먼저.

// authorization-demo.js

const express = require("express");
const app = express();
const dotenv = require("dotenv");
dotenv.config();
const jwt = require("jsonwebtoken");

// 서버셋팅 : 포트넘버 1234로 셋팅
app.listen(process.env.PORT);

// GET + "/jwt" : 토큰 발행
app.get("/jwt", function (req, res) {
  const token = jwt.sign(
    {
      username: "rururu",
    },
    process.env.PRIVATE_KEY,
    {
      expiresIn: "1m",
      issuer: "admin",
    }
  );

  res.cookie("jwt", token, { httpOnly: true });
  res.send("토큰 발행 완료!");
});

jwt 토큰을 발해하고, 검증까지 했음

그리고나서 유효기간이 1분 지난 상태에서 다시 검증 시도를 하면 500 에러가 뜨는 것을 알 수 있다.

콘솔창을 확인하면 jwt가 expired됐다는 것을 알 수 있다.

  • 500 에러(가 난다과 해서 서버가 꺼지진 않았지만)를 낼 게 아니고, 예외(개발자가 생각하지 못한 에러) 처리 해줘야함
  • 유효기간이 지난 토큰 => '로그인(인증) 세션(유지되는 상태)이 만료되었습니다. 다시 로그인 하세요.' 를 알려줘야함
  • 예외 처리 (jwt에서 제공하는 예외 처리 방법이 있음)
    • try / catch 사용 예정 (이전에 우리는 if/else문으로 예외 처리함)
    • JWT에서 발생하는 예외를 처리하는 방법
      • TokenExpiredError : 유효기간이 지난 토큰 = 만료된 토큰
      • JsonWebTokenError : 문제 있는 토큰

💡 try catch 구문

  • try …catch : 수많은 (개발자가 예상하지 못한) 에러(실수, 사용자가 입력을 잘못 한 것, DB가 응답을 잘못 … )를 처리하는 문법
    • if(실수1) {

      } else if(실수2) {

      }

    • 만약 실수 100개라면 100개를 다 써줘야함

    • 그런데 실수1, 실수2 … 비슷한 분류들끼리 묶어서 누군가 따로 관리해주면 좋겠다 → try catch를 이용

    • A 코드 실행;
      if (A에서 발생한 실수1) {

      } else if (A에서 발생한 실수2) {

      } …

    • 이 코드를 try catch문으로 바꾸면

      • try {
        // A 코드 실행
        } catch(err) {
        // 에러 처리
        }
    • try catch문을 직접 작성해서 어떻게 되는지 실행해보자

  • try catch 문을 이용하면 어떤 코드든 에러를 다 잡아내준다
// try-catch-demo.js

try {
  username;
} catch (err) {
  console.log("username이 선언되지 않았습니다.");
  console.log("발생한 에러는 다음과 같습니다.");
  console.log(err);
}

  • 에러를 직접 내보자
// try-catch-demo.js

let string = '{ "num1":1 ';

try {
  let json = JSON.parse(string);
  console.log(json);
} catch (err) {
  console.log(err);
}

  • 에러를 발생시킬 코드가 여러개 있으면 먼저 에러를 발생시킨 코드의 에러가 찍히는 것을 볼 수 있다.
// try-catch-demo.js

let string = '{ "num1":1 ';

try {
  username;
  let json = JSON.parse(string);
  console.log(json);
} catch (err) {
  console.log(err);
}

  • 이를 통해서 실수가 100개 발생해도 if else문처럼 일일이 다 적는게 아니라 catch문에서 에러 처리를 다 적을 수 있다는 것을 알 수 있음
  • 에러가 나지 않으면 catch는 무시되고 try만 하고 빠져나감
  • try 구문의 코드를 실행하다가 에러가 발생하면, try 코드를 멈추고 ⇒ catch로 err와 함께 빠져나감!!
  • try 구문의 어떤 에러가 발생해도, 우리가 다 if문 분기 처리를 해주던 내용들이 ⇒ 알아서 catch에 잡힘!! ex) SyntaxError, TypeError

💡 에러 객체

  • 자바스크립트가 고대 개발자분들의 노고를 보고, “내장” 에러 객체를 만들어둠
  • JWT라는 모듈에서 제공하는 에러 객체도 있음
  • 직접 만들어 쓸 수도 있음
// try-catch-demo.js

let string = '{ "num1":1 ';

try {
  // username;
  let json = JSON.parse(string);
  console.log(json);
} catch (err) {
  console.log(err.name);
  console.log(err.message);
  // 에러 객체의 프로퍼티를 콘솔에 찍어보자.
}

💡 throw 연산자

  • 에러를 발생시키는 연산자
  • 형태 : throw 에러 객체 → 에러객체를 발생시킴
    ex) throw new SyntaxError(메시지);
// throw-demo.js

let error = new Error("대장 에러 객체");
let syntaxError = new SyntaxError("구문 에러 발생");
let referenceError = new ReferenceError("대입 에러 발생");

console.log(error.name);
console.log(error.message);

console.log(syntaxError.name);
console.log(syntaxError.message);

console.log(referenceError.name);
console.log(referenceError.message);

  • 그럼 우리는 언제 에러를 발생시킬 수 있을까?
// try-catch-demo.js

let string = '{ "num1":1 }';

try {
  // username;
  let json = JSON.parse(string);
  console.log(json.name);
} catch (err) {
  console.log(err.name);
  console.log(err.message);
  console.log(err);
}

이름이 없으니 undefined가 나오는 것보단 에러를 던져줘야한다 → js 입장에서는 에러가 아니지만, 우리 입장에서는 에러 ⇒ 입력값이 잘못된 에러

if else문으로 예외 처리 해보자.

// try-catch-demo.js

let string = '{ "num1":1 }';

try {
  // username;
  let json = JSON.parse(string);

  if (!json.name) {
    console.log("입력 값에 이름이 없습니다.");
  } else {
    console.log(json.name); // js 입장에선 에러가 아니지만, 우리 입장에선 에러 = 입력값이 잘못된 에러
  }
  let name = json.name;
  console.log(name);
} catch (err) {
  console.log(err.name);
  console.log(err.message);
  console.log(err);
}

  • 입력값이 없을때 예외처리를 해주었는데도, if else문을 사용하면 try/catch문과 달리 밑의 코드까지 다 읽는다.
  • 에러를 발생시키지 않아서 catch문으로 넘어가지 않았고, 밑에 있는 코드를 다 읽는 것임 ⇒ if else와 try catch의 가장 큰 차이!
  • 따라서, throw를 사용하면 그 뒤의 코드를 다 읽지 않음
// try-catch-demo.js

let string = '{ "num1":1 }';

try {
  // username;
  let json = JSON.parse(string);

  if (!json.name) {
    throw new SyntaxError("입력 값에 이름이 없습니다.");
  } else {
    console.log(json.name); // js 입장에선 에러가 아니지만, 우리 입장에선 에러 = 입력값이 잘못된 에러
  }
  let name = json.name; // 이름이 있으면
  console.log(name);
} catch (err) {
  console.log(err.name);
  console.log(err.message);
  console.log(err);
}

throw를 던져주자마자 밑의 코드를 읽지 않고 바로 catch로 넘어가는 것을 알 수 있음 → 우리가 발생시킨 에러 객체가 들어가는 것 확인할 수 있음

💡 JWT Expried Error (본격적으로 문제 1 해결)

  • 유효기간 만료 에러 뿐만 아니라,
  • 토큰을 받아서 복호화하는 모듈(ensureAuthorization)에서 에러가 발생할 여지 2가지 있음
    1. authorizaition에 아무것도 넣지 않거나 잘못된 토큰을 넣으면
    2. 토큰의 유효기간
  • 따라서, 유효기간 만료 에러 뿐만 아니라 2가지의 예외 처리를 try & catch문으로 처리해보자

📍 JWT Expired Error 잡기

// CartController.js

... 생략 ...

function ensureAuthorization(req, res) {
  try {
    let receivedJwt = req.headers["authorization"];
    console.log("received jwt : ", receivedJwt);

    let decodedJwt = jwt.verify(receivedJwt, process.env.PRIVATE_KEY);
    console.log(decodedJwt);

    return decodedJwt;
  } catch (err) {
    console.log(err.name);
    console.log(err.message);

    return res.status(StatusCodes.UNAUTHORIZED).json({
      message: "로그인 세션이 만료되었습니다. 다시 로그인 하세요.",
    });
  }
}
... 생략 ...

로그인 + jwt token 복사해서 POSTMAN) localhost:9999/carts + {”selected” : [4] } + header에 authorizaiton & 복사한 token 붙여넣어서 확인

그런데, throw 다음 에러 객체가 있는 것을 확인할 수 있다 ⇒ ERR_HTTP_HEADERS_SENT()

📍 res를 두번 보내면 생기는 일 - instanceof로 해결

  • ERR_HTTP_HEADERS_SENT() : response를 한번 보내는데 왜 또 보내냐하는 에러임
    • ensureAuthorization() 모듈에서 try catch문을 통해 response가 리턴되고 ensureAuthorizaiton() 호출을 통해 authorization 객체에 response가 들어가서 values에 쓰임
    • 그리고다시 쿼리문이 실행되어서 response를 또 리턴함
    • 이를 통해서 우리는 response를 두번 보냈다는 것을 알 수 있음(catch에서 1번, err에서 1번
  • 따라서 우리는 에러를 리턴 해주고 에러 객체(TokenExpiredError)가 있으면 예외처리를 해주고, 아니면 쿼리를 발생시킬수 있게 한다.
    • instanceof : 왼쪽이 오른쪽의 것과 같냐(비슷하냐)
      • 객체가 어떤 클래스의 인스턴스인지 알아내기 위해 사용
// CartController.js

... 생략 ...
const getCartItems = (req, res) => {
  let { selected } = req.body; // selected = [1, 3]

  let authorization = ensureAuthorization(req, res);

  if (authorization instanceof jwt.TokenExpiredError) {
    return res.status(StatusCodes.UNAUTHORIZED).json({
      message: "로그인 세션이 만료되었습니다. 다시 로그인 하세요.",
    });
  } else {
    let sql = `SELECT cartItems.id, book_id, title, summary, quantity, price 
    FROM cartItems LEFT JOIN books 
    ON cartItems.book_id = books.id 
    WHERE user_id = ? AND cartItems.id IN (?)`;
    let values = [authorization.id, selected];
    conn.query(sql, values, (err, results) => {
      if (err) {
        console.log(err);
        return res.status(StatusCodes.BAD_REQUEST).end();
      }

      return res.status(StatusCodes.OK).json(results);
    });
  }
};

function ensureAuthorization(req, res) {
  try {
    let receivedJwt = req.headers["authorization"];
    console.log("received jwt : ", receivedJwt);

    let decodedJwt = jwt.verify(receivedJwt, process.env.PRIVATE_KEY);
    console.log(decodedJwt);

    return decodedJwt;
  } catch (err) {
    console.log(err.name);
    console.log(err.message);

    return err;
  }
}
... 생략 ...

📍 잘못된 Authorizaition 에러 잡기

  • 토큰에 임의의 숫자(123)를 넣어 토큰을 올바르게 넣지 않으면 어떻게 될까?

  • JsonWebTokenError가 발생하고, invalid token이라는 메시지를 확인할 수 있다.
  • 이 에러를 처리해보자
// CartController.js

... 생략 ...
const getCartItems = (req, res) => {
  let { selected } = req.body; // selected = [1, 3]

  let authorization = ensureAuthorization(req, res);

  if (authorization instanceof jwt.TokenExpiredError) {
    return res.status(StatusCodes.UNAUTHORIZED).json({
      message: "로그인 세션이 만료되었습니다. 다시 로그인 하세요.",
    });
  } else if (authorization instanceof jwt.JsonWebTokenError) {
    return res.status(StatusCodes.BAD_REQUEST).json({
      message: "잘못된 토큰입니다.",
    });
  } else {
    let sql = `SELECT cartItems.id, book_id, title, summary, quantity, price 
    FROM cartItems LEFT JOIN books 
    ON cartItems.book_id = books.id 
    WHERE user_id = ? AND cartItems.id IN (?)`;
    let values = [authorization.id, selected];
    conn.query(sql, values, (err, results) => {
      if (err) {
        console.log(err);
        return res.status(StatusCodes.BAD_REQUEST).end();
      }

      return res.status(StatusCodes.OK).json(results);
    });
  }
};
... 생략 ...

ensureAuthorizaition()을 쓰는 다른 API들도 고치기(addCart, addLike, removeLike)

그런데, 코드를 보면 ensureAuthorizaition()함수가 계속 반복되고, 호출해서 사용하는 부분도 계속 반복되는 것을 볼수 있음 -> 외부모듈로 빼서 관리해줘야할 필요 있음

🍎🍏 오늘의 느낀점 : 오늘은 토큰을 발행하고 검증하는 연습부터 직접 프로젝트에 넣어보면서 많은것들을 배웠다. try, catch문을 간단히 알고 있었는데 어떤 구문인지 확실하게 알 수 있었다.

profile
프론트엔드개발자가 되고 싶어서 열심히 땅굴 파는 자

0개의 댓글