Recommanded Free YOUTUBE Lecture: <% selectedImage[1] %>

Contents

쿠키와 세션

HTTP는 단순하고 이해하기 쉬운 프로토콜이다. 대충 읽어도 이게 뭐하는 녀석인지 감을 잡을 수 있을 정도로 쉽다. 1요청 1응답 이로 직관적이고, 때문에 응용 프로그램을 만들기도 쉽다. 웹 브라우저를 비롯해서 수많은 지원 애플리케이션과 라이브러리들이 차고 넘친다.

HTTP는 연결을 유지하지 않는다. 각 요청은 서로 독립적이다. 따라서 상태정보를 유지 할 수가 없다. 예를 들어서 클라이언트가 처음 방문을 했는데, 로그인을 했는지, 어떤 상품을 구입했는지를 알 수 없다. 이 문제는 HTTP 클라이언트와 웹 서버가 쿠키(cookie)교환하는 것으로 해결하고 있다. 쿠키는 HTTP 헤더에 설정이 되며, 여기에 상풍구입정보, 방문정보 등을 저장한다.

쿠키는 정보를 교환하기 위한 편리한 방법이긴 하지만 정보가 인터넷에 노출되며, 클라이언트 PC에 그대로 남는 다는 심각한 보안 문제가 있다.

이 문제는 세션(session)을 이용해서 해결한다. 세션이라고 해서 특별히 다른 기술은 아니다. 쿠키를 그대로 이용한다. 다만 중요 정보들은 서버에 저장을 하고, 쿠키에는 서버에 저장된 정보를 찾을 수 있는 키 값만 보낸다. 아래 그림을 보자.

 세션
  1. ID와 Password 기반으로 로그인을 한다.
  2. 서버는 ID&Password가 일치하면, 인증 정보를 session table에 저장한다.
  3. 인증정보를 꺼낼 수 있도록 유니크한 key를 만들어서 session-id 형태로 PC(웹 브라우저)에 리턴한다.
  4. PC는 session-id로 서버에 접근한다. session-id를 읽은 서버는 session table을 읽어서 유저 정보를 찾는다.
  5. 쇼핑카트를 구현하고 싶다면 session table에 구매 정보를 추가하면 된다.
session-id에는 유저정보가 없기 때문에 쿠키보다 안전하게 사용 할 수 있다.

JWT

JWT 특징

JWT(Json Web Token)은 쿠키와 세션의 대안으로 만들어진 정보교환 방식이다. 대안으로 만들었다는 것은 기존 방식에 기능이나 성능상의 결함이 있었다는 의미다. JWT가 기존 방식과 비교해서 어떤 장점이 있는지 살펴보자.

세션은 데이터를 저장하지 않는다. 정보가 저장된 위치만 기록하고 있을 뿐이다. 따라서 정보를 꺼내기 위해서는 매 요청마다 데이터베이스를 조회해야 한다. 데이터 공간을 차지하며 시스템의 성능을 떨어트릴 수 있기 때문에 확장을 어렵게 한다. 또한 데이터베이스 시스템은 가장 주요한 fail point이다. 서비스에 문제가 생겼다는 보고를 받은 서비스 관리자는 가장 먼저 데이터베이스를 살펴보기 마련이다.

유능한 서비스 관리자는 최소한의 데이터베이스 접근만으로 서비스가 가능하도록 시스템을 구성한다. 먼저 생각해 볼 수 있는게 REDIS로 캐시 시스템을 만드는 것이다. 유저가 로그인을 하면, 원본 데이터를 데이터베이스에 저장하고, 그 사본을 REDIS에 저장한다. 이후 에는 REDIS를 조회하고, REDIS에 없으면 데이터베이스에서 조회하는 식으로 데이터베이스 조회를 줄인다.

캐시를 유지하는 방법도 괜찮지만, 어쨋든 REDIS라는 또 다른 데이터베이스 시스템(NoSQL로 분류한다)을 운용해야 하는 부담이 있다.

