Node JS #9

USER 기능

회원 관련 기능은 크게 회원가입을 의미하는 JOIN과 LOGIN 으로 이루어져 있다

사용자가 먼저 회원가입을 할 수 있게 한 뒤에, 그 계정으로 로그인을 할 수 있게 하는 순서로 알아보도록 하자.

Join

회원가입 기능을 위한 순서는 아래와 같다.

  1. User 모델 제작
  2. 라우팅 처리
  3. Form 제작
  4. 컨트롤러 처리

User 모델

각 회원이 가지고 있어야 정보들을 userSchema에 정의하고, 이를 User 모델로 만든다.

이때 회원이 가지고 있는 email과 username은 유일해야 하므로 unique : true 속성을 부여한다.

import mongoose from "mongoose";
import bcrypt from "bcrypt";

const userSchema = new mongoose.Schema({
    email: { type: String, required: true, unique: true },
    username: { type: String, required: true, unique: true },
    password: { type: String, required: true },
    name: { type: String, required: true },
    location: String,
});

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

물론 모델을 제작한 뒤, 서버의 초기화를 담당하는 파일에서는 이 모델을 import 시켜주어야 한다.

import "./db";
import "./models/Video";
import "./models/User";
import app from "./server";
const PORT = 4000;
const handleListening = () =>
    console.log(`🚀 Server listening on port http://localhost:${PORT}`);

app.listen(4000, handleListening);

Routing

localhost:4000/join의 URL로 접속할 때 알맞은 응답을 받기 위해 라우팅 처리를 해준다.

import express from "express";
import {
    getJoin,
    postJoin,
    getLogin,
    postLogin,
} from "../controllers/userController";
import { home, search } from "../controllers/videoController";
const rootRouter = express.Router();

rootRouter.get("/", home);
rootRouter.route("/join").get(getJoin).post(postJoin);
rootRouter.route("/login").get(getLogin).post(postLogin);
rootRouter.get("/login", getLogin);
rootRouter.get("/search", search);

export default rootRouter;

여기서 컨트롤러는 임시로 간단한 문자열을 출력하는 수준으로 만들어 놓고, 추후 세부적인 세팅을 하자.

Making Form

extends base

block content
    if errorMessage
        span=errorMessage

    form(method="POST")
        input(placeholder="Name" name="name", type="text",required)
        input(placeholder="Email" name="email",type="email",required)
        input(placeholder="Username" name="username",type="text",required)
        input(placeholder="Password" name="password",type="password",required)
        input(placeholder="Confirm Password" name="password2",type="password",required)
        input(placeholder="Location" name="location",type="text")
        input(type="submit",value="Join")
    hr
    div
        span Already have an account?
        a(href="/login") Login in now →

Form을 작성 완료한 뒤에 Join 버튼을 누르면 현재 페이지 URL에서 POST 요청을 보낸다.

또한 Form에 작성된 데이터들은 req.body를 이용하여 접근할 수 있다.

Controlling

위의 Form에서 보낸 데이터들을 컨트롤러에서 받아서 계정을 생성하고, 이를 DB에 저장한다.

이때 주의해야 할 점이 몇가지 있다.

먼저, 사용자가 입력한 Form의 비밀번호와 비밀번호 확인란의 TEXT가 동일한지 확인한다.

만약 틀릴 경우 브라우저에게 에러코드를 넘겨주며 ‘이 계정정보를 저장하시겠습니까’ 문구를 출력하지 않도록 해야한다.

그리고 계정을 생성하는 과정에서 중복되는 email이나 username의 저장을 모델의 unique 속성에 의해 에러가 막아준다. 이 에러를 처리해주어야 한다.

마지막으로 비밀번호가 DB에 그대로 저장될 경우 보안에 매우 취약해지므로 해시화하여 저장한다.

주석과 함께 코드를 살피며 천천히 나아가 보도록 하자.

export const postJoin = async (req, res) => {
    // 사용자가 입력한 Form에서 데이터를 받아온다.
    const { name, username, email, password, password2, location } = req.body;
    const pageTitle = "Join";

    // 비밀번호 확인 실패시 error status code 400 반환하며 페이지 렌더링
    if (password !== password2) {
        return res.status(400).render("join", {
            pageTitle,
            errorMessage: "Passwords do not match.",
        });
    }

    // 사용자가 입력한 계정의 이름과 이메일이 중복되었는지 확인하고 관련 에러 처리
    const exists = await User.exists({ $or: [{ username }, { email }] });
    if (exists) {
        return res.status(400).render("join", {
            pageTitle,
            errorMessage: "This username/email is already taken.",
        });
    }

    // 사용자 계정을 생성하여 DB에 저장, 예상치 못한 예외 발생시 처리
    try {
        await User.create({
            name,
            username,
            email,
            password,
            location,
        });
        return res.redirect("/login");
    } catch (error) {
        return res.status(400).render("join", {
            pageTitle: "Upload Video",
            errorMessage: error._message,
        });
    }
};

