黒曜 / @kokuyouwind
Leaner Technologies Inc. 所属
Railsエンジニア・SRE
今はLeanerの認証基盤サービスを
作ってます
シンプルに Rails アプリを作る場合、
認証(ログイン)も1つの機能として実装
認証機能
プロダクト機能
ログイン
機能利用
複数のアプリがあるとそれぞれログインが必要
認証機能
プロダクトA機能
認証機能
プロダクトB機能
ログイン
機能利用
ログイン
機能利用
ユーザー課題
開発課題
個別ログインが面倒
パスワード管理が大変
アカウント管理も手間
ログイン機能を個別に開発するコスト
プロダクト間の連携が難しい
認証機能を単独のサービスに切り出す
認証機能
プロダクトB機能
ログイン
機能利用
機能利用
プロダクトA機能
認証基盤サービス
認証機能
プロダクトB機能
ログイン
機能利用
機能利用
プロダクトA機能
セッション管理
アクセストークン
セキュリティ
リフレッシュトークン
JWT
OIDC
SSO
ローカルストレージ
PKCE
nonce
state
IdP
SP
Auth0
Cognito
ActiveDirectory
SAML
SCIM
弊社の認証基盤で採用した
「ドメイン指定Cookie + 共有Redis」構成の
紹介をします
認証・認可・セッション管理
認証共通化のアーキテクチャ選択肢
Leanerの認証基盤サービス構成
各種Tips
まとめ
認証・認可・セッション管理
認証共通化のアーキテクチャ選択肢
Leanerの認証基盤サービス構成
各種Tips
まとめ
1. 私は黒曜です
荷物の受取をお願いします
2. 確かに正当な本人証明書でした
あなたは黒曜さんです(認証)
3. この荷物は黒曜さん宛なので
黒曜さんに渡してOK(認可)
1. 私は黒曜です
パスワードはxxxです
2. 確かに正しいパスワードでした
あなたは黒曜さんです(認証)
3. 毎回本人確認を行うと大変なので、
クッキーにあなたの情報をメモしました
クッキーの中身はサーバーからしか見えないので、
以降はこのクッキーを送ってください(セッションの記録)
4. ファイルにアクセスしたいです
クッキーはこれです
5. クッキーの中には「黒曜さん」が
記録されているので、
この人は黒曜さんです(セッションの復元)
6. リクエストされたファイルの持ち主は黒曜さんなので、
黒曜さんにこのファイルを送ってもOK(認可)
HTTPはステートレス
認証リクエストと、認可の必要なリクエストは独立
認証の結果をセッションに保存することで、
以降のリクエストの認可制御が行える
認証機能を切り出そうと考えると…
認可は切り分けて考えやすそう
セッション管理は密接に関連しそう
ここからは、
「認証結果をどうセッションに保存するか」に
フォーカスして掘り下げます
# 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
session['warden.user.user.key'] = [user.id, user.salt]
セッションにユーザーID(とsalt)を保存
# app/controllers/concerns/authentication.rb
module Authentication
def start_new_session_for(user)
user.sessions.create!.tap do |session|
Current.session = session
cookies.signed.permanent[:session_id] =
{ value: session.id, httponly: true, same_site: :lax }
end
end
def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id])
end
# ...
認証結果をSessionに保存し、
Cookieにsession.idを記録
CookieからSessionを復元
(Session.user でユーザーを取得できる)
基本的には User Model のidを
session[]= で保存するだけ
session[]
と session[]=
で読み書きできる
config.session_store
で保存方法を決定
デフォルトは CookieStore
暗号化してCookieに丸ごと保存
サーバー側にデータは持たない
ミドルウェア系Store(MemCacheStore, Redis::Store
など)
CookieにセッションIDのみを保存
サーバー側ミドルウェアでセッションIDをキーにデータを保存
私の情報はこの金庫に入ってます
鍵を使って中身を取り出しました
黒曜さんのuser.id が書いてあるので
この人は黒曜さんですね
私の情報は16番金庫に入ってます
16番金庫からメモを取り出しました
黒曜さんのuser.id が書いてあるので
この人は黒曜さんですね
認証は「本人確認」、認可は「権限確認」
認可は都度判断なので認証とは切り離せる
認証結果はセッションに保存する必要がある
セッションの保存方法は大まかに2種類
CookieStoreは暗号化して全部Cookieに入れる
ミドルウェア系Storeはサーバー側にデータを持って
取り出し用の鍵だけCookieに入れる
認証・認可・セッション管理
認証共通化のアーキテクチャ選択肢
Leanerの認証基盤サービス構成
各種Tips
まとめ
ずっと1プロダクトしか作らないならまず必要ない
分離するコスト・保守するコストがかかる
責務の分割だけなら Rails Engine 切り出しや
gemを適切に使えば十分そう
ユーザ層が被る複数プロダクトを提供するならほぼ必須
マルチプロダクト戦略・コンパウンド戦略
ユーザ視点・開発視点でメリットが多い
認証機能を単独のサービスに切り出す(再掲)
認証機能
プロダクトB機能
ログイン
機能利用
機能利用
プロダクトA機能
課題
ログイン
機能利用
認証した結果をどう渡す?
認証機能
プロダクトA機能
セッションをそれぞれで管理する?
なんらかの方法で共通化する?
案A. 認証結果だけ渡してセッション個別管理
ログイン
ログイン結果連携
機能利用
なんらかの手段で認証結果を渡す
認証機能
プロダクトA機能
セッションは各プロダクトで個別に管理
(それぞれのプロダクトのuser.idを保存)
(OIDCはセッション管理に責務を持たないのでこれ)
案B. 前段でセッションを集約管理
1. ログイン
3. 機能利用
認証機能
プロダクトA機能
(ALBでのCognito認証連携などはこれ)
2. 認証結果をセッションに記憶
4. 認証結果を取り出してプロダクトAに一緒に送信
案C. セッションを共有 (今回の本題)
1. ログイン
4. 機能利用
(鍵を渡す)
認証機能
プロダクトA機能
2. ユーザーIDを記録
3. 鍵を渡す
5. ユーザーIDを取得
(JWTトークンは鍵自体に情報を埋め込むが、原理はこれに近い)
認証情報をOIDCで受け渡す形にすると技術標準に乗れる
…が、事前にOAuthApplication払い出しが必要だったり
state, nonce, PKCEなどセキュリティ担保のための仕様が多く複雑
OIDCはサードパーティーにIDを提供する目的の仕様
自社サービスだけで使うのにほんとにそこまで必要?
前段で受けるのはインフラ構成の制約が大きい
セッションをうまく共有できれば良いのでは?
という方針でこの後の話をしていきます
認証・認可・セッション管理
認証共通化のアーキテクチャ選択肢
Leanerの認証基盤サービス構成
各種Tips
まとめ
1. ログイン
4. 機能利用
(Cookieも送信)
認証基盤
2. ユーザーsubを記録
(via. redis-session-store)
3. Cookieにsession_idを保存
5. ユーザーsubを取得
1. ログイン
4. 機能利用
(Cookieも送信)
認証基盤
2. ユーザーsubを記録
(via. redis-session-store)
3. Cookieにsession_idを保存
5. ユーザーsubを取得
認証: Rodauth + Rodauth Rails
セキュリティ強そうなのと拡張性高そうなので選定
パスワードログイン: Rodauth標準
SAMLログイン: ruby-saml で独自Featureを実装
セッション管理: redis-session-store
Sidekiq とか使うかなと思ってRedisを選定
# 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
# 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. ログイン
4. 機能利用
(Cookieも送信)
2. ユーザーsubを記録
(via. redis-session-store)
3. Cookieにsession_idを保存
5. ユーザーsubを取得
認証基盤
認証基盤
auth.leaner.app
mitsumori.leaner.app
認証基盤とプロダクトはURLが異なる
(後出しの制約)
Cookie は基本的に同一ドメインにしか送らないため、
認証基盤でセッションIDを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: {
# ...
}
Cookie に domain を指定すると、後方一致で送信するか決まる
こうすると auth.leaner.app と mitsumori.leaner.app の
両方に同じCookieを送信する
1. ログイン
4. 機能利用
(Cookieも送信)
認証基盤
2. ユーザーsubを記録
(via. redis-session-store)
3. Cookieにsession_idを保存
5. ユーザー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"
]
}
# app/controller/application_controller.rb
class ApplicationController < ActionController::API
def current_user
User.find_by(sub: session[:shared_sub])
end
end
1. ログイン
4. 機能利用
(Cookieも送信)
認証基盤
2. ユーザーsubを記録
(via. redis-session-store)
3. Cookieにsession_idを保存
5. ユーザーsubを取得
認証・認可・セッション管理
認証共通化のアーキテクチャ選択肢
Leanerの認証基盤サービス構成
各種Tips
まとめ
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
# leaner:session:2::b0db908bd1428e4638...
{
"shared_organization_sub": "M0qMtiOCrv5...",
"shared_sub": "ZiJkFfmqOzSuTtvMwHflJDxWD7h1",
"shared_product_licenses": [
"mitsumori"
],
...
}
# 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])
# app/controller/application_controller.rb
class ApplicationController < ActionController::API
# 認証基盤からユーザーを取得する
include LeanerAuthenticatable.create_module(
# ...
product_code: 'mitsumori',
# session[:shared_product_licenses]
# .member?('mitsumori')
# leaner:session:2::b0db908bd1428e4638...
{
"shared_organization_sub": "M0qMtiOCrv5...",
"shared_sub": "ZiJkFfmqOzSuTtvMwHflJDxWD7h1",
"shared_product_licenses": [
"mitsumori"
],
...
}
# leaner:session:2::b0db908bd1428e4638...
{
# session[:login_method] = "leaner_auth"
"mitsumori_login_method": "leaner_auth"
# shared_ から始まるキーはそのまま読み書きできる
"shared_sub": "ZiJkFfmqOzSuTtvMwHflJDxWD7h1",
...
# 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
GitHub Packagesで配信し、
プロダクト側のGemfileで依存バージョンを管理
プロダクト側のローカル開発時に使えるよう、
認証基盤をコンテナ化してGitHub Packagesで配信
# 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 # <= ???
ローカル開発でもCookieでセッション共有できるよう
minica を使って証明書を発行、 nginx でプロキシ
https://auth.leaner.localhost
https://mitsumori.leaner.localhost
w/Cookie
認証基盤
SetCookie domain: .leaner.localhost
認証基盤と各プロダクトでDBが異なるため、
subをキーとしてデータの同期が必要
(現在はプロダクト側にそれぞれユーザー管理がある)
情報変更時の都度同期のほか、一括同期タスクも作成
認証基盤
POST /internal/users/[:sub]
CookieStoreではセッション値を暗号化して設定するが、
このときの暗号化鍵が Rails の secret_key_base に依存している。
すでに稼働しているプロダクトの secret_key_base を共通化するのは厳しい。
RedisStoreではセッションIDからRedis keyを計算する際
ハッシュ関数しか使わないので、どのプロダクトで計算しても同じになる。
多分他のミドルウェア系SessionStoreでも同じ感じのはず。
めちゃくちゃ柔軟性が高くカスタム処理が書きやすいが、
独自DSLでかなり慣れが必要なうえ、
ドキュメントはRDocメインで使い方例みたいなのはほとんどない。
困ったら元コードを掘るパワーが必要。
あとAIとの相性が死ぬほど悪い。
正直素直に広く使われているdeviseとかを使うほうが
AIのサポートを受けやすくて良いと思う。
正直むずかしいのは認証というよりセッション同期
SAML使うと一気に高くなり、長期的には独自でやるほうが良いと判断
実は Google Identity Platformを認証基盤化するつもりで一部採用してたが
パスワードポリシーが弄れないなど色々しんどくて諦めた
パスワードハッシュexportしたらBcryptじゃなく独自scryptで
計算するのにC buildが必要と言われてインポートを泣く泣く諦め
ログイン時の都度migrationに倒している
認証・認可・セッション管理
認証共通化のアーキテクチャ選択肢
Leanerの認証基盤サービス構成
各種Tips
まとめ
マルチプロダクト戦略だと認証基盤サービスが欲しくなる
実現方法はいろいろある
認証結果の連携はOIDCが技術標準だが、
サードパーティーを考慮していて仕様が複雑
自社サービス内だけならセッション共有がシンプルでは
Cookieを親レベルにすればRailsの設定レベルで実現できる
共通ドメインが必要など制約も多いので、ハマる局面は限られる
これが良いやり方か自分もわからないので、色々話しましょう!