EnvoyでWebアプリのセキュリティ周りの関心事を分離する


以前から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 AuthenticationExternal 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を提供

  1. ユーザーはAuthenticatorからJWTトークンを取得
  2. ユーザーはEnvoy経由でAPI呼び出し(HTTPヘッダーにJWTトークンを含める)
    • まずEnvoyはAuthenticatorから公開鍵(jwks.json)を取得してJWTトークンを検証
    • その後EnvoyはAuthorizerに userGET /item/1 のアクセス権限があるか問い合わせ
  3. 問題なければ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"

その他