"devise with all the bells and whistles" を作ってみる

なんとなく使い方が分かってきたので、リベンジ:Rails 2.3RC1で”Restful Authentication with all the bells and whistles”をやってみるをdeviseでやってみる。ちなみに "with all the bells and whistles" はあんまり良い意味ではないみたい。

チュートリアルの実施環境

% uname -a
Linux pi 2.6.32-5-686 #1 SMP Thu Aug 12 13:38:27 UTC 2010 i686 GNU/Linux

% more /proc/version
Linux version 2.6.32-5-686 (Debian 2.6.32-20) (ben@decadent.org.uk) (gcc version
 4.3.5 (Debian 4.3.5-2) ) #1 SMP Thu Aug 12 13:38:27 UTC 2010

% ruby -v
ruby 1.8.7 (2010-08-16 patchlevel 302) [i486-linux]

% gem1.8 --version
1.3.7

% rails -v
Rails 3.0.0

% sqlite3 --version
3.7.2

deviseのインストール

gemで導入する。

% sudo gem1.8 install devise

次に、プロジェクトを新規作成する。

% rails new app_30
% cd app_30

Pageリソースを生成する。

% rails g scaffold Page title:string boty:text

Gemfileに以下を書き加える。

gem 'devise', '1.1.2

deviseを組み込む。

% rails g devise:install
      create  config/initializers/devise.rb
      create  config/locales/devise.en.yml

===============================================================================

Some setup you must do manually if you haven't yet:

  1. Setup default url options for your specific environment. Here is an
     example of development environment:

       config.action_mailer.default_url_options = { :host => 'localhost:3000' }

     This is a required Rails configuration. In production it must be the
     actual host of your application

  2. Ensure you have defined root_url to *something* in your config/routes.rb.
     For example:

       root :to => "home#index"

  3. Ensure you have flash messages in app/views/layouts/application.html.erb.
     For example:

       <p class="notice"><%= notice %></p>
       <p class="alert"><%= alert %></p>

===============================================================================

deviseをインストールした際のメッセージにしたがう。まず、config/environments/development.rbに以下を加える。

# For devise
config.action_mailer.default_url_options = { :host => 'localhost:3000' }

config/routes.rbにルートを設定する。pages#indexをルートとする。

App30::Application.routes.draw do
  root :to => "pages#index"
  resources :pages
end

app/views/layouts/application.html.erb にflushを表示する。

<!DOCTYPE html>
<html>
<head>
  <title>devise with all the bells and whistles</title>
  <%= stylesheet_link_tag :all %>
  <%= javascript_include_tag :defaults %>
  <%= csrf_meta_tag %>
</head>
<body>
<div class="notiece">
<p class="notice"><%= notice %></p>
<p class="alert"><%= alert %></p>
</div>
<%= yield %>

</body>
</html>

Userの作成

deviseでログイン管理をするモデルを作成する。今回はUserとする。

% rails g devise User

deviseでは11のモジュールが用意されており目的別に使用するモジュールを選択する。今回は以下の9つを使う。

  1. Database Authenticatable: 自アプリケーションでパスワード管理をするならば必ず選ぶ
  2. Registerable:ユーザーが自分でアカウント作成&編集&削除できるようにするならば選ぶ
  3. Confirmable:restful_authenticationで言うactivationが必要ならば選ぶ
  4. Recoverable:パスワード忘れ対策が必要ならば選ぶ
  5. Rememberable:「次回以降自動的にログインする」の機能を実現するならば選ぶ
  6. Trackable:ログイン履歴をとるならば選ぶ
  7. Timeoutable:一定期間操作を行っていなければ自動ログアウトを実現するならば選ぶ
  8. Validatable:入力されたメールアドレスやパスワードのチェックをするならば選ぶ
  9. Lockable:規定回数以上ログインに失敗したらアカウントをロックするならば選ぶ

app/models/user.rbにおいて、上記9つのモジュールを列挙する。また、loginという項目をattr_accessibleに付け加える。

class User < ActiveRecord::Base
  # Include default devise modules. Others available are:
  # :token_authenticatable, :confirmable, :lockable and :timeoutable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :trackable, :validatable, :confirmable, 
         :lockable

  # Setup accessible (or protected) attributes for your model
  attr_accessible :login, :email, :password, :password_confirmation, :remember_me
