로그 미들웨어 ( Log Middleware )는 클라이언트의 모든 요청 사항을 기록해 서버의 상태를 모니터링하기 위한 미들웨어다.

 

로그 미들웨어는 클라이언트의 요청을 기록해 어플리케이션을 모니터링하고 문제가 발생할 때 빠르게 진단할 수 있다.

또한, 로그 데이터는 사용자의 행동을 분석하는 등 데이터 분석 작업에도 활용할 수 있다.

규모가 큰 프로젝트를 진행하게 되면, 화면에 표시되는 모든 로그를 일일이 확인하는 것은 불가능에 가깝다.

이런 경우를 대비해 로그 기능을 지원하는 morgan, winston과 같은 라이브러리를 사용하거나 AWS CloudWatch, Datadog와 같은 외부 모니터링 솔루션 서비스를 이용해 로그를 수집하거나 관리할 수 있다.

 

Datadog와 같은 서비스를 이용해 로그 수집, 로그 분석과 같은 서비스를 빠르게 구현할 수 있다.

 

아래 예시를 통해 클라이언트의 요청을 터미널에 기록하는 간단한 로그 미들웨어를 구현해보자.

우선 winstion 라이브러리를 설치하는것으로 시작!

 

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

 

log.middleware

// src/middlewares/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();

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

  next();
}

 

app.js

// src/app.js

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

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

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

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

 

로그 레벨 ( level )은 로그의 중요도를 나타낸다. 

단순히 클라이언트의 요청 사항을 기록하기 위해 "Info" 레벨을 사용하지만, "error", "warn", "debug" 등 다양한 로그레벨이 있으며, 특정 상황에 따라 출력하는 레벨을 다르게 구현할 수 있다.

 

로그 미들웨어는 클라이언트의 요청이 발생했을 때, 가장 먼저 실행되어야 하는 미들웨어다.

따라서 app.use를 이용한 전역 미들웨어 중에서 가장 최상단에 위치한다.

 

에러 처리 미들웨어

에러 처리 미들웨어 ( Error Handling Middleware )는 Express.js에서 발생한 에러를 통합적으로 처리하기 위한 미들웨어다

 

error-handling.middleware

// src/middlewares/error-handling.middleware.js

export default function (err, req, res, next) {
  // 에러를 출력합니다.
  console.error(err);

  // 클라이언트에게 에러 메시지를 전달합니다.
  res.status(500).json({ errorMessage: '서버 내부 에러가 발생했습니다.' });
}

 

user.router.js

// src/routes/users.router.js

/** 사용자 회원가입 API 에러 처리 미들웨어 **/
router.post('/sign-up', async (req, res, next) => {
  try {
    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: '회원가입이 완료되었습니다.' });
  } catch (err) {
    next(err);
  }
});

 

try catch 구문으로 묶고 error가 나면 next로 error-handling.middleware로 에러 전달

 

 

app.js

// src/app.js

import express from 'express';
import cookieParser from 'cookie-parser';
import LogMiddleware from './middlewares/log.middleware.js';
import ErrorHandlingMiddleware from './middlewares/error-handling.middleware.js';
import UsersRouter from './routes/users.router.js';

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

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

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

 

에러 처리 미들웨어는 클라이언트의 요청이 실패했을 때, 가장 마지막에 실행되어야 하는 미들웨어다.

따라서 app.use를 이용한 전역 미들웨어 중 가장 최하단에 위치한다.

 

에러 처리 미들웨어는 모든 에러를 관리하는 미들웨어, 서버 내부에서 발생한 에러를 상세하게 클라이언트에게 제공한다면 악의적인 사용자에게 공격의 표적이 될 수 있다. 

그러므로, 에러 처리 미들웨어에서는 "서버에서 에러가 발생했습니다."와 같은 추상적인 내용을 클라이언트에게 전달하도록 구현해야한다.

 

에러처리 공식 문서 

https://expressjs.com/ko/guide/error-handling.html

 

Express - Node.js web application framework

 

expressjs.com

 

 

 

delete 뒤에 객체의 속성 값을 입력하면 해당 속성을 제거한다.

 

let test = {a:0, b:1, c:2};
delete test.a;

console.log(test); // { b: 1, c: 2 }

 

https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Operators/delete

 

delete 연산자 - JavaScript | MDN

**delete**는 객체의 속성을 제거합니다. 속성의 값이 객체이고 더 이상 그 객체에 대한 참조가 없다면, 해당 객체는 결국 자동으로 메모리에서 해제됩니다.

developer.mozilla.org

 

정규표현식은 어떤 특정한 규칙을 가진 문자열의 집합을 표현하기 위해 사용하는 형식 언어다.

 

아래 표를 통해 기본적인 정규표현식의 문법을 확인할 수 있다.

