Node 강의/숙련

2-9 [게시판 프로젝트] express-session으로 리팩토링

kagan-draca 2024. 9. 9. 20:50

로그인 기능과 사용자 인증 미들웨어express-session을 이용해 리팩토링 할 예정 입니다.

1. 초기설정

yarn add express-session

# express-session 미들웨어를 설치합니다.

2. app.js 파일에 express-session 추가하기

app.js에 express-session 등록하기 

import expressSession from 'express-session'

 

express-session 패키지를 import 받습니다.

 

app.use(
  expressSession({
    secret : 'customized_secret_key', //세션을 암호화하는 비밀 키를 설정
    resave : false,
    // 클라이언트의 요청이 올 때 마다 세션을 새롭게 저장할 지 설정,
    // 변경사항이 없어도 다시 저장
    saveUninitialized : false,
    // 세션이 초기화되지 않았을 때 세션을 저장할 지 설정
    cookie : {
      maxAge : 1000 * 60 * 60 * 24
      // 쿠키 만료 기간 1일로 설정
    }
  })
)

그리고 expressSession을 설정해줍니다.

 

최종 코드 : 

import express from "express";
import cookieParser from "cookie-parser";
import expressSession from 'express-session'
import UsersRouter from "../routes/users.router.js";
import PostsRouter from "../routes/posts.router.js";
import CommentsRouter from "../routes/comments.router.js";
import LogMiddleware from "../middlewares/log.middleware.js";
import ErrorHandingMiddleware from "../middlewares/errorhanding.middleware.js";

const app = express();
const PORT = 3018;

app.use(LogMiddleware);
app.use(express.json());
app.use(cookieParser());
app.use(
  expressSession({
    secret : 'customized_secret_key', //세션을 암호화하는 비밀 키를 설정
    resave : false,
    // 클라이언트의 요청이 올 때 마다 세션을 새롭게 저장할 지 설정,
    // 변경사항이 없어도 다시 저장
    saveUninitialized : false,
    // 세션이 초기화되지 않았을 때 세션을 저장할 지 설정
    cookie : {
      maxAge : 1000 * 60 * 60 * 24
      // 쿠키 만료 기간 1일로 설정
    }
  })
)


app.use("/api", [UsersRouter, PostsRouter, CommentsRouter]);

app.use(ErrorHandingMiddleware);
app.listen(PORT, () => {
  console.log(PORT, "프트로 서버가 열렸습니다");
});

 

3. 로그인 리팩토링

[게시판 프로젝트] 로그인 API 변경 전 비즈니스 로직

  1. email, password를 body로 전달받습니다.
  2. 전달 받은 email에 해당하는 사용자가 있는지 확인합니다.
  3. 전달 받은 password와 데이터베이스에 저장된 password를 bcrypt를 이용해 검증합니다.
  4. 로그인에 성공한다면, 사용자에게 JWT를 발급합니다. (방법 변경 예정)

JWT 대신 express-session의 세션 ID를 사용해서 쿠키를 전달하겠습니다.

로그인을 성공하면 세션 ID를 생성하고, 이를 쿠키로 사용자에게 전달할 예정입니다.

 

기존 코드 :

UsersRouter.post("/sign-in", async (req, res, next) => {
  const { email, password } = req.body;
  const user = await prisma.users.findFirst({ where: { email } });

  if (!user)
    return res.status(401).json({ message: "존재하지 않는 이메일입니다." });
  // 입력받은 사용자의 비밀번호와 데이터베이스에 저장된 비밀번호를 비교합니다.
  else if (!(await bcrypt.compare(password, user.password)))
    return res.status(401).json({ message: "비밀번호가 일치하지 않습니다." });

  // 로그인에 성공하면, 사용자의 userId를 바탕으로 토큰을 생성합니다.
  const token = jwt.sign({ userId: user.userId }, "custom-secret-key");
  // 암호화할 내용, 암호화에 사용될 키 이름(향후 복호화할 때 비교 대상으로 사용)
  res.cookie("authorization", `Bearer ${token}`);
  //        cookie 이름, Bearer 토큰 형식으로 token 정보 전달
  return res.status(200).json({ message: "로그인 성공" });
});

에서 

// 로그인에 성공하면, 사용자의 userId를 바탕으로 토큰을 생성합니다.
  const token = jwt.sign({ userId: user.userId }, "custom-secret-key");
  // 암호화할 내용, 암호화에 사용될 키 이름(향후 복호화할 때 비교 대상으로 사용)
  res.cookie("authorization", `Bearer ${token}`);

jwt와 관련된 부분을 제거하고,

  // 로그인에 성공하면, 사용자의 userId를 바탕으로 세션을 생성합니다.
  req.session.userId = user.userId;

