본문으로 바로가기

* jwt.decode는 해독하지만 signature를 검증하지는 않습니다. verify 는 검증합니다. verify 쓰세요

 

 

passport를 쓰다가 커스텀 함수를 만드는 데 너무 번거롭고 작동도 잘 안해서 (deserialize가 작동을 너무 안하잖아..)

앞으로는 oAuth를 쓰지 않는 이상 그냥 직접 구현하기로 했다. 이게 훨씬 마음이 편하다.

 

작동 방식은 다음과 같다.

 

회원가입

우선 해당 식별자로 가입한 사람이 없는지 먼저 체크한 후 (빈 비밀번호, 이미 존재하는 닉네임 등등도 처리)
플레인한 비밀번호를 받아 hash + salt 화를 진행한다. (bcrypt를 활용한 해쉬, 솔트)
해쉬화된 비밀번호와 함께 유저 정보를 DB에 저장한다.

로그인

유저가 입력한 식별자(id나 emai)를 이용해 DB에서 찾는다.
유저가 입력한 플레인한 비밀번호를 bcrypt.compare를 활용해 해쉬화된 비밀번호와 일치하는지 확인
(기존 암호를 복호화하여 비교하는 방식보다 합리적임)

비밀번호가 같다면 JWT를 생성한다. (jsonwebtoken 활용)
생성한 JWT는 프론트 단에서 넘기고 프론트를 그걸 LS나 쿠키에 저장한다. 로컬스토리지나 세션스토리지에 담는 방법도 있지만 XSS 공격을 받을 가능성이 높다. 쿠키도 공격받을 수 있으나 검증을 통해 방어할 수 있기 때문에 웹토큰을 쿠키에 저장하는 것이 좋습니다.

 

로그아웃

 

현재 로그인한 유저의 정보를 DB에서 찾는다

해당 유저의 토큰을 지워준다.

 

 

 

bcrypt

A bcrypt library for NodeJS.

www.npmjs.com

 

jsonwebtoken

JSON Web Token implementation (symmetric and asymmetric)

www.npmjs.com

 

 

bcrypt.genSalt 솔트 생성 -> bcrypt.hash 해쉬화 -> bcrypt.compare 일치 확인

 

jwt.sign 토큰 생성 -> cookieParser를 이용한 쿠기에 토큰 저장 -> jwt.verify로 토큰으로 유저 serialize


 

회원가입

 

이번에는 hash 처리와 동시에 salt도 더해주는 방식으로 구현해보았다. genSalt에 salt 생성에 성공하면 hash 처리를 해주는 방식으로 구현했다.

 

const express = require("express");
const User = require("../models/User");
const bcrypt = require("bcrypt");
const saltRounds = 10;

authRouter.post("/login", (req, res) => {
  console.log(req.body);
});

authRouter.post("/register", async (req, res) => {
  let { userEmail, nickname, password, passwordCheck } = req.body;
  // 빈값이 오면 팅겨내기
  if (
    userEmail === "" ||
    nickname === "" ||
    password === "" ||
    passwordCheck === ""
  ) {
    return res.json({ registerSuccess: false, message: "정보를 입력하세요" });
  }

  // 비밀번호가 같지 않으면 팅겨내기
  if (password !== passwordCheck)
    return res.json({
      registerSuccess: false,
      message: "비밀번호가 같지 않습니다",
    });

  const sameEmailUser = await User.findOne({ email: userEmail });
  if (sameEmailUser !== null) {
    return res.json({
      registerSuccess: false,
      message: "이미 존재하는 이메일입니다",
    });
  }

  const sameNickNameUser = await User.findOne({ nickname });
  if (sameNickNameUser !== null) {
    return res.json({
      registerSuccess: false,
      message: "이미 존재하는 닉네임입니다.",
    });
  }

  // 솔트 생성 및 해쉬화 진행
  bcrypt.genSalt(saltRounds, (err, salt) => {
    // 솔트 생성 실패시
    if (err)
      return res.status(500).json({
        registerSuccess: false,
        message: "비밀번호 해쉬화에 실패했습니다.",
      });
    // salt 생성에 성공시 hash 진행

    bcrypt.hash(password, salt, async (err, hash) => {
      if (err)
        return res.status(500).json({
          registerSuccess: false,
          message: "비밀번호 해쉬화에 실패했습니다.",
        });

      // 비밀번호를 해쉬된 값으로 대체합니다.
      password = hash;

      const user = await new User({
        email: userEmail,
        nickname,
        password,
      });

      user.save((err) => {
        if (err) return res.json({ registerSuccess: fasle, message: err });
      });
      return res.json({ registerSuccess: true });
    });
  });
});

 

 

로그인 + JWT 생성

 

