LoginSignup
9
11

More than 3 years have passed since last update.

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

Posted at

やりたいこと

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

参考 API GatewayをOSSで実現。Kongを使ったJWT認証の方法。

作るシステム

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

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

Kongの設定

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

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自体を立ち上げます。

$ 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アドレス等を入れます。

# サービスの登録
$ 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に生パスワードを保存しなくてすみます。

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


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

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

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を作成します。

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を作る部分は次のように作ります。

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でまとめます。

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リクエストヘッダを単純に表示するだけです。

ソースコード全体

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)

実行

$ 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が入っていることが確認できました。めでたしめでたし。

9
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
11