[번역] `passport-local`에 대해 알아야 하는 모든 것

Jake Seo·2020년 12월 16일
21

네트워크

목록 보기
2/16

Original Author : Zach Gollwitzer
Original Link : https://levelup.gitconnected.com/everything-you-need-to-know-about-the-passport-local-passport-js-strategy-633bbab6195
Thanks a lot for allowing me to upload this translated post!
Original Author's Youtube: https://www.youtube.com/zachgollwitzer

왜 Node/Express 웹 앱을 구현하는 작은 팀과 스타트업에게 passport-local 인증 전략이 간단하고, 안전한 전략인지 한번 자세히 알아보자.

이 글은 저자가 이전에 썼던 포스트인 passport-jwt 전략과 밀접하게 연관이 있다. 그 글은 여기서찾아볼 수 있다.

이 포스트를 쉽게 설명하기 위해서, 코드가 들어있는 깃허브 레포지토리를 세션을 베이스로한 인증 레포지토리에 올려두었다.

목차

  • 인증방법 선택지들
  • 세션을 베이스로한 인증이란 무엇일까?
  • HTTP 헤더들
  • 쿠키는 어떻게 동작하는가?
  • 현실적인 상황
  • 세션은 어떻게 동작하는가?
  • Express 미들웨어 다시 훑어보기
  • Express 세션 미들웨어 는 어떻게 동작하는가?
  • Passport JS 로컬 전략은 어떻게 동작하는가?
  • 세션을 기반으로 한 인증에 대한 개념적인 개요
  • 세션을 기반으로 한 인증 구현하기
  • 리뷰와 미리보기

인증방법 선택지들

  • 세션
  • JSON 웹 토큰(JWT)
  • OAuth
  • 기타 / Ad-hoc (일반화 할 수 없는 특정한 해결책)

위에 나열된 것들은 오늘날의 개발자들이 선택 가능한 인증 선택지들에 대한 높은 수준의 개요이다. 각각을 간단히 설명해보자면 이렇다.

  • 세션을 기반으로 한 인증 - 사용자 로그인, 로그아웃을 관리하기 위해 백엔드의 '세션'과 함께 브라우저 쿠키를 활용한다.
  • JSON 웹 토큰(JWT) 인증 - JSON Web Token(JWT)가 주로 브라우저의 localStorage에 저장되는 상태가 없는(stateless) 인증 방법이다. 이 JWT는 사용자에 대한 확인을 갖고 있으며, 서버에 저장된 secret으로만 디코드가 가능하다.
  • OAuth와 OpenID Connect 인증 - 현대적인 인증 방식 중 하나이다. 클라이언트 어플리케이션이 클라이언트의 사용자를 인증하기 위해서 다른 어플리케이션으로부터 생성된 'claims'라는 것을 사용한다. 클라이언트 어플리케이션이 이 플로우를 활용해 유저 인증을 하는 동안 구글과 같은 현존하는 서비스가 인증과 사용자의 저장소를 관리하는 서로 결합된 인증이다.

참고로 OAuth의 설명은 당장에 좀 이해가 안될 수 있고 그래서 이 포스트에서는 완전히 둘러보진 않을 것이다. 앱을 출시시키고 있는 작은 팀이나 스타트업에게 불필요할 뿐만 아니라, 이를테면 구글, 페이스북, 깃허브 등 사용자가 어떤 서비스를 사용하고 있는지에 따라서도 조금 의존성이 있다.

마지막으로, 그림에서 OAuth에 리스트되어있는 "As a Service"와 "In House"를 보았을 수 있는데, 이것은 실제로 OAuth 프로토콜을 서비스로 구현하고 있는 'OAuth'라 불리는 회사가 있다는 사실을 강조하기 위해서이다. 물론 당신은 OAuth 프로토콜을 OAuth 사의 서비스 없이 구현할 수도 있다.

세션을 베이스로 한 인증이란 무엇일까?

이러한 인증 방법들에 대한 계통을 만든다면, 세션을 기반으로 한 인증은 아마 이 중에 가장 오래된 인증 방법 중 하나일 것이지만, 아직 쓸만하고 쓰이고 있다. 세션을 기반으로 한 인증은 passport-local 전략의 근간이다. 이 인증 방법은 클라이언트 앱을 방문하는 각 사용자의 현재 인증 상태를 유지시키기 위해 익스프레스 앱과 데이터베이스가 함께 돌아가는 "서버-사이드"를 이용한다.

세션을 기반으로 한 인증의 뼈대를 이해하기 위해서 다음 컨셉들을 이해할 필요가 있다.

  • 기본 HTTP 헤더 프로토콜
  • 쿠키란 무엇인가
  • 세션이란 무엇인가
  • 세션(서버)와 쿠키(브라우저)는 유저 인증을 위해 어떻게 소통하는가

HTTP 헤더

브라우저에서 HTTP 요청을 만들어내기 위한 방법은 여러가지가 있다. HTTP 클라이언트는 웹 어플리케이션, IoT 디바이스, 커멘드 라인 또는 기타의 것들이 될 수 있다. 각각의 클라이언트들은 인터넷에 연결되고 데이터를 fetch(GET)하거나 데이터를 수정하는(POST, PUT, DELETE) HTTP 요청을 보낸다.

설명하자면 다음의 가정을 할 수 있다.

서버 = www.google.com
클라이언트 = 커피숍에서 노트북으로 일하고 있는 어떤 사람

커피숍에 있는 사람이 구글 크롬 브라우저를 이용하여 www.google.com에 접속할 때, 이 요청은 "HTTP 헤더"와 함께 보내진다. HTTP 헤더는 요청을 무사히 마치는 것을 돕기 위해서 브라우저로 추가적인 키:밸류 쌍 데이터를 제공한다. 이 요청에는 두가지 종류의 헤더가 있을 것이다.

  1. 일반적인 헤더 (General Header)
  2. 요청 헤더 (Request Header)

이걸 상호작용하게 만들고 싶으면, 구글 크롬을 켜서 개발자 도구를 열고 "네트워크"탭에 들어가보자. 그리고 주소 표시줄에 www.google.com을 쳐보고 서버로부터 리소스를 로드하는 것을 네트워크 탭을 통해 구경해보자. 그러면 Name, Status, Type, Initiator, Size, Time, 그리고 Waterfall이 보일 것이다. Typedocument인 것을 찾아서 클릭해보자. request와 response에 필요한 헤더들을 전부 볼 수 있을 것이다.

클라이언트로서 요청한 리퀘스트는 일반적 헤더의 내용과 요청 헤더의 내용이 전부 포함되어 있을 것인데 아마 아래와 비슷하게 나올 것이다.

General Headers
  Request URL: https://www.google.com/
  Request Method: GET
  Status Code: 200
  
Requset Headers
  Accept: text/html
  Accept-Language: en-US
  connection: keep-alive

이건 나의 실제 결과.

www.google.com을 주소표시줄에 타이핑하고 엔터를 쳤을 때, HTTP 요청이 이러한 헤더와 함께 보내진다 (아마 몇가지 다른 것들도 함께). 헤더들은 그냥 읽어보기만 해도 이해는 가능함에도 불구하고, HTTP 헤더들이 어떻게 이용되는지에 대해 더 잘 알아보기 위해서 몇가지 더 알아보려고 한다. 만일 모르는 것이 있다면 MDN에 접속해서 마음껏 구경해도 좋다.

General 헤더는 요청과 응답 데이터의 혼합이 될 수 있다. 분명하게 Request URLRequest Method는 요청 객체의 일부이고 이 요청 객체의 일부들은 구글 크롬 브라우저에게 어디로 요청을 라우팅해주어야 할지 말한다. Status Code는 분명하게 응답(response)의 일부이다. GET 요청이 성공적이었고, www.google.com 웹페이지가 잘 로드되었는지 가리켜준다.

Request Headers는 요청 오브젝트 자체를 포함한 헤더들을 갖고 있다. 우리는 요청 헤더를 "서버에 대한 지시사항"으로 생각할 수 있다. 이러한 경우에, 내 요청이 구글 서버에게 아래와 같은 사항들을 전달할 수도 있다.

  • 구글 서버야, 나에게 HTML이나 텍스트 데이터를 전달해줘. 나는 지금 당장 다른 것은 읽을 수가 없는 상태야!
  • 구글 서버야, 오직 영어만 보내줘.
  • 구글 서버야, 요청이 끝나더라도 나랑 연결을 끊지 마.

이 외에도 우리가 설정 가능한 아주 많은 요청 헤더들이 있다. 하지만 HTTP 요청에서 우리가 실제로 흔히 보게될 것들은 몇가지 안된다.

그래서 우리가 www.google.com를 검색했을 때, 우리는 우리 리퀘스트와 헤더를 구글 서버로 보내는 것이다. (간단한 설명을 위해, 하나의 큰 서버로 퉁친 것이다.) 구글 서버는 우리의 요청을 받고 "지시사항들"(헤더들)을 읽고 응답을 만드는 것이다. 응답은 아래의 것들을 포함한다.

  • HTML 데이터 (우리가 브라우저에서 보는 것들)
  • HTTP 헤더들

