Rails devise / cancancan / rolify
Introduction
- devise : 提供註冊, 登入, 登出一整套 solution
- cancancan : 授權功能, 判斷 user 是否可以做什麼, 不可以做什麼
- rolify : 身份功能, 賦與 user 身份 ex: 一般 user 或 admin 等等..
cancancan 設計上跟 model 綁太緊我覺得實作上會有些綁手綁腳,而 rolify 適合用在 role 分很多的情況下使用,如果希望專案引入的東西單純一點,我建議只需要 devise 就夠了
Devise
安裝
-
Gemfile :
gem ‘bcrypt’
gem ‘devise’, ‘~> 3.5.6’
-
Init :
rails generate devise:install
rails generate devise User
rails generate devise:views
rake db:migrate
-
補上 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
-
設定預設為 zh-TW
, config/application.rb :
config.i18n.default_locale = :‘zh-TW’
-
修改 config/initializers/devise.rb
config.secret_key = ‘rake secret 產生的 key 貼到這裡’
config.mailer_sender = ‘dev@mail.com’
用 custom devise view (views/users 下)
config.scoped_views = true
-
[必要] 設定 mailer 的 host, 在 config/environments/development.rb
加在 block 裡面 :
config.action_mailer.default_url_options = { host: ’localhost:3000’ }
config.action_mailer.delivery_method = :sendmail
-
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
-
config/initializers/devise.rb
:
config.reconfirmable = false
-
顯示按鈕及訊息 app/views/layouts/application.html.erb
:
<% if notice %>
assets 要引入 jquery_ujs
, logout 才會 work, 因為要靠它送出 Http method 為 delete
的 request
- 完成!
如果 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
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
-
到 Google reCAPTCHA 申請
-
Install
gem “recaptcha”, :require => “recaptcha/rails”
-
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”)
-
使用
<%= form_for @foo do |f| %>
<%= recaptcha_tags %>
<% end %>
打開頁面就能看到了, 不需要特別引入 reCAPTCHA 的 <script>
, 因為 gem 已經幫你引入了
- 建立 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"}
- 完成! 如果不通過就會以 alert 的方式顯示
最好使用以下的步驟,不要單獨另外自已整合 oauth-facebook,會踩很多雷
-
先將 Facebook 的 App 申請好
-
安裝
gem ‘omniauth-facebook’, ‘~> 3.0.0’
-
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
-
config/initializers/devise.rb
config.omniauth :facebook, “APP_ID”, “APP_SECRET”
-
app/models/user.rb
在原本的後面加上這兩項
devise :omniauthable, :omniauth_providers => [:facebook]
devise 會自動加上這兩個 routes ( user_omniauth_authorize_path(provider)
, user_omniauth_callback_path(provider)
)
-
重新啟動 rails server
-
加上 Login Link
<%= link_to “Sign in with Facebook”, user_omniauth_authorize_path(:facebook) %>
Logout 用原本 devise 的登出就行了
-
加上 callback route
devise_for :users, :controllers => { :omniauth_callbacks => “users/omniauth_callbacks” }
-
建立 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
-
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
-
完成!
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
-
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
-
app/controllers/application_controller.rb
rescue_from CanCan::AccessDenied do |exception|
redirect_to root_url, :alert => exception.message
end
-
app/controllers/posts_controller.rb
load_and_authorize_resource
-
Check ability in view
<% if can? :index, Post %>
<%= link_to ‘Posts’, posts_path, class: ‘btn btn-success’ %>
<% end %>
-
測試
- 如果使用 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