Techouse Developers Blog

テックハウス開発者ブログ|マルチプロダクト型スタートアップ|エンジニアによる技術情報を発信|SaaS、求人プラットフォーム、DX推進

OIDCの仕組みを完全理解して、SaaSプロダクトに2FA機能を実装しました

ogp

この記事は、Techouse Advent Calendar 2024 6日目です。
昨日は nozomemein さんによる Active Storageを使った画像のCORS対応で沼った話 でした。

はじめに

こんにちは、2024年に新卒で入社し、クラウドハウス労務でバックエンドエンジニアをしているdaiki_fujiokaです。
本記事では、私が設計・実装を担当した2FA機能について、開発中に得られた知見や学んだことについて紹介します。

現状と課題

クラウドハウス労務では企業向けの人事労務管理サービスを提供しています。利用企業は従業員に対してアカウントを払い出し、従業員は自身のアカウントにログインして各種労務手続きを行います。

クラウドハウス労務のログイン方式には外部サービスであるAuth0を利用したOpenID Connect(以下OIDC)を利用しています。

OIDCには認証機能を提供するIDプロバイダー(以下IdP)、サービスを提供しているリライングパーティ(以下RP)の2つのアクターが登場します。
OIDCではRPに認証の機能を持たせずIdPで認証情報を一元管理をしており、それによってRPが得られる利益はたくさんあります。

話は変わってクラウドハウス労務の顧客要望の話になります。近年では企業でも情報セキュリティの重要性が高まっています。個人情報を取り扱う労務サービスとして、よりセキュリティの高い認証方式を提供する必要がありました。
検討を行った結果、提供すべき認証方式は他にもありましたが、どの業種の従業員でも活用できるSMSを用いた二要素認証(2FA)を実装することになりました。

しかしOIDCを導入しているクラウドハウス労務へSMSを用いた二要素認証(2FA)を実現するにあたって以下の課題があることがわかりました。

  • サービスを利用している一部の企業のみが2FAを利用するため、企業単位、企業内のユーザー単位での2FAの利用ON/OFFを制御する必要がある
  • 実装以前のクラウドハウス労務ではID/パスワード認証とSAML認証を提供しており、前者では2FAを適用し後者では2FAを適用しない仕様とした。そのため2FAを判断するタイミングで、従業員のログイン方法がわかる情報を提供しなければならない
  • 上記の変更によって他クラウドハウスサービスに対して影響を与えないようにしなければならない

2FA利用フロー図

課題を解決するためには、OIDCによってどのようにIdPと認証情報を受け渡しているのか、認証フローの中でどのようにプロダクトのドメイン情報を受け渡せるのかを理解する必要がありました。

本記事ではOIDCの技術について、そして私がどのように2FAをクラウドハウス労務に実現したのかを紹介します。

OIDCとは

そもそも、OIDC とは何なのでしょうか?OIDC とは、IETF が RFC6749 で定義した OAuth2.0 をベースにした認証プロトコルです。
OIDCを活用することで複数サービスを利用する同一ユーザーに対して、1回のログインで全てのサービスの認証ができます。このことをシングルサインオンと言います。

弊社ではクラウドハウス労務及びクラウドハウス採用の2つのプロダクトにてOIDCを活用しており、2つのサービス間でシングルサインオンを実現しています。

OAuth2.0では認可情報を共有するためにJSON Web Token(以下JWT)と呼ばれるトークンを利用します。
JWTはRFC 7419で定義されており、定義によれば認可に必要なclaimと呼ばれる様々なキーバリュー型の情報が登録可能となっています。

そしてOIDCは認可に加えてユーザーの認証を実現するため、JWTに対して認証に必要な情報をclaimとして登録するよう定めています。

以下が実際にアプリケーションに対してログインを実行したときの通信フローです。
図を参考に、クラウドハウス労務とAuth0で行われているOIDC認証フローではどのようなデータが送受信されているかを見ていきます。 OIDCフロー図

引用: https://auth0.com/docs/get-started/authentication-and-authorization-flow/authorization-code-flow#how-authorization-code-flow-works

実際の通信内容を見て理解する

ユーザーが認証情報を入力する(1~2)

利用ユーザーはRP(クラウドハウス労務)にアクセスすると、ドメインの異なるIdP(Auth0)の認証画面にリダイレクトされます。クラウドハウス労務では認証状態を保有していないためです。

リダイレクトをさせるためには、IdPが提供するリダイレクトURLをアプリケーション側で発行できなければなりません。
Auth0の場合、リダイレクトURLの発行にはテナントごとに提供されるDomain, クライアントごとに発番されるClient ID, Client Secretの3つの情報が必要になります。ここでいうクライアントはクラウドハウス労務でいうところのサーバサイドの Ruby on Rails 製アプリケーションにあたります。

環境変数から取得した3つの情報によってリダイレクトURLを生成し、認証情報を持つAuth0の画面にリダイレクトさせ、ユーザーにID/パスワードなどの認証情報を入力させます。
ここでユーザー視点ではクラウドハウス労務でログインの処理を行なっているように見えるのですが、実際はAuth0の画面で認証が行われています。

