본문 바로가기

Node.js

Node.js - Passport 모듈 (Passport Module)

 

Passport

Passport는 이름 그대로 서비스를 사용할 수 있게끔 해주는 여권 같은 역할을 하는 모듈이다. 회원가입과 로그인은 직접 구현할 수도 있지만, 세션과 쿠키 처리 등 복잡한 작업이 많으므로 검증된 모듈을 사용하는 것이 좋다. Passport는 사용하기 좋은 검증된 모듈이다.

 

요즘에는 서비스에 로그인할 때 아이디와 비밀번호 이외에 구글, 페이스북, 카카오 같은 기존의 SNS 서비스 계정을 이용하여 로그인하기도 한다. 이 또한 Passport로 해결할 수 있다. Passport 모듈을 이용하여 자체 회원가입 및 로그인 외에도 카카오톡을 이용해 로그인하는 방법을 알아보자.

 

먼저 passport 관련 패키지들을 설치한다.

 

> npm install passport passport-local passport-kakao bcrypt

 

설치 후 Passport 모듈을 미리 app.js와 연결시켜주자.

 

...

const passport = require('passport');

...

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

...

const app = express();
passportConfig();

...

app.use(passport.initialize());
app.use(passport.session());

...

 

passport.initialize 미들웨어는 요청 (req 객체) 에 passport 설정을 심고, passport.session 미들웨어는 req.session 객체에 passport 정보를 저장한다. req.session 객체는 express-session에서 생성하는 것이므로, passport 미들웨어는 express-session 미들웨어보다 뒤에 연결해야 한다.

 

모듈 코드 작성

passport/index.js 는 다음과 같이 작성해 주었다.

 

const passport = require('passport');
const local = require('./localStrategy');
const kakao = require('./kakaoStrategy');
const User = require('../models/user');

module.exports = () => {
    passport.serializeUser((user, done) => {
        done(null, user.id);
    });

    passport.deserializeUser((id, done) => {
        User.findOne({ where: { id } })
            .then(user => done(null, user))
            .catch(err => done(err));
    });

    local();
    kakao();
}

 

User 모델은 유저 정보를 담는 시퀄라이즈 (MySQL 데이터베이스 관리) 모델이다.

 

모듈 내부를 보자. passport.serializeUserpassport.deserializeUser가 passport의 핵심이다. serializeUser는 로그인 시 실행되며, req.session 객체에 어떤 데이터를 저장할지 정하는 메서드이다. 매개변수로 user를 받아온 후, done 함수에 두 번째 인수로 user.id를 넘기는데, user가 어디서 오는지는 나중에 알아본다.

 

done 함수의 첫 번째 인수는 에러 발생 시 사용하는 것이고, 두 번째 인수에는 저장하고 싶은 데이터를 넣는다. 로그인 시 사용자 데이터를 세션에 저장하는데, 세션에 사용자 정보를 모두 저장한다면 세션의 용량이 커지고 데이터 일관성에 문제가 발생하므로 사용자의 아이디만 저장하라고 명령한 것이다.

 

serializeUser가 로그인 시에만 실행된다면 deserializeUser는 매 요청 시 실행된다. passport.session 미들웨어가 이 메서드를 호출하는데, serializeUser의 done의 두 번째 인수로 넣었던 데이터 (이 경우 id) 가 곧 deserializeUser의 매개변수가 된다. 조금 전 serializeUser에서 세션에 저장했던 아이디를 받아 데이터베이스에서 사용자 정보를 조회한다. 조회한 정보를 req.user에 저장하므로 앞으로 req.user를 통해 로그인한 사용자의 정보를 가져올 수 있다.

 

즉 serializeUser는 사용자 정보 객체를 세션에 아이디로 저장하고, deserializeUser는 세션에 저장한 아이디를 통해 사용자 정보 객체를 불러온다. 이는 세션에 불필요한 데이터를 담는 것을 방지하게 해준다.

 

