본문 바로가기
TIL

Nodejs 강의 숙련 게시판 프로젝트(5) 회원가입 / 로그인 / 사용자 인증,조회

by 황민도 2024. 5. 24.

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

이어서 오늘은 인증, 인가에 대해 살펴보고 게시판 프로젝트를 이어가 보도록 하겠다. 

 

인증(Authentication) : 

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

인가(Authorization) :

이미 인증된 사용자가 특정 리소스에 접근하거나 특정 작업을 수행할 수 있는 권한이 있는지를

검증하는 작업을 뜻한다. 예를 들어 놀이공원에서 자유 이용권을 소지하고 있는지 확인하는 단계

인증된 사용자 즉, 로그인 된 사용자만 게시글을 작성할 수 있는지 검증한다면, 이 과정을 인가

과정이라고 부름. 인가 기능은 사용자 인증 미들웨어를 통해서 구현할 예정.

 

 

[ 회원가입 기능구현  ]

 

회원가입 기능 디렉터리 구성

 

 

< README.md >

# history
1. yarn init -y
2. yarn add express prisma @prisma/client cookie-parser jsonwebtoken
3. yarn add -D nodemon // nodemon 라이브러리를 DevDependency로 설치합니다.
4. npx prisma init
5. < package.json > "type": "module",
6. .env 경로 설정
7. provider 기본설정 mysql 로 변경
    generator client {
    provider = "prisma-client-js"
    }

    datasource db {
    provider = "mysql"
    url      = env("DATABASE_URL")
    }
8. npx prisma db push (schema.prisma 를 작성한후 실제 데이터 베이스를 mysql에 upload해 준다.)
9. yarn run nodemon src/app.js // 코드를 작성하고 잘 작동하는지 확인을 위해 nodemon으로 서버를 열어줌

 

 

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

 

 

< prisma / schema.prisma >   

// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model Users {
  userId Int @id @default(autoincrement()) @map("userId")
  email String @unique @map("email")
  password String @map("password")

  createdAt DateTime @default(now()) @map("createdAt")
  updatedAt DateTime @updatedAt @map("updatedAt")

  UserInfos UserInfos?
  // 사용자(Users) 테이블과 사용자 정보(UserInfos) 테이블이 1:1
  // 1명의 사용자는 1개의 사용자 정보를 가진다.
  Posts Posts[]
  // 사용자(Users) 테이블과 게시글(Posts) 테이블이 1:N
  // 1명의 사용자는 여러개의 게시글을 작성할 수 있다. (배열사용)
  Comments Comments[]
  // 1명의 사용자는 여러개의 댓글을 작성할 수 있다. (배열사용)

  @@map("Users")
}

model UserInfos {
  userInfoId Int @id @default(autoincrement()) @map("userInfoId")
  UserId Int @unique @map("UserId")
  name String @map("name")
  age Int? @map("age") 
  // null을 허용 할때는 ? 를 사용 , Optional Parameter(?)
  gender String @map("gender")
  profileImage String? @map("profileImage")

  createdAt DateTime @default(now()) @map("createdAt")
  updatedAt DateTime @updatedAt @map("updatedAt")

  User Users @relation(fields: [UserId], references: [userId], onDelete: Cascade)
  // Users 테이블과 관계를 설정합니다.
  // 무결성 제약조건 사용자가 삭제된다면 사용자 정보 또한 같이 삭제되도록 Cascade 설정
  // onDelete | onUpdate 참조하는 부모모델이 삭제 or 수정될 경우 자식모델이 어떤행위를 할 지 설정합니다.

  @@map("UserInfos")
}

model Posts {
  postId Int @id @default(autoincrement()) @map("postId")
  UserId Int @map("UserId")
  // 사용자(Users) 테이블을 참조하는 외래키
  title String @map("title")
  content String @db.Text @map("content") 
  // @db.Text 긴 텍스트 사용시, 텍스트 데이터를 효율적으로 저장하고 사용할 수 있음.
  createdAt DateTime @default(now()) @map("createdAt")
  updatedAt DateTime @updatedAt @map("updatedAt")

  User Users @relation(fields: [UserId], references: [userId], onDelete: Cascade)
  // Users 테이블 관계를 설정합니다.
  Comments Comments[]
  // 1개의 게시글에서 여러개의 댓글을 작성할 수 있다.

  @@map("Posts")
}

model Comments {
  commentId Int @id @default(autoincrement()) @map("commentId")
  UserId Int @map("UserId")
  PostId Int @map("PostId")
  content String @map("content")

  createdAt DateTime @default(now()) @map("createdAt")
  updatedAt DateTime @updatedAt @map("updatedAt")

  User Users @relation(fields: [UserId], references: [userId], onDelete: Cascade)
  Post Posts @relation(fields: [PostId], references: [postId], onDelete: Cascade)

  @@map("Comments")
}

 

 

 

