メジャーリーグの労使協定が決着ついて、「ああ、やっと球春だな⚾」って思ったマンです*1.
なお, このエントリーは野球の話題では有りません.
それはさておき, 私は毎朝毎夕に「血圧」「脈拍」「体重」「飲酒量」といったメトリクスを健康維持のために記録・振り返りをしているのですが, これをいい感じにするための個人的なSaaSを(本来の目的 + 技術的な検証・キャッチアップそして趣味として)作っています.
Firebase上のフロントエンド(ちなみにNext.jsで作ってます)とApp Engine上のバックエンド(ちなみにFast APIで作ってます)を軸に,
- Google アカウント(Gmail)でのシングルサインオン(≒めっちゃ狭義な意味でのゼロトラスト認証)を実現するためにFirebase Authenticationを認証プロバイダとして使用
- 主にバックエンド側の認証実装を回避(かつ, フロントエンドに対するBFF*2の役割をバックエンドから委譲)するため, Cloud Endpointsを使ってAPI Gatewayを構築して認証をそこでやらせる
といったアプローチを取っていい感じにうまく行ったのですが,
- Firebase Authenticationを認証プロバイダとして使うのにまあまあハマった
- CORS対策で更にドツボにハマった
という事があり, もう二度とハマりたくないのでメモとしてやったことをサクッと残したいと思います.
なお, Webアプリ開発中級者向けの内容となります(少なくとも初心者向けではない).
TL;DR
API Gatewayを介するゼロトラストっぽい構成, OpenAPIとCloudプラットフォームの仕様を理解して使うとめっちゃ楽
「仕様を理解」までが割と難産だったので, そこを書きますよと.
おしながき
ゼロトラスト #とは
目線を揃えるため, ちょこっとだけ小咄をします.
世にいう「ゼロトラスト」は, めっちゃ曖昧かつ色んな意味・定義を持つ考え方だと私は思っています.
上記のAkamaiのページからの引用ですが,
ゼロトラストとは、厳しいアイデンティティ検証プロセスに基づいたネットワーク・セキュリティ・モデルのことです。このフレームワークでは、認証や許可を受けたユーザーおよびデバイスのみがアプリケーションやデータにアクセスできます。さらに、アプリケーションやユーザーをインターネット上の高度な脅威から保護します。
ちょこっと分解すると,
- ゼロトラストはネットワーク・セキュリティなモデルやで
- 認証認可をネットワーク的な境界やなくて, アプリケーションやデータに対してかけるやで(≒アプリケーション層の話やで)
と読んでいいかなと思っています&これをアプリケーション開発者(と少しでも楽をしたい私)向けに翻訳すると,
- 今どきのWebやスマホのアプリケーションってSNSやメール(GmailとかOfficeとか)系の何かでシングルサインオンするでしょ?
- 各々のアプリ・サービスで独自で認証認可の実装は(少なくともコンシューマ向けのアプリでは*3)避けたい, 開発の難易度・運用・メンテの効率を考えても.
- 認証認可は有りモノのサービスに丸投げして作るべきFeatureに集中しようぜ!
という,
「自分で認証認可を開発しない, 仕組みに乗っかる」って考え方が(狭義の意味での)ゼロトラストである
と, 捉えていいかなと思います, 雑ですけど.
Firebaseでの認証
ちょっと雑談が過ぎましたがここから本題です.
Firebaseでの認証ですが,
- フロントエンド側でFirebase Authenticationによる認証を実装
- フロントエンドから, バックエンドへのリクエストに対する認証(=ログインしたユーザーかつ, ちゃんとしたToken使ってますよね?というチェック)をAPI Gateway(Cloud Endpointsで構築)で行う
- バックエンドに対するリクエストはすべてAPI Gatewayを通す. 一部の例外(preflight request, health checkのエンドポイントなど)を除き, 認証済みのリクエストのみ通す
という方針でやりました.
フロントエンドでやること
これは割とシンプルで,
- フロントエンドアプリにFirebaseを導入する
- Authenticationを使うための実装をする
- バックエンドに対するリクエストにJWT Tokenを載せる
これだけです.
Firebaseの導入やAuthenticationの利用は公式を参考にしつつ,
Zennなどの記事を読みながら程よくやれました.
なお, バックエンドに対するリクエストの実装はこんなノリです.
import axios from "axios" // tokenの所にFirebase Authで発行したJWT Tokenがのるイメージ export const jsonPost = async (data, path, token) => { await axios .post(`${process.env.NEXT_PUBLIC_API_URL}/${path}/`, data, { headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, }) .then((response) => { return response.data }) .catch((error) => { const { status, statusText } = error.response console.log(`Error! HTTP Status: ${status} ${statusText}`) }) }
ここまでは, 比較的ドキュメントやブログなどが充実しているのでハマらず進むんじゃないかなと思います.
API Gatewayの仕事
API GatewayはCloud Endpointsを使って作りました, 詳しくは以前ブログに書いたのでそちらを御覧ください.
ここで, バックエンドで使うURL・エントリーポイントの定義を行います.
swagger: "2.0" info: title: Cloud Endpoints + App Engine description: Sample API on Cloud Endpoints with an App Engine backend version: 1.0.0 host: ${Cloud RunのURL} schemes: - https produces: - application/json x-google-backend: address: ${App EngineのURL} jwt_audience: ${App EngineのIAPクライアントID} protocol: h2 securityDefinitions: firebase: authorizationUrl: "" flow: "implicit" type: "oauth2" x-google-issuer: https://securetoken.google.com/${自分のプロジェクトのID} x-google-jwks_uri: https://www.googleapis.com/service_accounts/v1/metadata/x509/securetoken@system.gserviceaccount.com x-google-audiences: ${自分のプロジェクトのID} paths: /: get: # Health checkで使うため, 認証はかけていない summary: Health check operationId: index responses: "200": description: A successful response schema: type: string /stats: post: summary: post stats operationId: stats security: # 認証が必要やでっていう設定 - firebase: [] responses: "200": description: A successful response schema: type: string
Cloud EndpointsのCORS設定
認証をFirebaseおよびCloud Endpointsで仕込むまでは大丈夫だったのですが, 地味にCORS周りの設定でハマりました.
試行錯誤し調査した結果,
- API GatewayとバックエンドでCORS設定の棲み分けを決める
- preflight request用のエンドポイントを用意する
これで回避できました.
CORS設定をどこでやるか決める
これが結構重要で, 考え方が4パターン存在します.
- API Gateway側でCORSを定義する or しない
- バックエンド側でCORSを定義する or しない
どっちもチェックしません, というパターンは(よほど勇気がない限り)やらないと思う(というかやるべきではない)ので,
- API GatewayでCORS定義, バックエンドはやらない
- バックエンドでCORS定義, API Gatewayはやらない
- どっちもCORS定義を仕込む
の実質3パターンかなと思います.
今回私がやったのは,
- API GatewayではCORSのチェックをやらない(すべてのリクエストを通す). 理由としては, 「APIだけ他に開放して」とかやりそうな予感があったため.
- 一方, バックエンド側は「API Gatewayからのリクエストのみ許容する」を実現したかったのでCORSを設定
しました.
バックエンド側のCORS設定は各WebサーバーやFWの仕様に依存する所なので使ってるもの次第で対応が変わります.
今回はFast APIを使ったのでこんなノリでした.
from datetime import datetime from typing import Dict import uvicorn # type: ignore from fastapi import FastAPI from starlette.middleware.cors import CORSMiddleware from environment import DEBUG if DEBUG: app = FastAPI() else: app = FastAPI(docs_url=None, redoc_url=None) app.add_middleware( CORSMiddleware, allow_origins=[ 'http://localhost:3000', # ローカル開発で使うので 'https://apigateway.example.com', # API GatewayのURL ], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) @app.get('/') def index() -> Dict: return {'status': 'ok'} # 以下略
API Gatewayの設定
API Gatewayの設定ですが, 今回はCORSをスルーしたかったので,
- すべてのリクエストを許可する設定を実施. 具体的にはここを参考にして,
x-google-endpoints
を追加 - preflight request用のパスを追加
という処置を行いました.
完成形のOpenAPI定義はこんな感じです.
swagger: "2.0" info: title: Cloud Endpoints + App Engine description: Sample API on Cloud Endpoints with an App Engine backend version: 1.0.0 host: ${Cloud RunのURL} schemes: - https produces: - application/json x-google-backend: address: ${App EngineのURL} jwt_audience: ${App EngineのIAPクライアントID} protocol: h2 x-google-endpoints: - name: ${Cloud RunのURL} allowCors: True securityDefinitions: firebase: authorizationUrl: "" flow: "implicit" type: "oauth2" x-google-issuer: https://securetoken.google.com/${自分のプロジェクトのID} x-google-jwks_uri: https://www.googleapis.com/service_accounts/v1/metadata/x509/securetoken@system.gserviceaccount.com x-google-audiences: ${自分のプロジェクトのID} paths: /: get: # Health checkで使うため, 認証はかけていない summary: Health check operationId: index responses: "200": description: A successful response schema: type: string /stats: options: # preflight用のoptionsを追加, ここは認証をかけない(空で返すAPIをバックエンド側で用意) summary: cors operationId: options-stats responses: "200": description: A successful response schema: type: string post: summary: post stats operationId: stats security: # 認証が必要やでっていう設定 - firebase: [] responses: "200": description: A successful response schema: type: string
私がやったときは, prefligtht requestが原因で処理が通らず, 結構ハマったのですが「そうだった, パスがなかった」と気がつき, やったら割とアッサリでした.
なお, CORSを設定(API Gateway側で弾く)場合の設定は公式のドキュメントにしっかり載ってるのでこちらが参考になります.
こちらのパターンだとAPI Gatewayですべて吸収する(例えばpreflight requestはバックエンドまで到達させない)らしいのですが, まだ試してないのでいずれやってみたいです.
結び
というわけで, 個人開発してるプロジェクトでゼロトラストやってみた(かつCORSでハマった)という記事でした.
最後に書くのもアレですが, 実はこの記事の続編だったりします.
これの最後でCORSにハマり, 開発に時間がかかったためブログに間に合わず, 別記事にした所存です.
とはいえ,
- 認証をゼロから作らず, 作りたい機能に集中して開発できる体験を手に入れた
- ちゃんと知ったらシングルサインオン程度のゼロトラストは割とすぐ作れる
- OpenAPIと各クラウドの独自仕様を理解したら横展開も楽そう
とわかったのが学びでした, 今回はGoogle CloudでやりましたがAWSやAzureでも同じような考え方でできると思うんですよね, 多分.
最近はお仕事がわかりやすいぐらいにマネージャー業*4で, プログラミング・エンジニアリングをするのは趣味の時間が多くなりましたが, 引き続きこういうことをしてもっと色んなことを覚えたい所存です.
最後までお読みいただきありがとうございました.
*1:今やってる個人開発が落ち着いたら再びメジャーリーグのデータを使った野球データサイエンス活動を再開していくつもりです, やりたいネタが溜まってきた
*2:いわゆるBackend for Frontendのこと.
*3:これがBtoB SaaSとかになるともうちょっと違う事情が出てくると思いますが, 「ひとつの認証プロバイダで発行したAPI Keyを複数のSaaSサービスで使いたい」みたいなユースケースがあったりするとゼロトラストなパターンが活躍することもあるんじゃないかなと思います.
*4:パワポとエクセル, チャットとメールが私の仕事の相棒です&仕事のパソコンでエディタを開くのは1日5分あればいいほうかな...(って意味ではゼロではない)