Rails Webook

自社のECを開発している会社で働いています。Rails情報やサービスを成長させる方法を書いていきます

Railsの認証機能Gemのclearanceのコードリーディング

Railsに認証機能を追加するGemのClearanceのソースコードを読んでみました。
本体のコード量は、1,200行程度ですので比較的読みやすいと思います。

Clearnaceは、メールとパスワードで認証できるようにする機能を提供しています。
目的は、小さく、シンプルで、よくテストされていることを意識しており、また、デフォルトを用意しているが簡単に変更できるようなことも気にしているような作りのようです。

1. コードを読む目的

認証機能の実現方法を中心にソースコードを見ていきたいと思います。

  • 認証機能の本体となる実装方法
  • Railsで認証機能を使う方法

2. 基本情報

対象バージョン

2017/11/3に作られたv1.16.1で確認します。

コード量

まず、コード量ですが、全体のコード量は5,000行程ありますが、lib配下だけだと1,200行程度なのでコード量はそこまで多くないと思います。

$ cloc ./clearance
     148 text files.
     146 unique files.
      23 files ignored.

github.com/AlDanial/cloc v 1.78  T=1.05 s (119.9 files/s, 6824.5 lines/s)
-------------------------------------------------------------------------------
Language                     files          blank        comment           code
-------------------------------------------------------------------------------
Ruby                           108            910            744           3817
Markdown                         3            428              0           1002
ERB                             11             21              0            108
YAML                             3              9              0            106
Bourne Shell                     1              4              3              8
-------------------------------------------------------------------------------
SUM:                           126           1372            747           5041
-------------------------------------------------------------------------------

ディレクトリ構成

次に、ディレクトリ構成です。gemなのでlib/clearanceに認証機能の本体となるコードがあります。
また、メールアドレスとパスワードの認証機能なので、Railsのルート、コントローラー、ビューなどのファイルもappconfiglib配下に配置されています。

pundit
├── app
│   ├── controllers
│   │   └── clearance
│   │       ├── base_controller.rb
│   │       ├── passwords_controller.rb
│   │       ├── sessions_controller.rb
│   │       └── users_controller.rb
│   ├── mailers
│   │   └── clearance_mailer.rb
│   └── views
│       ├── clearance_mailer
│       │   ├── change_password.html.erb
│       │   └── change_password.text.erb
│       ├── layouts
│       │   └── application.html.erb
│       ├── passwords
│       │   ├── create.html.erb
│       │   ├── edit.html.erb
│       │   └── new.html.erb
│       ├── sessions
│       │   ├── _form.html.erb
│       │   └── new.html.erb
│       └── users
│           ├── _form.html.erb
│           └── new.html.erb
├── bin
├── clearance.gemspec
├── config
│   ├── locales
│   │   └── clearance.en.yml
│   └── routes.rb
├── db
│   ├── migrate
│   │   └── 20110111224543_create_clearance_users.rb
│   └── schema.rb
├── gemfiles
├── lib
│   ├── clearance
│   │   ├── authentication.rb
│   │   ├── authorization.rb
│   │   ├── back_door.rb
│   │   ├── configuration.rb
│   │   ├── constraints
│   │   │   ├── signed_in.rb
│   │   │   └── signed_out.rb
│   │   ├── constraints.rb
│   │   ├── controller.rb
│   │   ├── default_sign_in_guard.rb
│   │   ├── engine.rb
│   │   ├── password_strategies
│   │   │   ├── bcrypt.rb
│   │   │   ├── bcrypt_migration_from_sha1.rb
│   │   │   ├── blowfish.rb
│   │   │   └── sha1.rb
│   │   ├── password_strategies.rb
│   │   ├── rack_session.rb
│   │   ├── rspec.rb
│   │   ├── session.rb
│   │   ├── session_status.rb
│   │   ├── sign_in_guard.rb
│   │   ├── test_unit.rb
│   │   ├── testing
│   │   │   ├── controller_helpers.rb
│   │   │   ├── deny_access_matcher.rb
│   │   │   ├── helpers.rb
│   │   │   └── view_helpers.rb
│   │   ├── testing.rb
│   │   ├── token.rb
│   │   ├── user.rb
│   │   └── version.rb
│   ├── clearance.rb
│   └── generators
└── spec

