JavaScript/Node JS

Express + passport (+jwt) 이용하여 로그인, 회원가입, 인증 구현하기

한땀코딩 2020. 10. 11. 14:01

개인화된 서비스를 제작하려고 보면 항상 고민이 생기는 부분이 로그인/회원관리인 것 같습니다. 물론 처음부터 너무 본질적인 부분을 제하고 로그인에만 매몰되면 안 되겠지만, 공부를 하다보면 유저를 먼저 생성하고 그 정보가 어떤 식으로 서버에서 돌아다니는지(?) 알고 시작하는 것이 확실히 흐름을 파악하는 데 도움이 되는 것 같습니다.

기존 방식

기존에는 Oauth, jwt 같이 유저를 서버 차원에서 인증하는 별도의 방식 없이 단순히 db에 저장된 유저명, 비밀번호와 대조해서 로그인을 허용하는 식으로 진행을 해왔습니다. 유저가 계정과 비밀번호를 post로 전달하면, 서버에서 db에 저장된 내용과 일치하면 유저가 로그인 했다는 의미에서 세션에 유저 정보를 추가하고, 이 세션 id를 브라우저의 쿠키에 저장해둡니다. 이러면 리퀘스트가 발생할 때마다 이 쿠키 정보를 통해 해당 리퀘스트가 어떤 유저의 것인지 판단할 수 있었습니다. express-session 같은 것을 이용해서 해당 작업을 진행했습니다.

JWT?

https://jwt.io/

 

JWT.IO

JSON Web Tokens are an open, industry standard RFC 7519 method for representing claims securely between two parties.

jwt.io

JWT는 JSON Web Token의 준말로, 통신하는 두 개체 사이에서 안전하게 인증 정보를 주고 받게 해주는 RFC 7519 웹 표준 방식입니다. 이런 말 자체에도 많은 개념을 내포하고 있어서 모든 걸 깊게 다루기는 어렵지만, 쉽게 생각해서 위에서 로그인을 성공하면(인증된 유저라면) 어떤 토큰을 발급받게 되고, 이 토큰에는 유저 정보가 암호화 되어 저장되어 있습니다. 즉, 토큰은 일종의 암호화된 문자열이라고 볼 수 있는데, 이 문자열을 해독하기 위한 비밀키는 서버에서 가지고 있습니다. 대략적인 사용의 흐름을 보자면 아래와 같습니다.

유저가 클라이언트에서 로그인
→ 서버에서 jwt 토큰 발급
→ 클라이언트가 토큰을 저장
→ 유저가 리퀘스트 생성 시 토큰을 함께 서버에 전달
→ 서버는 토큰을 해석하여 유저 정보 추출
→ 해당 유저의 요청 실행 및 응답

왜 굳이 이런 방식을 택하는가에 대한 의문점이 생길 수도 있는데, 세션 등으로 관리하는 것에 비해 가지는 장점이 있습니다. JWT는 유저의 로그인 여부, 즉 유저의 상태에 대해서 서버가 관리하지 않아도 됩니다. 그저 전달받은 토큰이 유효한지만 확인해서 거기서 필요한 정보를 추출하면 됩니다. 그리고 세션은 저장이 되어있는 만큼 유효성 여부를 체크할 때 동일한 세션 id 가 존재하는지 찾는 데 비용이 발생하지만, JWT방식은 이런 비용으로부터는 자유롭습니다.

다만, 이런 이유로 JWT가 무조건 선호되어야 하는 것은 아니며, 토큰이 노출되면 보안에 치명적일 수 있다는 리스크 또한 가지고 있습니다. 서비스의 특징과 규모를 잘 생각하여 적절한 방법을 선택하면 될 것 같습니다.

Passport JS

Passport JS는 주로 로그인 기능 구현 쪽에서 많이 소개되는 node JS, express JS 미들웨어입니다. 인증을 간편하게 해주는 도구 정도로 생각하면 쉬운데, 사실 개인적으로 Oauth 같은 것이 아닌, 위에서 설명한 단순한 jwt나 db를 통한 비밀번호 대조 정도의 로직에서는 조금 과한 감이 없잖아 있습니다. 이런 경우는 결과적으로 jwt나 로그인 관련 로직은 직접 작성하게 되기 때문에 별도로 만든 로직을 그냥 미들웨어로 만들어주어도 무방하기 때문입니다. 그래도 이후 로그인 기능이 추가되거나 확장이 필요할 때 여러가지를 지원해주는 passport를 사용하고 있다면 편리할 수 있을 것 같습니다.

express 서버에 passport로 회원가입, 로그인 구현하기

