Jex’s Note

Rails - Devise / Cancancan / Rolify

介紹

  • devise : 提供註冊, 登入, 登出一整套 solution
  • cancancan : 授權功能, 判斷 user 是否可以做什麼, 不可以做什麼
  • rolify : 身份功能, 賦與 user 身份 ex: 一般 user 或 admin 等等..

cancancan 設計上跟 model 綁太緊我覺得實作上會有些綁手綁腳,而 rolify 適合用在 role 分很多的情況下使用,如果希望專案引入的東西單純一點,我建議只需要 devise 就夠了

Devise

安裝

1) Gemfile :

gem 'bcrypt'
gem 'devise', '~> 3.5.6'
  • devise 預設使用 bcrypt 做加密

2) Init :

rails generate devise:install
rails generate devise User
rails generate devise:views
rake db:migrate

3) 補上 zh-TW 旳 rails-i18n 及 devise i18n :

rails i18n (validate 等等會用到的 i18n) 及 devise i18n (devise 專屬的 i18n, 主要是一些流程的訊息) 都有套件可以直接引入,但我不這麼做,因為如果有訊息需要客製會不好改,所以我建議還是手動新增 i18n

  • 預設安裝完 devise 只會產生 config/locales/devise.en.yml, 這樣訊息只會有英文版
  • 新增 devise.zh-TW.yml, 內容從devise-i18n 這裡 copy
  • 新增 rails-i18n.zh-TW.yml, 內容從rails-i18n 這裡 copy

4) 設定預設為 zh-TW, config/application.rb :

config.i18n.default_locale = :'zh-TW'

5) 修改 config/initializers/devise.rb

config.secret_key = 'rake secret 產生的 key 貼到這裡'

# 寄件人 (email header 的 From 及 Reply-To)
config.mailer_sender = 'dev@mail.com'

# 用 custom devise view (views/users 下)
config.scoped_views = true

6) [必要] 設定 mailer 的 host, 在 config/environments/development.rb 加在 block 裡面 :

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

7) Confirm email

首先到 app/models/user.rb 加上 :confirmable

devise :database_authenticatable, :registerable,
           :recoverable, :rememberable, :trackable, :validatable, :confirmable

再增加 confirm 欄位到 users

rails g migration add_confirmable_to_devise

migrate/20150703173329_add_confirmable_to_devise.rb (不要使用 change) :

class AddConfirmableToDevise < ActiveRecord::Migration
  # Note: You can't use change, as User.update_all will fail in the down migration
  def up
    add_column :users, :confirmation_token, :string
    add_column :users, :confirmed_at, :datetime
    add_column :users, :confirmation_sent_at, :datetime
    # add_column :users, :unconfirmed_email, :string # Only if using reconfirmable
    add_index :users, :confirmation_token, unique: true
    # User.reset_column_information # Need for some types of updates, but not for update_all.
    # To avoid a short time window between running the migration and updating all existing
    # users as confirmed, do the following

    # mysql 才能執行
    # execute("UPDATE users SET confirmed_at = NOW()")

    # All existing user accounts should be able to log in after this.
    # Remind: Rails using SQLite as default. And SQLite has no such function :NOW.
    # Use :date('now') instead of :NOW when using SQLite.
    # => execute("UPDATE users SET confirmed_at = date('now')")
    # Or => User.all.update_all confirmed_at: Time.now
  end

  def down
    remove_columns :users, :confirmation_token, :confirmed_at, :confirmation_sent_at
    # remove_columns :users, :unconfirmed_email # Only if using reconfirmable
  end
end

8) config/initializers/devise.rb :

config.reconfirmable = false

9) 顯示按鈕及訊息 app/views/layouts/application.html.erb :

<% if notice %>
  <p class="alert alert-success"><%= notice %></p>
<% end %>
<% if alert %>
  <p class="alert alert-danger"><%= alert %></p>
<% end %>