end

db/migrate以下にあるXXX_devise_create_users.rbを編集し、選択したモジュールに関連するフィールドとloginフィールドを追加する。

class DeviseCreateUsers < ActiveRecord::Migration
  def self.up
    create_table(:users) do |t|
      t.string :login, :limit => 40
      t.database_authenticatable :null => false
      t.recoverable
      t.rememberable
      t.trackable
      t.confirmable
      t.lockable :lock_strategy => :failed_attempts, :unlock_strategy => :both
      # t.token_authenticatable


      t.timestamps
    end

    add_index :users, :email,                :unique => true
    add_index :users, :reset_password_token, :unique => true
    add_index :users, :confirmation_token,   :unique => true
    add_index :users, :unlock_token,         :unique => true
  end

  def self.down
    drop_table :users
  end
end

pagesのビューにアクセスする際にはログインが必要にする。app/controllers/pages_controller.rbに以下のフィルターを加える。

before_filter :authenticate_user!

一度、動かしてみる。

% rake db:migrate
% rails server

http://localhost:3000/pages にアクセスして、 http://localhost:3000/users/sign_in に飛ばされた成功!


ただし、このままだと、login フィールドの入力欄および入力処理がない。なので、修正する。まず、devise関連のビューを編集できるようにするためにビューをローカルに生成する。

% rails generate devise:views

app/views/devise/registrations 以下の new.html.erbとedit.html.erbを編集する。たとえば、new.html.erbは以下のようにする。form_for でデータが送られているので、そのUserテーブルのフィールド名を書くだけで新たな項目を追加できる。

<h2>Sign up</h2>

<%= form_for(resource, :as => resource_name, :url => registration_path(resource_name)) do |f| %>
  <%= devise_error_messages! %>

  <p><%= f.label :login %><br />
  <%= f.text_field :login %></p>

  <p><%= f.label :email %><br />
  <%= f.text_field :email %></p>

  <p><%= f.label :password %><br />
  <%= f.password_field :password %></p>

  <p><%= f.label :password_confirmation %><br />
  <%= f.password_field :password_confirmation %></p>

  <p><%= f.submit "Sign up" %></p>
<% end %>

<%= render :partial => "devise/shared/links" %>

一方、app/views/devise/sessions/new.html.erb ではパスワードとlogin名でログインできるように変更する。

<h2>Sign in</h2>

<%= form_for(resource, :as => resource_name, :url => session_path(resource_name)) do |f| %>
  <p><%= f.label :login %><br />
  <%= f.text_field :login %></p>

  <p><%= f.label :password %><br />
  <%= f.password_field :password %></p>

  <% if devise_mapping.rememberable? -%>
    <p><%= f.check_box :remember_me %> <%= f.label :remember_me %></p>
  <% end -%>

  <p><%= f.submit "Sign in" %></p>
<% end %>

<%= render :partial => "devise/shared/links" %>

Custom field for sign inにしたがい、config/initializers/devise.rbの変数「config.authentication_keys」を変更し、loginをSign inの際に使うようにする。

config.authentication_keys = [ :login ]

WebRickを立ち上げなおして、変更がうまくいっているかどうかを確認してみる。

RolesとPermissionsの設定

rolesとpermissionsを設定する。

% rails g scaffold Role rolename:string
% rails g model Permission

db/migrate以下にあるXXX_create_permissions.rbを編集する。

class CreatePermissions < ActiveRecord::Migration
  def self.up
    create_table :permissions do |t|
      t.integer :role_id, :user_id, :null => false
      t.timestamps
    end
  end

  def self.down
      drop_table :permissions
  end
end

db/seeds.rbを以下のように書く。

#Make sure the role migration file was generated first
Role.create(:rolename => 'administrator')
#Then, add default admin user 
#Be sure change the password later or in this migration file
user = User.new
user.login = "admin"
user.email = "info@yourapplication.com"
user.password = "administrator"
user.password_confirmation = "administrator"
user.save
user.confirm!
role = Role.find_by_rolename('administrator')
user = User.find_by_login('admin')
permission = Permission.new
permission.role = role
permission.user = user
permission.save

次に、app/models以下のファイルをいくつか変更しなければならない。はじめに、rolesとusersの間の関係を定義する。rolesとusersは多対多の関係なので、has_many :through宣言で関係を示す。