JWT는 토큰에 데이터를 저장한다. 데이터베이스 조회 없이 토큰을 읽는 것 만으로 서비스를 위해서 필요한 정보를 얻을 수 있다. 다른 어떤 데이터베이스 시스템의 개입없이 정보를 주고 받을 수 있다. 이러한 구성은 특히 분산 서비스 시스템에서 큰 이점이 있다. 아래 그림을 보자.

 Token

유저가 인증을 하면, Server는 토큰을 발행한다. 이 토큰에는 유저정보가 포함돼 있다. 클라이언트는 이 토큰으로 서버에 접근하는데, 토큰에서 유저 정보를 추출 할 수 있기 때문에 세션 데이터베이스를 들락날락 할 필요가 없다. 소셜 서비스를 위한 REST API 서버의 경우 많은 요청이 있을 수 밖에 없다. 토큰을 이용하면 효율적인 처리가 가능하다.

크로스 도메인 쿠키 문제. 쿠키는 발행한 서버에서만 유효하다. site-a.com 에서 발행한 쿠키는 site-b.com 에서 사용 할 수 없다. 토큰은 HTML Body 형태로 전송하기 때문에 다른 도메인에서도 사용 할 수 있다. 특히 서로 다른 도메인에서 API를 호출해야 하는 소셜 서비스 구성에 유용하다. 아래 그림은 쿠키 기반 인증과 JWT 기반 인증을 묘사하고 있다.

 쿠키기반 인증 vs JWT 기반 인증

Server는 HTML 바디에 토큰을 보낸다. 클라이언트(웹 브라우저)는 이 토큰으로 요청을 만든다. 보통 HTTP의 Authorization 헤더를 이용하는데, URL 파라메터를 이용해도 상관 없다.

특징은 아래와 같이 요약 할 수 있다.
  • Compact : 토큰은 URL과 HTTP 헤더로 보낼 수 있을 만큼 충분히 작다.
  • Self-contained : 페이로드(Payload)에 어떤 데이터라도 포함 할 수 있다. 데이터를 읽기 위해서 데이터베이스를 조회할 필요가 없다.

JWT 구조

JWT는 Header, Payload, Signature 3개 부분으로 구성되며, "."으로 구분된다. 따라서 JWT는 xxxxxx.yyyyyy.xxxxx 과 같은 구조를 가진다.

Header
헤더는 두 개 파트로 구성된다. 하나는 토큰의 타입 정보를 담고 있으며, 다른 하나는 HMAC SHA256, RSA와 같은 해싱 알고리즘 정보를 담고 있다.
{
    "alg": "HS256",
    "typ": "JWT"
}
이 정보는 Base64URL인코딩을 한 후 JWT의 첫 부분에 배치한다.

Payload
두번째 파트는 payload로, claim 정보를 담고 있다. 보통 유저 정보를 포함한 각종 메타 정보들이 들어간다. Claim은 3가지 종류가 있다.
  1. Rserved claims : iss(issuer), exp(expiration time), sub(subject), aud(audience) 등과 같은 예약된 claim들이다.
  2. Public claims : 충돌을 방지하기 위해서 공개된 이름이다.
  3. Private claims : 커스텀 claims다. 서버와 클라이언트간 교환할 정보들을 담기 위해서 사용한다.
아래는 Reserved claims의 목록이다.
  • exp : 토큰의 만료시간. 이 시간이 지난 토큰은 폐기해야 한다.
  • jti : JWT ID다. 유일한 값을 설정하며, 토큰을 추적하기 위해서 사용한다.
  • iss : 토큰의 발행자.
  • aud : 토큰의 수신자.
  • nbf : Not Before로 설정 한 시간이 지난 후에 사용 할 수 있다.
Payload 예제
{
    "sub": "Test token",
    "iss": "joinc.co.kr",
    "name": "yundream",
    "login": true,
    "admin": false
}