으로 바꿔줍니다.

 

이렇게 변경한다면, 쿠키 내부가 어떤 값을 가지는지 알 수 없어도,

클라이언트가 다음 요청을 보낼 때 마다 세션 ID를 바탕으로

사용자 정보를 조회할 수 있게 될 것 입니다.

 

변경된 sign-in 전체 코드:

UsersRouter.post("/sign-in", async (req, res, next) => {
  const { email, password } = req.body;
  const user = await prisma.users.findFirst({ where: { email } });

  if (!user)
    return res.status(401).json({ message: "존재하지 않는 이메일입니다." });
  // 입력받은 사용자의 비밀번호와 데이터베이스에 저장된 비밀번호를 비교합니다.
  else if (!(await bcrypt.compare(password, user.password)))
    return res.status(401).json({ message: "비밀번호가 일치하지 않습니다." });

  // 로그인에 성공하면, 사용자의 userId를 바탕으로 세션을 생성합니다.
  req.session.userId = user.userId;

  //        cookie 이름, Bearer 토큰 형식으로 token 정보 전달
  return res.status(200).json({ message: "로그인 성공" });
});

 

Insomnia에서 로그인을 확인해보면

정상적으로 로그인 되고,

connect.sid(sessionId)를 바탕으로 쿠키가 발급된 것을 확인할 수 있습니다.

 

3. 사용자 인증 미들웨어 리팩토링

[게시판 프로젝트] 사용자 인증 미들웨어 변경된 비즈니스 로직

  1. 클라이언트로부터 세션 ID를 전달받습니다.
  2. 세션 정보에 저장된 userId를 이용해 사용자를 조회합니다.
  3. req.user 에 조회된 사용자 정보를 할당합니다.
  4. 다음 미들웨어를 실행합니다.

기존 사용자 인증 미들웨어는 쿠키를 조회하고, Bear Token과 JWT를 검증하는 복잡한

로직을 수행하였습니다. 하지만, express-session을 도입하게 된다면, 이러한 복잡한 로직 없이도

세션 정보를 활용해 사용자를 식별할 수 있게 될 것 입니다.

 

따라서, 개선된 사용자 인증 미들웨어는 세션 정보에 저장된 userId를 이용해 사용자를 조회하고,

이를 req.uer에 할당하는 간단한 역할만 담당하게 됩니다. 이로 인해, 사용자 미들웨어의

코드 복잡도를 줄이며, 인증 과정은 더욱 단순해지게 될 것 입니다.

 

이전 코드 : 

import jwt from "jsonwebtoken";
import prisma from "../src/utils/prisma/index.js";

// 1. 클라이언트로 부터 **쿠키(Cookie)**를 전달받습니다.
// 2. **쿠키(Cookie)**가 **Bearer 토큰** 형식인지 확인합니다.
// 3. 서버에서 발급한 **JWT가 맞는지 검증**합니다.
// 4. JWT의 `userId`를 이용해 사용자를 조회합니다.
// 5. `req.user` 에 조회된 사용자 정보를 할당합니다.
// 6. 다음 미들웨어를 실행합니다.

export default async function (req, res, next) {
  try {
    const { authorization } = req.cookies;
    if (!authorization) throw new Error("토큰이 존재하지 않습니다.");

    // 현재 토큰이 "Bearer ~~~~~"이기 때문에
    // split한 배열의 0번지는 Bearer이고, 1번은 "암호화에 사용한 키" 이다.
    const [tokenType, token] = authorization.split(" ");

    if (tokenType !== "Bearer")
      throw new Error("토큰 타입이 일치하지 않습니다.");

    const decodedToken = jwt.verify(token, "custom-secret-key");
    const userId = decodedToken.userId;

    const user = await prisma.users.findFirst({
      where: { userId: +userId },
    });
    if (!user) {
      res.clearCookie("authorization");
      throw new Error("토큰 사용자가 존재하지 않습니다.");
    }

    // req.user의 정보를 user 정보로 제공해준다.
    req.user = user;

    // 다음 미들웨어가 호출될 수 있도록 next()를 사용한다.
    next();
  } catch (error) {
    res.clearCookie("authorization");

    // 토큰이 만료되었거나, 조작되었을 때, 에러 메시지를 다르게 출력합니다.
    switch (error.name) {
      case "TokenExpiredError":
        return res.status(401).json({ message: "토큰이 만료되었습니다." });
      case "JsonWebTokenError":
        return res.status(401).json({ message: "토큰이 조작되었습니다." });
      default:
        return res
          .status(401)
          .json({ message: error.message ?? "비정상적인 요청입니다." });
    }
  }
}

