エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Rails の認証機能を JWT を使ったマイクロサービスに切り出したった

この記事はエムスリー Advent Calendar 2020 3日目の記事です。まだまだ面白い記事が続々投稿されるので、ぜひぜひ読んでみてください!

エムスリーエンジニアリンググループの青木(@blue_1617)です。 日々「エムスリーデジカル」を作ったり YouTube 見たりしてます。最近は「ピクミン3」をクリアしました。

さて、今回は Rails で認証マイクロサービスを作った話をします。 ちなみにサービスの名前は Gatekeeper です。かっこいいでしょう??

モチベーション

事業が拡大するにあたり RDS の負荷や Blast Radius の拡大が懸念されてきたため、シャーディングを実施することにしました。
今回のシャーディングは、もともとあった RDS とアプリ群をまるごと各シャードに配置するイメージです。すると、個々のシャード内には認証機能を備えた Rails アプリが構えているので、シャードに閉じたユーザーに関しては気にすることはあまりないです。

一方、問題になるのは全シャードを跨ぐことができるシステム管理者などです。

具体的には

  • システム管理者はシャードごとに Password などを管理しないとならない
  • エムスリーデジカルでは各シャードの振り分けにクライアント証明書を使っているので、シャードを行き来するために証明書を N 個管理する必要がある
  • 認証を各シャード内の Rails アプリが実施すると各シャードの DB でデータが個別にできてしまい、全シャード間で "整合性" を保って管理する必要がある

などなどの問題があります。

これらを解決するために、各シャードの外側にシステム管理者用の認証マイクロサービスを作成する必要がでてきました。

システム概略

f:id:blue0513:20201201205226p:plain
システムの概略図

上図にシステムの概略図を示します。

システム管理者がログインして、あるシャードにアクセスする流れは以下です。

  1. システム管理者は一般ユーザーとは異なるドメインにアクセスする
  2. 社内 IP アドレス制限を突破する(VPN 経由)
  3. ID, Password 認証を突破する
  4. GUI でアクセスしたいシャードを選択する
  5. JWT が払い出される
  6. JWT の内容を元に認証 & シャードへ振り分けを実施
  7. JWT の内容を元にシャード内の DB で find_or_create 相当の処理
  8. シャード内でシステム管理者として振る舞える

この仕組みによって、システム管理者は自身の ID, Password を一度入力するだけでどのシャードにもアクセスできます。

また、システム管理者のデータは Gatekeeper 内の DB が正となり、シャードの DB たちは Gatekeeper の DB(正確にはそこから生成された JWT)の内容を信用するだけでよくなりました。

もうちょっとだけ説明加えます。

Rails 6系の認証サーバー

シャード内の Rails アプリ(5系)に先立って、Gatekeeper は Rails 6系で作りました。

Rails 6系 では webpacker が導入されたり、autoloading の仕組みとして Zeitwerk が採用されたり変更点がそこそこありました。 シャード内のアプリ本体ではなく、マイクロサービスで先に技術検証などを済ませてしまえたので、本体の 5系 => 6系 もやりやすくなりました。

ちなみにエムスリーデジカルの Rails 5系への移行はこの話が面白いです。 www.m3tech.blog

試行錯誤の時の誰か氏

JWT を使った認証機構

JWT についての説明は ここ を読んでいただくとして、今回はどういった使い方をしたか紹介します。

まず ruby-jwt を使って下記のように JWT を生成します。

ALGORITHM = 'ES512'

# JWT 生成
def generate_jwt(payload)
  target_payload = payload.merge({ iss: env_jwt_iss, exp: env_jwt_expire })
  JWT.encode(target_payload, ecdsa_key, ALGORITHM)
end

payload の中には下記のように、システム管理者の情報が詰まっています。 これを cookies に埋め込むことでシャード内のアプリが読み取れるようになります。

ちなみに、JWT はあくまで Gatekeeper の認証結果を各シャードのアプリに渡すためだけに使っており、セッション保持やログアウトの処理はすべて各シャード内の Rails アプリ側が実施します。そのため、ここでの expires は短く(1時間とか)設定されてます。認証処理は Gatekeeper で、セッション管理は各シャードの Rails という役割分担ですね。

# JWT に含める認証用の cookie
def update_auth_cookie_of_digikar_user(user:, shard_id:)
  payload = {
    digikar_user: {
      id: user.id,
      name: user.name,
      email: user.email
    },
    shard_id: shard_id
  }

  cookies[cookie_name] = {
    value: JwtService.generate_jwt(payload),
    expires: cookies_expire_at,
    httponly: true,
    secure: Rails.env.production?,
    domain: domain
  }
end

実際にシャード内の Rails アプリが JWT をデコードする処理。公開鍵は KMS 管理にしてます。

ALGORITHM = 'ES512'

# cookies[cookie_name] として cookies から jwt が取得できる
def decode_jwt(jwt)
  JWT.decode(jwt, ecdsa_public, true, iss: iss, verify_iss: true, algorithm: ALGORITHM)
end

デコードした内容をもとにシャード内の DB でシステム管理者を find_or_create します。例外を吐き得るので、実際のコードでは rescue 処理などもしてます。

def authenticate_by_cookie(jwt:)
  # デコードした JWT の内容を検証
  decoded_jwt = JwtService.decode_jwt(jwt)
  validate_jwt_info!(decoded_jwt)

  # JWT からデータを parse してシステム管理者を DB から探す
  admin_user_info = decoded_jwt&.first&.dig(JWT_USER_INFO_KEY)
  authenticated_user = find_with_check_admin!(id: admin_user_info['id'], email: admin_user_info['email'])  
  return authenticated_user if authenticated_user.present?

  # DB に JWT に含まれたシステム管理者が存在しなければ作る
  create_by_jwt!(admin_user_info)
end

以上の処理を経て、晴れてシャードに Gatekeeper 経由でログインできました!

まとめ

システム管理者の認証機構を JWT を使った Rails 6系のマイクロサービスに切り出しました! これによってシステム管理者のログイン導線を簡略化でき、システムの複雑性も解消できました! やったね!

We are Hiring!!!

エムスリーはギークでオタクでグレートなエンジニアを絶賛募集しています!

ちなみに、自分が所属する「エムスリーデジカル」チームは12/14(月) 19:00~ に採用説明会を行います! その後は親睦会もあるのでエムスリーのことが気になるそこのあなたもぜひいらしてください!

m3-engineer.connpass.com

カジュアル面談もカジュアルに開いております!

jobs.m3.com