role.rb:

class Role < ActiveRecord::Base
  has_many :permissions
  has_many :users, :through => :permissions
end

permission.rb

class Permission < ActiveRecord::Base
  belongs_to :user
  belongs_to :role
end

usr.rbの編集については、リベンジ:Rails 2.3RC1で”Restful Authentication with all the bells and whistles”をやってみるではいろいろと行ったが、deviseの場合は特に何かをする必要はないらしい。Userモデルに格納されるデータのvalidationについては、config/initializers/devise.rbで定義する様子。

なので、user.rbにはとりあえず以下だけを付け加えておく。

has_many :permissions
has_many :roles, :through => :permissions

def has_role?(rolename)
  self.roles.find_by_rolename(rolename) ? true : false
end

roleのチェック

リベンジ:Rails 2.3RC1で”Restful Authentication with all the bells and whistles”をやってみるでは、authenticated_system.rbにnot_logged_in_required, check_role, check_administrator_roleと permission_denied が付け加えられている。

not_logged_in_requiredは、ログインしていない場合はアクセスでき、ログインしている場合は、元のページかuser_rootへ飛ばされるというフィルター。check_role(role)は、呼び出されたときcurrent_userが引数で与えられたroleを与えられているかどうかをチェックし、与えられていない場合は元のページかuser_rootへ飛ばすというメソッド。check_administrator_roleは、current_userにadministratorという役割が与えられているかどうかを真か偽で返すメソッド。permission_deniedは、元のページがリファラーに保存されていたら、そのページにそうでなければuser_rootへ飛ばすメソッド

app/controllers/application_controller.rbに上記を用意する。

class ApplicationController < ActionController::Base
  protect_from_forgery

  def check_role(role)
    unless user_signed_in? && @current_user.has_role?(role)
      if user_signed_in?
        permission_denied
      else
        store_referer
        access_denied
      end
    end
  end

  def check_administrator_role
    check_role('administrator')
  end  

  def not_logged_in_required
      user_signed_in? || permission_denied
  end

  def access_denied
    respond_to do |format|
      format.html do
        store_location
        flash[:error] = "You must be logged in to access this feature."
        redirect_to user_session_path
      end
      format.xml do
        request_http_basic_authentication 'Web Password'
      end
    end
  end

  def permission_denied
    respond_to do |format|
      format.html do
        host_name = App30.config.action_mailer.default_url_options
        domain_name = "http://#{host_name}"
        http_referer = session[:refer_to]
        if http_referer.nil?
          store_referer
          http_referer = ( session[:refer_to] || domain_name )
        end
        flash[:error] = "You don't have permission to complete that action."
        
        if /\A#{domain_name}/  =~ http_referer
          session[:refer_to] = nil
          redirect_to root_path
        else
          redirect_to_referer_or_default(root_path)
        end
      end
      format.xml do
        headers["Status"]           = "Unauthorized"
        headers["WWW-Authenticate"] = %(Basic realm="Web Password")
        render :text => "You don't have permission to complete this action.", :status => '401 Unauthorized'
      end
    end
  end

   # Store the URI of the current request in the session.
   #
   # We can return to this location by calling #redirect_back_or_default.
   def store_location
     session[:return_to] = request.request_uri
   end
   
   def store_referer
     session[:refer_to] = request.env["HTTP_REFERER"]
   end
   
   # Redirect to the URI stored by the most recent store_location call or
   # to the passed default.
   def redirect_back_or_default(default)
     redirect_to(session[:return_to] || default)
     session[:return_to] = nil
   end
   
   def redirect_to_referer_or_default(default)
     redirect_to(session[:refer_to] || default)
     session[:refer_to] = nil
   end

end

コントローラーの編集

restful_authenticationの場合、アカウントの作成などをすべて自前で用意する必要があったが、deviseでは既に用意済み。パスがどうなっているのか確認する。