로그인을 시도 한다면 DB에 email이 있는지, 있다면 pw는 같은지를 확인하고, 로그인이 맞다면 token을 만들어 주어야 한다.

 

여기서, 이미 salt와 hash 처리가 된 원본 비밀번호를 디코딩하는 것은 사실상 어려우므로, 사용자가 제출한 비밀번호를 hash 처리해주어서 값이 같은지 비교해야 합니다. 이 과정을 제공해 주는 것이 bcrypt.compare 메서드 입니다.

 

 

* 현재 req.cookie로 토큰을 쿠키에 저장했습니다. 

req.cookie에 대한 설정은 (https://expressjs.com/en/api.html#res.cookie) 여기를 참조합시다.

const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");
const cookieParser = require("cookie-parser");
const { User } = require("./models/User");

... 중략

app.post("/login", (req, res) => {
  // 해당 email이 있는지 확인
  User.findOne({ email: req.body.email }, (error, user) => {
    // 에러는 500
    if (error) {
      return res.status(500).json({ error: "오류" });
    }

    // 찾는 유저가 없다?
    if (!user) {
      return res.status(403).json({
        loginSuccess: false,
        message: "해당되는 이메일이 없습니다.",
      });
    }

    // email이 맞으니 pw가 일치하는지 검증합니다.
    if (user) {
      const checkPW = () => {
        bcrypt.compare(req.body.password, user.password, (error, isMatch) => {
          if (error) {
            return res.status(500).json({ error: "something wrong" });
          }
          if (isMatch) {
            // 비밀번호가 맞으면 token을 생성해야 합니다.
            // secret 토큰 값은 특정 유저를 감별하는데 사용합니다.

            // 토큰 생성 7일간 유효
            const token = jwt.sign({ userID: user._id }, SECRET_TOKEN, {expiresIn: '7d'});

            // 해당 유저에게 token값 할당 후 저장
            user.token = token;
            user.save((error, user) => {
              if (error) {
                return res.status(400).json({ error: "something wrong" });
              }

              // DB에 token 저장한 후에는 cookie에 토큰을 저장하여 이용자를 식별합니다.
              return res
                .cookie("x_auth", user.token, {
                  maxAge: 1000 * 60 * 60 * 24 * 7, // 7일간 유지
                  httpOnly: true,
                })
                .status(200)
                .json({ loginSuccess: true, userId: user._id });
            });
          } else {
            return res.status(403).json({
              loginSuccess: false,
              message: "비밀번호가 틀렸습니다.",
            });
          }
        });
      };
      checkPW();
    }
  });
});

app.listen(port, () =>
  console.log(`Example app listening at http://localhost:${port}`)
);

 

jsw.sign에 지정한 expiresIn이 7d 이므로 쿠키에도 maxAge를 7일로 잡아줬습니다. 둘이 동일해야죠? 유효기간이 지나면 자동으로 쿠키에서 지워져야하니까?

 

설정한대로 x_auth로 생성한 쿠키에 max-age가 설정된 것을 볼 수 있습니다. 

즉, 한 유저가 로그인 한 후에 생기는 웹토큰의 만료일이 7일이라는 것입니다.

7일이 지나면 유저는 다시 로그인을 해야 한다는 의미이기도 합니다. (토큰이 만료되었습니다. 다시 로그인해주세요)

 

 

여기서 JWT 생성 부분만 떼어서 보자면 다음과 같이 진행됩니다.

 

// 환경 변수
const SECRET_TOKEN = process.env.SECRET_TOKEN;

// 비밀번호가 맞으면 token을 생성해야 합니다.
// secret 토큰 값은 특정 유저를 감별하는데 사용합니다.

// 토큰 생성. 7일 동안 유효하도록 처리함
const token = jwt.sign({ userID: user._id }, SECRET_TOKEN, {expiresIn: "7d"});

// 해당 유저에게 token값 할당 후 저장
user.token = token;
user.save((error, user) => {
  if (error) {
    return res.status(400).json({ error: "something wrong" });
  }

// DB에 token 저장한 후에는 cookie에 토큰을 저장하여 이용자를 식별합니다.
  return res
    .cookie("x_auth", user.token)
    .status(200)
    .json({ loginSuccess: true, userId: user._id });
}

 

jwt.sign(payload, secret, options, [callback])

jwt.sign은 token을 생성하는데 첫 인자(payload)로 객체를 받습니다. 아무 것이나 괜찮습니다. 공식 문서의 예시로 보면 { foo: 'bar' }와 같이 아무값이나 준 것을 볼 수 있습니다만, 이후 decode하게 payload들을 식별할 수 있습니다.

그러니까, JWT를 decode한 후 사용하고 싶은 정보들을 넣어야 된다는 것입니다. 이번에는 _id가 유저를 식별하는데 중요하게 사용되는 정보이기 때문에 여기서는 DB의 키값인 _id를 주기로 했습니다. 

 

두번째 인자로는 decode하는데 필요한 랜덤한 값을 줍니다. 공식 문서에는 'shhhhh'와 같은 값을 주었습니다. 이후 decode하여 유저를 식별하는데 재사용될 것이므로 환경 변수로 만들어 값을 할당합시다.

 

JWT를 만들었다면 유저에도 저장하고, 쿠키에도 저장하도록 합니다. cookie-parser를 설치해서 res.cookie로 쿠키를 설정할 수 있습니다. 세션, 쿠키, 로컬 스토리지 등 어디에 저장할 것인지에 대한 의견은 많으나 역시 쿠키가 가장 보편적이고 안전합니다. 

 

decode되지 않은 JWT를 직접 쿠키를 열어서 보면 점으로 구분된 3부분으로 나눠진 것을 볼 수 있습니다. 앞서 작성한 정보들이 모여 JWT를 이루는 것입니다.

 

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. (헤더)

eyJ0b2tlbklkIjoiNWVmOGQzYWNiMjk1Y2YyNmU0Njk1MzVjIiwiaWF0IjoxNTkzMzc2MzMxLCJleHAiOjE1OTM5ODExMzF9. (payload)

8tQ-yMJ3IXeMjfO7V40yBLOYmmQ2J0WoSmDW5T2zHEs (서명. SECRET_TOKEN이 암호화)

 

 

 

경로 auth 검증 미들웨어 (JWT 검증 미들웨어)

 

jwt.verify를 통해 토큰을 decode할 수 있습니다. decode한 값으로 유저를 식별하는 미들웨어를 만들고 이를 이용해 로그인한 유저만 이용할 수 있는 프로필 수정, 파일 업로드, 로그아웃 등의 경로에서 auth를 넣어줍니다.

 

// custome middleware
// 토큰 값을 통해 유저를 찾아 auth를 합니다.
const jwtMiddleware = (req, res, next) => {
  // 클라이언트 쿠키에서 token을 가져옵니다.
  let token = req.cookies.x_auth;

  // token을 decode 합니다.
  jwt.verify(token, SECRET_TOKEN, (error, decoded) => {
    if (error) {
      return res
        .status(500)
        .json({ error: "token을 decode하는 데 실패 했습니다." });
    }
    // decoded에는 jwt를 생성할 때 첫번째 인자로 전달한 객체가 있습니다.
    // { random: user._id } 형태로 줬으므로 _id를 꺼내 씁시다
    User.findOne({ _id: decoded.UserId }, (error, user) => {
      if (error) {
        return res.json({ error: "DB에서 찾는 도중 오류가 발생했습니다" });
      }
      if (!user) {
        return res
          .status(404)
          .json({ isAuth: false, error: "token에 해당하는 유저가 없습니다" });
      }
      if (user) {
        // 🚨 다음에 사용할 수 있도록 req 객체에 token과 user를 넣어줍니다
        req.token = token;
        req.user = user;
      }
      next();
    });
  });
};

 

미들웨어로 넣어주면 됩니다. 미들웨어에서 req 객체로 token과 user를 넣어주었으니 적절하게 사용하면 됩니다.

나중에 React의 경우 HOC을 이용해 이 경로로 post를 날려서 해당 유저의 JWT가 만료되지는 않았는지, 로그인은 했는지 등을 체크해서 분기 처리를 해주면 됩니다. 

app.post("/api/users/auth", jwtMiddleware, async (req, res) => {
  res.status(200).json({
    isAuth: true,
    _id: req.user._id,
    isAdmin: req.user.role === 0 ? false : true,
    name: req.user.name,
  });
});

 

 

한편 decode한 token을 까보면 다음과 같이 생겼습니다. 앞서서 decode하면 payload를 살펴볼 수 있다고 했죠?

 

iat는 토큰이 발급된 시간 exp는 만료일입니다. 그 외의 값들은 우리가 JWT를 생성할 때 sign의 첫번째 인자로 넣어줬던 값들입니다. 

 

 

 

로그아웃

 

프론트 단의 jwt를 지워줘야 합니다. 유효기간이 지났다면 프론트 단에서 이미 쿠키에서 jwt각 지워졌을 테고, LS에 jwt를 저장했다면 그냥 setItem에서 빈 문자열을 할당하면 됩니다.

 

authRouter.post("/logout", jwtMiddleware, (req, res) => {
  // 쿠키를 지웁니다.

  return res.cookie("x_auth", "").json({ logoutSuccess: true });
});

 


darren, dev blog
블로그 이미지 DarrenKwonDev 님의 블로그
VISITOR 오늘 / 전체