oauth2
OAuth2.0
Kong
Authorization
Keycloak

Keycloakを使ってAPI GatewayでAPIをアクセス制限する


今日やること

アドベンド19日目は、API Gatewayを使ったアクセス制限(認可)をやります。スマホアプリ向けAPIサーバーを、どう防御するかという話です。1日で書きたかったので、かなり長い記事になってしまいました。

:warning: AWS API Gatewayの話は全く出てきません。"AWS"でググって来てしまった人は、申し訳ないですが、ブラウザバックをお願いします。


API Gatewayとは何か?

例えばあなたが何かWebサービスを作って公開するとしよう。jspや(xがない)aspのような昔ながらのWebアプリケーションでもいいが、今風にサービスのやることはREST APIの公開だけにしよう。さて、このAPIは誰でも使っていいものだろうか? もちろんそんなことはなく、使える人には制限がいるだろう... 認証は必要そうだ。しかしAPIは通常(認証した人)誰でも等しく使っていいことは無く、○○APIは誰でも可だが××APIは特定の人だけ使用可、とするのが一般的だ。...つまりアクセス制限(認可)がいる。

別に俺流認可でもいいが、どうせなら世間でよく使われているプロトコルに合わせたいのが人情だ。世間で使われているプロトコル、OAuth2.0だろう。

OAuth2.0なら作らなくても何かモジュールがあるだろう。Webで検索すれば、OAuth2.0のモジュールやライブラリはたくさん見つけることができる。しかしAPIの立場からみると、これらのモジュールやライブラリからは何の情報を得る必要があっただろうか。ユーザー情報(≒IDトークン)だけである。それだけだったら、わざわざWebコンテナ上のモジュールとして入れなければならないのだろうか。リクエストの前に認可でアクセス制限をするのであるなら、リバースプロキシーサーバーではダメなのだろうか?

API Gatewayとは、雑な理解で済ますと、アクセス制限をうまいことやってくれるリバースプロキシ―サーバーである。実際にはアクセス制限だけではなく、流量制限とかロードバランスとか監査ログの出力とかApacheで言うvirtual hostの機能とかこれらの機能を可視化するとか、「認可サービス」として欲しい機能を集中させて、更にWebアプリケーションからも切り離して(疎結合にして)1つの独立したサービスとして提供しよう、という考えである。偉そうな言葉でいうとWebサービス層(service layer)に前に置く認可層(authorization layer)、とか言うのでしょうか。


API Gatewayの一般的な構成

API Gatewayの本体は、先に書いたようにリバースプロキシ―サーバーなので、APIサーバーの前に置く構成なります。また、ユーザー(というかAPIを使うクライアント)は、このAPI Gatewayの置き場所は通常DMZになり、APIサーバーはその後ろになるかと思います。

2017-11-22_105314.png

ここで一番重要なポイントはAPIサーバー... ではなく「アプリケーション(クライアント)」の部分です。このアプリケーションはもちろんWebアプリケーションでもいいのですが、今回はスマホアプリ(記事中ではWindowsアプリケーションですが)になります。

API利用シーンでのフローは、大体こんな感じになるのではないかと思います。

2017-11-22_105538.png

① ユーザーがアプリケーションを操作する

② アプリケーションがAPIを使いたいので、APIサーバーの前にあるAPI Gatewayにアクセスする

③ 権限が無いので 401(Unauthorized)を返す

④ APIへのアクセス権限をもらうために、アプリケーションが認証・認可リクエストをOPに送る

⑤ OPはユーザーに対して認証&同意を行う

2017-11-22_105641.png

⑥ OPは認可コードをアプリケーションに渡す

⑦ アプリケーションは認可コードからアクセストークンをもらう

⑧ アプリケーションは、再びアクセストークンを付けてAPI Gatewayへアクセスする

⑨ API Gatewayは、今度はOKなので、APIへプロキシーする

⑩ APIは、ただリクエストに対してレスポンスを返すだけ。アプリケーションはAPIの結果をもらってユーザーにコンテンツを返す。


環境を構築する

前置きが長くなってしまいましたが、環境を作っていきましょう。今回はフローで描いたように、OPとしてのKeycloak以外にはAPIとAPI Gateway、APIのクライアントが必要になります。これらを1つづつ作っていきましょう(長いです)。


Kong(インストール)

API Gatewayとして、オープンスースであるKongを使いたいと思います(AWSのAPI Gatewayを期待していた人、すみません)。


Kongの構成

Kongのインストールの前に、簡単に構成を説明します。そうしないと、何をインストールして良いか分からないので。Kong本体は、最近良くあるWebコンテナが同梱されており、中身はNginxです。Kongの設定は外部のデータベースで(=別途インストールが要る)、ドキュメントによるとサポートしているデータベースは PostgreSQL 9.4以上 or Cassandra 3系(2017/12時点)だそうです。今回の記事では特にドキュメントデータベースにする必要性はないので、PostgreSQLにしました。

2017-11-22_105828.png

