Slack風、Rails によるファストユーザスイッチの実装方法 (Devise 未使用)

この記事は 高知工科大 Advent Calendar 2016 の 14 日目の記事です。

概要

趣味で作っている Rails 製小規模向けグループウェアに、ファストユーザスイッチを実装した話。アカウント登録・認証は Devise を使ってサクッと作ることが多いけど、Devise のカスタマイズで心をすり減らしたくなかったので、Devise なしで一から実装してみた。

今回、Devise を使わないセッション管理のベタ実装には、以下を参考にした。この記事の範囲外の細かい話は、Rails Tutorial を見てほしい。

railstutorial.jp

利用技術

ファストユーザスイッチ?

用語が正しいかどうか分からないけど、Slack や Google のサービスは、アカウント間をログアウト不要で素早く切り替えることができる。

例えば Google なら、ヘッダーのアカウントアイコンを選択して、切り替えるアカウントを選べる。2個以上の Google アカウントを持っている人なら、見たことがあると思う。
Slack なら、チームアイコンをクリックすることで、素早くチーム間を行き来できる。

Google f:id:nyamadori:20161213203224p:plain:w300 / Slack f:id:nyamadori:20161213203736p:plain

要件

グループウェアを作っているので、グループの概念を持った Slack を参考にして要件を立てた。

  • アカウント認証には、サインインするグループID (slug)、グループに登録したアカウントの Email アドレス (email)、パスワード (password) が必要
  • グループごとにアカウントで使用するメールアドレスを変えることができる

以上の要件を反映させたのが、次の ER 図*1

f:id:nyamadori:20161213201403p:plain

実装

実装上、細々とした処理がいくつかあるものの、重要な部分を抜き出すと次のようになる。以降、太字で示したクラスについて説明していく。

  • サインイン時の処理
    1. サインインページで認証に必要な情報 (group slug, email, password) を入力
    2. サインインのリクエストを受け付ける
      [SessionsController]
    3. フォームのバリデーションと、フォームの値を元にアカウントを認証
      [Session モデル]
    4. 認証に成功したら、セッション一覧に新しいセッションを追加し、現在のセッションとする
      [Authentication クラス]
    5. グループページにリダイレクト
  • サインアウト時の処理
    1. サインアウトボタンをクリック
    2. サインインのリクエストを受け付ける
      [SessionsController]
    3. 認証に成功したら、セッション一覧から現在のセッションを削除
      [Authentication クラス]
    4. グループページにリダイレクト
  • サインイン後のセッション維持
    1. Rails アプリケーション内のとあるページに遷移する
    2. リクエストに紐づくセッションを用いてアカウントを認証 [Authentication クラス]
    3. 認証に成功したら、リクエストしたページにそのまま遷移

SessionsController

サインイン・サインアウトのリクエストを受け付けるコントローラ。 具体的な認証処理は Session モデル、Authentication concern にまかせているため、SessionsController は大した処理をやっていないが、 全体の処理の流れが理解しやすいので、以下にソースコードを示す。

class SessionsController < ApplicationController
  def new
    @session = Session.new
  end

  def create
    @session = Session.new(session_params)

    if @session.valid? # `session_params` で渡されたフォームの値のバリデーションと、認証を行う。
      sign_in(@session) # `@session` を元に、新しいセッションを生成
      redirect_to group_path(current_group.slug) # グループページにリダイレクト
    else
      render :new
    end
  end

  def destroy
    sign_out(current_session) # セッションを破棄

    if current_group
      redirect_to group_path(current_group.slug)
    else
      redirect_to new_session_path
    end
  end

  private

  def session_params
    params.require(:session).permit(:email, :password, :group_slug)
  end
end

Session モデル

Session モデルは、ActiveModel::Model を include したクラスになっていて、フォームのバリデーションと、フォームの値を元にアカウント認証を行うのが責務。 アカウント認証はモデルバリデーションとして扱っており、認証が成功すると、該当の Account と Group のレコードをそれぞれ group, account に格納する。これらのレコードは、セッション管理に利用する。認証が失敗すると #errors にエラーメッセージが格納される。

参考元の Rails Tutorial にはないモデルだけど、このモデルが合ったほうが SessionsController がよりスッキリするので、作ることにした ((コントローラで Session モデルのオブジェクトの変数名に session をつけると、元の session オブジェクトが使えなくなってしまうので、注意が必要。名前大事))。

class Session
  include ActiveModel::Model

  attr_accessor :email, :password, :group_slug, :group, :account

  with_options unless: :authenticated? do |auth|
    # 認証が完了している場合は、以下のバリデーションを行わない
    auth.validates :email, presence: true
    auth.validates :password, presence: true
    auth.validates :group_slug, presence: true
  end

  validate :authenticate, if: -> { errors.size.zero? }

  def authenticate
    @group = Group.find_by(slug: group_slug)

    if @group
      @account = @group.accounts.find_by(email: email)

      unless @account&.authenticate(password)
        errors.add(
          :base, :email_or_password_invalid,
          message: 'Invalid Email or Password'
        )
      end
    else
      errors.add(:base, :no_group, message: "No group for #{group_slug}")
    end
  end

  def authenticated?
    !!(group && account) # group と account の両方が非 nil => 認証が完了
  end
end

Authentication クラス

