Help us understand the problem. What is going on with this article?

個人開発 Web アプリの認証周りを Auth0 に移行した

個人開発している Ruby on Rails アプリ LiveLog の認証周りを Auth0 に移行しました。
この記事では、移行に関して以下を紹介します。

  • 背景: なぜ移行したか
  • 方法: どうやって移行したか
  • 結果: 移行してどうだったか

背景: なぜ移行したか

LiveLog について

はじめに、対象の Web アプリについて簡単に紹介します。
LiveLog https://livelog.ku-unplugged.net/ は、私が所属していた軽音サークルのセットリスト管理アプリです。

2016年に CakePHP から Ruby on Rails にリプレースし、それ以来 Heroku Hobby Dyno + Heroku Postgres で稼働しています。
Ruby on Rails チュートリアル に沿って開発したので、認証周りは devise gem ではなく、Rails の has_secure_password を使って実装していました。

サークルのメンバーまたは OB・OG のみがユーザー登録でき、その登録数は2020年1月現在361です。
ユーザー登録は招待制で、既存のユーザーが LiveLog 上でメールアドレスを入力して新ユーザーを招待します。
ログインすると、外部に公開されていないライブ動画や音源を視聴できます。

ユーザー周りの課題

先日サークルの OB・OG 会に参加したとき、サークル員向けの Web アプリを新しくつくったという話を現役生から聞きました。
ただ、LiveLog とは別でユーザー管理しているので、以下のような課題があります。

  • サークルのメンバーはそれぞれのアプリでユーザー登録・ログイン・ユーザー情報の編集を行う必要があり面倒
  • サークル内アプリ間でユーザーを突き合わせできず、連携が困難

また、LiveLog 単体でもセキュリティに不安がありました。
LiveLog はメールアドレス等の個人情報を保持していますが、認証周りはチュートリアルに則った単純な実装になっています。
セキュリティホールがないとは言い切れず、もしもの際に個人情報漏洩のリスクがあります。
年々ユーザーが増えるに従って、このリスクを減らしたいという気持ちが強くなってきていました。

これらの課題を解決するため、LiveLog の認証周りを Auth0 へ移行することにしました。

なぜ Auth0 か

Auth0 https://auth0.com/ は、IDaaS と呼ばれる認証・認可周りの機能を提供するクラウドサービスのひとつです。
以下のような理由から Auth0 を利用することにしました。

方法: どうやって移行したか

移行の基本戦略は次のとおりです。
ここでいう認証情報は、メールアドレスとパスワードを指します。

  1. 認証情報の書き込み(招待・メールアドレス/パスワード変更)を止める
  2. 認証情報を Auth0 にインポートする
  3. 認証情報の読み込み(ログイン・メール送信)を Auth0 に切り替える
  4. 認証情報の書き込みを Auth0 に置き換えて再開する

移行中にデータの変更を止められるのは、身内向けの小規模アプリだからこそですね。
丁寧にやるなら、移行状態に応じてうまくハンドリングしながら並行して書き込みを行うような工夫が必要だと思います。
ただ、それは手間がかかるので、今回はサービスの機能を一部停止して一気にガッと切り替える方針を取ることにしました。

以下、それぞれについて詳しく説明します。

認証情報の書き込みを止める

書き込みを止めるのは、データのインポート後 Auth0 への切り替え前に更新が行われ、Auth0 のデータが古いままになるという事態を避けるためです。
また、移行中に何かあって切り戻す際にも、データの変更がないとわかっていれば安心して切り戻せます。

認証情報を Auth0 にインポートする

Auth0 へのデータ移行には主に次の2つの方法があります。

今回のケースでは、まず Bulk Import を行い、そこでインポートに失敗したユーザーのみ Automatic Migration で移行するという方法を取りました。

Bulk Import

認証情報のデータ移行で問題になるのが、パスワードのハッシュ化関数です。
移行前後のシステムで同じハッシュ化関数を用いていないと、パスワードの検証ができなくなります。
Auth0 の Bulk Import では、bcrypt でハッシュ化したパスワードしかインポートできないようになっています。
rails の has_secure_password は bcrypt を利用する実装になっていたので、この点は心配ありませんでした。

Bulk Import には次のようなバッチスクリプトを書きました。
色々と前提をすっ飛ばしてますが、雰囲気は伝わるかなと思います。

CONNECTION_ID = 'con_***'

auth0_client = Auth0Client.new(
  client_id: ENV['AUTH0_RUBY_CLIENT_ID'],
  client_secret: ENV['AUTH0_RUBY_CLIENT_SECRET'],
  domain: ENV['AUTH0_RUBY_DOMAIN'],
)