메타문자 기능 설명
. 문자 1개의 문자와 일치한다. 단일행 모드에서는 새줄 문자를 제외한다.
[ ] 문자 클래스 "["과 "]" 사이의 문자 중 하나를 선택한다. "|"를 여러 개 쓴 것과 같은 의미다.
예를 들어 [abc]d는 ad, bc, cd를 뜻한다. 또한, "-" 기호와 함께 쓰면 범위를 지정할 수 있다. "[a-z]"는 a부터 z까지 중 하나, "[1-9]"는 1부터 9까지 중의 하나를 의미한다.
[^ ] 부정 문자 클래스 안의 문자를 제외한 나머지를 선택한다. 예를 들면 [^abc]d는 ad, bd, cd는 포함하지 않고 ed, fd 등을 포함한다. [^a-z]는 알파벳 소문자로 시작하지 않는 모든 문자를 의미한다.
^ 처음 문자열이나 행의 처음을 의미한다.
$ 문자열이나 행의 끝을 의미한다.
( ) 하위식 여러 식을 하나로 묶을 수 있다. "abc|adc"와 "a(b|d)c"는 같은 의미를 가진다.
\n 일치하는 n번째 패턴 일치하는 패턴들 중 n번째를 선택하며, 여기에서 n은 1에서 9 중 하나가 올 수 있다.
* 0회 이상 0개 이상의 문자를 포함한다. "a*b"는 "b", "ab", "aab", "aaab"를 포함한다.
{m,n} m회 이상 n회 이하 "a{1,3}b"는 "ab", "aab", "aaab"를 포함하지만, "b"나 "aaab"는 포함하지 않는다.

 

다양한 정규표현식을 살펴보자.

 

숫자만 

/^[0-9]+$/

 

소문자 영어와 숫자로만 이루어진 2자 이상 10자 이하 문자열

 

/^(?=[a-za-z])(?=.*[0-9]).{2,10}$/

 

이메일

/^([a-zA-Z0-9._%-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,6})*$/

 

 jpg, gif, png 확장자를 가진 그림 파일명

/([^\s]+(?=.(jpg|gif|png)).\2)/

 

'IT' 카테고리의 다른 글

[IT] 객체 지향 프로그래밍 ( Object-Oriented Programming, OOP )  (0) 2024.09.26
[IT] Visual Studio Code - 디버그  (0) 2024.09.19
[IT] Access Token, Refresh Token  (1) 2024.09.09
[IT] 인증, 인가  (0) 2024.09.09
[IT] JWT  (0) 2024.09.08

오늘의 목표

더보기

✔️ 개인 과제 구현 


⏱️ 오늘의 일정

10:00 ~ 11:00 - 스탠다드반 CS 강의

11:00 ~ 19:00 - 개인과제 구현

19:00 ~ 20:30 - 챌린지반 수업


📜 스탠다드 반 CS 강의

10:00 ~ 11:00 - 스탠다드 반 CS 강의

 

매주 화목에 진행하는 스탠다드반 수업 강의가 있어서 참여했다.

오늘 내용은 이더넷, 허브, 스위치, Mac 주소에 관해 배웠다.

 

📜 개인과제 구현

11:00 ~ 19:00 개인과제 구현

 

git Commit 목록

 

오늘은 속도가 좀 붙어서 로그인 기능과 캐릭터 생성, 캐릭터 삭제, 캐릭터 조회, 아이템 생성 까지 구현했다.

 

로그인 기능에서 고민이 있던 부분을 포스팅하고자 한다.

처음에는 로그인 할때 아이디와 비밀번호가 맞으면 AccessToken을 생성해 반환해주는 걸로 우선 구현을 했다.

 

이렇게 구현하다가는 만약 재로그인한 대상이 해당 유저가 아닐 경우에는? 어떻게 해야할까? 라고 생각이 들어서 재로그인할때는 AccessToken을 살펴보기로 하고 구현했다.

 

내가 생각한 로그인 방식은 다음과 같다.

 1. 로그인 요청한 대상이 AccessToken을 가지고 오지 않았으면

  1.1 처음 발급 하는것이므로 AccessToken과 RefreshToken을 발급하고 로그인 성공 처리한다.

 2. 로그인 요청한 대상이 AccessToken을 가지고 오면

  2.1 해당 AccessToken이 유효한지 판단한다.

   2.1.1 AccessToken이 유효하면 로그인 성공 처리한다.

   2.1.2 AccessToken이 유효하지 않으면, RefreshToken을 가지고 있는지 확인한다.

    2.1.2.1 RefreshToken을 가지고 있으면, RefreshToken이 유효한지 판단한다.

     2.1.2.1.1 RefreshToken이 유효하면, AccessToken을 재발급하고 로그인 성공 처리한다.

     2.1.2.1.2 RefreshToken이 유효하지 않으면, RefreshToken과 AccessToken을 재발급하고 로그인 성공 처리한다.

    2.1.2.2 RefreshToken을 가지고 있지않으면, RefreshToken을 발급하고 AccessToken을 재발급하고 로그인 성공처리

 

위처럼 생각하고 구현해보니 어떻게해도 무조건 로그인은 성공처리가 되는것이 아닌가?

 

다시한번 생각하니 로그인 처리에서는 AccessToken과 RefreshToken을 발급 하는것이 목적인것이고 ( 아이디와 비밀번호만 맞으면 ), 클라이언트에서는 AccessToken을 가지고 로그인을 제외한 다른 작업을 할때, '검증' 하는 것을 목적으로 삼으면 되는것이니까.

 

