NodeJS+MongoDB

NodeJS+MongoDB Part3 - 암호화

hminor 2023. 6. 27. 17:48

회원가입 페이지 + 비밀번호 암호화

우선 회원가입 페이지부터 만들기

로그인 페이지와 입력하는 레이아웃을 유사하니 login.ejs 파일 그대로 가져와서 form 태그의 action 만 /register로 변경하고 submit 을 회원가입으로 변경하기

<!doctype html>
<html>

<head>
  <!-- Required meta tags -->
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">

  <!-- Bootstrap CSS -->
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/css/bootstrap.min.css"
    integrity="sha384-xOolHFLEh07PJGoPkLv1IbcEPTNtaed2xpHsD9ESMhqIYd0nLMwNLD69Npy4HI+N" crossorigin="anonymous">
  <link rel="stylesheet" href="/public/main.css">

  <title>NodeJS+MongoDB</title>
</head>

<body>
  <%- include('nav.html')%>

  <h4 class="container mt-4"><strong>회원가입 페이지</strong></h4>

  <div class="container mt-3">
    <form action="/register" method="POST">
      <div class="form-group">
        <label>Id</label>
        <input type="text" class="form-control" name="id">
      </div>
      <div class="form-group">
        <label>PW</label>
        <input type="text" class="form-control" name="pw">
      </div>
      <button type="submit" class="btn btn-danger mt-1">회원가입</button>
    </form>
  </div>


  <script src="https://cdn.jsdelivr.net/npm/jquery@3.5.1/dist/jquery.slim.min.js"
    integrity="sha384-DfXdz2htPH0lsSSs5nCTpuj/zy4C+OGpamoFVy38MVBnE+IbbVYUew+OrCXaRkfj" crossorigin="anonymous">
  </script>
  <script src="https://cdn.jsdelivr.net/npm/bootstrap@4.6.2/dist/js/bootstrap.bundle.min.js"
    integrity="sha384-Fy6S3B9q64WdZWQUiU+q4/2Lc9npb8tCaSX9FK7E8HnRr0Jz8D6OP9dO5Vg3Q9ct" crossorigin="anonymous">
  </script>

  <script>
  </script>

</body>

</html>

 

그리고 server.js 에서도 signup 페이지를 GET, POST 요청시에 대한 코드도 작성하기

여기서 주의할 점은 회원 기능이 필요하다면 passport 셋팅하는 부분이 위에 있어야한다고 한다.

// GET

app.get("/signup", (req, res) => {
  res.render("signup.ejs");
});
// POST
app.post("/register", (req, res) => {
  db.collection("login").insertOne(
    { id: req.body.id, pw: req.body.pw },
    (err, data) => {
      res.render("login.ejs", { data: "로그인 성공" });
    }
  );
});

다만 위의 POST 요청시 고려해야하는 점이 몇가지가 있는데

  1. 저장전에 ID가 이미 있는지 중복 검사를 했는지
  2. ID에 알파벳 또는 숫자만 들어가 있는지
  3. 비밀번호 저장시 암호화를 했는지

강의에는 위의 고려사항을 하지 않았지만 따로 해보고 싶어서 했음.

우선 첫 번째 ID 중복 검사에 대한 코드는 아래와 같다.

app.post("/register", (req, res) => {
  db.collection("login")
    .find({ id: req.body.id })
    .toArray((err, data) => {
      console.log(data);
      if (data.length === 0) {
        // 배열로 가져와지는 data의 length 즉 길이로 판단했음.
					
      }
    });
});

두 번째로 ID에 알파벳 또는 숫자만 들어가 있는지 체크하는 코드는 아래와 같다.

// 정규식 파악 함수
// pattern의 /(슬래시) 사이에 작성하는 것으로
// ^ 와 $ 로 시작과 끝을 알려주고
// [] 사이에 a-zA-Z0-9 로 소문자 a 부터 z, 대문자 A 부터 Z, 0부터 9까지만 포함되도록 하기.
function isTrueId(id) {
  const pattern = /^[a-zA-Z0-9]+$/;
  return pattern.test(id);
}

