본문 바로가기
TIL

Node.js 백오피스 팀 프로젝트 (2)

by 황민도 2024. 6. 24.

내일배움캠프 스파르타 코딩클럽

 

< prisma.utils.js >

import { PrismaClient } from '@prisma/client';

export const prisma = new PrismaClient({
  // Prisma를 이용해 데이터베이스를 접근할 때, SQL을 출력해줍니다.
  log: ['query', 'info', 'warn', 'error'],

  // 에러 메시지를 평문이 아닌, 개발자가 읽기 쉬운 형태로 출력해줍니다.
  errorFormat: 'pretty',
}); // PrismaClient 인스턴스를 생성합니다.

try {
  await prisma.$connect();
  console.log('DB 연결에 성공했습니다.');
} catch (error) {
  console.error('DB 연결에 실패했습니다.', error);
}

 

< tokens.js >

import jwt from 'jsonwebtoken';
import {
  ACCESS_TOKEN_SECRET,
  REFRESH_TOKEN_SECRET,
} from '../constants/env.constant.js';
import {
  ACCESS_TOKEN_EXPIRES,
  REFRESH_TOKEN_EXPIRES,
} from '../constants/auth.constant.js';

//엑세스 토큰 발급 함수
export function createAccessToken({ id, role }) {
  try {
    console.log(ACCESS_TOKEN_SECRET);
    return jwt.sign({ id, role }, ACCESS_TOKEN_SECRET, {
      expiresIn: ACCESS_TOKEN_EXPIRES,
    });
  } catch (error) {
    console.log('에러메세지', error.message);
  }
}
//리프레시 토큰 발급 함수
export function createRefreshToken({ id, role }) {
  return jwt.sign({ id, role }, REFRESH_TOKEN_SECRET, {
    expiresIn: REFRESH_TOKEN_EXPIRES,
  });
}

 

< http.error.js >

import { HTTP_STATUS } from '../constants/http-status.constant.js';

class BadRequest {
  constructor(message = BadRequest.name) {
    this.message = message;
    this.status = HTTP_STATUS.BAD_REQUEST;
  }
}

class Unauthorized {
  constructor(message = Unauthorized.name) {
    this.message = message;
    this.status = HTTP_STATUS.UNAUTHORIZED;
  }
}

class Forbidden {
  constructor(message = Forbidden.name) {
    this.message = message;
    this.status = HTTP_STATUS.FORBIDDEN;
  }
}

class NotFound {
  constructor(message = NotFound.name) {
    this.message = message;
    this.status = HTTP_STATUS.NOT_FOUND;
  }
}

class Conflict {
  constructor(message = Conflict.name) {
    this.message = message;
    this.status = HTTP_STATUS.CONFLICT;
  }
}

class InternalServerError {
  constructor(message = InternalServerError.name) {
    this.message = message;
    this.status = HTTP_STATUS.INTERNAL_SERVER_ERROR;
  }
}

export const HttpError = {
  BadRequest,
  Unauthorized,
  Forbidden,
  NotFound,
  Conflict,
  InternalServerError,
};

 

< auth.constant.js >

export const HASH_SALT_ROUNDS = 10;
export const ACCESS_TOKEN_EXPIRES = '1h';
export const REFRESH_TOKEN_EXPIRES = '7d';
export const MIN_PASSWORD_LENGTH = 8;
export const MAX_PASSWORD_LENGTH = 12;

 

< env.constant.js >

export const SERVER_PORT = process.env.SERVER_PORT;

export const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET;
export const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;

export const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID;
export const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY;
export const AWS_REGION = process.env.AWS_REGION;
export const AWS_S3_BUCKET_NAME = process.env.AWS_S3_BUCKET_NAME;

 

< http-status.constant.js >

export const HTTP_STATUS = {
  OK: 200, // 호출에 성공했을 때
  CREATED: 201, // 생성에 성공했을 때
  BAD_REQUEST: 400, // 사용자가 잘못 했을 때 (예: 입력 값을 빠뜨렸을 때)
  UNAUTHORIZED: 401, // 인증 실패 unauthenciated (예: 비밀번호가 틀렸을 때)
  FORBIDDEN: 403, // 인가 실패 unauthorized (예: 접근 권한이 없을 때)
  NOT_FOUND: 404, // 데이터가 없는 경우
  CONFLICT: 409, // 충돌이 발생했을 때 (예: 이메일 중복)
  INTERNAL_SERVER_ERROR: 500, // 예상치 못한 에러가 발생했을 때
};

 

< message.constant.js >

import { MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH } from './auth.constant.js';