로그인 처리에서는 앞서 언급했듯이 아이디와 비밀번호만 맞으면 로그인 성공처리로 해주면 되겠구나라는 생각이 들어서 그대로 진행했다.

 

로그인 처리를 제외하고는 다른 캐릭터 생성, 캐릭터 삭제 등 인증이 필요한 곳에는 AccessToken으로 검증을 해 해당 값이 유효하지 않으면 바로 에러를 반환하도록 구현했다.

 

위처럼 생각한 것이 맞는지는 확신할 수 없다.

 

https://velog.io/@chuu1019/Access-Token%EA%B3%BC-Refresh-Token%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B4%EA%B3%A0-%EC%99%9C-%ED%95%84%EC%9A%94%ED%95%A0%EA%B9%8C

 

🧐 Access Token과 Refresh Token이란 무엇이고 왜 필요할까?

JWT 토큰은 유저의 신원이나 권한을 결정하는 정보를 담고 있는 데이터 조각이다. JWT 토큰은 비밀키로 암호화되어 있기에 비교적 안전하다. 그런데 탈취 당했을 때가 문제다!! 어떻게 위험을 최

velog.io

위 글을 보면 맞는거 같기도 하고.. 애매..

 

내일 과제를 완료하면 따로 튜터님한테 가서 물어보긴 해야겠다.

 

로그인

// 로그인
router.post('/sign-in', async (req, res, next) => {
    const { id, password } = req.body;
    const user = await prisma.users.findFirst({
        where: { id }
    });

    // 아이디와 비밀번호 검사
    if (!user) {
        return res.status(401).json({ message: `${id}은 존재하지 않는 아이디 입니다.` });
    }
    else if (!(await bcrypt.compare(password, user.password))) {
        return res.status(401).json({ message: `비밀번호가 일치하지 않습니다.` });
    }

    // 재로그인할때 필요한 accessToken을 가져옴
    const c2sAccessToken = req.cookies.accessToken;    

    let s2cAccessToken = 0;
    let s2cRefreshToken = 0;

    // accessToken 발급 여부 판단
    let newAccessToken = false;

    if (!c2sAccessToken) // 액세스 토큰이 없음
    {
        // 액세스 토큰 새 발행
        newAccessToken = true;
    }
    else // 액세스 토큰이 있음
    {        
        // 액세스 토큰 유효한지 확인
        // 유효하면 로그인 성공
        const [tokenType, token] = c2sAccessToken.split(' ');

        // tokenType이 맞는지 확인
        if (tokenType !== process.env.TOKEN_TYPE_CHECK) {
            return res.status(404).send('not found');
        }

        // 다른 유저의 AccessToken을 가지고 왔을 경우
        // 현재 로그인한 유저를 대상으로 AccessToken 재발행
        const myToken = CreateAccessToken(id);
        if (myToken !== c2sAccessToken)
        {
            newAccessToken = true;
        }
        else
        {
            // 나의 AccessToken을 가지고 오면 유효한지 확인
            const payload = ValidateToken(token, process.env.ACCESS_TOKEN_SECRET_KEY);
            if (!payload) // 액세스 토큰이 유효하지 않음
            {
                // 액세스 토큰 새 발행
                newAccessToken = true;
            }
        }        
    }

    // 액세스 토큰 새로 발급
    if (newAccessToken) {
        // DB에서 리프레시 토큰을 읽어옴
        const dbRefreshToken = await prisma.refreshTokens.findFirst({
            where: { userId: id },
            select: {
                token: true
            }
        });        

        // DB에 리프레시 토큰이 없음
        if (dbRefreshToken == null)
        {
            // 액세스 토큰과 리프레시 토큰을 새로 발급
            s2cAccessToken = CreateAccessToken(id);
            s2cRefreshToken = CreateRefreshToken(id);

            // 리프레시 토큰을 DB에 저장
            const newDBRefreshToken = await prisma.refreshTokens.create({
                data: {
                    userId: id,
                    token: s2cRefreshToken
                }
            });   

            // 쿠키 전달
            res.cookie('accessToken', s2cAccessToken);
            res.cookie('refreshToken', s2cRefreshToken);
        }
        else 
        {
            // DB에 리프레시 토큰이 있음

            const [tokenType, token] = dbRefreshToken.token.split(' ');

            // tokenType이 맞는지 확인
            if (tokenType !== process.env.TOKEN_TYPE_CHECK) {
                return res.status(404).send('not found');
            }

            // 리프레시 토큰이 유효한지 확인
            const dbRefreshTokenCheck = ValidateToken(token, process.env.REFRESH_TOKEN_SECRET_KEY);
            if (dbRefreshTokenCheck) // 리프레티 토큰이 유효함
            {
                // 액세스 토큰 발급
                s2cAccessToken = CreateAccessToken(id);                

                // 액세스 토큰 전달
                res.cookie('accessToken', s2cAccessToken);
            }
            else // 리프레티 토큰이 유효하지 않음
            {
                // 액세스 토큰과 리프레시 토큰을 새로 발급
                s2cAccessToken = CreateAccessToken(id);
                s2cRefreshToken = CreateRefreshToken(id);

                // 리프레시 토큰을 DB에 업데이트
                const newDBRefreshToken = await prisma.refreshTokens.update({
                    where: { userId: id },
                    data: {                        
                        token: s2cRefreshToken
                    }
                }); 

                // 쿠키 전달
                res.cookie('accessToken', s2cAccessToken);
                res.cookie('refreshToken', s2cRefreshToken);
            }
        }
    }

    return res
        .status(200)
        .json({ message: `${id}로 로그인 성공` });
});

 

 

