[Express] Passport.js를 이용한 로그인

Sean·2023년 2월 2일
3

Web

목록 보기
9/22

🛂Passport.js

회원가입과 로그인을 우리가 직접 구현할 수도 있지만, 이런 작업에는 세션과 쿠키를 비롯해서 복잡한 처리 과정이 많이 들어가므로 우리는 로그인을 보다 편리하게 구현하고 싶을 것이다!

그래서 소개하는 것이 바로 Passport.js이고, Passport.js는 로그인 절차에 대해서 확실하게 작동하고 사용하기 좋은 검증된 모듈이니 한 번 배우면서 써보아야겠다고 생각했다.
대략적으로 말하자면 클라이언트가 서버에 요청할 자격이 있는지 인증할 때에 passport 미들웨어를 사용한다.

✈️들어가기

passport/index.js

우선, 디렉토리에 passport 폴더를 만들고, 그 안에 index.js를 추가해주자.
Passport.js에는 로컬 로그인, 카카오 로그인, 페이스북 로그인 등 다양한 '전략'(Strategy)들을 제공해주는데, 여기 index.js는 그러한 전략들을 통합적으로 묶어주는 '중앙 통제실'이라고 보면 되겠다.

Serialize와 Deserialize

  • Serialize 작업
    사용자가 로그인을 하면 그 정보를 세션(서버 쪽 메모리)에 저장하게 된다.
    SNS로 예를 들어보자. 어떤 SNS 플랫폼에서 우리의 프로필만 들어가봐도 엄청나게 방대한 양의 정보가 나온다. 그런데 이 모든 정보를 세션에 저장하게 된다면 서버의 메모리가 남아나지 않을 것이다. 따라서, 우리는 세션에 저장할 정보를 최소화해서 저장해야 하는데,
    [{id: 3, cookie: 'asdfasdf'}]와 같이 최소한의 정보만 저장한다.
    여기서 cookie는 프론트 단으로 보내준다.
    이 과정을 Serialize 한다고 한다.

  • Deserialize 작업
    프론트에서 asdfasdf라는 쿠키 값을 가지고 있다가 나중에 서버로 무언가를 요청할 때 이 쿠키 값도 같이 전달하게 되는데, 서버는 이 asdfasdf라는 값을 통해서 해당 유저의 아이디가 3이라는 사실 밖에 모른다. 따라서, 이 id가 3이라는 정보를 가지고 해당 유저의 정보를 되찾아야 하는데, 이를 deserialize라고 한다. 여기서 되찾은 유저 정보는 req.user에 저장된다.

여기까지가 서버에서 로그인 정보를 저장하는 방법이라고 보면 되겠다.

Strategy를 연결

Passport.js에는 다양한 로그인 전략(Strategy)를 제공한다고 위에서 말했었다.
Serialize와 Deserialize 작업에 대한 코드를 다 짠 후에 그러한 strategy들을 연결해주어야 한다.
지금 이 글에서는 아직 Strategy를 사용한 코드를 사용하진 않았지만, 같은 폴더 내에 localStrategy를 담은 local.js가 있다고 치고 다음 코드를 이해해보자.

코드

const passport = require("passport");
const db = require("../models");
const local = require("./local");

// 서버쪽의 부담을 최소화하려고 최소한의 데이터만 저장하려는 목적이다.
// 사용자가 로그인을 하면 정보를 세션(서버 쪽 메모리)에 저장한다.

module.exports = () => {
  // Serialize
  // 서버쪽에 [{id: 3, cookie: 'asdfasdf'}]와 같은 정보 저장.
  // 여기서 cookie는 프론트 단으로 보내준다.
  passport.serializeUser((user, done) => {
    return done(null, user.id);
  });

  // 프론트에서 'asdfasdf'라는 cookie를 보내줬을 때, id가 3이라는 정보 밖에 모른다.
  // 따라서, id가 3이라는 정보로 해당 유저의 정보를 되찾는 과정을 deserialize라고 한다.
  // 되찾은 유저 정보는 req.user에 저장된다.
  passport.deserializeUser(async (id, done) => {
    try {
      // DB에서 findOne으로 user는 이상한 객체 형식으로 되어 있어서
      // 나중에 routes/user.js에서 user.toJSON() 으로 변환해주어야 한다.
      const user = await db.User.findOne({
        where: { id },
      });
      if (user) return done(null, user);
    } catch (e) {
      console.error(e);
      return done(e);
    }
  });

  // 전략을 연결한다
  local();
};