提供している主な機能

  • 会員登録
  • ログイン/ログアウト
  • パスワードの変更

といった基本的な認証機能を提供しています。

簡単な使い方

clearance - READMEに書いてあるのですが、抜粋して下記に記載します。

認証されているかどうかは、require_loginフィルターでできます。

class ArticlesController < ApplicationController
  before_action :require_login

  def index
    current_user.articles
  end
end

また、ヘルパーメソッドでコントローラーやビュー内でcurrent_usersigned_in?signed_out?が利用できます。

ビューでログインしているかどうかによって、表示を出し分ける例

<% if signed_in? %>
  <%= current_user.email %>
  <%= button_to "Sign out", sign_ount_path, method: :delete %>
<% else %>
  <%= link_to "Sign in", sign_in_path %>
<% end %>

クラス図

大きくは、ActionControllerを拡張するClearcance::Controllerモジュールと、会員登録・ログインを行うUserモデルを拡張するClearance::Userに別れています。
Clearance::Controllerは、クッキー値を使ってcurrent_usersign_insign_outなどの認証機能の実装がされています。また、コントローラーのアクションを実行できるか確認するrequire_logindeny_accessなどのメソッドを実装しています。奥深くでは、Rackアプリケーションとして認証情報を保持しています。 Clearance::Userは、ActiveRecordのモデルを拡張すべく、認証処理(パスワードのハッシュ処理やパスワード確認処理)、バリデーション(emailやpasswordの長さなど確認)、コールバック(emailの正規化やトークンの発行など)が実装されています。

f:id:nipe880324:20181024015954p:plain:w420

また、gem本体とは別に、会員登録、ログイン/ログアウト、パスワード変更といった基本的な認証機能のコントローラーとビューも提供しています。

f:id:nipe880324:20181024020230p:plain:w420

※分かりやすさのため一部省略しています。また、UML上の間違いがある可能性があります。

DBモデル

ユーザーのテーブルは次の通りです。 emailと暗号化されたパスワードのencrypted_passwordで認証をします。
remember_tokenは、クッキーに保存して、ログインしているかどうかを確認します。ログアウトしたりすると新たに作り出すような仕組みにしているようです。
confirmation_tokenは、パスワード変更のときに使われるトークンで、トークンを知っている人しかパスワードを変更できないようにしています。 また、emailremember_tokenでDBを検索するため、インデックスがはられています。

create_table "users", force: :cascade do |t|
  t.string "email", null: false
  t.string "encrypted_password", limit: 128, null: false
  t.string "remember_token", limit: 128, null: false
  t.string "confirmation_token", limit: 128
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.index ["email"], name: "index_users_on_email"
  t.index ["remember_token"], name: "index_users_on_remember_token"
end

3. 認証機能の本体となる実装方法

Generatorでrails generate clearance:installを実行すると次のことが実行されます。

  • Clearnce::UserUsreモデルにインクルード(Userモデルがなければモデルも作成)
  • Clearnce::ControllerAcpplicationControllerにインクルード
  • config/initializers/clearance.rbというClearnceの設定ファイルを追加
  • ユーザーテーブルの作成か既存テーブルに必須なカラムを追加するマイグレーションファイルを作成

まずは、モデル側のClearance::Userモジュールから見ていきます。次のように、ActiveRecordのモデルにモジュールをインクルードされます。

# app/models/user.rb
class User < ApplicationRecord
  include Clearance::User
end

Clearance::Userは、ActiveSupport::Concernで、認証機能、バリデーション、コールバックメソッドを追加しています。
認証機能のメインのメソッドは、authenticate(email, password)です。
ユーザーをemailで探し、入力したパスワードをハッシュ化して、DB上のencrypted_passwordと比較することでパスワードがあっているか確認します。 user.authenticted?(password)メソッドでパスワードが正しいか確認しています。