캐릭터 생성, 캐릭터 삭제, 캐릭터 조회에서는 authMiddleware를 통해 검증하도록 구현했다.

export default async function (req, res, next) {
    const c2sAccessToken = req.cookies.accessToken;
    if (c2sAccessToken === undefined) {
        return res.status(404).json({ message: 'not found' });
    }

    const [tokenType, accessToken] = c2sAccessToken.split(' ');
    if (tokenType !== process.env.TOKEN_TYPE_CHECK) {
        return res.status(401).json({ message: '토큰 타입이 일치하지 않습니다.' });
    }

    // 토큰 검증
    const decodedToken = ValidateToken(accessToken, process.env.ACCESS_TOKEN_SECRET_KEY);
    if (!decodedToken) {
        return res.status(401).json({ message: '토큰이 만료되었습니다. 로그인을 다시 해주세요' })
    }

    const id = decodedToken.id;

    const user = await prisma.users.findFirst({
        where: {
            id : id
        }
    });

    if (!user) {
        return res.status(401).json({message:`${id} 사용자를 찾을수 없습니다.`})
    }

    req.user = user;

    next();    
}

 

로그인한 유저가 AccessToken을 들고오지 않았을 경우, TokenType이 일치하지 않을 경우,

그리고 Token이 만료할 경우 에러를 반환하도록했다.

오늘의 목표

더보기

✔️ 프로그래머스 코테 문제 풀기

✔️ Node.js 강의 듣기


⏱️ 오늘의 일정

9:00 ~ 10:00 - 프로그래머스 코테 문제 풀기

10:00 ~ 17:00 - Node.js 강의

17:00 ~ 21:00 - 개인과제

 

 

📜 프로그래머스 코테 문제 풀기

9:00 ~ 10:00 - 프로그래머스 코테 문제 풀기

 

오늘도 알고리즘 코드카타를 진행해 문제를 풀었다.

 

옹알이(2)

https://github.com/YamSaeng/AlgorithmCodingTest/tree/main/%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%A8%B8%EC%8A%A4/1/133499.%E2%80%85%EC%98%B9%EC%95%8C%EC%9D%B4%E2%80%85%EF%BC%882%EF%BC%89

 

AlgorithmCodingTest/프로그래머스/1/133499. 옹알이 (2) at main · YamSaeng/AlgorithmCodingTest

This is an auto push repository for Baekjoon Online Judge created with [BaekjoonHub](https://github.com/BaekjoonHub/BaekjoonHub). - YamSaeng/AlgorithmCodingTest

github.com

 

처음에는 아기가 말할 수 있는 모든 단어를 우선 조합하고 진행하려 했는데, 옳은 방법 같지가 않아서

좀더 고민을 하다가 아래처럼 풀게 되었다.

 

아기가 말할 수 있는 단어가 포함되어 있는지 찾는 방법이다.

뒤에 babyStrDuplicationCheck는 같은 발음이 연속되는 것을 방지한다.

function solution(babbling) {
    let answer = 0;    

    for (let i = 0; i < babbling.length; i++) {

        let isCheck = true;
        let babyStrDuplicationCheck = 0;

        for (let j = 0; j < babbling[i].length; j++) {           

            if (babbling[i].substr(j, 3) == "aya" && babyStrDuplicationCheck != 1) {
                babyStrDuplicationCheck = 1;
                j += 2;
            }
            else if (babbling[i].substr(j, 2) == "ye" && babyStrDuplicationCheck != 2) {
                babyStrDuplicationCheck = 2;
                j += 1;
            }
            else if (babbling[i].substr(j, 3) == "woo" && babyStrDuplicationCheck != 3) {
                babyStrDuplicationCheck = 3;
                j += 2;
            }
            else if (babbling[i].substr(j, 2) == "ma" && babyStrDuplicationCheck != 4) {
                babyStrDuplicationCheck = 4;
                j += 1;
            }
            else {
                isCheck = false;
                break;
            }            
        }

        if (isCheck == true) {
            answer++;
        }
    }

    return answer;
}

 

 

📜 Node.js 강의

10:00 ~ 17:00 - Node.js 강의

 

2024.09.09 - [IT] - [IT] 인증, 인가

 

[IT] 인증, 인가

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

program-yam.tistory.com

 

 

2024.09.09 - [Javascript/실습] - [Javascript][실습][게시판 프로젝트] 로그인, 회원가입 API

 

[Javascript][실습][게시판 프로젝트] 로그인, 회원가입 API

Express.js를 이용한 게시판 프로젝트를 만들기 전에, 우선 app.js에 기본적인 토대를 만들고 간단한 API를 구현해보자. 모든 Router에서 Prisma를 사용할 수 있도록 utils/prisma/index.js 파일을 생성한다. ap

program-yam.tistory.com

 

2024.09.09 - [IT] - [IT] Access Token, Refresh Token

 

[IT] Access Token, Refresh Token

Access Token Access Token은 사용자의 인증( 예) 로그인 )이 완료된 후 해당 사용자를 인증하는 용도로 발급하는 토큰을 말한다.앞서 쿠키 ( Cookie )에 jwt를 설정하고, 지정된 만료 시간이 지나면 인증이

