먹고 기도하고 코딩하라

Passport.js 로컬 인증 방식 사용법과 동작 흐름 본문

Javascript

Passport.js 로컬 인증 방식 사용법과 동작 흐름

사과먹는사람 2020. 8. 20. 14:43
728x90
728x90

 

Passport.js는 Node.js를 위한 인증 미들웨어이다. OAuth를 이용한 구글, 페이스북, 트위터, 혹은 전통적인 로컬 인증 방법(DB에 저장된 계정 정보로 로그인) 등 여러 가지 인증 방법을 제공하는데 이 개별 인증 방법을 Strategy라고 부른다. 현재 500개가 넘는 인증 방법을 제공하고 있다. 네이버, 카카오도 가능하다. Strategy는 passport 의존성과 별개로 설치를 해줘야 한다.

여기서는 OAuth를 통한 인증 방법은 다루지 않고 로컬 인증 방법만 다룬다. 추후에 OAuth 인증 방법을 다시 다룰 예정이다.

 

 

설치

> npm i passport
const express = require('express');
const app = express();
const passport = require('passport');

app.post('/login',
  passport.authenticate('local'),
  function(req, res) {
    // If this function gets called, authentication was successful.
    // `req.user` contains the authenticated user.
    res.redirect('/users/' + req.user.username);
  });

Passport 문서에 나온 첫 예시이다. vscode에서는 passport.authenticate를 자동완성해줄 때 첫 번째 인자로 단일 strategy(String)나 strategy 배열(String[]), 두 번째 인자로는 콜백 함수를 등록하게 되어 있다.

위의 예제에서는 요청과 응답 객체를 받은 함수를 두 번째 인자로 줬다. 인증이 성공하면 이 함수가 호출되며 req.user에 인증된 사용자의 정보가 들어간다. 만약 이렇게 함수를 줬는데 인증이 실패한다면 401 Unauthorized 에러로 응답하게 된다. 

 

app.post('/login', passport.authenticate('local', { 
  successRedirect: '/',
  failureRedirect: '/login' 
}));

여기서는 함수 대신 객체를 줬다. 콜백 함수를 없애고 그냥 오버라이딩한 것이다. passport 모듈의 authenticate 를 통해 인증이 성공했을 때와 실패했을 때 리다이렉션할 경로를 지정하게 된다.

'/login' 에 POST 메소드로 접근했을 때 passport.authenticate 함수를 호출하게 되는데, 위 예제에서는 local strategy를 사용한다고 쓰여 있다. 로컬 방식은 username, password를 이용한 로그인 방식이다. 성공했을 때 '/', 실패했을 때는 다시 '/login'으로 리다이렉션하게 되어 있다. 이 경우는 실패했을 때 그냥 리다이렉션을 하는 방식으로, 401 에러가 아니다.

passport.authenticate 함수는 성공했을 때 기본적으로 req.login() 함수를 호출하게 된다. 

 

 

설정

위에서 passport 모듈을 설치했지만 바로 쓸 수 있는 것은 아니다. 아직 strategy를 설치하지도 않았고 앱 내에서 설정하지 않았기 때문이다. passport.use()를 통해 어떤 strategy를 쓸지 정하고 인증에 성공/실패했을 때의 경우도 챙겨줘야 한다.

여기서는 로컬 인증 방식을 써 보자.

 

Verify callback

> npm i passport-local
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;

passport.use(new LocalStrategy(function (username, password, done) {
  User.findOne({ username: username }, function (err, user) {
    //DB 연결 실패 등의 에러
    if (err) { return done(err); }
    //username 자체가 DB에 없을 때
    if (!user) {
      return done(null, false, { message: 'Incorrect username.' });
    }
    //username은 맞지만 비밀번호가 틀릴 때
    if (!user.validPassword(password)) {
      return done(null, false, { message: 'Incorrect password.' });
    }
    //인증 성공
    return done(null, user);
  });
}));

여기서 function (username, password, done)을 verify callback이라고 부르나 보다. username, password를 토대(credential)로 사용자를 찾는 것이 그 목표다. 

여기서는 done의 호출에 따라 passport.authenticate() 호출 시 인증 결과가 달라진다. 가령 실패는 done(null, false)이다. done(null, user)는 로그인이 성공했을 때의 일인데, done의 두 번째 인자가 명시적으로 false가 아니면 일단 true로 친다.

만약 DB에 연결하는 데 실패했거나 유효하지 않은 DB라면 아예 에러를 만든다.

return done(err);

만약 username과 password가 맞다면 done(null, user)를 return한다.

return done(null, user);

만약 유효하지 않다면 done(null, false)를 return한다.

return done(null, false);

그냥 done(null, false)를 해도 되긴 하는데 왜 실패했는지 알려주고 싶다면 객체로 메시지를 넘길 수 있다. 이 메시지는 뒤에 살펴볼 플래시 메시지로 띄우는 데 도움이 된다.

return done(null, false, { message: 'Incorrect password.' });