# clearance/lib/clearance/user.rb
module Clearance
  module User
    extend ActiveSupport::Concern

    included do
      ...

      include password_strategy

      ...
    end

    # @api private
    module ClassMethods
      # DBからemailで探し、`User#authenticated?`メソッドでパスワードが正しいか確認する
      def authenticate(email, password)
        if user = find_by_normalized_email(email)
          if password.present? && user.authenticated?(password)
            user
          end
        end
      end

      # emailで検索する
      def find_by_normalized_email(email)
        find_by_email normalize_email(email)
      end

      # emailを小文字にしスペースをなくす
      def normalize_email(email)
        email.to_s.downcase.gsub(/\s+/, "")
      end

      private

      # password_strategyというメソッドを経由してincludeすることで、パスワードのハッシュ化アルゴリズムを変更しやすくしている
      # 例えば、自前のハッシュ化アルゴリズムのモジュールを作って、Clearance.configuration.password_strategyに設定することで
      # そのアルゴリズムでパスワードのハッシュ化やハッシュ値が同じかどうか確認ができる
      def password_strategy
        Clearance.configuration.password_strategy || PasswordStrategies::BCrypt
      end
    end

  ...

  end
end

User#authenticted?(password)メソッドは、PasswordStrategies::BCryptで実装されています。
BCryptに任せていますが、内部的には、DBのencrypted_password(BCryptで暗号化されたパスワード)と入力されたパスワードを暗号化してそれを比較しています。 BCryptの実装を見ると中でsaltなどごにょごにょやっています。bcrypt/password - github

# clearance/lib/clearance/password_strategies/bcrypt.rb
module Clearance
  module PasswordStrategies
    module BCrypt
      require 'bcrypt'

      def authenticated?(password)
        if encrypted_password.present?
          ::BCrypt::Password.new(encrypted_password) == password
        end
      end

      ...
    end
  end
end

コントローラー側ではClearance::Controllerモジュールがインクルードされます。

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  include Clearance::Controller
end

Clearance::Controllerを見ると、2つのモジュールをインクルードしています。

# app/controllers/application_controller.rb
require 'clearance/authentication'
require 'clearance/authorization'

module Clearance
  module Controller
    extend ActiveSupport::Concern

    include Clearance::Authentication
    include Clearance::Authorization
  end
end

Clearance::Authenticationから見ていきます。
authenticatecurrent_usersign_insign_outsigned_in?signed_out?などの認証に必要なメソッドが定義されています。
基本的には、clearance_sessionメソッドに処理を委譲しています。

# clearance/lib/clearance/Authentication.rb
module Clearance
  module Authentication
    extend ActiveSupport::Concern

    included do
      # current_user, signed_in?, signed_out?はヘルパーメソッドにしている
      if respond_to?(:helper_method)
        helper_method :current_user, :signed_in?, :signed_out?
      end

      private(
        :authenticate,
        :current_user,
        :current_user=,
        :handle_unverified_request,
        :sign_in,
        :sign_out,
        :signed_in?,
        :signed_out?
      )
    end

    # 先ほどの`User.authenticate(email, password)`を呼んでメールとパスワードの情報が正しいか返す
    # ログイン処理で入力情報が正しいか確認するために使われる
    def authenticate(params)
      Clearance.configuration.user_model.authenticate(
        params[:session][:email], params[:session][:password]
      )
    end

    # 現在のログインユーザーを取得する、ログインしてなければnull
    def current_user
      clearance_session.current_user
    end

    # 引数のuserでログイン処理をする
    def sign_in(user, &block)
      clearance_session.sign_in(user, &block)

      if signed_in? && Clearance.configuration.rotate_csrf_on_sign_in?
        session.delete(:_csrf_token)
        form_authenticity_token
      end
    end

    # ログアウト処理をする
    def sign_out
      clearance_session.sign_out
    end

    # ログインしているか否かを返す
    def signed_in?
      clearance_session.signed_in?
    end

    def signed_out?
      !signed_in?
    end

    ...

    protected

    # @api private
    def clearance_session
      request.env[:clearance]
    end
  end