program-yam.tistory.com

 

2024.09.09 - [Javascript/실습] - [Javascript][실습] Access Token, Refresh Token 발급

 

[Javascript][실습] Access Token, Refresh Token 발급

Access Token과 Refresh Token을 발급해보자. # yarn을 이용해 프로젝트를 초기화합니다.yarn init -y# express, jsonwebtoken, cookie-parser 패키지를 설치합니다.yarn add express jsonwebtoken cookie-parserpackage.json에 "type":"mod

program-yam.tistory.com

 

2024.09.09 - [Javascript] - [Javascript] .env ( 환경 변수 읽어 오기 )

 

[Javascript] .env ( 환경 변수 읽어 오기 )

.env 파일에 설정한 환경변수 값을 읽어 오고 싶을 경우 dotenv 패키지를 설치해 읽어오면 편하다. yarn add dotenv 위 명령어를 이용해 dotenv 패키지를 설치한다. .envACCESS_TOKEN_SECRET_KEY="YamSaeng52"REFRESH_T

program-yam.tistory.com

 

인증, 인가에 대한 개념을 배우고 이를 토대로 실습으로 로그인과 회원가입 API를 작성했다.

Access Token과 Refresh Token을 배우고 이를 토대로 실습으로 직접 Access Token, Refresh Token을 발급했다.

 

📜 개인과제

17:00 ~ 21:00 - 개인과제

 

개인과제를 진행했다. 오늘은 회원가입 까지만 우선 완료하고 내일 캐릭터 생성 까지 끝낼 예정이다.

 

회원 가입 router

router.post('/sign-up', async (req, res, next) => {
    const { id, password, confirmPassword } = req.body;
    const isExistUser = await prisma.users.findFirst({
        where: {
            id,
        },
    });

    const engNumIdRule = /^(?=[a-za-z])(?=.*[0-9]).{2,10}$/;
    if (!engNumIdRule.test(id)) {
        return res.status(409).json({ message: '소문자 영어와 숫자를 조합해 입력하세요 ( 최소 2글자, 최대 10글자 )' });
    }

    if (confirmPassword === undefined) {
        return res.status(409).json({ message: '비밀번호 확인을 입력하세요' });
    }

    if (password.length < 6) {
        return res.status(409).json({ message: '비밀번호는 최소 6자 이상이 되어야 합니다.' });
    }

    if (password != confirmPassword) {
        return res.status(409).json({ message: '비밀번호와 비밀번호 확인이 일치하지 않습니다.' });
    }

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

    const hashedPassword = await bcrypt.hash(password, 10);

    const user = await prisma.users.create({
        data: {
            id,
            password: hashedPassword,
        },
    });

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

 

예전에 배운 정규표현식을 이용해 유저한테 입력받은 아이디를 판별햇다.

영어 소문자 + 숫자 조합으로 2자 이상 10자 이하 만 아이디로 등록할 수 있도록 했다.

 

2024.09.10 - [IT] - [IT] 정규표현식

 

[IT] 정규표현식

정규표현식은 어떤 특정한 규칙을 가진 문자열의 집합을 표현하기 위해 사용하는 형식 언어다. 아래 표를 통해 기본적인 정규표현식의 문법을 확인할 수 있다.메타문자기능설명.문자1개의 문

program-yam.tistory.com

 

이번에 찾은 김에 글로 정리했다.

.env 파일에 설정한 환경변수 값을 읽어 오고 싶을 경우 dotenv 패키지를 설치해 읽어오면 편하다.

 

yarn add dotenv

 

위 명령어를 이용해 dotenv 패키지를 설치한다.

 

.env

ACCESS_TOKEN_SECRET_KEY="YamSaeng52"
REFRESH_TOKEN_SECRET_KEY="JungSungWon52"

 

 

.env파일에 위와 같이 선언한 변수 값을 읽어오고 싶을때 아래와 같이 해주면 된다.

import dotenv from "dotenv";

dotenv.config();

const accessTokenSecretKey = process.env.ACCESS_TOKEN_SECRET_KEY;
const refreshTokenSecretKey = process.env.REFRESH_TOKEN_SECRET_KEY;

console.log(accessTokenSecretKey);
console.log(refreshTokenSecretKey);

 

출력 모습

Access Token과 Refresh Token을 발급해보자.

 

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

# express, jsonwebtoken, cookie-parser 패키지를 설치합니다.
yarn add express jsonwebtoken cookie-parser
  • package.json에 "type":"module" 추가

 

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, '포트로 서버가 열렸어요!');
});

 

 