아마 추측했을 수도 있지만, "응답 헤더"는 구글 서버에 의해 세팅된다. 여러분이 볼 것은 아마 다음과 같다.

Response Headers
  Content-Length: 41485
  Content-Type: text/html; charset=UTF-8
  Set-Cookie: made_up_cookie_name=some value; expires=Thu, 28-Dec-2020 20:44:50 GMT;

이런 응답 헤더는 Set-Cookie와 같은 예외를 빼면 꽤 직관적이다.

나는 Set-Cookie 헤더를 추가해주었다 왜냐하면 세션을 기반으로 한 인증을 학습할 때 우리는 이 부분에 대해 이해할 필요가 있고 사실상 이 부분이 전부이기 때문이다. (그리고 이 포스트에서 설명할 다른 인증방법들에 대해서 이해하는 데도 도움을 줄 것이다.)

쿠키는 어떻게 동작하는가

브라우저에 쿠키가 없다면, 문제가 발생할 것이다.

우리가 만일 우리 사용자가 로그인해야 접근할 수 있는 보호된 웹페이지를 갖고 있는데 쿠키가 없다면, 유저들은 새로고침을 할 때마다 로그인을 새로 해야할 것이다. 그 이유는 HTTP 프로토콜이 기본값으로 "상태 없음(stateless)"으로 세팅되어 있기 때문이다.

쿠키는 "지속 가능한 상태(persistent state)"의 개념을 도입했고 브라우저가 서버가 이전에 알려주었던 내용을 브라우저가 "기억"하는 것을 허락한다.

구글 서버가 내 구글 크롬 브라우저에게 나를 보호된 페이지에 접근할 수 있게 만들라고 하는 것이 가능하다. 하지만, 내가 페이지를 새로고침하면, 내 브라우저는 이것을 "잊어버리고(forget)" 내가 다시 인증을 받게 만들 것이다.

이게 쿠키가 필요한 곳이고, Set-Cookie헤더가 목표로 하는 바에 대한 설명이다. 우리가 www.google.com을 우리 브라우저에 입력하고 엔터를 눌러 요청을 보내면 우리 클라이언트가 헤더와 함께 요청을 보내고 구글 서버는 response와 다른 헤더들을 통해 응답한다. 그 응답 헤더들 중 하나는 Set-Cookie: made_up_cookie_name=some value; expires=Thu, 28-Dec-2020 20:44:50 GMT;와 같은 것이다. 이러한 것들이 어떻게 소통하는지에 대해 설명해보겠다.

서버: "야 클라이언트! 나는 니가 made_up_cookie_name이라는 쿠키의 값을 some value라는 값으로 설정해줬으면 좋겠어.

클라이언트: "야 서버, 그래. 그 값을 2020년 12월 28일까지 이 도메인에서 보내는 내 모든 요청의 Cookie 헤더에 세팅할게.

우리는 이 과정이 실제로 어떻게 일어나는지 구글 크롬 개발자모드에서 확인 가능하다. 실제로 확인했더니 아래와 같은 값들이 세팅되어 있었다.

이 쿠키는 쿠키의 만료기간이 되기 전까지 우리가 www.google.com로 보내는 우리의 모든 요청 헤더Cookie 값에 세팅될 것이다.

아마 이미 결론지었을 수도 있지만, 우리가 어떤 "권한"쿠키를 설정한다는 것은 실제 인증을 하는데 매우 유용할 수 있다. 많이 축약한 프로세스로 이게 어떻게 작동하는지를 설명하면 다음과 같다.

  1. 어떤 사람이 커피숍에서 브라우저를 통해 www.example-site.com/login/에 접속한다.
  2. 사용자 이름과 암호를 로그인 폼에 입력했다.
  3. 로그인을 하기 위해 구글 크롬 브라우저를 통해서 아이디와 패스워드를 POST 요청을 통해 www.example-site.com으로 보낸다.
  4. 운영중인 www.example-site.com은 로그인 정보를 받고 데이터베이스를 확인한다. 그리고 로그인 정보에 대한 유효성 검사를 진행한 뒤에 만일 성공했다면, 응답 헤더에 다음과 같은 내용을 넣는다. Set-Cookie: user_is_authenticated=true; expires=Thu, 1-Jan-2020 20:00:00 GMT.
  5. 구글 크롬 브라우저는 위와 같은 내용을 받고 브라우저에 쿠키를 설정한다.
쿠키 이름: user_is_authenticated
쿠키 값: true
만료 일자: 2020-12-28T20:44:50.674Z
  1. 이제 로그인한 그 사람은 로그인한 사람만 방문할 수 있는 www.example-site.com/protected-route/를 방문할 수 있다.
  2. 로그인한 사람이 HTTP 요청을 보낼 때마다 다음과 같은 내용도 따라다닌다. Set-Cookie: user_is_authenticated=true; expires=Thu, 1-Jan-2020 20:00:00 GMT
  3. 서버는 이러한 요청을 받아들일 때, 요청에 쿠키가 있음을 보고 이 사용자가 몇초전에 인증받았던 것을 기억한다. 그리고 유저를 해당 페이지에 접근할 수 있게 해준다.

현실적인 상황

사실 분명히, 위에 유저를 인증하기 위해 작성된 방법은 매우 보안성이 낮다. 현실에서는 서버는 유저가 제공한 패스워드로부터 해쉬 값을 만들어낼 것이고 서버에서 해쉬에 암호화 라이브러리와 같은 것으로 유효성 검사를 할 것이다.

고수준 개념은 유효하다 그리고 고수준 개념은 우리가 인증에 관해 얘기할 때, 쿠키 값을 이해할 수 있도록 만들어준다.

다음 챕터로 넘어가면서도 이전에 배웠던 예제들을 꼭 기억하자.

세션

세션과 쿠키는 사실 꽤 비슷하다. 그리고 그 둘은 꽤나 경계선 없이 함께 동작하기에 헷갈리기 쉽다. 둘의 주된 차이점은 그들이 저장되는 위치이다.

쿠키는 서버에 의해 설정되지만 브라우저에 저장된다. 서버가 사용자의 "상태"를 저장하기 위해서 쿠키를 사용하길 원한다면, 브라우저 내부에서 쿠키가 어떻게 생겼는지에 대해서 계속 추적하기 위한 정교한 계획을 떠올려야 할 것이다. 아마 다음과 같이 진행될 것이다.

  • 서버: 야 브라우저, 나 이 사용자에 대한 인증을 끝냈어. 그니까 너는 다음에 서버에 뭔가 요청을 보낼 때, 이 쿠키를 저장해놓고 나에게 사용자 인증이 끝났다는 사실을 계속 인지시켜줘야 해 (Set-Cookie: user_auth=true; expires=Thu, 1-Jan-2020 20:00:00 GMT)
  • 브라우저: 고마워 서버! 내가 이 쿠키를 내 요청 헤더의 Cookie 부분에 첨부할게
  • 브라우저: 야 서버, 내가 www.domain.com/protected의 내용 좀 볼 수 있을까? 여기 지난 요청에 대해서 니가 보냈던 쿠키를 같이 보낼게
  • 서버: 물론이지. 여기 페이지 데이터. 여기 또 다른 Set-Cookie 헤더도 같이 줄게. (Set-Cookie: marketing_page_visit_count=1; user_ip=192.1.234.21) 이걸 왜주냐 하면 우리 회사에서 마케팅 목적으로 얼마나 많은 사람들이 특정한 페이지에 방문했는지 알길 원하거든.
  • 브라우저: 알았어. 요청 헤더의 Cookie 항목에 포함시킬게
  • 브라우저: 야 서버, www.domain.com/protected/spcial-offer에 대한 내용좀 줄 수 있어? 니가 여태까지 줬던 쿠키에 대한 정보도 전부 같이 줄게. (Cookie: user_auth=true; expires=Thu, 1-Jan-2020 20:00:00 GMT; marketing_page_visit_count=1; user_ip=192.1.234.21)

위에 보이듯, 브라우저 방문이 계속되면 더 많은 페이지들이 붙을 것이고 더 많은 쿠키가 설정될 것이다. 그리고 브라우저는 매 요청 헤더에 그 쿠키들을 설정해주어야 한다.

서버는 요청에 첨부된 모든 쿠키들을 파싱하기 위한 몇몇 함수들을 갖고 있을 수 있고 특정한 쿠키의 부재나 존재를 기반으로 어떠한 액션을 취할 수 있다. 나는 이런 것들을 보았을 때, 자연스레 질문이 떠올랐는데... 왜 서버는 그냥 이 기록들을 데이터베이스에 저장하지 않는 것일까? 그리고 단일 "session ID"를 사용해서 유저가 일으키는 이벤트들을 구분하면 안될까?