end

上のclearance_sessionは、request.env[:clearance]をみていて、Rackぽい感じがするのでいろいろみてみると、
Clearance::Engineで、ミドルウェアにClearance::RackSessionを追加していることがわかります。

# clearance/lib/clearance/engine.rb
require "clearance"
require "rails"

module Clearance
  class Engine < Rails::Engine
    ...

    config.app_middleware.use(Clearance::RackSession)

    ...
  end
end

RackSessionを見ると、env[:clearance]Clearance::Sessionインスタンスを代入しているので先ほどの、request.env[:clearance]Clearance::Sessionということがわかります。

# clearance/lib/clearance/rack_session.rb
module Clearance
  class RackSession
    def initialize(app)
      @app = app
    end

    def call(env)
      session = Clearance::Session.new(env)
      env[:clearance] = session
      response = @app.call(env)
      session.add_cookie_to_headers response[1]
      response
    end
  end
end

では、Clearance::Sessionを見ると、クッキー内のremember_tokenとDB上のremember_tokenをみて、ユーザー識別やログインしているかどうかを判断しています。

# clearance/lib/clearance/session.rb
require 'clearance/default_sign_in_guard'

module Clearance
  class Session
    def initialize(env)
      @env = env
      @current_user = nil
      @cookies = nil
    end

    ...

    # ログインユーザーを取得
    def current_user
      # クッキー内のremember_tokenの値が存在すれば、remember_tokenでユーザーを検索して、
      # ユーザーがいれば、current_userとしている(=ログイン済みと判断)
      if remember_token.present?
        @current_user ||= user_from_remember_token(remember_token)
      end

      @current_user
    end

    # ログイン処理
    def sign_in(user, &block)
      @current_user = user
      # 認証処理を拡張できる仕組み、デフォルトでは必ずsuccess?になりそう
      # 詳細: https://github.com/thoughtbot/clearance#extending-sign-in
      status = run_sign_in_stack

      if status.success?
        cookies[remember_token_cookie] = user && user.remember_token
      else
        @current_user = nil
      end

      if block_given?
        block.call(status)
      end
    end

    # ログアウト処理
    def sign_out
      if signed_in?
        # DB上のremember_tokenを作り直して保存する
        current_user.reset_remember_token!
      end

      # クッキー上のremember_tokenを削除しておく
      @current_user = nil
      cookies.delete remember_token_cookie
    end

    # ログインしているか判定
    def signed_in?
      current_user.present?
    end

    def signed_out?
      !signed_in?
    end

    private

    # @api private
    def cookies
      @cookies ||= ActionDispatch::Request.new(@env).cookie_jar
    end

    # @api private
    def user_from_remember_token(token)
      Clearance.configuration.user_model.where(remember_token: token).first
    end

    # @api private
    def remember_token
      cookies[remember_token_cookie]
    end

    # @api private
    def remember_token_cookie
      Clearance.configuration.cookie_name.freeze
    end

    ...
  end
end

4. Railsで認証機能を使う方法

Clearanceでは、認証機能をRackアプリやクッキー、暗号などで実装していることがわかりました。
では、Rails上のコントローラーやビューでは、会員登録やログイン機能はどのように実装されているかをみていきます。

まずは、Clearanceのroutes.rbをみてみます。/sign_in/sign_upなどのパスが定義されており、Clearance::SessionsControllerClearance::UsersControllerといったコントローラーが呼ばれている設定になっていることがわかります。

# clearance/confing/routes.rb
if Clearance.configuration.routes_enabled?
  Rails.application.routes.draw do
    ...

    resource :session, controller: 'clearance/sessions', only: [:create]

    resources :users, controller: 'clearance/users', only: [:create]

    get '/sign_in' => 'clearance/sessions#new', as: 'sign_in'
    delete '/sign_out' => 'clearance/sessions#destroy', as: 'sign_out'

    get '/sign_up' => 'clearance/users#new', as: 'sign_up'
  end
