Lock in $30 Savings on PRO—Offer Ends Soon! ⏳

ドメイン指定Cookieとサービス間共有Redisで作る認証基盤サービス

 ドメイン指定Cookieとサービス間共有Redisで作る認証基盤サービス

Kaigi on Rails 2025 の発表資料です。
https://kaigionrails.org/2025/talks/kokuyouwind/#day2

Avatar for kokuyouwind

kokuyouwind

September 26, 2025
Tweet

More Decks by kokuyouwind

Other Decks in Programming

Transcript

  1. $ whoami 黒曜 / @kokuyouwind Leaner Technologies Inc. 所属 Rails

    エンジニア・SRE 今はLeaner の認証基盤サービスを 作ってます 2
  2. 認証・認可のイメージ(Web) 1. 私は黒曜です パスワードはxxx です 2. 確かに正しいパスワードでした あなたは黒曜さんです( 認証) 3.

    毎回本⼈確認を⾏うと⼤変なので、 クッキーにあなたの情報をメモしました クッキーの中身はサーバーからしか⾒えないので、 以降はこのクッキーを送ってください( セッションの記録) 13
  3. Devise # lib/devise/controllers/sign_in_out.rb def sign_in(resource_or_scope, *args) # ... warden.session_serializer.store(resource, scope)

    # ... end # lib/warden/session_serializer.rb def store(user, scope) return unless user method_name = "#{scope}_serialize" specialized = respond_to?(method_name) session[key_for(scope)] = specialized ? send(method_name, user) : serialize(user) end 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 session['warden.user.user.key'] = [user.id, user.salt] セッションにユーザーID( とsalt) を保存 18
  4. Rails のセッション管理 session[] と session[]= で読み書きできる config.session_store で保存⽅法を決定 デフォルトは CookieStore

    暗号化してCookie に丸ごと保存 サーバー側にデータは持たない ミドルウェア系Store(MemCacheStore, Redis::Store など) Cookie にセッションID のみを保存 サーバー側ミドルウェアでセッションID をキーにデータを保存 20
  5. そもそも認証基盤は必要? ずっと1 プロダクトしか作らないならまず必要ない 分離するコスト・保守するコストがかかる 責務の分割だけなら Rails Engine 切り出しや gem を適切に使えば⼗分そう

    ユーザ層が被る複数プロダクトを提供するならほぼ必須 マルチプロダクト戦略・コンパウンド戦略 ユーザ視点・開発視点でメリットが多い 25
  6. 案B. 前段でセッションを集約管理 認証機能 1. ログイン 3. 機能利⽤ プロダクトA 機能 (ALB

    でのCognito 認証連携などはこれ) 2. 認証結果をセッションに記憶 29 4. 認証結果を取り出してプロダクトA に⼀緒に送 信
  7. 案C. セッションを共有 ( 今回の本題) 認証機能 1. ログイン 4. 機能利⽤ (

    鍵を渡す) プロダクトA 機能 2. ユーザーID を記録 3. 鍵を渡す 5. ユーザーID を取得 30 (JWT トークンは鍵⾃体に情報を埋め込むが、原理はこれに近い)
  8. どれがいいの? 認証情報をOIDC で受け渡す形にすると技術標準に乗れる … が、事前にOAuthApplication 払い出しが必要だったり state, nonce, PKCE などセキュリティ担保のための仕様が多く複雑

    OIDC はサードパーティーにID を提供する⽬的の仕様 ⾃社サービスだけで使うのにほんとにそこまで必要? 前段で受けるのはインフラ構成の制約が⼤きい セッションをうまく共有できれば良いのでは? という⽅針でこの後の話をしていきます 31
  9. Leaner の認証基盤サービス構成 認証基盤 1. ログイン 4. 機能利⽤ (Cookie も送信) 2.

    ユーザーsub を記録 (via. redis-session-store) 3. Cookie にsession_id を保存 5. ユーザーsub を取得 33
  10. ログイン ~ ユーザーsub を記録 認証基盤 1. ログイン 4. 機能利⽤ (Cookie

    も送信) 2. ユーザーsub を記録 (via. redis-session-store) 3. Cookie にsession_id を保存 5. ユーザーsub を取得 34
  11. ログイン ~ ユーザーsub を記録 認証: + セキュリティ強そうなのと拡張性⾼そうなので選定 パスワードログイン: Rodauth 標準

    SAML ログイン: で独⾃Feature を実装 セッション管理: Sidekiq とか使うかなと思ってRedis を選定 Rodauth Rodauth Rails ruby-saml redis-session-store 35
  12. ログイン ~ ユーザーsub を記録 # app/misc/rodauth_main.rb class RodauthMain < Rodauth::Rails::Auth

    configure do after_login do # save user session[:shared_sub] = user.sub end end end 1 2 3 4 5 6 7 8 9 # leaner:session:2::b0db908bd1428e4638... { "shared_sub": "ZiJkFfmqOzSuTtvMwHflJDxWD7h1", "leaner_id_user_id": 6, "leaner_id_active_session_id": "q_KRmXTSw3...", "leaner_id_authenticated_by": [ "password" ] } 1 2 3 4 5 6 7 8 9 36
  13. Cookie の保存 ~ 送信 認証基盤 1. ログイン 4. 機能利⽤ (Cookie

    も送信) 2. ユーザーsub を記録 (via. redis-session-store) 3. Cookie にsession_id を保存 5. ユーザーsub を取得 37
  14. Cookie の保存 ~ 送信 認証基盤 auth.leaner.app mitsumori.leaner.app 認証基盤とプロダクトはURL が異なる (

    後出しの制約) Cookie は基本的に同⼀ドメインにしか送らないため、 認証基盤でセッションID をCookie に格納しても プロダクト側に送られない! 38
  15. Cookie の保存 ~ 送信 # config/application.rb Rails.application.config.session_store :redis_session_store, key: 'LEANER_SESSION_ID',

    domain: '.leaner.app', secure: true, same_site: :lax, serializer: :json, redis: { # ... } 1 2 3 4 5 6 7 8 9 10 Cookie に domain を指定すると、後⽅⼀致で送信するか決まる こうすると auth.leaner.app と mitsumori.leaner.app の 両⽅に同じCookie を送信する 39
  16. ユーザーsub を取得 認証基盤 1. ログイン 4. 機能利⽤ (Cookie も送信) 2.

    ユーザーsub を記録 (via. redis-session-store) 3. Cookie にsession_id を保存 5. ユーザーsub を取得 40
  17. ユーザーsub を取得 # leaner:session:2::b0db908bd1428e4638... { "shared_sub": "ZiJkFfmqOzSuTtvMwHflJDxWD7h1", "leaner_id_user_id": 6, "leaner_id_active_session_id":

    "q_KRmXTSw3...", "leaner_id_authenticated_by": [ "password" ] } 1 2 3 4 5 6 7 8 9 # app/controller/application_controller.rb class ApplicationController < ActionController::API def current_user User.find_by(sub: session[:shared_sub]) end end 1 2 3 4 5 6 41
  18. Leaner の認証基盤サービス構成( 再掲) 認証基盤 1. ログイン 4. 機能利⽤ (Cookie も送信)

    2. ユーザーsub を記録 (via. redis-session-store) 3. Cookie にsession_id を保存 5. ユーザーsub を取得 42
  19. プロダクト側で使う独⾃gem LeanerAuthenticatable gem を使って プロダクト側処理を共通化 # app/controller/application_controller.rb class ApplicationController <

    ActionController::API # 認証基盤からユーザーを取得する include LeanerAuthenticatable.create_module( organization_class: Organization, user_assoc_method: 'users', product_code: 'mitsumori', session_key_prefix: 'mitsumori' ) # 以下メソッドが利⽤できるようになる # def current_user: () -> User end 1 2 3 4 5 6 7 8 9 10 11 12 13 45
  20. 組織からのユーザー取得 # app/controller/application_controller.rb class ApplicationController < ActionController::API # 認証基盤からユーザーを取得する include

    LeanerAuthenticatable.create_module( organization_class: Organization, user_assoc_method: 'users', # Organization # .find_by(sub: session[:shared_organization_sub]) # .users.find_by(sub: session[:shared_sub]) 1 2 3 4 5 6 7 8 9 # leaner:session:2::b0db908bd1428e4638... { "shared_organization_sub": "M0qMtiOCrv5...", "shared_sub": "ZiJkFfmqOzSuTtvMwHflJDxWD7h1", "shared_product_licenses": [ "mitsumori" ], ... } 1 2 3 4 5 6 7 8 9 46
  21. プロダクト利⽤権の判定 # app/controller/application_controller.rb class ApplicationController < ActionController::API # 認証基盤からユーザーを取得する include

    LeanerAuthenticatable.create_module( # ... product_code: 'mitsumori', # session[:shared_product_licenses] # .member?('mitsumori') 1 2 3 4 5 6 7 8 # leaner:session:2::b0db908bd1428e4638... { "shared_organization_sub": "M0qMtiOCrv5...", "shared_sub": "ZiJkFfmqOzSuTtvMwHflJDxWD7h1", "shared_product_licenses": [ "mitsumori" ], ... } 1 2 3 4 5 6 7 8 9 47
  22. session の名前空間分割 # app/controller/application_controller.rb class ApplicationController < ActionController::API # 認証基盤からユーザーを取得する

    include LeanerAuthenticatable.create_module( # ... session_key_prefix: 'mitsumori' # class SessionStoreWrapper # def [](key) # parent[shared_key?(key) # ? key : :"#{prefix}_#{key}"] # end 1 2 3 4 5 6 7 8 9 10 11 # leaner:session:2::b0db908bd1428e4638... { # session[:login_method] = "leaner_auth" "mitsumori_login_method": "leaner_auth" # shared_ から始まるキーはそのまま読み書きできる "shared_sub": "ZiJkFfmqOzSuTtvMwHflJDxWD7h1", ... 1 2 3 4 5 6 7 48
  23. # docker-compose.yml services: auth_server: image: ghcr.io/.../leaner-auth/leaner-auth-api:latest # <= 認証基盤 redis:

    image: redis:alpine # <= session store ⽤のredis proxy: image: ghcr.io/.../leaner-auth/minica-proxy:latest # <= ??? 1 2 3 4 5 6 7 8 ローカル開発⽤ 認証基盤コンテナ プロダクト側のローカル開発時に使えるよう、 認証基盤をコンテナ化してGitHub Packages で配信 50
  24. Q. CookieStore じゃできないの? A. secret_key 依存があるので厳しい CookieStore ではセッション値を暗号化して設定するが、 このときの暗号化鍵が Rails

    の secret_key_base に依存している。 すでに稼働しているプロダクトの secret_key_base を共通化するのは厳しい。 RedisStore ではセッションID からRedis key を計算する際 ハッシュ関数しか使わないので、どのプロダクトで計算しても同じになる。 多分他のミドルウェア系SessionStore でも同じ感じのはず。 54
  25. Q. Rodauth どう? A. 慣れると結構良いがおすすめはしない めちゃくちゃ柔軟性が⾼くカスタム処理が書きやすいが、 独⾃DSL でかなり慣れが必要なうえ、 ドキュメントはRDoc メインで使い⽅例みたいなのはほとんどない。

    困ったら元コードを掘るパワーが必要。 あとAI との相性が死ぬほど悪い。 正直素直に広く使われているdevise とかを使うほうが AI のサポートを受けやすくて良いと思う。 55
  26. Q. 既存のIDaaS 使わないの? A. 検討したけどコスパや柔軟性でやめた 正直むずかしいのは認証というよりセッション同期 SAML 使うと⼀気に⾼くなり、⻑期的には独⾃でやるほうが良いと判断 実は Google

    Identity Platform を認証基盤化するつもりで⼀部採⽤してたが パスワードポリシーが弄れないなど⾊々しんどくて諦めた パスワードハッシュexport したらBcrypt じゃなく独⾃scrypt で 計算するのにC build が必要と⾔われてインポートを泣く泣く諦め ログイン時の都度migration に倒している 56