やりたいこと
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を利用して起動します。
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リクエストヘッダを単純に表示するだけです。
ソースコード全体
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が入っていることが確認できました。めでたしめでたし。