Authentication クラスの役割は、以下の通り。

  • Session モデルオブジェクトを元に、新たなセッションを作成する (Authentication#sign_in)
  • リクエストに紐づくセッションを読み込み、ユーザを認証し、セッションを維持する (Authentication#authenticate!)
  • Session モデルオブジェクトに対応する、セッションを削除する (Authentication#sign_out)

Authentication#authenticate! は、ApplicationControllerbefore_action コールバックで呼び出され、アプリケーション内のページ遷移ごとに実行される。

ファストユーザスイッチのためのセッション管理

ファストユーザスイッチでは、複数のセッションを同時に管理する必要がある。これは、CookieStore に複数のセッションを格納することで実現できる *2 。セッションには以下のようなデータを格納する。

# 現在ログイン中のセッションの配列
session[:sessions] = [
  # 各ハッシュは、Session モデルオブジェクトを元に作られる
  {
    'account' => 2, # アカウントID
    'group_slug' => 'xxx-group' # グループslug
  },
  {
    'account' => 3,
    'group_slug' => 'yyy-circle'
  }
  # ...
]

# session[:sessions] のインデックス。xxx-group にログインしていることを表す
session[:current_session] = 0

session[:sessions] はログイン中のセッション一覧を保持する。Authentication#authenticate! メソッドは、このようなセッションデータを元に、アクセスしてきたユーザを認証する。セッション一覧にないグループにアクセスしようとした場合や、ログイン中にアカウントがグループのメンバーから外された場合は、ログインページへリダイレクトする処理を行う。

Authentication#sign_inAuthentication#sign_out メソッドは、それぞれログイン・ログアウト時にセッションの追加・削除を行う。

以下が Authentication クラスのソースコード。少しに長くなってしまったが、重要なのは前半の Authentication#authenticate!Authentication#sign_inAuthentication#sign_out の 3 つのメソッド。

concern :Authentication do
  included do
    # メソッドをビューヘルパーとして使えるようにする
    helper_method :sign_in_accounts, :sign_in_groups,
                  :current_group, :current_account,
                  :sign_in_any?

    private

    def authenticate!
      initial_session_data
      return unless group_slug # パスに group_slug が含まれない場合は、認証処理を行わない

      cs = session_for(group_slug) # group_slug から Session モデルオブジェクトを取得する

      if cs&.group.member?(cs.account) # アカウントがグループのメンバーかどうかを判定
        session[:current_session] = session_index_for(group_slug)
      else
        sign_out(cs) # グループのメンバーから外された場合は、残っているグループのセッションを削除
        redirect_to new_session_path
      end
    end

    def initial_session_data
      session[:sessions] ||= []
      session[:current_session] ||= -1
    end

    def sign_in(s)
      return if signed_in?(s)

      ## 新しいセッションを追加
      session[:sessions] << {
        'account' => s.account.id,
        'group_slug' => s.group.slug
      }
      session[:current_session] = session[:sessions].size - 1

      # clear cache: sign_in_* メソッドの呼び出し結果を最新の状態にするため、キャッシュをクリア
      @sign_in_sessions = nil
      @sign_in_accounts = nil
      @sign_in_groups = nil
    end

    def sign_out(s)
      # セッションを破棄
      session[:sessions].delete_if { |g| g['group_slug'] == s.group.slug }

      if session[:current_session].to_i >= session[:sessions].size
        session[:current_session] = session[:sessions].size - 1
      end

      # clear cache
      @sign_in_sessions = nil
      @sign_in_accounts = nil
      @sign_in_groups = nil
    end

    def signed_in?(s)
      sign_in_groups.include?(s.group)
    end

    def sign_in_sessions
      @sign_in_sessions ||= session[:sessions].map do |s| # s のキーは文字列なので、気をつける
        Session.new(
          account: Account.find_by(id: s['account']),
          group: Group.find_by(slug: s['group_slug'])
        )
      end
    end

    def sign_in_accounts
      @sign_in_accounts ||= sign_in_sessions.map(&:account)
    end

    def current_account
      current_session&.account
    end

    def current_session_index
      session[:current_session].to_i
    end

    def current_session
      sign_in_sessions[current_session_index]
    end

    def session_index_for(group_slug)
      sign_in_sessions.index { |s| s.group.slug == group_slug }.to_i
    end

    def session_for(group_slug)
      sign_in_sessions[session_index_for(group_slug)]
    end
  end
end

まとめ

簡単にだが、Slack風、Rails によるファストユーザスイッチの実装方法を示した。Devise を使わないセッション管理のベタ実装には、Rails Tutorial を参考にした。

railstutorial.jp

実際にファストユーザスイッチを実装したグループウェア (作りかけ) は、以下のリポジトリにあるので、参考までにどうぞ。

github.com

実装ポイントは、

  • 複数のセッションを配列として格納すること
  • DB には格納しないけど、ActiveRecord と同じインタフェースでバリデーションしたいときは、ActiveModel::Model を include すると便利

ぐだぐだと書いたけど、Rails Tutorial との差分はそれ以外にないという。

雑感

  • セッション管理のベタ実装は、初動の開発が遅れるが、一通り作りきってしまえば意外と簡単
    • ただ、作りきった後に「あー、簡単だったな」となるかどうかは、うまく設計できるかどうかにかかってそう

*1:https://github.com/voormedia/rails-erd で作った

*2:CookieStore 以外を使う方法もあるが、気軽で要件的にも必要十分だったので CookieStore にした