들어가기 전에

리액트 지식 정리 #6, 리액트에서 JWT 토큰과 Passport.js 구현하기

JWTImage.png

이미 인터넷에는 JWT사용법을 설명하는 많은 MERN(MongoDB, Express, React, Nodejs)스택 튜토리얼들이 존재합니다. 하지만 그 튜토리얼들이 설명하지 않고 이 포스팅에서만 커버하는 부분이 있습니다.

왜, 어떻게 Passport는 passport-jwt를 포함하여 이렇게 다양한 인증방식을 제공하는가?

계속 몇시간동안이나 계속 유저 등록 어플리케이션을 만들어왔습니다.

여러분의 경험이 어땠는지는 모릅니다 하지만 제가 회사에서 일했던 프로젝트들의 대부분은 인증 부분이 이미 누군가에 의해 구현됐었습니다. 이번 포스팅은 MERN 스킬을 더욱 증진시키는 것 외에도 보안과 인증 그리고 이를 위한 다른 방법들에 대해 더 많이 배울 수 있는 기회일 것입니다.

이 포스팅은 주로 어떻게 서버사이드에서 Passport와 JWT를 구현하는가에 대해 다룰 것입니다. 왜냐하면 MAGIC은 거기서 일어나기 때문입니다. 하지만 만일 원한다면, 소스 코드와 만든 MERN 프로젝트의 모든 파일들을 여기에서 확인할 수 있습니다. 일단 백엔드에서 제대로 연결이 되면, 프론트엔드는 순수하게 리액트입니다.

배우기 전에, JWT와 Passport.js 인증에 대해 간단히 알아봅시다.

JSON Web Token이란 무엇인가?

JWT.png

JSON Web Token의 공식 사이트의 설명은 다음과 같습니다.

"JSON 객체로써 파티들(Parties) 사이에서 안전하게 정보를 전송하기 위해 간단하고 독립적인 방법을 정의하는 공개된 표준(RFC7519) - JWT.io

본질적으로, JWT는 디지털적으로 인증된 확인 가능하고 믿을 수 있는 토큰입니다. 그리고 파티(parties(server-client 같은 것)) 사이에서 보안과 인증을 위해 그리고 토큰이 변조되거나 디코딩되지 않았는지 확인하는 동안 나오는 민감한 정보의 보안을 위해 더욱 인기가 많아지고 있습니다.

토큰이 어떻게 동작하는지 상세히 기술하진 않을 것입니다. 그건 더욱 신뢰할 수 있는 소스인 여기에서 확인하세요. 하지만 어플리케이션을 만들 때, 간단한 유저 등록을 만들기 위해 Passport.js와 함께 유저 등록의 strategy로 JWT를 사용했다는 것은 말할 수 있습니다. (왜냐하면 구현을 하기 위해 이렇게 많은 작업을 했던 경험이 없어서 제가 진짜로 할 수 있는지 궁금했거든요.)

이정도까지 알아보고, 인증 솔루션을 알아봅시다. 다음 부분은 Passport.js입니다.

Passport.js 는 무엇이고 왜 사용해야 하는가

passport.jslogo.png

Passport는

Node.js를 위한 간단하고, 무겁지 않은(unobtrusive) 인증입니다.

그리고 Passport.js는 Express와 함께 매우 잘 동작합니다. Passport는 노드를 위한 인증 미들웨어입니다. 모든 다른 기능은 어플리케이션 자체에 보내고 모듈 단위에서 요청을 인증하기 위한 목적을 갖고 있습니다. 코드를 더욱 깔끔하게 하고 유지보수를 더욱 쉽게 하고 고려할 부분을 명확히 나눠줍니다.

만일 'JavaScript authentication middleware'혹은 'JavaScript authentication'을 구글 검색창에 타이핑하면 Passport.js가 상위 5위 안에 있습니다. 이 결과는 JavaScript ecosystem에서 Passport.js가 얼마나 많이 쓰이는지 알려줍니다.

Passport.js가 500개 이상의 인증 Stategy(전략)를 제공한다고 말했었나요? 간단하게 유저 이름과 비밀번호로 로그인을 하고싶던 Github, Facebook, oAuth 등등 다른 인증을 쓰고싶던지 상관없이 Passport가 그것을 지원할 것입니다.

인증 strategy를 위해 Passport를 고르는 것은 매우 간단하고 당연한 선택입니다.