app.post("/register", (req, res) => {
  db.collection("login")
    .find({ id: req.body.id })
    .toArray((err, data) => {
      console.log(data);
      if (data.length === 0) {
        // 해당 데이터가 없다면 정규식 검사로 알파벳 또는 숫자만 들어가 있는지 확인
        if (isTrueId(req.body.id)) {
          // 확인되고 만약 잘 작성되었다면 비밀번호 암호화 후 DB에 저장하기 <----- 비밀번호는 저장하지 않고 salt와 hash 값을 저장해줘서 로그인시 매칭할때 사용하기

        }
      }
    });
});

세 번째로 비밀번호 저장시 암호화를 했는지를 위한 코드

// 암호화
const crypto = require("crypto");

// 정규식 확인 함수
function isTrueId(id) {
  const pattern = /^[a-zA-Z0-9]+$/;
  return pattern.test(id);
}

// 비밀번호 해시화 함수
// salt와 hash를 만들어줘서 login 시 비교하도록 하기.
// 여기서 암호화한 해쉬 알고리즘은 sha512를 사용
function hashPassword(pw) {
  const salt = crypto.randomBytes(16).toString("hex"); // 임의의 salt 생성
  const hash = crypto.pbkdf2Sync(pw, salt, 1000, 64, "sha512").toString("hex"); // 입력한 비밀번호와 salt를 가지고 비밀번호를 해싱

  return {
    salt: salt,
    hash: hash,
  };
}

app.post("/register", (req, res) => {
  db.collection("login")
    .find({ id: req.body.id })
    .toArray((err, data) => {
      console.log(data);
      if (data.length === 0) {
        // 해당 데이터가 없다면 정규식 검사로 알파벳 또는 숫자만 들어가 있는지 확인
        if (isTrueId(req.body.id)) {
          // 확인되고 만약 잘 작성되었다면 비밀번호 암호화 후 DB에 저장하기 <----- 비밀번호는 저장하지 않고 salt와 hash 값을 저장해줘서 로그인시 매칭할때 사용하기
          const { salt, hash } = hashPassword(req.body.pw);
          db.collection("login").insertOne(
            { id: req.body.id, salt: salt, hash: hash },
            (err, data) => {
              if (!err) {
                res.render("login.ejs", { data: "성공" });
              }
            }
          );
        }
      }
    });
});

여기서 끝이 아니라

초기 로그인시엔 암호화 되지 않은 비밀번호로 로그인을 했기에 로직을 변경해줘야 한다. (귀찮…)

// 기존 로그인시 post 요청하는 코드

app.post(
  "/login",
  passport.authenticate("local", {
    failureRedirect: "/fail",
  }),
  (req, res) => {
    // 아이디, 비번 맞으면 메인 페이지로 보내주기.
    res.redirect("/");
  }
);

위의 코드는 form 태그 안에 있는 input 태그의 name 속성으로 object를 넘겨주는 passport로 로그인을 하는 코드이기에 passport를 사용하는 코드였던 아래 코드를 건드려줘야한다.

아래 코드는 기존 코드

