パーフェクトRails著者が解説するdeviseの現代的なユーザー認証のモデル構成について

最近、パーフェクトRuby on Railsの増補改訂版をリリースさせていただいた身なので、久しぶりにRailsについて書いてみようと思う。 まあ、書籍の宣伝みたいなものです。

数日前に、noteというサービスでWebフロント側に投稿者のIPアドレスが露出するという漏洩事故が起きました。これがどれぐらい問題かは一旦置いておいて、何故こういうことになるのか、そしてRailsでよく使われるdeviseという認証機構作成ライブラリのより良い使い方について話をしていきます。 (noteがRailsを使っているか、ここで話をするdeviseを採用しているかは定かではないので、ここから先の話はその事故とは直接関係ありません。Railsだったとしても恐らく使ってないか変な使い方してると思うんですが、理由は後述)

何故こんなことが起きるのか

そもそも、フロント側に何故IPアドレスを送ってんだ、という話ですが、いくつかのブログ記事に書かれている様に、ActiveRecordがUserレコードに詰め込まれている色々な情報をSELECT * FROM usersで引っ張ってきてしまって、json変換する時に参照してしまってフロントに漏れたんでは、というのは想像できる話ですね。

Railsというフレームワークデファクトとして使われている認証機構構築ライブラリのdeviseは、とにかく色々な機能が組込まれており設定と簡単なDSLでパスワード認証、メールアドレス認証、パスワードリセット、ログイントラック等ができる様になります。

しかし、これを何も考えずにざっと作ったUserモデルでポコポコ有効化していくと、上記の様な問題に繋がる可能性はあります。

ただ私としては、こういう事情があったからといって別にDeviseを使うべきではない、とまでは思いません。というか改めて確認してみましたが、Devise自体はこの問題についてはちゃんと対処済みです。具体的にはdeviseのモジュールをincludeしたら時点でserializable_hashが上書きされ、to_jsonとas_jsonからdevise関連のカラムを除外する様になってます。(ソース)

むしろ、Deviseの構成元にして半端にパクったものを自作する方が危ない例の一つと言えますね。まあ、どっちにしてもto_jsonをそのまま渡すのは止めた方が良いやり方であることは間違いないです。Deviseの防御機構もこれで安心か、と言われると色々と説明が無さ過ぎて何とも言えないところです。READMEに書いてるRailsの知識が全然無いなら使わない方が良いよ、というのはこういう所に現れているのかなという感じです。

結局のところ、これはRailsがどういうフレームワークとして成り立ってきたかということと現代的なWebアプリケーションの実装におけるモデル構造とが乖離してきており、現代的なモデル構成が取れていないという点に問題の根幹があるからで、別にDeviseを使おうが使わなかろうが、同じ様なモデル構成を取って雑にフロントに渡せば同じ事故が起き易くなります。

私見ですが、Deviseが採用している基本形であるUserモデルに認証に付随する様々な機能をぶち込むというのは、現代において余り良い方法ではないし、それを参考にして半端にパクった認証機構を作ると尚のこと危険である、と思っています。(とにかくdeviseは初心者向けのサンプルが悪い)

Railsというフレームワークはv1.0から数えても既に15年近い歴史があり、充分な老舗フレームワークです。そして、Railsは現代においても充分に便利で強力なフレームワークですが、JSの扱いにおいては現代の潮流とは割と異なった流れに乗っています。

SPAというものを余り重視しておらず、現代においても基本的にはRailsはHTMLを出力するフレームワークという立ち位置が基本だと思っています。そういうフレームワークなので先進的なJSコミュニティからの評判は余り良くないようです。

HTMLを出力する場合、HTMLテンプレートのそれぞれの箇所に「この情報を出すぞ」とバックエンドも触っている人が明確に決めて記述するので、あるモデルに必要ない情報が混じっててもサーバーの向こう側に届くことはありません。

一方でSPAの様にフロントサイドでレンダリングしてDOMを弄くりまわす様な構成のアプリケーションの場合、フロントエンドとバックエンドで開発の専門性が異なるケースがしばしばあるため、バックエンドからまとめて情報を渡して後はフロントエンドでよしなに扱ってくれ、という方向に意識が向く場合があります。

