100%를 한번에 바꾸는건 어려워도 1%를 100번 바꾸는건 쉽다.

생각정리 자세히보기

개발/Node.js(Express)

[Node.js] Passport (로컬 로그인 with session)

dc-choi 2022. 1. 19. 01:03
반응형

Passport란?

Passport is authentication middleware for Node.js. Extremely flexible and modular, Passport can be unobtrusively dropped in to any Express-based web application. A comprehensive set of strategies support authentication using a username and password, Facebook, Twitter, and more.

 

즉, Passport는 Node.js에서 인증을 도와주는 미들웨어이며, 여러가지 전략 (인증 방법을 전략이라고 칭한다.)을 지원함.

필자는 API서버로서 session기반의 전통적인 ID, PW를 이용한 로그인 방식을 구현하였다.

로컬 로그인 구현 방법

먼저 코드를 보고 나중에 흐름을 이해하도록 하자.

먼저 npm을 이용해 모듈을 설치해야한다.

npm i express-session
npm i passport
npm i passport-local

모듈을 설치하면 각각의 코드를 입력해야한다.

 

app.js

const session = require('express-session')
const passport = require('passport');

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

passportConfig(); // passport의 설정 적용

app.use(session({
  secret: process.env.SECRET,
  resave: false,
  saveUninitialized: false,
}));

// 이 부분의 설정은 반드시 세션 설정 뒤에 사용해야 한다.
app.use(passport.initialize()); // 요청에 passport 설정을 넣는다.
app.use(passport.session()); // req.session에 passport 정보를 저장한다.

app.use('/api/auth', loginRouter);

기본적으로 프로젝트에서는 session을 사용하기 때문에 express-session 모듈과 함께 사용해야한다 passport의 설정은 session이 적용되고난 후에 적용해야한다.

 

auth/isLogged.js

module.exports = {
  async isLoggedIn(req, res, next) {
    if (req.isAuthenticated()) {
      next();
    } else {
      res.status(403).send('로그인 필요.');
    }
  },
  async isNotLoggedIn(req, res, next) {
    if (!req.isAuthenticated()) {
      next();
    } else {
      res.status(403).send('이미 로그인 됨.');
    }
  },
}

로그인여부를 물어보는 두개의 함수를 담아둔 파일이다. passport가 적용되면 req에 isAuthenticated()가 추가되는데, 이것은 로그인이 되었는지 여부를 물어보는 메서드이다.

 

routes/login.js

const express = require('express');
const passport = require('passport');
const router = express.Router();

const { isLoggedIn, isNotLoggedIn } = require('../auth/isLogged');
const loginService = require('../service/loginService');

router.post('/localLogin', isNotLoggedIn, async(req, res, next) => {
  passport.authenticate('local', (authError, user, info) => {
    if (authError) {
      console.error(authError);
      res.status(500);
      return next(authError);
    }
    if (!user) {
      res.status(500);
      return res.send(info.message);
    }
    return req.login(user, (loginError) => {
      if (loginError) {
        console.error(loginError);
        res.status(500);
        return next(loginError);
      }
      return res.send(user);
    });
  })(req, res, next);
});

module.exports = router;

localLogin과 localSignup, logout에 대한 요청을 받는 router이다. localLogin을 유심히 봐야한다. 이 요청에 대한 콜백을 실행하기 전에 isNotLoggedIn()을 실행하여 로그인 여부를 확인한다, 확인이 되었다면 오류 메시지를 리턴한다. authError는 DB 연결 실패 등의 에러가 나타났을때의 예외처리를 하는 부분이고, !user는 해당 유저가 DB에 없을 경우에 대한 예외처리고, loginError는 password가 틀렸을 경우에 대한 예외처리를 하는 부분이다.

 

passport/localStrategy.js

const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const bcrypt = require('bcrypt');

const user = require('../models/index').models.user;