< 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) => {
    // 1. `email`, `password`, `name`, `age`, `gender`, `profileImage`를 **body**로 전달받습니다.
    const { email, password, name, age, gender, profileImage } = req.body;

    // 2. 동일한 `email`을 가진 사용자가 있는지 확인합니다.
    const isExistUser = await prisma.users.findFirst({
        where: { email },  
    })
    if(isExistUser) {
        return res.status(409).json({ message: '이미 존재하는 이메일 입니다.' });
        // 실제 서버에서 동일한 사용자가 존재했을때 409 사용.
    }

    // 3. **Users** 테이블에 `email`, `password`를 이용해 사용자를 생성합니다.
    const user = await prisma.users.create({
        data: {
            email,
            password
        }
    });

    // 4. **UserInfos** 테이블에 `name`, `age`, `gender`, `profileImage`를 이용해 사용자 정보를 생성합니다.
    const userInfo = await prisma.userInfos.create({
        data: {
            UserId: user.userId,
            name,
            age,
            gender: gender.toUpperCase(),
            // 전달받은 gender를 전부 대문자로 치환한다.
            profileImage
        }
    });

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

export default router;

 

여기서 알고가야 할 한가지

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

위와같이 express 는 중괄호로 묶이지 않았고

prisma 같은 경우 중괄호로 묶여있다.

그럼 중괄호를 신경 안쓰고 코딩을 하면 어떻게 될까?

SyntaxError 가 발생하고 만다.

 

그러면 왜 express 는 중괄호를 사용하지 않아도 되고 prisma 는 중괄호를 사용해야 할까?

 

- Default Export와 Named Export -

express 같은경우 export 를 할때 export default express 로 하였다.

허나 prisma 는 Named Export 인

export const prisma = new PrismaClient(); 이런 변수 선언과 내보내기를 바로하는 형식으로 작성되거나

export { prisma }; 이미 선언된 변수를 중괄호에 넣음으로써 Named Export 방식으로 쓰였다는 것이다. 

 

그리고 이 또한 알고 가야할 부분이다.

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

    // 4. **UserInfos** 테이블에 `name`, `age`, `gender`, `profileImage`를 이용해 사용자 정보를 생성합니다.
    const userInfo = await prisma.userInfos.create({
        data: {
            UserId: user.userId,
            name,
            age,
            gender: gender.toUpperCase(),
            // 전달받은 gender를 전부 대문자로 치환한다.
            profileImage
        }
    })

prisma.users.create() 는 users가 prisma schema에서 작성한 Users 와 다르게 users 로 작성해야한다.

그럼 어떻게 설정했든 모두 소문자로 작성을 해야하는건가?

그것은 아니다.

위에 prisma.userinfos.create()는 중간에 대문자 I 가 포함되어있다.

즉  아래와 같이 camelCase로 작성을 해야 한다는 것이다.

  1. 모델 이름 vs. 테이블 이름:
    • Prisma 스키마에서 모델 이름은 Users와 UserInfos이다.
    • 하지만 Prisma Client에서 이 모델들을 호출할 때는 일반적으로 소문자와 camelCase로 작성해야 한다.
      즉, users와 userInfos로 작성해야 한다.
  2. PascalCase와 camelCase:
    • Prisma 스키마 파일에서는 모델 이름을 PascalCase로 정의하지만, Prisma Client에서는 해당 모델을 소문자로 시작하는 camelCase로 접근한다.
    • UserInfos 모델은 Prisma Client에서 userInfos로 접근해야 한다.

이것은 prisma.호출 을 할때 적용되며 그 이후의 create ({}) 내부에 작성되는 key들은 Schema에서 작성한 대문자를

그대로 반영해 줘야한다. UserId: user.userId, 이것처럼 말이다.

 


 

[ bcrypt 모듈을 사용한 리펙터링 ]

 

위와 같이 password 를 평문으로 저장하는것을 단방향 암호화 한 결과 값으로 데이터베이스에 저장 하기 위해

bcrypt 를 사용하여 리펙터링을 해보도록 하겠다.

 

해싱 암호화

 

< src/routes/users.router.js >

import bcrypt from 'bcrypt';

 

    // 3. **Users** 테이블에 `email`, `password`를 이용해 사용자를 생성합니다.
    const hashedPassword = await bcrypt.hash(password, 10)
    // hash(password, salt)

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

단순하게 평문 저장하였던 password 를 위와 같이 salt 값 을 지정해주고

( 10 정도가 적당, 높으면 높을수록 보안성은 높지만 시간지연 위험성이 높아진다.)

bcrypt.hash를 통해 암호화된 hashedPassword 를 데이터베이스 password 에 할당해준다.

 

해싱 암호화

그럼 평문 저장된 암호를 지우기 위해 일단 전체 데이터를 삭제해보도록 하겠다.

터미널에 npx prisma db push --force-reset 를 입력하게 되면 

이렇게 데이터가 정상적으로 모두 삭제 되는 것을 알 수 있다. 

 


 

[ 로그인 기능 구현, 사용자 인증 미들웨어 ] 

 

< README.md >

# history
10. npm install -g node-pre-gyp
11. yarn add bcrypt
12. npx prisma db push --force-reset // 평문암호를 지우기 위해 모든 데이터을 삭제함

 

< src/routes/users.router.js >

// 사용자 로그인 API
router.post('/sign-in', async (req, res, Next) => {
    // 1. `email`, `password`를 **body**로 전달받습니다.
    const { email, password } = req.body

    // 2. 전달 받은 `email`에 해당하는 사용자가 있는지 확인합니다.
    const user = await prisma.users.findFirst({
        where: { email }
    })
    if (!user) {
        return res.status(401).json({ message: '존재하지 않는 이메일입니다.' })
        // 인증 실패 401
    }

    // 3. 전달 받은 `password`와 데이터베이스의 저장된 `password`를 bcrypt를 이용해 검증합니다.
    if (!await bcrypt.compare(password, user.password)) {
        return res.status(401).json({ message: '비밀번호가 일치하지 않습니다.' })
    }

    // 4. 로그인에 성공한다면, 사용자에게 JWT를 발급합니다.
    const token = jwt.sign(
        {
        userId: user.userId
        },
        'customized_secret_key'
        // 비밀키, 현재는 평문으로 그냥 지정하지만 나중에는 dotenv를 이용하여 외부에서 코드를 보더라도 알 수 없도록 구현해야함
    )


    res.cookie('authorization', `Bearer ${token}`) // 베어러 토큰 형식
    return res.status(200).json({ message: '로그인 성공했습니다.' });
});

 

insomnia 를 통해 확인해 본다면

json 정상 반환
쿠키 정상 반환

 


미들웨어 같은경우는 아래처럼 작성할 수 있다. 

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

export default async function (req, res, next) {
    try{
    // 1. 클라이언트로 부터 **쿠키(Cookie)**를 전달받습니다.
    const { authorization } = req.cookies

    // 2. **쿠키(Cookie)**가 **Bearer 토큰** 형식인지 확인합니다.
    const [ tokenType, token ] = authorization.split(' ')
    if (tokenType !== 'Bearer') {
        throw new Error('토큰 타입이 일치하지 않습니다.');
    }

    // 3. 서버에서 발급한 **JWT가 맞는지 검증**합니다.
    const decodedToken = jwt.verify(token, 'customized_secret_key');
    const userId = decodedToken.userId

    // 4. JWT의 `userId`를 이용해 사용자를 조회합니다.
    const user = await prisma.users.findFirst({
        where: { userId: +userId }
    })
    if (!user) {
        res.clearCookie('authorization');
        // 쿠키를 받았는데 없다면 정상적이지 않은 쿠키이기 때문에 삭제를 진행해야함
        throw new Error('토큰 사용자가 존재하지 않습니다.');
    }

    // 5. `req.user` 에 조회된 사용자 정보를 할당합니다.
    req.user = user;

    // 6. 다음 미들웨어를 실행합니다.
    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 ?? '비정상적인 요청입니다.' })
                // error.message ?? 의 ??연산자는 왼쪽 피연산자가 null or undefined 일때만 오른쪽 피연산자를 반환 한다.
        }
    }
}

 

 