export const MESSAGES = {
  AUTH: {
    COMMON: {
      EMAIL: {
        REQUIRED: '이메일을 입력해 주세요.',
        INVALID_FORMAT: '이메일 형태가 올바르지 않습니다.',
        DUPLICATED: '이미 가입된 사용자입니다.',
      },
      PASSWORD: {
        REQUIRED: '비밀번호를 입력해 주세요.',
        LENGTH: `비밀번호를 ${MIN_PASSWORD_LENGTH}자리 이상, ${MAX_PASSWORD_LENGTH}자리 이하로 설정해주세요.`,
        NO_GAP: '비밀번호에는 공백이 포함될 수 없습니다.',
        NO_STRING: '비밀번호는 문자열로 입력해야합니다.',
      },
      REPEAT_PASSWORD: {
        REQUIRED: '비밀번호 확인을 입력해 주세요.',
        NOT_MATCHED: '비밀번호가 일치하지 않습니다.',
      },
      NAME: {
        REQUIRED: '이름을 입력해주세요.',
        NO_STRING: '이름은 문자열로 입력해야합니다.',
      },
      INTRODUCE: {
        NO_STRING: '자기소개는 문자열로 입력해야합니다.',
      },
      PROFILE_IMAGE: {
        NO_STRING: '프로필 URL을 문자열로 입력해주세요.',
      },
    },
    JWT: {
      NO_TOKEN: '인증 정보가 없습니다.',
      NOT_SUPPORTED_TYPE: '지원하지 않는 인증 방식입니다.',
      EXPIRED: '인증 정보가 만료되었습니다.',
      NO_USER: '인증 정보와 일치하는 사용자가 없습니다.',
      INVALID: '인증 정보가 유효하지 않습니다.',
    },
    SIGN_UP: {
      SUCCEED: '회원가입에 성공했습니다.',
    },
    SIGN_IN: {
      SUCCEED: '로그인에 성공했습니다.',
      UNAUTHORIZED: '인증에 실패했습니다',
      TOKEN: '토근 재발급에 성공했습니다.',
    },
    SIGN_OUT: {
      SUCCEED: '로그아웃에 성공했습니다.',
    },
  },
};

 

< error-handler.middleware.js >

import { HTTP_STATUS } from '../constants/http-status.constant.js';

const errorHandler = (err, req, res, next) => {
  console.log(err);
  // joi 에러처리

  if (err.name === 'ValidationError') {
    return res.status(HTTP_STATUS.BAD_REQUEST).json({
      status: HTTP_STATUS.BAD_REQUEST,
      message: err.message,
    });
  }

  // Http Error 처리
  if (err.status && err.message) {
    return res.status(err.status).json({
      status: err.status,
      message: err.message,
    });
  }

  res.status(HTTP_STATUS.INTERNAL_SERVER_ERROR).json({ message: err.message });
};

export default errorHandler;

 

< log.middleware.js >

import winston from 'winston';

const logger = winston.createLogger({
  level: 'info', // 로그 레벨을 'info'로 설정합니다.
  format: winston.format.json(), // 로그 포맷을 JSON 형식으로 설정합니다.
  transports: [
    new winston.transports.Console(), // 로그를 콘솔에 출력합니다.
  ],
});

export default function (req, res, next) {
  // 클라이언트의 요청이 시작된 시간을 기록합니다.
  const start = new Date().getTime();

  // 응답이 완료되면 로그를 기록합니다.(finish)
  res.on('finish', () => {
    const duration = new Date().getTime() - start;
    logger.info(
      `Method: ${req.method}, URL: ${req.url}, Status: ${res.statusCode}, Duration: ${duration}ms`
    );
  });

  next();
}

 

< require-access-token.middleware.js >

import { ACCESS_TOKEN_SECRET } from '../constants/env.constant.js';
import { MESSAGES } from '../constants/message.constant.js';
import jwt from 'jsonwebtoken';
import { HttpError } from '../errors/http.error.js';
import { UsersRepository } from '../repositories/users.repository.js';
import { PetsitterRepository } from '../repositories/petsitters.repository.js';
import { prisma } from '../utils/prisma.utils.js';

const usersRepository = new UsersRepository(prisma);
const petsittersRepository = new PetsitterRepository(prisma);

export const requireAccessToken = async (req, res, next) => {
  try {
    const authorization = req.headers.authorization;

    if (!authorization) {
      throw new HttpError.Unauthorized(MESSAGES.AUTH.JWT.NO_TOKEN);
    }

    const [type, accessToken] = authorization.split(' ');

    if (type !== 'Bearer') {
      throw new HttpError.Unauthorized(MESSAGES.AUTH.JWT.NOT_SUPPORTED_TYPE);
    }

    if (!accessToken) {
      throw new HttpError.Unauthorized(MESSAGES.AUTH.JWT.NO_TOKEN);
    }

    let payload;
    try {
      payload = jwt.verify(accessToken, ACCESS_TOKEN_SECRET);
    } catch (error) {
      if (error.name === 'TokenExpiredError') {
        throw new HttpError.Unauthorized(MESSAGES.AUTH.JWT.EXPIRED);
      } else {
        throw new HttpError.Unauthorized(MESSAGES.AUTH.JWT.INVALID);
      }
    }

    const { id } = payload;
    const { role } = payload;

    const user =
      role === 'user'
        ? await usersRepository.findOneId(id)
        : await petsittersRepository.findPetsitterById({ id });

    if (!user) {
      throw new HttpError.Unauthorized(MESSAGES.AUTH.JWT.NO_USER);
    }

    if (role === 'user') {
      const { refreshToken } = await usersRepository.findOneRefreshTokenId(id);

      if (refreshToken === null) {
        throw new HttpError.Unauthorized(MESSAGES.AUTH.JWT.NO_TOKEN);
      }
    }

    req.user = { ...user, role };
    next();
  } catch (error) {
    next(error);
  }
};

 