クラウドハウスシリーズではAuth0によるシングルサインオンを実現することで、ログイン情報をサービスごとに管理するコストや手間、利用者目線におけるユーザビリティを向上させています。
これがOIDCを活用する最も大きな利点の1つです。

ログイン画面

ユーザーの認可(3~7)

ユーザーとIdPが直接認証情報のやり取りをしてしまうと情報が改ざんされるリスクが発生します。そこでIdPとRPが認証情報のやり取りする前に Authorization Code と呼ばれる使い捨てのトークンをRPに発行し、署名を検証することで完全性を確保します。

3番でIdPが所有する秘密の値を用いて生成されたハッシュ値を事前にユーザーおよびRP側に共有し、4番でユーザーが一意の識別子(メールアドレスなど)の入力とともに認可を取得します。認可で取得した情報をもとに生成された Authorization Code を5,6番で通信し、攻撃者による情報の改ざんが発生していないか検証を行います。

Authorization Code はIdPが持つ秘密鍵で生成できるハッシュ値です。1番や5番でユーザーの認可を乗っ取るような攻撃を行ったとしても、生成されたハッシュ値との検証に失敗してしまうため認証情報の完全性は保たれるのです。

Auth0では認証フローで必要な実装をSDK(omniauth-auth0 )として提供しています。
SDKを利用すると実装者はAuthrization Codeや8~10で行われるJWTトークンの通信を意識せずに実装できます。

IDトークンの返却(8~10)

RPはAuthrization Codeと共に認証のリクエストを行うと、以下のようなJWTが返却されます。

eyJhbGciOiJIUzI1NiIsImtpZCI6IjEyMzQ1Njc4OSIsInR5cCI6IkpXVCJ9.
eyJpc3MiOiJodHRwczovL215YXBwLmNvbSIsImF1ZCI6Imh0dHBzOi8vYXBpLm15YXBwLmNvbSIsImlhdCI6MTY5MDAwMDAwMCwiZXhwIjoxNjkwMDAzNjAwLCJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoiYWJjZGVmZ2hpamtsbW5vcCIsIm5vbmNlIjoiYWJjMTIzZGVmIn0.
aTJ7EKHb-NM7SGrBxAbqOw_Aq5eTG-3x7hFPIRlzCDQ

JWTはピリオドによってヘッダー・ペイロード・署名の3つに分けることが可能です。
トークンはBase64でエンコードされており、ヘッダー・ペイロードをデコードすると以下のような情報を取得できます。

## ヘッダー
eyJhbGciOiJIUzI1NiIsImtpZCI6IjEyMzQ1Njc4OSIsInR5cCI6IkpXVCJ9
=> {
  "alg": "RS256",
  "typ": "JWT",
  "kid": "Vy7kF22c6Sv75rjNMvUUv"
}

## ペイロード
eyJpc3MiOiJodHRwczovL215YXBwLmNvbSIsImF1ZCI6Imh0dHBzOi8vYXBpLm15YXBwLmNvbSIsImlhdCI6MTY5MDAwMDAwMCwiZXhwIjoxNjkwMDAzNjAwLCJzdWIiOiIxMjM0NTY3ODkwIiwic2lkIjoiYWJjZGVmZ2hpamtsbW5vcCIsIm5vbmNlIjoiYWJjMTIzZGVmIn0
=> {
  "nickname": "daiki_fujioka",
  "name": "佐藤 太郎",
  "picture": 画像のURL,
  "updated_at": "2024-09-19T02:56:52.536Z",
  "email": "hoge@techouse.jp",
  "email_verified": false,
  "iss": "https://#{domain}/",
  "aud": "LRk1E4p088bxPAh9WsbBF0EsCC6M9pLY",
  "iat": 1726714614,
  "exp": 1729306614,
  "sub": "#{クラウドハウス労務の従業員ID}",
  "sid": "d69UZic__xffyB9DQHmw_fW-JPxIBZUo",
  "nonce": "2e4f86615f97ab08ff75b1bc919555a5"
}

## 署名
aTJ7EKHb-NM7SGrBxAbqOw_Aq5eTG-3x7hFPIRlzCDQ

ヘッダーには後述する署名部分で活用する暗号化アルゴリズムを表す文字列 alg と、暗号化を解除するために必要な文字列 kid が含まれています。

ペイロードにはIdPで保存されているユーザー情報やJWTの有効期限などが示されています。

署名は「ヘッダーの内容をBase64エンコードした文字列」「ペイロードの内容をBase64エンコードした文字列」「IdPの所有する秘密鍵」「alg に記載された暗号化アルゴリズム」の4つの情報によって生成された署名になります。

署名を検証するためには秘密鍵と対になる公開鍵を取得する必要があり、Auth0ではJWKS(JSON Web Key Sets)によって公開されています。 RPは公開されているJWKSエンドポイントにリクエストを送ることで、ヘッダーにあるkidと一致する鍵セットから公開鍵を生成することができます。
JWKSを利用することによって公開鍵のローテーションや複数鍵の運用が容易になりセキュリティの向上に寄与します。Auth0では利用ドメインごとにエンドポイントが公開されており、https://<domain>/.well-known/jwks.jsonの形で誰でもエンドポイントにアクセス可能です。