사이트의 문서도 꽤 괜찮다고 말하고 싶습니다. custom callback과 같은 부분은 올바르게 이해하려면 좀 더 잘 읽어봐야 하지만요.

Express.js와 약간의 React로 구현하기

expresslogo.png

이제 재밌는 부분으로 넘어갑시다. Passport.js와 JWT를 Express/Node 어플리케이션 내부에서 구현할까요? 솔직히 말하자면, 이 문제는 저를 한참이나 괴롭혔습니다. 많은 튜토리얼 후에 많은 문서를 다시 정독하고 스택오버플로에 도움을 청한 뒤에, 어느정도 이해를 했고 만족했습니다.

이 앱의 목적 중 하나는 모듈 형태로 만드는 것이라 할 수 있습니다. 추가하고 삭제하는 기능을 매우 간단하게 만드는 것입니다. 그래서 파일 구조를 봤을 때, CRUD 함수에 대한 모든 라우트가 각각의 파일에 구성되어 있다는 것을 한 눈에 알 수 있을 것입니다. 그리고 모든 Passport 인증은 중앙의 파일 하나에서 다뤄진다는 것 또한 쉽게 알 수 있을 것입니다. 어플리케이션 API부분 파일 구조를 봅시다.

API 파일 트리

root/
├── api/ 
| ├── config 
| | ├── passport.js 
| | ├── jwtConfig.js
| ├── server.js 
| ├── sequelize.js 
| ├── package.json
| ├── models/ 
| | ├── user.js
| ├── routes/ 
| | ├── deleteUser.js 
| | ├── findUsers.js 
| | ├── loginUser.js 
| | ├── registerUser.js
| | ├── updateUser.js
| ├── node-modules/

Package.json 파일

jwtpackage.png

딱히 대단한 것은 없습니다: just jsonwebtoken, passport, passport-local and passport-jwt.

몇몇 추가적인 의존성이 있는데 babel과 같은 것입니다. ES6 문법을 Node.js app에서 쓰고 싶어서죠. bcrypt는 패스워드 해싱을 위해서고 sequelize는 MySQL ORM입니다. 하지만 무엇보다 잘 봐둬야 할 것들은 jsonwebtoken, passport, passport-local 그리고 passport-jwt입니다. 이 블로그의 필수요소들이죠.

Server.js파일

설명이 가장 적게 필요한 server.js 파일을 처음으로 시작하겠습니다. 이 파일은 순수하게 서버를 시작하기 위해서 존재합니다. app에서 Passport의 사용을 초기화하고 모든 라우트와 client에서 받은 요청들을 파싱하는 부분이 세팅되어 있습니다.

jwtserverjs.png

ES6의 import와 const같은 것들이 들어갔지만, passport.initialize()와 routes같은 것들에 집중해주세요.

꽤 직관적이죠? 좋습니다. 다음으로 갑시다.