Status Code

계정을 만들면 아래와 같은 창이 뜬다.

image

이것이 뜨는 이유는 브라우저가 200번 status code를 받아 로그인에 성공하였고, 따라서 방금 입력한 계정은 올바른 계정이라 판단하여 이 계정은 자동완성에 등록할꺼냐 물어보는 거다.

그러나 만일 사용자가 잘못된 계정을 집어넣어 로그인에 실패하였는데도 이 창이 뜨는데, 이것은 400번 status code를 기재하지 않아 기본값인 200번 코드로 브라우저가 인식하여 브라우저는 여전히 계정이 올바르다 판단하기 때문. 따라서 로그인 실패시 위의 코드에서 처럼 400번 status code를 기재하여 오류가 있는 계정, 자동완성에 등록할 가치가 없는 계정임을 브라우저가 알 수 있도록 하였다.

image

MDN : Status Code

Hash

비밀번호를 아무런 암호화 없이 DB에 집어넣었다면, 만일 회사 DB가 해킹당하게 되면 그대로 회원정보들을 해커의 손에 넘어가게 된다.

따라서 우리는 비밀번호를 암호화의 일종인 해시함수를 이용하여 DB에 저장할 것이다.

먼저 지금 DB의 상태를 보자.

> mongosh
> show dbs
> use <myDBName>
> show collections
> db.users.find() // users의 모든 데이터 출력
> db.users.remove({}) // users의 모든 데이터 삭제

image

이렇게 내가 비번을 “hello”로 설정한게 그대로 다 보여버린다. 보안에 매우 취약하다.

우리는 비번을 그대로 저장하지 않으면서, 추후 사용자가 입력한 비번이 맞는지 확인해야 한다. 그리고 이는 해쉬함수를 이용해서 가능하다.

해쉬함수는 임의의 데이터를 일정 길이의 비트열로 변환하는 단방향 함수이면서 determinstic 함수이다.

단방향 함수이기 때문에 output에서 input을 끌어낼 수 없다.

determinstic 함수이기 때문에 동일 input에서 항상 동일 output이 나온다.

해쉬 함수에 대한 글

비밀번호 저장에 해시함수를 적용하기 위해 bcrypt라는 라이브러리를 이용한다.

npm : bcrypt

> npm i bcrypt // bcrypt 설치

Usage

const bcrypt = require("bcrypt");
const saltRounds = 10;
const myPlaintextPassword = "s0//P4$$w0rD";
const someOtherPlaintextPassword = "not_bacon";

To hash a password:

Technique 1 (generate a salt and hash on separate function calls):

bcrypt.genSalt(saltRounds, function (err, salt) {
    bcrypt.hash(myPlaintextPassword, salt, function (err, hash) {
        // Store hash in your password DB.
    });
});

Technique 2 (auto-gen a salt and hash):

bcrypt.hash(myPlaintextPassword, saltRounds, function (err, hash) {
    // Store hash in your password DB.
});

Note that both techniques achieve the same end-result.

To check a password:

// Load hash from your password DB.
bcrypt.compare(myPlaintextPassword, hash, function (err, result) {
    // result == true
});
bcrypt.compare(someOtherPlaintextPassword, hash, function (err, result) {
    // result == false
});

해시의 어원은 해시 브라운(고기, 감자를 섞어 조리한 것)이다.

재료가 같다면 해시 브라운은 같을 것이다. 그리고 좀더 맛을 내기 위해 소금(salt)를 뿌릴 수 있다.

이때 saltRound는 바로 그 소금의 역할, 정확히는 해시화 하는 횟수를 의미한다.

이제 우리의 비밀번호를 해시화 하여 저장하자.

이때 컨트롤러에서 암호화를 해주어도 되지만, 그냥 moongose의 pre m/w를 이용하여 더 깔끔하게 코드를 작성하자.

import mongoose from "mongoose";
import bcrypt from "bcrypt";

const userSchema = new mongoose.Schema({
    email: { type: String, required: true, unique: true },
    username: { type: String, required: true, unique: true },
    password: { type: String, required: true },
    name: { type: String, required: true },
    location: String,
});