% rake routes
                  root        /(.:format)                       {:action=>"index", :controller=>"pages"}
      new_user_session GET    /users/sign_in(.:format)          {:action=>"new", :controller=>"devise/sessions"}
          user_session POST   /users/sign_in(.:format)          {:action=>"create", :controller=>"devise/sessions"}
  destroy_user_session GET    /users/sign_out(.:format)         {:action=>"destroy", :controller=>"devise/sessions"}
         user_password POST   /users/password(.:format)         {:action=>"create", :controller=>"devise/passwords"}
     new_user_password GET    /users/password/new(.:format)     {:action=>"new", :controller=>"devise/passwords"}
    edit_user_password GET    /users/password/edit(.:format)    {:action=>"edit", :controller=>"devise/passwords"}
         user_password PUT    /users/password(.:format)         {:action=>"update", :controller=>"devise/passwords"}
     user_registration POST   /users(.:format)                  {:action=>"create", :controller=>"devise/registrations"}
 new_user_registration GET    /users/sign_up(.:format)          {:action=>"new", :controller=>"devise/registrations"}
edit_user_registration GET    /users/edit(.:format)             {:action=>"edit", :controller=>"devise/registrations"}
     user_registration PUT    /users(.:format)                  {:action=>"update", :controller=>"devise/registrations"}
     user_registration DELETE /users(.:format)                  {:action=>"destroy", :controller=>"devise/registrations"}
     user_confirmation POST   /users/confirmation(.:format)     {:action=>"create", :controller=>"devise/confirmations"}
 new_user_confirmation GET    /users/confirmation/new(.:format) {:action=>"new", :controller=>"devise/confirmations"}
     user_confirmation GET    /users/confirmation(.:format)     {:action=>"show", :controller=>"devise/confirmations"}
           user_unlock POST   /users/unlock(.:format)           {:action=>"create", :controller=>"devise/unlocks"}
       new_user_unlock GET    /users/unlock/new(.:format)       {:action=>"new", :controller=>"devise/unlocks"}
           user_unlock GET    /users/unlock(.:format)           {:action=>"show", :controller=>"devise/unlocks"}
                 roles GET    /roles(.:format)                  {:action=>"index", :controller=>"roles"}
                 roles POST   /roles(.:format)                  {:action=>"create", :controller=>"roles"}
              new_role GET    /roles/new(.:format)              {:action=>"new", :controller=>"roles"}
             edit_role GET    /roles/:id/edit(.:format)         {:action=>"edit", :controller=>"roles"}
                  role GET    /roles/:id(.:format)              {:action=>"show", :controller=>"roles"}
                  role PUT    /roles/:id(.:format)              {:action=>"update", :controller=>"roles"}
                  role DELETE /roles/:id(.:format)              {:action=>"destroy", :controller=>"roles"}
                 pages GET    /pages(.:format)                  {:action=>"index", :controller=>"pages"}
                 pages POST   /pages(.:format)                  {:action=>"create", :controller=>"pages"}
              new_page GET    /pages/new(.:format)              {:action=>"new", :controller=>"pages"}
             edit_page GET    /pages/:id/edit(.:format)         {:action=>"edit", :controller=>"pages"}
                  page GET    /pages/:id(.:format)              {:action=>"show", :controller=>"pages"}
                  page PUT    /pages/:id(.:format)              {:action=>"update", :controller=>"pages"}
                  page DELETE /pages/:id(.:format)              {:action=>"destroy", :controller=>"pages"}

user_controllerを作成する。上のrake routesで表示しているとおり、Userリソースに対するnew, create, edit, update, destroyはdeviseが担当して作ってくれているので不要。なので、showとindexだけ用意する。なお、管理者権限保有者がユーザーを強制退会させたい場合には、user_controllerにdestroyを用意する必要がある。deviseで用意されている"user_registration DELETE"は、現在、ログインしているユーザーが自分のアカウントを消すときの処理なので、強制退会目的にはつかえない。

% rails g controller Users index show

以下のように編集する。

class UsersController < ApplicationController
  layout 'application'

  before_filter :login_required, :only => [:show]
  before_filter :check_administrator_role, :only => [:index]

  def index
    @users = User.find(:all)
  end

  def show
    @user = current_user
  end
end

roles_controllerを編集する。

class RolesController < ApplicationController
  layout 'application'
  before_filter :check_administrator_role

  def index
    @user = User.find(params[:user_id])
    @all_roles = Role.find(:all)
  end

  def update
    @user = User.find(params[:user_id])
    @role = Role.find(params[:id])
    unless @user.has_role?(@role.rolename)
      @user.roles << @role
    end
    redirect_to :action => 'index'
  end
 
  def destroy
    @user = User.find(params[:user_id])
    @role = Role.find(params[:id])
    if @user.has_role?(@role.rolename)
      @user.roles.delete(@role)
    end
    redirect_to :action => 'index'
  end

