
設計
イベント
マガジン
技術ブログ
システムの監視機能自体が停止するリスク(SPOF)に備えるため、「監視機能の監視」の必要性と、デッドマンズスイッチや外形監視などの3つの設計パターン、および運用コストのトレードオフについて解説します。
API連携やアプリ開発において、アクセス・トークンの漏洩リスクは常に大きな課題です。従来のBearerトークンでは、万が一トークンが盗まれてしまうと悪用を防ぐのが難しいという側面がありました。 この課題を解決するのが、OAuth 2.1でも重要な要素となっているDPoPです。クライアントが持つ鍵ペアを使ってリクエストに署名することで、送受信中のトークンを保護します。 今回は、なぜ今OAuth 2.1が必要とされているのか、そしてDPoPによってどのように安全性が確保されるのかをご紹介します。 今回はOAuthの基本概念や基礎的な仕組みについては言及しません、以下の記事で詳しく解説されいるので参考にしていただければと思います。 ・ 【連載】世界一わかりみの深いOAuth入門 〜 その1:OAuthってなに? ・ 何も知らない人向けにふんわりOAuthについて解説する:超初心者向け なぜ今「OAuth 2.1」が必要とされるのか? 2012年に「OAuth 2.0」の仕様が策定されてから長い年月が経ちました。 現在、Webアプリやモバイルアプリの開発環境は大きく変化しており、それに伴いアクセストークンの保護や認可の仕組みにもアップデートが求められています。 1. OAuth 2.0策定当時の想定と現代の変化 OAuth 2.0が作られた2012年当時は、Webアプリケーションといえばサーバーサイドで処理が完結する構造が主流でした。 ブラウザ側で複雑な処理を行うシングルページアプリケーション(SPA) 現在のようにネイティブ動作する数多くのスマートフォンアプリ 当時は、上記のようなクライアント環境がこれほど急速に普及・多様化することは想定されていませんでした。 2. OAuth 2.0におけるセキュリティ上の課題 アプリの利用環境が多様化した結果、以下のような課題が発生するようになりました。 仕様の曖昧さと新たな攻撃手法 仕様の曖昧な部分や想定外のユースケースを狙った、様々な脆弱性や攻撃手法(リダイレクトURIの悪用やトークン奪取など)の出現 乱立するベストプラクティス(BCP / RFC) セキュリティを担保するため、後追いで数多くのセキュリティベストプラクティス文書(BCPや追加RFC)が個別に追加され、その結果、仕様やドキュメントが分散し、「開発者が 安全な最新仕様 を正しく理解して実装する」ことが困難に 3. OAuth 2.1の目的:セキュリティ新標準への統合 こうした課題を解決するために作られたのが OAuth 2.1 です。 OAuth 2.1の目的は、個別に乱立してしまったベストプラクティスを1つの仕様に統合し、 「開発者がデフォルトで安全に実装できる新標準」 として再定義することにあります。 過去の仕様から危険な仕組み(Implicit FlowやPassword Grantなど)を削ぎ落とし、PKCE や DPoP といった最新の安全な標準を組み込むことで、迷わずセキュアな認証・認可機能を組み込める環境を整えています。 OAuth 2.0からOAuth 2.1への主要な変更点 OAuth 2.1では、セキュリティ向上と実装のシンプル化を目的として、OAuth 2.0で使われていた危険なフローの廃止やルールの厳格化が行われています。 1. PKCEがすべてのクライアントで必須化 OAuth 2.0: モバイルアプリなど一部のパブリッククライアント向け推奨 OAuth 2.1: すべてのクライアントで必須化 認可コード横取り攻撃を防ぐ拡張機能「PKCE(Proof Key for Code Exchange)」が、Webサーバー等のコンフィデンシャルクライアントを含むすべての環境で必須となりました。 2. Implicit Flowの廃止 OAuth 2.0: ブラウザ等で直接トークンを受け取るフローが存在 OAuth 2.1: 仕様から完全に廃止 アクセストークンがURLフラグメント経由で露出し、アクセスログやブラウザ履歴から漏洩するリスクが高かったため廃止されました。代わりに「PKCEを組み合わせた認可コードフロー」を使用します。 3. Password Grantの廃止 OAuth 2.0: ユーザーのID/パスワードをクライアントが直接受け取る方式が存在 OAuth 2.1: 仕様から完全に廃止 クライアントアプリに直接パスワードを渡す必要があり、「サードパーティに資格情報を預けない」というOAuth本来の目的に反するため非推奨・廃止となりました。 4. リダイレクトURIの完全一致チェック義務化 OAuth 2.0: 前方一致やワイルドカードによる検証も許容 OAuth 2.1: 完全一致の検証を義務化 曖昧なURI検証を利用したオープンリダイレクト脆弱性や、認可コード奪取の攻撃を防ぐため、事前に登録されたリダイレクトURIと完全に一致するか確認することが必須となりました。 5. DPoP(Demonstrating Proof-of-Possession)等の推奨 OAuth 2.0: トークンは基本 Bearer(持っている人なら誰でも使える) OAuth 2.1: DPoPなどの送信者拘束(Sender-Constrained)メカニズムを推奨 従来のBearerトークンでは、通信経路で盗まれた場合に第三者に悪用される危険がありました。DPoPを利用することで「トークンとクライアントの公開鍵」を結びつけ、安全性を大幅に向上させることができます。DPoPの詳細は後程解説します。 6.URLクエリ文字列でのBearerトークン送信を禁止 OAuth 2.0(RFC 6750)では許可されていた「URLのクエリパラメータ(`?access_token=xxx`)にアクセス・トークンを載せて送信する手法」が完全に禁止されました。ブラウザ履歴やサーバーアクセスログからのトークン漏洩を回避するため、リクエストヘッダー(`Authorization: Bearer …`)等での送受信に限定されます。 7.リフレッシュトークンの保護強化(ローテーション必須化等) パブリッククライアント(SPAやモバイルアプリなど)でリフレッシュトークンを扱う場合、利用ごとに新しいリフレッシュトークンを発行・刷新する「 RefreshToken Rotation」 または 「送信者拘束(Sender-Constrained)」 を適用することが義務付けられました。 DPoP(Demonstrating Proof-of-Possession)の動作シーケンス解説 DPoPは、アクセストークンを発行されたクライアント自身しか使えないように「送信者拘束(Sender-Constrained)」を行う仕組みです。 ここでは、DPoPを利用したログインからAPI呼び出しまでの全体の流れを、こちらのシーケンス図で解説します。 事前準備:鍵ペアの生成 アプリ利用開始時、クライアント側で 鍵ペア(公開鍵 / 秘密鍵) を生成しておきます。この秘密鍵を用いて以降のリクエスト(DPoP Proof)に署名を行います。 フェーズ 1:認可コードの取得(ステップ 1〜5) 1. 認可リクエスト: ユーザーがアプリの利用を開始すると、クライアントは認可サーバーへ認可リクエストを送信します。 2. ログイン・同意: 認可サーバーがログイン画面を表示し、ユーザーは認証・認可の同意を行います。 3. 認可コード返却: 認可サーバーからクライアントへ「認可コード」が返却されます。 フェーズ 2:DPoP Proofの生成とトークン取得(ステップ 6〜7) 6:DPoP Proof 1 (JWT) の作成とトークンリクエスト: クライアントは、自身の公開鍵・ペイロード・秘密鍵を用いて署名した 「DPoP Proof 1 (JWT)」 を作成します。 取得した認可コードと一緒に「DPoP Proof 1」を添えて認可サーバーへトークンリクエストを送信します。 7:認可サーバーでの検証とアクセストークン発行 : 認可サーバーは以下の検証を行った上でアクセストークンを返却します。 ① DPoP Proofから公開鍵を取得 ② DPoP Proofの署名を検証 ③ アクセストークン内に公開鍵(のハッシュ値等)を埋め込んで返却 フェーズ 3:リソースサーバーへのAPIリクエスト(ステップ 8〜9) 8:DPoP Proof 2 (JWT) の作成とAPI呼び出し: APIを呼び出す際、クライアントは新たに 「DPoP Proof 2 (JWT)」 を作成します。 アクセストークンと「DPoP Proof 2」の両方を指定して、リソースサーバーへAPIリクエストを送信します。 9:リソースサーバーでの検証とレスポンス返却: リソースサーバーは以下の通りトークンとリクエストの妥当性を確認し、問題がなければレスポンスを返します。 ① DPoP Proofから公開鍵を取得 ② DPoP Proofの署名を検証 ③ アクセストークン内の公開鍵と、Proofの公開鍵が一致することを確認 なぜこれで安全になるのか? 万が一アクセストークンが第三者に盗まれたとしても、攻撃者はクライアントの「秘密鍵」を持っていないため、有効なDPoP Proofを作成できません。そのため、リソースサーバー側で検証エラーとなり、不正利用を防ぐことができます。 まとめ 今回は、OAuth 2.1が必要とされる背景と主要な変更点、そしてDPoPの動作シーケンスについて解説しました。 記事のポイントを振り返ると以下の通りです。 OAuth 2.1の狙い: 乱立していたセキュリティベストプラクティスを統合し、開発者が「最初から迷わず安全に実装できる」標準仕様を確立すること。 不要・危険な仕様の排除: PKCEの必須化やImplicit Flow / Password Grantの廃止など、現代のクライアント環境に合わせた厳格化。 DPoPによる強力な保護: クライアントの鍵ペアでリクエストごとに署名(DPoP Proof)を行うことで送信者拘束を実現し、アクセストークン単体の漏洩による不正利用を防止。 Webやモバイルを問わず、フロントエンドでトークンを扱うアーキテクチャが一般化している今、アクセストークンの保護はこれまで以上に重要視されています。 従来のBearerトークンから脱却し、OAuth 2.1やDPoPを取り入れたより堅牢な認証・認可基盤の設計・実装をぜひ検討してみてください。 ご覧いただきありがとうございます! この投稿はお役に立ちましたか? 役に立った 役に立たなかった 0人がこの投稿は役に立ったと言っています。 The post OAuth 2.1で推奨されるDPoPの仕組みと動作確認 first appeared on SIOS Tech Lab .
はじめに こんにちは、トモニテ開発部の吉田です。 現在、トモニテではデータ基盤(Databricks)の整備に取り組んでいます。これまでの分析は個人が作成したスプレッドシートに頼る部分が多く、人やシートによって指標の定義が違ったり、誰が何を見られるのかを把握しにくかったりしたため、データを一元化することにしました。 その一環として、アプリの本番 DB のデータを Databricks に連携します。本番 DB には扱いに気をつけるべき情報が多く入っているので、開発部では「どのテーブル・カラムを Databricks に渡すか」をアプリのリポジトリで定義し、デプロイのたびにその定義を DB の権限と view に反映する仕組みをつくりました。 この記事は、本番 DB をデータ基盤につなごうとしていて、個人情報の扱いに悩んでいるエンジニアに向けて、その設計で考えたことを紹介します。伝えたいのは次の2点です。 連携の分類をマイグレーションの手順に組み込み、人が気づくことに頼らない 本番 DB を書き換えるツールは、失敗したら見える範囲を減らす側に倒す 全体の流れは次のとおりです。 図は本番環境の流れです。開発環境でも同じ流れで同期します。 連携に必要なこと Databricks が本番 DB(Aurora MySQL 3.10.4、MySQL 8.0 互換)のデータを取り込むには、次の2つが必要です。 Databricks の環境から本番 DB のリードレプリカに届く接続経路 Databricks が参照に使う、読み取り専用の DB ユーザー 経路はインフラ側で用意すれば済みますが、もう1つの「読み取り用のユーザーに何を見せるか」は、そう簡単には決められません。この記事の本題はここです。 何をどこで絞るか トモニテは妊娠・育児期の家族向けのアプリで、DB には扱いに気をつけるべき情報が多く入っています。一方で、分析に必要な情報もあり、テーブル・カラムの単位で「渡す/粗くして渡す/渡さない」を決める必要があります。 DB 側で絞る 絞り方には、次の2つがあります。 データ基盤にはすべて取り込み、分析向けにはデータ基盤側でマスクする 読み取り用ユーザーの権限で、問題のないものだけを見せる 一度 Databricks に取り込まれたデータは、コピー先まで含めて消すのが難しくなります。データの発生元である DB 側で絞るのが一番漏れのない形なので、2を選びました。 カラム単位の GRANT ではなく view で渡す 最初は、 GRANT SELECT (id, created_at, ...) ON users のように機密カラム以外のカラムだけに GRANT する方法(カラム単位の GRANT)を試しましたが、そのテーブルを Databricks から読めなくなりました。Databricks からは、Lakehouse Federation(外部の DB を Unity Catalog のカタログとして見せる機能)の MySQL 接続で読んでいます。 手元の MySQL 8.0 で、カラム単位の GRANT だけを持つユーザーをつくって確かめると、次のようになりました。 クエリ 結果 SHOW TABLES 、 information_schema.COLUMNS テーブルは見え、カラムは許可したものだけが見える SELECT id, created_at FROM users (許可したカラムだけを指定) 読める SELECT * FROM users WHERE 1=0 ERROR 1142 (42000): SELECT command denied to user ... for table 'users' MySQL では、許可していないカラムを含む SELECT * は、行を1件も返さないクエリでも拒否されます。しかもエラーはカラムではなくテーブル単位の拒否(1142)として返るので、テーブルの権限そのものが無いように見えます。 一方、Spark の JDBC は、テーブルのカラムと型を調べるときに既定で SELECT * FROM <テーブル> WHERE 1=0 を送ります。この既定は JdbcDialect.getSchemaQuery で、MySQL 用の MySQLDialect はこれを上書きしていません。 github.com github.com Databricks も同じクエリを送っているのかを、Aurora の performance_schema で確かめました。 events_statements_summary_by_digest は、実行されたクエリの値を ? に置き換えた形ごとに集計した表で、実行回数・返した行数・エラーの回数に加えて、置き換える前のクエリの例( QUERY_SAMPLE_TEXT )も持っています。users について、元のテーブルと、後述する機密カラムを除いた view( <masked> のデータベースに置いたもの)を、次のクエリで比べました。 SELECT QUERY_SAMPLE_TEXT, COUNT_STAR, SUM_ROWS_SENT, SUM_ERRORS FROM performance_schema.events_statements_summary_by_digest WHERE DIGEST_TEXT IN ( ' SELECT * FROM `<元のデータベース>` . `users` WHERE ? = ? ' , ' SELECT * FROM `<masked>` . `users` WHERE ? = ? ' ); QUERY_SAMPLE_TEXT COUNT_STAR SUM_ROWS_SENT SUM_ERRORS SELECT * FROM `<元のデータベース>`.`users` WHERE 1=0 41 0 7 SELECT * FROM `<masked>`.`users` WHERE 1=0 40 0 0 WHERE 1=0 を付けた SELECT * が届いていて、1行も返していませんでした。Lakehouse Federation も、Spark と同じ方法でカラムを調べていると考えられます。カラム単位の GRANT を試した元のテーブルにはこのクエリのエラーが記録されていて、view では 0 回でした。カラムを調べる段階で拒否され、テーブルごと読めなくなったと考えています。 許可したカラムを明示したクエリ( SELECT * FROM (SELECT id, created_at FROM users) t )なら通るので、読む側でテーブルごとにカラムを指定すれば回避はできます。ただ、それでは「何を渡すか」の定義が Databricks 側にも分散します。そこで、機密カラムを除いた view を別のデータベース(以下、masked)につくって渡すことにしました。view なら SELECT * で読んでも、出てくるのは view が出力するカラムだけです。連携が不要なテーブルには、権限を付けません。 加工も Databricks の ETL ではなく view で行います。ETL で加工すると、加工前の値が、取り込んだデータをそのまま置く層(Bronze)に残ってしまうためです。 view と Unity Catalog の二段構え view で落とす・粗くするのが一段目、Databricks 側の Unity Catalog で見られる人を絞るのが二段目です。view で粗くしきれないものは、二段目で閲覧者を限定します。 分類の方針 どのカラムを渡すかは、厳しめにルールを整備しました。 認証情報や、それ単独で個人情報になるものは渡さない 単独では個人を特定できなくても、ほかの情報と容易に照合することで個人を識別できるもの(容易照合性)も考慮し、渡さないか、分析に必要な粒度まで粗くして渡す 判断に迷ったら「渡さない」側に倒す 迷ったら渡さない側に倒したのは、あとから必要になれば view を直して追加できますが、一度渡したものを消すのは難しいからです。厳しめにした分、運用を始めてメンバーから「分析しづらい」という声が出れば、見直すつもりです。 連携するテーブル・カラムを tables.yml で宣言する この方針では、テーブルの追加・変更のたびに権限を整理する必要があります。手作業で整理する運用は避けたいので、定義をリポジトリで管理し、CI で検査、デプロイで反映する仕組みにしました。 定義は、アプリ(API サーバー)のリポジトリに置いています。中心になるのは tables.yml で、全テーブルを次の3つに分類します。 grant :そのまま渡す view :機密カラムを除いた view で渡す exclude :渡さない view のテーブルは、さらに全カラムを include (そのまま渡す)/ transform (加工して渡す)/ exclude (渡さない)に分け、 transform には加工内容と理由を、 exclude には理由を書きます。 現在は全 201 テーブルを分類していて、内訳は grant が 165、 view が 30、 exclude が 6 です。 # テーブル名・カラム名は説明用の例です tables : table_a : { policy : grant } table_b : policy : view columns : include : [ id, status, created_at ] # そのまま渡すカラム transform : # 加工して渡すカラム: 加工内容と理由 column_x : 粒度を粗くする。元の値はほかの情報と照合すると個人を特定できる exclude : # 渡さないカラム: 理由 column_y : 個人を特定できる情報 table_c : policy : exclude reason : 連携が不要 この定義と、view の SQL( views/*.sql )、Databricks の読み取り用ユーザーへの GRANT( grants.sql )を1つのディレクトリで管理しています。 なぜインフラのリポジトリではなく、アプリのリポジトリに置いたか トモニテのインフラは Terraform で別のリポジトリが管理していますが、定義はアプリのリポジトリに置きました。 見直しのきっかけになるテーブル・カラムの追加はアプリのマイグレーションで行われるので、同じ PR で分類と view も更新・レビューできる 分類漏れの検出にはマイグレーション済みの DB が必要で、アプリの CI ではすでに MySQL を立ててマイグレーションを流している Databricks からの経路や DB ユーザー・IAM などは、引き続きインフラのリポジトリで管理しています。 分類を書くまで CI が通らないようにする CI では、マイグレーション後のスキーマと定義を突き合わせ、次のものがあれば落とします。 分類されていないテーブル、 view のテーブルの分類されていないカラム 元のテーブルの変更で壊れた view exclude ・ transform にしたカラムを、そのまま出力している view grants.sql と tables.yml の食い違い これで、マイグレーションでテーブルやカラムを足すと、分類を書くまで CI が通らなくなります。 「追加に人が気づく」ことに頼らず、分類を書くことがマイグレーションの手順に組み込まれる 形です。 開発メンバーにとっては、マイグレーションのたびに分類を書く手間が増えます。この手間をどう感じるかは、これから運用しながら確かめていきます。 開発環境の DB でも実際に止まった 同じ検査は、後述の同期コマンドもデプロイのたびに実際の DB に対して行います。開発環境で動作確認したところ、定義に分類の無いカラムがある状態で、同期が想定どおり止まりました。別の機能の開発で開発環境の DB にはすでにカラムが追加されていましたが、連携定義にはそのカラムの分類がなかったためです。止まったのは定義の検証の段階(Apply の前)なので、DB には何も変えずに失敗が通知されました。 マイグレーションと定義のずれを、CI だけでなく実際の DB に対しても検知できることを確かめられました。 同期コマンド:差分(Plan)を計算して適用する 定義を DB に反映するのは、Go で書いた同期コマンドです。デプロイのたびに ECS のタスクとして起動し、定義を検証して排他をとったうえで、今の権限と view を読んで定義との差分(Plan)を計算し、適用します。適用後は状態を読み直して確かめ、変更か失敗があれば Slack に通知します。 宣言的で冪等に動くので、差分がなければ何もせず、通知もしません。定義に無い権限や view は外れます。手作業で付けた GRANT や、手でつくった view も、同期用ユーザーが外せる範囲で次の同期で外れます。 権限の管理には Terraform の MySQL provider などの既存ツールもありますが、定義をアプリのリポジトリに置いたので、同期コマンドもアプリのリポジトリに置き、アプリと同じ Go で書きました。 適用の順番を固定する Plan は、必ず次の順番で適用します。 ① view の作成・更新 ② GRANT ③ REVOKE と、定義に無い view の DROP この順番には、次のルールを決めています。 ①が1本でも失敗したら、②は流さない。 新しい view ができていないのに、GRANT だけが増える状態を避けるためです ①の中、②の中では、1本が失敗しても残りは止めない。 関係のない文まで巻き込んで止めないためです ③は①②の結果にかかわらず必ず全部流し、1本が失敗しても残りを続ける。 見える範囲を減らす操作は、止める理由がないためです 適用後に確かめ、だめなら段階的に安全側へ倒す 適用が終わったら、状態(権限と view の一覧)と、masked の各 view の出力カラムを読み直します。そのうえで、機密カラム( exclude ・ transform にした元のカラム)を出している view があれば、次のように段階的に対応します。 その view を DROP する。取り込みは止まるが、漏れは止まる DROP できなければ(ほかのユーザーが作った view など)、masked 全体の SELECT を REVOKE する。全 view の取り込みが止まるが、機密カラムが見え続けるよりは安全 それもできなければ、機密カラムが見え続けていることを至急の通知で知らせる 同期コマンド自体を信用しすぎない 同期は本番 DB の権限と view を書き換えるので、誤って別のユーザーや別のデータベースを触らないよう、コマンド自体にも制限をかけています。 発行できる SQL を4種類に固定する masked の view をつくる・更新する CREATE OR REPLACE VIEW 読み取り用ユーザーに権限を付ける GRANT SELECT 読み取り用ユーザーの権限を外す REVOKE masked の view を消す DROP VIEW 制限は二段構えです。 生成時 :SQL は専用の型( Statement )のコンストラクタからしか作れず、この4種類以外を組み立てる手段がない 実行直前 :実行する側が、許可リスト(正規表現)で SQL の文字列を再検査し、一致しないものは実行しない 同期用ユーザーの権限を絞る 同期用の DB ユーザーには、必要な権限だけを付けています。同期で外せる権限も、同期用ユーザーが GRANT OPTION 付きで持っている範囲に限り、それ以外の権限が付いていた場合は、同期では直せないものとして毎回失敗で通知します。 この最小権限で同期が通ること、2回目の同期では SQL を1本も送らないこと(冪等)、手作業で足した権限が次の同期で外れることは、CI の MySQL で本物の定義を使った結合テストで確かめています。 デプロイとの連動 同期は、開発環境・本番へのデプロイの後に、別のジョブとして起動します。定義やマイグレーションなど同期に関係するものが変わったときだけ動き、変わったかを判定できないときは、見落とさないよう同期する側に倒しています。失敗した実行は成功に数えないので、その変更は次のデプロイで同期し直されます。 割り切ったこと 最後に、この仕組みでは検出できないことも書いておきます。 view の検査は、 exclude ・ transform にした元のカラム名がそのまま出力に無いかだけを見ている。別名を付ける、式で包むといった書き方で機密カラムを出しても検出しないので、view の SQL の変更は PR のレビューで確かめる grant のテーブルに機密カラムが足された場合は、CI では検出できない。マイグレーションのレビューで view に切り替える 読み取り用ユーザーのグローバル権限やロール経由の権限は、同期用ユーザーが読めないので検出できない すべてを機械で防げるわけではないので、機械で防げる範囲と、レビューで確かめる範囲を README に明記しています。 まとめ 本番 DB のデータを Databricks に安全に渡すために、「どのテーブル・カラムを渡すか」をアプリのリポジトリで宣言し、デプロイのたびに DB に反映する仕組みをつくりました。冒頭の2点をあらためて挙げます。 連携の分類をマイグレーションの手順に組み込み、人が気づくことに頼らない。 分類を書くまで CI が通らず、分類はマイグレーションと同じ PR でレビューする 本番 DB を書き換えるツールは、失敗したら見える範囲を減らす側に倒す。 適用の順番を固定し、機密カラムが見えていれば view の DROP、masked 全体の REVOKE と段階的に安全側へ倒す データ基盤に何を渡すかを、シートの共有範囲ではなく、リポジトリの定義で管理できるようになりました。これからは、一元化したデータの上で分析を進めていきます。























