Ruby
RubyOnRails
bcrypt

RailsでBasic認証とユーザー認証の二重認証を実装

この記事は?

Ruby on Railsで二重認証をかける話です。

二重認証?

Basic認証とユーザー認証の二段構えの認証を行いたい、みたいな話です。

Basic認証とユーザー認証(フォームによる認証)の違いについては、こちらを参考にするとよいのではないかと思います。

必要性

身内のみで使うことを想定したWebアプリなんかで、Basic認証で身内以外の人を弾いてフォームによる認証で個人を識別したい、というときに使うことを考えています。

Webサイトに最初にアクセスした時に

Basic_ninshow.png

のような認証を出してこれを通過した場合には、

User_ninshow.png

のようなフォームによる認証を出し両方を通過した場合のみWebアプリケーションを利用できる、という仕組みを作りたいと思います。

アプリケーション構成

実験的なアプリケーションとして最低限必要なものはUserデータベースとそれを操作する足場(Scaffold)、そしてログイン認証のコントローラとそのビューと言ったところでしょうか?

まずは、足場を作ってしまいます。

$ rails g scaffold user name:string password_digest:string

今回はログイン機能に最低限必要なコラムだけ作りました。続いて、セキュアな暗号化を提供してくれるgemをインストールします。Gemfileを開いて、'bcrypt'にかかっているコメントアウトを外して保存し

$ bundle install

とします。次に、ログイン認証のコントローラを作ります。

$ rails g controller login

これでひとまずの準備は整ったので、ファイルをいじっていきます。membersモデルのscaffoldingによってアクセス可能なアクションとして、
'users#index'、'users#show'、'users#edit'、'users#new'が追加されているはずです。これらのうち'user#new'以外にアクセスしようとした場合にはbasic認証とユーザー認証がかかるようにします。

Basic認証

RailsでBasic認証を利用するのはとても簡単です。今回は特定のグループ所属のユーザー以外を弾きたい、という場合を想定しているのでグループに所属していない人にはそもそもアクセスできないようにしたいので、Applicationコントローラー内にフィルターとして実装します。全機能に制限をかけたいのでonly/exceptオプションはなしです。このとき、ユーザー認証に関しても予めフィルタとして書いておきます。basicフィルタがBasic認証に関して、check_loginedが二段階目のユーザー認証に関してのアクションです。

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception
  before_action :basic, :check_logined 

  private
    def basic
      name = 'hogehoge'
      passwd = '39975bb0ba31825c4fdd3de775dd468081b3522b'
      authenticate_or_request_with_http_basic('BA') do |n, p|
        n == name && Digest::SHA1.hexdigest(p) == passwd
      end
    end
end

basicフィルターはそのブラウザで初めてアクセスした際にBasic認証を行い、ブラウザを閉じるまでは認証を維持します。

上の例ではユーザ名に'hogehoge'、パスワードに'fugafuga'を入れることで認証されます。なお、パスワードはハッシュ化されたものをコード内に記述しましたが、自分でパスワードを設定してハッシュ化したものを記述したい場合は、

$ rails console

でrailsの対話環境を開き

irb(main)> Digest::SHA1.hexdigest('パスワード化したい文字列')

を実行するとハッシュ化された文字列が帰ってくるので、それをソース内に記述すると良いでしょう。なお、ハッシュ関数はこの他にもいろいろ利用できるようです。

フォームによるユーザー認証

フォームによる認証の場合、ユーザーの情報を記録したデータベースが必要になりますがこれは先程作成しました。

まずは、Applicationコントローラー内のcheck_loginedフィルタを定義してしまいます。

class ApplicationController < ActionController::Base
  (中略)
  private
    (中略)
  def check_logined
    if session[:usr] then
        begin
          @usr = User.find(session[:usr])
        rescue ActiveRecord::RecordNotFound
          reset_session
        end
      end
      unless @usr
        flash[:referer] = request.fullpath
        redirect_to controller: :login, action: :check
      end
  end
end

これによってセッション情報がUsersテーブルに存在するユーザーであるかどうかを判定して、ログイン済みかどうかを判定することができます。ユーザー情報が取得できなかった場合はloginコントローラのcheckアクションにリダイレクトします。したがって、次はcheckアクションをloginコントローラーに定義しにいきます。

このとき、bcryptによるセキュアな暗号化機能を利用したいのでUserモデルを編集します。

class User < ApplicationRecord
  has_secure_password
end

このメソッドは、モデルにpassword/password_confirmationプロパティやその検証機能、認証のためのauthenticateメソッドを自動的に付加します。従って、こちらはモデルにpasswordプロパティなどを定義する必要はありません。ただし、このメソッドはusersテーブルにpassword_digestフィールドが存在することを前提としています。このpassword_digestフィールドには暗号化されたパスワード列が保存されます。

password_digestフィールドは先程のscaffoldingの際に作ってしまったので再定義の必要はありません。

認証用フォームの作成

認証用のフォームを作成します。check_loginedフィルタでリダイレクト先としてloginコントローラーのcheckアクションが指定されていたのでcheckアクションに対応したビューを作成します。

