9/10 Node.js 숙련 2주차 (1)

성준호·2024년 9월 10일
0

1. 인증, 인가

1) 인증

인증은 서비스를 이용하려는 사용자가 인증된 신분을 가진 사람이 맞는지 검증하는 작업이다.
인증(Authentication)은 일반적인 사이트의 로그인 기능에 해당한다.

2) 인가

인가(Authorization)는 인증된 사용자가 특정 리소스에 접근하거나 특정 작업을 수행할 수 있는 권한이 있는지를 검증하는 작업이다. 로그인된 사용자만 게시글을 작성할 수 있는지 검증한다면, 이를 인가 과정이라고 부른다

2. 로그인, 회원가입 API

1) express 시작

app.js 초기화

// src/app.js

import express from 'express';
import cookieParser from 'cookie-parser';

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

app.use(express.json());
app.use(cookieParser());

app.listen(PORT, () => {
  console.log(PORT, '포트로 서버가 열렸어요!');
});

Prisma 초기화

// src/utils/prisma/index.js

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

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

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

프로젝트 초기화

.
├── package.json
├── prisma
│   └── schema.prisma
├── src
│   ├── app.js
│   └── utils
│       └── prisma
│           └── index.js
└── yarn.lock

2) 회원가입 API

회원가입 API 비즈니스 로직
1. email, password, name, age, gender, profileImagebody로 전달
2. 동일한 email을 가진 사용자가 있는지 확인
3. Users 테이블에 email, password를 이용해 사용자를 생성
4. UserInfos 테이블에 name, age, gender, profileImage를 이용해 사용자 정보를 생성

회원가입 API는 사용자와 사용자 정보가 1:1 관계를 맺는다.

UserRouter를 등록한 app.js

// app.js

import express from 'express';
import cookieParser from 'cookie-parser';
import UsersRouter from './routes/users.router.js';

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

app.use(express.json());
app.use(cookieParser());
app.use('/api', [UsersRouter]);

app.listen(PORT, () => {
  console.log(PORT, '포트로 서버가 열렸어요!');
});

회원가입 API

// src/routes/users.router.js

import express from 'express';
import { prisma } from '../utils/prisma/index.js';

const router = express.Router();

/** 사용자 회원가입 API **/
router.post('/sign-up', async (req, res, next) => {
  const { email, password, name, age, gender, profileImage } = req.body;
  const isExistUser = await prisma.users.findFirst({
    where: {
      email,
    },
  });

  if (isExistUser) {
    return res.status(409).json({ message: '이미 존재하는 이메일입니다.' });
  }

  // Users 테이블에 사용자를 추가합니다.
  const user = await prisma.users.create({
    data: { email, password },
  });
  // UserInfos 테이블에 사용자 정보를 추가합니다.
  const userInfo = await prisma.userInfos.create({
    data: {
      userId: user.userId, // 생성한 유저의 userId를 바탕으로 사용자 정보를 생성합니다.
      name,
      age,
      gender: gender.toUpperCase(), // 성별을 대문자로 변환합니다.
      profileImage,
    },
  });

  return res.status(201).json({ message: '회원가입이 완료되었습니다.' });
});

export default router;

3) bcrypt

사용자의 비밀번호를 데이터베이스에 저장할 때 보안을 위해 암호화하여 저장한다.

bcrypt를 이용해 암호화하면 단방향 암호화 되어 원래의 비밀번호로 복구할 수 없다.
하지만, 입력된 비밀번호가 암호화된 문자열과 일치하는지는 비교할 수 있다.
이를 통해 사용자의 비밀번호가 올바른지 아닌지 검증할 수 있다.

bycrypt 암호화

import bcrypt from 'bcrypt';

const password = 'Sparta'; // 사용자의 비밀번호
const saltRounds = 10; // salt를 얼마나 복잡하게 만들지 결정합니다.

// 'hashedPassword'는 암호화된 비밀번호 입니다.
const hashedPassword = await bcrypt.hash(password, saltRounds);

console.log(hashedPassword); //$2b$10$OOziCKNP/dH1jd.Wvc3JluZVm7H8WXR8oUmxUQ/cfdizQOLjCXoXa

bycrypt 복호화

import bcrypt from 'bcrypt';

const password = 'Sparta'; // 사용자가 입력한 비밀번호
const hashed = '$2b$10$OOziCKNP/dH1jd.Wvc3JluZVm7H8WXR8oUmxUQ/cfdizQOLjCXoXa'; // DB에서 가져온 암호화된 비밀번호