만약 form에서 input 태그의 name이 username과 password가 아니고 email, pwd라면 어떨까? 기본적으로는 username, password만 받기 때문에 변경해줘야 한다.

이럴 때는 아래와 같이 객체를 하나 추가해주면 된다.

passport.use(new LocalStrategy({
  usernameField: 'email',
  passwordField: 'passwd'
  }, function (username, password, done) {
    //...
  }
));

 

Initialize

passport.initialize() 미들웨어를 앱에서 사용해줘야 한다. 만약 앱에서 로그인 세션을 유지한다면, passport.session() 역시 같이 사용해줘야 한다. passport.session()을 사용할 때 주의할 점은 내부적으로 세션을 이용하는 미들웨어이기 때문에 이것을 쓰기 전에 반드시 app.use(session())을 먼저 해주는 게 맞는 순서라는 점이다. 

 

> npm i express-session
const session = require('express-session');

app.use(session({ secret: 'keyboard cat' }));
app.use(passport.initialize());
app.use(passport.session());

 

세션

인증이 성공하면 로그인 세션이 만들어지고 사용자 브라우저에 쿠키로 유지된다. 사용자 인증에 성공하면 사용자 정보를 세션에 저장해둘 수 있고, 이 저장된 정보를 세션으로부터 불러올 수 있다. 이게 로그인 세션을 유지하기 위한 것인데, 저장은 serialize이고 불러오는 일은 deserialize이다. 

Serialize가 뭐지? 하고 검색해봤는데 직렬화란다. 뜻으로는 느낌이 확 와닿지는 않는데, 객체를 저장하거나 통신으로 넘길 수 있게 만드는 것이라고 한다. 자세한 설명은 이 블로그를 참고하길 바란다.

로그인에 성공하면 serializeUser에 등록된 콜백함수를 실행한다. 이 함수는 인증이 성공했을 때 최초 1회만 호출된다. 이 함수의 첫 번째 매개변수 user는 passport.use(new LocalStrategy(function (username, password, done) { }))에서 로그인이 성공했을 때 반환하는 done(null, user)에서의 두 번째 매개변수인 user이다. 

passport.serializeUser(function (user, done) {
  done(null, user.id);
});

이 경우 user.id만 세션에 저장된다. 이유는 세션에 가능한 적은 데이터를 저장해서 세션 크기를 작게 유지하기 위함이라고 한다.

이제 세션 파일을 확인해 보면 "passport": { "user": user.id }이 추가되어 있는 것을 확인할 수 있다.

deserializeUser도 콜백 함수 하나를 인자로 받는다. 이 콜백 함수는 인증이 성공한 후 로그인을 유지하며 사용자가 웹페이지에 방문할 때마다 호출된다.

passport.deserializeUser(function (id, done) {
  User.findById(id, function (err, user) {
    done(err, user);
  });
});

세션에 저장된 user.id로 user를 찾는다. User.findById를 하게 되면 연결된 DB에서 user.id로 사용자를 찾게 된다. 

확인해 보면 req.user에는 user가 들어가게 된다. 만약 done(err, id)로 바꾸게 되면 req.user에는 id 정보만 들어가게 된다. 여기서 done의 두 번째 매개변수가 req.user로 들어가게 되는 것이다.

DB SELECT 연산이 계속 일어나기 때문에 아예 세션 자체에 user를 넣어버리는 것이 더 좋을 수 있다는 의견이 있다. 내 생각에도 user 객체가 아주 크지 않은 이상 이 방법이 좋을 것 같다. 이 의견에 관련된 글은 여기서 볼 수 있다. 

 

 

사용자의 인증 상태 확인

세션을 확인할 수도 있지만, passport를 이용해서 인증을 하게 되면 req.user에 사용자 정보가 들어가게 된다. 인증이 되지 않은 상태일 때 req.user는 undefined, 즉 falsy한 값이다. 그래서 다음과 같이 쓸 수 있다.

if (req.user) {
  //인증됐을 때
} else {
  //인증이 안 됐을 때
}

 

 

플래시 메시지

401 에러를 띄우는 대신 리다이렉션을 하기로 했다면, 사용자가 로그인에 실패했을 때 바로 '/login' 페이지로 리다이렉션된다. 사용자는 계속 같은 화면이 폼이 빈 채로 나오니 어렴풋이 뭔가 잘못됐음을 알 수도 있지만 명시적이지는 않다. 이럴 때 뭐 때문에 틀렸는지 1회성으로 원인을 알려주면 좋을 것이다. 그럴 때 쓰기 좋은 것이 바로 플래시 메시지이다.

플래시 메시지를 사용하려면 일단 authenticate 함수의 두 번째 인자 객체 안에 failureFlash를 true로 줘야 한다.

