본문 바로가기

DayDream Project/BackEnd

[Node.js] 유저 회원가입/로그인 구현하기 With Mongoose - 1

어떤 프로그램이든 회원가입 로그인은 기본적으로 구현해야 하는 부분 ,,,

효율적인 방법인지는 모르겠지만 간단하게 회원가입과 로그인을 구현해보자.

 

우선 유저 데이터에 어떤 값이 들어갈지를 미리 설정해야 한다.

DayDream에는 회원가입 시 이메일, 비밀번호, 비밀번호 확인, 휴대폰 인증(추후 시간이 된다면...), 나이, 키, 몸무게, 자기소개, 닉네임을 받고 있다. 그에 맞춰서 유저 모델을 설정해주면 된다.

 

/models/userModel.js

const userSchema = new mongoose.Schema({
  email: {
    type: String,
    required: [true, "이메일을 입력해주세요."],
    unique: true,
    lowercase: true,
    validate: [validator.isEmail, "유효한 이메일을 입력해주세요."],
  },
  password: {
    type: String,
    required: [true, "비밀번호를 입력해주세요."],
    minLength: 8,
    maxLength: 16,
    select: false,
  },
  passwordConfirm: {
    type: String,
    required: [true, "비밀번호를 한번 더 입력해주세요."],
    validate: {
      validator: function (el) {
        return el === this.password;
      },
      message: "동일한 비밀번호를 입력해주세요.",
    },
  },
  createdAt: {
    type: Date,
    default: Date.now(),
  },
  passwordChangedAt: Date,
  name: {
    type: String,
    required: [true, "닉네임을 입력해주세요."],
    minLength: [1, "닉네임은 1글자 이상이여야 합니다."],
    maxLength: [8, "닉네임은 8글자 이하여야 합니다."],
  },
  introduce: {
    type: String,
    default: "",
    maxLength: [100, "자기소개는 100글자 이하여야 합니다."],
  },
  img: [String],
  age: {
    type: Number,
    default: 0,
  },
  weight: {
    type: Number,
    default: 0,
  },
  height: {
    type: Number,
    default: 0,
  },
  active: {
    type: Boolean,
    default: true,
  },
  role: {
    type: String,
    default: "user",
    enum: ["user", "admin"],
  },
});

const User = mongoose.model("User", userSchema);

module.exports = User;

 이렇게 필드값을 정해주고, 스키마를 생성해주면 된다.

각 필드에는 추가적인 옵션들을 설정할 수 있는데, 이메일은 유일해야한다(unique), 필수적으로 필요한 값 등등 .. 내가 구현하고자 하는 기능에 맞춰 설정해주면 된다.

나는 특정 기능들을 유저/관리자에 따라 제한해 줄 생각이기 때문에, role 필드도 추가해줬다.

 

그리고 간단히 users에 user를 추가하는 코드를 userController에 작성해주자.

/Controllers/userController.js

exports.createUser = catchAsync(async (req, res, next) => {
  const user = await User.create(req.body);

  res.status(201).json({
    status: "success",
    data: {
      data: user,
    },
  });
});

이러면, body에 넣은 값 중 내가 스키마에 추가한 내용들을 기반으로 유저를 생성해준다.

이제 간단하게 라우터를 설정하고 테스트 해보자.

/Routers/userRouter.js

const express = require("express");
const userController = require("../Controllers/userController");

const router = express.Router();

router.route("/signup").post(userController.createUser);

그리고 테스트 해주면 ...

유저 생성 완료!

간단하게 유저가 생성된다. 근데, 비밀번호 확인을 보면 DB에 대놓고 비밀번호가 써있는 것을 볼 수 있다.

해커가 DB 뚫으면, 유저들 개인정보 술술 풀리는거다. 위험하다.

따라서 우리는 암호화를 해줘야하는데, 이제 시작해보자.

 

이러한 DB 데이터에 관한 로직 조작은 model에서 해 주는 것이 좋다. Controller와 Model의 의존성을 최대한 낮춰주는 것이 좋으니까 !

따라서 mongoose의 pre 기능을 사용해 조작해보자.

userSchema.pre("save", async function (next) {
  if (!this.isModified("password")) return next();

  this.password = await bcrypt.hash(this.password, 12);
  this.passwordConfirm = undefined;
  next();
});

 