사실 이건 정확히 세션이 무엇을 위해 존재하냐에 관한 얘기이다. 내가 언급했듯, 쿠키와 세션의 가장 큰 차이는 어디에 저장되는가? 이다. 쿠키는 브라우저에 저장되는 반면 세션은 어떤 데이터 저장소(주로 데이터베이스)에 저장된다. 세션은 서버에 저장되기 때문에, 민감한 정보를 저장할 수 있다. 민감한 정보를 쿠키에 저장하는 것은 매우 보안에 취약할 수 있다.

이제 우리가 조금 헷갈리는 부분은 쿠키와 세션을 함께 이야기할 때이다.

쿠키는 클라이언트와 서버가 (다른 HTTP 헤더들과 함께) 메타데이터를 이용하여 통신하는 방법이기 때문에, 세션은 여전히 반드시 쿠키를 활용해주어야 한다. 이러한 상호작용을 가장 쉽게 알아보는 방법은 Node + Express + MongoDB를 이용한 간단한 인증 어플리케이션을 만들어보는 것이다. 여러분이 익스프레스로 간단한 앱을 만들정도의 이해가 있다고 가정할 것이다. 하지만 그래도 매 스텝이 진행될 때마다 설명을 할 것이다.

기본 앱을 만들면서 배워보자.

기본 앱 만들기

먼저, 디렉토리를 하나 생성한 뒤에 커멘드라인에서 다음 명령어를 쳐준다.

npm init -y
npm install --save express mongoose dotenv connect-mongo express-session passport passport-local

그리고 메인 디렉토리에 app.js 파일을 생성해주고 아래의 내용을 입력한다.

const express = require('express');
const mongoose = require('mongoose');
const session = require('express-session');
const MongoStore = require('connect-mongo')('session');

/**
 *  Express 미들웨어 및 dotenv 관련
 */

// dotenv 작동을 위해 .config() 실행
require('dotenv').config();
var app = express();

// JSON과 x-www-form-urlencoded로 온 데이터를 Express가 파싱할 수 있도록 다음 미들웨어를 추가해준다.
// `bodyParser`와 비슷한데, 대부분의 앱에서 여기에 bodyParser를 추가하는 것을 보았을 것이다.
app.use(express.json());
app.use(express.urlencoded({extended: true}));

/**
 * 데이터베이스 관련
 */

// DB_STRING=mongodb://<user>:<password>@localhost:27017/database_name
const connection = mongoose.createConnection(process.env.DB_STRING);

// 사용자 정보를 저장할 스키마 생성, salt는 사용자가 생성될 때, 1~12사이 랜덤하게 집어넣는다.
const UserSchema = new mongoose.Schema({
    username: String,
    hash: String,
    salt: String,
});

// 우리가 앱에서 사용할 모델을 정의
mongoose.model('User', UserSchema);

/**
 * 세션 관련
 */

// MongoStore는 세션 데이터를 저장하기 위해 사용된다.
// 이전에 mongoose.createConnection의 결과를 담아뒀던 connection 상수를 이용
const sessionStore = new MongoStore({mongooseConnection: connection, collection: 'sessions'})

// https://www.npmjs.com/package/express-session 에서 옵션 확인 가능
// secret: 세션을 인증하기 위해 사용하는 랜덤한 문자열, 실무에서는 엄청 긴 랜덤생성 문자열을 씀
// resave: 이걸 true로 설정하면, 세션이 아무것도 바뀌지 않더라도 저장함. 이걸 세팅안해도 앱은 돌지만, 터미널에서 경고 메세지가 송출됨
// saveUnintialized: resave와 비슷함. true로 세팅될 경우, 세션이 초기화되지 않은 경우에도 세션이 강제로 저장됨.
app.use(session({
    secret: process.env.SECRET,
    resave: false,
    saveUninitialized: true,
    store: sessionStore
}));

/**
 * 라우팅 관련
 */

// /login에 방문할 시에 "로그인 페이지"를 표출
app.get('/login', (req, res, next) => {
    res.send('<h1>로그인 페이지</h1>')
});

app.post('/login', (req, res, next) => {

});

// /register에 방문할 시에 "회원가입 페이지"를 표출
app.get('/register', (res, req, next) => {
    res.send('<h1>회원가입 페이지</h1>')
})
app.post('/register', (req, res, next) => {

});

/**
 * 서버 실행 부분
 */

app.listen(parseInt(process.env.PORT));

처음 우리가 해야할 것은 express-session 모듈이 어떻게 동작하는지 이해하는 것이다. 먼저 이 패키지는 "미들웨어"라 불리는 것이다. 미들웨어는 우리 앱에서 무언가 수정하는 함수를 약간 고급스럽게 말하는 것이다.

Express 미들웨어 다시 훑어보기

우리가 아래의 코드를 갖고 있다고 가정해보자.

const express = require('express');
var app = express();

// 커스텀 미들웨어
function myMiddleware1(req, res, next) {
  req.newProperty = 'my custom property';
  next();
}

// 또다른 커스텀 미들웨어
function myMiddleware2(req, res, next) {
  req.newProperty = 'updated value';
  next();
}

app.get('/', (req, res, next) => {
  res.send(`<h1>Custom Property Value: ${req.newProperty}</h1>`);
});

// Server listens on http://localhost:3000
app.listen(3000);

위에서 볼 수 있듯, 엄청나게 간단한 Express 미들웨어를 2개 정의했다. 그리고 브라우저에서 방문할 수 있는 유일한 주소인 http://localhost:3000/에 대한 라우트를 하나 만들었다. 만일 앱을 시작하고 해당 경로에 들어가보면, "Custom Property Value: undefined" 메세지를 볼 수 있을 것이다. 왜냐하면 미들웨어 함수를 정의하는 것만으로는 충분하지 않기 때문이다.

우리는 익스프레스보고 이 미들웨어를 쓰라고 해야 한다. 우리는 익스프레스에게 미들웨어를 사용하라고 하기 위해 몇가지 방법을 사용할 수 있다.

첫번째 방법으로, route에 함께 넣어버릴 수 있다.

이 방법은 특정 라우트들에 미들웨어를 적용하고 싶을 때 사용된다.

app.get('/', myMiddleware1, (req, res, next) => {
  res.send(`<h1>Custom Property Value: ${req.newProperty}</h1>`);
});

만일, 첫 미들웨어 함수를 라우트에 인자로 넣어버리면, 이제 여러분은 다음과 같은 메세지를 볼 수 있다. "Custom Property Value: my custom property". 실제로 일어나는 일을 정리해보면 아래와 같다.

  1. 어플리케이션이 초기화됨.
  2. 사용자가 브라우저로 http://localhost:3000에 방문함. 이 방문은 app.get() 라우트 함수를 호출함.
  3. 익스프레스 앱은 처음에 라우트에 설치된 "전역" 미들웨어가 있는지 확인하지만, "전역" 미들웨어는 존재하지 않으니까 찾을 수 없음.
  4. 익스프레스 앱은 app.get() 함수를 보고 콜백함수 전에 설치되어 있는 미들웨어를 인지함. 어플리케이션은 미들웨어를 실행하고 미들웨어에 req, res 인자를 넘기고 next() 콜백함수를 넘김.
  5. myMiddleware1req.newProperty를 처음 설정하고 next()가 호출됨, next()가 호출되는 것은 다음 미들웨어를 실행시키라는 의미가 있음. 만일 미들웨어가 next()를 실행시키지 않는다면, 브라우저는 아무것도 반환하지 않은 채로 "막힌(stuck)" 상태가 되어버림
  6. 익스프레스 앱은 더이상 다른 미들웨어를 찾을 수 없고, 요청에 따른 응답을 마저 하고 결과를 내보내줌

이게 미들웨어를 사용하는 한가지 방법이고, passport.authenticate() 함수가 어떻게 수행되는지에 대한 정확한 표현이다. (passport.authenticate() 함수는 좀 더 나중에 다뤄볼 예정임.)

또 다른 방법으로 우리는 미들웨어를 "전역"으로 설정할 수 있음. 아래 코드를 보자.

const express = require('express');
var app = express();

// 커스텀 미들웨어
function myMiddleware1(req, res, next) {
  req.newProperty = 'my custom property';
  next();
}

// 또다른 커스텀 미들웨어
function myMiddleware2(req, res, next) {
  req.newProperty = 'updated value';
  next();
}

app.use(myMiddleware2);
app.get('/', myMiddleware1, (req, res, next) => {
  res.send(`<h1>Custom Property Value: ${req.newProperty}</h1>`);
});

// Server listens on http://localhost:3000
app.listen(3000);

이 앱 구조에서, http://localhost:3000에 접속해보면 이전과 결과가 똑같다는 것을 알 수 있다. 왜냐하면 app.use(myMiddleware2)app.get('/', myMiddleware1) 이전에 일어나기 때문이다. 만일, 라우트에서의 미들웨어를 제거해주면, 브라우저에서 'updated value'를 볼 수 있을 것이다.

app.use(myMiddleware2);