<p class="navbar-text pull-right">
    <% if user_signed_in? %>
      Logged in as <strong><%= current_user.email %></strong>.
      <%= link_to 'Edit profile', edit_user_registration_path, :class => 'navbar-link' %> |
      <%= link_to "Logout", destroy_user_session_path, method: :delete, :class => 'navbar-link'  %>
    <% else %>
      <%= link_to "Sign up", new_user_registration_path, :class => 'navbar-link'  %> |
      <%= link_to "Login", new_user_session_path, :class => 'navbar-link'  %>
    <% end %>
</p>

assets 要引入 jquery_ujs, logout 才會 work, 因為要靠它送出 Http method 為 delete 的 request

10) 完成!

如果 sign up 後沒有收到信

看 rails log 有沒有組出 email 的內容,

有的話可以直接 copy email 內容的 confirm link, 先手動 confirm email

http://localhost:3000/users/confirmation?confirmation_token=hhdRjPszSyPS5w8iczwH

不使用第三方服務,用本機寄信很可能被 gmail 擋掉, 可以先以 sendmail 下指令看 /var/log/mail.log 的錯誤訊息

判斷是否登入

application_controller.rb :

before_action :authenticate_user!

現在應該就可以看到效果了, 首頁會顯示 notice 及 alert

判斷 login status

在 User 新增 status 欄位用來判斷帳號的登入狀態,透過 devise 登入時我們僅需要多加兩個 method 在 user.rb, devise 會自動使用它們

models/user.rb :

enum status: {
  freeze: 0,            # 無法登入
  active: 1,            # 可以正常登入
}

def active_for_authentication?
  super && self.active?                 # status 是 active 才通過,否則觸發下面的錯誤訊息
end

# 錯誤訊息
def inactive_message
  if !self.active?
    :locked                             # 會顯示 i18n (`zh-TW.devise.failure.locked`) 的錯誤訊息
  else
    super # Use whatever other message
  end
end
  • 凍結的帳號,即使已經登入了,操作其他頁面時仍然會被強制登出
  • 如果你有整合 facebook 登入,用戶使用 Facebook 登入時,它也會檢查 status 是否為 active,行為如同使用 devise 登入

判斷原密碼是否正確

User.find(2).valid_password?('00000000')
=> true

驗證碼

class User::SessionsController < ::Devise::SessionsController
  before_action :check_otp, :only => [:create]

  private
  def check_otp
    return true unless user
    return true unless user.profile_otp
    if xxx
      flash[:warning] = "錯誤,請檢查OTP驗證碼"
    end
  end
end

設定 session 存活時間

一般來說, session 存活的時間是看 browser 的 cookie 存活時間, 但也可以透過 devise 去限制 session 存活時間

models/user.rb :

devise :timeoutable

config/initializers/devise.rb :

config.timeout_in = 30.minutes

開發環境可一鍵登入

讓連結 GET users/sign_in?user_id=1 可以直接登入

view :

<%= link_to '一鍵登入', new_user_session_path + "?user_id=#{user.id}", class: 'btn btn-info btn-xs' %>

controllers/users/sessions_controller.rb :

class Users::SessionsController < ::Devise::SessionsController
  before_action :fast_pass, only: [:new]

  private

  def fast_pass
    # 限制測試環境才可以
    if Rails.env.development? && params[:user_id]
      sign_in(:user, User.find(params[:user_id]))
      flash[:notice] = '登入成功'
      redirect_to root_path
      return
    end
  end
end

config/routes.rb

# 將 devise_for 改成 :
devise_for :users, :controllers => {:sessions => "users/sessions"}

註冊加入自訂欄位

情景 : 判斷 user sign_up 選擇的 role 再做 assign

views/users/registrations/new.html.erb
<%= f.input :role, collection: ['user','translator'], prompt: "Select your age", selected: 21 %>
models/user.rb
attr_accessor :role
validates :role, presence: true
after_create :assign_role

def assign_role
  if self.role == 'translator'
    self.add_role(:translator)
  else
    self.add_role(:user)
  end
end
controllers/application_controller.rb
before_filter :configure_permitted_parameters, if: :devise_controller?

protected

def configure_permitted_parameters
  devise_parameter_sanitizer.for(:sign_up) << :role
end

其他寫法

devise_parameter_sanitizer.for(:sign_up) {|u|u.permit(:name,:nick_name,:mobile,:email,:password,:password_confirmation)}
devise_parameter_sanitizer.for(:account_update) << :full_name

