2008年11月11日 星期二

Merb-Auth (4) 討厭的 Google OpenID (其實 Yahoo 更討厭)

為什麼說他們討厭呢?其實是因為他們不支援簡單好用的 SReg ,所以即使是登入成功,也拿不到使用者的個人資訊。還好 Google 還支援 AX (不過現在也只能拿到 email),Yahoo 的話,就只好乖乖的去用 BBAuth 了。

這篇文章就來擴充一下 default_openid 的功能,讓它也可以支援 google。

首先我們在 login form 上加上 "用google" 登入的按鈕:
<div>
<form action="<%= slice_url(:merb_auth_slice_password, :perform_login) %>" method="POST" accept-charset="utf-8"> <input type="hidden" name="_method" value="PUT" />
<div class="formRow">
<input type="submit" name="google_openid_url" value="用 GOOGLE 帳號登入" id="google_openid_url">
</div> <!-- close: formRow -->
</form>
</div>
再來編輯 merb/merb-auth/strategies.rb ,多加上一個 GoogleOpenID strategy:
require 'openid/extensions/ax'
class GoogleOpenID < Merb::Authentication::Strategies::Basic::OpenID
def run!
if params[:google_openid_url]
identity_url = 'https://www.google.com/accounts/o8/id'
begin
openid_request = consumer.begin(identity_url)
openid_ax = ::OpenID::AX::FetchRequest.new
ax_info = ::OpenID::AX::AttrInfo.new('http://axschema.org/contact/email','email',true)
openid_ax.add(ax_info)
openid_request.add_extension(openid_ax)
redirect!(openid_request.redirect_url("#{request.protocol}://#{request.host}", openid_callback_url))
rescue ::OpenID::OpenIDError => e
request.session.authentication.errors.clear!
request.session.authentication.errors.add(:openid, 'The OpenID verification failed')
nil
end
end
end
end
GoogleOpenID 只是繼承了 OpenID,然後將 run! 的部份改寫成,當發現 params 中有 google_openid_url 時,就發出一個 openid 的 request 給 google 的 server,原本 SReg 去要 nickname/email 的部份則改成用 AX 來要 email。這個 Strategy 是一定不會回傳登入物件的,因為 google 導回來我們的程式時,相關的參數會被 OpenID 那個 Strategy,所以我們也要將他稍微的改寫:
  def on_success!(response, sreg_response)
if user = find_user_by_identity_url(response.identity_url)
user
else
user = user_class.new
unless user.login = sreg_response.data['nickname']
ax_response = ::OpenID::AX::FetchResponse.from_success_response(response)
user.login = ax_response.data["http://axschema.org/contact/email"][0] if ax_response
user.login.sub!(/@.*/,'') if user.login
end

user.identity_url = response.identity_url
user.password = user.password_confirmation = Digest::SHA1.hexdigest("#{response.identity_url}#{rand(1000)}")
user.save ? user : nil
end
end
我們只改寫 on_success! 的部份。一開始先判斷是否已經用 SReg 取得了 nickname 和 email 了,如果拿不到的話,再用 AX 試試看能不能拿到 email,再將 email 的 @ 之前的部份暫且當做是 nickname,其它的部份就和原來的 OpenID 相同了。

其實要這樣寫應該是原本的 default_openid 不夠彈性。應該是在發出 OpenID request 給 OpenID Provider 之前提供一個 callback 讓使用者可以設定不同的需求才對。

2008年11月10日 星期一

Merb-Auth (3) 再來一種 Strategy

上一篇講了設定和簡單的流程原理。這一篇又要來寫些程式
先來看看最基本的 Strategy 大概的樣子:
class MyStrategy < Merb::Authentication::Strategy
def run!
#這裡要寫登入的邏輯,成功時傳回登入物件,失敗時傳回 false 或 nil
end
end
每個 Strategy 都會繼承 Merb::Authentication::Strategy 這個 class,並且有一個名為 run! 的 method。當 Merb-Auth 要 Login 時,就會一個一個 Strategy 的去跑這個 Method。當此 method 回傳一個非 false 的值時,Merb-Auth 認為登入成功。如果失敗的話,可以回傳 nil/false ,或者也可直接 redirect! 到某個位置,對過接下來其它的 Strategy (OpenID 就是這麼做的)。