そうすると、昔ながらのRailsの様に何でもかんでもUserに突っ込んでActiveRecordでよしなにやる、みたいな考え方でモデルを構築していくと、事故る訳です。

Deviseも10年近い歴史があって、そういう所からスタートしているので、昔からの情報やREADMEにある情報だけでは、現代的なWebアプリケーションと合致しない箇所がしばしば出てきていると思います。

そもそもSPAは難しいものです。サーバーサイドという自分達の環境に情報を閉じ込めておけないので、サーバーサイドでデータを取り扱うのに比べて、何を持つべきで何を持つべきでないのかを遥かにセンシティブに考える必要があります。

防衛策

じゃあ、どうやってこういったことを防ぐと良いのか。サーバーサイド側で責任を持つなら、必ず明示的にattributeを列挙するシリアライザかjbuilderの様なjson builderを通して、暗黙的なデータ渡しを行わない様にすることが基本になります。render json: @hogeは止めましょう。まあ、固定でエラーメッセージを返すエラーハンドラとかなら問題無いと思いますが、余計なお節介としてエラーになったオブジェクトの情報も付けとくか、とか思ってしまうと死ぬので気は使いましょう。

フロントサイドでも明示的に必要な情報だけをくれ、とサーバーサイドに要求することは出来ます。それにはGraphQLが使えます。GraphQLではフロント側から明示的に必要な属性値を列挙してリクエストを行うため余計な情報を取得してしまう可能性を下げることができます。フロントとバックエンドで開発体制を分業しつつ、安全性と柔軟性のバランスを取った仕組みと言えます。

そして、そもそも余計な情報をUserに持たせない様にモデルを作っておくという方法も取れます。人間書いてればうっかりしたりサボったりするもので、何事も見過ごす可能性があるもので、最初に防御的に作っておけばそういった時に事故を防げる可能性が上がります。Railsは良くも悪くもRDBと密結合したフレームワークなので、まずテーブル構成からしっかりやっておけば、それなりに安全性が上がります。

Deviseでこういうデータベース構造を取ろうとすると多少の工夫が必要になるのが残念なところですが、そもそもテンプレのまま作れるユーザー認証基盤というのはお試しアプリぐらいなものなので、割り切って多少コントローラーとviewを弄るぐらい普通だと考えた方が良いんじゃないかと思います。ログインを要求するモデルはUserに限らずAdministratorとかOperatorとか色々あったりするので、コントローラーとviewをdeviseから分離して弄るのは、実務の場合ほぼ必須なので。

じゃあ、どういう風に弄るのかという例を紹介していこうと思います。

ユーザー認証関連のテーブル設計

昔はただユーザーIDとパスワードで認証できればそれで良かったのですが、昨今のWebアプリケーションでは遥かに多くのことが必須になっています。現代のユーザー認証において求められる機能や前提は以下の様なものになります。

  • 古典的なパスワード認証
  • Twitter/Google等の各種サービスアカウントを利用した認証
  • APIのためのトークン認証
  • メールアドレスの存在確認
  • パスワードリセット
  • 2FA
  • アカウントロック

これらを都度自作するのは、非常に大変です。認証だけなら割と何とかなりますが、メールアドレスの存在確認やパスワードリセット等は、ユーザー側からの要求を受け付けるビューやエントリポイントがあって、更にそれらの有効期限管理等が絡んでくるため、サーバサイドだけで話が完結しないし状態遷移が長時間に渡って継続します。Deviseが使われる理由は主にこの辺りにあります。

という訳で、Deviseを使って工数を削減したいが出来るだけ安全に使いたい場合にどうすれば良いのか、というと話は単純で機能毎にテーブルを分けた方が良いだろう、と私は考えています。

認証部分のテーブル分割の例

Userというその他のリソースと関連するモデルに持たせるのは、そのアカウントとしてログインしているならほぼ常に必要とするデータだけを持たせる様にしましょう。ID、ログインネーム、メールアドレスのみぐらいが良いですね。 そして、認証機構毎にテーブルを分けます。例えば以下の様なモデルを作ります。

  • User::DatabaseAuthentication
  • User::TwitterAuthentication
  • User::GoogleAuthentication
  • User::ApiTokenAuthentication

(User名前空間に作ってますが、定数の扱いで事故る可能性もあるのでUser prefixとして作っても良い。管理のしやすい方で)