로그인의 과정은 다음과 같다.

  1. 라우터를 통해 로그인 요청이 들어옴
  2. 라우터에서 passport.authenticate 메서드 호출
  3. 로그인 전략 수행
  4. 로그인 성공 시 사용자 정보 객체와 함께 req.login 호출
  5. req.login 메서드가 passport.serializeuser 호출
  6. req.session에 사용자 아이디만 저장
  7. 로그인 완료

로그인 이후의 과정은 다음과 같다.

  1. 요청이 들어옴
  2. 라우터에 요청이 도달하기 전 passport.session 미들웨어가 passport.deserializeUser 메서드를 호출
  3. req.session에 저장된 아이디를 바탕으로 데이터베이스에서 사용자 조회
  4. 조회된 사용자 정보를 req.user에 저장
  5. 라우터에서 req.user 객체 사용 가능

 

로컬 로그인 구현

로컬 로그인이란 자체적인 회원가입을 의미힌다. 즉, 아이디/비밀번호 또는 이메일/비밀번호를 통한 로그인을 뜻한다. Passport에서 이를 구현하기 위해선 passport-local 모듈이 필요하다.

 

로그인에 해당하는 전략을 짜야하는데, 이는 로그인에만 해당하는 전략이므로 회원가입은 따로 만들어야 한다. 회원가입, 로그인, 로그아웃 라우터를 먼저 만들어보자. 이러한 라우터에는 접근 조건이 있는데, 로그인한 사용자는 회원가입과 로그인 라우터에 접근하면 안되며, 로그인을 하지 않은 사용자는 로그아웃 라우터에 접근하면 안된다. 따라서 라우터에 접근 권한을 제어하는 미들웨어가 필요하다. 이를 미리 만들어주자.

 

Passport는 req객체에 isAuthenticated라는 메서드를 만들어준다. 이를 사용하여 routes/middlewares.js에 다음과 같은 코드를 작성해준다.

 

exports.isLoggedIn = (req, res, next) => {
    if (req.isAuthenticated()) {
        next();
    } else {
        res.status(403).send('로그인 필요');
    }
};

exports.isNotLoggedIn = (req, res, next) => {
    if (!req.isAuthenticated()) {
        next();
    } else {
        const message = encodeURIComponent('로그인한 상태입니다.');
        res.redirect(`/?error=${message}`);
    }
};

 

로그인이 되어있다면 req.isAuthenticated()가 true일 것이고, 그렇지 않다면 false일 것이다. 따라서 이 메서드를 통해 로그인 여부를 파악할 수 있다. 후에 라우터에 로그인 여부를 검사하는 위 미들웨어들을 넣어 원하지 않는 상황들을 방지할 수 있을 것이다.

 

이제 이 미들웨어들을 라우터에서 사용해보자.

 

const { isLoggedIn, isNotLoggedIn } = require('./middlewares');

...

router.use((req, res, next) => {
    res.locals.user = req.user;
    ...
    next();
});

router.get('/profile', isLoggedIn, (req, res) => {
    ...
});

router.get('/join', isNotLoggedIn, (req, res) => {
    ...
});

 

라우터에 접근할 때, 요청을 본 후, 로그인 여부에 따라 해당 페이지에 접근할 수 있게 할 지 접근하지 못하게 할 지를 위와 같이 미들웨어로써 넣어주었다. /profile 경로로 접근하였을 때 로그인이 되어있지 않다면 접근할 수 없도록 해야 할 것이기 때문에 isLoggedIn을 미들웨어로 넣어주었고, /join 경로로 접근하였을 때 로그아웃이 되어있지 않다면 (즉, 현재 로그인한 상태라면) 접근할 수 없도록 해야하기 때문에 isNotLoggedIn을 미들웨어로 넣어주었다.

 

회원가입, 로그인, 로그아웃 라우터를 각각 작성해주자.

 

아래는 routes/auth.js이다 (회원가입 라우터)

 