User.find_in_batches(batch_size: 50) do |users|
  puts "Processing users: #{users.map(&:id).join(',')}"

  users_json = users.map do |user|
    {
      user_id: user.id.to_s, # user_id must be string
      email: user.email,
      email_verified: true,
      password_hash: user.password_digest,
    }
  end.to_json

  # https://github.com/auth0/ruby-auth0/blob/79f5a27abe2f2f5d0b4624548e559669d1c99a40/spec/integration/lib/auth0/api/v2/api_jobs_spec.rb#L27-L28
  file_path = Rails.root.join("tmp/auth0_import_users_#{users.first.id}-#{users.last.id}.json")
  File.open(file_path, 'w+') { |file| file.write(users_json) }
  File.open(file_path, 'rb') do |file|
    job = auth0_client.import_users(file, CONNECTION_ID)
    puts "Created import job: #{job}"
  end

  sleep 1 # for rate limit
end

しかし、このスクリプトを実行したところ、一部のユーザーのインポートに失敗しました。
エラーメッセージはすべて同じです。

Error in passwdHash property - String does not match pattern ^\$2[ab]?\$10+\$[./A-Za-z0-9]{53}$

たしかに、失敗したユーザーの password_digest カラムを見ると $2a$12$... のようになっており、パターン中の 10 のところが 12 になっています。
ドキュメント をよく見ると、password_hash について以下の説明がありました。

Passwords should be hashed using bcrypt \$2a\$ or \$2b\$ and have 10 saltRounds.

saltRounds はハッシュ化時の計算コストを決めるパラメータです。
has_secure_password で利用している bcrypt gem は v3.1.13 でデフォルトのコストを10から12に上げており、LiveLog では 2019/6/8 に bcrypt gem を v3.1.13 に上げる変更をデプロイしていました。
このため、6/8 以降にユーザー登録またはパスワード変更を行ったユーザーだけ Auth0 にインポートできませんでした。

Automatic Migration

Automatic Migration は、個々のユーザーのログイン時にマイグレーションを行います。
Auth0 にデータがなければ既存のデータベースの情報を用いてログインを試み、成功すれば Auth0 にデータを保存するという方法です。
https://auth0.com/docs/users/concepts/overview-user-migration#automatic-migrations の図がわかりやすいです。
ユーザーが入力した平文のパスワードを利用して移行するので、bcrypt 以外のハッシュ化関数を用いていても移行できるのがポイントです。

LiveLog のデータベースにアクセスできるよう、Auth0 側でスクリプトを設定します。
以下は Auth0 で用意されている PostgreSQL 用のテンプレートを少し書き換えたものです。

function login(email, password, callback) {
  //this example uses the "pg" library
  //more info here: https://github.com/brianc/node-postgres

  const bcrypt = require('bcrypt');
  const postgres = require('pg');

  postgres.connect(configuration.DATABASE_URL, function (err, client, done) {
    if (err) return callback(err);

    const query = 'select id, email, password_digest from users where email = $1';
    client.query(query, [email], function (err, result) {
      // NOTE: always call `done()` here to close
      // the connection to the database
      done();

      if (err || result.rows.length === 0) return callback(err || new WrongUsernameOrPasswordError(email));

      const user = result.rows[0];

      bcrypt.compare(password, user.password, function (err, isValid) {
        if (err || !isValid) return callback(err || new WrongUsernameOrPasswordError(email));

        return callback(null, {
          user_id: user.id.toString(),
          email: user.email,
          email_verified: true
        });
      });
    });
  });
}

認証情報の読み込みを Auth0 に切り替える

ログイン・ログアウトは https://auth0.com/docs/quickstart/webapp/rails/01-login に沿って置き換えました。
ただし、ログイン中のユーザーに影響がないよう、ログイン成功時セッションに保存する内容は以前と同じままにします。
セッションにはもともと user_id しか入れていませんでした。

class Auth0Controller < ApplicationController
  # GET /auth/auth0/callback
  def callback
    auth = request.env['omniauth.auth']
    user = User.find(auth.uid.match(/auth0\|(?<id>\d+)/)[:id])
    user.activate! unless user.activated?
    session[:user_id] = user.id
    redirect_to root_path, notice: 'ログインしました'
  end
end

メールアドレスは Auth0 Management API を使って取得するようにします。
https://auth0.com/docs/api/management/v2#!/Users/get_users_by_id
マイグレーションが完了していないユーザーのため、Auth0 のユーザーが見つからなかった場合はデータベースから取得します。

class User < ApplicationRecord
  # ...
  def fetch_email
    $auth0_client.user("auth0|#{id}", fields: 'email')['email']
  rescue Auth0::NotFound
    email
  end
  # ...
end