可參考 strong parameter

改變註冊完成去的頁面

routes :

devise_for :users, :controllers => { registrations: "users/registrations" }

controllers/users/registrations_controller.rb

如果沒有開啟 email confirmable 的話, 使用這個 method

class Users::RegistrationsController < Devise::RegistrationsController
  protected

  def after_sign_up_path_for(resource)
    root_path
  end
end

有開啟 email confirmable 使用它

class Users::RegistrationsController < Devise::RegistrationsController
  protected

  # 剛註冊完 "確認信已寄到您的 Email 信箱。請點擊信內連結以啓動您的帳號"
  def after_inactive_sign_up_path_for(resource)
    root_path
  end
end

Edit 時不能改變 Email 及不用再輸入密碼

routes :

devise_for :users, :controllers => { registrations: "users/registrations" }

controllers/users/registrations_controller.rb

class Users::RegistrationsController < Devise::RegistrationsController

  def update
    # 排除 email 不給改
    account_update_params = devise_parameter_sanitizer.sanitize(:account_update).except(:email)
    @user = User.find(current_user.id)

    # 判斷此項修改是否需要密碼
    if needs_password?
      successfully_updated = @user.update_with_password(account_update_params)
    else
      # 不需密碼
      account_update_params.delete('password')
      account_update_params.delete('password_confirmation')
      account_update_params.delete('current_password')
      successfully_updated = @user.update_attributes(account_update_params)
    end

    if successfully_updated
      set_flash_message :notice, :updated
      sign_in @user, :bypass => true
      redirect_to edit_user_registration_path
    else
      render 'edit'
    end
  end

  protected

  def needs_password?
    params[:user][:password].present?
  end
end

如果只需要修改密碼

def update
  @user = User.find(current_user.id)
  if @user.update_with_password(account_update_params)
    set_flash_message :notice, :updated
    sign_in @user, :bypass => true
    redirect_to edit_user_registration_path
  else
    render 'edit'
  end
end

def account_update_params
  params[:user].permit(:password, :password_confirmation, :current_password)
end

關閉預設有的 Unhappy? Cancel my account 功能

routes :

devise_for :users, skip: :registrations
devise_scope :user do
  resource :registration,
    only: [:new, :create, :edit, :update],
    path: 'users',
    path_names: { new: 'sign_up' },
    controller: 'devise/registrations',                     # 如果有改原生的, 路徑要指向正確 ex: users/registrations  -> users/registrations_controller.rb
    as: :user_registration do
      get :cancel
    end
end

你會發現原本刪除帳號的 route 己經不見了 :

edit_user_registration DELETE /users(.:format)                  users/registrations#destroy

主要是剛剛 route 的 only 那段把 :delete 拿掉,如果不想用 devise 提供的修改 user 功能,也可以再把 :edit :update 拿掉

不使用 devise 的修改資料功能

先拿掉 devise 修改基本資料功能

devise_for :users, skip: :registrations
devise_scope :user do
  resource :registration,
    only: [:new, :create],          # 拿掉 :edit, :update
    path: 'users',
    path_names: { new: 'sign_up' },
    controller: 'devise/registrations',                     # 如果有改原生的, 路徑要指向正確 ex: users/registrations  -> users/registrations_controller.rb
    as: :user_registration do
      get :cancel
    end
end

resources :users, only: [:edit, :update]

雖然可以直接複寫 devise 的 method,但如果要客製自已功能的話,建議還是自已建新的 controller, view

controllers/users_controller.rb :

class UsersController < ApplicationController

  def edit
  end

  def update
    # 修改基本資料
    current_user.update(params[:user].permit(:name, :phone, :address))

    # 修改密碼
    if current_user.update(params.require(:user).permit(:password, :password_confirmation))
      return redirect_to root_path          # 修改密碼成功後 session 會被清掉,所以返回首頁
    end

    redirect_to edit_user_path(current_user)
  end

end

views/users/edit.html.erb

<h2>修改會員資料</h2>

<%= form_for current_user, url: user_path(current_user), html: { method: :put } do |f| %>