const express = require('express');
const passport = require('passport');
const bcrypt = require('bcrypt');
const { isLoggedIn, isNotLoggedIn } = require('./middlewares');
const User = require('../models/user');

const router = express.Router();

router.post('/join', isNotLoggedIn, async (req, res, next) => {
    const { email, nick, password } = req.body;
    try {
        const exUser = await User.findOne({ where: { email } });
        if (exUser) {
            return res.redirect('./join?error=exist');
        }
        const hash = await bcrypt.hash(password, 12);
        await User.create({
            email,
            nick,
            password: hash,
        });
        return res.redirect('/');
    } catch (err) {
        console.error(err);
        return next(err);
    }
});

router.post('/login', isNotLoggedIn, (req, res, next) => {
    passport.authenticate('local', (authError, user, info) => {
        if (authError) {
            console.error(authError);
            return next(authError);
        }
        if (!user) {
            return res.redirect(`/?loginError=${info.message}`);
        }
        return req.login(user, (loginError) => {
            if (loginError) {
                console.error(loginError);
                return next(loginError);
            }
            return res.redirect('/');
        });
    })(req, res, next); //미들웨어 내의 미들웨어에는 (req, res, next) 를 붙여준다.
});

router.get('/logout', isLoggedIn, (req, res) => {
    req.logout();
    req.session.destroy();
    res.redirect('/');
});

module.exports = router;

 

회원가입 라우터는 기존에 같은 이메일로 가입한 사용자가 있는지 먼저 조회한 후, 있다면 회원가입 페이지로 되돌려보낸다. 이 경우 주소 뒤에 에러를 쿼리스트링으로 퓨시한다. 같은 이메일로 가입한 사용자가 없다면 비밀번호를 암호화하고 사용자 정보를 생성한다. 회원가입 시의 비밀번호는 암호화하여 저장해야 한다. bcrypt 모듈을 사용했는데, crypto 모듈의 pbkdf2 메서드를 사용할 수도 있다. bcrypt 모듈의 hash 메서드는 손쉽게 비밀번호를 암호화하도록 해준다. bcrypt의 두 번째 인수는 pbkdf2의 반복 횟수와 비슷한 기능을 한다. 숫자가 커질수록 비밀번호를 알아내기 어려워지지만 암호화 시간도 오래 걸린다. 12 이상을 추천하며, 31까지 사용할 수 있다. 프로미스를 지원하는 함수이므로 await을 사용했다.

 

로그인 라우터의 경우 로그인 요청이 들어왔을 때, passport.authenticate('local') 미들웨어가 로컬 로그인 전략을 수행한다. 미들웨어인데 라우터 미들웨어 안에 들어가 있다. 미들 웨어가 사용자 정의 기능을 추가하고 싶을 때 이렇게 할 수 있다. 이럴 때 내부 미들웨어에 (req, res, next)를 인수로 제공하여 호출하면 된다. 전략이 성공하거나 실패하면 authenticate 메서드의 콜백 함수가 실행된다. 콜백 함수의 첫 번째 매개 변수인 authErr 값이 있다면 실패한 것이며, 두 번째 매개변수 값이 있다면 성공한 것이다. 성공한 경우 req.login 메서드를 호출한다. Passport는 req 객체에 login과 logout 메서드를 추가하는데, login은 passport.serializeUser을 호출하며, req.login에 제공하는 user 객체가 serializeUser로 넘어가게 된다. 위의 passport/index.js에서 다룬 user 객체가 바로 이 객체이다.

 

로그아웃 라우터는 req.logout 메서드를 호출하며, 이 경우 req.user 객체를 제거하고, req.session.destroy는 req.session 객체의 내용을 제거한 후, 메인 페이지로 돌아간다.

 

passport/localStrategy.js는 다음과 같다.

 

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

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

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

 

passport-local 모듈에서 Strategy 생성자를 불러와 전략을 구현했다.

 