讓我們把 Hello World 加上 OpenID 的功能吧!
首先要改變一下 User model,將 app/models/user.rb 加上一個 property,用來存此使用者所用的 openid:
property :identity_url, String, :length => 256
在 app/views/exceptions/unauthenticated.html.erb 加上 openid 專用的 form:
<div>
<form action="<%= slice_url(:merb_auth_slice_password, :perform_login) %>" method="POST" accept-charset="utf-8">
<input type="hidden" name="_method" value="PUT" />
<div class="formRow">
<label>OpenID
<input type="text" name="openid_url" value="" id="openid_url">
</label>
</div> <!-- close: formRow -->
<div class="formRow">
<input type="submit" name="Submit" value="登入" id="Submit">
</div> <!-- close: formRow -->
</form>
</div>
這個 form 最主要是讓使用者輸入他的 openid 到 openid_url 這個 field 裡去,其它都照抄原來的 password form。

最後在 merb/merb-auth/strategies.rb 裡加上這幾行:
Merb::Authentication.activate!(:default_openid)
class Merb::Authentication::Strategies::Basic::OpenID
def openid_callback_url
"#{request.protocol}://#{request.host}/"
end

def on_success!(response, sreg_response)
if user = find_user_by_identity_url(response.identity_url)
user
else
user = user_class.new
user.login = sreg_response.data['nickname']
user.identity_url = response.identity_url
user.password = user.password_confirmation = Digest::SHA1.hexdigest("#{response.identity_url}#{rand(1000)}")
user.save ? user : nil
end
end
end
第一行是將 default_openid 這個 strategy 開啟。接下來是 overwrite 它的一些 default 設定。

openid_callback_url 的回傳值是給 openid provider redirect back 用的。基本上就是一個需要 login 的 url,我們的例子中就用 http://localhost:4000/ 就可以了。

openid provider 回傳此使用者可以登人後,就會呼叫 on_success! 。
首先我們先用 find_user_by_identity_url 去找有沒有這個 user object,如果就就表示此人曾經用這個 openid 登入過,直接回值這個 user 就好了。如果沒有的話,就用建立一個新的使用者。(這裡這麼做是為了簡化起見,正式的做法應該是 redirect! 到一個註冊的頁面,讓使用者可以輸入/更改一些像是暱稱、生日之類的訊息。)

這樣就寫好了 opendid 支援。

Merb-Auth (2) 來龍去脈

上一篇我們快速生了一個有 login 功能的 merb app。這一篇要來好解釋一下這些東西的來龍去脈,還有各種可以設定的地方。

首先看看有哪些設定檔。(這裡可能講的有些零亂,不過先看過去,等把下面流程的部份對照一下就應該蠻清楚了。)

在 config/dependencies.rb 可以看到下面這三行:
dependency "merb-auth-core", merb_gems_version
dependency "merb-auth-more", merb_gems_version
dependency "merb-auth-slice-password", merb_gems_version
Merb-Auth 和 Merb 一樣,依據必要性的不同,分成三個部份。
  • merb-auth-core 包含了最基本的功能,像是上一篇講到的 session.user 或是 ensure_authenticated 這些做登入一定會用到的東西。
  • merb-auth-more 包含了一些好用的功能,像是已經寫好的三種登入方式(http-basic / password / openid),登入完可以導回原本要去的頁面的 redirect_back_or 等等。
  • merb-auth-slice-password 則是一個 slice 。slice 是 merb 的一個超強大功能,可以把他想成是一個小型的 app,讓你可以隨意的整合進其它的 app 裡面。我們上一篇,在我們還沒有寫 login form 的 view (app/views/exceptions/unauthenticated.html.erb) 時 ,用的就是這個 slice 已經幫我們寫好的!
