목록2018/01 (2)

Node.js + passport.js 를 이용한 소셜 로그인 구현 (페이스북, 깃허브, 구글)

기존 [마크다운 에디터 리뉴얼 관련 포스트](https://massivcode.com/8) 에서 1차 리뉴얼 때 사용했던 passport.js 관련 포스트. 로그인 프로세스는 다음과 같다. 1. 로그인 페이지 진입 2. 특정 소셜 로그인 엘리먼트 클릭 (페이스북, 깃허브, 구글) 3. 해당하는 라우트로 이동 및 passport 미들웨어 호출 4. 각 로그인 플랫폼에 대응하는 콜백 라우트 정의 및 진입시 미들웨어 호출 5. 로그인 성공시 처리 ## 1. npm 종속성 --- ```npm { "name": "markdown-editor", "version": "0.0.0", "private": true, "scripts": { "start": "pm2-dev start ecosystem.config.js", "deploy": "npm install && pm2 start ecosystem.config.js --env production" }, "dependencies": { "bcryptjs": "^2.4.3", "body-parser": "~1.18.2", "cookie-parser": "~1.4.3", "debug": "~2.6.9", "ejs": "~2.5.7", "express": "~4.15.5", "express-session": "^1.15.6", "jsonwebtoken": "^8.1.0", "morgan": "~1.9.0", "mysql2": "^1.5.1", "passport": "^0.4.0", "passport-facebook": "^2.1.1", "passport-github": "^1.1.0", "passport-google-oauth": "^1.0.0", "request-ip": "^2.0.2", "sequelize": "^4.28.6", "serve-favicon": "~2.4.5" }, "devDependencies": { "babel-cli": "^6.26.0", "babel-preset-env": "^1.6.1", "babel-preset-es2015": "^6.24.1" } } ``` ## 2. app.js - passport.js 관련 설정 --- ```javascript "use strict"; const express = require('express'); const path = require('path'); const favicon = require('serve-favicon'); const logger = require('morgan'); const cookieParser = require('cookie-parser'); const bodyParser = require('body-parser'); const SESSION_SECRET_KEY = require('./config/SecureConfig').SESSION_SECRET_KEY; const session = require('express-session'); const passport = require('passport'); ... const app = express(); // view engine setup app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); // uncomment after placing your favicon in /public app.use(favicon(path.join(__dirname, 'public', 'favicon.ico'))); app.use(logger('dev')); app.use(bodyParser.json({limit: '50mb'})); app.use(bodyParser.urlencoded({extended: false, limit: '50mb'})); app.use(cookieParser()); app.use('/markdown', express.static(path.join(__dirname, 'public'))); app.use(session({ secret: SESSION_SECRET_KEY, resave: false, saveUninitialized: true })); require('./config/passport')(passport); app.use(passport.initialize()); app.use(passport.session()); //로그인 세션 유지 ... module.exports = app; ``` ## 3. passport.js - passport 설정 모듈 --- ```javascript const FacebookStrategy = require('passport-facebook').Strategy; const GithubStrategy = require('passport-github').Strategy; const GoogleStrategy = require('passport-google-oauth').OAuth2Strategy; const userService = require('../service/userService'); module.exports = (passport) => { passport.serializeUser((user, done) => { done(null, user); }); passport.deserializeUser((user, done) => { done(null, user); }); passport.use(new FacebookStrategy({ clientID: "", clientSecret: "", profileFields: ['id', 'displayName', 'photos'], callbackURL: 'http://localhost:3000/markdown/auth/facebook/callback' }, function (accessToken, refreshToken, profile, done) { const socialId = profile.id; const nickname = profile.displayName; const profileImageUrl = profile.photos[0].value; onLoginSuccess('Facebook', socialId, nickname, profileImageUrl, done); } )); passport.use(new GithubStrategy({ clientID: "", clientSecret: "", callbackURL: 'http://localhost:3000/markdown/auth/github/callback' }, function (accessToken, refreshToken, profile, done) { const socialId = profile.id; const nickname = profile.username; const profileImageUrl = profile.photos[0].value; onLoginSuccess('Github', socialId, nickname, profileImageUrl, done); } )); passport.use(new GoogleStrategy({ clientID: "", clientSecret: "", callbackURL: 'http://localhost:3000/markdown/auth/google/callback', scope: ['https://www.googleapis.com/auth/plus.me'] }, function (accessToken, refreshToken, profile, done) { const socialId = profile.id; const nickname = profile.displayName; const profileImageUrl = profile.photos[0].value; onLoginSuccess('Google', socialId, nickname, profileImageUrl, done); } )); function onLoginSuccess(platformName, socialId, nickname, profileImageUrl, done) { userService.findOrCreate(platformName, socialId, nickname, profileImageUrl) .spread((user, created) => { if (user.state === 1) { userService.updateUserToJoinedState(user) .then(result => { done(null, user); }) .catch(err => { done(null, user); }) } else { done(null, user); } }); } }; ``` 클라이언트 ID, 클라이언트 시크릿, 콜백 URL (리다이렉트 URL) 은 로그인 플랫폼에서 발급받은 데이터를 넣어준다. onLoginSuccess 에서 로그인 성공한 사용자 정보를 db 에 넣어준다. 각 콜백 메소드의 역할 및 기초적인 사용법은 [passport.js 문서](http://www.passportjs.org/docs/downloads/html/) 와 [생활코딩](https://opentutorials.org/course/2136/12134) 을 참고하면 된다. ## 4. auth.js - 인증 관련 라우터 --- ```javascript "use strict"; const express = require('express'); const router = express.Router(); const passport = require('passport'); const userService = require('../service/userService'); // 로그인 페이지 진입 router.get('/login', function (req, res) { let redirectUrl = req.query.redirectUrl; if (redirectUrl) { res.cookie("redirectUrl", redirectUrl, { expires: new Date(Date.now() + (60 * 1000 * 2)), httpOnly: true }); } res.render('login', {title: '마크다운 에디터 - 로그인', isLogin: false}); }); // 로그아웃 router.get('/logout', (req, res) => { req.logout(); res.redirect('/markdown'); }); // 페이스북 로그인 시작 router.get('/facebook', passport.authenticate('facebook')); // 페이스북 로그인 결과 콜백 router.get('/facebook/callback', passport.authenticate('facebook', { failureRedirect: '/markdown/auth/login' }), (req, res) => { loginSuccessHandler(req, res); }); // 깃허브 로그인 시작 router.get('/github', passport.authenticate('github')); // 깃허브 로그인 결과 콜백 router.get('/github/callback', passport.authenticate('github', { failureRedirect: '/markdown/auth/login' }), (req, res) => { loginSuccessHandler(req, res); }); // 구글 로그인 시작 router.get('/google', passport.authenticate('google')); // 구글 로그인 결과 콜백 router.get('/google/callback', passport.authenticate('google', { failureRedirect: '/markdown/auth/login' }), (req, res) => { loginSuccessHandler(req, res); }); // 로그인 성공시 처리 function loginSuccessHandler(req, res) { let successRedirectUrl = "/markdown"; if (req.cookies.redirectUrl) { successRedirectUrl = req.cookies.redirectUrl; res.clearCookie("redirectUrl"); } return res.redirect(successRedirectUrl); } module.exports = router; ``` 로그인 성공시 loginSuccessHandler 가 호출되는데, 이렇게 따로 분리해놓은 이유는 로그인 성공 후 로그인 요청시의 URL 로 리다이렉트 해주기 위함이다. 만약 이런 부분이 필요 없고, 나는 무조건 특정 페이지로 이동시키고 싶다면 다음과 같이 코드를 수정한다. ```javascript router.get('/google/callback', passport.authenticate('google', { successRedirect: '/', failureRedirect: '/markdown/auth/login' })); ```

Node.js 2018.01.30 6:15
Node.js + ejs 기반 마크다운 에디터를 Angular 로 컨버팅 하며

# 1. 서론 기존에 운영하던 [마크다운 에디터](http://choisroyalfamily.com/markdown) 는 내가 자주 사용하던 [kobito](https://kobito.qiita.com/win) 라는 마크다운 에디터 (데스크톱 애플리케이션)의 데이터 백업 및 OS 간 비호환 문제 때문에 만들게 되었다. 백업의 경우 드롭박스나 구글 드라이브에 연동하여 코비토의 db 폴더를 심볼릭 링크 생성해서 사용했는데, PC 간 동기화 되며 db 파일이 깨진다거나, OS 간 호환되지 않는 점에서 많은 불편함이 있었다. 위의 작업이 별로 복잡하지도 않고 어렵지도 않지만 문제는 데이터가 깨질 수 있거나 (그래서 첫 1년간의 데이터 중 일부를 망실했다), OS 간 코비토의 데이터가 호환되지 않으며 매번 PC 가 달라질 때마다 이 짓을 하는건 상당히 짜증났다. 물론 코비토에서 백업 기능을 제공하긴 했지만, 모든 게시글이 kobito 웹 페이지에 포스트 형식으로 공유 되는 문제가 있었다 (지금은 어떨지 모름). 그래서 개발을 진행했는데, 내가 개발일을 입문하면서 가장 간단한 첫 목표로 삼은 것이 온라인 마크다운 에디터를 만드는 것이었다. 나는 웹 쪽에 전혀 프로페셔널한 지식도 없고, HTML 은 간단해서 쉽게 쉽게 배울 수 있었지만 CSS 가 너무나도 어려워서 많은 난관에 봉착했다. 크롬에서는 잘 동작하던 스타일이 IE 에서는 안된다던가, 브라우저별 호환이 심한 것도 안드로이드를 주로 개발해오던 나로서는 상당히 신기하고 짜증났던 상황이었다. 전에도 웹 프론트엔드 개발 경험이 있었지만, 그때는 회사에서 기존에 사용하던 템플릿을 이용하여 제작했고, 무엇보다 서비스의 관리자 페이지라 일반 사용자용 페이지의 UI 로는 전혀 알맞지도 편리하지도 않았다. 이때까지만 해도 부트스트랩이 뭔지 제이쿼리가 뭔지도 몰라서 처음에는 부트스트랩으로 진행하다가 머테리얼에 한참 빠져있던 상황이였는지라 [머테리얼라이즈 CSS](http://materializecss.com/) 를 이용하여 레이아웃을 갈아엎었다. 이메일도 개인정보에 해당되었던지라 완전 구시대적인 닉네임, 비밀번호 의 데이터만을 사용자로부터 받아서 계정관리를 진행했고 (이메일을 받으면 임시 비밀번호 발급이라던가 여러가지 장점이 있었지만, 개인정보를 받으면서 시작되는 수많은 고통과 그것을 대응하는 프로세스가 너무나도 번거로웠다) 따라서 비밀번호를 잊어버렸을 경우 관리자인 내게 요청하는 방법밖에 없었다. 또, 비회원과 회원의 차이를 따로 두기도 귀찮아서 무조건 모든 기능을 수행하려면 로그인을 했어야 했다. 그래서 기존 버전인 위의 링크를 클릭하면 인덱스 페이지가 로그인/회원가입 페이지다. 그러다보니까 당연히 컨텐츠 공유(그래서 별도의 공유 기능도 없었다)가 불가능한 수준에 이르렀고, 쿠키에 JWT 토큰을 넣어 보관하다가 사용자가 접속하면 자동 로그인을 하던 것도 미들웨어 코딩을 이상하게 해놔서 매번 이상한 alert 창을 띄우는 것도 꼴보기 싫었다. 대세가 되는 소셜로그인을 적용했다면 모든 문제가 해결되었겠지만, 그 당시만해도 웹에서는 소셜 로그인을 적용해본 경험도 없었고 막연한 두려움만이 존재해 닉네임-비밀번호 기반의 구조를 고집했다. # 2. 1차 리뉴얼 작업 (2018.01.02 - 2018.01.16) 지난 1년간 동업자와 운영하던 스타트업인 [최씨세가](http://choisroyalfamily.com/) (주로 SI를 진행했다) 의 운영을 계속하는게 도저히 불가능하다고 판단하며 폐업하기로 마음먹었고 따라서 내게는 시간이 생겼다. 1년간 SI 외주 프로젝트를 진행했던 기록을 포트폴리오로 만드는 과정에서 기존의 마크다운 에디터를 리뉴얼하고 싶은 욕심이 생겼고, 나는 작업에 착수했다. 가장 우선적으로 생각한 것은 기존의 로그인 시스템을 갈아엎는 것으로 시작되었고, 로컬 기반이 아닌 소셜 로그인 기반으로 전환했다. 대상이 되는 플랫폼은 3가지로 각각 페이스북, 깃허브, 구글이었다. 개발자들이 많이 쓸법한, 그리고 대중적으로 이용을 많이 할법한 플랫폼이 위의 3가지였고, node.js 진영에서 인증 관련으로 유명한 라이브러리 였던 [passport.js](http://www.passportjs.org/) 도 기존부터 많은 관심이 있었기 때문에 문서를 보고 적용해보았다. mysql 관련 코딩도 기존처럼 [mysql](https://www.npmjs.com/package/mysql) 라이브러리를 쓰기 않고 [sequelize.js](http://docs.sequelizejs.com/) 로 전환했다. sequelize 를 적용하고 내가 가장 감동받은 부분은 별개의 cli 가 제공되어 그것을 통하여 설정 파일을 만들고 테이블간의 관계를 정의하며 데이터의 생성 및 수정 시간을 자동으로 처리해주고, DATE 가 UTC 로 저장되어 별도의 작업을 하지 않아도 되었던 것과 mysql 의 트리거 처럼 쿼리의 라이프사이클 콜백에 따라 지정된 작업을 수행하던 것이 놀라웠다. 소셜 로그인을 진행하니까 기존처럼 댓글도 자체 구현으로 진행했으며 css 는 위의 머테리얼라이즈 css 를 이용했다. 거기에 좀더 개방적인 컨텐츠 소모를 위해 비회원과 회원간의 차이를 두고 별도의 공유기능 (트위터 트윗, 페이스북 공유, URL 복사) 등을 추가했다. letsencrypt 을 이용하여 https 를 제공하기 위해 certbot 을 통하여 관련 작업을 진행했고, http 접근을 https 로 리다이렉트 하는 경험도 해봤다. 본래 letsencrypt 가 최대 3개월마다 만료되어 매번 인증서를 갱신해야 했던 부분을 certbot 을 이용하면 자동갱신이 되어서 많은 편리함을 경험했다. 이렇게 1차 리뉴얼 작업이 완료되었고, 일시적으로 나는 행복함을 느꼈으나 문제는 마크다운 이었다. # 3. 2차 리뉴얼 작업 (2018.01.18 - 2018.01.30) 이 블로그 또한 그렇지만 1차 리뉴얼의 결과물도 code mirror 와 markdownit 을 이용하여 마크다운 텍스트를 HTML 로 파싱하여 실시간으로 화면에 렌더링 해줬는데, 문제는 다음의 2가지였다. 1. 파싱하기 위해서 html element 의 value 나 data 따위로 마크다운 텍스트를 지정해야 한다. 2. 마크다운 관련 내용이 변경될 때마다 URL 에 지속적으로 append 된다. 성능은 둘째치고 위의 문제가 상당히 꼴보기 싫어서 결국엔 앵귤러로 컨버팅하는 작업을 시작했다. 앵귤러는 이전에 2버전이 나오고 입수했던 앵귤러 책을 보며 살짝 공부했고, 범용 관리자 페이지를 만들면서 기초적인 부분을 맛봤다. 기존의 서버사이드 템플릿 렌더링과의 결정적인 차이점이 바로 컴포넌트 개념이었다. ejs 템플릿에서도 컴포넌트 까지는 아니더라도 반복되는 구문 (head 태그의 정보라던가 css, js 로딩, 푸터 헤더 html 로딩)은 어느정도 대체할 수 있었지만 Spring 으로 프로젝트를 진행하면서 사용해봤던 [Thymeleaf](http://www.thymeleaf.org/index.html) 의 상속 개념보다는 살짝 적용가능한 범위가 적었고, 무엇보다도 중요한 것 스크립트렛이 상당히 불편했다. 반복문이나 조건문 자체를 이용하는 것은 둘째치고, 특정 상황일 때 특정 HTML 코드를 생성하는 부분을 작성하려면 상당히 지저분했으니까. 또 인텔리센스도 적용되지 않았고, 따라서 밑줄로 경고 메세지가 나오는 부분도 짜증났다. 추가적으로 아예 앵귤러 자체가 말 그대로 웹 애플리케이션을 개발하기 위한 프레임워크 인지라 안드로이드 앱을 개발하는 것 같은 느낌이 들어서 적응에도 많은 도움이 되었다. (마치 안드로이드의 Fragment 를 이용하는 듯한 느낌이 들었다) 타입스크립트 기반이라 OOP 적인 개발하기도 쉬웠고, 자체적인 라우팅이 지원되는 것도 아름다웠으며 AuthGuard 을 이용하여 페이지별 권한 체크하는 것도 간단했다. UI 는 [앵귤러 머테리얼](https://material.angular.io/) 를 이용하여 만들었다. 앵귤러로 옮기면서 가장 고통스러웠던 부분이 소셜 로그인 관련 이었는데, 기존에는 node.js 에서 passport 를 이용하여 모든 프로세스가 규격화되어 있어서 작업하기 편했지만 앵귤러에선 이용할 수 없었다. 그래서 오만가지 방법을 다 시도해보다가 잘 안되어서 포기해보려던 찰나, [예제](http://cuppalabs.github.io/ng2-social-login/documentation/) 를 참조하여 구현에 성공했다. 그 로그인 절차는 다음과 같다. 1. 로그인 페이지에서 특정 소셜 로그인 버튼 클릭 2. 컴포넌트에서 클릭된 로그인 서비스의 프로바이더 값 확인 3. 별도 구현한 AuthService 에 해당 프로바이더 값을 전달하고, 로그인 프로바이더 별로 지정된 키라던가 클라이언트 아이디를 포함하여 해당 서비스의 OAuth 페이지로 이동 (window.location.href 이용) 4. 지정된 내 웹 페이지 중 리다이렉트 페이지로 지정한 곳으로 code 값이 넘어오는데, 이를 node.js 서버로 전달 5. 관련 OAuth API 를 호출하여 사용자 프로필 획득 6. 전달받은 사용자 프로필 이용 위의 과정중 3번에서 url safe encoding 하지 않고 값들을 넘겼다가 실패해서 고통받은 것이 무려 2일. 인코딩 하고 보내니까 성공해서 허탈함에 빠졌다가 기뻐서 울뻔했다. 인증 절차는 어찌저찌 구현이 끝났지만, 그 다음 문제는 바로 마크다운 파싱. 이 부분은 [marked.js](https://github.com/chjj/marked) 와 [highlight.js](https://highlightjs.org/) 를 이용하여 쉽게 쉽게 구현했다. 이때 Post 컴포넌트 의 하위 컴포넌트로 마크다운 에디터 컴포넌트와 마크다운 프리뷰 컴포넌트를 배치하여 실시간 렌더링과 신택스 하이라이팅이 정상적으로 수행되던 것에서 1차적으로 감동, 이후 게시글 뷰어 컴포넌트 제작시 마크다운 프리뷰 컴포넌트를 정상적으로 재활용할 수 있었던 부분에서 2차적으로 감동 대폭발. 추가적으로 호스트 리스너를 이용하여 화면 너비의 변화를 실시간으로 콜백받아 툴바의 메뉴들을 펼쳐놓는 다던지, 메뉴 버튼으로 대체하여 드롭다운 식으로 배치하는 것이 상당히 좋았다. 무엇보다도 앵귤러로 컨버팅하는 작업중 가장 감명깊었던 것이 아래의 내용이다. 1. 백엔드는 오로지 API 서버의 역할만 담당. 불필요하게 세션따위를 이용하지 않아도 되니 서버 리부팅시 웹 페이지의 로그인 정보가 날아가지 않아도 되니 관련된 부담감 감소. 로그인 또한 JWT 기반으로 진행되니 모바일 애플리케이션이나 앵귤러에서 동일한 API 를 이용하여 관련 작업 수행가능. 템플릿 렌더링 로직을 작성할 필요가 없어서 서버의 라우터 관련 코드가 많이 간소화 됨. 2. 컴포넌트의 재활용으로 마치 안드로이드 애플리케이션을 개발하는 것 처럼 생산성의 엄청난 향상. 특정 엘리먼트의 참조라던가 모델 변경시 관련된 뷰의 자동적인 갱신, 각종 이벤트 리스너의 쉽고 간편한 구현. 3. 불필요한 페이지 전체 렌더링이 필요하지 않아 실질적인 페이지 로딩 속도 향상. 4. 쉽고 간단한 실시간 폼 검증. 5. 다이얼로그의 쉬운 구현 (라이브러리의 도움을 받더라도) 및 스낵바의 이용 6. enviroment 와 enviroment.prod 를 이용한 개발/배포 간 별도의 상수 데이터 제공 물론 고통스러웠던 것은 수많은 jquery 기반 템플릿을 이용할 수 없었던 것이라던가, 실제로 작업해야 하는 분량이 기존 템플릿 엔진을 이용한 분량보다 많아졌다는 부분이지만 위의 장점이 너무나도 강력해서 그렇게까지 힘들지도 않았다. # 4. 결과 및 반성 그 결과로 [앵귤러 기반의 마크다운 에디터](https://massivcode.com/markdown/) 가 탄생했다. 반성 및 개선해야 할 부분은 다음과 같다. 1. 앵귤러에 대한 깊이가 얕아서 보다 효율적이고 고도화된 작업을 할 수 없었다. 2. 정밀한 테스팅의 부재. 3. 리퀘스트 인터셉터의 미구현으로 API 호출시 반복코드의 생성. 4. 댓글 출력이 조금 부자연스러움. 5. 스크롤 업 관련 기능의 구현 실패 (앱 컴포넌트 하위에 자식 컴포넌트 들이 위치하고 있는데, 호스트 리스너나 다른 방법을 이용해서 현재 스크롤된 페이지의 y 값을 얻어올 수 없어서 구현할 수 없었다.)

Angular 2018.01.30 5:53