end

ビューの編集

次はビューの編集。まず、最初にこのアプリケーションのレイアウトを作成する。app/view/layout/application.html.erb を次のようにする。

<!DOCTYPE html>
<html>
<head>
  <meta http-equiv="content-type" content="text/html;charset=UTF-8" />
  <title><%= @title -%></title>
  <%= stylesheet_link_tag :all %>
  <%= javascript_include_tag :defaults %>
  <%= csrf_meta_tag %>
</head>
<body>
<div class="notiece">
<p class="notice"><%= notice %></p>
<p class="alert"><%= alert %></p>
</div>
<p>
  <% if user_signed_in? %>
    Logged in as: <%= link_to h(current_user.login.capitalize), users_show_path(current_user) %> | 
    <%= link_to 'Edit Profile', edit_user_registration_path(current_user) %> |
    <%= link_to 'Change Password', edit_user_password_path(current_user) %> |
    <%= link_to 'Sign Out', destroy_user_session_path(current_user) %> |
    <% if current_user.has_role?('administrator') %>
      <%= link_to 'Administer Users', users_index_path %>
    <% end %>
  <% else %> 
    <%= link_to 'Sign In', new_user_session_path %> |
    <%= link_to 'Sign Up', new_user_registration_path %> |
    <%= link_to 'Forgot Password?', new_user_password_path %>
  <% end %>         
</p>
<hr>
<%= yield %>

</body>
</html>

app/views/roles/_role.html.erbの編集。

<li>
  <%= role.rolename %>
    <% if @user.has_role?(role.rolename) %>
      <%= link_to 'remove role', user_role_url(:id => role.id, :user_id => @user.id), :method => :delete %>
    <% else %>
      <%= link_to 'assign role', user_role_url(:id => role.id, :user_id => @user.id), :method => :put %>
    <% end %>
</li>

app/views/roles/index.html.erb:

<h2>Roles for <%=h @user.login.capitalize %></h2>

<h3>Roles assigned:</h3>
<ul><%= render :partial => 'role', :collection => @user.roles %></ul>

<h3>Roles available:</h3>
<ul><%= render :partial => 'role', :collection => (@all_roles - @user.roles) %></ul>

他のapp/views/rolesのビューに関しては削除しても良いし、拡張してもよい。

app/views/users/index.html.erbの編集。

<% @title= "All Users" %>
<h2><%= @title %></h2>
<table>
  <tr>
    <th>Username</th>
    <th>Email</th>
    <th>Roles</th>
  </tr>
    <%= render :partial => 'user', :collection => @users %>
</table>

app/views/users/_user.html.erbの編集。

<tr class="<%= cycle('odd', 'even') %>">
  <td><%=h user.login %></td>
  <td><%=h user.email %></td>
  <td><%= link_to 'edit roles', user_roles_path(user) %>]</td>
</tr>

app/views/users/show.html.erbの編集。

<% @title = "User:#{@user.login}" %>
<h2><%= @title %></h2>
<p>Joined on: <%= @user.created_at.to_s(:long) %></p>

config/routesの編集

以下のようにする。

App30::Application.routes.draw do

  root :to => "pages#index"

  devise_for :users

  get "users/index"
  get "users/show"

  resources :users do
    resources :roles
  end
  
  resources :pages
end

最後にpublic/index.htmlを削除する。

動作確認

% rake db:reset
% rake server

http://localhost:3000/pages にアクセスし、ID: admin, Password: administrator でログインしてみる。

おわりに

リベンジ:Rails 2.3RC1で”Restful Authentication with all the bells and whistles”をやってみるでやってみたこととほとんど同じことが以上で出来ているはず。restful_authenticationよりdeviseの方がいろいろと用意してあるので動かす段階まで用意するのはかなり楽だと思った。ただ、カスタマイズするとなるとブラックボックスに隠されているのでてこずりそうな予感。

Restful Authentication with all the bells and whistlesの後半に対応する deviseでOpenIDを使う方法に関しては、ククログ:Rails 3.0 beta4でDeviseを使ってOpenID認証で詳しく説明されている。