passport.use(
  new localStrategy(
    {
      usernameField: "id", // form 태그의 name 속성의 id 값
      passwordField: "pw", // form 태그의 name 속성의 pw 값
      session: true, // 로그인 후 세션을 저장할 것인지에 대한 셋팅
      passReqToCallback: false, // 아이디/비밀번호 말고도 다른 정보로 검증을 할 것인지에 대한 셋팅
      // 만약 passReqToCallback: true로 한다면 밑의 (id, pw, done) 에서 req를 추가해서 req.body 하면 데이터가 나온다고 한다.
    },
    (id, pw, done) => {
      // done(1번째 인자: 서버에러와 같은 db 연결 불가 등, 2번째 인자: 요청을 성공했을 때 사용자 db 데이터 만약 실패의 경우 false 넣어야 함, 3번째 인자: 에러 메시지)
      db.collection("login").findOne({ id: id }, (err, data) => {
        if (err) return done(err);
        if (!data)
          // 결과가 없다면 --> db에 대항 아이디가 없다면
          return done(null, false, { msg: "존재하지 않는 아이디 입니다." });
        if (pw == data.pw) {
          // pw가 암호화 되어있지 않기에 보안이 좋지 않지만 지금은 우선 이렇게 진행함.
          // db에 아이디가 있다면, 입력한 비밀번호와 db에 있는 비번이랑 확인해보기.

          // 아이디와 비번이 맞아서 로그인을 성공하면 세션 정보를 만들어줘야 함 (로그인 했는지 확인하기 위해)
          return done(null, data); // <--- 여기 성공시의 data가 257번줄의 serializeUser((user, done))의 user 데이터에 들어가게 됨
        } else {
          return done(null, false, { msg: "잘못된 비밀번호 입니다." });
        }
      });
    }
  )
);

아래 코드가 변경된 코드

// 로직 순서는 아래와 같다.
// db.collection의 login 에서 form 태그 안의 name이 id인 input 태그의 value에 해당하는
// id를 찾아서 해당 data를 찾은 다음 checkPassword 메서드 안에 
// form 태그 안의 name이 pw인 input 태그의 value인 pw와 data로 가져온 salt와 hash값을 전달해
// 비밀번호가 맞는 값인지에 대한 return 값을 boolean으로 가져와서 
// 맞다면 로그인 시켜주고 세션을 저장한 다음 쿠키를 브라우저에 저장하기.

function checkPassword(pw, salt, savedHash) {
  const hash = crypto.pbkdf2Sync(pw, salt, 1000, 64, "sha512").toString("hex"); // 입력된 비밀번호를 해시화
  return hash === savedHash;
}

passport.use(
  new localStrategy(
    {
      usernameField: "id", // form 태그의 name 속성의 id 값
      passwordField: "pw", // form 태그의 name 속성의 pw 값
      session: true, // 로그인 후 세션을 저장할 것인지에 대한 셋팅
      passReqToCallback: false, // 아이디/비밀번호 말고도 다른 정보로 검증을 할 것인지에 대한 셋팅
      // 만약 passReqToCallback: true로 한다면 밑의 (id, pw, done) 에서 req를 추가해서 req.body 하면 데이터가 나온다고 한다.
    },
    (id, pw, done) => {
      // done(1번째 인자: 서버에러와 같은 db 연결 불가 등, 2번째 인자: 요청을 성공했을 때 사용자 db 데이터 만약 실패의 경우 false 넣어야 함, 3번째 인자: 에러 메시지)
      db.collection("login").findOne({ id: id }, (err, data) => {
        // 해당 아이디의 salt, hash 값 가져오기
        const isPW = checkPassword(pw, data.salt, data.hash);

        if (err) return done(err);
        else if (!data)
          // 결과가 없다면 --> db에 대항 아이디가 없다면
          return done(null, false, { msg: "존재하지 않는 아이디 입니다." });
        else if (isPW) {
          console.log("로그인 했다!");
          // pw가 암호화 되어있지 않기에 보안이 좋지 않지만 지금은 우선 이렇게 진행함.
          // db에 아이디가 있다면, 입력한 비밀번호와 db에 있는 비번이랑 확인해보기.

          // 아이디와 비번이 맞아서 로그인을 성공하면 세션 정보를 만들어줘야 함 (로그인 했는지 확인하기 위해)
          return done(null, data); // <--- 여기 성공시의 data가 257번줄의 serializeUser((user, done))의 user 데이터에 들어가게 됨
        } else {
          return done(null, false, { msg: "잘못된 비밀번호 입니다." });
        }
      });
    }
  )
);