Kongの設定は、最近良くあるパターンなのか、ユーザー(APIクライアント)がアクセスするモジュールと同じ場所であり、ポートが異なっているだけです。これが困るのは、Kongはユーザー(APIクライアント)からアクセスされるため、DMZに置かざるを得ず、ネットワーク管理者を困らせることになるかもしれない、ということです。ポートが違うのでファイアーウォールの設定を頑張れ、ということなのでしょうが、Kongの管理者機能が外から見えるかもしれない位置にあるのは、ちょっと気持ち悪いです。ただ将来のKongでは分離されるかもしれません。

デフォルトのポートは、ユーザー(APIクライアント)が使う番号は 8000(http)と8443(https)で、管理者が8001(http)と8444(https)です。このポート番号はKongの設定で変えられますが、この記事では面倒なのでデフォルトのまま使ってしまいます。つまりユーザー(APIクライアント)のリクエストは、ポート8000を通してAPIにアクセスされることになります。


Kongのインストール

前置きが長くなってしまいましたが(2回目)、やっとインストールです。公式のインストール手順を見たい人はこちらになります。手っ取り早く3行で構築したい人にはdockerが提供されています。


  • PostgreSQLのインストール

PostgreSQLのインストール自体は、通常のインストールと何も変わりません。私はMySQL派なので、細かい設定(特に権限周り)は良く分からないので、適当になっています。PostgreSQLに慣れている人ならば、適切に設定していただいてOKです。

バイナリの入手はこの辺からRPMを落とします。この記事では、特に深い理由はないですが、 9.6 を選択しました。 何のRPMをインストールして良いかよく分からないので、-devel以外を入れましたが、どうやら本体と-serverだけでよさそうです。

$ yum install pgdg-centos96-9.6-3.noarch.rpm

$ yum install postgresql96 postgresql96-server postgresql96-libs postgresql96-contrib
$ /usr/pgsql-9.6/bin/postgresql96-setup initdb

データベースの権限の設定もしておきます。PostgreSQLはKong本体からのアクセスだけなので、localhost(127.0.0.1)だけで良いですが、外からデータベースの中を見られると便利なので、この記事では制限なしにしました。また、パスワード有りを設定したかったのですが(md5)、OSユーザーとの紐付けを止められず、あきらめました(trust)。


/var/lib/pgsql/9.6/data/pg_hba.conf

# IPv4 local connections:

-host all all 127.0.0.1/32 ident
# IPv4 local connections:
+host all all 0.0.0.0/0 trust

また、PostgreSQLではデフォルトでlocahostしかLISTENしないので、これも変更しておきます。


/var/lib/pgsql/9.6/data/postgresql.conf

# - Connection Settings -

-#listen_addresses = 'localhost'
# - Connection Settings -
+listen_addresses = '*'

これでサービス起動ができます。

$ systemctl enable postgresql-9.6

$ systemctl start postgresql-9.6

Kongが接続するPostgreSQLのユーザー: kong を作ります。

$ psql -U postgres

postgres=# CREATE USER kong; CREATE DATABASE kong OWNER kong;
postgres=# \l
Name | Owner | Encoding | Collate | Ctype | Access privileges
-----------+----------+----------+-------------+-------------+-----------------------
kong | kong | UTF8 | en_US.UTF-8 | en_US.UTF-8 |
postgres | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 |
template0 | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 | =c/postgres +
| | | | | postgres=CTc/postgres
template1 | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 | =c/postgres +
| | | | | postgres=CTc/postgres


  • Kongのインストール

KongインストールのページからRPMをダウンロードします。この記事ではCentOS7用を選択しています。2017/12時点では、Kongのバージョンは 0.11.1でした。ダウンロードしたらyumするだけです。

$ yum install kong-community-edition-0.11.1.el7.noarch.rpm

データベースの設定をKongのデフォルト設定で動作するようにしてしまったので、このままKongを起動できますが、PostgreSQLのユーザー名を変えたり、パスワードを設定した場合は、Kongの設定ファイルを変更する必要があります。

Kongの設定ファイルは、実はデフォルトでは無く、そのためすべてデフォルト値で動作します(エラーじゃない)。そのため設定変更する場合は、テンプレート(/etc/kong/kong.conf.default)をコピーする必要があります。設定ファイルは /etc/kong/kong.conf に配置します(Kongの起動時オプションで、パスを指定可)。

$ cp /etc/kong/kong.conf.default /etc/kong/kong.conf

Kongの設定ファイルの説明については、ドキュメントを参照してください。PostgreSQLへ接続する設定に関しては、この辺の値を設定すればよさそうです。


/etc/kong/kong.conf

database = postgres

pg_host = ...
pg_port = ...
pg_user = ...
pg_password = ...
pg_database = ...

migrationした後、サービスを起動します。

$ kong migrations up

$ kong start

Kongの設定ファイルを指定する場合は、オプションに -c (設定ファイルのパス) を付けます。ちなみにkong stopで停止、kong restartで再起動です。


  • Kongの起動を確認をする