<div>  姓名:<%= f.text_field :name %></div>
<div>  電話:<%= f.text_field :phone %></div>
<div>  地址:<%= f.text_field :address %></div>

<hr>
<div>  密碼:<%= f.password_field :password %></div>
<div>  確認密碼:<%= f.password_field :password_confirmation %></div>
<%= f.submit 'OK' %>
<% end %>

ref : Allow users to edit their password

After email confirmation

routes :

devise_for :users, controllers: { confirmations: "users/confirmations" }

controllers/users/confirmations_controller.rb

class Users::ConfirmationsController < ::Devise::ConfirmationsController

    protected
    def after_confirmation_path_for(resource_name, resource)
      resource.status = 11
      resource.save
      root_path
    end

end

覆寫 devise sign_in 後去的頁面

controllers/application_controller.rb

def after_sign_in_path_for(resource)
  sign_in_url = new_user_session_url
  if request.referer == sign_in_url
    super
  else
    stored_location_for(resource) || request.referer || root_path
  end
end

加上 reCAPTCHA

1) 到 Google reCAPTCHA 申請

2) Install

gem "recaptcha", :require => "recaptcha/rails"

3) config/initializers/recaptcha.rb

Recaptcha.configure do |config|
  config.public_key  = '6Lc6BAAAAAAAAChqRbQZcn_yyyyyyyyyyyyyyyyy'           # Site key
  config.private_key = '6Lc6BAAAAAAAAKN3DRm6VA_xxxxxxxxxxxxxxxxx'           # Secret key
end

# [Optional] Skip Test Env
Recaptcha.configuration.skip_verify_env.delete("test")

4) 使用

<%= form_for @foo do |f| %>
  <%= recaptcha_tags %>
<% end %>

打開頁面就能看到了, 不需要特別引入 reCAPTCHA 的 <script>, 因為 gem 已經幫你引入了

5) 建立 RegistrationsController

users/registrations_controller.rb

class RegistrationsController < Devise::RegistrationsController
    def create
      if verify_recaptcha
        super
      else
        build_resource(sign_up_params)
        clean_up_passwords(resource)
        flash.now[:alert] = 'reCAPTCHA 未通過, 請再試一次'
        flash.delete :recaptcha_error
        render :new
      end
    end
end

routes :

devise_for :users, controllers: {registrations: "users/registrations"}

6) 完成! 如果不通過就會以 alert 的方式顯示

oauth-facebook

最好使用以下的步驟,不要單獨另外自已整合 oauth-facebook,會踩很多雷

0) 先將 Facebook 的 App 申請好

1) 安裝

gem 'omniauth-facebook', '~> 3.0.0'

2) Migrate

rails g migration add_oamniauth_to_users

add_column :users, :oauth_provider, :string
add_column :users, :oauth_uid, :string
add_column :users, :oauth_token, :string
add_column :users, :oauth_expires_at, :datetime

3) config/initializers/devise.rb

config.omniauth :facebook, "APP_ID", "APP_SECRET"

4) app/models/user.rb

# 在原本的後面加上這兩項
devise :omniauthable, :omniauth_providers => [:facebook]

devise 會自動加上這兩個 routes ( user_omniauth_authorize_path(provider), user_omniauth_callback_path(provider) )

5) 重新啟動 rails server

6) 加上 Login Link

<%= link_to "Sign in with Facebook", user_omniauth_authorize_path(:facebook) %>

Logout 用原本 devise 的登出就行了

7) 加上 callback route

devise_for :users, :controllers => { :omniauth_callbacks => "users/omniauth_callbacks" }

8) 建立 app/controllers/users/omniauth_callbacks_controller.rb

class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
  def facebook
    # You need to implement the method below in your model (e.g. app/models/user.rb)
    @user = User.from_omniauth(request.env["omniauth.auth"])

    if @user.persisted?
      sign_in_and_redirect @user, :event => :authentication #this will throw if @user is not activated
      set_flash_message(:notice, :success, :kind => "Facebook") if is_navigational_format?
    else
      session["devise.facebook_data"] = request.env["omniauth.auth"]
      redirect_to new_user_registration_url
    end
  end

  def failure
    redirect_to root_path
  end