認証情報の書き込みを Auth0 に置き換えて再開する

ここまで作業したあと、数日様子を見ました。
認証情報の変更が起こらない現状であれば、LiveLog と Auth0 とでデータに差分がありません。
そのため、何か不具合があった場合も、まだ容易に切り戻すことができます。
……のはずだったのですが、Auth0 側でパスワードリセットできることを考慮しておらず、気づいたときには一部のユーザーがすでにパスワードを変更していました。2
幸い、大きな不具合はなかったので、認証情報の書き込みも Auth0 に置き換えていくことにしました。

ユーザーの招待は、Auth0 ユーザーの作成とパスワードリセットを同時に行うことで実現しました。
パスワードリセットには、Authentication API を用いるものと Management API を用いるものがあります。

前者は、パスワードを忘れた場合等に Auth0 上でパスワードリセットの手続きを踏んだ場合と同じメールが即座に送信されます。
後者は、API を叩くとパスワードリセットに進む URL が返るので、それを使って独自のメールを送信できます。
以下のコード例では前者を用いています。

class InvitationsController < ApplicationController
  CONNECTION_NAME = 'Username-Password-Authentication'

  before_action :require_current_user

  # POST /users/:user_id/invitations
  def create
    @user = User.inactivated.find(params[:user_id])

    if @user.invitations.empty?
      $auth0_client.create_user(
        nil,
        connection: CONNECTION_NAME,
        user_id: @user.id.to_s,
        email: params[:email],
        password: SecureRandom.base58,
        verify_email: false,
      )
    else
      $auth0_client.patch_user(
        "auth0|#{@user.id}",
        email: params[:email],
        verify_email: false,
      )
    end

    $auth0_client.change_password(params[:email], nil) # send a change password email
    @user.invitasions.create!(inviter: current_user)

    redirect_to @user, notice: '招待しました'
  rescue Auth0::BadRequest => e
    @user.errors.add(:base, t("auth0.error.#{JSON.parse(e.message)['errorCode']}", default: '招待に失敗しました'))
    render :new
  end
end

メールアドレスの変更は Management API を使って行います。
https://auth0.com/docs/api/management/v2#!/Users/patch_users_by_id

結果: 移行してどうだったか

2019年1月現在、Automatic Migration は完了しておらず、移行のきっかけとなった複数アプリでの利用も実現していません。
まだまだこれから直面する問題や、気づいていない便利機能等があるかもしれませんが、現時点での変化について簡単に紹介します。
ただ、Auth0 を利用すること自体の利点は auth0.com や各種記事等で紹介されているので、ここでは個人的に意外だったポイントに焦点を絞ります。

認証周りの分離によるアプリケーションのシンプル化

認証周りの分離により、思っていた以上にコードやテーブル、カラムが削除できました。
Rails チュートリアルがそのほとんどを認証周りの実装に費やしていることを考えると、初期のコードの大部分を消し去れたと思います。
なかでも、users テーブルと User モデルの見通しがよくなったのは大きいです。
これらが整理された結果、LiveLog でフォーカスすべき機能がより明確になった感じがします。

ダッシュボードによる可視化

ダッシュボードでは、1日あたりのログイン回数が GitHub の草風の見た目でわかるようになっています。
https://auth0.com/docs/getting-started/dashboard-overview

manage_auth0_com_dashboard_us_patient-bar-7812__iPad_Pro_.png

無料プランでは2日分だけですが、ログイン成功・失敗やパスワード変更のログも保存されています。
移行直後は定期的にログ一覧をみて、ログインできないまま諦めているユーザーがいないか監視していました。

個人開発ではなかなかログやダッシュボードに手が回らないので、こうしたデータが見られるのは嬉しいですね。

開発環境の複雑化

仕方ないことではあるのですが、これがデメリットでしょうか。
開発環境用の Tenant を作成し、環境変数を設定しないとローカルで認証周りがまともに動きません。
一度設定してしまえば大したことはないのですが、最初の環境構築のハードルは上がりますね。

おわりに

以上、LiveLog における認証周りの Auth0 移行について紹介しました。

  • 個人開発の小規模 Ruby on Rails アプリケーション (not SPA)
  • ユーザー登録は招待制
  • ログイン方法はメールアドレス+パスワードのみ
  • 一部機能停止を伴う移行方法

という特殊な事例でしたが、参考になれば幸いです。


  1. Custom Domains が利用できないのは手痛いですが、メリットの方が大きいので妥協しました。 

  2. ユニバーサルログインの使用でログイン画面のドメインが ***.auth0.com になり、ユーザー側でブラウザのパスワード自動入力が効かなくなっていたため、少なくないユーザーがログインに失敗してパスワード変更していました。 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした