게시판 - Login 기능 추가

소스코드

이 게시물에는 코드작성이 포함되어 있습니다. 소스코드를 받으신 후 진행해 주세요. MEAN Stack/개발 환경 구축에서 설명된 프로그램들(git, npm, atom editor)이 있어야 아래의 명령어들을 실행할 수 있습니다.

이 게시물의 소스코드는 게시판 만들기 / 게시판 - User Error 처리에서 이어집니다.

board.git 을 clone 한 적이 있는 경우: 터미널에서 해당 폴더로 이동 후 아래 명령어들을 붙여넣기합니다. 폴더 내 모든 코드가 이 게시물의 코드로 교체됩니다. 이를 원치 않으시면 이 방법을 선택하지 마세요.

git reset --hard
git pull
git reset --hard e3bda46
git reset --soft 5ce1cfa
npm install
atom .

board.git 을 clone 한 적이 없는 경우: 터미널에서 코드를 다운 받을 폴더로 이동한 후 아래 명령어들을 붙여넣기하여 board.git 을 clone 합니다.

git clone https://github.com/a-mean-blogger/board.git
cd board
git reset --hard e3bda46
git reset --soft 5ce1cfa
npm install
atom .

- Github에서 소스코드 보기: https://github.com/a-mean-blogger/board/tree/e3bda4629aba20b3c808eb4471bb7409839a3493


passport package를 사용해서 login 기능을 만들어 봅시다.

passport는 node.js에서 user authentication(사용자 인증, 즉 login)을 만들기 위해 널리 사용되는 package입니다. passport는 단독으로 사용할 수 없고, passport strategy package와 함께 사용해야 합니다. passport package는 인증 시스템을 위한 bass package이며, passport strategy package는 구체적인 인증 방법을 구현하는 package입니다.

이렇게 package가 나눠진 이유는 passport strategy package 가 인증 방법별로 수십가지(Facebook strategy, Twitter strategy, 심지어 Naver strategy도 있습니다)가 되기 때문인데, 실제 한 사이트에서 사용하는 strategy는 그 중에 몇 개밖에 안됩니다. 즉 사이트에 필요한 인증 방법만 설치하기 위해서 package를 분리한 것입니다.

이번 포스팅에서는 입력받은 username, password과 DB에 존재하는 data의 값을 비교해서 login을 하는 local strategy를 사용하며 따라서 passport package와 passport-local package 두가지를 설치해 주어야 합니다.

로그인의 기본 원리

웹 프로그램에서 server와 client간의 정보교환은 단발성입니다. 사용자의 browser(client)에서 주소가 입력되거나, link가 click이 되면 server로 요청(request)이 전달되고, server는 요청에 맞는 결과를 응답(response)하게 되는 것이죠.

이렇듯 server와 client의 통신은 연결을 유지하고 있지 않기 때문에 client를 구별하기 위해서는 각각의 request에 고유한 식별코드가 필요합니다. 이 식별코드는 사이트에 처음 접속하는 순간 생성이 되어 client의 브라우저에 저장이 되고, server에 요청을 할때마다 같이 server로 전달됩니다.
서버에는 이 식별코드가 session에 저장되어 어느 client로 부터 요청이 오는지를 구별할 수 있게 됩니다.

로그인을 성공하게 되면 server의 session에 기록이 되고 다음번 request부터는 로그인한 상태로 인식하게 됩니다.

로그인은 DB에 이미 등록되어 있는 user를 찾는 것인데, 로그인시에 DB로 부터 user를 찾아 session에 user 정보의 일부(간혹 전부)를 등록하는 것을 serialize라고 합니다. 반대로 session에 등록된 user 정보로부터 해당 user를 object로 만드는 과정을 deserialize라고 하며, server에 요청이 올때마다 deserialize를 거치게 됩니다.

폴더구조

Package 설치

passport, passport-local package들을 설치해 줍니다.

$ npm install --save passport passport-local

코드 - js

// index.js

...
var session = require('express-session');
var passport = require('./config/passport'); //1
var app = express();

...