end

9) models/user.rb

def self.from_omniauth(auth)
  where(oauth_provider: auth.provider, oauth_uid: auth.uid).first_or_create do |user|
    user.email = auth.info.email
    user.password = Devise.friendly_token[0,20]         # 密碼必填
    user.confirmed_at = Time.now                        # 直接通過 confirmation
    user.oauth_token = auth.credentials.token
    user.oauth_expires_at = Time.at(auth.credentials.expires_at)
    # user.name = auth.info.name   # assuming the user model has a name
    # user.image = auth.info.image # assuming the user model has an image
  end
end

def self.new_with_session(params, session)
  super.tap do |user|
    if data = session["devise.facebook_data"] && session["devise.facebook_data"]["extra"]["raw_info"]
      user.email = data["email"] if user.email.blank?
    end
  end
end

10) 完成!

Devise view 取得適當的錯誤訊息

以重寄驗證信來說,如果 User 已經 confirm 過再寄驗證信,預設會得到不那麼好看的錯誤訊息

<%= devise_error_messages! %>

顯示 :

有 1 個錯誤導致 用戶 不能被儲存:
電子郵箱 已經驗證,請直接登入。

但我們僅需要直接跟使用者說原因就好

<%= resource.errors.full_messages_for(:email).first %>
<%= resource.errors.messages[:email].first if resource.errors.messages.include?(:email) %>

未登入情況下觸發 ajax 得到 401

如果在未登入的情況下做一些 ajax 的操作,ajax 的 request 可能會因為 authenticate_user 得到 401 且內容為 “您需要先登入或註冊後才能繼續。”, 為了讓這個訊息可以正確被顯示出來要加上以下的 js

$(document).ajaxError(function (e, xhr, settings) {
    if (xhr.status == 401) {
        alert(xhr.responseText);
    }
});

讓 devise 輸出 json api

要在 application_controller.rb 加上 :

respond_to :html, :json

在 devise 裡面的 controller e.g. registrations_controller.rb 是沒有用的

Rolify

安裝

Gemfile :

gem 'rolify'

Init :

rails generate rolify Role User
rake db:migrate

Method

Add role

u1 = User.create({email: "example@gmail.com", password: "00000000"})
u1.add_role :root

Remove role

# rolify 會很聰明的去找 users_roles TABLE 目前這個 role 是不是最後一個 role 正在被使用, 如果是的話, 它不但會刪 users_roles 記錄, 也會去 roles 刪那筆 role
u.remove_role(:admin)

Has role?

u1.has_role? :admin
 => true

Find all users with specific role

User.with_role(:admin)

Find all users with A role or B role

User.with_any_role(:user, :admin)

Find all users with A role and B role

User.with_all_roles(:user, :admin)

Cancancan

安裝

Gemfile

gem 'cancancan'

Init :

rails generate cancan:ability
rake db:migrate

Example

1) app/models/ability.rb :

class Ability
  include CanCan::Ability

  def initialize(user)

    if user.blank?

      cannot :manage, :all

    elsif user.has_role?(:root) || user.has_role?(:admin)

      can :manage, :all

    elsif user.has_role?(:translator)

      can :index, Post

    elsif user.has_role?(:user)

      cannot :index, Post
    end

  end
end

這個 user 其實就是 devise 提供你在 controller 使用的 current_user

2) app/controllers/application_controller.rb

rescue_from CanCan::AccessDenied do |exception|
  redirect_to root_url, :alert => exception.message
end

3) app/controllers/posts_controller.rb

load_and_authorize_resource

4) Check ability in view

<% if can? :index, Post %>
  <%= link_to 'Posts', posts_path, class: 'btn btn-success' %>
<% end %>

4) 測試

  • 如果使用 user 的帳號不會看到 Post button, 即使打網址到 /posts, 也被導到首頁, 並顯示 You are not authorized to access this page.
  • 如果使用 translator, admin, admin 可正常看到 Post button, 也能正常到 /posts