module.exports = () => {
  passport.use(new LocalStrategy({
    usernameField: 'us_email',
    passwordField: 'us_password',
  }, async (us_email, us_password, done) => {
    try {
      const exUser = await user.findOne({ where: { us_email } });
      if (exUser) {
        const result = await bcrypt.compare(us_password, exUser.us_password);
        if (result) {
          done(null, exUser);
        } else {
          done(null, false, { message: '비밀번호가 일치하지 않습니다.' });
        }
      } else {
        done(null, false, { message: '가입되지않은 회원입니다.' });
      }
    } catch (error) {
      console.error(error);
      done(error);
    }
  }))
}

usernameField와 passwordField라는 프로퍼티가 있는데, 이 부분은 username에 대한 받아오는 값, password에 대한 받아오는 값을 정의하는 부분이다. 만약 이 둘과 다르게 요청에 데이터를 보낸다면, 해당 부분의 프로퍼티를 작성해야 한다. 이 부분을 정의했으면, 콜백함수를 적는데, 각각, username, password 그리고 done()을 매개변수로 받는다. username을 기반으로 DB에 값이 있는지 조회를 한 후, 해당 유저가 없는지 확인을 한다. 그 후, DB의 password와 받아온 password를 비교한 후 데이터를 다음 로직으로 넘겨준다.

 

done()이라는 함수에 대해서 궁금증이 생긴다. 이 함수는 뭐지? 이라는 생각이 든다. 일단 이 함수는 다음 로직으로 넘기는 passport에서 정의한 함수다. 예시는 다음과 같다.

done(error);

// 에러가 발생했을 경우 첫번째 인자로 에러를 넣어주고 두번째 인자는 넣지않는다.
done(null, exUser);

// 값이 유효할 경우 첫 번째 인자에 null을 넣고, 두번째 인자로 유효한 값을 넘긴다.
done(null, false, { message: '비밀번호가 일치하지 않습니다.' });

// 값이 유효하지 않을 경우 첫 번째 인자를 null, 두 번째 인자는 false, 세 번째 인자는 해당 오류 메시지를 보낸다.

 

passport/index.js

const passport = require('passport');

const local = require('./localStrategy');
const user = require('../models/index').models.user;

module.exports = () => {
  // 로그인시 실행되며, req.session에 데이터를 저장 즉, 사용자 정보를 세션에 아이디로 저장함.
  passport.serializeUser((user, done) => {
    done(null, user.us_email);
  });

  // 매 요청시 실행됨. 즉, 세션에 저장한 아이디를 통해 사용자 정보를 불러옴.
  passport.deserializeUser((us_email, done) => {
    user.findOne({ where: { us_email } })
      .then(user => done(null, user))
      .catch(err => done(err));
  });

  local();
};

passport에 기본적인 설정을 담아놓는 파일이다. serializeUser()는 로그인시 실행되며, req.session에 데이터를 저장한다.

deserializeUser()는 매 요청시 실행되며, 세션에 저장한 아이디를 통해 사용자 정보를 불러온다.

serializeUser()나 deserializeUser()에는 done()이라는 함수로 인자를 넘겨주게 되는데 첫번째 인자로는 에러값을 넘겨줘야하고 두번째 인자로는 다음 로직을 실행할 때 필요한 데이터를 넘기게 된다.

로컬 회원가입

다음은 회원가입에 대한 소스코드다.

 

routes/login.js의 일부 코드

router.post('/localSignup', isNotLoggedIn, async(req, res, next) => {
  try {
    const str = await loginService.localSignup(req, res, next);
    if (str === '회원가입이 이미 되어 있습니다.') throw new Error(str);
    res.status(200).send(str);
  } catch (error) {
    res.status(500);
    next(error);
  }
});

service/loginService.js

const bcrypt = require('bcrypt');

const user = require('../models/index').models.user;

module.exports = {
  async localSignup(req, res, next) {
    const { us_code, us_email, us_password } = req.body;
    const exCode = await user.findOne({ where: { us_code } }); // ex: us_220112_123456
    if (exCode) {
      return '회원가입이 이미 되어 있습니다.';
    }
    const name = us_email.split('@')[0];
    const hash = await bcrypt.hash(us_password, 12);
    await user.create({
      us_code,
      us_email,
      us_name: name,
      us_password: hash,
      us_admin: 'Y',
      us_workspace: 'ws_220112_123456'
    });
    return '회원가입 완료.';
  }
}