Signature
JWT 토큰은 인증, API의 수행등에 사용한다. 당연히 서버는 수신한 JWT 토큰이 내가 서명한 토큰인지를 알 수 있어야 한다. Signature는 데이터를 서명하기 위해서 사용한다. 토큰을 발행하는 서버는 Header의 alg 알고리즘을 이용해서 Header와 Payload 데이터를 해싱한다. 해싱에 사용하는 secret 키는 서버만 알고 있기 때문에, 토큰을 수신한 서버는 (내가 서명한)올바른 토큰인지를 검증 할 수 있다. 만약 "alg"가 "HS256" 이라면, 아래와 같이 시그니처를 포함한 JWT를 만들 수 있다.
var headers = base64URLencode(myHeaders);
var claims = base64URLencode(myClaims);
var payload = headers + "." + claims;

var signature = base64URLencode(HMACSHA256(payload, secret));

var JWT = payload + "." + signature;
JWT 토큰을 받은 서버는 secret을 이용해서 headers와 claims에 대한 signature를 만들고 이 signature가 JWT의 signature와 일치하는 지를 확인한다.

jwt.io 사이트에서 Header, Payload를 이용해서 Signature를 만드는 과정을 확인 할 수 있다.

주로 아래와 같은 알고리즘으로 시그니처를 만든다.
  • HMAC with SHA-256 : HMA과 SHA-256 의 첫글자를 따서 HS256 이라고 부르기도 한다.
  • RSA-signature with SHA-256 : RSA와 SHA-256의 첫글자를 따서 RS256 이라고 부르기도 한다.
  • JWA(Json Web Algorithms)
예제 코드
HS256 시그니처 알고리즘을 이용한 JWT 예제 코드다.
package main

import (
    "fmt"
    "github.com/dgrijalva/jwt-go"
    "time"
)

func main() {
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
        "id":  "yundream",
        "nbf": time.Date(2015, 10, 10, 12, 0, 0, 0, time.UTC).Unix(),
    })

    tokenString, err := token.SignedString([]byte("HRhF14BHM5"))
    if err != nil {
        panic(err)
    }
    fmt.Println(tokenString, len(tokenString))

    decToken, err := jwt.Parse(tokenString,
        func(token *jwt.Token) (interface{}, error) {
            if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
                return nil, fmt.Errorf("Unexpected signing method: %v", token.Header["alg"])
            }
            return []byte("HRhF14BHM5"), nil
        })
    if claims, ok := decToken.Claims.(jwt.MapClaims); ok && decToken.Valid {
        fmt.Println(claims["id"], claims["nbf"])
    } else {
        fmt.Println("Error")
    }
}

		
  • JWT에 담을 유저 데이터는 map 타입(jwt.MapClaims)으로 설정했다. 여기에 id와 nbf 정보를 저장했다.
  • 유저 데이터를 포함한 데이터에 대한 jwt 토큰을 만든다.
  • jwt.Parse를 이용해서 토큰이 올바른지 테스트하고, 값을 출력한다.

주의 해야 할 점

Header와 payload는 base64 인코딩만 수행한다. 간단하게 디코딩 할 수 있으므로 여기에 중요 정보를 담아서 보내면 안된다.

JWT 테스트

테스트 환경

 테스트 환경
  1. auth.joinc.co.kr : go 언어로 간단한 인증서버를 만든다. 이 서버는 id/password 기반으로 로그인이 성공하면 유저 정보를 포함한 JWT 토큰을 발행한다.
  2. api.joinc.co.kr : 클라이언트는 서명이 끝난 JWT 토큰을 이용해서 api.joinc.co.kr 에 API를 요청한다.
  3. 요청을 받은 api 서버는 JWT 토큰이 올바른지 인증서버에 검증 요청을 한다.

서명 키 만들기

서명에 사용할 rsa 키를 만든다.
# openssl genrsa -out demo.rsa 2048 
# openssl rsa -in demo.rsa -pubout > demo.rsa.pub 
demo.rsa는 토큰을 서명하기 위해서, demo.rsa.pub는 토큰을 인증하기 위해서 사용한다.