預設是這三樣東西通通都啟用。如果你不想用某個部份,可以直接在這裡把他們 comment 掉。

接下來另一個重要的設定檔是 merb/merb-auth/setup.rb:
# Merb::Plugins.config[:"merb-auth"][:login_param]    = :email 
# Merb::Plugins.config[:"merb-auth"][:password_param] = :my_password_field_name
這兩行原本是被 comment 起來。它們是用來設定登入時所用的欄位名稱。預設是用 login/password,不過大家應該比較喜歡 username/password 吧!
Merb::Authentication.user_class = User
這行設定的是登入物件的 class 。所謂的登入物件就是你要用什麼物件來代表登入的這個人。像有人會喜歡用 Person / Account 之類的。
require 'merb-auth-more/mixins/salted_user'
Merb::Authentication.user_class.class_eval{ include Merb::Authentication::Mixins::SaltedUser }
這兩行將 salted password 的邏輯加到了剛剛設定的登入物件 class 裡面。如果你去翻一下上一篇我們的程式,在 app/models/user.rb 裡,你會看到並沒有設定 crypted_password,salt 等欄位,可是生出來的 User object 卻有,就是因為這兩行啦!
class Merb::Authentication

def fetch_user(session_user_id)
Merb::Authentication.user_class.get(session_user_id)
end

def store_user(user)
user.nil? ? user : user.id
end
end
這幾行是告訴 Merb-Auth 怎麼從 session 中取得的東西轉換成登入物件(fetch_user),或是怎麼從登入物件轉換成 session 中所存的東西(store_user)。因為有這個設定的關係,你的登入物件可以和 database 完全沒有關係,甚至只是一個 Boolean 都可以(就是只有登入和沒登入兩種情況,不管登入的人是誰)。預設的 fetch_user 是用 datamapper 去資料庫拿物件出來,如果你用的是 AR 就要改這裡了。

還有一另一個設定檔 merb/merb-auth/strategies.rb
Merb::Authentication.activate!(:default_password_form)
Merb::Authentication.activate!(:default_basic_auth)
這裡是將 default_password_form 和 default_basic_auth 這兩個登入的方式啟動。如果你想用別的認證方式,像是 openid / ldap / AD 之類的,就可以寫在這裡。

接下來我們來解釋一下整個登入的流程。

當我們要去看一個需要登入才能看的頁面時(例子中的 /hello/index 或是 /hello/wazzap ),Merb-Auth 會先看看 session[:user] 是否有東西,有的話表示已經登入了。如果沒有登入的話,它會用各種的方式去登入。這些方式被稱為 Strategies,也就是在 merb/merb-auth/strategies.rb 裡設定的東西。

這些 Strategy 可以取得 Request 的各種參數(cookie/session/url/route/params...),用它們來判斷這個 session 是不是可以成功的登入。當某一個 Strategy 成功時,Merb-Auth 會用設定的 store_user 將 user 存進 session 裡,這樣下一次 request 再來時就可以直接跳過登入的過程了。

當所有的 Strategies 都失敗的時候,Merb-Auth 並不像 Rails 一般的做法,丟一個 302 redirect 到登入的 action 去,而是直接丟 401 Unauthorized 錯誤出來。(你可以用 firebug 去看他的 response status code。別告訴我你沒裝這個 WebDeveloper 最好的朋友。)

那為什麼 401 錯誤會有個 login 的 form 呢?這就是 merb 好玩的地方了!當程式執行中發生的 Exception,都會被 merb 自己 catch 起來,幫他放一個 status code ,然後 re-route 到 app/controllers/exceptions.rb 的某個 action 去。 Merb-Auth 發出來的 Exception 是自己定義的 Unauthenticated,它的 status code 401 ,而對應到的 action 就是 Exceptions#unauthenticated 了。

這個 controller 和一般的 controller 沒什麼兩樣,也可以用 layout,可以用 before/after filter,可以用 template。merb-auth-slice-password 貼心的(或者應該說是雞婆的)幫我們準備了 Exceptions#unauthenticated 這個 aciton 還有它的 view 。所以你如果要改變登入 form 樣子,就自己寫 app/controllers/exceptions.rb 的 unauthenticated 這個 method 和 app/views/exceptions/unauthenticated.html.erb 這個 file 吧。