LocalStrategy 생성자의 첫 번째 인수는 전략에 관한 설정이다. usernameField와 passwordField에는 일치하는 로그인 라우터의 req.body 속성명을 적으면 된다. req.body.email에 이메일 주소가, req.body.password에 비밀번호가 담겨 들어오므로, email과 password를 각각 넣어주었다.

 

실제 전략은 async 함수에서 실행된다. LocalStrategy 생성자의 두 번째 인수인데, 첫 번째 인수에서 넣어준 email과 password는 각각 async 함수의 첫 번째와 두 번째 매개변수가 된다. 세 번째 매개변수인 done 함수는 passport.authenticate의 콜백 함수이다.

 

전략은 다음과 같다. 먼저 데이터베이스에서 일치하는 이메일이 있는지를 찾은 후, 있다면 bcrypt의 compare 함수로 비밀번호를 비교한다. 비밀번호가 일치한다면 done 함수의 두 번째 인수로 사용자 정보를 넣어 보낸다. 두 번째 인수를 사용하지 않는 경우는 로그인에 실패했을 때 뿐이다. done 함수의 첫 번째 인수를 사용하는 경우는 서버 쪽에서 에러가 발생했을 때이고, 세 번째 인수를 사용하는 경우는 로그인 처리 과정에서 비밀번호가 일치하지 않거나 존재하지 않는 회원일 때와 같은 사용자 정의 에러가 발생했을 때이다.

 

카카오 로그인 구현

카카오 로그인은 로그인 인증 과정을 카카오에 맡긴다. 사용자는 번거롭게 새로운 사이트에 회원가입할 필요가 없어 좋고, 서비스 제공자는 로그인 과정을 검증된 SNS에 안심하고 맡길 수 있어 좋다.

 

SNS 로그인의 특징은 회원가입 절차가 따로 없다는 것이다. 처음 로그인할 때에는 회원가입 처리를 해야하고, 두 번째 로그인부터는 로그인 처리를 해야한다. 따라서 SNS 로그인 전략은 로컬 로그인 전략보다는 다소 복잡하다.

 

passport/kakaoStrategy.js를 다움과 같이 작성해준다.

 

const passport = require('passport');
const KakaoStrategy = require('passport-kakao').Strategy;

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

module.exports = () => {
    passport.use(new KakaoStrategy({
        clientID: process.env.KAKAO_ID,
        callbackURL: '/auth/kakao/callback'
    }, async (accessToken, refreshToken, profile, done) => {
        console.log('kakao profile', profile);
        try {
            const exUser = await User.findOne({
                where: { snsId: profile.id, provider: 'kakao' }
            });
            if (exUser) {
                done(null, exUser);
            } else {
                const newUser = await User.create({
                    email: profile._json && profile._json.kaccount_email,
                    nick: profile.displayName,
                    snsId: profile.id,
                    provider: 'kakao'
                });
                done(null, newUser);
            }
        } catch (err) {
            console.error(err);
            done(err);
        }
    }));
};

 

로컬 로그인과 마찬가지로 첫 번째 인수로 카카오 로그인에 대한 설정을 한다. clientID는 카카오에서 발급해주는 아이디이다. 노출되지 않아야하므로 .env 파일에 넣어줄 것이다. callbackURL은 카카오로부터 인증 결과를 받을 라우터 주소이다.

 

그 후로 들어오는 두 번째 인수가 실행된다. 먼저, 기존에 카카오를 통해 회원가입한 사용자가 있는지를 profile.id로 조회한다. 있다면 이미 회원가입이 되어 있는 경우이므로 사용자 정보와 함께 done 함수를 호출하고 전략을 종료한다.

 

