Slack風、Rails によるファストユーザスイッチの実装方法 (Devise 未使用)
この記事は 高知工科大 Advent Calendar 2016 の 14 日目の記事です。
概要
趣味で作っている Rails 製小規模向けグループウェアに、ファストユーザスイッチを実装した話。アカウント登録・認証は Devise を使ってサクッと作ることが多いけど、Devise のカスタマイズで心をすり減らしたくなかったので、Devise なしで一から実装してみた。
今回、Devise を使わないセッション管理のベタ実装には、以下を参考にした。この記事の範囲外の細かい話は、Rails Tutorial を見てほしい。
利用技術
ファストユーザスイッチ?
用語が正しいかどうか分からないけど、Slack や Google のサービスは、アカウント間をログアウト不要で素早く切り替えることができる。
例えば Google なら、ヘッダーのアカウントアイコンを選択して、切り替えるアカウントを選べる。2個以上の Google アカウントを持っている人なら、見たことがあると思う。
Slack なら、チームアイコンをクリックすることで、素早くチーム間を行き来できる。
Google / Slack
要件
グループウェアを作っているので、グループの概念を持った Slack を参考にして要件を立てた。
- アカウント認証には、サインインするグループID (
slug
)、グループに登録したアカウントの Email アドレス (email
)、パスワード (password
) が必要 - グループごとにアカウントで使用するメールアドレスを変えることができる
以上の要件を反映させたのが、次の ER 図*1。
実装
実装上、細々とした処理がいくつかあるものの、重要な部分を抜き出すと次のようになる。以降、太字で示したクラスについて説明していく。
- サインイン時の処理
- サインインページで認証に必要な情報 (group slug, email, password) を入力
- サインインのリクエストを受け付ける
[SessionsController] - フォームのバリデーションと、フォームの値を元にアカウントを認証
[Session モデル] - 認証に成功したら、セッション一覧に新しいセッションを追加し、現在のセッションとする
[Authentication クラス] - グループページにリダイレクト
- サインアウト時の処理
- サインアウトボタンをクリック
- サインインのリクエストを受け付ける
[SessionsController] - 認証に成功したら、セッション一覧から現在のセッションを削除
[Authentication クラス] - グループページにリダイレクト
- サインイン後のセッション維持
- Rails アプリケーション内のとあるページに遷移する
- リクエストに紐づくセッションを用いてアカウントを認証 [Authentication クラス]
- 認証に成功したら、リクエストしたページにそのまま遷移
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!
は、ApplicationController
の before_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_in
、Authentication#sign_out
メソッドは、それぞれログイン・ログアウト時にセッションの追加・削除を行う。
以下が Authentication クラスのソースコード。少しに長くなってしまったが、重要なのは前半の Authentication#authenticate!
、Authentication#sign_in
、Authentication#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 を参考にした。
実際にファストユーザスイッチを実装したグループウェア (作りかけ) は、以下のリポジトリにあるので、参考までにどうぞ。
実装ポイントは、
- 複数のセッションを配列として格納すること
- DB には格納しないけど、ActiveRecord と同じインタフェースでバリデーションしたいときは、
ActiveModel::Model
を include すると便利
ぐだぐだと書いたけど、Rails Tutorial との差分はそれ以外にないという。
雑感
- セッション管理のベタ実装は、初動の開発が遅れるが、一通り作りきってしまえば意外と簡単
- ただ、作りきった後に「あー、簡単だったな」となるかどうかは、うまく設計できるかどうかにかかってそう
*1:https://github.com/voormedia/rails-erd で作った
*2:CookieStore 以外を使う方法もあるが、気軽で要件的にも必要十分だったので CookieStore にした