에서 

    const { authorization } = req.cookies;
    if (!authorization) throw new Error("토큰이 존재하지 않습니다.");

    // 현재 토큰이 "Bearer ~~~~~"이기 때문에
    // split한 배열의 0번지는 Bearer이고, 1번은 "암호화에 사용한 키" 이다.
    const [tokenType, token] = authorization.split(" ");

    if (tokenType !== "Bearer")
      throw new Error("토큰 타입이 일치하지 않습니다.");

    const decodedToken = jwt.verify(token, "custom-secret-key");
    const userId = decodedToken.userId;

부분을 지우고,

    const { userId } = req.session;
    if (!userId) throw new Error("로그인이 필요합니다.");

으로 바꿔줍니다.

 

그리고,

catch (error) {
  res.clearCookie("authorization");

  // 토큰이 만료되었거나, 조작되었을 때, 에러 메시지를 다르게 출력합니다.
  switch (error.name) {
    case "TokenExpiredError":
      return res.status(401).json({ message: "토큰이 만료되었습니다." });
    case "JsonWebTokenError":
      return res.status(401).json({ message: "토큰이 조작되었습니다." });
    default:
      return res
        .status(401)
        .json({ message: error.message ?? "비정상적인 요청입니다." });
  }
}

을 지우고,

catch (error) {
    return res.status(401).json({ message: error.message });
  }

으로 바꿔줍니다.

 

변경된 전체 코드 :

import prisma from "../src/utils/prisma/index.js";

// 1. 클라이언트로 부터 **쿠키(Cookie)**를 전달받습니다.
// 2. **쿠키(Cookie)**가 **Bearer 토큰** 형식인지 확인합니다.
// 3. 서버에서 발급한 **JWT가 맞는지 검증**합니다.
// 4. JWT의 `userId`를 이용해 사용자를 조회합니다.
// 5. `req.user` 에 조회된 사용자 정보를 할당합니다.
// 6. 다음 미들웨어를 실행합니다.

export default async function (req, res, next) {
  try {
    const { userId } = req.session;
    if (!userId) throw new Error("로그인이 필요합니다.");

    const user = await prisma.users.findFirst({
      where: { userId: +userId },
    });
    if (!user) {
      res.clearCookie("authorization");
      throw new Error("토큰 사용자가 존재하지 않습니다.");
    }

    // req.user의 정보를 user 정보로 제공해준다.
    req.user = user;

    // 다음 미들웨어가 호출될 수 있도록 next()를 사용한다.
    next();
  } catch (error) {
    return res.status(401).json({ message: error.message });
  }
}

 

Insomnia에서 다시 로그인 및 사용자 정보 조회를 해보면,

로그인
사용자 인증 조회

 

둘 다 정상적으로 동작하는걸 확인할 수 있습니다.

 

4. express-mysql-session MySQL DB에 저장하기

express-mysql-session 모듈은 express-session의 세션 정보를 MySQL에 저장할 수 있도록 도와주는 모듈입니다.

 

express-mysql-session을 사용하면 서버를 재실행할 때마다 세션 정보가 초기화 되는 문제를 해결할 수 있습니다.

→ DB의 Table에 저장하는 방식이기 때문에 영구적으로 사용 및 관리할 수 있다.

 

1) express-mysql-session 설치 명령어

 

yarn add express-mysql-session

# 외부 세션 스토리지를 사용하기 위한,

   express-mysql-session 모듈을 설치합니다.

 

 

2) app.js 에 express-mysql-session 추가하기

 

import expressMySQLSession from "express-mysql-session";

 

로 express-mysql-session을 import 받습니다.

 

// MySQLStore를 Express-Session을 이용해 생성합니다.
const MySQLStore = expressMySQLSession(expressSession);
 
// MySQLStore를 이용해 세션 외부 스토리지를 선언합니다.
const sessionStore = new MySQLStore({
  user: 'DB 사용자 ID',
  password: 'DB 사용자 비밀번호',
  host: 'DB 경로',
  port: 포트 번호,
  database: 'community_hub', //사용할 DB 실제 이름
  expiration: 1000 * 60 * 60 * 24, // 세션의 만료 기간을 1일로 설정합니다.
  createDatabaseTable: true, // 세션 테이블을 자동으로 생성합니다.
});

을 추가해 세션을 저장할 수 있도록 만들어줍니다.

 

그리고,

 

app.use(LogMiddleware);
app.use(express.json());
app.use(cookieParser());
app.use(
  expressSession({
    secret: "customized_secret_key", //세션을 암호화하는 비밀 키를 설정
    resave: false,
    // 클라이언트의 요청이 올 때 마다 세션을 새롭게 저장할 지 설정,
    // 변경사항이 없어도 다시 저장
    saveUninitialized: false,
    // 세션이 초기화되지 않았을 때 세션을 저장할 지 설정
    cookie: {
      maxAge: 1000 * 60 * 60 * 24,
      // 쿠키 만료 기간 1일로 설정
    },
  })
);

 