end

まずは、会員登録機能をみていきます。
コントローラーは、とてもシンプルで、@user.saveで会員を作成できたら、sign_inメソッドでログインして、リダイレクトするという流れです。

# clearance/app/controllers/clearance/users_controller.rb
class Clearance::UsersController < Clearance::BaseController
  before_action :redirect_signed_in_users, only: [:create, :new]

  ...

  # 会員登録画面の表示
  def new
    @user = user_from_params
    render template: "users/new"
  end

  # 会員登録処理 & ログイン処理
  def create
    @user = user_from_params

    if @user.save
      sign_in @user
      redirect_back_or url_after_create
    else
      render template: "users/new"
    end
  end

  private

  # ログインしていた場合ルートにリダイレクトさせる
  def redirect_signed_in_users
    if signed_in?
      redirect_to Clearance.configuration.redirect_url
    end
  end

  # 会員登録後のリダイレクト先のURL
  def url_after_create
    Clearance.configuration.redirect_url
  end

  # 入力フォームからemailとpasswordを取得する
  def user_from_params
    email = user_params.delete(:email)
    password = user_params.delete(:password)

    Clearance.configuration.user_model.new(user_params).tap do |user|
      user.email = email
      user.password = password
    end
  end

  def user_params
    params[:user] || Hash.new
  end
end

次に、ログイン/ログアウト機能をみていきます。
こちらも、とてもシンプルで、Celarance::Controllerモジュールでインクルードしたメソッドを使ってログイン/ログアウト機能を実装しています。

# clearance/app/controllers/clearance/users_controller.rb

  ...
class Clearance::SessionsController < Clearance::BaseController
  before_action :redirect_signed_in_users, only: [:create, :new]

  ...

  # ログイン画面の表示
  def new
    render template: "sessions/new"
  end

  # ログイン処理
  def create
    # メールとパスワードの認証情報が正しいか確認
    # 正しい場合はUserインスタンス、誤っている場合はnil
    @user = authenticate(params)

    # ログイン
    # @userがnilの場合は失敗してログイン画面を再表示する
    sign_in(@user) do |status|
      if status.success?
        redirect_back_or url_after_create
      else
        flash.now.notice = status.failure_message
        render template: "sessions/new", status: :unauthorized
      end
    end
  end

  # ログアウト処理
  def destroy
    # ログアウト
    sign_out
    redirect_to url_after_destroy
  end

  private

  def redirect_signed_in_users
    if signed_in?
      redirect_to url_for_signed_in_users
    end
  end

  def url_after_create
    Clearance.configuration.redirect_url
  end

  def url_after_destroy
    sign_in_url
  end

  def url_for_signed_in_users
    url_after_create
  end
end

5. まとめ

Clearanceをコードリーディングし、認証機能の実装を確認しました。
認証の仕組みとしては、メールとパスワードで認証情報を確認し、クッキーにトークンを保存することでログイン処理を実施していました。
それを実現させるために、Clearance::ControllerモジュールやClearance::Userモジュールをインクルードしたり、Rackアプリケーションを使っていました。(Deviseもそうですが、Rackアプリケーションで認証をするのは、厳密にはよくわかってないですが、たぶん、Railsアプリ全体に対して認証をできるようになるからかなーと思っています。例えば、routes.rbで認証していたらこのルートにアクセスできて、認証してなかったらこのルートにアクセスできなるなど -> Code sample

また、いくつかgemをコードリーディングして思うのは、Configurationクラスはほぼ必ずといっていいほどみます。これはライブラリという特性上設定を管理するために使う上等手段なのだと思います。
また、テストコードは、若干冗長だったりRSpecだとしてもネストが深くならず読みやすいシンプルで読みやすいテストコードになっていると思いました。(gemだと複雑な条件分岐とかは比較的少ないのでそのようなテストコードになりやすいのかもです)

以上です。