userSchema.pre("save", async function (err, result) {
    console.log(`Users password : ${this.password}`);
    this.password = await bcrypt.hash(this.password, 5);
    console.log(`Hashed password : ${this.password}`);
});

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

image

그러면 이처럼 비번이 해시화 된것을 확인할 수 있고

image

실제 DB에도 해시화된 비번이 저장된 것을 알 수 있다.

이로써 우리는 DB가 털려도 비번이 그대로 노출되지 않는 DB를 가지게 되었다!

$or 구문

우리는 사용자가 만드려는 계정이 DB에 email과 username이 존재하지 않는지 or연산으로 조사해야한다.

그리고 이때, mongoDB의 or 구문을 활용한다.

$orimg

  • $orimg

    The $or operator performs a logical OR operation on an array of one or more <expressions> and selects the documents that satisfy at least one of the <expressions>.

Compatibilityimg

You can use $or for deployments hosted in the following environments:

  • MongoDB Atlas: The fully managed service for MongoDB deployments in the cloud

  • MongoDB Enterprise: The subscription-based, self-managed version of MongoDB
  • MongoDB Community: The source-available, free-to-use, and self-managed version of MongoDB

Syntaximg

The $or operator has the following syntax:

{ $or: [ { <expression1> }, { <expression2> }, ... , { <expressionN> } ] }

Consider the following example:

db.inventory.find( { $or: [ { quantity: { $lt: 20 } }, { price: 10 } ] } )

This query will select all documents in the inventory collection where either the quantity field value is less than 20 or the price field value equals 10.


Login

이제 우리는 회원가입을 통해 DB에 회원정보를 가지고 있기 때문에, 이를 바탕으로 로그인을 구현할 수 있다.

로그인 구현 순서는 아래와 같다.

  1. Form 제작
  2. Routing 처리
  3. Controller 처리

Making Form & Routing

extends base

block content
    if errorMessage
        span=errorMessage

    form(method="POST")
        input(placeholder="Username" name="username",type="text",required)
        input(placeholder="Password" name="password",type="password",required)
        input(type="submit",value="Login")
    hr
    div
        span Don't have an account?
        a(href="/join") Create one now &rarr;

Join과 비슷하게 login 버튼을 누르면 같은 URL에서 POST 요청을 보낸다.

이 POST 요청을 받는 라우팅 처리를 해주자.

rootRouter.route("/login").get(getLogin).post(postLogin);

Contorlling

export const postLogin = async (req, res) => {
    const { username, password } = req.body;
    const pageTitle = "Login";
    //check if account exists
    const user = await User.findOne({ username });
    if (!user) {
        return res.status(404).render("login", {
            pageTitle,
            errorMessage: "An account with this username does not exists.",
        });
    }

    //check if password correct
    const checkPassword = await bcrypt.compare(password, user.password);
    if (!checkPassword) {
        return res.status(404).render("login", {
            pageTitle,
            errorMessage: "Wrong password",
        });
    }

    return res.redirect("/");
};

Hash : check password

해쉬함수는 동일 input에 대해 항상 같은 output을 내놓는 것을 이용하여 입력된 비번의 해시값이 우리 DB에 저장된 해시값과 일치하는 지 비교한다.

이것은 실제 비번을 몰라도 일치성을 확인할 수 있다는 것을 의미한다.

따라서 보안 측면에서 우수하다.

To check a password:

// Load hash from your password DB.
bcrypt.compare(myPlaintextPassword, hash, function (err, result) {
    // result == true
});
bcrypt.compare(someOtherPlaintextPassword, hash, function (err, result) {
    // result == false
});

async function checkUser(username, password) {
    //... fetch user from a db etc.

    const match = await bcrypt.compare(password, user.passwordHash);

    if (match) {
        //login
    }

    //...
}

bcrypt는 user가 입력한 text인 첫 인자를 자동으로 해시화 함수에 넣어서 두번째 인자와 비교하고, Boolean 값을 반환한다.

그리고 saltRound 값은 해시 값에서 구할 수 있으므로, 우리는 따로 saltRound값을 신경쓰지 않아도 된다.

참고로 우리가 비번을 까먹었을때 사이트는 그 비번을 알려주지 않았던 이유가 여기에 있다.

사실 그 사이트는 우리의 비번을 안 알려준게 아니라, 못 알려준것이다.

왜냐하며 우리의 비번을 해시화되어 있었기 때문이다.

만일 그대로 우리의 비번을 알려주는 사이트는 바로 거르도록하자. 우리의 비번이 DB에 그대로 노출되어 있다는 뜻이니.

Categories:

Updated:

Leave a comment