<p style="color: Red">
    <%= @error %>
</p>
<%= form_tag action: :check do %>
    <div class="field">
        <%= label_tag :username, 'ユーザ名' %> : <br/>
        <%= text_field_tag :username, '', size: 20 %>
    </div>
    <div class="field">
        <%= label_tag :password, 'パスワード' %> : <br/>
        <%= password_field_tag :password, '', size: 20 %>
    </div>
    <%= hidden_field_tag :referer, flash[:referer] %>
    <%= submit_tag 'ログイン' %>
<% end %><br/>

<%= link_to '新規登録', new_user_path %>

一方、loginコントローラーのcheckアクションの方にはフォームの入力内容が送信されてくるのでそれを処理するコードを記述します。

class LoginController < ApplicationController
    skip_before_action :check_logined

    def check
        usr = User.find_by(name: params[:username])
        if usr && usr.authenticate(params[:password]) then
            reset_session
            session[:usr] = usr.id
            redirect_to params[:referer]
        else
            flash.now[:referer] = params[:referer]
            @error = 'ユーザ名/パスワードが間違っています。'
            render 'check'
        end
    end
end

二行目のskip_before_actionに:check_loginedを記述しないと堂々巡りが発生しログインフェーズに映ることができません。

ルーティング

このままだとルーティングが失敗するのでroutes.rbにcheckアクションへのルートを記述します。

Rails.application.routes.draw do
  get 'login/check'
  post 'login/check'
  resources :users
  # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html
end

resourcesの部分は、usersモデルをscaffoldで作成した場合はすでに書いてあるはずです。

usersビュー側の整形

これで認証部分は終わりですが、usersのビューに少し不備があるので直します。newテンプレートは特に直す必要はありませんがせっかくなので日本語化しておきます。

<h1>新規登録</h1>

<%= render 'form', user: @user %>

<%= link_to '戻る', users_path %>

showテンプレートはデフォルトだとpassword_digestを表示するようになっていますが、ハッシュされたものだとしてもこれが見えるのは嬉しくはないので表示するパラグラフを消しておきます。

<p id="notice">
    <%= notice %>
</p>

<p>
    <strong>名前:</strong>
    <%= @user.name %>
</p>

<%= link_to '編集', edit_user_path(@user) %> |
<%= link_to '戻る', users_path %>

続いてindexテンプレートですが、こちらもデフォルトではpassword_digestが見えてしまうので消しておきます。

<p id="notice">
    <%= notice %>
</p>

<h1>ユーザー一覧</h1>

<table>
    <thead>
        <tr>
            <th>名前</th>
            <th colspan="3"></th>
        </tr>
    </thead>

    <tbody>
        <% @users.each do |user| %>
            <tr>
                <td>
                    <%= user.name %>
                </td>
                <td>
                    <%= link_to '見る', user %>
                </td>
                <td>
                    <%= link_to '編集', edit_user_path(user) %>
                </td>
                <td>
                    <%= link_to '破棄', user, method: :delete, data: { confirm: '本当に破棄しますか?' } %>
                </td>
            </tr>
            <% end %>
    </tbody>
</table>

<br>

<%= link_to '新規作成', new_user_path %>

editテンプレートはとくに修正の必要はありません。日本語化だけしておきます。

<h1>ユーザーの編集</h1>

<%= render 'form', user: @user %>

<%= link_to '見る', @user %> |
<%= link_to '戻る', users_path %>

最後に部分テンプレート_form.html.erbは、少し修正が必要です。

<%= form_with(model: user, local: true) do |form| %>
    <% if user.errors.any? %>
        <div id="error_explanation">
            <h2>
                <%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:</h2>

            <ul>
                <% user.errors.full_messages.each do |message| %>
                    <li>
                        <%= message %>
                    </li>
                    <% end %>
            </ul>
        </div>
        <% end %>

            <div class="field">
                <%= form.label :name %>
                    <%= form.text_field :name, id: :user_name %>
            </div>

            <div class="field">
                <%= form.label :password %>
                    <%= form.password_field :password %>
            </div>

            <div class="actions">
                <%= form.submit %>
            </div>
        <% end %>

デフォルトでは、パスワードの部分がpassword_digestになってしまっていてform.text_fieldになってしまっているはずなので、password_fieldに変えて引数も:passwordに変更します。passwordプロパティはbcryptの機能で自動的に設定されています。ここに受け取った文字列を暗号化したものがpassword_digestに保存されるという仕組みです。

最後に

ユーザーの新規登録だけはログインせずにでもできるようにしないといけません。usersコントローラーにskip_before_actionの一文を追加します。また、StrongParameters機能も少し書き換えなければなりません。デフォルトでは:password_digestになっているはずなので、:passwordに書き換えます。

class UsersController < ApplicationController
  before_action :set_user, only: [:show, :edit, :update, :destroy]
  skip_before_action :check_logined, only: [ :new, :create ]
    (中略)
  def member_params
      params.require(:user).permit(:name, :password)
    end
end

これで全ての準備が整ったので、二重認証ができるようになるはずです。