這一篇我們說明了 Merb-Auth 大致的原理,下一篇就幫我們的 Hello 加上 OpenID 的支援。

Merb-Auth (1) 快速上手

幾乎每個 WebApp 一開始要都是先把登入登出做起來吧?

Merb-Auth 是 Merb 預設的 Authentication framework,非常的彈性,和一般 Rails 的做法(對,就是 restful_authetication 和他的徒子徒孫),相當的不同。MerbAuth 它只做一件事:登入。所以有關使用者管理、像是註冊,認證email、重設密碼之類的,事通通要你自己來

這一篇文章我們先完全用 Merb 初始的設定,來完成一個有登入功能的 HelloWorld,下篇我們再慢慢解釋其中的原理,如何做各種的設定。

首先,產生一個新的 merb app
$ merb-gen app hello_world
$ cd hello_world
merb-gen 出來的程式,預設就會開啟 Merb-Auth 的功能,使用 username/password 的認證方式,它會假設你要用 User 這個 model,所以就幫你建好在 app/models/user.rb 裡了。
$ rake db:automigrate
這會將 User model 的 schema 寫到 db 裡。因為 MerbAuth 並沒有註冊的功能,所以我們先手動建立一個 user:
$ merb -i
>> u = User.new(:login => "new_user")
>> u.password = u.password_confirmation = "12345"
>> u.save
接下來我們隨便產生一個頁面,就叫它 /hello/index 吧:
$ merb-gen controller hello
然後修改 config/routes.rb,把首頁指到 /hello/index 去:
Merb::Router.prepare do
slice(:merb_auth_slice_password, :name_prefix => nil, :path_prefix => "")
authenticate do
match('/').to(:controller => 'hello', :action =>'index')
end
end
用 authenticate do ... end 將 route 包住,就會讓它必需要登入才能看到。
這樣就好了!啟動 server
$ merb
用 browser 開啟 http://localhost:4000/ 就會看到登入的畫面。
輸入正確的 username/password (我們的例子中是 new_user/12345) 後,就會看到 hello/index 的內容。

登入後,我們可以用 session.user 取得 user object。讓我們修改一下 app/view/hello/index.html.erb,可以看到登入者的名字,順便也把登出的 link 做上去:
<div>Hello!!! <%= session.user.login %></div>
<%= link_to "登出", url(:logout) %>
除了用 authenticate 來包住 route 之中,也可以用 before filter 的方式,在 app/controllers/hello.rb 再加上一個 action:
before :ensure_authenticated, :only => 'wazzap'
def wazzap
"wazzap!! #{session.user.login}"
end
還有那個登入的 form 實在是太醜了!想要改變的話,可以寫這個檔案 app/views/exceptions/unauthenticated.html.erb:

<h3>還是很醜的登入 FORM</h3>

<div>
<%= session[:return_to].inspect %>
<%= error_messages_for session.authentication %>
<% @login_param = Merb::Authentication::Strategies::Basic::Base.login_param %>
<% @password_param = Merb::Authentication::Strategies::Basic::Base.password_param %>
<form action="<%= slice_url(:merb_auth_slice_password, :perform_login) %>" method="POST" accept-charset="utf-8">
<input type="hidden" name="_method" value="PUT" />
<div class="formRow">
<label>帳號:
<input type="text" name="<%= @login_param.to_s %>" value="" id="<%= @login_param.to_s %>">
</label>
</div> <!-- close: formRow -->
<div class="formRow">
<label>密碼:
<input type="password" name="<%= @password_param.to_s %>" value="" id="<%= @password_param.to_s %>">
</label>
</div> <!-- close: formRow -->
<div class="formRow">
<input type="submit" name="Submit" value="登入" id="Submit">
</div> <!-- close: formRow -->
</form>
</div>
下一篇我們會解釋為什麼這個 form 的位置這麼的奇怪。