회원가입의 경우 route와 Service단을 따로 나누었다.

로그아웃

routes/login.js의 일부 코드

router.get('/logout', isLoggedIn, (req, res) => {
  try {
    req.logout();
    req.session.destroy(() => {
      res.clearCookie('connect.sid');
      res.status(200).send('로그아웃을 완료하였습니다.');
    });
  } catch (error) {
    res.status(500);
    next(error);
  }
});

특이한점은 req.logout()이다. passport는 로그아웃에 대한 메서드를 따로 요청에 정의하였다고 한다. 그래서 req.logout()을 실행하면, 세션에 대한 정보를 사라지게 한다고 한다. 필자는 Session Store를 사용해서, 세션에 대한 정보를 Session Store에서 없애는 req.session.destroy()를 사용했다. req.session.destroy()는 콜백 함수로, 응답에 쿠키를 강제로 없애라는 메서드와 응답코드, 메시지를 전달한다. 쿠키를 강제로 없애기 때문에 안정성은 보장할 수 없다. 추후에 다시 알아봐야할것 같다.

동작 방식

동작 방식은 다음과 같다.

1. 모듈 적용

const session = require('express-session')
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;

2. 미들웨어 적용

passportConfig(); // passport의 설정 적용

app.use(session({ // 세션 설정 적용
  secret: process.env.SECRET,
  resave: false,
  saveUninitialized: false,
}));

// 이 부분의 설정은 반드시 세션 설정 뒤에 사용해야 한다.
app.use(passport.initialize()); // 요청에 passport 설정을 넣는다.
app.use(passport.session()); // req.session에 passport 정보를 저장한다.

3. route단에서 요청이 들어오면 authenticate() 실행

router.post('/localLogin', isNotLoggedIn, async(req, res, next) => {
  passport.authenticate('local', (authError, user, info) => {
    if (authError) {
      console.error(authError);
      res.status(500);
      return next(authError);
    }
    if (!user) {
      res.status(500);
      return res.send(info.message);
    }
    return req.login(user, (loginError) => {
      if (loginError) {
        console.error(loginError);
        res.status(500);
        return next(loginError);
      }
      return res.send(user);
    });
  })(req, res, next);
});

4. LocalStrategy 실행

module.exports = () => {
  passport.use(new LocalStrategy({
    usernameField: 'us_email',
    passwordField: 'us_password',
  }, async (us_email, us_password, done) => {
    try {
      const exUser = await user.findOne({ where: { us_email } });
      if (exUser) {
        const result = await bcrypt.compare(us_password, exUser.us_password);
        if (result) {
          done(null, exUser);
        } else {
          done(null, false, { message: '비밀번호가 일치하지 않습니다.' });
        }
      } else {
        done(null, false, { message: '가입되지않은 회원입니다.' });
      }
    } catch (error) {
      console.error(error);
      done(error);
    }
  }))
}

5. serialize/deserialize 실행

module.exports = () => {
  // 로그인시 실행되며, req.session에 데이터를 저장 즉, 사용자 정보를 세션에 아이디로 저장함.
  passport.serializeUser((user, done) => {
    done(null, user.us_email);
  });

  // 매 요청시 실행됨. 즉, 세션에 저장한 아이디를 통해 사용자 정보를 불러옴.
  passport.deserializeUser((us_email, done) => {
    user.findOne({ where: { us_email } })
      .then(user => done(null, user))
      .catch(err => done(err));
  });

  local();
};

 

1번과 2번의 경우는 Server를 실행할 시에 적용이 된다.

 

3) Client가 /localLogin에 요청을 보낸다면 route단에서 authenticate('local') 실행

4) LocalStrategy 실행

5) serializeUser() 실행

 

이후에는 로그아웃을 하기 전까지 매 요청을 보낼때마다 deserializeUser()를 실행시킨다.

 

마무리

passport는 여러번 강의를 들었고, 이해하기까지의 시간이 오래걸렸다...

하지만 한번 이해를 하면 정말 편리하게 로그인을 구현할 수 있다.

다음에는 OAuth에 대해서도 정리를 해봐야겠다.

반응형