// Passport // 2
app.use(passport.initialize());
app.use(passport.session());

// Custom Middlewares // 3
app.use(function(req,res,next){
  res.locals.isAuthenticated = req.isAuthenticated();
  res.locals.currentUser = req.user;
  next();
});

...

1. passport가 아닌, config/passport module를 passport 변수에 담았습니다. ./confing/passport.js는 밑에서 살펴보겠습니다. passport는 와 passport-local package는 index.js에 require되지 않고 config의 passport.js에서 require됩니다.

2. passport.initialize()는 passport를 초기화 시켜주는 함수, passport.session()는 passport를 session과 연결해 주는 함수로 둘다 반드시 필요합니다.(session은 게시판 - User Error 처리에서 설치한 express-session package로부터 생성되므로, 로그인을 구현하기 위해서는 express-session package와 session생성 코드 app.use(session({secret:'MySecret', resave:true, saveUninitialized:true}));가 반드시 필요합니다.)

3. app.use에 함수를 넣은 것을 middleware라고 합니다. 사실 route에 위에 // Other settings에 있는 app.use들도 middleware들이죠.
app.use에 있는 함수는 request가 올때마다 route에 상관없이 무조건 해당 함수가 실행됩니다.
위치가 중요한데, app.use들 중에 위에 있는 것 부터 순서대로 실행되기 때문이죠. route과도 마찬가지로 반드시 route 위에 위치해야 합니다.
app.use에 들어가는 함수는 route에 들어가는 함수와 동일한 req, res, next의 3개의 parameter를 가집니다.
함수안에 반드시 next()를 넣어줘야 다음으로 진행이 됩니다.

req.isAuthenticated()는 passport에서 제공하는 함수로, 현재 로그인이 되어있는지 아닌지를true,false로 return합니다.
req.user는 passport에서 추가하는 항목으로 로그인이 되면 session으로 부터 user를 deserialize하여 생성됩니다.(이 과정 역시 밑에서 살펴보겠습니다.)
res.locals에 위 두가지를 담는데, res.locals에 담겨진 변수는 ejs에서 바로 사용가능합니다.
res.locals.isAuthenticated는 ejs에서 user가 로그인이 되어 있는지 아닌지를 확인하는데 사용되고, res.locals.currentUser는 로그인된 user의 정보를 불러오는데 사용됩니다.

// config/passport.js

var passport = require('passport');
var LocalStrategy = require('passport-local').Strategy; // 1
var User = require('../models/User');

// serialize & deserialize User // 2
passport.serializeUser(function(user, done) {
  done(null, user.id);
});
passport.deserializeUser(function(id, done) {
  User.findOne({_id:id}, function(err, user) {
    done(err, user);
  });
});

// local strategy // 3
passport.use('local-login',
  new LocalStrategy({
      usernameField : 'username', // 3-1
      passwordField : 'password', // 3-1
      passReqToCallback : true
    },
    function(req, username, password, done) { // 3-2
      User.findOne({username:username})
        .select({password:1})
        .exec(function(err, user) {
          if (err) return done(err);

          if (user && user.authenticate(password)){ // 3-3
            return done(null, user);
          }
          else {
            req.flash('username', username);
            req.flash('errors', {login:'The username or password is incorrect.'});
            return done(null, false);
          }
        });
    }
  )
);

module.exports = passport;

1. strategy들은 거의 대부분이 require다음에 .Strategy가 붙습니다. .Strategy없이 사용해도 되는 것도 있는데, 다들 붙여주니까 같이 붙여줍시다.
꼭 붙여야 된다거나, 혹은 다른 단어가 붙는 경우도 있는데, 이런건 https://www.npmjs.com 에서 해당 package를 검색한 후 해당 package의 공식 문서에서 확인할 수 있습니다.

2. passport.serializeUser함수는 login시에 DB에서 발견한 user를 어떻게 session에 저장할지를 정하는 부분입니다. user정보 전체를 session에 저장할 수도 있지만, session에 저장되는 정보가 너무 많아지면 사이트의 성능이 떨어질 수 있고, 회원정보수정을 통해 user object가 변경되더라도 이미 전체 user정보가 session에 저장되어 있으므로 해당 부분을 변경해 주어야하는 등의 문제들이 있으므로 user의 id만 session에 저장합니다.

