프로필

프로필 사진
Popomon
Frontend Developer
(2020/12 ~)

    카테고리

    포스트

    [Node/Rest API] SFA - (1) - 인증 모듈과 사용자 모델

    2020. 11. 13. 00:51

    꿈가게: To Do List - iOS

    꿈가게: To Do List - Android

    요즘 인증방식은 어떻게 구성되어 있나요?

    요즘엔 SSO 방식으로 타 웹서비스의 계정을 통해서 로그인을 할 수 있도록 되어있는 웹사이트가 거의 대부분입니다. 그렇다고 해서 웹사이트에 가입하는 화면 자체가 없지는 않습니다.

     

    이번 포스팅에서는 SSO 방식의 로그인이 아니라, 하나의 웹사이트에서 로그인, 회원가입, 2FA 등의 인증을 어떻게 구현되어있는지 설명하려고 합니다.

     


    Single-Factor Authentication 인증

    Single Factor Authentication 이란 일반적으로 아이디와 비밀번호를 사용하여 로그인을 하는 방법을 의미하며, 절차는 다음과 같습니다.

     

    1. 회원가입

    2. 이메일로 가입확인을 위한 링크 전송

    3. 링크를 클릭시 계정 활성화

    4. 로그인

     

    이어서 JWT 토큰을 이용하여 SFA 인증에 대한 리소스를 구현해 보겠습니다.

     


    밸리데이션 모듈

    우선 회원가입이나 로그인을 할 때 어떤 정보를 입력받을 것인지가 중요합니다. 주요 정보로는 이메일, 비밀번호, 비밀번호 확인, 이름 등이 있습니다. 그 외에 부가정보는 rest 라고 명시하겠습니다. 이어서 밸리데이션, 밸리데이션 핸들러, 요청 핸들러 총 3가지의 절차로 진행해 보겠습니다.

     

    밸리데이션

    우선 이메일은 이메일 형식이어야 하며, 비밀번호는 영문 대소문자, 특수문자, 숫자가 각각 한개 이상 포함되어 있도록 하겠습니다. 이메일 형식을 필터링하기 위한 함수는 express-validator 모듈에서 isEmail 이라는 함수로 정의되어 있기 때문에 따로 작성하지 않아도 됩니다. 하지만 비밀번호는 조건이 다양하기 때문에 직접 정의해야 하는데, 위에서 언급한 조건에 따라서 비밀번호를 필터링하기 위해서는 다음과 같은 정규표현식을 사용할 수 있습니다.

     

    const REGEX = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z])(?=.*[!@#\$%\^&\*]).{8,}$/;
    
    REGEX.test('hello1234!') // false
    REGEX.test('Hello1234') // false
    REGEX.test('Hello1234!') // true

     

    express-validator 모듈을 이용하여 responseBody 객체가 json 객체인지 검증하고, email의 형식, 비밀번호 형식 등을 검증하는 Validator 객체를 각각 정의해 보겠습니다. 다음과 같이, validate 오브젝트 안에 각각의 값에 따라서 밸리데이션을 정의해 줍니다.

     

    const { body : validateBody, validationResult } = require("express-validator");
    
    const validate = {
      json: 
        validateBody()
          .exists()
          .withMessage("Body must be valid JSON"),
      email: 
        validateBody("email")
          .exists()
          .withMessage("Body must contain `email`")
          .bail()
          .isEmail()
          .withMessage("Body must have valid email"),
      password:
        validateBody("password")
          .exists()
          .withMessage("Body must contain `password`")
          .bail()
          .custom(value => {
            const regexPassword = /^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[a-zA-Z])(?=.*[!@#\$%\^&\*]).{8,}$/;
            const isNotValidPassword = !regexPassword.test(value);
            if(isNotValidPassword) {
              return Promise.reject('Body must have valid password (At Least 1 Upper Case, 1 lower case, 1 spacial character, 1 numeric character)');
            }
          }),
      // ...
    }

     

    밸리데이션 핸들러

    validationResult는 특정 값에 에러가 있는 경우에 에러 배열을 반환합니다. 따라서, 밸리데이션 핸들러를 통해 HTTP 상태코드를 400으로 잘못된 요청이라는 의미를 리턴해줍니다. 앞으로 validate 객체에 밸리데이션이 필요한 값들이 추가될 것입니다. 크기에 따라 별도의 파일로 분리할 수도 있습니다. 하지만 지금은 인증 절차만을 사용하기 때문에 validate.js 라는 파일을 하나 만들어서 다음과같이 정의하도록 합니다.

     

    const { body : validateBody, validationResult } = require("express-validator");
    
    const validateHandler = function(req, res, next){
      const errors = validationResult(req);
      if (!errors.isEmpty()) {
        return res.status(400).send({
          message: errors.array()
        })
      }
      next();
    }
    
    const validate = {
      // ...
    }
    
    module.exports = {
      validateHandler,
      validate,
    }

     


    이메일 모듈

    회원가입시 가입 승인 이메일을 보내주거나, 비밀번호 초기화, 새로운 비밀번호로 변경 과 같은 작업을 도와주는 모듈입니다. 비밀번호를 잊은 경우와 처음 가입하는 사용자를 도와주는 모듈이라고 생각해주시면 될 것 같습니다.

     

    다음과 같이 주로 2종류의 프로세스에 이용됩니다.

     

    회원가입 프로세스

    1. 회원가입 요청

    2. 이메일 확인링크 전송 (이메일 모듈)

    3. 사용자가 이메일 링크를 클릭

    4. 계정 활성화

    5. 로그인

     

    비밀번호 찾기

    1. 비밀번호 찾기 요청

    2. 비밀번호 초기화 링크를 이메일로 전송 (이메일 모듈)

    3. 비밀번호 변경

    4. 로그인

     

    이어서 이메일 모듈에 어떤 함수를 정의해야하는지 알아보겠습니다.

     

    비밀번호 초기화 URL 생성함수

    비밀번호 초기화를 위해 "/reset_password/:token" 이와같이 토큰값을 붙여서 리다이렉트 할 수 있도록 URL을 생성합니다.

     

    const resetPasswordURL = token => {
      return `localhost:3000/reset_password/${token}`;
    };

     

    비밀번호 초기화 메일 템플릿 생성함수

    메일을 보내기 위해서 필요한 정보를 반환합니다. 형식은 보내는 사람 이메일, 받는 사람 이메일, 제목, 내용 입니다. 여기서 내용에서는 위에서 정의한 비밀번호 초기화 URL 생성함수를 통해 생성된 문자열이 두번째 인자로 들어갑니다.

     

    const resetPasswordTemplate = (user, resetPasswordURL) => {
      const from = process.env.EMAIL_LOGIN;
      const to = user.email;
      const subject = "Password Reset";
      const html = `
      This link should take the user to a Password Form but you can try this link in Postman.
      Required body data: "password" ... ${resetPasswordURL}
      `;
    
      return { from, to, subject, html };
    };

     

    원가입 확인 링크 URL 생성함수

    회원가입 후 가입한 계정이 승인 대기 상태가 됩니다. 승인으로 전환하기 위해서는 회원가입 확인 링크를 클릭하면 되는데, 이 링크는 "/reset_password/:token" 이와같이 토큰값을 붙여서 리다이렉트 할 수 있도록 생성해야 합니다. 토큰값이 맞다면 계정이 활성화 됩니다.

     

    const emailConfirmationURL = token => {
      return `localhost:3000/email_confirmation/${token}`;
    };

     

    회원가입 확인 메일 템플릿 생성함수

    메일을 보내기 위해서 필요한 정보를 반환합니다. 형식은 보내는 사람 이메일, 받는 사람 이메일, 제목, 내용 입니다. 여기서 내용에서는 위에서 정의한 회원가입 확인 URL 생성함수를 통해 생성된 문자열이 두번째 인자로 들어갑니다.

     

    const emailConfirmationTemplate = (user, emailConfirmationUrl) => {
      const from = "Horong2020@gmail.com";
      const to = user.email;
      const subject = "Welcome";
      const html = `
      Thanks for signing up to My Service!
      To confirm your account click here: ${emailConfirmationUrl}
      `;
     
      return { from, to, subject, html };
    };

     


    사용자 모델

    사용자 모델은 우선 2FA 가 아니라 SFA를 사용한다는 기준으로 진행할 예정이기 때문에, 컬럼은 이메일과 비밀번호 관련 컬럼과, 세션키, 접근토큰 4종류가 주를 이루고 있습니다.

     

    이메일을 보시면, 아이디 역할을 하는 이메일과 회원가입 후 승인 대기였다가 가입메일 확인을 해야 승인으로 변하는 이메일 인증여부, 그리고 회원가입 후 가입 확인 메일에 전달될 메일 토큰이 있습니다. 이렇게 3개의 값을 이용하여 메일 인증이 진행됩니다.

     

    // Email
    email: {
      required: [true, "Email is required."],
      type: String,
      unique: true
    },
      email_verified: {
      required: false,
      type: Boolean
    },
    email_verify_token: {
      required: false,
      type: String,
      unique: true
    },

     

    비밀번호 관련 컬럼에도 총 3가지가 있습니다. 로그인 및 회원가입시 입력하는 비밀번호와 비밀번호 분실 시 비밀번호 변경 URL을 만들기 위한 비밀번호 초기화 토큰 및 그 토큰의 유효시간이 있습니다.

     

    // Password
    password: {
      required: [true, "Password is required."],
      type: String
    },
    password_reset_token: {
      required: false,
      type: String
    },
    password_reset_token_expiry: {
      required: false,
      type: Date
    },

     

    로그인에 성공하면 로그인한 계정 정보를 담고있는 액세스 토큰이 발급됩니다. 이 토큰을 이용하여 인증된 사용자가 이용할 수 있는 모든 페이지에 접속이 가능합니다. REST API 기준으로 보면, 해당 액세스 토큰이 가지는 권한에 따라서 제공되는 요청을 자유롭게 이용하실 수 있습니다. 또한, 계정이 이미 로그인되어있는지를 체크하기 위한 세션 아이디가 랜덤 문자열로 제공됩니다.

     

    // Access Token
    access_token: {
      type: String,
      required: false
    },
    
    // Session Key
    session_string: {
      type: String,
      required: true
    }

     

    사용자 모델에 정의할 전처리 함수를 알아보겠습니다. 사용자 정보를 저장하기 전에 입력받은 비밀번호를 암호화 시켜주어야 합니다. 이 것은 단방향 암호화를 해야합니다. 따라서 save(데이저 저장) 함수를 호출할 시, 전처리로 암호화를 시켜주는 함수를 정의합니다.

     

    const mongoose = require("mongoose");
    const bcrypt = require("bcryptjs");
    const { randomBytes } = require("crypto");
    
    const Schema = mongoose.Schema;
    const schema = { ... }
    
    const userSchema = new Schema(schema, { timestamps: true });
    userSchema.pre("save", function(next) {
      const user = this;
      if (!user.isModified("password")) return next();
    
      try {
        bcrypt.hash(user.password, 10, function(err, hash) {
          if (err) return next(err);
          user.password = hash;
          next();
        });
      } catch (err) {
        console.log(err);
      }
    });

     

    이제 사용자 모델에 정의할 내장함수에 대해서 알아보겠습니다. 우선 첫번째로는 비밀번호를 비교하는 함수입니다. 비밀번호가 단방향 암호화되었기 때문에, 값을 불러와서 같은지 비교하는게 아니라, 암호화하여 저장했던 모듈이 제공하고 있는 compare 함수를 사용하여 두 값을 비교해야 합니다. 해당하는 로직은 다음과 같습니다.

     

    비밀번호 찾기로 이미 초기화된 상태라면 로그인을 할 수 없도록 비밀번호 변경함수가 콜백 함수로 false를 전달합니다. 하지만 그렇지 않은 경우라면 암호화 모듈인 bcrypt를 사용하여 비밀번호를 비교 후에  결과를 전달합니다.

     

    const mongoose = require("mongoose");
    const bcrypt = require("bcryptjs");
    const { randomBytes } = require("crypto");
    
    const Schema = mongoose.Schema;
    const schema = { ... }
    
    const userSchema = new Schema(schema, { timestamps: true });
    userSchema.pre("save", function(next) { ... });
    
    userSchema.methods.comparePassword = function(candidatePassword, cb) {
      // 비밀번호가 이미 초기화된 상태라면 콜백함수를 실행
      if (this.password_reset_token !== undefined) {
        return cb(null, false);
      }
      
      // 비밀번호 비교
      bcrypt.compare(candidatePassword, this.password, function(err, isMatch) {
        if (err) return cb(err);
        cb(null, isMatch);
      });
    };

     

    두번째로는 비밀번호 찾기로 해당하는 이메일 및 사용자 정보 입력에 성공한 경우 (일반적으로 휴대전화 인증이 동반됩니다.)  비밀번호 초기화 토큰이 발급됩니다. 이 내장함수는 user.resetPassword() 형태로 호출되기 때문에 여기서 this.reset_token은 현재 사용자 모델 객체의 값을 변경합니다.

     

    이메일 모듈에서는 이렇게 리셋 토큰을 발급하여 사용자 계정에서의 토큰값을 넣어준 다음 save() 함수를 호출하여 데이터베이스에 저장합니다. 그래서 이제 메일로 보내진 비밀번호 초기화 링크를 누르면, 데이터베이스의 리셋 토큰값과 비교하여 비밀번호를 변경할 수 있게 됩니다.

     

    const mongoose = require("mongoose");
    const bcrypt = require("bcryptjs");
    const { randomBytes } = require("crypto");
    
    const Schema = mongoose.Schema;
    const schema = { ... }
    
    const userSchema = new Schema(schema, { timestamps: true });
    userSchema.pre("save", function(next) { ... });
    
    userSchema.methods.comparePassword = function(candidatePassword, cb) { ... };
    userSchema.methods.resetPassword = function() {
      return new Promise((res, rej) => {
        randomBytes(32, (err, buf) => {
          if (err) {
            const err = new Error("Internal error");
            err.status = 500;
            rej(err);
          }
    
          const generateToken = () => {
            const token = buf.toString("hex");
            User.findOne({ resetToken: token }, (err, user) => {
              if (err) {
                const err = new Error("Internal error");
                err.status = 500;
                rej(err);
              }
    
              if (user) return generateToken();
    
              this.password_reset_token = token;
              let tempdate = new Date();
              tempdate.setHours(tempdate.getHours() + 24);
              this.password_reset_token_expiry = tempdate.toISOString();
              res();
            });
          };
          generateToken();
        });
      });
    };

     

    마지막으로는 회원가입 후 확인 이메일에 보낼 이메일 인증 토큰을 사용자 객체에 넣어서 데이터베이스에 저장할 수 있도록 도와줍니다. 위에서 정의한 비밀번호 초기화 토큰과 같은 매커니즘입니다.

     

    const mongoose = require("mongoose");
    const bcrypt = require("bcryptjs");
    const { randomBytes } = require("crypto");
    
    const Schema = mongoose.Schema;
    const schema = { ... }
    
    const userSchema = new Schema(schema, { timestamps: true });
    userSchema.pre("save", function(next) { ... });
    
    userSchema.methods.comparePassword = function(candidatePassword, cb) { ... };
    userSchema.methods.resetPassword = function() { ... };
    userSchema.methods.verifyEmail = function() {
      return new Promise((res, rej) => {
        randomBytes(32, (err, buf) => {
          if (err) {
            const err = new Error("Internal error");
            err.status = 500;
            rej(err);
          }
    
          const generateToken = () => {
            const token = buf.toString("hex");
            User.findOne({ emailVerifyToken: token }, (err, user) => {
              if (err) {
                const err = new Error("Internal error");
                err.status = 500;
                rej(err);
              }
    
              if (user) return generateToken();
    
              this.emailVerifyToken = token;
              res();
            });
          };
          generateToken();
        });
      });
    };

     

    최종적으로는 아래와 같이 사용자 모델이 완성됩니다.

     

    const mongoose = require("mongoose");
    const bcrypt = require("bcryptjs");
    const { randomBytes } = require("crypto");
    
    const Schema = mongoose.Schema;
    const schema = { ... }
    
    const userSchema = new Schema(schema, { timestamps: true });
    userSchema.pre("save", function(next) { ... });
    
    userSchema.methods.comparePassword = function(candidatePassword, cb) { ... };
    userSchema.methods.resetPassword = function() { ... };
    userSchema.methods.verifyEmail = function() { ... };
    
    mongoose.set("useCreateIndex", true);
    mongoose.set("useFindAndModify", false);
    export const User = mongoose.model("User", userSchema, "users");