DB에 유저의 아이디, 비밀번호를 단순 대조하는 형태로 생각하고 대략적인 방법을 소개해보겠습니다. 유저가 POST로 유저명, 비밀번호를 전달하고, 이 정보가 db의 정보와 일치한다면 인증이 통과되는 형태입니다. 회원가입 또한 마찬가지로 유저명, 비밀번호를 전달하면 db에 해당정보를 단순하게 저장하는 구조로 생각하시면 됩니다.

기본적인 구조는 express-generator를 통해 생성된 틀 위에서 작성되었습니다. 그 외 민감한 정보(db 접근 유저, 비밀번호 등)는 모두 dotenv에 작성되어 있습니다. process.env로 시작하는 부분은 모두 암호화된 정보고 본인의 환경에 맞춰 변경하여 작성하시거나 dotenv 파일을 만들면 됩니다.

먼저 필요한 모듈은 아래와 같습니다. 설치를 진행합니다.

npm install passport passport-local passport-jwt jsonwebtoken

passport에서 사용하는 로그인, 회원가입 로직은 passport의 용어로 보면 strategy라고 부릅니다. 이걸 작성해서 passport에서 사용할 수 있도록 이름을 정하고 passport.use를 해줘야 합니다. 아래의 경우가 제가 작성한 예시입니다.

// auth.js

require('dotenv').config();
const passport = require('passport');
const localStrategy = require('passport-local').Strategy;
const { ExtractJwt, Strategy: JWTStrategy } = require('passport-jwt');

const JWTConfig = {
  jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
  secretOrKey: process.env.JWT_SECRET_KEY,
};

const JWTVerify = async (jwtPayload, done) => {
  try {
    // jwtPayload에 유저 정보가 담겨있다.
		// 해당 정보로 유저 식별 로직을 거친다.

    // 유효한 유저라면
    if (user) {
      done(null, user);
      return;
    }
    // 유저가 유효하지 않다면
    done(null, false, { message: 'inaccurate token.' });
  } catch (error) {
    console.error(error);
    done(error);
  }
};

passport.use('jwt', new JWTStrategy(JWTConfig, JWTVerify));

// 토큰에 담길 유저명의 key를 지정하는 옵션. 패스워드도 지정할 수 있다.
const passportConfig = { usernameField: 'userName' };

passport.use(
  'signup',
  new localStrategy(passportConfig, async (userName, password, done) => {
		// 유저 생성
		// 성공하면
    return done(null, userName);
		
		// 실패하면
    return done(null, false, { message: 'User creation fail.' });
  });
);

passport.use(
  'signin',
  new localStrategy(passportConfig, async (userName, password, done) => {
    // 유저가 db 에 존재한다면
    return done(null, userName, { message: 'Sign in Successful' });

		// 없다면
    return done(null, false, { message: 'Wrong password' });
  })
);

module.exports = { passport };

app.js가 위치한 루트 디렉토리에 auth.js라는 이름으로 모든 strategy를 명시해줍니다. jwt, 회원가입, 로그인 로직이 각각 명시되어있고 이는 미들웨어처럼 동작하게 됩니다.

이렇게 세팅된 passport를 사용할 수 있도록 app.js에서 불러옵니다.

// app.js

const { passport } = require('./auth');

이렇게 해두면 이제 routes들에서 passport.authenticate로 원하는 strategy를 지정하여 미들웨어로 넘길 수 있습니다. 로그인을 구현하기 위한 라우터는 아래처럼 작성되었습니다.

// routes/signin.js

require('dotenv').config();
const express = require('express');
const router = express.Router();
const passport = require('passport');
const jwt = require('jsonwebtoken');

router.post('/', (req, res, next) => {
  passport.authenticate('signin', (err, user, info) => {
    if (!user) {
      return res.status(400).json({ message: info.message });
    }
    const token = jwt.sign(
      { userName: req.body.userName },
      process.env.JWT_SECRET_KEY
    );
    res.json({ token });
  })(req, res, next);
});

여기서 주목해야하는 점은, passport 부분이 미들웨어가 아니라 함수 안에서 동작하고 있다는 점입니다. 사실 미들웨어로 표기해주는 것이 일반적이지만, 인증 실패 시 지정한 메시지를 띄우기 위해 해당 미들웨어를 함수 내부에서 호출하도록 변경하였습니다. passport.authenticate의 콜백함수에서 info는 위에서 done함수를 통해 넘겨주는 임의의 정보를 의미합니다. 저는 이 안에 오류 메시지를 담아 전달하기 때문에 오류 발생 시, json으로 오류 메시지를 함께 보내주었습니다.

만약 로그인이 성공한다면 JWT 동작 방식처럼 토큰 문자열을 클라이언트로 전달해주어야 합니다. 이는 jsonwebtoken 모듈의 sign 함수를 통해 생성할 수 있습니다.

Postman 과 같이 rest api 통신의 결과를 확인할 수 있는 도구를 사용해보면 다음과 같이 토큰이 전달되는 것을 확인하실 수 있습니다.