こうすることで、特定の認証を必要としないユーザーはレコード自体を作らないで済みますし、必要なユーザーのカラムはほぼ全てNOT NULLにできます。

もし、後から2FAに対応してくれ、という要求があったとしても、新しくテーブルを作れば対応できます。既存のモデルに与える影響は小さくできるでしょう。

登録前確認やリセットやロックについての分割の例

登録前確認やアカウントロックの場合は以下の様なモデルを作ることになるでしょう。

  • User::Registration
  • User::AccountLocking
  • User::PasswordResetRequest

この様にテーブルを分けるのは、RESTfulなエントリポイントを作る上でも親和性があり、デフォルトでエントリポイント毎に必要な情報のみ受け渡す様に制限されます。 また、RDBのレコードを頻繁にアップデートしたり、NULLABLEなカラムが多数存在するのは状態管理や異常データの検知を複雑化させるので、出来る限り避けたいところですが、こういったテーブル構成を取ることでレコードがあるか無いかと、一つか二つ程度のカラムの整合性を確認するだけで正常かどうかを判断できる様になります。 レコードのvalidationを行う時には、境界を明確に分けて組み合わせが爆発しない様にコントロールすることが重要になります。

例えば、登録プロセスの途中でメールアドレスの存在確認を行い確認出来た場合にのみ、アカウントを利用可能にしたい場合、ユーザーの登録途中、という状態をUserモデルに持たせない様にし、ユーザー登録という行為自体を事実として記録します。こうすることで、中途半端な状態のUserレコードや関連リソースを作らずに済みます。

この様に機能毎に単一目的のモデルにしておくと、これらのレコードは用事が終わった時点で完全に削除することができます。そもそも一時的な情報でしかないので、最悪何らかの事故や実装上の問題によってテーブルごと作り直しても致命的な問題になり辛くなります。

もし機能の必要性が無くなれば、モデルと関連しているコントローラーごと消せば済むし、後から必要になってもUser本体へのmigrationが不要です。

Userというとにかく肥大化しがちで絶対に失ってはいけないレコードに、一時的な要求でしか使わない情報を持たせるのはほとんどの場合で悪手でしょう。

サンプルコード

一応、User, User::DatabaseAuthentication, User::Registrationとモデルを分割しUserモデルを極限までコンパクトにした動くサンプルを用意してみました。3時間ぐらいで作ったので登録とログイン回り以外は張りぼてだし、もっと調整する所色々あると思いますが、まあどうやって弄るのかの参考ぐらいにはなると思います。

github.com

各テーブルのカラムはdeviseが必要とするものと名前だけという最小構成です。

class DeviseCreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      t.string :nickname, null: false

      t.timestamps null: false
    end

    add_index :users, :nickname, unique: true
  end
end
class DeviseCreateUserDatabaseAuthentications < ActiveRecord::Migration[6.0]
  def change
    create_table :user_database_authentications do |t|
      t.references :user, foreign_key: true, dependent: :destroy,  index: {unique: true}
      ## Database authenticatable
      t.string :email,              null: false
      t.string :encrypted_password, null: false

      t.timestamps null: false
    end

    add_index :user_database_authentications, :email, unique: true
  end
end
class DeviseCreateUserRegistrations < ActiveRecord::Migration[6.0]
  def change
    create_table :user_registrations do |t|
      ## Confirmable
      t.string   :confirmation_token,  null: false
      t.datetime :confirmed_at
      t.datetime :confirmation_sent_at
      t.string   :unconfirmed_email
      t.string   :email

      t.timestamps null: false
    end

    add_index :user_registrations, :confirmation_token, unique: true
    add_index :user_registrations, :unconfirmed_email, unique: true
  end
end

モデルはこんな感じ。

class User < ApplicationRecord
  devise :authenticatable

  has_one :database_authentication
end
class User::DatabaseAuthentication < ApplicationRecord
  belongs_to :user
  devise :database_authenticatable, :validatable
end
class User::Registration < ApplicationRecord
  devise :confirmable
end

何故Userを:authenticatableにしているかというと、もし複数の認証基盤に対応することになった場合、database_authenticationのsign_inが行われていなくてもuserという大本の方でsession管理する方向に修正できる様にしてあります。

で、次にコントローラーを多少カスタマイズします。

