Node.js, Express, Swagger를 이용한 RESTful API 설계 (4) JWT, OAuth2 구현하기

jamie-lee 2023. 7. 30. 19:40

참고: Building RESTful APIs Using Node.js and Express | Coursera
JSON Web Token Introduction - jwt.io

  • 이 포스트에서는 coursera의 Building RESTful APIs Using Node.js and Express | Coursera 를 수강하며 배운 주요 개념과 과제를 진행하며 발생한 이슈 해결 방법을 공유합니다.
  • 이는 저의 개인적인 이해를 바탕으로 작성되었으며 모든 정보가 최신이거나 완전한 것은 아니므로 항상 개선의 여지가 있습니다. 따라서 읽는 동안 어떤 피드백이나 추가적인 아이디어가 있으시다면 언제든지 댓글로 알려주시기 바랍니다.

4. Authentication & Authorization 구현하기: JWT & OAuth2

4.1. 인증(Authentication)과 권한부여(Authorization)의 차이

  • 인증
    • 사용자의 신원을 확인하는 과정
    • 로그인시 유저가 입력한 아이디와 비밀번호를 통해 신원을 검증하고, 누군지 확인하는 것
  • 권한부여
    • 인증 후에 유저가 자원에 접근하려고 할 때 유저가 수행 가능한 액션(행동)이 무엇인지 결정하는 것
    • 특정 문서 수정 권한, 관리자 페이지 접근 권한 등

4.2. JWT payload(claim) 종류와 구성

Pasted image 20230718215539.png

// Payload의 예시
{ "sub": "1234567890", 
"name": "John Doe", 
"admin": true }

  • payload의 이름은 컴팩트함을 위해 3글자만 사용함
  • payload 내용은 Base64Url로 인코딩되어 JWT를 구성하게 됨
  • payload의 타입
    1. registered: 의무는 아니지만 포함시킬 것이 권장됨
      • iss
      • exp
      • sub
      • aud 등이 있음
    2. public
      • IANA “JSON Web Token Claims” registry에 등록된 클레임
    3. private
      • registered, public이 아닌 custom claims
  • 주요 payload 필드
    • exp : 만료일! 중요
    • jti : Jwt가 replayed 되는 것을 방지하는 식별자

4.3. JWT signature

  • 인코딩된 헤더, 인코딩된 페이로드, secret key를 알고리즘으로 암호화
// HMAC SHA256 알고리즘을 사용하는 경우

HMACSHA256( 
	base64UrlEncode(header) + "." + 
	base64UrlEncode(payload), 
	secret)

4.4. JWT dataflow

Pasted image 20230718215501.png

4.5. JWT 구현하기

Pasted image 20230718220217.png

  • app.js 파일에 route 인증 라우트 구현 (/auth, /users)
    • /auth/register 새로운 유저 등록
    • /auth/login 기존 유저 로그인(jwt 토큰 생성하여 반환)
  • 토큰은 헤더에 담아 전송한다.
  • 이후 인증이 필요한 api에 접근할 때 요청 헤더(Authorization)에 담긴 token으로 유저 데이터 확인

질문 & 배운 점

Q. !(a, b, c)와 !(a && b && c)의 차이?

const { userName, email, password } = req.body;
if (!(userName && email && password)) {
  return res.status(400).send('required inputs are missing');
}
 

위 조건문의 표현식에서 영상 속 예제 코드가 !(a, b, c)라는 표현을 사용했다.(아마 잘못 기재된 것 것 같다.)
결론적으로 && 연산자를 사용하여야 예측대로 작동한다.
저게 맞나 싶었는데 역시 예측대로 작동하지 않았다.
그런데 명시적인 에러가 없는 걸 보면 실행은 된다는 소린데 어떻게 해석되는 것인지 감이 안와서 찾아보았다.

!(a && b && c)는 알다시피 a, b, c 세 값이 모두 truthy일 때만 false를 반환하고, 그렇지 않으면 true를 반환한다.
!(a, b, c)는 쉼표 연산자를 사용한 것으로, 왼쪽에서 오른쪽으로 평가하다가 마지막 c의 부정을 반환한다.

Q. router에서 유저 이메일을 바로 파싱하여 넘겨주지 않고, controller 계층에서 파싱 작업을 처리하는 이유?

유저 정보를 router에서 controller 계층으로 넘겨줄 때, 실제로 검증에 필요한 것은 유저 이메일 뿐이므로 라우터에서 바로 파싱하여 이메일을 넘겨주면 되지 않을까 생각했다.
그런데 예제 코드에서는 router 단계에서 요청에서 받은 유저 정보를 모두 넘겨주고, 컨트롤러 단계에서 이메일을 파싱하여 서비스 계층으로 넘겨주고 있었다.
여기서 의문점은 필요한 정보는 이메일 뿐이니 라우터에서 바로 파싱하여 넘겨주면 편하지 않은가이다.

관련 내용을 찾아본바에 따르면, 소프트웨어 아키텍쳐 설계 원칙 중 하나인 '단일 책임 원칙(Single Responsibility Principle)'으로 이를 설명할 수 있을 것 같다.

단일 책임 원칙은 다음과 같은 원칙이다.

  1. 각 컴포넌트는 자신의 책임 범위 내에서만 동작해야 한다
  2. 그 외의 책임은 다른 컴포넌트에게 위임해야 한다

이전에 작성한 Node.js, Express, Swagger를 이용한 RESTful API 설계 (2) Express 프레임워크 사용하기의 1.4. 항목에서 각 계층의 책임을 언급했다.

이에 따르면 Router는 HTTP 요청을 받아 controller에게 넘겨주는 것이 주요 책임이다.
요청 데이터를 파싱하는 것까지는 책임의 범위를 규정하고 있지는 않다.

모든 상황에 최적인 정답은 없겠지만 어쨌든 이러한 질문들에 답을 내놓으려면 결국은 소프트웨어 아키텍쳐 지식이 있어야 겠구나 생각이 들었다. 학습 필요를 느낀다.

Q. fs.writeFileSync와 write, writeFile의 차이?

과제에서 데이터베이스의 역할로 json 파일을 불러다가 자주 처리하는데, 파일 쓰기를 담당하는 fs 모듈의 메소드의 차이점을 비교해봤다.

  1. fs.writeFileSync:
    • 동기적으로 파일에 데이터를 쓸 때 사용. 해당 메소드가 작업을 완료하고 반환할 때까지 다른 작업을 처리하지 않음.
    • 파일에 쓰는 작업이 많거나 큰 파일을 쓸 경우에는 이 메소드의 사용이 프로그램의 성능을 저하시킬 수 있음.
  2. fs.writeFile:
    • 이 메소드는 비동기적으로 파일에 데이터를 쓸 때
    • 해당 메소드가 작업을 완료할 때까지 기다리지 않고 다음 작업을 처리
    • fs.writeFileSync 메소드보다 프로그램의 성능에 덜 영향을 미치지만, 이 메소드를 사용할 때는 비동기 작업의 완료를 처리하기 위한 콜백 함수를 제공해야 함
  3. fs.write:
    • fs.writeFile과 비슷하게 비동기적으로 파일에 데이터를 쓰는 데 사용되지만, fs.writeFile와는 달리 파일 디스크립터를 직접 다룸
    • 데이터를 쓰는 위치를 지정할 수 있어 파일의 특정 부분에 데이터를 쓰는 데 사용할 수 있음.
    • 비동기 작업이므로 콜백 함수를 제공해야 함

Q. 전체 유저를 반환하는 endpoint, 로그인한 유저를 반환하는 endpoint는?

JWT 인증을 기존에 짜놓은 서버에 덧붙여 구현하다가 의문이 생겼다.
모든 유저를 반환하는 엔드포인트와 로그인한 유저 정보를 반환하는 엔드포인트는 달라야 할진대, 어떻게 하는 것이 최선의 방법일까?

찾아본 바에 따르면 아래와 같다.

  • /users는 대체로 "모든 유저"를 반환하는 데에 사용되는 것이 일반적
  • 현재 로그인한 유저의 정보를 반환하는 엔드포인트는 /users/me, /me, /current-user, /profile 등으로 설정할 수 있음

그런데 기존에 정의한 /users/{userId} 엔드포인트를 이용해 로그인 유저 정보 또한 불러올 수 있지 않을까? 충분히 가능한데 굳이 /users/me 와 같은 엔드포인트를 사용하였을때 (더 직관적이라는 점을 제외하고) 얻을 수 있는 이점이 무엇일지 의문이 들었다.

요 부분에 대해서는 GPT4랑 얘기해서 결론을 아래와 같이 내렸다.

  • /users/me: 엔드포인트를 사용하면 로그인한 사용자의 정보만을 얻을 수 있음. 즉, 자신의 정보에 대한 요청에 대해서만 제한적으로 허용하므로 다른 사용자의 정보에 접근하는 것을 방지하므로 보안적으로 더 나음
  • /users/{userId}: 이 방식은 사용자가 자신 뿐만 아니라 다른 사용자의 정보에도 접근할 수 있게 됨. 이는 관리자가 특정 사용자의 정보를 조회하거나 수정해야 하는 경우에 사용하기 적합하다 볼 수 있음.

4.6. OAuth2의 개념과 필수 요소

  • 권한 부여를 위한 오픈 스탠다드 프로토콜(인증 프로토콜이 아니다!)
  • 리소스 집합에 유저 접근 관리
  • 외부 서비스 공급자를 이용함(ex. 페이스북, 지메일, 깃헙 등)
  • OAuth2의 필수 요소
    1. 클라이언트 : 로그인하려는 사용자
    2. 리소스 서버 : 클라이언트가 로그인하여 자원에 접근하려고 하는 서버. 클라이언트로부터 토큰을 받고 검증하여 돌려준다.
    3. authorization 서버 : 유저 아이덴티티를 인증하는 외부 앱.

4.7. How OAuth2 Works

Pasted image 20230723220854.png

4.8. OAuth2 GitHub으로 구현하기

참고: Authorizing OAuth apps - GitHub Docs

  1. 프로젝트 구조 갖추기
  2. UI 페이지(view layer) 만들기
  3. github에 OAuth2 app 등록하기

질문 & 배운 점

1) express에서 static 파일 서빙하기

access token을 받고 welcome 페이지로 리다이렉트 되어야 하는데, resource not found 에러 메시지가 뜬다.
경로는 제대로인데, 어째서 static 파일 리소스를 못 찾는다는 거지? 🤔
app.js 파일에서 static 파일을 서빙하는 부분을 다시 꼼꼼히 살펴보다가, 다음 미들웨어를 빼먹은 것을 확인.

app.use(express.static('static')); // static 파일 서빙하기 위한 미들웨어

추가해주니 잘 된다. 간단한 실수.

2) Q. HTTP Authorization header에 세팅할 수 있는 authorization 타입과 예시

Authorization: <type> <credentials>

[!NOTE] HTTP Authorization 헤더란?

  • HTTP Authorization 헤더는 사용자의 인증 정보를 포함하는 데 사용.
  • 일반적으로 사용자의 로그인 요청 또는 로그인 후 보호된 리소스에 접근하는 요청에서 사용.
  • <type>: 인증 체계
  • <credentials>: 해당 체계에 따른 실제 인증 정보
  1. Basic Auth:
    • 사용자 이름과 비밀번호를 콜론으로 연결한 문자열을 Base64 인코딩한 값
    • 단순하고 구현이 쉬우나, 매우 기본적인 보안 수준을 제공하며, 요즘은 잘 사용되지 않는다고 함. 보안성이 낮기 때문에 HTTPS 등의 보안 프로토콜 위에서 사용되어야 함
// 'Aladdin:OpenSesame'라는 문자열을 Base64로 인코딩한 것 

Authorization: Basic QWxhZGRpbjpPcGVuU2VzYW1l
  1. Bearer Token:
    • JWT(JSON Web Token) 또는 OAuth 2.0을 사용하는 경우에 주로 사용되는 방식
    • 사용자가 인증을 받으면 서버는 토큰을 반환하고, 이후 요청에서는 이 토큰을 Authorization 헤더에 포함하여 서버에 전송
    • OAuth의 경우 액세스 토큰 값이다.
// <credential> 부분에 토큰 값이 포함되었다. 

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  1. Digest:
    • Digest 인증은 Basic 인증의 단점을 보완하기 위해 만들어진 인증 방식
    • 비밀번호를 직접 전송하는 대신, 요청과 비밀번호를 해싱하여 이 값을 전송하며, 해싱된 값은 클라이언트와 서버 모두에서 계산될 수 있음.
    • 사용자 이름, 실제 요청에 대한 정보, 서버에서 제공한 “nonce” 값(일회성 난수), 그리고 이 정보들을 이용해 생성한 “response” 값을 Authorization 헤더에 포함
    • digest와 hoba 인증 모두 상대적으로 basic, bearer 타입보다 복잡함
// 요청을 보내는 사용자가 "user"이고, 요청하는 페이지가 "/dir/index.html", 서버에서 제공한 nonce 값이 "nonce1234"인 경우

Authorization: Digest username="user", realm="realm", nonce="nonce1234", uri="/dir/index.html", response="762c55611d8ffeb6b26b069b8636c1dc"
  1. Hoba (HTTP Origin-Bound Authentication)
    • 비밀번호를 사용하지 않는 HTTP 인증 방식
    • 클라이언트 키와 연관된 디지털 서명(아래 예시의 “sig”)을 사용하며, 이는 서버에서 클라이언트를 식별하는 데 사용
    • 특정 요청에 대한 “bodyhash” 값, 알고리즘, 키 ID, 시간스탬프, 그리고 이 정보들로 생성한 서명을 Authorization 헤더에 포함
// 사용자가 서명 알고리즘 "hoba-sha256"을 사용하고, 키 ID가 "1", 요청의 bodyhash 값이 "Abdabc2mz", 시간스탬프가 "1361392324", 그리고 이 정보로 생성한 서명이 "8w2G1avF7j..."인 경우
// 서명 "sig"는 알고리즘, 키 ID, 시간스탬프, 요청 방식, 요청 경로, 요청 본문의 해시 값 등을 합친 문자열에 대한 사용자의 개인 키로 생성된 디지털 서명

Authorization: HOBA alg="hoba-sha256", kid="1", ts="1361392324", bodyhash="Abdabc2mz", sig="8w2G1avF7j..."

TASK 5: JWT 토큰 인증 구현하기

this.stack.push 에러

Pasted image 20230727002539.png
router를 못 찾아가는 것 같다.
처음에는 import 하면서 파일 패스를 잘못 주었나? 하고 검토했지만, 에러 메시지를 보면 router를 지정한 파일까지는 잘 찾아가고 있다.

Pasted image 20230727125220.png
router의 post를 호출하면서 에러가 뜬 걸 확인할 수 있다.

Pasted image 20230727125326.png
router의 라이브러리 파일을 타고 들어가니, 이 부분에서 this.stack이 undefined로 찍히니 제대로 인스턴스 생성이 안 됐기 때문에 push 메소드를 읽어들일 수 없어 문제인 것.

왜 제대로 할당이 안 되었을까 내가 작성한 router 파일을 찬찬히 읽어가던 중, 아래와 같은 황당한 실수 발견.

Pasted image 20230727125637.png

이를 아래와 같이 수정하였다.

const router = express.Router();

괄호를 빼먹은 허무한 실수였다. 😅
그치만 또 요런 것 때문에 반나절을 다 날리기 십상인데 금방 찾았다.

TASK 6: Oauth2 인증 로직 구현하기

GET /profile not found 에러

Pasted image 20230727150937.png

Oauth 로직 테스트 도중 발생한 에러.
/oauth/login 엔드포인트로 진입하여 깃헙 로그인 페이지까지 리다이렉트 성공.
깃헙 로그인을 하고, 나의 로그인 성공 페이지로 돌아와야 하는데 문제가 있는 것 같다.
/profile 이라는 내가 작성한 적 없는 엔드포인트는 도대체 어디서 튀어나온 것인지. 🤔
제대로라면 /oauth/callback이라는 내가 작성한 콜백 URL로 들어와야 한다.

깃헙 로그인 콜백 url이 잘못 되었다는 진단 하에 깃헙의 oauth 앱 설정을 확인했지만 분명 /oauth/callback로 잘 세팅해 놓았다. 👀
내가 지정한 적 없는 /profile 이라는 엔드포인트가 어디서 튀어나왔을까 곰곰히 생각해보다가, 아무래도 테스트를 만든 사람이 설정한 콜백 url이라는 생각이 들었다.

그래서 아래와 같은 config 변수의 디폴트 client_id, client_secret을 주석 처리하고 나의 값만 환경변수로 취할 수 있도록 했다.
Pasted image 20230727154100.png

동일한 에러는 발생하지 않는다.
그런데 이번에는 /oauth/login을 호출하는데 쿼리로 client_id가 제대로 넘어가지 않는 에러가 발생했다.

Pasted image 20230727154356.png
자세히 보면 client_id를 undefined로 받고 있다.
아, 분명 환경변수가 제대로 안 넘어가고 있다.
다시 쳐다본 config 파일에서 내가 놓친 실수를 확인했다.
dotenv 모듈을 임포트 안한 것!

Pasted image 20230727154432.png

실수를 교정하고 재시도 결과, 내가 지정한 /oauth/callback url을 제대로 호출하고 있음을 확인.

Pasted image 20230727154640.png

로그인 잘 된다.
Pasted image 20230727155342.png

코세라 과제 제출 에러

로컬에서는 테스트 케이스가 잘 통과하는데, 제출한 경우 아래와 같은 에러가 뜨면서 0점이 떴다.

{"fractionalScore":0,"feedback":"Unable to read test report file. Kindly ensure your test cases are executing using `npm run test` command from the root folder","feedbackType":"TXT"}

Pasted image 20230727180209.png

후… discussion에도 나와 같은 에러 때문에 헤메는 사람이 많았다.
어쨌든 위와 같은 여러 시행착오 끝에 원인을 알아내서 해결하긴 했지만, 좀 더 grading 주의사항이 잘 적혀있었더라면 하는 아쉬움이 있다.
여하튼 위와 같은 에러가 뜨는 경우 아래 사항을 확인할 것.

  1. 추가 패키지를 설치하지 않았는지 확인해라(본인은 이 경우였음)
  2. 테스트 스크립트가 제대로 작동하는지 확인할 것
  3. 오로지 src 파일의 내용만 수정하는 것이 좋다

하여튼 이렇게 마지막 과제를 잘 마무리하고 수료 과정을 마쳤다. 🎉
기쁨도 잠시, 이제 이렇게 배운 걸 가지고 프로젝트에 적용하러 갈 차례다. ㅎㅎ