// 'result'는 비밀번호가 일치하면 'true' 아니면 'false'
const result = await bcrypt.compare(password, hashed);

console.log(result); // true

// 비밀번호가 일치하지 않다면, 'false'
const failedResult = await bcrypt.compare('FailedPassword', hashed);

console.log(failedResult); // false

4) 회원가입 API Bcrypt 리팩토링

bcrypt 설치

# yarn을 이용해 bcrypt를 설치합니다.
yarn add bcrypt

회원가입 API Bcrypt 리팩토링

// src/routes/users.router.js

import bcrypt from 'bcrypt';

/** 사용자 회원가입 API 리팩토링**/
router.post('/sign-up', async (req, res, next) => {
  const { email, password, name, age, gender, profileImage } = req.body;
  const isExistUser = await prisma.users.findFirst({
    where: {
      email,
    },
  });

  if (isExistUser) {
    return res.status(409).json({ message: '이미 존재하는 이메일입니다.' });
  }

  // 사용자 비밀번호를 암호화합니다.
  const hashedPassword = await bcrypt.hash(password, 10);

  // Users 테이블에 사용자를 추가합니다.
  const user = await prisma.users.create({
    data: {
      email,
      password: hashedPassword, // 암호화된 비밀번호를 저장합니다.
    },
  });

  // UserInfos 테이블에 사용자 정보를 추가합니다.
  const userInfo = await prisma.userInfos.create({
    data: {
      userId: user.userId, // 생성한 유저의 userId를 바탕으로 사용자 정보를 생성합니다.
      name,
      age,
      gender: gender.toUpperCase(), // 성별을 대문자로 변환합니다.
      profileImage,
    },
  });

  return res.status(201).json({ message: '회원가입이 완료되었습니다.' });
});

5) 로그인 API, 사용자 인증 미들웨어

로그인 API 비즈니스 로직
1. email, passwordbody로 전달
2. 전달 받은 email에 해당하는 사용자가 있는지 확인
3. 전달 받은 password와 데이터베이스의 저장된 password를 bcrypt를 이용해 검증
4. 로그인에 성공한다면, 사용자에게 JWT를 발급

클라이언트로부터 받은 emailpassword를 사용하여 데이터베이스에 저장된 사용자를 검증하고, 검증에 성공하면 JWT를 담고있는 쿠키를 생성하여 반환한다.

로그인 API

// src/routes/users.route.js

import jwt from 'jsonwebtoken';

/** 로그인 API **/
router.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',
  );

  // authotization 쿠키에 Berer 토큰 형식으로 JWT를 저장합니다.
  res.cookie('authorization', `Bearer ${token}`);
  return res.status(200).json({ message: '로그인 성공' });
});

6) 사용자 인증 미들웨어

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

사용자 인증 미들웨어는 클라이언트가 전달한 쿠키를 바탕으로 사용자를 검증한다.
토큰이 만료되진 않았는지, 토큰 형식은 일치하는지, 서버가 발급한 토큰이 맞는지 다양한 검증을 수행하여 사용자의 권한을 확인한다.

사용자 인증 미들웨어

// src/middlewares/auth.middleware.js

import jwt from 'jsonwebtoken';
import { prisma } from '../utils/prisma/index.js';

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

    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에 사용자 정보를 저장합니다.
    req.user = user;

    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 ?? '비정상적인 요청입니다.' });
    }
  }
}

7) 사용자 정보 조회 API

사용자 정보 조회 API 비즈니스 로직
1. 클라이언트가 로그인된 사용자인지 검증.
2. 사용자를 조회할 때, 1:1 관계를 맺고 있는 UsersUserInfos 테이블을 조회
3. 조회한 사용자의 상세한 정보를 클라이언트에게 반환

사용자 정보 조회 API는 단순히 User 테이블 하나만 조회를 하는 것이 아닌, UserInfos 테이블을 함께 조회한다. 때문에 각각의 테이블을 1번씩 조회하여 총 2번 조회를 하는 문제가 발생한다.
이를 해결하기 위해 Prisma에서는 중첩 select라는 문법을 제공한다.

// src/routes/users.route.js

/** 사용자 조회 API **/
router.get('/users', authMiddleware, async (req, res, next) => {
  const { userId } = req.user;

  const user = await prisma.users.findFirst({
    where: { userId: +userId },
    select: {
      userId: true,
      email: true,
      createdAt: true,
      updatedAt: true,
      userInfos: {
        // 1:1 관계를 맺고있는 UserInfos 테이블을 조회합니다.
        select: {
          name: true,
          age: true,
          gender: true,
          profileImage: true,
        },
      },
    },
  });

  return res.status(200).json({ data: user });
});
  • select 내에 또다른 select가 존재하는데, 이것을 중첩 select 문법이라 부른다.
  • 중첩 select는 SQL의 JOIN과 동일한 역할을 수행한다.
  • 중첩 select 문법을 사용하기 위해선 @relation()과 같이 관계 설정이 되어야 한다.
  • @relation()으로 Prisma는 현재 모델에서 참조하는 외래키를 인식하고, SQL을 생성할 수 있게 된다.
  • 현재 테이블과 연관된 테이블의 모든 컬럼을 조회하고 싶다면 include 문법으로도 조회할 수 있다.

1. Acess Token, Refresh Token

1) Acess Token

사용자의 인증(로그인)이 완료된 후 해당 사용자를 인증하는 용도로 발급하는 토큰이다.

인증 요청 시 Acess Token을 사용하면, 토큰을 생성할 때 사용한 비밀키로 인증을 처리하게 된다. 이 방식은 복잡한 설계나 여러 분기 처리 없이 코드를 구현할 수 있다는 장점이 있다.
Acees Token은 Stateless(무상태), 즉 서버가 재시작되더라도 동일하게 작동한다. 이로 인해 jwt를 이용해 사용자의 인증 여부는 확인할 수 있지만, 처음 토큰을 발급한 사용자가 정말 그 사용자인지는 확인할 수 없다.

2) Refresh Token

사용자의 모든 인증 정보를 담고있는 Access Token과 달리 특정 사용자가 Access Token을 발급받기 위한 목적으로 사용된다.

사용자의 인증 젖ㅇ보를 검증하는데 사용되며, 이를 서버에서 관리한다.
서버는 Refresh Token을 디코딩하여 사용자의 정보를 확인한다. 이때 서버에서 강제로 토큰을 만료시킬 수 있으며, 사용자의 인증 상태를 언제든지 서버에서 제어할 수 있다는 장점이 있다.

3) Refresh Token 템플릿

yarn Package 설치

# yarn을 이용해 프로젝트를 초기화합니다.
yarn init -y

# express, jsonwebtoken, cookie-parser 패키지를 설치합니다.
yarn add express jsonwebtoken cookie-parser

app.js

// app.js

import express from 'express';
import jwt from 'jsonwebtoken';
import cookieParser from 'cookie-parser';

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

// 비밀 키는 외부에 노출되면 안되겠죠? 그렇기 때문에, .env 파일을 이용해 비밀 키를 관리해야합니다.
const ACCESS_TOKEN_SECRET_KEY = `HangHae99`; // Access Token의 비밀 키를 정의합니다.
const REFRESH_TOKEN_SECRET_KEY = `Sparta`; // Refresh Token의 비밀 키를 정의합니다.

app.use(express.json());
app.use(cookieParser());

app.get('/', (req, res) => {
  return res.status(200).send('Hello Token!');
});

app.listen(PORT, () => {
  console.log(PORT, '포트로 서버가 열렸어요!');
});

서버를 시작한 뒤 http://localhost:3019/ 주소로 들어가 Hellow Token이라는 문구가 보이는지 확인

4) Refresh Token, Access Token 발급 API

POST /tokens - app.js

// app.js

...
let tokenStorage = {}; // Refresh Token을 저장할 객체

/** Access Token, Refresh Token 발급 API **/
app.post('/tokens', (req, res) => {
  const { id } = req.body;
  const accessToken = createAccessToken(id);
  const refreshToken = createRefreshToken(id);

  // Refresh Token을 가지고 해당 유저의 정보를 서버에 저장합니다.
  tokenStorage[refreshToken] = {
    id: id, // 사용자에게 전달받은 ID를 저장합니다.
    ip: req.ip, // 사용자의 IP 정보를 저장합니다.
    userAgent: req.headers['user-agent'], // 사용자의 User Agent 정보를 저장합니다.
  };

  res.cookie('accessToken', accessToken); // Access Token을 Cookie에 전달한다.
  res.cookie('refreshToken', refreshToken); // Refresh Token을 Cookie에 전달한다.

  return res
    .status(200)
    .json({ message: 'Token이 정상적으로 발급되었습니다.' });
});