デフォルトの挙動ではUserが先に作られていることを前提にしているので、registrations#createのsuperを呼ぶ前にregistrationが無ければ作成する様にしておきます。後は、renderするテンプレートやredirect先を適宜調整します。

この時点でaction_mailerからメールが飛び、トークン付きのURLが送信されます。(development環境ならデフォルトで本文がコンソールに表示されます)

デフォルトのconfirmations_controllerは確認が取れたら、ログイン画面にredirectしますが、controllerを少しだけ上書きし普通にshowテンプレートをレンダリングする様にします。

コードは以下の様になる。

class User::RegistrationsController < Devise::ConfirmationsController
  # GET /resource/confirmation/new
  # def new
  #   super
  # end

  # POST /resource/confirmation
  def create
    user_registration = User::Registration.find_or_initialize_by(unconfirmed_email: params[:registration][:email])
    if user_registration.save
      super do
        flash[:notice] = "Sending an email confirmation instruction"
        return render :create
      end
    else
      respond_with(user_registration)
    end
  end

  # GET /resource/confirmation?confirmation_token=abcdef
  def show
    super do
      @user = User.new
      @user_database_authentication = User::DatabaseAuthentication.new
      return render :show
    end
  end
end

そして、showのテンプレートに本登録に必要なパスワード登録等のformを記述します。 (面倒なので省略)

このフォーム画面は、有効期限内のメールアドレス認証トークンを持っている場合のみアクセス可能です。

後はフォームの受け取り先で、transactionを使ってUserとUser::DatabaseAuthenticationを登録します。UserはただのIDの箱みたいなものなので何も気にせず作ることができます。User::DatabaseAuthenticationもモデルがsaveされる時にdeviseが良しなにやるので、普通にモデルをnewしてsaveすれば問題ありません。(自分でソースコードは読んでおきましょう)

コードで書くとこんな感じ。

class User::RegistrationsController < Devise::ConfirmationsController
  def finish
    self.resource = resource_class.confirm_by_token(params[:confirmation_token])
    ActiveRecord::Base.transaction do
      @user = User.new(nickname: params[:nickname])
      @user_database_authentication = User::DatabaseAuthentication.new(user: @user, email: params[:email], password: params[:password], password_confirmation: params[:password_confirmation])
      @user.save!
      @user_database_authentication.save!
      self.resource.destroy!
    end

    sign_in(:user, @user)
    sign_in(:database_authentication, @user_database_authentication)

    redirect_to posts_path
  rescue
    render :show
  end
end

丁寧にやるなら、showで表示するformのためにFormクラスを作っても良いでしょう。

後、Deviseはroutingの設定が無いとDevise.mappingsに登録されず組込みのコントローラーを使えなくなってしまうので、routes.rbも設定しておかないと駄目です。

Rails.application.routes.draw do
  devise_for :user, skip: :all
  devise_for :database_authentications, class_name: "User::DatabaseAuthentication", controllers: {
    sessions: 'user/database_authentication/sessions'
  }
  devise_for :registrations, class_name: "User::Registration", controllers: {
    confirmations: 'user/registrations'
  }
  devise_scope :registration do
    post "/registration/finish", to: "user/registrations#finish",  as: "finish_user_registration"
  end
  devise_for :users
  resources :posts
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
end

とまあ、こんな感じで調整していけば、そんなに魔術的じゃない感じでdeviseの機能を利用できます。モデルのモジュールとcontrollerのソースコードは追えないと駄目ですが、利用に際した前提条件と言えます。(wardenは流石に深く突っ込まなくても大丈夫だと思うけど)

コントローラー名とかURLとかもうちょっとどうにかしようがありますが、とりあえずこの様にして不要な情報をUserから引っ剥がしていくことは出来ます。

認証機構って実はあんまり作る機会が無いんですよね。最初から必要なのでプロジェクトの立ち上げ期に参画してないと余り触らない。しかも後からモデル構造弄るのがめちゃくちゃ大変になるので、微妙な作りになっててもほとんど修正されないことが多いし。

という訳で、この機会にユーザー認証のモデル構造がどうなってると良いのか考え直して、素振りしてみても良いんじゃないでしょうか。 (正直、deviseどころか久しぶりにRails触ったんで、routingの設定とかマジ分からんってなってた……)