Action

  • :manage : 這個 controller 內所有的 action
  • :read : :index:show
  • :update : :edit:update
  • :destroy : :destroy
  • :create : :new:create
  • :index : :index, 也可特別指定 action name
  • :qq : 也可指定非 restful 的 action, 只要是 controller 下的 action, 都能限制
  • :all : 所有 object (resource), 也就是 model name

也能這樣寫 :

can :read, [ Post, Comment ]
can [ :create, :update ], [ Post, Comment ]

Alias action

alias_action :update, :destroy, :to => :modify
can :modify, Comment

or method

protected

def basic_read_only
  can :read,    Post
  can :list,    Post
  can :search,  Post
end

Ability

只能編輯自己的文章

can :update, Post do |post|
  (post.user_id == user.id)
end

# 也可以這樣寫
can :update, Post, user_id: user.id

View 注意傳入的物件

是否能建立 Post

<% if can? :create, Post %>
  <%= link_to 'New Post', new_post_path, class: 'btn btn-success' %>
<% end %>

傳入的是 Post

只能編輯自己的 Post

<% @posts.each do |post| %>
    <% if can? :update, post %>
      <%= link_to 'Edit', edit_post_path(post), class: 'btn btn-warning btn-xs' %>
    <% end %>
<% end %>

注意! each 裡傳入的是 post 而不是 Post, 否則 view 會與 ability.rb 的 can :update, Post, user_id: user.id 不一致

捕捉 Access Deny Error

rescue_from CanCan::AccessDenied do |exception|
  flash[:warning] = exception.message
  redirect_to root_path
end

raise CanCan::AccessDenied.new("You are not authorized to perform this action!", :custom_action, Project)

測試將 post_controller.rb 的 load_and_authorize_resource 拿掉

view 仍然會依照 ability.rb 的設定顯示或不顯示,

如果權限不夠, 直接去 /posts 也是能正常瀏覽,

但如果將 load_and_authorize_resource 加回來,

就會導致 Access Deny 了, 符合預期結果,

ability.rb 設定完一定要在 post_controller.rb 加上 load_and_authorize_resource 才能真正起到權限管理作用

Authorization for Namespaced Controllers

controllers/dashboard/dashboard_controller.rb

class Dashboard::DashboardController < ApplicationController

controllers/dashboard/log_controller.rb

class Dashboard::LogController < Dashboard::DashboardController

application_controller.rb

private

def current_ability
  # I am sure there is a slicker way to capture the controller namespace
  controller_name_segments = params[:controller].split('/')
  controller_name_segments.pop
  controller_namespace = controller_name_segments.join('/').camelize
  Ability.new(current_user, controller_namespace)
end

ability.rb

class Ability
  include CanCan::Ability

  def initialize(user, controller_namespace)
    case controller_namespace
    when 'Dashboard'
      can :manage, Log
    end
  end
end

uninitialized constant Welcome 錯誤

load_and_authorize_resource 會自動使用 RESTful style 驗證 Controller 及依 Controller Name 去找對應的 Model

會發生這原因是沒有 Welcome model

很多情況我們的 Controller 可能不會有對應的 model,

所以我們可以用以下兩種方式達到 authorize 這個 controller

1) Call authorize

這個 controller 不要寫 authorize_resource

直接在 action 寫

def roll_logs authorize! :roll, :logs end

在 Ability(models/ability.rb) 加上

can :roll, :logs if user.admin?

authorize 可以通過兩個 symbol 給予這個 authorize 的行為命名而不需要跟 controller 或 model name 一樣

2) 讓 Ability 不要去找它的 model

class ToolsController < ApplicationsController
  authorize_resource class: false
  def show
  end
end

它會自動地去 call authorize!(:show, :tool)

在 Ability 加上 :

can :show, :tool if user.admin?

其他

預設 resource 與 Controller 同名

load_and_authorize_resource = load_resource + authorize_resource

load_resource 會自動在 Action 裡執行對應的 instance

new :
    @post = Post.new`

show :
    @post = Post.find(params[:id])

authorize_resource 的 resource 與 models/ability.rb 設定的相同, ex: can :manage, Post

如果是 PostController 預設的 resource 就是 Post, instance 也就是 @post, 也可以另外指定

authorize_resource :message

can :manage, Message

Comments