(2023/03/30追記)Envoyの設定項目の変更により本記事のそのままの通りでは動作しなくなっていますので注意してください。
以前からEnvoyに興味があって検証したりしています(サーキットブレーカーの効用を検証, その2)。
今回、Envoyでセキュリティ周りの関心事を分離できないか試してみました(サンプル実装)。
背景
フレームワークを使わずにWeb APIを実装すると下記のようになるかと思います。
app.get('/api/item/:id', (req, res) => {
// ユーザー認証: AuthorizationヘッダーのJWTトークンを検証&Subjectを特定
const subject = authenticateSubject(req.headers)
// 権限チェック
if (checkPermission(subject)) {
... // API固有の処理を実行
res.send(...) // CORS関連のレスポンスヘッダーをつけて返却
} else {
res.send(403) // 権限がないことを通知
}
})
app.post('/api/item/:id', (req, res) => {
// ユーザー認証: AuthorizationヘッダーのJWTトークンを検証&Subjectを特定
const subject = authenticateSubject(req.headers)
// 権限チェック
if (checkPermission(subject)) {
... // API固有の処理を実行
res.send(...) // CORS関連のレスポンスヘッダーをつけて返却
} else {
res.send(403) // 権限がないことを通知
}
})
この実装には問題があって
- そのAPIでやりたい処理以外のコードが多く、入り組みがち
- JWTトークン検証/サブジェクトの権限チェック/CORS用のヘッダー付与/…
- 同じような処理を何度も書く
- APIの実装で忘れないように注意しないといけない
このとき、APIをどんどん追加していくと何が起きるか... → 実装漏れ!
- APIが弾かれる(CORSの設定漏れ)
- 必要な情報が見れない or 見えてはNGなものが見える (権限設定ミス)
- そして再発防止 → しっかりレビュー!リリース後も不安!
Envoyを使って上記の横断的関心事をアプリのコードから分離できて実装ミスが減る&プログラミング言語非依存にできて再利用性が高まりそうです。
JWT Authentication / External Authorization
Envoyには JWT Authentication や External Authorization の仕組みがあり、認証・アクセス制御周りの機能が実現しやすくなっています。試してみました。
動作例
サンプル実装のリポジトリ をclone後、下記コマンドの実行により認証とアクセス制御の動作を確認できます。
Envoyとサンプルアプリの起動
cd auth-nz
docker-compose down ; docker-compose up --build # Docker for Macなど必要です
権限のあるユーザーのアクセス
権限のあるユーザー(bob)がJWTトークンを取得して APIを呼び出すと 200 OK
が返ってきます。
$ JWT_TOKEN=$(curl -XPOST -d '{"sub":"bob","aud":"books.read","exp":2345678901,"iss":"my.issuer.local"}' -H 'Content-Type: application/json' http://localhost:8080)
$ curl -i http://localhost:10000/item/1 -H "Authorization: Bearer ${JWT_TOKEN}"
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 2
{}
認証前のユーザーからのアクセス
Authorizationヘッダーがない場合は 401 Unauthorized
になります(トークンが不適切な場合も)。
$ curl -i http://localhost:10000/item/1
HTTP/1.1 401 Unauthorized
content-length: 14
content-type: text/plain
Jwt is missing
権限のないユーザーのアクセス
権限のないユーザー(ng-user)だと 403 Forbidden
となります。
$ JWT_TOKEN=$(curl -XPOST -d '{"sub":"ng-user","aud":"books.read","exp":2345678901,"iss":"my.issuer.local"}' -H 'Content-Type: application/json' http://localhost:8080)
$ curl -i http://localhost:10000/item/1 -H "Authorization: Bearer ${JWT_TOKEN}"
HTTP/1.1 403 Forbidden
content-type: text/plain; charset=utf-8
content-length: 9
Forbidden
サンプル実装の構成、処理の流れ
- Envoy
- 諸々を調停
- Authenticator
- JWTトークンの払い出し、jwks.json(公開鍵)の提供
- Authorizer
- subjectごとのアクセス権限をチェック
- API Server
- ダミーAPIを提供
- ユーザーはAuthenticatorからJWTトークンを取得
- ユーザーはEnvoy経由でAPI呼び出し(HTTPヘッダーにJWTトークンを含める)
- まずEnvoyはAuthenticatorから公開鍵(jwks.json)を取得してJWTトークンを検証
- その後EnvoyはAuthorizerに
user
がGET /item/1
のアクセス権限があるか問い合わせ
- 問題なければEnvoyはAPI Serverにアクセス、ユーザーはレスポンスを得る
設定内容 (envoy.yaml)
JWT Authenticatorを設定
- name: envoy.filters.http.jwt_authn
config:
providers:
jwt_provider:
forward_payload_header: jwt_payload_in_json_base64_encoded
issuer: my.issuer.local
remote_jwks:
http_uri:
uri: http://signer:8080/.well-known/jwks.json
cluster: jwks_cluster
rules:
- match:
prefix: /
requires:
provider_and_audiences:
provider_name: jwt_provider
audiences:
books.read
-
forward_payload_header
の設定をすることで、EnvoyからAuthorizerやAPIサーバーへのHTTPリクエストヘッダーにユーザー情報を含めることができます。 -
remote_jwks
にAuthenticatorのURLを設定しています。実際のアプリではAWS CognitoなどのURLを設定するかと思います。
External Authorizerを設定
- name: envoy.ext_authz
config:
http_service:
server_uri:
uri: authorizer:8080
cluster: ext-authz
timeout: 0.25s
authorization_request:
allowed_headers:
patterns:
- prefix: jwt
allowed_headers
にはAuthorizerに渡すHTTPヘッダーを設定します。サンプル実装のAuthorizerは jwt_payload_in_json_base64_encoded
ヘッダーからアクセス可否を判定しています。そのため jwt
がプレフィックスのヘッダーを転送するようにしておきます。
補足
- Authenticator
- 実際のアプリではAWS Cognitoなどの認証基盤になるかと思います。本来ならID/パスワード認証後などにトークンを払い出すところですが、サンプル実装のAuthenticator ではPOSTされたJSONからJWTトークンを生成しています。
- Authorizer
- ユーザーごとにアプリ固有のアクセス権限を設定しないといけないので、自前実装してEnvoyと連携させる必要があります。
- 権限あるなら
200
を、ないなら403
などのHTTPステータスコード返すように実装します。
CORS
Envoyを使うとCORSの設定にもとづいてレスポンスヘッダーを付与することもできます。
動作例
サンプル実装のリポジトリ [^サンプル実装のリポジトリ] を git clone
してきて下記のコマンドを実行することで、CORS関連の動作が確認できます。
Envoyとサンプルアプリの起動
cd cors/
docker-compose down ; docker-compose up --build
API呼び出しがブロックされるケース
ブラウザで http://localhost:8080 のページにアクセスし、[http://localhost:8081/cors/restricted]
のボタンを押すとバックエンドのAPI呼び出しがブロックされます。Resultに CORS Error
が表示され、ブラウザの開発者用コンソールにもエラーが出ます。
APIを呼び出せるケース
ブラウザで http://localhost:8080 にアクセスし、[http://localhost:8081/cors/open]
のボタンを押すとバックエンドのAPIが呼び出され、Resultに API is called!
と表示されます。レスポンスのHTTPヘッダーを見ると access-control-allow-origin: http://localhost:8080
が付与されています。
設定
/cors/open
のパスに対してEnvoyのCORSの設定をしています:
cors:
allow_origin: ["*"]
allow_methods: "GET"