[ 사용자 조회 API ]

 

이번엔 사용자 인증을 집어넣어 그 미들웨어가 완료되면 진행하게끔 만든

사용자 조회 API 코드를 만들어 보도록 하겠다. 

import authMiddleware from '../middlewares/auth.middleware.js';
// 사용자 조회 API
router.get('/users', authMiddleware, async (req, res, next) => {
    // 1. 클라이언트가 **로그인된 사용자인지 검증**합니다.
    const { userId } = req.user;

    // 2. 사용자를 조회할 때, 1:1 관계를 맺고 있는 **Users**와 **UserInfos** 테이블을 조회합니다.
    const user = await prisma.users.findFirst({
        where: {userId: +userId},

        // 특정 컬럼만 조회하는 파라미터
        select: {
            userId: true,
            email: true,
            createdAt: true,
            updatedAt: true,

            // 1:1 관계를 맺고있는 UserInfos 테이블을 조회할수 있도록 구현(중첩 select 문법)
            // 중첩 select는 SQL의 JOIN 과 동일한 역할을 수행
            // 현재 테이블과 연관된 테이블의 모든 컬럼 조회는 include 문법 사용
            UserInfos: {
                select: {
                    name: true,
                    age: true,
                    gender: true,
                    profileImage: true
                }
            }
        }
    });

    // 3. 조회한 사용자의 상세한 정보를 클라이언트에게 반환합니다.
    res.status(200).json({ data: user });
   
});

위와 같이 authMiddleware 를 집어넣어 인자값이 3개가 될경우 위와같이 작동하게 할 수 있다.

저번 < auth.middleware > 에서 try 마지막 구문에 next() 를 적어 실행시킨것을 볼 수 있다.

그 next 는 authMiddleware 가 모두 인증되어야 만 현재 사용자 조회 API 를 실행시킬 수 있다.

 

이렇게 해서 사용자 조회 까지 구현을 해보았고

다음엔 엑세스, 리프레시 토큰에 대해 알아 보도록 하겠다.