done() 함수

done() 이라는 함수에는 파라미터가 3개까지 들어갈 수 있다. 각 파라미터는 순서대로 다음과 같은 의미를 갖는다.

  1. 서버쪽 에러
  2. 성공적으로 찾아낸 유저 정보
  3. 실패한 이유에 대한 정보

ex) done(null, false, { reason: '비밀번호가 틀립니다.' });

done() 함수에 이러한 정보를 담아내면 나중에 routes/user.js에서 로그인 요청이 들어왔을 때 passport.authenticate('local', callbackFn)를 사용하여 로직을 작성할 때 저절로 done() 함수에 작성되었던 정보들이 callbackFn(콜백함수)의 인자들로 전달된다.

그래서 로그인을 성공하게 된다면 routes/user.js 안에서 사용자 정보 객체와 함께 req.login()을 수행하게 된다. (뒷편에 설명!)

passport/local.js

passport 폴더 안에 새로운 전략에 대한 코드를 짜보자.
여기서는 LocalStrategy를 짤 것이다. 일단 코드 먼저 보면 다음과 같다.

const passport = require('passport');
const bcrypt = require('bcrypt');
const { Strategy: LocalStrategy } = require('passport-local');
const db = require('../models');

module.exports = () => {
  passport.use(
    new LocalStrategy(
      {
        // usernameField와 passwordField는 HTML form의 어떤 field를 읽어들일지를 명시해주는 것
        usernameField: 'userID',
        passwordField: 'password',
      },
      async (userID, password, done) => {
        try {
          const user = await db.User.findOne({
            where: { userID },
          });
          if (!user) {
            return done(null, false, {
              reason: '존재하지 않는 계정입니다.'
            });
          }
          
          // user가 존재하면 DB에 저장된 비밀번호와 비교
          const result = await bcrypt.compare(password, user.password);
          if (result) return done(null, user);
          else return done(null, false, {
            reason: "잘못된 비밀번호입니다."
          });
        } catch (e) {
          console.error(e);
          return done(e);
        }
     }));
}

이렇게 우리가 passport/local.js에서 passport-local을 이용해서 LocalStrategy에 관한 함수를 모듈로써 export 해준다.
그렇기 때문에 위에서 passport.index.js에서 serialize와 deserialize를 해 준 다음에 맨 밑에서 local()과 같이 전략 함수를 실행해 준 것이다.

routes/user.js

여기까지 하고 이제 다 된 것 같다고 생각할 수 있지만 더 남았다.
유저(클라이언트)의 요청은 어디로 들어오는가? 바로 routes/user.js로 들어온다.
따라서, 전략을 짜고 그걸 passport/index.js에서 통합시키는 것 이외에도 클라이언트의 요청이 들어왔을 때 적절히 처리하는 코드를 짜야한다.

const express = require('express');
const db = require('../models');
const bcrypt = require('bcrypt');
const passport = require('passport');
const router = express.Router();

// 로그인 로직만 작성할게요
// localhost:8080/api/user/login으로 POST 요청이 올 때
router.post('login', (req, res, next) => {
  // passport.authenticate의 콜백함수는 위의 done() 함수 설명 참고
  passport.authenticate('local', (err, user, info) => {
    // err는 서버의 에러
    if (err) {
      console.error(err);
      return next(err);
    }
    // info는 로직 상의 에러
    if (info) {
      return res.status(401).send(info.reason);
    }
    // 위의 모든 에러가 없다면 로그인을 시킨다.
    // req.login을 하면 서버쪽에 세션과 쿠키로 저장이 된다.
    return req.login(user, (loginErr) => {
      // loginErr가 터지면 next로 보내버리고
      if (loginErr) return next(loginErr);
      // 정상적이라면 비밀번호를 제외한 유저 정보를 클라이언트에게 보낸다.
      const filteredUser = Object.assign({}, user.toJSON());
      delete filteredUser.password;
      return res.json(filteredUser);
    });
  })(req, res, next);
});

index.js