app.get('/', (req, res, next) => {
  // "Custom Property Value: updated value"를 보내줌
  res.send(`<h1>Custom Property Value: ${req.newProperty}</h1>`);
});

라우트에서 첫번째 미들웨어 이후에 두번째 미들웨어를 실행시키게 해도 결과는 같다.

app.get('/', myMiddleware1, myMiddleware2, (req, res, next) => {
    // "Custom Property Value: updated value"를 보내줌
    res.send(`<h1>Custom Property Value: ${req.newProperty}`);
});

위의 예제들은 매우 간단한 익스프레스 미들웨어 개요일지라도, express-session의 동작을 이해하는데 많은 도움이 될 것이다.

Express 세션 미들웨어는 어떻게 동작하는가?

이전에도 말했듯, express-session 모듈은 우리 앱에서 사용할 수 있는 미들웨어를 제공해준다. 이 미들웨어는 우리 코드 중 다음 내용에서 적용된다.

// https://www.npmjs.com/package/express-session 에서 옵션 확인 가능
// secret: 세션을 인증하기 위해 사용하는 랜덤한 문자열, 실무에서는 엄청 긴 랜덤생성 문자열을 씀
// resave: 이걸 true로 설정하면, 세션이 아무것도 바뀌지 않더라도 저장함. 이걸 세팅안해도 앱은 돌지만, 터미널에서 경고 메세지가 송출됨
// saveUnintialized: resave와 비슷함. true로 세팅될 경우, 세션이 초기화되지 않은 경우에도 세션이 강제로 저장됨.
app.use(session({
    secret: process.env.SECRET,
    resave: false,
    saveUninitialized: true,
    store: sessionStore
}));

익스프레스 세션 미들웨어가 무엇을 하는지에 대한 간단한 개요는 다음과 같다.

  1. 라우트가 로드되었을 때, 미들웨어는 Session Store에 만들어진 세션이 있는지 확인한다. (우리의 경우에는 connect-mongo 패키지를 이용해서 커스텀 세션스토어를 만들어줬기 때문에 몽고디비 데이터베이스이다.)
  2. 세션이 있다면, 미들웨어는 암호표기법으로 유효성을 검사한다. 그리고 브라우저에게 세션이 유효한지 아닌지에 대해서 말해준다. 만일 유효하다면, 브라우저는 자동으로 HTTP 요청에 connect.sid를 붙여준다.
  3. 만일 세션이 없다면, 미들웨어는 새로운 세션을 만든다. 세션의 암호화된 해쉬를 받고 쿠키에 그 값을 connect.sid로 저장한다. 그리고 미들웨어는 Set-Cookie를 HTTP 헤더의 res 객체에 해쉬화된 값으로 붙여서 같이 보내준다. (Set-Cookie: connect.sid=hashed value).

이게 왜 유용한 방법인지 궁금해할 수도 있다. 그리고 이게 어떻게 동작하는지에 대해서도 궁금할 수 있다.

만일, 익스프레스 미들웨어에서 배웠던 것을 기억한다면, reqres가 최종 라우트에 가기 전까지 미들웨어가 reqres를 마음대로 바꿀 수 있는 능력을 가졌다는 것을 기억할 것이다. 우리가 req 객체에 커스텀 프로퍼티를 설정했던 것처럼, 우리는 프로퍼티와 메소드등을 가진 훨씬 복잡한 session 오브젝트와 같은 것도 설정할 수 있다는 것을 알 수 있다.

이게 express-session 미들웨어가 어떻게 동작하는지에 대한 답이다. 새로운 세션이 만들어지면, 다음 프로퍼티들이 req 오브젝트에 추가된다.

  • req.sessionID - 랜덤하게 생성된 UUID이다. 만일 원한다면, genid 옵션을 세팅함으로써 다른 커스텀한 ID 값을 만들어낼 수도 있다. 만일 이 옵션을 세팅하지 않는다면, 디폴트는 uid-safe 모듈을 사용하여 만들어낸다.