Refresh Token과 Access Token을 발급하는 API 만들어보기

POST /tokens 의 주소를 갖는 API 생성

// 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!');
});

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

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

    // accessToken 발급
    const accessToken = createAccessToken(id);
    // refreshToken 발급
    const refreshToken = createRefreshToken(id);

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

        // 사용자의 User Agent 정보를 저장합니다.
        // 특정 클라이언트가 어떤 방식으로 서버에 요청했는지를 알수 있는 정보
        // 예를 들어 firefox를 이용했는지, chrome을 이용했는지, 모바일로 chrome을 실행했는지 등        
        userAgent: req.headers['user-agent'], 
    };

    //console.log(tokenStorage);

    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;
}

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

 


 

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

위 예시에서는 tokenStorage 라는 변수에서 관리했지만, 이는 인 메모리 방식을 사용하기 때문에 서버가 재시작 또는 종료될 경우 모든 정보가 사라지게 된다.

따라서 별도의 테이블에서 Refresh Token을 저장하고 관리해야한다. 

이렇게 하면, Refresh Token 검증 작업을 MySQL과 같은 데이터베이스를 조회함과 동시에 함께 처리할 수 있다.

 

Refresh Tokens Table

Name 타입 ( Type ) NULL default 비고
tokenId ( PK ) INTEGER NOT NULL AUTO_INCREMENT 토큰의 기본 키
userId ( FK ) INTEGER NOT NULL   사용자의 기본 키
token STRING NOT NULL   리프레시 토큰
expiresAt DATETIME NOT NULL   만료 날짜
createdAt DATETIME NOT NULL 현재 시간 생성 날짜
  • 이 외에도 ip 또는 user-Agent와 같은 정보를 추가할 수 있다.

 

Access Token 검증 API

서버에서 발급받은 Access Token을 검증하는 GET /tokens/validate API를 만들어보자.

 

// 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 함수 추가 설명

ValidaeToken 함수는 제공된 토큰이 유효한지 여부를 검증하는 역할을 담당한다.

  • secretkey를 전달받아, 서버에서 검증할 비밀 키를 설정한다.
  • Access Token 이나 Refresh Token이 우리가 발급한 것인지 검증한다.
  • Access Token 이나 Refresh Token의 만료 여부를 검증한다.

사용자가 Cookie를 전달할 때, Access Token이 없다면 에러가 발생한다.

  • { "message": "Access Token이 존재하지 않습니다." }

사용자가 전달한 Access Token이 유효하지 않을 경우 에러가 발생한다.

  • { "message": "Access Token이 유효하지 않습니다." }

 

사용자가 Access Token을 이용해 API를 호출하면, 해당 토큰의 상태에 따라 적절한 응답 ( Response )이 반환되는 것을 확인 할 수 있다.

Access Token이 정상적으로 인증되면, 응답에서 페이로드 값을 확인할 수 있다.

 


 

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

서버에서 발급받은 Access Token을 검증하는 POST /tokens/refresh API를 만들어보자.

// 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;
  }
}

 

사용자가 Cookie를 전달할 때, Refresh Token이 없다면 에러가 발생한다.

  • { "message": "Refresh Token이 존재하지 않습니다." }

사용자가 전달한 Refresh Token이 유효하지 않을 경우 에러가 발생한다.

  • { "message": "Refresh Token이 유효하지 않습니다." }

Refresh Token이 유효하지만, 서버에 해당 토큰 정보가 없을 경우 에러가 발생한다.

  • { "message": "Refresh Token의 정보가 서버에 존재하지 않습니다." }

사용자가 Refresh Token을 이용해 API를 호출하면, 해당 토큰의 상태에 따라 적절한 응답 ( Response )이 반환되는 것을 확인할 수 있다.

만약, Refresh Token이 정상적으로 인증되면, 사용자에게 Access Token을 담은 Cookie를 전달하는 것을 확인할 수 있다.

 


 

 

Access Token

Access Token

 

Access Token은 사용자의 인증( 예) 로그인 )이 완료된 후 해당 사용자를 인증하는 용도로 발급하는 토큰을 말한다.

앞서 쿠키 ( Cookie )에 jwt를 설정하고, 지정된 만료 시간이 지나면 인증이 만료되는 구조 또한 Access Token이라고 부를 수 있다.

인증 요청시 Access Token을 사용하면, 토큰을 생성할 때 사용한 비밀키 ( Secret Key )로 인증을 처리하게 된다.

이 방식은 복잡한 설계나 여러 분기 처리 없이 코드를 구현할 수 있다는 장점을 가지고 있다.

 

Access Token은 Statelss ( 무상태 ) 즉, Node.js 서버가 재시작되더라도 동일하게 작동한다. 이는 jwt를 이용해 사용자의 인증 여부는 확인할 수 있지만, 처음 토큰을 발급한 사용자가 정말 그 사용자인지는 확인할 수 없다.