< require-refresh-token.middleware.js >

import { REFRESH_TOKEN_SECRET } from '../constants/env.constant.js';
import { MESSAGES } from '../constants/message.constant.js';
import jwt from 'jsonwebtoken';
import { HttpError } from '../errors/http.error.js';
import { UsersRepository } from '../repositories/users.repository.js';
import { PetsitterRepository } from '../repositories/petsitters.repository.js';
import bcrypt from 'bcrypt';
import { prisma } from '../utils/prisma.utils.js';

const usersRepository = new UsersRepository(prisma);
const petsittersRepository = new PetsitterRepository(prisma);

export const requireRefreshToken = async (req, res, next) => {
  try {
    const authorization = req.headers.authorization;

    if (!authorization) {
      throw new HttpError.Unauthorized(MESSAGES.AUTH.JWT.NO_TOKEN);
    }

    const [type, refreshToken] = authorization.split(' ');

    if (type !== 'Bearer') {
      throw new HttpError.Unauthorized(MESSAGES.AUTH.JWT.NOT_SUPPORTED_TYPE);
    }

    if (!refreshToken) {
      throw new HttpError.Unauthorized(MESSAGES.AUTH.JWT.NO_TOKEN);
    }

    let payload;
    try {
      payload = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET);
    } catch (error) {
      if (error.name === 'TokenExpiredError') {
        throw new HttpError.Unauthorized(MESSAGES.AUTH.JWT.EXPIRED);
      } else {
        throw new HttpError.Unauthorized(MESSAGES.AUTH.JWT.INVALID);
      }
    }

    const { id } = payload;
    const { role } = payload;

    const existedRefreshToken = await usersRepository.findOneRefreshTokenId(id);

    if (!existedRefreshToken) {
      throw new HttpError.Unauthorized(MESSAGES.AUTH.JWT.NO_USER);
    }

    const isValidRefreshToken = bcrypt.compareSync(
      refreshToken,
      existedRefreshToken.refreshToken
    );

    if (!isValidRefreshToken) {
      throw new HttpError.Unauthorized(MESSAGES.AUTH.JWT.NO_USER);
    }

    const user =
    role === 'user'
      ? await usersRepository.findOneId(id)
      : await petsittersRepository.findPetsitterById({ id });

    if (!user) {
      throw new HttpError.Unauthorized(MESSAGES.AUTH.JWT.NO_USER);
    }

    req.user = { ...user, role };
    next();
  } catch (error) {
    next(error);
  }
};

 

< require-roles.middleware.js >

import { HTTP_STATUS } from '../constants/http-status.constant.js';

export const requireRoles = (roles) => {
  return (req, res, next) => {
    try {
      const user = req.user;

      const hasPermission = user && roles.includes(user.role);

      if (!hasPermission) {
        res
          .status(HTTP_STATUS.FORBIDDEN)
          .json({ message: '접근권한이 없습니다.' });
      }

      next();
    } catch (error) {
      next(error);
    }
  };
};

 

< upload-image.js >

import { S3Client } from '@aws-sdk/client-s3';
import multer from 'multer';
import multerS3 from 'multer-s3';
import path from 'path';
import {
  AWS_ACCESS_KEY_ID,
  AWS_SECRET_ACCESS_KEY,
  AWS_REGION,
  AWS_S3_BUCKET_NAME,
} from '../constants/env.constant.js';

// S3 클라이언트 설정
const s3 = new S3Client({
  region: AWS_REGION,
  credentials: {
    accessKeyId: AWS_ACCESS_KEY_ID,
    secretAccessKey: AWS_SECRET_ACCESS_KEY,
  },
});

// 파일 필터 설정 (이미지 파일만 허용)
const fileFilter = (req, file, cb) => {
  const fileTypes = /jpeg|jpg|png/;
  const extname = fileTypes.test(path.extname(file.originalname).toLowerCase());
  const mimetype = fileTypes.test(file.mimetype);

  if (mimetype && extname) {
    return cb(null, true);
  } else {
    cb(new Error('이미지 파일만 업로드할 수 있습니다.'));
  }
};

// multer 설정 (S3 버킷에 저장)
const upload = multer({
  storage: multerS3({
    s3: s3,
    bucket: AWS_S3_BUCKET_NAME,
    acl: 'public-read', // 파일 접근 권한 설정
    key: (req, file, cb) => {
      const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9);
      cb(
        null,
        file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname)
      );
    },
  }),
  limits: { fileSize: 1024 * 1024 * 5 }, // 파일 크기 제한 (5MB)
  fileFilter: fileFilter,
}).single('profileImage'); // 파일 필드 이름 명시

export { upload };