코드

이 웹 서버는 POST /toke 과 POST /validation 두 개의 api를 제공한다. POST /token 은 유저 정보(아이디와 유저 페이스 이미지 링크)를 포함하는 토큰을 만들어서 반환한다. POST /validation은 토큰 유효성을 체크한다. 원래대로라면 로그인 서버가 있어야 겠지만, 로그인은 성공 한 것으로 간주하고 토큰을 발행한다. 설명은 주석으로 대신한다. go 언어라서 생소 할 수 있겠지만 이해하기 어렵진 않을 것이다.
package main

import (
	"crypto/rsa"
	"encoding/json"
	"fmt"
	"github.com/dgrijalva/jwt-go"
	"io/ioutil"
	"net/http"
	"os"
	"strings"
	"time"
)

// openssl로 만든 private key와 public key
// private key는 서명을 위해서 public key는 검증을 위해서 사용한다.
const (
	privateKeyFile = "demo.rsa"
	publicKeyFile  = "demo.rsa.pub"
)

var (
	signKey   *rsa.PrivateKey
	verifyKey *rsa.PublicKey
)

// JWT 토큰에 포함할 유저 정보
type UserInfo struct {
	Name    string
	Picture string
}

type UserClaims struct {
	*jwt.StandardClaims
	TokenType string
	UserInfo
}

func check(err error) {
	if err != nil {
		fmt.Printf(err.Error())
		os.Exit(1)
	}
}

// private/public key 파일을 읽어서
// rsa key를 만든다.
func Init() {
	signBytes, err := ioutil.ReadFile(privateKeyFile)
	check(err)

	signKey, err = jwt.ParseRSAPrivateKeyFromPEM(signBytes)
	check(err)

	verifyBytes, err := ioutil.ReadFile(publicKeyFile)
	check(err)

	verifyKey, err = jwt.ParseRSAPublicKeyFromPEM(verifyBytes)
	check(err)
}

// 유저 정보를 포함하는 토큰을 만들고
// private key를 이용해서 서명한다.
func createToken(user UserInfo) (string, error) {
	t := jwt.New(jwt.GetSigningMethod("RS256"))
	t.Claims = &UserClaims{
		&jwt.StandardClaims{
			ExpiresAt: time.Now().Add(time.Minute * 60).Unix(),
		},
		"level1",
		UserInfo(user),
	}
	return t.SignedString(signKey)
}

// POST /token 이 호출하는 핸들러 함수
func GetToken(w http.ResponseWriter, r *http.Request) {
	body, _ := ioutil.ReadAll(r.Body)
	user := UserInfo{}
	_ = json.Unmarshal(body, &user)
	token, err := createToken(user)
	if err != nil {
		return
	}
	fmt.Fprintf(w, token)
}

// POST /validation이 호출하는 핸들러 함수
// public key를 이용해서 토큰 유효성을 검사한다.
func ValidationToken(w http.ResponseWriter, r *http.Request) {
	body, _ := ioutil.ReadAll(r.Body)
	tokenString := strings.TrimSpace(string(body))
	token, err := jwt.ParseWithClaims(tokenString, &UserClaims{}, func(token *jwt.Token) (interface{}, error) {
		return verifyKey, nil
	})
	check(err)
	claims := token.Claims.(*UserClaims)
	fmt.Printf("%#v\n", claims.UserInfo)
	userData, _ := json.Marshal(claims.UserInfo)
	fmt.Fprintf(w, string(userData))
}

func main() {
	Init()
	http.HandleFunc("/token", GetToken)
	http.HandleFunc("/validation", ValidationToken)
	http.ListenAndServe(":8000", nil)
}
		
테스트에 사용한 유저 데이터 정보다. 파일의 이름은 user.json 이다.
{
	"name": "yundream",
	"picture": "http://pic.joinc.co.kr/...."
}