Access Token은 그 자체로도 사용자 인증에 필요한 모든 정보를 가지고 있다. 그렇기 때문에 토큰을 가지고 있는 시간이 늘어날 수록, 탈취되었을 때 피해 규모는 매우 크다.

토큰이 탈취되었다 하더라도, 서버에서는 해당 토큰이 탈취된 토큰인지 알 수 없고, 강제로 토큰을 만료시킬 수도 없다. 

따라서 서버는 언제나 토큰이 탈취될 수 있다는 가정 하에, 피해를 최소화할 수 있는 방향으로 개발을 진행해야 한다.

 


 

Refresh Token

Refresh Token

 

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

 

Refresh Token은 사용자의 인증 정보를 검증하는데 사용되고, 이를 서버에서 관리한다.

서버는 Refresh Token디코딩해 사용자의 정보를 확인하게 된다. 이 방식은 필요한 경우 서버에서 강제로 토큰을 만료시킬 수 있으며, 사용자의 인증 상태를 언제든지 서버에서 제어할 수 있다는 장점을 가지고 있다.

왜 직접 Access Token을 발급하지 않고, Refresh Token을 통해 Access Token을 발급하는 걸까?

이는 토큰이 탈취당한 경우에 대비해 피해를 최소화하기 위함이다.

일상생활에서 흔히 사용하는 OTP처럼, 사용자의 인증 정보는 짧은 시간동안만 사용되어야 한다. 주기적으로 토큰을 재발급함으로써, 토큰이 유출되더라도 그 피해가 오랜 시간 동안 지속되는 것이 아니라, 짧은 기간 동안만 사용 가능하도록 제한피해를 최소화 할 수 있게 된다.

서버에서는 언제나 토큰이 탈취될 수 있다는 사실을 항상 인지하고, 탈취를 막는 것이 어려운 상황이라면, 탈취된 토큰을 사용할 수 있는 기간을 줄임으로써 피해를 방지해야 한다.

'IT' 카테고리의 다른 글

[IT] Visual Studio Code - 디버그  (0) 2024.09.19
[IT] 정규표현식  (1) 2024.09.10
[IT] 인증, 인가  (0) 2024.09.09
[IT] JWT  (0) 2024.09.08
[IT] 쿠키와 세션  (0) 2024.09.08

일반적으로, 사용자의 비밀번호를 데이터베이스에 저장할 때, 보안을 위해 비밀번호를 평문으로 저장하지 않고 암호화해 저장한다.

 

bcrypt 모듈은 입력받은 데이터를 특정 암호화 알고리즘을 이용해 암호화 및 검증을 도와주는 모듈이다.

aaaa4321 이라는 비밀번호를 bcrypt를 이용해 암호화하면 특정한 문자열로 변환된다.

이 변환된 문자열은 단방향 암호화되어 원래의 비밀번호 ( aaaa4321 )로 복구할 수 없게 된다.

하지만, 입력된 비밀번호가 암호화된 문자열과 일치하는지를 비교할 수 있다.

이를 통해 사용자의 비밀번호가 올바른지, 아닌지 검증할 수 있게 되는것이다.

 

bcrypt 설치

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

 

bcrypt 암호화

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

 

 

bcrypt 복호화

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

 

Express.js를 이용한 게시판 프로젝트를 만들기 전에, 우선 app.js에 기본적인 토대를 만들고 간단한 API를 구현해보자.

 

모든 Router에서 Prisma를 사용할 수 있도록 utils/prisma/index.js 파일을 생성한다.

 

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, '포트로 서버가 열렸어요!');
});

 

Primsa 초기화

// 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

 

회원가입 API

회원가입 API 비즈니스 로직

  1. email, password, name, age, gender, profileImage를 body로 전달받는다.
  2. 동일한 email을 가진 사용자가 있는지 확인한다.
  3. Users 테이블에 email, password를 이용해 사용자를 생성한다.
  4. UserInfos 테이블에 name, age, gender, profileImage를 이용해 사용자 정보를 생성한다.

회원가입 API사용자사용자 정보가 1:1 관계를 가진 것을 바탕으로 비즈니스 로직이 구현된다.

사용자 정보사용자가 존재하지 않을 경우 생성될 수 없으므로, 전달받은 인자값을 이용해 사용자사용자 정보 순서대로 회원가입을 진행해야한다.

routes/users.router.js 파일을 생성하고, UsersRouter를 app.js 전역 미들웨어에 등록 후 회원가입 API를 구현해보자.

 

UsersRouter를 등록한 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;

 

사용자만 생성되고, 사용자 정보 생성에 실패할 경우

현재는 Users 테이블에 사용자를 생성하고, UserInfos 테이블에 userId를 이용해 사용자의 정보를 저장한다.

만약, 사용자 정보 저장에 실패하게 될 경우 Users 테이블에는 사용자가 존재하지만, UserInfos 테이블의 사용자 정보는 저장되지 않는 상태로 남아있는다.

이런 문제를 해결하기 위해 MySQL과 같은 RDBMS에서는 트랜잭션 ( Transaction )이라는 개념이 도입됐는데, Prisma 또한 동일하게 사용할 수 있기 때문에 다음 글에서 작성해보도록 하겠다.

 