app.post('/login', passport.authenticate('local', {
  successRedirect: '/',
  failureRedirect: '/login',
  failureFlash: true
});

마찬가지로 successFlash 도 쓸 수 있다. 

passport.authenticate('local', { successFlash: 'Welcome!' });

플래시 메시지를 사용하려면 req.flash() 함수를 써야 한다. Express 3부터 flash가 없어졌기 때문에 따로 설치해야 하며 내부적으로 세션을 이용하기 때문에 session 미들웨어를 먼저 놔야 한다.

> npm i connect-flash
const flash = require('connect-flash');

app.use(session({ secret: 'keyboard cat' }));
app.use(flash());

flash 미들웨어까지 사용했다. 이제 쓰는 방법을 다시 보겠다. Verify callback이 있던 쪽으로 간다. 이 'Incorrect password.'를 플래시 메시지로 띄우고 싶다고 가정해 보자.

return done(null, false, { message: 'Incorrect password.' });

접근하는 방법은 간단하다. 플래시 메시지는 세션에 저장되고 일회성이기 때문에 한 번 읽으면 지워진다.

req.flash('msg', 'Hello World!');
req.flash();	//{'msg': ['Hello World!']}
//혹은
req.flash('msg');	//['Hello World!']
//한 번 조회하면 사라진다
req.flash();	//{}

req.flash() 안에 인자가 두 개라면 첫 번째는 key, 두 번째는 value이다.

그리고 req.flash() 안에 인자가 하나라면 그 인자를 key로 해서 value를 return한다. 

인증이 실패했을 때 done(null, false, { message: 'Incorrect password.' })를 return하게 되고 객체 키가 'message'이기 때문에 왠지 req.flash('message')로 접근해야 할 것 같지만 실제로는 req.flash().error를 쓰게 된다. 세션 파일을 열고 직접 확인해보면 req.flash().error에 ['Incorrect password.']가 있다. 위에서 보다시피 value가 그냥 들어가는 것이 아니고 배열 안에 들어가는 형태이기 때문에 req.flash().error[0] 이렇게 쓰면 바로 문자열만 추출이 가능하다.

 

 

로그아웃

로그아웃은 아주 쉽다. 로그인 세션을 종료하기 위해 req.logout()을 쓰면 된다. req.logout()을 하게 되면 req.user 프로퍼티를 비우고 로그인 세션을 없애 버린다.

app.get('/logout', (req, res) => {
  req.logout();
  req.session.save((err) => {
    if (err) throw err;
    res.redirect('/');
  });
});

여기서 req.session.save를 하고 콜백 함수를 주는 이유는 req.logout한 상태를 세션에 저장하기 위한 것이다. 세션에 저장된 후에야 등록된 콜백 함수가 실행되므로 리다이렉션은 로그아웃이 완료되어 세션에 반영된 후에 이루어진다.

 

 

순서

다음은 passport를 이용한 인증 로직을 담은 코드이다.

//(1) 모듈 가져오기
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const flash = require('connect-flash');
const session = require('express-session');

//(2) 미들웨어 사용 (세션 -> 플래시 -> 패스포트 순)
app.use(session({ secret: 'keyboard cat' }));
app.use(flash());
app.use(passport.initialize());
app.use(passport.session());	//passport에서 세션 사용

//(3) serialize/deserialize 작성
//사용자 식별자 세션에 저장
passport.serializeUser((user, done) => {
  done(null, user.id);
  //session에 user.id 저장
});
//사용자가 페이지를 조회할 때마다 식별자로 사용자 확인
passport.deserializeUser((id, done) => {
  User.findById(id, (err, user) => {
  //DB에서 SELECT 연산
    done(err, user);
    //이 때 두 번째 인자인 user가 req.user가 됨
  });
});

//(4) Verify callback 작성
passport.use(new LocalStrategy({
  usernameField: 'email',
  passwordField: 'pwd'
  //이 객체는 form input name을 username과 password로 쓰지 않을 때만 선택적으로 사용
  //POST 메소드로 넘어온 폼 데이터들을 받음
}, (username, password, done) => {
  //인증 성공/실패 여부 판가름
  //여기서의 done(null, false), done(null, user) 등의 반환값은 passport.authenticate로 넘어감
  //두 번째 인자가 명시적으로 false가 아니면 true로 침
});

//(5) authenticate 작성
app.post('/login', passport.authenticate('local', {
  successRedirect: '/',
  failureRedirect: '/login',
  failureFlash: true
});

//(6) 로그아웃
app.get('/logout', (req, res) => {
  req.logOut();
  req.session.save(err => {
    if (err) throw err;
    res.redirect('/');
  });
});

일단 (1), (2)는 앱 시작과 동시에 이루어진다.

그리고 사용자가 '/login' 경로에서 submit을 했다면, 그 때는 (5) passport.authenticate -> (4) LocalStrategy -> (3) serializeUser의 순으로 이루어진다. 이후로는 로그아웃이 될 때까지 deserializeUser 를 매 페이지 방문 시 호출하게 된다.

 

Passport가 처음에는 조금 복잡해 보이는데 차근차근 순서를 정리해 보면 배워서 써먹을 만한 가치가 충분한 미들웨어이다. 나도 아직은 조금 헷갈리고 OAuth 인증 방식을 쓰기 전이지만 왠지 든든하다. ㅋㅋ

 

 

 

References

 

 

 

728x90
반응형
Comments