루트 디렉토리에 있는 index.js에서는 passport를 사용하기 위한 준비를 모두 해주어야 한다. 그래서 passport/index.js에서 export 해준 함수를 실행시키면 된다.

  • app.use(passport.initialize())
    passport를 초기화하기 위해서 passport.initialize 미들웨어를 사용
  • app.use(passport.session())
    세션 사용을 원한다면, passport.session 미들웨어를 사용
/*
const express = require('express');
const morgan = require('morgan');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const expressSession = require('express-session');
const dotenv = require('dotenv');
*/

/* passport */
const passport = require('passport');
const passportConfig = require('./passport');
/* //////// */

/*
dotenv.config();
const app = express();
db.sequelize.sync();
*/

/* passport */
passportConfig();
/* //////// */

/*
app.use(morgan('dev'));
app.use(express.json());
app.use(express.urlencoded({extended: true}));
app.use(
  cors({
    origin: 'http://localhost:3000',
    credentials: true,
  })
);
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(
  expressSession({
    resave: false,
    saveUninitialized: false,
    secret: process.env.COOKIE_SECRET,
    cookie: {
      httpOnly: true,
      secure: false,
    },
  })
);
*/

// Passport 같은 경우 express-session 아래에 적는다.
// (미들웨어는 서로 의존 관계에 있는 경우 순서가 중요하다.)
app.use(passport.initialize());
app.use(passport.session());

// routes
/*
app.use("/api/user", userAPIRouter);
app.use("/api/lecture", lectureAPIRouter);
app.use("/api/lectures", lecturesAPIRouter);
app.use("/api/post", postAPIRouter);

app.get("/", (req, res) => {
  res.send("Hello Server!");
});

app.listen(8080, () => {
  console.log("Listening on 8080!");
});
*/

이 밑의 두 섹션에 대해서는 inpa님의 블로그를 참고하였습니다.

🗝️전체적인 로그인 과정

  1. 로그인 요청이 라우터로 들어옴.
  2. 미들웨어를 거치고, passport.authenticate() 호출
  3. authenticate에서 passport/local.js에서 passport.use 호출
  4. 해당 로그인 전략을 실행하고 done()을 호출하면 그 결과를 가지고 다시 passport.authenticate()로 돌아가서 콜백함수 실행
  5. done() 정보를 토대로 로그인 성공 시 사용자 정보 객체와 함께 req.login()을 자동으로 호출
  6. req.login() 메소드가 passport/index.jspassport.serializeUser() 호출
  7. req.session에 사용자의 아이디 키값만 저장 (메모리의 최적화를 위해서)
  8. passport.deserializeUser()로 바로 넘어가서 SQL 조회 후 req.user 객체를 등록 후, done()함수를 실행한 후 req.login() 메소드로 다시 되돌아감.
  9. req.login() 메소드를 실행 후에 res.redirect('/')을 응답하면 세션쿠키를 브라우저에 보내게 된다.
  10. 로그인 완료 처리 (이제 세션쿠키를 통해서 통신하며 로그인됨 상태를 알 수 있다.)

🪄참고 !
프론트에서는 서버로 cookie만 보내준다. (이상한 문자열 ex. a23asdgklwer482)
서버는 위의 cookie 값을 받아서 그 이상한 문자열을 cookie-parser와 express-session으로 쿠키를 검사한 후에 'id: 7' 이라는 정보를 발견한다.
그래서 이 'id: 7'이 deserializeUser 함수에 들어가고, 데이터베이스에서 해당하는 유저를 찾아서 그것이 req.user에 사용자 정보로써 들어가는 로직임.

🗝️passport 로그인 이후 과정

  1. 모든 요청에 passport.session() 미들웨어가 passport.deserializeUser() 메소드를 매번 호출한다.
  2. deserializeUser에서 req.session에 저장된 아이디로 데이터베이스에서 사용자 조회
  3. 조회된 사용자의 정보 전체를 req.user 객체에 저장
  4. 이제부터 라우터에서 req.user를 공용적으로 사용 가능하게 된다.

🪄참고 !
여기를 읽고 마음이 좀 불편하지 않았나요? deserializeUser() 메소드를 "매번" 호출한다니...
그래서 실무에서는 deserializeUser()의 결과물을 캐싱한다고 합니다.

profile
여러 프로젝트보다 하나라도 제대로, 깔끔하게.

0개의 댓글