curlで試します。ポートは管理用なので8001です。何か長いJSON(Kongの設定情報です)が出てきます。

$ curl -X GET http://127.0.0.1:8001

{"version":"0.11.1","plugins": ...(超長い)...

これでKongのインストールは完了です。


APIサーバー

Kongで防御されるAPIも用意しましょう。このAPIはAPI Gatewayで防御してくれることを期待して、アクセス制限が全くない張りぼてアプリにします。APIの中身はただのJSONを返すだけのApache上で動作するアプリケーション、どころかただの静的コンテンツ(plain/text)を返すだけです。

2017-11-15_123957.png

エンドポイントは次のようにしました。典型的なAPIはCRUD操作だと思うので、それに見立てて4つ用意しました。「許可する予定のメソッド」は、APIとしてはこのメソッドしか許可したくはないが、アクセス制限をさぼって何もしていない、という意味です。(ApacheはデフォルトでPUTとDELETEが許可されていませんが)

エンドポイントURL
許可する予定のメソッド
説明

http://172.26.22.66/r/
GET
何かをREADするエンドポイント

http://172.26.22.66/c/
POST
何かをCREATEするエンドポイント

http://172.26.22.66/u/
PUT
何かをUPDATEするエンドポイント

http://172.26.22.66/d/
DELETE
何かをDELETEするエンドポイント

こんなAPIでも、Kongがちゃんとアクセス制限をしてくれるか、試してみましょう。


APIクライアント

冒頭の辺りの一般的な構成で描いた通り、APIクライアントはWebアプリケーションでもよいのですが、この記事ではWindowsアプリケーションにしました。OAuth2.0の文脈では、スマホアプリのWindowsアプリケーションもネイティブアプリケーションであるため、Windowsアプリケーションでのやり方や考え方はスマホアプリでも全く同じです。

2017-11-15_163614.png

このアプリケーションの左側のフォームは、上部にAPIのエンドポイントURLを指定して「送信」を押すとそのエンドポイントにアクセスするようになっています。下部はそのAPIのレスポンスを、そのまま出力するテキストボックスです。いちおうスマホアプリの画面だと思ってください。右側のモードレスダイアログは、OP(=Keycloak)の設定を入れる画面で、実際にはユーザーに入力させる項目ではありません。そのため右側によけて表示しています。このアプリはテスト用のため入力出来るようになっていますが(その割にはいろいろ入力項目が足りませんが)、実際はインストール時やアプリケーションにハードコーディングすることで、アプリに設定します。

この自作アプリケーションの動作を簡単に説明すると、APIにアクセスして 401(Unauthorized) だった場合、OPに対して認可コードフローを開始します。OPから認可コードをもらってアクセストークンをもらったら、APIに対してもう一度アクセストークンを付けてアクセスします。401(Unauthorized) 以外だった場合は、そのままAPIのレスポンスを表示領域(フォーム下側のテキストボックス)に表示するだけです。また、認可コードフローとは何ぞや、という話は、この記事では書ききれないので、2日前のアドベントを見て欲しいです。

このアプリケーションの動作確認として、直接API (http://172.26.22.66/r/) にアクセスしてみます。今はまだ右側の窓は何の関係がないので省略しています。見ての通り、1行目にステータスコードと2行目以降にレスポンスボディをそのまま表示します。

2017-11-15_175046.png

POSTでも試してみます。APIは何の防御もしていないため、GETと全く同じ内容が返ってきます。

2017-11-15_175403.png


Keycloak(OP)の設定

APIクライアントはOAuth2.0クライアントでもあるため、Keycloakに設定が必要です。クライアントID:kongを作りました。他のパラメータは次のように設定しています。

2017-11-16_104017.png

2017-11-16_104039.png

一つ注意点があって、リダイレクトURIは http://localhost にしています。もちろんAPIクライアントを実行するWindows PCにWebサーバーがある訳ではないので、宛先はありません。これについては後で説明します。


環境の整理

環境構築は終了ですが、登場人物が4つでてきて複雑なため、サーバー情報を整理しておきます。

サーバー名
役割
接続先URL

APIサーバー
API提供
http://172.26.22.66

Kong
API Gateway
http://127.26.22.29:8000

Keycloak
OP
http://172.26.22.5/auth/realms/master

Windowsアプリ
APIクライアント
自分のPC


Kongで防御してみる


単純なプロキシ―のみ(まだ防御なし)

ようやくKongの設定に到達しました。まずはAPIへのプロキシー設定だけやってみます。Kongの管理画面... は無いので、curlでREST APIを直接叩くしかないです。管理画面はそのうち実装されるでしょう(Enterprise版だと あります )。

KongにAPIを登録します。今やりたい設定は http://172.26.22.29:8000/r/http://172.26.22.66/r/ へのプロキシーで、許可するメソッドはGETだけです。(※レスポンスボディのJSONは、見やすいように整形しています)

$ curl -i -X POST \

--data "name=myapi-read" \
--data "hosts=172.26.22.29"
--data "upstream_url=http://172.26.22.66/r" \
--data "uris=/r" \
--data "methods=GET" \
http://172.26.22.29:8001/apis

HTTP/1.1 201 Created
Date: Wed, 15 Nov 2017 09:07:24 GMT
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Access-Control-Allow-Origin: *
Server: kong/0.11.1

{
"created_at": 1510736844046,
"strip_uri": true,
"id": "3d4772a4-9826-4691-8809-ccbc9f62a831",
"hosts": [
"172.26.22.29"
],
"name": "myapi-read",
"methods": [
"GET"
],
"http_if_terminated": false,
"preserve_host": false,
"upstream_url": "http://172.26.22.66/r",
"uris": [
"/r"
],
"upstream_send_timeout": 60000,
"upstream_connect_timeout": 60000,
"upstream_read_timeout": 60000,
"retries": 5,
"https_only": false
}

Kong管理者の認証が無いのですが、まぁそのうち実装されるでしょう。パラメータは少し独特です。

リクエストパラメータ名
説明

name
APIの名前(何でも良い)

upstream_url
プロキシー先のURL

hosts
Kongが受けつける、リクエストのHostヘッダの値。複数ある場合はカンマ区切り

uris
Kongが受け取るパス

methods
許可するメソッド。複数ある場合はカンマ区切り

uris/rなので、Kongは http://172.26.22.29:8000/r で受け付け、それをupstream_urihttp://172.26.22.66/r へプロキシーする、と読みます。そして多分hostsの存在が奇妙に見えると思います。Kongへリクエストを送信するのですから、KongのFQDN=Hostヘッダの値になるはずですので、この値の存在意味がよく分からないかもしれません。これは、おそらくApacheで言うvirtual hostのServerNameのようなものを想定しているのだと思われます。

同じように、残り3つのエンドポイント(/c, /u, /d)もKongに登録してしまいましょう。本当はKong上の設定は1つのAPIにしたかったのですが、URLとメソッドが両方異なる場合は、うまく1つの設定にする方法が思い浮かびませんでした。そのうちAPIグループなるものが実装されるでしょう。curlを3つ並べても見にくいだけなので、設定を表にしておきます。

name
hosts
uris
methods
upstream

myapi-read
172.26.22.29
/r
GET
http://172.26.22.66/r

myapi-create
172.26.22.29
/c
POST
http://172.26.22.66/c

myapi-update
172.26.22.29
/u
PUT
http://172.26.22.66/u

myapi-delete
172.26.22.29
/d
DELETE
http://172.26.22.66/d

では、試してみましょう。右窓はまだ不要なので省略しています。


  • GET

2017-11-16_105645.png


  • POST

2017-11-16_105730.png

ちゃんとPOSTが防御されています。ちなみにstatusが 404(Not Found) でボディが "no API..." となっているのは、Kongの仕様です。

お前の作ったアプリなんか信用できない、という人のためにcurlの結果も載せておきます。

$ curl -i -X GET http://172.26.22.29:8000/r/

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Content-Length: 15
Connection: keep-alive
Date: Thu, 16 Nov 2017 02:03:34 GMT
Server: Apache/2.4.25 (Unix) OpenSSL/1.0.2k-fips
Last-Modified: Mon, 13 Nov 2017 08:55:41 GMT
ETag: "f-55dd96f59dd40"
Accept-Ranges: bytes
X-Kong-Upstream-Latency: 2
X-Kong-Proxy-Latency: 0
Via: kong/0.11.1

{"hoge","READ"}

$ curl -i -X POST http://172.26.22.29:8000/r/
HTTP/1.1 404 Not Found
Date: Thu, 16 Nov 2017 02:03:39 GMT
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Server: kong/0.11.1

{"message":"no API found with those values"}

$ curl -i -X PUT http://172.26.22.29:8000/r/
HTTP/1.1 404 Not Found
Date: Thu, 16 Nov 2017 02:03:43 GMT
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Server: kong/0.11.1

{"message":"no API found with those values"}

$ curl -i -X DELETE http://172.26.22.29:8000/r/
HTTP/1.1 404 Not Found
Date: Thu, 16 Nov 2017 02:03:50 GMT
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Server: kong/0.11.1

{"message":"no API found with those values"}


APIを防御する

続いてAPIを防御の設定をいれましょう。


  • plugin:jwtを設定する

JWTで保護するために、APIに対してpluginを入れます。JWTで保護ってアクセストークンじゃないじゃん! まあKeycloakのアクセストークンはJWTだから問題ないでしょ。普通のOPではアクセストークンはJWTじゃないから、IDトークンにするのかもしれません。(※レスポンスのJSONは整形)

$ curl -X POST http://172.26.22.29:8001/apis/myapi-read/plugins --data "name=jwt"

{
"created_at": 1510798731000,
"config": {
"secret_is_base64": false,
"key_claim_name": "iss",
"anonymous": "",
"run_on_preflight": true,
"uri_param_names": [
"jwt"
]
},
"id": "bb3cae8b-0bf9-44f4-bf3b-9ea27f418f8c",
"name": "jwt",
"api_id": "8478b314-f0aa-49b7-804f-ce0320d4461c",
"enabled": true
}

API名myapi-readに対して、plugin:jwtを有効にします。name=jwtは固定値です。すでにコレだけで防御が掛かっています、が、許可の条件を入れていないので、常に 401(Unauthorized)になります。


  • consumerを設定する

consumerというのが、ドキュメントに何の説明もないので意味不明なのですが、APIを使う人(消費者)という意味みたいです。KongのAPI Referenceに3行で説明しているので、載せておきます。


The Consumer object represents a consumer - or a user - of an API. You can either rely on Kong as the primary datastore, or you can map the consumer list with your database to keep consistency between Kong and your existing primary datastore.

ConsumerオブジェクトはAPIのconsumer、あるいはユーザーを表します。Kongを主のデータストアとして使うか、Kongとあなたが使用している主のデータストアとの間で一貫性を保つために、consumer listをあなたのデータベースにマッピングできます。


$ curl -X POST http://172.26.22.29:8001/consumers --data "username=myapi"

{"created_at":1510800004000,"username":"myapi","id":"087f5be4-cff5-4e40-b8a0-9b2925712834"}

パラメータ username は consumerの名前で何でもいいです。


  • JWTの署名を検証する公開鍵を設定する

まずはKeycloakから公開鍵を入手しましょう。Keycloakが発行するトークンに対する署名、ということなので、クライアントではなくレルム設定にある公開鍵になります。メニューの「レルム設定」>「鍵」タブ から、タイプ「RSA」の「公開鍵」を押して、公開鍵の値をコピーします。

2017-11-16_115916.png

2017-11-16_120001.png

公開鍵を取得したら、いつものようにcurlで登録します。公開鍵の書式は、律儀にPEM形式を要求するため、少し加工してやる必要があります。さっきコピーした公開鍵をテキストに貼りつけて、ヘッダとフッタを追加します。

-----BEGIN PUBLIC KEY-----

MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1iSFWp3kfdxipF9TtHnB0ZQPUDGCpC7KKpPDq9Rp7TEkMfqpVw7Sq/3iMSsNJtpx1jusmqaMoz8KD7hffVm5UGv7gbsOAAlEp6jYFfZT2kk1iEEBgCW5d67PZQqPbx63rcxAE1wWD42v+3gzKzzVPx+7uqBzfTIXPi1a9eWMmyITYMfYamLnXM8x0NYW8ME4ggrHqEyoHn71xPOkl4KKMOSdKyMg0NnG5YIWq13F0CR/YuPHxbK9FF2ssNkfOwWxpfYBl/oKmjs7BOTgXJnF9cHCs8fRspjJTVOfNdZ3ZH6fHZnBpbh+OxJUGLR1ShG34hZdkjHaRliRxDpddJ8NiQIDAQAB
-----END PUBLIC KEY-----

PEMってMIMEだから1行76byteで改行がいるのでは? というPEMマニアな人のために補足しておくと、不要でした。ヘッダ&フッタをくっつけたら、curlで送信するのでURLエンコードをします。

-----BEGIN+PUBLIC+KEY-----%0D%0AMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1iSFWp3kfdxipF9TtHnB0ZQPUDGCpC7KKpPDq9Rp7TEkMfqpVw7Sq%2F3iMSsNJtpx1jusmqaMoz8KD7hffVm5UGv7gbsOAAlEp6jYFfZT2kk1iEEBgCW5d67PZQqPbx63rcxAE1wWD42v%2B3gzKzzVPx%2B7uqBzfTIXPi1a9eWMmyITYMfYamLnXM8x0NYW8ME4ggrHqEyoHn71xPOkl4KKMOSdKyMg0NnG5YIWq13F0CR%2FYuPHxbK9FF2ssNkfOwWxpfYBl%2FoKmjs7BOTgXJnF9cHCs8fRspjJTVOfNdZ3ZH6fHZnBpbh%2BOxJUGLR1ShG34hZdkjHaRliRxDpddJ8NiQIDAQAB%0D%0A-----END+PUBLIC+KEY-----

これでようやくcurlで送信できます。

curl -X POST http://172.26.22.29:8001/consumers/myapi/jwt \

--data "key=http://172.26.22.5/auth/realms/master" \
--data "algorithm=RS256" \
--data "rsa_public_key=-----BEGIN+PUBLIC+KEY-----%0D%0AMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1iSFWp3kfdxipF9TtHnB0ZQPUDGCpC7KKpPDq9Rp7TEkMfqpVw7Sq%2F3iMSsNJtpx1jusmqaMoz8KD7hffVm5UGv7gbsOAAlEp6jYFfZT2kk1iEEBgCW5d67PZQqPbx63rcxAE1wWD42v%2B3gzKzzVPx%2B7uqBzfTIXPi1a9eWMmyITYMfYamLnXM8x0NYW8ME4ggrHqEyoHn71xPOkl4KKMOSdKyMg0NnG5YIWq13F0CR%2FYuPHxbK9FF2ssNkfOwWxpfYBl%2FoKmjs7BOTgXJnF9cHCs8fRspjJTVOfNdZ3ZH6fHZnBpbh%2BOxJUGLR1ShG34hZdkjHaRliRxDpddJ8NiQIDAQAB%0D%0A-----END+PUBLIC+KEY-----"

{
"rsa_public_key": "-----BEGIN PUBLIC KEY-----\r\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1iSFWp3kfdxipF9TtHnB0ZQPUDGCpC7KKpPDq9Rp7TEkMfqpVw7Sq/3iMSsNJtpx1jusmqaMoz8KD7hffVm5UGv7gbsOAAlEp6jYFfZT2kk1iEEBgCW5d67PZQqPbx63rcxAE1wWD42v+3gzKzzVPx+7uqBzfTIXPi1a9eWMmyITYMfYamLnXM8x0NYW8ME4ggrHqEyoHn71xPOkl4KKMOSdKyMg0NnG5YIWq13F0CR/YuPHxbK9FF2ssNkfOwWxpfYBl/oKmjs7BOTgXJnF9cHCs8fRspjJTVOfNdZ3ZH6fHZnBpbh+OxJUGLR1ShG34hZdkjHaRliRxDpddJ8NiQIDAQAB\r\n-----END PUBLIC KEY-----",
"created_at": 1510801636000,
"id": "45cc56d0-660f-41dd-a5ab-6a833ecdfbd4",
"algorithm": "RS256",
"key": "http://172.26.22.5/auth/realms/master",
"secret": "cOjsJz2daBE73YapxhHmde7YMchU4uK1",
"consumer_id": "087f5be4-cff5-4e40-b8a0-9b2925712834"
}

パラメータの意味は次の通りです。

パラメータ名
説明

key
検証するJWTの属性値

algorithm
署名のアルゴリズム名

rsa_public_key
署名を検証するRSA公開鍵

この中で最重要パラメータはkeyで、ここに設定した値とJWTの値を比較して、許可/拒否を判断します。JWTのどの属性と? それはplugin:jwtで設定した"key_claim_name"で、デフォルト値はissです。つまり、JWTのiss値と、ここで設定したkey値を、文字列の完全一致で比較されます。Keycloakが発行するアクセストークンJWTのiss値は "http://172.26.22.5/auth/realms/master" なので、コレを設定しました。

:information_source: JWTの検証は、


  • "key_claim_name"で指定した属性値の完全に一致

  • JWTの署名の検証


が行われます。JWTの有効期限("exp")は検証されません。


  • 試してみる

設定はこれで完了なので、試してみましょう。アプリケーションの動作が分かりやすいように、フロー図を再掲しておきます。

2017-11-22_105538.png

今度は右窓にOPの設定も入力して、アプリケーションからKong(http://172.26.22.29:8000/r/ )にアクセスします(①②)。Kongは、アプリケーションのリクエストにアクセストークンが無いので 401(Unauthorized) を返します(③)。アプリケーションはアクセストークンをもらうためにKeycloakに対して認可リクエストを送ります(④)。Keycloakは未認証なのでログイン画面を返します(⑤)

2017-11-16_161733.png

このアプリケーションでは、Keycloakの画面をアプリケーション内に表示させています(内部は TextBox コントロールを WebBrowser コントロールに切り替えています)。ログイン画面がうまくレスポンシブになってないですが、気にしないことにします。

2017-11-22_105641.png

ログイン画面でユーザー名&パスワードを入力してログインすると、今はKeycloakには同意画面を出さない設定にしているため、認可コードがリダイレクトURIへ送られます(⑥)。認可コードを受け取るリダイレクトURIは... http://localhost です。つまり WebBrowser コントロールは http://localhost にアクセスしようとして、「そんなページは表示できません」画面になるのですが、アプリケーションが欲しいのは認可コードであり、それはクエリパラメータにあり、つまり WebBrowser コントロールの Uri プロパティから取得できます。つまり、アプリケーションに組み込みのブラウザを表示させてしまうと、リダイレクトURIの踏み倒しができてしまいます(組み込みブラウザがセキュリティ上まずい理由の1つでもある)。

セキュリティはともかく、リダイレクトURIを踏み倒して認可コードが奪えるので、アクセストークンが入手できます(⑦)。アクセストークンを入手したら、再度Kongにアクセスします(⑧)。今度はKongが認可するので、KongはAPIにプロキシーをし(⑨)、無事APIからレスポンスがもらえます(⑩)

2017-11-16_163607.png

:warning: この記事のアプリケーションはテスト用であるため、雑な作りになっていますが、本来はアプリケーション上にHTMLを表示してしまうOAuth2.0クライアントアプリケーションはセキュリティ上問題があります。理由は、



  • client_secret をアプリケーションに埋め込んでしまう(アプリケーションを解析することでclient_secretがばれてしまう。

  • リダイレクトURIを踏み倒している。(アプリの開発者はリダイレクトURIが何の検証にも役に立っていないことを知っている)

  • ユーザーにどのURLにアクセスしているかが分からない。(URLバーをアプリに付けても、アプリが勝手に表示しているだけなので、いくらでも詐称できる)


といったものがあります。セキュアにやるには、外部ブラウザ(http(s)://~で起動するアプリ)を起動して認証・認可リクエストを投げます。リダイレクトURIには、アプリケーションが受け取れるようにカスタムスキーマをOSに登録します。カスタムスキーマとは、例えばブラウザのURLに hoge://~ と入力すると、アプリが起動するように関連づけておくことです。 リダイレクトURIには、スマホアプリの場合iOS Universal LinksAndroid App Linksなど、特定のURLをアプリに紐付ける仕組みを利用します。Windowsアプリケーションの場合は、WebBrowserコントロールを使えばURLを直接取得できるので、そのまま利用します。また、client_secretが秘密に保てないので、このOAuth2.0クライアントは publicクライアントとして扱う必要があります。スマホアプリでセキュアにOAuth2.0 / OpenID Connectで連携する方法は、これだけで大きなテーマであるため、この記事では割愛させていだたきます。


  • 認可周りの設定の関連(Kongのデータモデル)

GET用エンドポイント( http://172.26.22.29:8000/r/ )についてはこれでよいのですが、まだPOST( http://172.26.22.29:8000/c/ ), PUT( http://172.26.22.29:8000/u/ ), DELETE( http://172.26.22.29:8000/d/ )用のエンドポイントについては、まだ設定していません。API:myapi-create, myapi-update, myapi-delete については、それぞれ「plugin:jwt」の設定を入れます。「consumer」と「公開鍵の設定」は、GETと同じ設定が使えるので、APIごとに設定を追加する必要はありあせん。

認可の設定を入れるのに登場人物が多いので、簡単にですが設定の関連を描いておきます。この記事の例では、apisにはエンドポイントに対応して4つAPIを設定しました。pluginsは apis:plugins = 1:1 の関係で設定して、計4つ設定しました。consumersはpluginsとは無関係で設定して1つだけです。jwt_secretsはconsumers:jwt_secrets = 1:1 の関係で1つ設定しました。

2017-11-22_111124.png

pluginsはconsumersと 1:1 の関係になれますが、無関係にもできます。この記事の例ではconsumerは理論上不要なはずなのですが、jwt_secretの設定にはconsumerが必須になっているため、consumerを設定しました。将来のKongでは変更されるかもしれません。


ユーザー単位で防御する

APIの防御は、Keycloakが発行するアクセストークン(というよりJWT)で防御できました。しかし、その判定方法はJWTのiss値で、この値はKeycloak固有の値なので常に同じです(正確にはレルム単位で値は異なります)。つまり、APIへのアクセスはKeycloakからもらったアクセストークンを持っている人なら、だれでも使えることになります。このレベルの防御でも十分な場合はありますが、せっかく認可をやっているので、もう少し頑張りましょう。ユーザーによってアクセス可/否するようにします。


  • JWTの指定した属性を見るようにする(plugin)

アクセス制限がユーザー単位でコントロールできていないは、JWTのiss値を見ているからで、これをユーザー名やユーザーIDから判断するようにすれば良い、ということになります。iss値を見る設定が入っているのはpluginなので、これを変更します。またKeycloakのアクセストークンは、どの属性にユーザー名が入っているかが重要なのですが、preferred_usernameが良さそうです。

いつものようにcurlで設定します。

$ curl -i -X PATCH --data "config.key_claim_name=preferred_username" \

"http://172.26.22.29:8001/apis/myapi-read/plugins/bb3cae8b-0bf9-44f4-bf3b-9ea27f418f8c"
HTTP/1.1 200 OK
Date: Fri, 17 Nov 2017 02:35:25 GMT
Content-Type: application/json; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Access-Control-Allow-Origin: *
Server: kong/0.11.1

{
"created_at": 1510798731000,
"config": {
"key_claim_name": "preferred_username",
"secret_is_base64": false,
"anonymous": "",
"run_on_preflight": true,
"uri_param_names": [
"jwt"
]
},
"id": "bb3cae8b-0bf9-44f4-bf3b-9ea27f418f8c",
"name": "jwt",
"api_id": "8478b314-f0aa-49b7-804f-ce0320d4461c",
"enabled": true
}

パラメータがややこしいのですが、URLにあるmyapi-readはAPI名ですが、.../plugins/の後ろにbb3c...はpluginのidです。なぜかplugin名(jwt)が指定できないので(ドキュメントには出来ると書いてある)、idで指定しています。設定値自体は、リクエストボディにconfig.(属性名)で指定します。今回はkey_claim_nameisspreferred_usernameにしたいので、config.key_claim_name=preferred_usernameを指定します。ちなみにHTTPメソッドはPATCHであってPUTはダメでした。Kongの更新系APIはPATCHPUTが混じっていて、ちょっと整理されていない感じがします。

一旦消してから作りなおしてたい人は、DELETEで消すことができます。

$ curl -i -X DELETE http://172.26.22.29:8001/apis/myapi-read/plugins/bb3cae8b-0bf9-44f4-bf3b-9ea27f418f8c


  • 比較する値を変更する(jwt_secret)

比較する属性名を変更したので、属性値自体を変更します。これは公開鍵を設定した箇所で、keyの値で設定しました。ユーザーu001だけ許可するように設定します(ユーザーはKeycloakに作成しておいてください)。

$ curl -i -X PUT --data "key=u001" http://172.26.22.29:8001/consumers/myapi/jwt

{
"created_at": 1510887959000,
"id": "7c493182-f49d-4cd8-8cf1-a197f4763cce",
"algorithm": "RS256",
"key": "u001",
"secret": "QLgb9vQK0r9YbSD0b0MP0U6IPBFEzCpl",
"consumer_id": "087f5be4-cff5-4e40-b8a0-9b2925712834"
}


  • 試してみる

試してみましょう。まずはユーザーu001でKongが認可してくれるかを試します。

2017-11-17_121349.png

2017-11-17_121413.png

無事に認可されました。続いて権限がないユーザーu111で試してみます。

2017-11-17_121654.png

2017-11-17_121711.png

無事に認可されず 403(Forbidden)になりました。


まとめ

長い記事になってしまったので、いくつかのポイントに分けてまとめたいと思います。


  • Kongの認可について(JWT検証)

今回行った認可は OAuth2.0 というより、ただのJWTにある属性値の一致だけでした。属性値の検証条件は、一致だけではなく正規表現など、もう少し柔軟な設定がそのうち実装されると思います。しかし、一番行いたい検証は scope 値で、scope=foo barscope=bar fooは同一権限を表しますが、現在この2つを同じと見なす設定はできず、また正規表現でも不可能(か、ものすごく難しい)であるため、将来のバージョンでそこまで実装してくれるかが、気になるところです。

またJWTでの検証という点も少々使いずらく、というのはアクセストークンは通常ただの文字列であり、JWTではないからです。KeycloakはアクセストークンがJWT形式であるという仕様のため、特に何も考えずにKongで認可が出来ましたが、大抵のOPやOP製品はそうはいかないでしょう。IDトークンはJWT形式ですが、IDトークンの目的(認証に使用)とトークンの有効期限(リフレッシュがない)を考えると、ちょっと使いずらいように思います。


  • Kongの認可について(アクセストークン)

OAuth2.0での認可は、やはりアクセストークンを使った方式なので、KongはRPからもらったアクセストークンをOPのトークン・インストロスペクション・エンドポイントに送信して、権限情報を取得するようになって欲しいです。実はデータベースを見ると、oauth2_xxxというテーブルがあるため、すでに実装されている可能性がありますが、まだドキュメントに記載がないため、どう設定してどう使うのかが良く分かりません。近い将来ドキュメントを書いてくれるでしょう。そのときにまた試してみていいと思います。

2017-11-22_112756.png


  • Kong本体について

2017/12時点でバージョン 0.11.1 ですが、まだ時期尚早感があります。ドキュメントはそこそこ書かれていますが、すべてが書かれているのはAPI referenceくらいで、さらに例が無いため、なかなか厳しいものがあります。また管理画面(GUI)が無いのが、実際の運用を考慮するとかなり厳しく、これは本当に早く欲しいところです。curl での入力は、今回のような一番単純な例でも結構大変だったのではないでしょうか。ちなみに私は直接データベースを更新しました。


  • 全体として

今回の記事は、一番簡単な認可の例だけで、しかも認可の機能しか見ていないのにも関わらず、長い記事になってしまいました。まぁ半分くらい構築なんですが。本当はログとかロードバランスも見てみたいところではありましたが、また別の機会にしたいと思います。

おそらくKongの直接の競合は、現時点では AWS API Gateway になると思いますが、機能面はともかく(1年後のKongはそこそこ実装されるでしょうし、多分API Gatewayとしての機能には大差が無くなっていると思う)、設定しやすさ、使いやすさが比較対象になりそうに思います。多分スクリプトを含めたノン・コーディングで設定ができるかが鍵になりそうな気がします。システム屋の人ならスクリプトにの1つや2つは造作のないことなのでしょうが、実際一番操作をする人は運用であることを忘れてはいけません。

また、単に要件上「AWSが使えない」ため、選択の余地が無い場合もあると思います。そういう人のためにも、早くKongバージョン1.0がリリースされて欲しいと思います。


参考資料

QiitaにすでにKongを試した人がいました。KongのドキュメントはAPIリファレンスを見ながらで例もないので、参考になりました。

見ない訳にはいかない。

どんなAPIを使う必要があるのかが分かります。