카카오를 통해 회원가입한 사용자가 없다면 해당 사용자의 회원가입을 자동으로 진행한다. 카카오에서는 인증 후 callbackURL에 적힌 주소로 accessToken, refreshToken과 profile을 보내는데, profile에 사용자 정보들이 들어있다. 카카오에서 보내주는 것이므로 후에 콘솔에서 확인할 수 있도록 로그에 찍도록 해놓았다. 이후 원하는 정보들을 profile 객체에서 꺼내와 회원가입을 진행하면 된다. 사용자를 생성한 뒤 done 함수를 호출한다.

 

이제 카카오 로그인 라우터를 routes/auth.js 파일에 다음과 같이 추가해주자.

 

...

router.get('/logout', isLoggedIn, (req, res) => {
    req.logout();
    req.session.destroy();
    res.redirect('/');
});

router.get('/kakao', passport.authenticate('kakao'));

router.get('/kakao/callback', passport.authenticate('kakao', {
    failureRedirect: '/'
}), (req, res) => {
    res.redirect('/');
});

 

이제 GET /auth/kakao로 접근한다면 카카오 로그인 과정이 시작될 것이다. 이를 위해서 views/layout.html의 카카오톡 버튼에는 /auth/kakao 링크가 붙어 있다. GET /auth/kakao에서 로그인 전략을 수행하는데, 이 때 카카오 로그인 창으로 리다이렉트한다. 그 창에서 로그인 후, 성공 여부 결과를 GET /auth/kakao/callback으로 받는다. 이 라우터에서는 카카오 로그인 전략을 다시 수행한다.

 

로컬 로그인과 다른 점은 passport.authenticate 메서드에 콜백 함수를 제공하지 않는다는 점이다. 카카오 로그인은 로그인 성공 시 내부적으로 req.login을 호출하므로, 우리가 직접 호출할 필요가 없다. 콜백 함수 대신 로그인에 실패했을 때 어디로 이동할지를 failureRedirect 속성에 적어주었다. 성공했을 때에도 어디로 이동할지를 미들웨어에 적어주었다.

 

이제 auth 라우터를 app.js에 연결해주자.

 

...

const pageRouter = require('./routes/page');
const authRouter = require('./routes/auth');

...

app.use('/', pageRouter);
app.use('/auth', authRouter);

...

 

카카오 로그인을 위해서는 카카오 개발자 계정과 로그인용 애플리케이션 등록이 필요하다. https://developers.kakao.com 에 접속하여 개발자 계정을 만들고 아이디를 만들어주자.

https://developers.kakao.com/docs/latest/ko/kakaologin/common#:~:text=%EC%B9%B4%EC%B9%B4%EC%98%A4%20%EB%A1%9C%EA%B7%B8%EC%9D%B8%EC%9D%80%20%EC%B9%B4%EC%B9%B4%EC%98%A4%EA%B3%84%EC%A0%95,%EC%97%90%20%EB%A1%9C%EA%B7%B8%EC%9D%B8%ED%95%A0%20%EC%88%98%20%EC%9E%88%EC%8A%B5%EB%8B%88%EB%8B%A4.

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

이 글을 참고하자.

 

이후 카카오 로그인을 진행하면 잘 되는 것을 볼 수 있다.

 

카카오 로그인 이외에도 구글 (passport-google-oauth2), 페이스북 (passport-facebook), 네이버 (passport-naver), 트위터 (passport-twitter) 로그인도 가능하다. npm에서 찾아서 사용하면 된다.

 

 

출처

Node.js 교과서 개정 2판 - 길벗, 조현영

https://developers.kakao.com/docs/latest/ko/kakaologin/common#:~:text=%EC%B9%B4%EC%B9%B4%EC%98%A4%20%EB%A1%9C%EA%B7%B8%EC%9D%B8%EC%9D%80%20%EC%B9%B4%EC%B9%B4%EC%98%A4%EA%B3%84%EC%A0%95,%EC%97%90%20%EB%A1%9C%EA%B7%B8%EC%9D%B8%ED%95%A0%20%EC%88%98%20%EC%9E%88%EC%8A%B5%EB%8B%88%EB%8B%A4.