아마 위의 소스에서 require('./config/passport);와 같은 부분을 발견했을지도 모릅니다. 이제 이 부분이 우리가 작성해야 할 부분입니다. JWT와 Passport 설정(configuration)입니다. config라는 폴더 내부에 들어갈 것입니다.

jwtConfig.js 파일

JWT config는 매우 간단합니다. JWT가 인코딩과 디코딩 하기 위해 필요한 secret을 제공합니다. 일반적으로는 Github에 올라가지 않기 위해 .env파일에 저장됩니다. 하지만 이번에는 여기에 작성하겠습니다.

jwtConfig.png

config 폴더 내부에 jwtConfig.js 파일입니다.

passport.js 파일

Passport 인증을 위한 진짜 설정은 passport.js파일입니다.

passportjssetup.png

한번에 받아들이기엔 너무 많은 코드라는 것을 압니다. 그냥 Passport와 Passport 인증에 대한 routes에 관한 코드만 보고싶다면, 몇가지 요점만 정리된 여기에서 코드를 보세요.

여기서 이뤄지는 Passport 구현은 두가지 타입을 다룰 것입니다. passport-local은 register 그리고 login 메소드를 위한 것이고, passport-jwt는 jwt 메소드를 위한 것입니다.

Passport-local은 유저 이름과 패스워드를 사용하고 Passport-jwt는 유저가 올바른지 확인하기 위해, JWT payload를 사용합니다.

이전에 말했듯이, Express.js를 사용하기 위한 문서는 꽤 잘돼있습니다 하지만 MySQL 데이터베이스와 조합하는 것은 조금 까다롭습니다. 정보는 프론트 엔드에 있는 리액트 클라이언트에서 전달됩니다.

일단 passport-local strategy에 username과 password가 넘겨지면(서버에서 먼저 두가지 입력 값들이 적어도 제대로 입력 됐는지 확인하고 서버로 보내야겠죠?), MySQL ORM인 Sequelize로 해당 username이 데이터베이스에 존재하는지 먼저 체크를 합니다. 만일 null 값을 반환한다면, 인증이 실패합니다.(db에 어떤 사용자도 매칭되지 않았을 때). 그리고 일반적으로 추가적인 정보 없이 401 Unauthorized가 서버로부터 클라이언트로 반환될 것입니다.

Passport에서 알아야 할 것#1: 에러 핸들링 정보

Passport에서 이런 방식의 에러 핸들링은 저에게 가장 짜증나는 부분이었습니다. 유저에게 정확히 어떤 에러인지 알려주기엔 정보가 너무 부족합니다. 그저 401 Unauthorized라고만 나오니까요.

하지만, 조금 더 작업을 해야하지만 해결 방법은 있습니다. 커스텀 콜백(custom callbacks) 으로 처리하는 것입니다. 콜백에 대해서 더 자세한 정보는 이따가 알아보기로 하고, 지금은 몇몇종류의 에러가 있는지 알아보도록 합시다. 그냥 return done(null, user);를 반환하는 것 대신에, Passport는 return done(null, false, { message: 'bad username or password don\'t match'});와 같이 어떤 문제가 있는지에 대한 메세지를 유저에게 반환할 수 있습니다. 만일 사용자에게 그냥 인증이 실패했다고 알려주는 것이 더 좋고 왜인지 알려주고 싶지 않다면, 그것 또한 방법입니다. 선택의 문제입니다. 하지만 여기서는 에러 메시지가 어떻게 일어나게 됐는지 알려주고 싶습니다.

passport-local strategy의 나머지 부분은 소스만 봐도 꽤 잘 설명되어 있습니다. register에서 사용자의 패스워드는 암호화 패키지(bcrypt)에 의해 해시화되고 솔트화(salted) 됩니다. 그 후에 login 메소드가 호출될 때, 새롭게 입력된 패스워드를 해시화하고 인증에서 허가 혹은 불허를 반환하기 전에 패스워드를 bcrypt의 compare 함수로 검증합니다.

이제 jwt라는 password-jwt strategy로 이동하게 됩니다. 이것은 어플리케이션 내부 보호된 routes위에서 불려지는 인증입니다: findUsers, updateUser, deleteUser. jwt strategy를 위해, JWT는 클라이언트의 호출에 각각 넘겨집니다. (위의 코드에서는 JWT라는 인증 헤더로 넘겼습니다.), 후에 시크릿을 이용하여 JWT가 추출되고 디코딩 됩니다.(우리가 주로 .env파일에 저장해뒀던 그 secret을 말합니다. 이 포스트에서는 jwtConfig.js에 저장해뒀습니다. 실제 프로젝트에서는 반드시 .env파일에 저장해야 합니다.)

일단 JWT payload가 복호화되면, passport-local strategy가 하는 것처럼 ID(여기서는 username)가 database에 의해 검색될 수 있습니다 . 그리고 만일 user를 찾았다면, done(null, user);를 반환하거나, user를 찾지 못했다면, done(null,false);를 반환할 수 있습니다. 후자와 같은 일은 주로 잘 일어나지 않습니다. 왜냐하면 JWT가 username을 암호화된 형식으로 포함하고 그래서 만일 JWT 혹은 DB가 조작되지 않았다면, user를 찾을 수 있을 것입니다.

Passport에서 알아야 할 것#2: Passport-local은 Return을 해야합니다. Passport-JWT는 Return을 안합니다.

이것 또한 저를 오랜기간 속썩였던 것입니다. 모든 passport strategy가 똑같은 resolution을 요구하지 않습니다. 위에 나와있던 passport.js의 코드를 자세히 본다면, passport-local strategy는 둘 다 return done(null, user)형식의 코드를 갖는 반면에, passport-jwt strategy는 return 없이 done(null,user)를 갖습니다. 차이가 보이시나요? 이건 아주 하찮지만 return을 갖거나 없애는 건 미들웨어로부터 넘겨지는 사용자 데이터가 서버로 가냐 아니냐의 차이를 말해줍니다.

이것은 사실 StackOverflow에서 친절한 누군가가 저를 도와주기 전까지, 제 프로젝트의 진행을 몇시간동안 멈추게 만들었습니다. 여러분은 return을 하는지 아닌지 반드시 알아보시기 바랍니다.

이제 여기까지 두개의 Passport strategy를 다뤘습니다. 그리고 프로그램에서 미들웨어 인증을 위해 사용하는 세가지 메소드들을 배웠죠.

registerUser.js 파일

다음 단계는 'routes 내부에 새롭게 만들어진 메소드들을 어떻게 구현하는가?' 입니다. 여기 registerUser route가 있습니다.

registeruserroute.png

이 route의 클로져 스타일의 호출을 잘 보세요. 그것이 인증 레이어로부터 서버와 클라이언트로의 메시지를 가능하게 합니다.

여기서 볼 수 있듯, passport의 이 구현은 많은 예제들과는 조금 다르게 보입니다. 그리고 그 이유는 여기서 Passport의 custom callback 버전을 사용하고 있기 때문일 것입니다. custom callback 버전은 클로져 호출을 요구합니다. 만일 Passport의 인증이 실패하면, 무슨 소리인지 모를 401 Unauthorized 대신에 되돌려 받아진 에러 메세지가 서버에서 클라이언트로 보내질 수 있도록 하기 위해서 이러한 방법을 사용하였습니다.

(req, nes, next)가 한번만 쓰인 것이 아니라 두번 쓰인 것을 보시면 아시겠지만, 이렇게 콜백으로 구현되었기 때문에 이러한 방법은 Passport 미들웨어로부터 (err, user, info)에 대한 접근권한을 줍니다. info는 사실 우리가 관심있는 것은 아닙니다. 이것은 되돌려지는 메세지입니다. 만일 info가 null외에 다른 무언가 값을 갖고있다면, 인증이 실패했고 우리가 클라이언트에게 왜 실패했는지 메시지를 보내 알려줄 수 있다는 것입니다.

Passport에서 알아야 할 것#3: 커스텀 콜백 내부에 req.login()을 빼먹지 맙시다.

passport-local과 함께 동작하는 클로져와 커스텀 콜백을 만들기 위해서, 작은 메소드 req.login()은 미들웨어에서 user 데이터가 성공적으로 반환됐다면 user 데이터가 변경되기 전에 반드시 호출되어야 합니다.

이 내용은 Passport 문서에 기록되어 있지만 이것의 중요성은 제가 원하는 만큼 강조되어 있지 않습니다. 그리고 저의 경우엔 처음 몇번 커스텀 콜백을 동작하게 만들려고 시도할 때마다, 이것을 빼먹었습니다. 이 경험이 제가 지금 강조하는 이유입니다.

일단 커스텀 콜백이 완성되면, 유저를 등록하기 위해 클라이언트 사이드에서 추가적인 입력을 받을 수 있고, passport-local register 호출 동안에 데이터베이스에서 만들어진 동일한 유저를 찾을 수 있습니다. 그리고 추가적인 정보를 업데이트 할 수 있습니다. 미들웨어를 통해서도 이러한 추가적인 정보를 넘길 수 있지만, 저의 경우에는 Passport가 오직 인증만을 처리하길 원합니다. 유저 생성도 처리하길 원하지 않습니다. 모듈화를 기억하시죠?

거기다 만일 인증이 암호화된 username과 password를 가진 다른 데이터베이스와 함께 독립된 서비스로 나누어져야 하면, 이렇게 구현해야 그러한 일을 하기 쉬울 것입니다. 그 후에 등록 서비스에서 부합하는 유저를 조회하거나 업데이트를 하기 위해 username 또는 ID를 사용합니다.

성공하면, 200 HTTP 상태와 성공 메세지가 클라이언트로 갑니다.

loginUser.js route 부분을 봅시다. 이제 더욱 이해가 잘 될 것입니다.

loginUser.js 파일

loginUserjs.png

login route와 같은 스타일의 클로져입니다.

같은 스타일의 커스텀 콜백 그리고 클로져가 사용됐습니다. 가장 큰 차이점은 user가 성공적으로 인증됐고 데이터베이스에 위치됐다면, JWT에서 username을 ID로 하는 jwt.sign()함수를 이용하여 JWT 토큰이 생성되고, 이전에 설정했던 secret을 통해 암호화된다는 것입니다.

모든 작업이 성공적이라면 위에서 설정한 것처럼 클라이언트는 200 HTTP 상태를 받습니다. auth가 true로 세팅되고 새롭게 생성된 토큰과 짧은 로그인 성공 메세지도 같이 받습니다.

findUsers.js 파일

마지막이지만, 역시나 중요한 findUsers route가 여기 있습니다. updateUserdeleteUser routes에서도 같은 passport-jwt 인증을 사용했습니다. 셋 중 이것을 예제로 보여드리겠습니다.

findUsers.png

Passport 인증 routes에 모두 custom callback을 썼습니다. 이러한 좋고 깔끔한 에러 핸들링에 감사합니다. 이젠 이런 코드 스타일이 루틴처럼 보일 것입니다.

이번엔, passport.authenticate()가 호출됐을 때, passport.js파일 내부에 정의된 JWT strategy를 실행합니다. 동일한 (err, user, info)가 미들웨어로부터 받아지지만, 이번엔 req.logInfo()로 부터의 호출이 없습니다. 대신 인증 동안에 찾은 user객체를 반환합니다. 그리고 모든 필요한 필드를 auth boolean과 함께 클라이언트에게 넘깁니다. 그리고 성공 메세지도 넘깁니다. JWT 토큰은 여전히 클라이언트 사이드의 local storage에 저장되어 있습니다. 그래서 다시 재생성하거나 클라이언트에게 다시 넘길 필요가 없습니다.

Passport에서 알아야 할 것#4: 인증 헤더를 올바르게 넘깁시다.

이건 Passport에만 관련된 것이 아닙니다.

말했듯, 저는 많은 튜토리얼과 문서들을 공부하고 JWT와 Passport의 지식을 많이 종합했습니다. 사실 많은 튜토리얼 중 하나가 저를 골치아프게 만들었습니다. 그것은 제가 true authorization 헤더를 제외하고 JWT를 클라이언트로 다시 보내게 만들었습니다. 그래서 제 passport-jwt strategy가 JWT payload를 클라이언트 요청에서 추출하도록 만들었습니다. 이러한 이유로, 토큰을 다시 올바른 양식으로 넘기는 곳의 프론트 엔드 코드에 대해서도 간단히 짚고 넘어갑시다. 그래야 제가 그랬던 것 처럼 디버깅하는데 오랜 시간을 쏟지 않을 겁니다.

JWT Token2.png

사용자의 local storage에 저장된 JWT를 다시 건네받은 후에 클라이언트 사이드에서 사용자의 데이터를 불러옵니다.

위의 코드에서는 서버 호출을 위해 인기 많은 promise 기반 HTTP 클라이언트인 Axios를 사용하고 있습니다. 하지만 진짜 주목해야 할 것은 header 섹션입니다. 서버로의 호출이 일어나기 전에, localStorage.getItem('JWT')를 이용하여 JWT를 로컬 저장소에서 추출합니다. (토큰을 넘길 때, JWT 값과 함께 셋팅한 키는 서버사이드의 loginUser() route 로 부터 왔습니다. 그리고 headers: { Authorization: `JWT ${accessString}` }를 이용해 헤더를 셋팅했습니다. 이런 방법을 쓰면, 만일 인증 헤더가 파싱할 값이 하나 이상 있어도, JWT와 관련된 스트링을 찾는 방법으로 passport-jwt가 올바른 JWT payload 정보를 추출하는 것은 쉽습니다.

결론

저도 이번 글이 엄청나게 길다는 것을 압니다. 하지만 인증은 간단한 개념이 아닙니다. 그리고 앱을 누구에게도 해킹 불가능하게 보안을 구성하는 것은 아마 누구도 할 수 없을 것입니다. 하지만 JWT와 Passport.js 인증 미들웨어 같은 예방책을 구성한다면, 노드 앱의 보안이 사람들의 악의적인 해킹 시도에 조금은 더 잘 버틸 수 있을 것입니다.

역자 후기
이번 글에는 너무나 많은 오역과 이해가 되지 않는 문장이 있을 수 있습니다. 한글로 번역하기 정말 너무나 어려웠고 역대 번역 글 중에 최악이었습니다.. ㅠㅠ 그래도 끝까지 읽어주신 분이 있다면 감사합니다.