公開鍵によって署名を検証でき、ペイロードが攻撃者によって改竄されていないことを確認できます。

留意すべき点として、ペイロードの情報は暗号化されていないため、攻撃者がJWTを窃取した際にはペイロードの情報が盗まれてしまいます。JWTを送受信する通信の暗号化や利用ハードウェアやブラウザへのセキュリティ対策はしっかり行う必要があります。

JWTのペイロードからユーザーのログイン情報(従業員IDやメールアドレス)を取得したRPは、データベースなどで管理しているアカウント情報と突合を行い、ログインしているユーザーを特定できます。

問題について改めて再掲

認証について理解できたところで改めて解決すべき課題について考えます。

  • サービスを利用している一部の企業のみが2FAを利用するため、企業単位、企業内のユーザー単位での2FAの利用ON/OFFを制御する必要がある

私たちはSaaSとして2FAをオプショナルな機能として提供します。そこでクラウドハウス労務の企業や従業員のテーブルに対して利用可否のカラムを作成しました。
しかし、上述した通りOIDCでは認証をIdP側で行なっています。5番や7番の前に従業員の設定に応じて2FAの処理を追加したいのですが、該当の操作はAuth0で行っているため、通常のOIDCのフローではクラウドハウス労務に登録されている情報を利用できませんでした。

  • ID/パスワード認証とSAML認証を提供しており、前者では2FAを適用し後者では2FAを適用しない仕様である。そのため2FAを判断するタイミングで、従業員のログイン方法がわかる情報を提供しなければならない
  • 上記の変更によって他クラウドハウスサービスに対して影響を与えないようにしなければならない

クラウドハウスシリーズでは認証をAuth0が一元管理しているため、ID/パスワードおよびSAMLによるログイン方式のうちどちらで認可するかはユーザーの行動に依存しています。
4番で行われる、ユーザーが認可をAuth0に対して承認する通信はクラウドハウス労務を中継することはなく、アプリケーション側の実装で対応できることに限りがありました。
加えてRPの設定ごと変更する場合は、系列サービスへの影響も考慮する必要がありました。

考えられた解決策

上記を解決するために2つの解決策を考えました。

ケース1 JWTに対してCustom Claimを定義して、認証要素に渡すようにする

JWTにはサービス固有の情報を登録できる Custom Claimという機能が標準化されています。issやaudなどRFCに規定されている予約されたキーを除いた独自のキーバリューを設定できます。

ユーザーがログインボタンをクリックする1番のタイミングで企業設定などを確認し、Custom Claimとして設定することで情報を伝達することが出来ると考えました。

しかしCustom Claimには以下の欠点があり、クラウドハウス労務のケースでは利用できないと判断しました。

  • Custom Claim の管理についてはサービスが担保する必要があり、認証周りの開発を行う際の認知・考慮コストが大きい
    • JWTの仕様変更に今まで以上にキャッチアップする必要があり、運用コストが増加する
  • クラウドハウス労務独自のCustom Claimを作成することで、クラウドハウス共通のサービス基盤ではなくなってしまう

ケース2 Auth0 の提供する Login Flow Action を活用する

Login Flow ActionとはAuth0にログインする前後のログインフローにおいて様々なActionを実行させることができる機能です。
Actionの定義はJavaScriptによって実装可能で、Action内の変数として認証情報へのアクセスAPIの実行が提供されています。

Actionの中ではクラウドハウス労務のデータに直接アクセスすることはもちろん不可能です。そこでクラウドハウス労務のDB情報が取得できるWebhook URLを用意し、JavaScriptでエンドポイントに対してリクエストを送る方法を考案しました。

これによって以下のことを達成できることがわかりました。

  • 7番の後のタイミングでクラウドハウス労務のデータにアクセスすることが可能になり、企業やユーザーの2FA利用可否を判断できるようになった
  • アクション途中における認証情報へのアクセスによって、ユーザーが現在行なっているログイン方法がID/パスワードかSAML認証か判別できるようになった
  • 上記は全てIdP上で認証情報の判定を行なわれており、結果として他のクラウドハウスサービスに影響を及ぼすことなく2FAがオプショナルな機能として利用できるようになった

結果とまとめ

今回はケース2を採用することで、SaaSとして提供するオプショナルな機能を共通基盤に対して実装できました。

加えてタスクを通じてOIDC及びJWTに対して深い知識を得ることができ、クラウドサービス事業部全体の影響を考えた機能実装ができるようになりました。
この記事を通じてOIDCのことが少しでもわかるようになった方がいれば非常に嬉しいです。

ここまでお読みいただきありがとうございました。

明日のTechouse Advent Calendar 2024は aaaa777 さんによる 配属1ヶ月のインターンが勝手にRBS::Inlineを導入して怒られた です。


Techouseでは、社会課題の解決に一緒に取り組むエンジニアを募集しております。 ご応募お待ちしております。

jp.techouse.com