curl로 user.json에 대한 토큰을 요청한다.
# curl -XPOST localhost:8000/token -d @user.json 
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE0NjY4Njc5NzAsIlRva2VuVHlwZSI6ImxldmVsMSIsIk5hbWUiOiJ5dW5kcmVhbSIsIlBpY3R1cmUiOiJodHRwOi8vcGljLmpvaW5jLmNvLmtyLy4uLi4ifQ.od_8jj5XiyjUdZ4xesdU7idqk1fNn-ELaonGY7TmQqDNo08jVLqStPiwPa3K0PKCOJ9-edWv0wrobHLF92MUAjhBXV--bP7dBKn7om-R94_PRl8VcVX8k8Y3ucukoe9nvj-exivSdDr8WvGGAA1ISQxRRVUWmOaGzqhiy_mPfaepNfsAU-0CWHy0AqMzR46graPMJG2FQ1jWmx85gWgjYAP0EldDAq2JzMPLweuxJ_KQgps_trlShRweMHEYjGgx3YYubI2pJf96mXWWLu5HxfyzVNurXZ_3H9nrez1cTjdkqVEewbVQjjiWSnHzJi7EnR1NTWGyjRP8Js-5KSRVWw
</div>

위 토큰을 token.dat로 저장 한 다음 검증 API를 호출했다.
# curl -XPOST localhost:8000/validation -d @token.dat{"Name":"yundream","Picture":"http://pic.joinc.co.kr/...."}

JWT 사용 시나리오

JWT 응용 방법에 대해서 살펴보자.

같은 도메인에서의 인증 및 API 호출

인증서버와 API 서버와 같은 도메인에 있을 경우 사용 할 수 있는 방법이다. 웹 애플리케이션이라면 Authorization 헤더와 POST Body 모두를 사용 할 수 있다. 웹 브라우저 경우에는 쿠키 나 HTML5 Web Storage를 사용하면 된다.

데이터베이스에 접근 하지 않고도 유저를 인증 할 수 있기 때문에 SSO(Single sign on)구현도 매우 쉽다. 구현 방식은 두 개 정도로 나눌 수 있을 것 같다.
  1. 각 도메인 서버에 public key를 배포해서 서명한 토큰을 직접 검증 할 수 있게 한다.
  2. 도메인 서버들은 public key를 가지고 있지않다. 직접 토큰을 검증 할 수 없으며, 인증 서버에 토큰 검증을 요청한다.
 Public Key를 배포

다른 도메인에서의 인증 및 API 호출

소셜 API를 서비스하고 있다면, 다른 도메인에서의 API 호출을 인증해야 한다. 이 과정은 oAuth2 흐름에 묶어내야 한다. 아래와 같은 흐름을 가질 것이다.
  1. 3rd-party 서비스를 관리해야 한다. 3rd-party 서비스가 가입하면, 서명과 증명에 사용할 키를 만들고 public key를 3rd-party 개발자에게 배포한다.
  2. 유저가 oAuth2 서버에서 인증을 받으면, 서명된 JWT 토큰과 함께 3rd-party 서버로 리다이렉트 된다.
  3. 3rd-party 서비스는 public key를 이용해서 토큰을 증명하고 유저 정보를 꺼내서 인증을 마친다.
  4. 유저가 API를 호출 할 경우, 3rd-party 서비스가 유저의 토큰을 이용해서 API를 대신 호출 하고 그 결과를 반환한다.

토큰 관리

토큰의 유효시간은 exp을 이용해서 관리 할 수 있다. REST API 서비스일 경우 매 요청마다 exp를 확인하면 된다.

토큰 유출과 같은 보안사고를 대비한 토큰 파기(revoke)기능도 필요하다. 대략 아래와 같은 과정을 거칠 것이다.

 토큰 파기 과정

  1. 일련의 알고리즘에 따라서 토큰 누출이 예상 된다.
  2. 토큰을 파기 할 건지를 묻는다. 보통 메일로 전송하는 경우가 많다.
  3. 관리자 페이지에 로그인해서, 권한을 얻는다.
  4. 토큰 목록을 확인하고 삭제한다.

참고