// Access Token을 생성하는 함수
function createAccessToken(id) {
  const accessToken = jwt.sign(
    { id: id }, // JWT 데이터
    ACCESS_TOKEN_SECRET_KEY, // Access Token의 비밀 키
    { expiresIn: '10s' }, // Access Token이 10초 뒤에 만료되도록 설정합니다.
  );

  return accessToken;
}

// Refresh Token을 생성하는 함수
function createRefreshToken(id) {
  const refreshToken = jwt.sign(
    { id: id }, // JWT 데이터
    REFRESH_TOKEN_SECRET_KEY, // Refresh Token의 비밀 키
    { expiresIn: '7d' }, // Refresh Token이 7일 뒤에 만료되도록 설정합니다.
  );

  return refreshToken;
}
  • createAccessToken 함수
    set-token API를 호출할 때 전달받은 id를 jwt 페이로드에 삽입하는 방식으로 Access Token을 생성한다.

  • createRefreshToken 함수
    jwt에 전달받은 id를 삽입하여 Refresh Token을 생성하며, 동시에 Refresh Token과 연관된 사용자 정보를 tokenStorage라는 변수에 저장한다.

Refresh Token의 정보는 어디서 관리해야 할까?

이번 예시에서 Refresh Token은 tokenStorage라는 변수에서 관리하였다. 하지만 이 방식은 실제 프로덕션 환경에선 사용해선 안 된다. 인 메모리 방식을 사용하기 때문에 서버가 재시작 또는 종료될 경우 모든 정보가 사라지기 때문이다.
이러한 문제점을 해결하기 위해 별도의 테이블에서 Refresh Token을 저장하고 관리한다.

5) Access Token을 검증하는 API

GET /tokens/validate - app.js

// app. js

...

/** 엑세스 토큰 검증 API **/
app.get('/tokens/validate', (req, res) => {
  const accessToken = req.cookies.accessToken;

  if (!accessToken) {
    return res
      .status(400)
      .json({ errorMessage: 'Access Token이 존재하지 않습니다.' });
  }

  const payload = validateToken(accessToken, ACCESS_TOKEN_SECRET_KEY);
  if (!payload) {
    return res
      .status(401)
      .json({ errorMessage: 'Access Token이 유효하지 않습니다.' });
  }

  const { id } = payload;
  return res.json({
    message: `${id}의 Payload를 가진 Token이 성공적으로 인증되었습니다.`,
  });
});

// Token을 검증하고 Payload를 반환합니다.
function validateToken(token, secretKey) {
  try {
    const payload = jwt.verify(token, secretKey);
    return payload;
  } catch (error) {
    return null;
  }
}
  • ValidateToken 함수
    • secretKey를 전달받아 서버에서 검증할 비밀 키를 설정
    • Access Token이나 Refresh Token이 서버가 발급한 것인지 검증
    • Access Token이나 Refresh Token의 만료여부를 검증

6) Refresh Token으로 Access Token을 재발급 하는 API

POST /tokens/refresh - app.js

// app. js

...

/** 리프레시 토큰 검증 API **/
app.post('/tokens/refresh', (req, res) => {
  const refreshToken = req.cookies.refreshToken;

  if (!refreshToken)
    return res
      .status(400)
      .json({ errorMessage: 'Refresh Token이 존재하지 않습니다.' });

  const payload = validateToken(refreshToken, REFRESH_TOKEN_SECRET_KEY);
  if (!payload) {
    return res
      .status(401)
      .json({ errorMessage: 'Refresh Token이 유효하지 않습니다.' });
  }

  const userInfo = tokenStorage[refreshToken];
  if (!userInfo)
    return res.status(419).json({
      errorMessage: 'Refresh Token의 정보가 서버에 존재하지 않습니다.',
    });

  const newAccessToken = createAccessToken(userInfo.id);

  res.cookie('accessToken', newAccessToken);
  return res.json({ message: 'Access Token을 새롭게 발급하였습니다.' });
});

// Token을 검증하고 Payload를 반환합니다.
function validateToken(token, secretKey) {
  try {
    const payload = jwt.verify(token, secretKey);
    return payload;
  } catch (error) {
    return null;
  }
}

7) 어떤 인증 방식을 사용해야 하는가?

프로젝트를 신속하게 구현해야 하거나 사용자의 요청에 대한 인증을 최소화해야 한다면 Access Token을 사용한다.
반면 보안성을 중요하게 여기고 서버를 더욱 견고하게 구성해야 한다면 Refresh Token을 사용한다.

profile
안녕하세요

0개의 댓글