bcrypt ( 단방향 암호화, 복호화 )

2024.09.09 - [Javascript] - [Javascript] bcrypt ( 단방향 암호화, 복호화 )

 

[Javascript] bcrypt ( 단방향 암호화, 복호화 )

일반적으로, 사용자의 비밀번호를 데이터베이스에 저장할 때, 보안을 위해 비밀번호를 평문으로 저장하지 않고 암호화해 저장한다. bcrypt 모듈은 입력받은 데이터를 특정 암호화 알고리즘을 이

program-yam.tistory.com

 

회원가입 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: '회원가입이 완료되었습니다.' });
});

 

회원가입 API 리팩토링이 완료되었으면, API Client를 이용해 해당 API를 호출해보자.

Users 테이블의 password 항목이 이전과 다르게 암호화된 값으로 작성되어 있을 것이다.

 

암호화된 사용자의 비밀번호


 

로그인 API

로그인 API는 bcrypt로 암호화된 사용자 비밀번호를 인증하는 방식이다.

클라이언트가 제공한 비밀번호와 데이터베이스에 저장된 암호화된 비밀번호를 검증해 구현해보자.

 

로그인 API 비즈니스 로직

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

로그인 API는 클라이언트가 전달한 정보를 바탕으로 사용자를 확인한다. 클라이언트로부터 받은 email과 password를 사용해 데이터베이스에 저장된 사용자를 검증하고, 검증에 성공하면 JWT를 담고있는 쿠키를 생성해 반환한다.

클라이언트는 로그인 이후 요청부터 쿠키를 함께 보내면, 이를 통해 서버에서는 해당 사용자를 식별하고, 인증 ( Authentication ), 인가 ( Authorization ) 과정을 거친다.

 

로그인 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: '로그인 성공' });
});

 

발급한 쿠키의 모습

 


사용자 인증 미들웨어

사용자 인증 미들웨어는 클라이언트로 부터 전달받은 쿠키를 검증하는 작업을 수행한다.

클라이언트가 제공한 쿠키에 담겨있는 JWT를 이용해 사용자를 조회하도록 구현해보자.

 

사용자 인증 미들웨어 비즈니스 로직

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

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

이렇게, JWT를 통해 사용자를 인증하는 것을 인증 ( Authentication )이라고 부른다.

클라이언트는 인증 과정을 전부 통과하였을 때에 로그인된 사용자만 사용할 수 있는 게시글 작성 API, 사용자 정보 조회 API와 같은 권한이 필요한 API를 사용할 수 있게 된다. 이 사용자의 권한을 확인하는 과정을 인가 ( Authorization ) 과정이라고 부른다. ( 사용자 인증 미들웨어는 인증 기능만 제공한다. 이후 관리자 권한, 등급별 권한이 필요하면, 별도의 인가 과정을 추가해야한다 )

 

사용자 인증 미들웨어

// 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('토큰이 존재하지 않습니다.');

	// Bearer이 tokenType에 들어가고, %20을 건너 뛴 후에 이후 내용이 token으로 들어간다.
    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 ?? '비정상적인 요청입니다.' });
    }
  }
}

 

사용자 정보 조회 API

사용자 정보 조회 API는 일반적으로 다른 사용자의 정보를 조회하기 위해 사용한다.

 

사용자 정보 조회 API 비즈니스 로직

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

사용자 정보 조회 API는 단순히 Users 테이블 하나만 조회를 하는 것이 아니라, UserInfos 테이블을 함께 조회한다.

그렇기 때문에, 각각의 테이블을 1번씩 조회하게 되어 총 2번의 조회를 하게되는 문제가 발생한다.

이런 문제를 해결하기 위해 Prisma에서는 중첩 select 라는 문법을 제공한다.

 

사용자 정보 조회 API

// 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 });
});

 

Prisma 에서 연관 관계 테이블을 조회하는 방법

Prisma의 select 문법은 Query 메서드의 옵션으로 사용할 수 있다.

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,
      },
    },
  },
});
  • select 내에 또다른 select가 있는데, 이를 중첩 select 문법이라 한다.
  • 중첩 select 는 SQL의 JOIN과 동일한 역할을 수행한다.
  • 중첩 select 문법을 사용하기 위해서는 Prisma model에서 @relation() 과 같이 관계가 설정이 되어야 한다.
  • @relation() 으로 Prisma는 현재 모델에서 참조하는 외래 키를 인식하고, SQL을 생성할 수 있게 된다.
  • 현재 테이블과 연관된 테이블의 모든 컬럼을 조회하고 싶으면, include 문법으로도 조회할 수 있다.

중첩 select 문법과 include 문법에 대한 자세한 정보 : https://www.prisma.io/docs/concepts/components/prisma-client/relation-queries

 

Relation queries (Concepts) | Prisma Documentation

Prisma Client provides convenient queries for working with relations, such as a fluent API, nested writes (transactions), nested reads and relation filters.

www.prisma.io

 

+ Recent posts