store : sessionStore, // 외부 세션 스토리지를 MySQLStore로 설정합니다.

을 추가해줍니다.

 

이와 같은 과정을 통해 외부 세션 스토리지 설정이 완료 됐습니다.

 

다시 Insomnia로 서버와 DB연결을 확인해보면,

서버와 DB가 정상 연결된 것을 확인할 수 있습니다.

 

그리고 해당 DB를 확인해보면,

 

session을 관리하는 테이블인 sessions가 추가된 것을 확인할 수 있습니다.

그리고, sessions 테이블 내부를 보면

위와 같이 세션들이 관리되는걸 확인할 수 있습니다.

 

expires를 통해 해당 row의 만료 기간과 data를 통해 세션 만료 기간, userId 를 확인할 수 있습니다.

 

5. express-mysql-session 단점

 

세션 ID로 정보를 조회할 때 마다 MySQL의 조회 쿼리가 매번 실행 됩니다.

 

이러한 문제를 해결하기 위해 다양한 방법들이 존재 하는데

JWT 쿠키를 이용하는 방법도 그 중 하나 입니다. 다른 방법으로는,

캐시 메모리 데이터베이스인 Redis변경하는 것도 가능한 해결책입니다.

 

따라서, 어떤 기술 스택을 선택할지는 현재 상황에서 가장 효율적이고,

적합한 기술을 선택하는 것이 중요합니다.

 

5. dotenv 적용하기

 

dotenv는 Node.js를 위한 환경 변수 관리 모듈로써,

.env 파일을 통해 환경 변수를 processs.env에 추가하는 역할을 담당합니다.

 

1) dotenv 설치하기

yarn add -D dotenv

# dotenv 모듈을 설치합니다.

 

2) .env 설정하기

# MySQL의 데이터베이스 URL입니다.
DATABASE_URL="mysql://아이디:비밀번호@DB 엔드 포인트:포트번호/DB이름"

# MySQL의 엔드 포인트입니다.
DATABASE_HOST="DB 엔드 포인트"

# MySQL의 포트입니다.
DATABASE_PORT=포트번호

# MySQL의 데이터베이스 명입니다.
DATABASE_NAME="사용중인 DB 실제 이름"

# MySQL의 사용자 ID 입니다.
DATABASE_USERNAME="아이디"

# MYSQL의 사용자 비밀번호 입니다.
DATABASE_PASSWORD="비밀번호"

# 세션에 사용되는 비밀키 입니다.
SESSION_SECRET_KEY="사용할 비밀키 이름"

으로 내용을 추가해줍니다.

 

3) app.js을 이용해 .env 파일 import 받기

import dotenv from 'dotenv'

 

으로 dotenv 패키지를 import 받고

dotenv.config()

최상단에 dotenv.config()로 root 위치에 존재하는 .env 내부 파일 정보를 가져옵니다

// MySQLStore를 Express-Session을 이용해 생성합니다.
const MySQLStore = expressMySQLSession(expressSession);
// MySQLStore를 이용해 세션 외부 스토리지를 선언합니다.
const sessionStore = new MySQLStore({
  user: process.env.DATABASE_USERNAME,
  password: process.env.DATABASE_PASSWORD,
  host: process.env.DATABASE_HOST,
  port: process.env.DATABASE_PORT,
  database: process.env.DATABASE_NAME, //사용할 DB 실제 이름
  expiration: 1000 * 60 * 60 * 24, // 세션의 만료 기간을 1일로 설정합니다.
  createDatabaseTable: true, // 세션 테이블을 자동으로 새성합니다.
});

 

위와 같이 수정해 DATABASE와 관련된 모든 정보를 암호화해줍니다.

 

Insomnia로 다시 서버를 통해 DB에 접근해보면

정상 동작을 확인할 수 있습니다.

 

 

모든 데이터베이스 관련 정보들이 이제 dotenv를 통해 로드된 process.env 환경 변수에서

사용할 수 있도록 설정했습니다. 이로 인해, 코드에서 실제 데이터베이스의 주소, ID, 비밀번호를

알아낼 수 없게 됐습니다.

 

주의할 점 .gitignore 파일에 .env를 추가하지 않는다면 해당 파일이

Github Repository에 업로드 된다. 이것은 중요한 정보를

'하드 코딩'한 것과 마찬가지로 보안상의 위협이 될 수 있다.

따라서, 꼭! 반드시! .gitignore 파일에 .env 를 포함시켜주자!!