우선 이 기능을 구현하기 위해선 bcrypt 라이브러리가 필요하다. `npm install bcrypt`

pre는 뒤에 설정한 기능을 수행하기 전에 수행하는 미들웨어라고 생각하면 편하다.

따라서 이 코드에서는 user를 저장하기 '전'에 수행하는 함수들을 콜백에 넣어준다.

우선 this는 현재 사용자가 입력한 데이터 값을 가지고 있는 객체이다. 이 점을 유의해두자.

일단 비밀번호가 수정되지 않았다면(세팅되지 않았다면) 아무 일 없이 넘기고, 비밀번호가 추가되었다면 bcrypt의 hash 함수를 통해 비밀번호를 해싱해주고, 이를 바꿔치기 해준다.

여기서 '12'는 얼마나 비밀번호를 복잡하게 암호화할지에 대한 값인데, 너무 크면 쿼리 수행속도가 느려지고, 낮으면 해킹당할 위험이 있다. 12정도가 적당하다고 들었다.

 

password에 대한 검증은 이미 위에서 끝났기 때문에, passwordConfirm 값은 undefined 값으로 설정해주자.

이렇게 구현하면, 사용자가 입력한 패스워드값은 암호화가 된 뒤 DB에 저장되어 해커가 DB를 뚫는다고 해도 로그인 할 수 없다. 굳!

 

그렇다면, 로그인 할 때는 내가 입력한 비밀번호(여기서는 test1234)를 입력해 로그인해야 하는데, 그 값과 DB에 있는 비밀번호 값과 비교는 어떻게 하는가? 쉬운 방법이 있다.

 

userSchema.methods.correctPassword = async function (
  candidatePassword,
  userPassword,
) {
  return await bcrypt.compare(candidatePassword, userPassword);
};

 

요러면, 같은 경우 true, 다른 경우 false를 반환해준다. 이걸 활용해 로그인을 구현하면 된다.

이런 인증 기능들은 authController를 따로 파서 저장할 것임!

 

Controllers/authController.js

exports.login = catchAsync(async (req, res, next) => {
  const { email, password } = req.body;

  // 1) check if email and password exist
  if (!email || !password) {
    return next(new AppError("Please provide email and password", 400));
  }
  // 2) check if user exists && password is correct
  const user = await User.findOne({ email }).select("+password");
  const correct = await user.correctPassword(password, user.password);
  if (!user || !correct) {
    return next(new AppError("Incorrect email or passsword", 401));
  }

  res.status(200).json({
  	status: 'success',
    data: {
    	data: user
        }
    }
  }
});

 

body로 들어오는 값 email, password를 구조분해로 받아주고,

1. body에 값을 입력했는지를 확인하고

2. 유저가 존재하는지 찾고, 비밀번호가 유효한지 확인한다.

이 두가지를 만족해야 한다.

 

1번은 단순히 값이 있는지만 확인하면 되고,

2번은 우선 유저를 이메일을 기반으로 찾고, 필드에 password 값을 추가해준다(원래 user를 출력하면 비밀번호는 나오지 않는데, 우리는 비밀번호를 비교해야 하니까 임시적으로 password 필드를 넣어준다.)

이후 아까 구현한 correctPassword 함수를 통해 입력한 패스워드와, 암호화 된 패스워드(DB에 저장되어 있는 패스워드)를 비교해준다.

두 값 중 하나라도 없다면 유저가 없거나, 비밀번호가 틀렸거나의 상황이므로 에러를 출력해준다.

해커들이 부르트 포스 해킹으로 수천, 수만개의 비밀번호를 넣으며 해킹하는 경우를 대비해 이메일이 틀린건지, 비밀번호가 틀린건지 정확히 알려주지 않는 것이 좋다고 한다.

 

이렇게 대충 회원가입과 로그인을 구현했지 ... 만 어떤 권한에 대한 제약을 설정하지 않았다.

이렇게 있는 회원인지 확인하는 과정(login)을 인증(Authentication)이라 하고, 이 인증된 유저가 어떤 행동을 할 때 권한이 있는지를 확인하는 과정을 인가(Authorization) 이라고 한다.

 

모던한 웹 프로젝트에서는 이러한 부분을 구현하기 위해서 JWT(Json Web Token)을 사용한다. 따라서 DayDream도 이러한 방식을 사용할거고, 이에 관한 리팩토링은 다음 포스트에서 구현할 것이다!