passport.deserializeUser함수는 request시에 session에서 어떻게 user object를 만들지를 정하는 부분입니다. 매번 request마다 user정보를 db에서 새로 읽어오는데, user가 변경되면 바로 변경된 정보가 반영되는 장점이 있습니다. 다만 매번 request마다 db에서 user를 읽어와야 하는 단점이 있습니다. user정보를 전부 session에 저장하여 db접촉을 줄이거나, 아니면 request마다 user를 db에서 읽어와서 데이터의 일관성을 확보하거나 자신의 상황에 맞게 선택하시면 됩니다.

3. local strategy를 설정하는 부분입니다.

3-1. 만약 로그인 form의 username과 password항목의 이름이 다르다면 여기에서 값을 변경해 주면 됩니다. 사실 이 코드에서는 해당 항목 이름이 form과 일치하기 때문에 굳이 쓰지 않아도 됩니다. 예를들어 로그인 form의 항목이름이 email, pass라면 usernameField : "email", passwordField : "pass"로 해야 합니다.

3-2. 로그인 시에 이 함수가 호출됩니다. DB에서 해당 user를 찾고, user model에 설정했던 user.authenticate 함수를 사용해서 입력받은 password와 저장된 password hash를 비교해서 값이 일치하면 해당 user를 done에 담아서 return하고 (return done(null, user);), 그렇지 않은 경우 username flash와 에러 flash를 생성한 후 done에 false를 담아 return합니다.(return done(null, false);) user가 전달되지 않으면 local-strategy는 실패(failure)로 간주됩니다.

3-3. user.authenticate(password)는 입력받은 password와 db에서 읽어온 해당 user의 password hash를 비교하는 함수로 게시판 - 계정 비밀번호 암호화(bcrypt) 강의에서 bcrypt로 만든 함수입니다.

참고: done함수의 첫번째 parameter는 항상 error를 담기 위한 것으로 error가 없다면 null을 담습니다.

// routes/home.js

var express = require('express');
var router = express.Router();
var passport = require('../config/passport'); // 1

// Home ...

// Login // 2
router.get('/login', function (req,res) {
  var username = req.flash('username')[0];
  var errors = req.flash('errors')[0] || {};
  res.render('home/login', {
    username:username,
    errors:errors
  });
});

// Post Login // 3
router.post('/login',
  function(req,res,next){
    var errors = {};
    var isValid = true;

    if(!req.body.username){
      isValid = false;
      errors.username = 'Username is required!';
    }
    if(!req.body.password){
      isValid = false;
      errors.password = 'Password is required!';
    }

    if(isValid){
      next();
    }
    else {
      req.flash('errors',errors);
      res.redirect('/login');
    }
  },
  passport.authenticate('local-login', {
    successRedirect : '/posts',
    failureRedirect : '/login'
  }
));

// Logout // 4
router.get('/logout', function(req, res) {
  req.logout();
  res.redirect('/');
});

module.exports = router;

1. index.js와 마찬가지로 passport가 아닌 config/passport를 passport 변수에 담았습니다(상대주소라서 여긴 점이 두개입니다). 사실 두군대 중에 한군대만 config/passport를 require해 주면 되는데, 저는 그냥 passport는 무조건 config에서 가져오는 걸로 했습니다.

2. login view를 보여주는 route입니다.

3. login form에서 보내진 post request를 처리해 주는 route입니다. 두개의 callback이 있는데, 첫번째 callback은 보내진 form의 validation을 위한 것으로 에러가 있으면 flash를 만들고 login view로 redirect합니다. 두번째 callback은 passport local strategy를 호출해서 authentication(로그인)을 진행합니다.

4. logout을 해주는 route입니다. passport에서 제공된 req.logout 함수를 사용하여 로그아웃하고 "/"로 redirect합니다.