app.use(session({
  genid: function (req) {
    // UUID 구현 방식을 여기에 코딩하면 됨
  }
});
  • req.session - 세션 객체. 이 객체는 세션에 대한 정보를 포함하며 커스텀 프로퍼티를 만드는 데 사용하는 것이 가능함. 예를 들면, 단일 세션에서 특정한 페이지가 얼마나 많이 로드되었는지 추적하기 위해서 다음과 같은 코드도 가능함.
app.get('/tracking-route', (req, res, next) => {
  if (req.session.viewCount) {
    req.session.viewCount = req.session.viewCount + 1;
  } else {
    req.session.viewCount = 1;
  }
  
  res.send("<p>View Count is: " + req.session.viewCount + "</p>");
});
  • req.session.cookie - 쿠키 객체. 브라우저 내부에서 해쉬된 세션 ID를 저장하는 쿠키의 행위(Behavior)를 정의한다. 한번 쿠키가 설정되면, 브라우저는 쿠키가 만료될 때까지 모든 HTTP 요청에 자동으로 쿠키를 첨부하는 것을 기억해야 한다.

Passport JS 로컬 전략은 어떻게 동작하는가?

세션을 베이스로한 인증(Passport JS)을 완벽히 이해하기 위해 마지막에 배워야 할 것이 있다.

Passport JS는 Node/Express App에서 동작하는 500개가 넘는 인증 "전략(Strategy)"을 갖고 있다. 이 중 많은 전략은 아주 구체적이다. (이를테면 passport-amazon은 우리의 앱이 아마존 자격증명서를 통해 로그인하는 것을 허가해준다.) 하지만, 전략들은 많긴 하지만 우리의 익스프레스 앱 내부에서 다들 비슷하게 동작한다.

내 의견으로는, Passport 모듈은 문서화 부서가 필요할 것 같다. Passport 두가지 모듈 (Passport 기본 모듈 + 특정한 전략 모듈)을 포함할 뿐만 아니라 우리가 배웠던 미들웨어도 포함한다. 혼란을 가중하기 위해, 우리가 다룰 전략(passport-local)은 다른 미들웨어인 (express-session)에 의해 만들어진 오브젝트를 또 수정하는 미들웨어이다. Passport 문서는 이것들이 어떻게 동작하는지 거의 알려주지 않아서, 나는 최대한 잘 설명하려 노력할 것이다.

먼저, 모듈의 셋업부터 알아보자.

만일 이 튜토리얼을 잘 따라왔으면, 필요한 모듈들이 이미 설치되어 있을 것이다. 만일 그렇지 않다면, 다음과 같은 모듈을 설치하면 된다.

npm install --save passport passport-local

암호화를 위해 다음 패키지도 설치해주자. (사실 설치 안해도 된다. NodeJS built-in 라이브러리라서)

npm install crypto

일단 설치하고 나면, 앱에 Passport를 구현할 필요가 있다. 아래에 passport-local 전략을 사용하기 위해 필요한 것들을 추가해놓았다. 간단하게 보여주기 위해서 주석을 지웠다. 간단하게 코드를 읽어보자. 우리는 그 후에 //NEW 코드를 알아볼 것이다.

const express = require('express');
const mongoose = require('mongoose');
const session = require('express-session');
const MongoStore = require('connect-mongo')('session');

// NEW
/**
 * 패스포트 관련 require
 */
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const crypto = require('crypto');


/**
 *  Express 미들웨어 및 dotenv 관련
 */

// dotenv 작동을 위해 .config() 실행
require('dotenv').config();
var app = express();

// JSON과 x-www-form-urlencoded로 온 데이터를 Express가 파싱할 수 있도록 다음 미들웨어를 추가해준다.
// `bodyParser`와 비슷한데, 대부분의 앱에서 여기에 bodyParser를 추가하는 것을 보았을 것이다.
app.use(express.json());
app.use(express.urlencoded({extended: true}));

/**
 * 데이터베이스 관련
 */

// DB_STRING=mongodb://<user>:<password>@localhost:27017/database_name
const connection = mongoose.createConnection(process.env.DB_STRING);

// 사용자 정보를 저장할 스키마 생성, salt는 사용자가 생성될 때, 1~12사이 랜덤하게 집어넣는다.
const UserSchema = new mongoose.Schema({
    username: String,
    hash: String,
    salt: String,
});

// 우리가 앱에서 사용할 모델을 정의
mongoose.model('User', UserSchema);

/**
 * 세션 관련
 */

// MongoStore는 세션 데이터를 저장하기 위해 사용된다.
// 이전에 mongoose.createConnection의 결과를 담아뒀던 connection 상수를 이용
const sessionStore = new MongoStore({mongooseConnection: connection, collection: 'sessions'})

// https://www.npmjs.com/package/express-session 에서 옵션 확인 가능
// secret: 세션을 인증하기 위해 사용하는 랜덤한 문자열, 실무에서는 엄청 긴 랜덤생성 문자열을 씀
// resave: 이걸 true로 설정하면, 세션이 아무것도 바뀌지 않더라도 저장함. 이걸 세팅안해도 앱은 돌지만, 터미널에서 경고 메세지가 송출됨
// saveUnintialized: resave와 비슷함. true로 세팅될 경우, 세션이 초기화되지 않은 경우에도 세션이 강제로 저장됨.
app.use(session({
    secret: process.env.SECRET,
    resave: false,
    saveUninitialized: true,
    store: sessionStore
}));

// NEW
/**
 * 패스포트 관련
 */

// 사용자가 입력한 패스워드를 해쉬로 만들어서 비교하여 해당 패스워드가 맞는지 확인하는 함수
function validPassword(password, hash, salt) {
    const hashVerify = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');
    return hash === hashVerify;
}

// 사용자가 입력한 패스워드에 랜덤한 salt를 대입하여 해쉬를 만들어 반환
function genPassword(password) {
    const salt = crypto.randomBytes(32).toString('hex');
    const genHash = crypto.pbkdf2Sync(password, sync, 10000, 64, 'sha512').toString('hex');

    return {
        salt: salt,
        hash: genHash
    }
}

passport.use(new LocalStrategy(
    function (username, password, cb) {
        User.findOne({username: username})
            .then((user) => {
                if (!user) {
                    return cb(null, false)
                }

                // 위에서 만들어준 함수
                const isValid = validPassword(password, user.hash, user.salt);

                if (isValid) {
                    return cb(null, user);
                } else {
                    return cb(null, false);
                }

            })
            .catch((err) => {
                cb(err);
            });
    }));

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

passport.deserializeUser(function (id, cb) {
    User.findById(id, function (err, user) {

        if (err) {
            return cb(err);
        }

        cb(null, user);
    })
})

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

/**
 * 라우팅 관련
 */

// /login에 방문할 시에 "로그인 페이지"를 표출
app.get('/login', (req, res, next) => {
    res.send('<h1>로그인 페이지</h1>')
});

app.post('/login', (req, res, next) => {

});

// /register에 방문할 시에 "회원가입 페이지"를 표출
app.get('/register', (res, req, next) => {
    res.send('<h1>회원가입 페이지</h1>')
})
app.post('/register', (req, res, next) => {

});

/**
 * 서버 실행 부분
 */

app.listen(parseInt(process.env.PORT));

여기서 배울 게 많다. 먼저 쉬운 부분부터 시작하자. helper functions들 부터 보자. 위 코드에서 패스워드를 생성해주고 유효성을 검사해주는 두가지 헬퍼 함수를 작성했다.

// 사용자가 입력한 패스워드를 해쉬로 만들어서 비교하여 해당 패스워드가 맞는지 확인하는 함수
function validPassword(password, hash, salt) {
    const hashVerify = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');
    return hash === hashVerify;
}

// 사용자가 입력한 패스워드에 랜덤한 salt를 대입하여 해쉬를 만들어 반환
function genPassword(password) {
    const salt = crypto.randomBytes(32).toString('hex');
    const genHash = crypto.pbkdf2Sync(password, sync, 10000, 64, 'sha512').toString('hex');

    return {
        salt: salt,
        hash: genHash
    }
}

주석의 설명에 추가로 이 함수들은 NodeJS에서 기본으로 제공(built-in)하는 crypto 라이브러리가 필요하다. 아마 몇몇은 더 좋은 암호화 라이브러리에 대해 논쟁을 할 것이지만, 앱이 높은 수준의 보안을 필요로 하지 않는 이상은 이정도 라이브러리면 꽤 괜찮다.

다음으로는 passport.use() 메소드를 보자.

// 이 함수는 `passport.authenticated()` 메소드가 실행되면 실행될 함수이다.
// 만일 사용자가 유효하다고 판명되면, 콜백함수인 `cb(null, user)`가 호출되어 user 오브젝트를 반환한다.
//그리고 그 user 오브젝트는 `passport.serializeUser()` 메소드에 의해 직렬화되고,
// `req.session.passport` 오브젝트에 추가된다.
passport.use(new LocalStrategy(
    function (username, password, cb) {
        User.findOne({username: username})
            .then((user) => {
                if (!user) {
                    return cb(null, false)
                }

                // 위에서 만들어준 함수
                const isValid = validPassword(password, user.hash, user.salt);

                if (isValid) {
                    return cb(null, user);
                } else {
                    return cb(null, false);
                }

            })
            .catch((err) => {
                cb(err);
            });
    }
));

키 컴포넌트에 대해 살펴보자. 처음으로, 모든 Passport JS 인증 전략 (우리가 사용하는 local strategy 외에도...)이 마찬가지인데, passport.authenticate()를 호출할 때 수행될 콜백을 작성해주어야 한다. 예를 들면, 앱에 다음과 같이 라우트가 있을 것이다.

app.post('/login', passport.authenticate('local', { failureRedirect: '/login' }), (err, req, res, next) => {
  if (err) next(err);
  console.log('You are logged in!');
});

사용자는 호스트 네임과 패스워드를 로그인 폼을 통해 입력할 것이다. 그리고 HTTP POST 요청을 만들어내서 /login 라우트로 보낼 것이다. 그렇게 되면 다음과 같은 데이터를 요청에 포함하여 보내게 될 것이다.

{
  "email": "sample@email.com",
  "pw": "sample password"
}

위와 같은 데이터가 날아오게 된다면, 여러분의 Passport는 작동하지 않을 것이다. 왜냐하면 usernamepassword를 받도록 passport.use() 함수를 작성하였으니까 그렇다.

{
  "username": "sample@email.com",
  "password": "sample password"
}

위와 같은 데이터가 날아오면 잘 처리될 것이다. 다른 방법으로는 passport.use() 함수의 필드 정의 옵션을 사용해도 된다.

passport.use(
  {
    usernameField: 'email',
    passwordField: 'pw'
  },
  function (email, password, callback) {
    // 여기에 콜백 함수를 구현하면 된다.
  }
);

위와 같이 usernameFieldpasswordField를 지정해주어도 된다. 이렇게 하면 사용자가 로그인 인증서(credentials)를 보낼 때, 여기서 미들웨어로 사용되는 passport.authenticate() 메소드는 POST 요청 바디로부터 내가 정의한 usernamepassword를 받아와서 콜백 함수를 실행할 것이다. passport.authenticate() 메소드는 두 개의 파라미터를 받는다. 첫번째는 전략이고 두번째는 옵션이다. 전략의 기본 값은 local이다. 하지만 다음과 같이 변경할 수 있다.

// 커스텀으로 사용할 이름을 앞에 적는다.
passport.use('custom-name', new Strategy());
// 위에 정의한 이름과 같은 이름을 적는다.
app.post('/login', passport.authenticate('custom-name', { failureRedirect: '/login' }), (err, req, res, next) => {
    if (err) next(err);
    console.log('You are logged in!');
});

내가 사용한 passport.authenticate() 전략은 new LocalStrategy()에 정의해뒀던 콜백 함수를 실행하고 만일 인증이 성공적이면 next() 함수를 호출하고 라우트로 들어가는 것이었다. 만일 인증이 성공적이지 않다면 (올바르지 않은 username이나 passpword를 입력했다면), 앱은 /login으로 다시 라우트될 것이다.

이제 우리는 이게 어떻게 작동하는지에 대해서 이해했으므로, 우리가 이전에 정의해줘서 passport.authenticate()가 사용 중인 콜백 함수로 돌아가보자.

// 이 함수는 `passport.authenticated()` 메소드가 실행되면 실행될 함수이다.
// 만일 사용자가 유효하다고 판명되면, 콜백함수인 `cb(null, user)`가 호출되어 user 오브젝트를 반환한다.
// 그리고 그 user 오브젝트는 `passport.serializeUser()` 메소드에 의해 직렬화되고,
// `req.session.passport` 오브젝트에 추가된다.
// passport에게 passport.authenticate()를 할 때 이 전략을 사용하라고 전달한다.
passport.use(new LocalStrategy(
    // login POST 요청에서 username과 password 필드를 제공해주는 함수
    function (username, password, cb) {
        // 몽고 DB를 찾아서 유저이름을 제공해줌
        User.findOne({username: username})
            .then((user) => {
                // 콜백 함수는 두가지 값을 예상함, 1. 에러, 2. 사용자 정보
                // DB에서 사용자 정보를 찾을 수 없다면, 어플리케이션 오류는 아님
                // 그래서 우리는 error 값으로는 `null`을 전달하고, `false`를 user value에 담아 보냄
                if (!user) {
                    return cb(null, false)
                }
                // 함수가 아직 리턴되지 않았기에, 우리는 `user` 오브젝트가 유효하다는 것을 알 수 있음.
                // 위에서 만든 비밀번호 검증 함수를 이용해서 비밀번호의 유효성 검사를 함
                const isValid = validPassword(password, user.hash, user.salt);

                if (isValid) {
                    // 비밀번호가 맞으면 사용자 정보 반환
                    return cb(null, user);
                } else {
                    // 비밀번호가 틀렸으므로 false 반환
                    return cb(null, false);
                }

            })
            .catch((err) => {
                // 앱 자체에서 에러가 났으면, 에러 파라미터를 채워서 콜백을 수행
                cb(err);
            });
    }));

아마 눈치 챘을 수도 있는데, 콜백 함수는 데이터베이스에 무관하고, 유효성 체크에 무관하다. 다른 말로 표현하면 몽고디비를 써야 할 이유도 없으며, 같은 방식으로 패스워드의 유효성을 검사할 이유도 없다. PassportJS는 이런 부분은 우리가 자유롭게 코딩할 수 있도록 내버려두었다. 이런 것들이 좀 헷갈릴 수 있지만, Passport JS가 왜 여기저기서 쓰이는지 알 수 있는 강력함을 어필하는 대목이기도 하다.

다음으로, 아까 작성한 두개의 연관된 함수를 볼 것이다.

passport.serializeUser(function(user, cb) {
    cb(null, user.id);
});
passport.deserializeUser(function(id, cb) {
    User.findById(id, function (err, user) {
        if (err) { return cb(err); }
        cb(null, user);
    });
});

개인적으로 나는 이 두 함수가 가장 헷갈릴 수 있다고 생각한다. 왜냐면 이것들에 대한 문서가 많이 없기 때문이다. 이 함수들이 무엇을 하는건지 알기 위해서는 Passport JS와 익스프레스 세션 미들웨어가 어떻게 상호작용을 하는지 알아야 한다. 하지만 짧게 말하자면, 이 두 함수는 세션 객체로부터 사용자를 "serializing"과 "deserializing" 하는 것에 책임이 있다는 것이다.

user 객체 전체를 세션에 저장하는 것 대신에, 데이터베이스에 있는 사용자의 ID만 저장할 수 있다. 우리가 사용자에 대해 더 많은 정보가 필요할 때는 사용자의 ID를 "deserialize"하여 데이터베이스에서 정보를 가져올 수 있다. 이걸 금방 이해하게 될 것이다.

마지막으로 Passport 구현에서, 다음과 같은 두개의 코드 line을 볼 수 있다.

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

만일 미들웨어가 어떻게 동작하는지에 대해 아직 기억한다면, app.use()가 의미하는 것이 전역 미들웨어임을 알 수 있을 것이다. 전역 미들웨어가 의미하는 것은 모든 리퀘스트에 대해서 저것을 실행하란 이야기이다.

다른 말로 하면, Express 앱이 만드는 모든 HTTP 요청에 대해, passport.initialize()passport.session()을 실행하란 이야기이다.

여기서 무언가 이상함이 느껴지지 않는가?

만일, app.use()가 내부에 있는 함수를 실행한다면, 위의 문법은 다음과 같은 의미를 지닐 것이다.

passport.initialize()();
passport.session()();

위에 있는 해당 소스코드가 잘 작동하는 이유는 사실 passport.intialize()passport.session()이 함수를 반환하기 때문이다. 다음과 같은 패턴으로 말이다.

Passport.prototype.initialize = function () {
  // 여기 무언가를 하는 코드가 있다.
  
  return function () {
    // 여기에 있는 부분이 `app.use()`에 의해 호출되는 부분이다. 
  }
}

위와 같은 내용은 사실 Passport를 사용하기 위해서 반드시 알아야 하는 문법은 아니지만, 뭔가 헷갈렸다면 알아두면 좋은 문법이다.

어쨌든...

위 두 미들웨어 함수들은 express-session 미들웨어와 PassportJS를 통합하기 위해, 필수적인 함수들이다. 이게 왜 두 함수들이 app.use(session({})) 미들웨어 다음에 와야하는지에 대한 이유이다. passport.serializeUser()passport.deserializeUser()와 같이, 이러한 미들웨어들은 더 간결하게 이해 될 것이다.

세션을 기반으로 한 인증에 대한 개념적 개요

이제 HTTP 헤더, 쿠키, 미들웨어, 익스프레스 세션 미들웨어, Passport JS 미들웨어 들에 대해 이해 했으니, 이 미들웨어를 앱에서 유저 인증을 하는데 어떻게 사용할지에 대해 배울 시간이다. 이 섹션을 이용해 개념적인 플로우를 리뷰하고 설명해보자. 그리고 다음 섹션에서는 구현하는데에 집중해보자.

우리 앱의 개념적인 플로우

  1. Express가 앱을 시작하고 http://www.expressapp.com에 대해 listen을 한다. (진짜 도메인이 있다고 가정해보자.)
  2. 사용자는 http://www.expressapp.com/login 페이지에 브라우저를 이용해 방문한다.
  3. express-session 미들웨어는 Express 서버로 사용자가 접근하는 것을 알아챈다. express-session 미들웨어는 HTTP 헤더의 req 객체에서 Cookie 부분을 체크한다. 이 사용자는 첫 방문이기 때문에, Cookie 헤더에 아무런 값이 없다. 그래서 Express 서버는 /login HTML을 반환해주고, Set-Cookie HTTP 헤더를 호출한다. Set-Cookie 값은 개발자에 의해 세팅된 옵션에 따라 동작하는 express-session 미들웨어에 의해 생성된 쿠키 스트링(UUID이고, maxAge 값은 10일이라고 가정하자.)이다.
  4. 사용자는 지금 당장 로그인하고 싶지 않고 브라우저 창을 닫는다.
  5. 사용자는 다시 브라우저를 켜고 http://www.expressapp.com/login에 접근한다.
  6. 다시 한번, express-session 미들웨어는 GET 요청을 확인하고 HTTP 헤더에서 Cookie 부분을 확인한다. 하지만 이번엔, Cookie를 찾는다! 쿠키를 찾을 수 있는 이유는 이전에 사용자가 이 사이트에 한번 방문했고, 10일동안 유지되는 쿠키를 갖고 있기 때문이다. 왜 10일동안 가냐면 express-session 미들웨어에서 maxAge를 10일로 설정해두었기 때문이다. 또한 브라우저를 닫는 행위 자체가 쿠키를 삭제하지 않기 때문이다.
  7. express-session 미들웨어는 이제 HTTP 헤더의 Cookie에서 connect.sid 값을 가져온다. MongoStore(데이터베이스 세션 저장소를 뭔가 있어보이게 이르는 말)에서 그 Cookieconnect.sid 값과 일치하는 데이터가 있는지 찾아본다. 쎄션이 존재하기 때문에, express-session 미들웨어는 아무런 일도 하지 않는다. 그리고 HTTP 헤더의 Cookie 값과 sessions collection에 있는 MongoStore 데이터베이스 엔트리는 같게 유지된다.
  8. 이제 사용자가 이름과 패스워드를 치고 "Login" 버튼을 눌렀다.
  9. "Login"버튼을 누름으로써, 사용자는 /login 경로에 POST 메소드로 요청을 보내게 된다. 해당 경로에는 passport.authenticate() 미들웨어가 이미 사용되고 있다.
  10. 여태까지 동작한 모든 요청에 대해서, passport.initialize()passport.session() 미들웨어가 동작 중이었다. 각각의 요청에 대해서, 이 미들웨어들은 req.session 오브젝트를 확인하고 있다. (express-session 미들웨어에 의해 만들어진) passport.user(req.session.passport.user)를 확인한다. passport.authenticate() 메소드가 아직 호출되지 않았기 때문에, req.session 오브젝트는 아직 passport라는 프로퍼티가 없다. passport.authenticate() 메소드는 이제 POST로 요청된 /login 메소드에 의해서 생기게 된다. Passport는 유저가 입력하고 보낸 username과 password를 이용해서 우리가 정의한 인증 콜백을 수행할 것이다.
  11. 우리는 사용자가 이미 데이터베이스에 등록이 되어있다고 가정할 것이고 올바른 인증 정보를 입력했다고 가정할 것이다. Passport 콜백 함수는 user 검증을 성공적으로 마쳤다.
  12. passport.authenticate() 메소드는 이제 검증된 user 오브젝트를 반환한다. 추가로, req.session.passport 프로퍼티가 req.session 오브젝트에 붙는다. passport.serializeUser() 메소드에 의해서 serialize (여기서는 사용자의 id로 변함) 된다. 그리고 serialize된 사용자의 데이터(여기서는 사용자의 id)가 req.session.passport.user 프로퍼티에 붙게 된다. 마지막으로, 전체 유저 오브젝트를 req.user에 붙인다.
  13. 사용자는 컴퓨터를 끄고, 잠시 다른 일을 한다.
  14. 다음날, 사용자는 컴퓨터를 다시 켜고 우리 앱의 보호된 경로에 방문한다.
  15. express-session 미들웨어는 req에서 HTTP 헤더의 Cookie 정보를 체크하고, 어제의 세션에서 해당 값을 찾아본다. (우리가 maxAge를 10일로 설정했기 때문에 아직 유효하다.) 세션은 mongoStore에서 찾아본다. 찾았고 정상적인 쿠키이므로, Cookie에 아무런 조치를 취하지 않는다. 미들웨어는 다시 req.session 객체를 초기화시키고 MongoStore에서 반환된 값을 세팅해준다.
  16. passport.initialize() 미들웨어는 req.session.passport 프로퍼티를 체크하고 user의 값이 여전히 있는지 확인한다. passport.session() 미들웨어는 req.session.passport.user에서 발견된 user 프로퍼티를 req.user 오브젝트에 다시 할당한다. 이 과정에서 passport.deserializeUser() 함수가 사용된다.
  17. 보호된 경로는 요청에서 req.session.passport.user가 존재하는지 확인한다. Passport 미들웨어가 방금 다시 초기화(re-initialize)해주었기 때문에, 존재한다. 그리고 보호된 경로는 사용자 접근을 허가해준다.
  18. 사용자는 2달정도 있다가 컴퓨터를 다시 켠다.
  19. 사용자는 다시 돌아와 보호된 경로로 접근한다 (이 때 세션은 만료되어 있을 것이다.)
  20. express-session 미들웨어가 작동한다. HTTP 헤더의 Cookie 부분에 만료된 쿠키 값이 있는 것을 알아채고 Cookie 값을 새로운 세션이 Set-Cookie HTTP 헤더를 이용해 res 오브젝트에 다른 값으로 대체시킨다.
  21. passport.initialize()passport.session() 미들웨어가 동작하지만 이번엔, express-session이 새로운 세션을 만들어야 했기 때문에, 더이상 req.session.passport 객체가 존재하지 않았다!
  22. 사용자가 로그인하지 않았었고 보호된 경로에 접근하려 했기 때문에, 해당 경로는 req.session.passport.user가 존재하는지 확인한다. 현재 존재하지 않기 때문에 접근은 거부된다.
  23. 일단 사용자가 로그인을 다시 하고 passport.authenticate() 미들웨어를 다시 동작시키면, req.session.passport 객체는 다시 생겨날 것이다. 그리고 사용자는 다시 보호된 경로로 접근이 가능하다.

세션을 기반으로 한 인증 구현

어려운 부분은 이제 다 끝났다.

배웠던 모든 것을 합쳐서, 아래의 완전히 동작하는 세션 기반의 Express 인증 앱이 아래에 있다. 아래는 모든 것들이 하나의 파일에 포함되어 있지만 아래의 앱을 실제 상황에서 사용하는 것과 비슷하게 만들어놓은 레포지토리가 여기 있다.

위에 제가 잘못 작성한 소스를 그대로 따라하면 에러가 납니다.. 아래 전부 해결한 제 소스를 사용하시는 것이 편할 거에요

const express = require('express');
const mongoose = require('mongoose');
const session = require('express-session');
const MongoStore = require('connect-mongo')(session);

/**
 * 패스포트 관련 require
 */
const passport = require('passport');
const LocalStrategy = require('passport-local').Strategy;
const crypto = require('crypto');


/**
 *  Express 미들웨어 및 dotenv 관련
 */

// dotenv 작동을 위해 .config() 실행
require('dotenv').config();
var app = express();

// JSON과 x-www-form-urlencoded로 온 데이터를 Express가 파싱할 수 있도록 다음 미들웨어를 추가해준다.
// `bodyParser`와 비슷한데, 대부분의 앱에서 여기에 bodyParser를 추가하는 것을 보았을 것이다.
app.use(express.json());
app.use(express.urlencoded({extended: true}));

/**
 * 데이터베이스 관련
 */

// DB_STRING=mongodb://<user>:<password>@localhost:27017/database_name
const connection = mongoose.createConnection(process.env.DB_STRING, {
    useNewUrlParser: true,
    useUnifiedTopology: true
});

// 사용자 정보를 저장할 스키마 생성, salt는 사용자가 생성될 때, 1~12사이 랜덤하게 집어넣는다.
const UserSchema = new mongoose.Schema({
    username: String,
    hash: String,
    salt: String,
});

// 모델을 실제로 정의 (define)
const User = connection.model('User', UserSchema);

// 우리가 앱에서 사용할 모델을 정의
mongoose.model('User', UserSchema);

/**
 * 세션 관련
 */

// MongoStore는 세션 데이터를 저장하기 위해 사용된다.
// 이전에 mongoose.createConnection의 결과를 담아뒀던 connection 상수를 이용
const sessionStore = new MongoStore({mongooseConnection: connection, collection: 'sessions'})

// https://www.npmjs.com/package/express-session 에서 옵션 확인 가능
// secret: 세션을 인증하기 위해 사용하는 랜덤한 문자열, 실무에서는 엄청 긴 랜덤생성 문자열을 씀
// resave: 이걸 true로 설정하면, 세션이 아무것도 바뀌지 않더라도 저장함. 이걸 세팅안해도 앱은 돌지만, 터미널에서 경고 메세지가 송출됨
// saveUnintialized: resave와 비슷함. true로 세팅될 경우, 세션이 초기화되지 않은 경우에도 세션이 강제로 저장됨.
// cookie: 쿠키 객체는 몇가지 옵션이 있는데 가장 중요한 옵션은 `maxAge` 옵션이다. 아무것도 입력하지 않으면 브라우저를 끄는 순간 사라진다.
// 브라우저마다 쿠키의 동작이 약간씩 다르니 주의해야 한다. 이를테면 크롬은 브라우저를 종료해도 쿠키를 유지하니 유의하자.
app.use(session({
    secret: process.env.SECRET,
    resave: false,
    saveUninitialized: true,
    store: sessionStore,
    cookie: {
        maxAge: 1000 * 30
    }
}));

// NEW
/**
 * 패스포트 관련
 */

// 사용자가 입력한 패스워드를 해쉬로 만들어서 비교하여 해당 패스워드가 맞는지 확인하는 함수
function validPassword(password, hash, salt) {
    const hashVerify = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');
    return hash === hashVerify;
}

// 사용자가 입력한 패스워드에 랜덤한 salt를 대입하여 해쉬를 만들어 반환
function genPassword(password) {
    const salt = crypto.randomBytes(32).toString('hex');
    const genHash = crypto.pbkdf2Sync(password, salt, 10000, 64, 'sha512').toString('hex');

    return {
        salt: salt,
        hash: genHash
    }
}

// 이 함수는 `passport.authenticated()` 메소드가 실행되면 실행될 함수이다.
// 만일 사용자가 유효하다고 판명되면, 콜백함수인 `cb(null, user)`가 호출되어 user 오브젝트를 반환한다.
// 그리고 그 user 오브젝트는 `passport.serializeUser()` 메소드에 의해 직렬화되고,
// `req.session.passport` 오브젝트에 추가된다.
passport.use(new LocalStrategy(
    function (username, password, cb) {
        User.findOne({username: username})
            .then((user) => {
                if (!user) {
                    return cb(null, false)
                }

                // 위에서 만들어준 함수
                const isValid = validPassword(password, user.hash, user.salt);

                if (isValid) {
                    return cb(null, user);
                } else {
                    return cb(null, false);
                }

            })
            .catch((err) => {
                cb(err);
            });
    }));

// 이 함수는 `passport.authenticate()` 메소드와 연결되어 사용된다.
// `passport.use()` 메소드를 보면서 참고하면 이해가 더 쉽다.
// 콜백에 에러는 null, 반환할 객체에는 `user.id`를 담아 보내는 것.
passport.serializeUser(function (user, cb) {
    cb(null, user.id);
});

// 이 함수는 `app.use(passport.session())` 미들웨어가 아래에 정의되어 있다.
// PASSPORT AUTHENTICATION 부분의 주석을 읽으면 도움이 될 것이다.
// 요약하자면 이 메서드는 `req.session.passport`에 저장된 user ID를 통해서 req에 전체 user 객체를 불러온다.
passport.deserializeUser(function (id, cb) {
    User.findById(id, function (err, user) {

        if (err) {
            return cb(err);
        }

        cb(null, user);
    })
})

/**
 * 패스포트 인증 관련
 */

// 미들웨어가 `express-session` 이후에 초기화된다는 것에 유의하자.
// 그 이유는 passport가 `express-session` 미들웨어에 의존하기 때문이다.
// 그리고 `req.session` 오브젝트에 접근이 가능해야 한다.

// passport.initialize()- 모든 HTTP 요청 전에 실행되는 미들웨어를 만든다.
// 이 미들웨어는 두가지 스텝으로 동작하는데
// 1. `req.session.passport` 오브젝트가 현재 세션 위에 있는지 확인한다.
// 위 오브젝트는 { user : '<Mongo DB user ID>' } 와 같은 형식으로 생겼을 것이다.
// 2. 만일 `req.session.passport` 프로퍼티가 세션에 있는 것을 확인하면,
// user ID를 갖고, 내부 passport 메소드에 나중을 위해 저장해놓을 것이다.
app.use(passport.initialize());

// passport.session() - "Session Strategy"를 이용하여 Passport 인증을 호출한다.
// 이 메소드는 다음과 같은 2가지 절차를 따른다.
// 1. MongoDB user ID를 `passport.initialize()` 메소드로부터 얻는다.
// 그리고 `passport.deserializeUser()`에 전달한다.
// `passport.deserializeUser()`가 데이터베이스에서 User를 찾아 반환할 것이다.
// 2. `passport.deserializeUser()`가 유저 오브젝트를 반환했다면, 이 유저 오브젝트는 `req.user` 프로퍼티에 할당될 것이다.
// 그리고 경로 내에서 접근될 수 있다. 만일 아무런 user도 반환되지 않으면, next()가 호출되어도 아무런 일도 일어나지 않는다.
app.use(passport.session());

/**
 * 라우팅 관련
 */

// '/login'에 방문할 시에 "로그인 페이지"를 표출
app.get('/login', (req, res, next) => {
    res.send('<h1>로그인 페이지</h1><form method="POST" action="/login">' +
        '    Enter Username:<br><input type="text" name="username">' +
        '    <br>Enter Password:<br><input type="password" name="password">' +
        '    <br><br><input type="submit" value="Submit"></form>')
});

app.post('/login', passport.authenticate('local', {
    failureRedirect: '/login-failure',
    successRedirect: '/login-success'
}), (err, req, res, next) => {
    if (err) next(err);
});

// '/register'에 방문할 시에 "회원가입 페이지"를 표출
app.get('/register', (req, res, next) => {
    res.send('<h1>Register Page</h1><form method="post" action="register">' +
        '                    Enter Username:<br><input type="text" name="username">' +
        '                    <br>Enter Password:<br><input type="password" name="password">' +
        '                    <br><br><input type="submit" value="Submit"></form>')
})

app.post('/register', (req, res, next) => {
    const saltHash = genPassword(req.body.password);
    const salt = saltHash.salt;
    const hash = saltHash.hash;

    const newUser = new User({
        username: req.body.username,
        hash: hash,
        salt: salt
    });

    newUser.save()
        .then((user) => {
            console.log(user);
        });

    res.redirect('/login')
});

app.get('/protected-route', (req, res, next) => {

    // This is how you check if a user is authenticated and protect a route.  You could turn this into a custom middleware to make it less redundant
    if (req.isAuthenticated()) {
        res.send('<h1>You are authenticated</h1><p><a href="/logout">Logout and reload</a></p>');
    } else {
        res.send('<h1>You are not authenticated</h1><p><a href="/login">Login</a></p>');
    }
});

app.get('/logout', (req, res, next) => {
    req.logout();
    res.redirect('/protected-route');
});

app.get('/login-success', (req, res, next) => {
    res.send('<p>You successfully logged in. --> <a href="/protected-route">Go to protected route</a></p>');
});

app.get('/login-failure', (req, res, next) => {
    res.send('You entered the wrong password.');
});


/**
 * 서버 실행 부분
 */

app.listen(parseInt(process.env.PORT), () => {
    console.log(`Server is running on localhost:${process.env.PORT}`);
});

결과

처음 로그인페이지를 접근했을 때,

아직 로그인은 하지 않았지만, express-sessionreq객체에서 Cookie부분을 체크하고 Cookie헤더에 아무런 값이 없던 것을 확인한 express-session은 그런 일을 참을 수 없다. 그래서 Set-Cookie로 쿠키를 만들어준다.

이 쿠키는 물론 몽고 DB의 세션 저장소에도 존재한다.

이제 이 쿠키는 maxAge인 3600초 즉, 1시간동안 유지된다. 이건 브라우저를 닫아도 계속 유지된다.

이 도메인에서 무언가 요청할 때마다 위와 같이 Set-Cookie를 달고다닌다. 이 Set-Cookie에서 계속 쿠키 값이 있음을 알린다.

위 sid 세션은 만료될 때까지 이 사용자가 어떤 사용자인지 로그인을 했는지 로그아웃을 했는지에 대해 기억하게 될 것이다.

사용자가 로그인을 했을 때,

로그인을 하면, POST 요청으로 /login 경로에 내가 입력하였던 usernamepassword를 전송한다.

그런데 이미 그 곳에는 아래와 같이 passport.authenticate(...) 미들웨어가 기다리고 있다.

passport.authenticate('local', ...) 미들웨어는 우리가 위에서 작성했던 passport.use('local', {... }) 메소드를 통해 입력된 usernamepassword에 대한 유효성 검사를 한 뒤에 만일 일치한다면 콜백함수로 user 객체를 보낸다.

해당 콜백함수로 반환된 user객체는 passport.serializeUser() 메소드에 의해 id만 남게되고, req.session.passport.user 프로퍼티에 그 아이디가 붙게된다.

그리고 passport.session() 미들웨어를 통해 req.user에 해당 user객체가 붙게된다.

보호된 라우트에 접근했을 때,

req.isAuthenticated()로 인증이 되었는지 아닌지 확인할 수 있고, if문 분기를 통해서 인증된 사용자와 인증이 되지 않은 사용자에게 다른 루틴으로 가도록 분리할 수 있다.

리뷰와 미리보기

이게 passport-local 인증의 끝이다. 하지만 나는 이번 글이랑 매우 비슷한 내용이며 또 다른 유저 인증방식 플로우에 대해서 더 넓은 이해를 하게 도와줄 passport-jwt 전략에 대한 튜토리얼도 올려놨다.

세션을 기반으로 한 인증에서 JWT로 넘어가고 있고, 인증방식 플로우를 깔끔하게 하는 것은 중요하다. 빠르게 리뷰하기 위해서, 세션을 기반으로 한 기본적인 인증의 흐름은 다음과 같다.

  1. 사용자가 익스프레스 앱에 방문한다 그리고 usernamepassword를 이용해 로그인한다.
  2. 사용자의 usernamepassword는 POST 전송방식을 통해서 익스프레스 앱의 /login 경로로 보내진다.
  3. 익스프레스 앱 서버는 데이터베이스에서 사용자 정보를 불러올 것이다. 그리고 몇초전에 사용자가 제공한 패스워드의 해시를 가져가게 될 것이다. 그리고 데이터베이스에서 salt를 불러오고 가져온 해시와 데이터베이스에 저장된 해시가 일치하는지 비교한다.
  4. 해시가 맞아떨어질 경우, 사용자가 올바른 인증정보를 보냈다는 것을 알 수 있고 passport-local 미들웨어는 사용자 정보를 현재 세션에 더할 것이다. req.session.passport.user
  5. 사용자가 프론트에서 하는 매 새로운 요청에 대해서, 사용자의 세션 쿠키가 매 요청에 붙을 것이다. 그리고 세션 쿠키는 패스포트 미들웨어에 의해서 순차적으로 검증될 것이다. 만일 패스포트 미들웨어가 세션 쿠키를 성공적으로 검증한다면, 서버는 요청된 경로의 데이터를 올바르게 반환해 주고 우리의 인증 플로우는 끝이 난다.

여러분이 알았으면 좋겠는 부분은 이 플로우가 사용자가 단 한번만 usernamepassport를 입력했다는 사실이다. 그리고 세션에 대해서 다시한번 되짚자면, 보호된 경로에 대해서 접근할 수 있다는 것이다. 세션 쿠키는 자동적으로 모든 요청에 붙게 된다. 왜냐하면 이게 웹 브라우저와 쿠키가 작동하는 기본적인 방법이기 때문이다. 그리고 추가로, 매 요청이 이뤄질 때마다 패스포트 미들웨어와 익스프레스 세션 미들웨어는 세션 정보를 저장하기 위해서 데이터베이스로 쿼리를 보낼 것이다. 즉, 유저를 인증하기 위해 데이터베이스가 필요하다.

이제 앞의 것들을 스킵하고, JWT는 매번 유저를 인증하기 위해서 데이터베이스가 필요 없었다. 그렇다, 우리는 초기에 유저를 인증하기 위해서 데이터베이스 요청을 딱 한번 하고, JWT를 만들었지만 이후로는 JWT는 HTTP 헤더의 Authorization (Cookie 헤더에 붙었던 것 과는 다르게) 그리고 이후에는 데이터베이스가 필요없었다.

만일 이해가 되지 않는다면 그걸로 괜찮다. passport-jwt 포스팅으로 가서 한번 배워보면 된다. 이 링크에 방문하면 된다.

profile
풀스택 웹개발자로 일하고 있는 Jake Seo입니다. 주로 Jake Seo라는 닉네임을 많이 씁니다. 프론트엔드: Javascript, React 백엔드: Spring Framework에 관심이 있습니다.

1개의 댓글

comment-user-thumbnail
2022년 12월 4일

한가지 오타가 있는 것 같아, 코멘트 남깁니다.
sync 대신 salt 를 쓰시려고 한 것 같은데 맞을까요?
=> const genHash = crypto.pbkdf2Sync(password, sync...)

해당 포스트를 감사히 보고 있습니다. 감사합니다.

답글 달기