Kongを利用してJWT認証のゲートウェイを作る

## やりたいこと

OSSのAPI gatewayであるKongを利用してJWTの認証基盤を作りたい、またApp側のアカウント作成時にはKongにも同様にconsumerを作成し、App側でパスワード認証したらKongの情報をもとにJWTの作成も自動化したい。

参考 [API GatewayをOSSで実現。Kongを使ったJWT認証の方法。] (https://qiita.com/ike_dai/items/5a14ced48c6ec7d80d70)

## 作るシステム

App側には次の3つのAPIを作ります。

- `/admin/account`: 新規アカウント作成用API。こちらは内部での利用を想定し、Kongは通さない。
- `/login`: アカウント名とパスワードで認証し、JWTを発行する。Kongは通すが、Kongの認証機能は使用しない。
- `/api/echo`: 実際にユーザーが使用するAPIのモック。KongでJWT認証する。 

## Kongの設定

KongとPostgresを立ち上げてバックエンドの情報を登録します。KongとPostgresはDockerを利用して起動します。

```yaml:docker-compose.yml
version: "3"
services:
  db:
    image: postgres
    environment:
      POSTGRES_USER: kong
      POSTGRES_PASSWORD: kong1234

  kong:
    image: kong
    ports:
      - 8000:8000
      - 8001:8001
    environment:
      KONG_DATABSE: postgres
      KONG_PG_HOST: db
      KONG_PG_PORT: 5432
      KONG_PG_USER: kong
      KONG_PG_PASSWORD: kong1234
      KONG_PG_DATABASE: kong
      KONG_PROXY_ACCESS_LOG: /dev/stdout
      KONG_PROXY_ACCESS_LOG: /dev/stdout
      KONG_PROXY_ERROR_LOG: /dev/stderr
      KONG_ADMIN_ERROR_LOG: /dev/stderr
      KONG_ADMIN_LISTEN: 0.0.0.0:8001
```

起動する際にはまずpostgresを立ち上げ、次にKongでDBの初期化を行ってからKong自体を立ち上げます。

```bash
$ docker-compose up -d db
$ docker-compose run --rm kong kong migrations bootstrap
$ docker-compose up -d kong
```

その後、Kongにバックエンドの情報を登録します。HTTPieでKongのAdmin APIを叩いています。`host_addr`にはDocker内部からも見えるIPアドレス等を入れます。

```bash
# サービスの登録
$ http -f :8001/services name=app url="http://<host_addr>:8080"

# login APIのrouteを登録
$ http -f :8001/services/app/routes name=app_login "paths[]=/login" strip_path=false

# それ以外のAPIのrouteを登録し、jwt認証を有効にする
$ http -f :8001/services/app/routes name=app_api "paths[]=/api" strip_path=false
$ http -f :8001/routes/app_api/plugins name=jwt config.cookie_names=jwt-token config.claims_to_verify=exp
```

## Backend APIサーバーの実装

Flaskで簡単にアカウント登録、ログイン、echo APIの3つをサクッと作ります。テストなのでDBもJSONをファイルにdumpするだけですが、実際はRDBやNoSQLなど適宜使用します。以下のライブラリを使用しています。

- flask: web framework
- jwt: jwt tokenの作成
- requests: KongのAPIを叩く
- passlib: パスワードのハッシュ化

まずはパスワードのハッシュを作る関数とハッシュ/生パスワードの検証をする関数です。これでDBに生パスワードを保存しなくてすみます。

```py
def encrypt(passwd):
    return pbkdf2_sha256.hash(passwd)


def verify(passwd, passwd_hash):
    return pbkdf2_sha256.verify(passwd, passwd_hash)
```

以下はアカウントの作成、検索、及びファイルへのIOです。ここでは簡略化のためDBは使用せずにファイルにdumpします。


```py
def _dump_accounts(accounts):
    with open(ACCOUNT_FILE, "w") as fp:
        json.dump(accounts, fp)


def _load_accoutns():
    try:
        with open(ACCOUNT_FILE) as fp:
            accounts = json.load(fp)
        return accounts
    except FileNotFoundError:
        return []


def find_account(accounts, account_name):
    matched_accounts = [a for a in accounts if a["name"] == account_name]
    return matched_accounts


def add_accounts(accounts, account_name, password):
    account_id = len(accounts)

    if len(find_account(accounts, account_name)) > 0:
        return None

    accounts.append(
        {
            "id": account_id,
            "name": account_name,
            "password_hash": encrypt(password)
        })
    return account_id
```

KongのConsumerとCredentialを作る関数を用意します。新しくアカウントが作成されたら対応するKongのConsumerとCredentialを作成します。

```py
def create_kong_consumer(username, custom_id):
    url = urljoin(KONG_URL, "/consumers")
    resp = requests.post(url, data={
        "username": username, "custom_id": custom_id
    })

    if not resp.ok:
        return resp.status_code, None

    url = urljoin(KONG_URL, "/consumers/{}/jwt".format(username))
    resp2 = requests.post(url)

    return resp.status_code, resp2.status_code
```

KongからそのアカウントのCredentialを取得し、JWTのTokenを作る部分は次のように作ります。

```py
def retrieve_credential(username):
    url = urljoin(KONG_URL, "/consumers/{}/jwt".format(username))
    resp = requests.get(url)
    data = []
    if resp.ok:
        data = resp.json()["data"]

    return resp.status_code, data


def craft_HS265_jwttoken(cred, lifetime=60*60*24):
    exp = int(time.time()) + lifetime
    payload = {'iss': cred["key"], "exp": exp}
    return jwt.encode(payload, cred["secret"], algorithm='HS256')
```

最後にこれまでに作成したものをFlaskでまとめます。

```py
app = flask.Flask(__name__)
accounts = []


@app.before_first_request
def load_accounts():
    global accounts
    accounts = _load_accoutns()


@app.route("/admin/account", methods=["POST"])
def create_account():
    account_name = request.form.get("name", "")
    password = request.form.get("password", "")
    if account_name == "" or password == "":
        return "Invalid Name or Password", 400

    account_id = add_accounts(accounts, account_name, password)
    if account_id is None:
        return "Duplicated account", 400
　　 _dump_accounts(accounts)

    res1, res2 = create_kong_consumer(account_name, str(account_id))
    if res1 != 201:
        return "Cannot create consumer", res1
    if res2 != 201:
        return "Cannot create credential", res1

    return str(account_id), 201


@app.route("/login", methods=["POST"])
def login():
    account_name = request.form.get("name", "")
    password = request.form.get("password", "")
    matched_accounts = find_account(accounts, account_name)
    if len(matched_accounts) == 0:
        return "Account Not Found", 400

    account = matched_accounts[0]
    if not verify(password, account["password_hash"]):
        return "Invalid Password", 401

    res, data = retrieve_credential(account_name)
    if len(data) == 0:
        return "Couldn't find credential", 500

    cred = data[0]
    token = craft_HS265_jwttoken(cred)

    resp = make_response(token)
    resp.set_cookie("jwt-token", token)
    return resp


@app.route("/api/echo")
def echo():
    result = ""
    for k, v in request.headers.items():
        result += "{}:{}\n".format(k, v)
    return result
```

`create_account`はアプリ側のアカウントを作成すると同時にKong側にもConsumerとCredentialを作成します。Consumerを作るときにアプリ側のaccount idをConsumerのcustom_idとして登録しておくことで、今後Kongで認証が通ってバックエンドのAPIに来たリクエストのヘッダにこのidが付き、そのままaccountの情報をDBから引っ張ってくることができます。 `login`はアプリ側でアカウントの名前とパスワードの確認し、対応するKongのCredentialを取ってきてJWT Tokenを作成してクライアントに返し、cookieにも付けておきます。 `echo`が実際に呼ばれる各アプリAPIのモックで、ここではHTTPリクエストヘッダを単純に表示するだけです。


ソースコード全体

```py:app.py
import json
import time
from urllib.parse import urljoin

import flask
import jwt
import requests
from flask import make_response, request
from passlib.hash import pbkdf2_sha256

ACCOUNT_FILE = "accounts.json"
KONG_URL = "http://<kong_address>:8001"


def encrypt(passwd):
    return pbkdf2_sha256.hash(passwd)


def verify(passwd, passwd_hash):
    return pbkdf2_sha256.verify(passwd, passwd_hash)


def _dump_accounts(accounts):
    with open(ACCOUNT_FILE, "w") as fp:
        json.dump(accounts, fp)


def _load_accoutns():
    try:
        with open(ACCOUNT_FILE) as fp:
            accounts = json.load(fp)
        return accounts
    except FileNotFoundError:
        return []


def find_account(accounts, account_name):
    matched_accounts = [a for a in accounts if a["name"] == account_name]
    return matched_accounts


def add_accounts(accounts, account_name, password):
    account_id = len(accounts)

    if len(find_account(accounts, account_name)) > 0:
        return None

    accounts.append(
        {
            "id": account_id,
            "name": account_name,
            "password_hash": encrypt(password)
        })
    _dump_accounts(accounts)
    return account_id


def create_kong_consumer(username, custom_id):
    url = urljoin(KONG_URL, "/consumers")
    resp = requests.post(url, data={
        "username": username, "custom_id": custom_id
    })

    if not resp.ok:
        return resp.status_code, None

    url = urljoin(KONG_URL, "/consumers/{}/jwt".format(username))
    resp2 = requests.post(url)

    return resp.status_code, resp2.status_code


def retrieve_credential(username):
    url = urljoin(KONG_URL, "/consumers/{}/jwt".format(username))
    resp = requests.get(url)
    data = []
    if resp.ok:
        data = resp.json()["data"]

    return resp.status_code, data


def craft_HS265_jwttoken(cred, lifetime=60*60*24):
    exp = int(time.time()) + lifetime
    payload = {'iss': cred["key"], "exp": exp}
    return jwt.encode(payload, cred["secret"], algorithm='HS256')


app = flask.Flask(__name__)
accounts = []


@app.before_first_request
def load_accounts():
    global accounts
    accounts = _load_accoutns()


@app.route("/admin/account", methods=["POST"])
def create_account():
    account_name = request.form.get("name", "")
    password = request.form.get("password", "")
    if account_name == "" or password == "":
        return "Invalid Name or Password", 400

    account_id = add_accounts(accounts, account_name, password)
    if account_id is None:
        return "Duplicated account", 400

    res1, res2 = create_kong_consumer(account_name, str(account_id))
    if res1 != 201:
        return "Cannot create consumer", res1
    if res2 != 201:
        return "Cannot create credential", res1

    return str(account_id), 201


@app.route("/login", methods=["POST"])
def login():
    account_name = request.form.get("name", "")
    password = request.form.get("password", "")
    matched_accounts = find_account(accounts, account_name)
    if len(matched_accounts) == 0:
        return "Account Not Found", 400

    account = matched_accounts[0]
    if not verify(password, account["password_hash"]):
        return "Invalid Password", 401

    res, data = retrieve_credential(account_name)
    if len(data) == 0:
        return "Couldn't find credential", 500

    cred = data[0]
    token = craft_HS265_jwttoken(cred)

    resp = make_response(token)
    resp.set_cookie("jwt-token", token)
    return resp


@app.route("/api/echo")
def echo():
    result = ""
    for k, v in request.headers.items():
        result += "{}:{}\n".format(k, v)
    return result


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080, debug=True)

```


## 実行

```sh
$ python app.py

#------ 別ターミナルで -------------

# 新規アカウント作成 FlaskのAPIサーバーに直でリクエスト
$ http -f :8080/admin/account name=user1 password=test
HTTP/1.0 201 CREATED
Content-Length: 1
Content-Type: text/html; charset=utf-8
Date: Mon, 01 Jul 2019 06:30:56 GMT
Server: Werkzeug/0.15.4 Python/3.7.3

2

# ログイン Kong経由でリクエスト
$ http --session=s0 -f :8000/login name=user1 password=test
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 160
Content-Type: text/html; charset=utf-8
Date: Mon, 01 Jul 2019 06:31:55 GMT
Server: Werkzeug/0.15.4 Python/3.7.3
Set-Cookie: jwt-token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJsTDJWd2ZZZm9xQWR0V1I2T3NPTW5hd3dqclZSdHFBWSIsImV4cCI6MTU2MjA0OTExNX0.LVJSWGrN_JAGAaNQoK8Z6dVEl-gQ3fhx5sPp4AKgnT8; Path=/
Via: kong/1.2.1
X-Kong-Proxy-Latency: 1
X-Kong-Upstream-Latency: 15

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJsTDJWd2ZZZm9xQWR0V1I2T3NPTW5hd3dqclZSdHFBWSIsImV4cCI6MTU2MjA0OTExNX0.LVJSWGrN_JAGAaNQoK8Z6dVEl-gQ3fhx5sPp4AKgnT8


# APIコール
$ http --session=s1 :8000/api/echo
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 505
Content-Type: text/html; charset=utf-8
Date: Mon, 01 Jul 2019 06:33:55 GMT
Server: Werkzeug/0.15.4 Python/3.7.3
Via: kong/1.2.1
X-Kong-Proxy-Latency: 0
X-Kong-Upstream-Latency: 1

Host:192.168.3.17:8080
Connection:keep-alive
X-Forwarded-For:172.18.0.1
X-Forwarded-Proto:http
X-Forwarded-Host:localhost
X-Forwarded-Port:8000
X-Real-Ip:172.18.0.1
User-Agent:HTTPie/0.9.8
Accept-Encoding:gzip, deflate
Accept:*/*
Cookie:jwt-token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJsTDJWd2ZZZm9xQWR0V1I2T3NPTW5hd3dqclZSdHFBWSIsImV4cCI6MTU2MjA0OTExNX0.LVJSWGrN_JAGAaNQoK8Z6dVEl-gQ3fhx5sPp4AKgnT8
X-Consumer-Id:ae122ea2-a548-47d4-b895-4db223fc46d8
X-Consumer-Custom-Id:0
X-Consumer-Username:user1
```

最後の部分で

```
X-Consumer-Custom-Id:0
X-Consumer-Username:user1
```

のようにCustom_IdにAccount IDが入っていることが確認できました。めでたしめでたし。