코드 - ejs

<!-- views/home/login.ejs -->

<!DOCTYPE html>
<html>
  <head>
    <%- include('../partials/head') %>
  </head>
  <body>
    <%- include('../partials/nav') %>

    <div class="container">

      <h3 class="mb-3">Login</h3>

      <form class="user-form" action="/login" method="post">

        <div class="form-group row">
          <label for="username" class="col-sm-3 col-form-label">Username</label>
          <div class="col-sm-9">
            <input type="text" id="username" name="username" value="<%= username %>" class="form-control <%= (errors.username)?'is-invalid':'' %>">
            <% if(errors.username){ %>
              <span class="invalid-feedback"><%= errors.username %></span>
            <% } %>
          </div>
        </div>

        <div class="form-group row">
          <label for="password" class="col-sm-3 col-form-label">Password</label>
          <div class="col-sm-9">
            <input type="password" id="password" name="password" value="" class="form-control <%= (errors.password)?'is-invalid':'' %>">
            <% if(errors.password){ %>
              <span class="invalid-feedback"><%= errors.password %></span>
            <% } %>
          </div>
        </div>

        <% if(errors.login){ %>
          <div class="invalid-feedback d-block"><%= errors.login %></div>
        <% } %>

        <div class="mt-3">
          <input class="btn btn-primary" type="submit" value="Submit">
        </div>

      </form>

    </div>
  </body>
</html>

login view입니다. user form과 형태가 거의 유사합니다.

<!-- views/partials/nav.ejs -->

...
      <ul class="navbar-nav">
        ...
      </ul>
      <ul class="navbar-nav ml-auto">
        <% if(isAuthenticated){ %> <!-- 1 -->
          <li class="nav-item"><a href="/users/<%= currentUser.username %>" class="nav-link">My Account</a></li>
          <li class="nav-item"><a href="/logout" class="nav-link">Logout</a></li>
        <% } else { %>
          <li class="nav-item"><a href="/users/new" class="nav-link">Sign Up</a></li>
          <li class="nav-item"><a href="/login" class="nav-link">Login</a></li>
        <% } %>
      </ul>
    </div>
  </div>
</nav>

1. login이 된 경우(if(isAuthenticated))에는 My Account 메뉴와 Logout 메뉴을 보여주고, login을 하지 않은 경우(else)에는 Sign Up 메뉴와 Login 메뉴를 보여줍니다.

실행결과

로그인 전의 화면입니다.

로그인전에는 우측상단에 Sign Up과 Login 메뉴가 보입니다. login 메뉴를 누르고 로그인을 해줍시다.

로그인을 하고 나면 우측 상단에 My Account와 Logout 메뉴가 보입니다.

마치며..

이번 포스팅에서 꼭 알고 넘어가야 하는 부분은 실제 로그인이 일어날때 코드의 진행순서입니다.

  1. 로그인 버튼이 클릭되면 routes/home.js의 [post] /login route의 코드가 실행됩니다.
  2. 다음으로 config/passport.js의 local-strategy의 코드가 실행됩니다.
  3. 로그인이 성공하면 config/passport.js의 serialize코드가 실행됩니다.
  4. 마지막으로 routes/home.js의 [post] /login route의 successRedirect의 route으로 redirect가 됩니다.
  5. 로그인이 된 이후에는 모든 request가 config/passport.js의 deserialize코드를 거치게 됩니다.

이번 포스팅에서 로그인을 구현했지만, 실제로는 로그인하기 전과 로그인한 후의 차이는 메뉴가 바뀌는 것 밖에 없습니다. 게시판에 글을 등록해도 로그인한 유저로 글을 등록하지 않으며, 다른 사람의 계정의 정보를 수정할 수도 있죠.
다음 포스팅부터는 이러한 기능들을 유저별로 제한 하는 방법에 대해 알아보겠습니다.
긴 글 읽으시느라 수고하셨습니다!

댓글

댓글쓰기

이 글에 댓글을 다시려면 SNS 계정으로 로그인하세요. 자세히 알아보기

UP