(These were posted in reverse order. If you came here from Railscast 160 and Railscast 170, start at the bottom of this README and work your way up.)
First we’ll need the Facebooker plugin:
script/plugin install git://github.com/mmangino/facebooker.git
Two things the readme says you can add to your application controller:
ensure_application_is_installed_by_facebook_user # optional - ensures users have added the application in facebook to ensure access all of facebooker‘s features. filter_parameter_logging :fb_sig_friends # recommended - prevents a violation of Facebook Terms of Service while reducing log bloat
If you get a Facebooker::AdapterBase::UnableToLoadAdapter
error like I did after installing Facebooker, it’s because you haven’t configured config/facebooker.yml
yet. So open it up and add the relevant info from your Facebook app signup – see wiki.developers.facebook.com/index.php/Connect/Setting_Up_Your_Site
An example facebooker.yml file: (Obviously you MUST replace the api_key and secret_key.)
development: api_key: a123b4567890b1cde23f456ghi7jk89l secret_key: a1234567bc8d9ef0g1h23i456789j01k canvas_page_name: callback_url: http://localhost:3000/ pretty_errors: true set_asset_host_to_callback_url: false api: new tunnel: public_host_username: public_host: public_port: 4007 local_port: 3000 server_alive_interval: 0
Okay, back to our terminal work:
script/generate xd_receiver script/generate migration AddFacebookFieldsToUser fb_user_id:integer email_hash:string
The first command creates cross-domain receiver files for Facebook callbacks. The second creates a migration, which we need to edit to say:
class AddFacebookFieldsToUser < ActiveRecord::Migration def self.up add_column :users, :fb_user_id, :integer, :limit => 8 add_column :users, :email_hash, :string end def self.down remove_column :users, :email_hash remove_column :users, :fb_user_id end end
The :limit => 8
makes the :integer act as a bigint where possible.
rake db:migrate
Now we modify application.html.erb
to add the Facebook Connect JavaScript and so on.
Just before the </body>
(to speed up HTML loading), add:
<%= javascript_include_tag :defaults%> <%= fb_connect_javascript_tag %> <%= init_fb_connect %>
And where you want the Facebook Connect sign in / sign out to be, you can put code like the following:
<%- if current_user && current_user.fb_user_id -%> <fb:profile-pic uid="<%= current_user.fb_user_id %>" facebook-logo="true" size="thumb" ></fb:profile-pic> <p><a href="#" onclick='FB.Connect.logoutAndRedirect("/logout")'>Logout of Facebook Connect</a></p> <%- else -%> <%= fb_login_button('window.location = "/login/Facebook";')%> <%- end -%>
This assumes routes (and related methods) like:
map.login "login", :controller => "user_sessions", :action => "new" map.login_with "login/:service", :controller => "user_sessions", :action => "create" map.logout "logout", :controller => "user_sessions", :action => "destroy"
In my user_sessions controller, I check if params is equal to “Facebook”, in the same way that I earlier looked for “windows” to process Windows Live ID. The Facebook code is as follows: (Could be improved)
elsif params[:service].downcase == "facebook" if facebook_session @user_session = UserSession.new @user_session.fb_user_id = facebook_session.user.id # can't be passed via initializer, haven't researched this. if current_user @user = User.new @user.fb_user_id = facebook_session.user.id @user.profile = current_user.profile @user.save do |result| if result flash[:notice] = "Login/Registration successful." else flash[:notice] = "Login/Registration failed." end redirect_to root_url return end end end end
But wait, you ask, what’s facebook_session
? Well, since Facebooker is tightly integrated with Rails, and since Facebook has lots of potential features, I didn’t try to force it to use Authlogic’s sessions, and instead used the built-in Facebooker ones in addition to the current_user provided by Authlogic. In my Application controller, I added:
filter_parameter_logging :fb_sig_friends before_filter :set_facebook_session helper_method :facebook_session
Overall, this is perhaps not the smartest way to process Facebook logins, but it does seem to get the job done. I think I’ll try refactoring next however, or rethinking this whole use of authlogic. Plus, profile merging is still bugging me.
Here’s some code I used for profiles - on the show profile page:
<p> <strong>Sign-in IDs:</strong> </p> <ul> <%- @profile.users.each do |u| -%> <li><%=h "OpenID: #{u.openid_identifier}" if u.openid_identifier %> <%=h "Windows Live (Internal ID: #{u.wll_id})" if u.wll_id %> <%=h "Facebook Connect (Internal ID: #{u.fb_user_id})" if u.fb_user_id %></li> <%- end -%> </ul> <p> <%= link_to "Add another Sign-in ID", new_user_path %> </p>
And in the ProfilesController
:
def destroy @profile = current_user.profile @profile.users.each do |u| u.destroy end @profile.destroy flash[:notice] = "Successfully destroyed profile." redirect_to profiles_url end
Now I have to figure out also how to explain “multiple login IDs” to my users. I think I’ll read up on Google’s OpenID usability studies. Certainly, if I didn’t know better, faced with logging in through 3 different options, I might randomly pick each time and get it wrong 3 times in a row, not knowing I should have associated all three login types the first time I logged in. Maybe I need a wizard of some kind. Or make it clearer which account they’ve logged in as, for when they return to the site later and have to pick again.
You’ll need to git clone this version of the code, as after spending hours trying to override methods, I realized the simplest way to support other types of logins would be to add other fields to the User model, such as wll_id
.
LICENSING WARNING: windowslivelogin.rb included in this fork might be protected by the Windows Live SDK License Agreement at msdn.microsoft.com/en-us/library/bb226715.aspx which you must accept to use the SDK. It restricts code, at least sample code, from being redistributed under a license that requires open source distribution. I believe then that an MIT license is appropriate, however given that the MIT license is easily the less restrictive of the two, I may be required to license this code not as MIT but as Windows Live SDK protected – or at least that one file and these instructions.
See below for my way of doing multiple logins, this builds on that in some ways – such as by assuming that all User model fields are optional within my User.find_or_create_by_wll_id
within the UserSession validation. I also added a has_profile method to ensure that all created Users have profiles, even if blank. (I updated the example User model code below to reflect that.)
The only problems I’m having now with the code is what to do about multiple profiles / merging them, and trying to de-couple the idea of registering with the idea of signing in. When someone signs in with an OpenID or Windows Live, the User should be automatically created if it doesn’t exist and associated with the current profile. If the ID or User already exists, but you’re signed in under a different profile, then it has to merge the profiles, likely out of scope for this module. Even registering then, could be as simple as clicking a Register option on the login form rather than Login. My User controller seems less and less useful, except in creating additional logins without signing in. Another concern I just realised is “adding” Windows Live IDs to an existing profile. If you’re signed in already, and you sign in as Windows Live, the Windows Live session must replace your current session, since sign-out must then be handled by Windows Live. To be more precise, the user and expiry must change … I’ll have to look into this further. (Now I see the value of tests :p)
Before we begin, you will want to read up on what all this means at msdn.microsoft.com/en-us/library/bb676633.aspx It’s easy enough to follow, though the docs are a little scattered and not Ruby-specific.
Okay, lets start by adding :wll_id to the User model:
script/generate migration AddWllIdToUser wll_id:string rake db:migrate
We’ll also need to create an Application-Key.yml file and load its values to WLL_CONFIG … or something. ;-) I put the yml file in the Rails root directory, where you have the Rakefile and README for your application, etc. Then I added the following line to a configuration file, like environment.rb:
WLL_CONFIG = YAML.load_file(Rails.root.join("Application-Key.yml"))
Here’s what Application-Key.yml looks like, it’s based off of the sample Application-Key.xml file. You will need to sign up for a Windows Live ID application key at live.azure.com/ – click Live Services, then New Service, then Existing APIs. Your returning URL will receive both get and post requests, you may want it to be a separate UserSessionController method, but we’ll get to that in a second. (You can change this URL at any time; localhost is not a valid domain, for local testing use 127.0.0.1 example.org in your /etc/hosts and tell Microsoft it’s example.org)
--- :appid: 000000001A01B984 :secret: abC1D2ef3g4hiJk5lmnOPQR6StUV7wXY :securityalgorithm: wsignin1.0
There’s a blank line below :securityalgorithm: wsignin1.0
. DON’T COPY AND PASTE THE ABOVE SAMPLE! It won’t work, you have to replace the appid and secret with your own registered appid and secret. Again, save the file to your Root folder as Application-Key.yml
, or put it in a safe spot. The idea is that you decrypt the response from Microsoft with your secret key, this way Microsoft knows your app is the only one that can understand the unique ID embedded in the returned token, and in return, you know the ID came from Microsoft and not through a third-party man-in-the-middle attack. This is only as secure as your secret key, however, so keep it safe and away from prying eyes.
We are now nearly done, all we have to do is add the login/logout iframe to layouts/application.html.erb
and modify our UserSession
controller to handle redirects from Windows Live after sign-in or sign-out. Unlike OpenID, the iframe loading on each page means we do not handle the “Sign in” or “Sign out” clicks and we only know the button is pressed after the sign-in has occurred or during the sign out, when the browser loads an image from us to tell us to clear cookies. (Yes, when someone signs out of Windows Live, they are supposed to sign out of all Windows Live properties, including us and Hotmail. This also means we’ll want to hide our sign out button when we are signed in through Windows Live.)
So below is my sample code from application.html.erb
– all the relevant bits say WLL_CONFIG
near them:
<%- if current_user -%> Hello <%= current_user.profile.first_name || "Anonymous" %>! You can <%= link_to "view", profile_path(current_user.profile) %> or <%= link_to "edit your profile", edit_profile_path(:current) %><% unless WLL_CONFIG && current_user.wll_id %>; or <%= link_to "logout", logout_path %>.<% end %> <%- else -%> Hello! Please <%= link_to "register", new_user_path %> or <%= link_to "login", login_path %> now, thanks! New: Login with <%= link_to "Google", login_with_path('Google') %>! <%= link_to image_tag('y-signin.png', :border =>0), login_with_path('Yahoo!') %> <%- end -%> <%- if WLL_CONFIG -%> <iframe id="WebAuthControl" name="WebAuthControl" src="http://login.live.com/controls/WebAuthButton.htm?appid=<%= WLL_CONFIG[:appid]%>&style=<%= CGI.escape 'font-size: 10pt; font-family: verdana; background: white;'%>" width="80px" height="20px" marginwidth="0" marginheight="0" align="middle" frameborder="0" scrolling="no"> </iframe> <%- end -%>
You’ll note the iframe src points to WebAuthButton.htm which is a self-contained badge that says “Sign in” or “Sign out” – for all options, see msdn.microsoft.com/en-us/library/bb676638.aspx (The other two are somewhat ugly looking bits of text more appropriate for cases where Windows Live is the only authentication mechanism used.)
On to user_sessions_controller.rb
where in the create
method or something based on it, before the usual @user_session.save
, I have:
wll = WindowsLiveLogin.new(WLL_CONFIG[:appid], WLL_CONFIG[:secret], WLL_CONFIG[:securityalgorithm]) if request.request_parameters[:action] == "login" # The user has completed a successful sign-in to your site. # This value appears in the HTTP POST response only. # Extract the user's authentication token from the POST response and store it in a session token = params[:stoken] context = CGI.unescape(params[:appctx]) if params[:appctx] # A string we pass in the iframe that is returned to us # Could be used to redirect user back to where they were. wll_user = wll.processToken(token, context) if wll_user # ... if we're already signed in, lets add this profile to the current account. if current_user @user = User.new({ :wll_id => wll_user.id }) @user.profile = current_user.profile @user.save do |result| if result flash[:notice] = "Login/Registration successful." else flash[:notice] = "Login/Registration failed." end redirect_to root_url return end end @user_session = UserSession.new({ :remember_me => wll_user.usePersistentCookie?, :remember_me_for => 10.years }) # Not sure if :remember_me_for works as specified here. @user_session.wll_id = wll_user.id # this is the unique id for the user # Do not use persistent cookies if the value of the user's UsePersistentCookie property is false. # Persistent cookies allow the user to remained signed in across multiple browser sessions. # ... from here we skip to @user_session.save... below. ;-) else flash[:notice] = "Sorry, there was an error processing the Windows Live ID login." redirect_to root_url return end elsif request.query_parameters[:action] == "clearcookie" # The Windows Live ID sign-out page is calling your site to clear user cookies. # This value appears in the HTTP GET response. # Clear the session cookie you created at sign-in and return a GIF image # to the service to indicate that the user has been signed out. @user_session = UserSession.find @user_session.destroy unless @user_session.nil? type, response = wll.getClearCookieResponse() render :text => response, :content_type => type return elsif request.query_parameters[:action] == "logout" # The user has completed a successful sign out from Windows Live. # This value appears in the HTTP GET response. # Clear the session cookie and redirect the signed-out user # to a page on your site that is appropriate for unauthenticated users. @user_session = UserSession.find @user_session.destroy unless @user_session.nil? flash[:notice] = "Successfully logged out." redirect_to root_url return end
Note the use of request.request_parameters[:action]
or request.query_parameters[:action] == "logout"
– this is because params is of course overridden to say create
or whatever the Rails action is. Instead, we need to look at the original request_parameters (POST) or query_parameters (GET) to see what Windows Live tells us to do: “login” (redirects like normal), “clearcookie” (returns GIF), or “logout” (redirects like normal).
Okay, now you’ve a working Windows Live ID setup. See, wasn’t that easy? For people confused about what to do with these files, just put them in vendor/plugins
, e.g. vendor/plugins/authlogic_openid/lib/windowslivelogin.rb
… it doesn’t need to be a gem to work. (In fact, that’s the only way I’ve tested it so far, I’m clueless at gem packaging)
What’s neat about authlogic_openid, which Railscast 170 doesn’t cover, is that it can support multiple OpenIDs out of the box. Here’s how:
Rather than my earlier attempt at turning User into a profile and using multiple Login objects to store OpenIDs, we’ll do the sensible thing and instead have multiple USERS – objects that act_as_authentic – and one Profile model that has_many USERs.
To start with, we’ll generate the Profile object:
script/generate model Profile first_name:string last_name:string email:string
Next let’s modify our User and Profile models adding belongs_to :profile
and has_many :users
. Two more changes: We’ll have to remove the username validation if we want to make one-click OpenID registration, and we’ll want to update our map_openid_registration
function to create a profile and/or update it. (I’m not sure how well the map_openid_registration
code works, though it has created a profile each time so far…)
class Profile < ActiveRecord::Base has_many :users end class User < ActiveRecord::Base belongs_to :profile validate :has_profile acts_as_authentic do |c| c.openid_required_fields = [:nickname, :fullname, "http://axschema.org/namePerson/first", "http://axschema.org/namePerson/last", :email, 'http://schema.openid.net/contact/email'] end private def has_profile unless self.profile self.profile = Profile.new end end def map_openid_registration(registration) unless self.profile self.profile = Profile.new end self.profile.email = registration["email"] || registration["http://schema.openid.net/contact/email"].to_s if self.profile.email.blank? self.profile.first_name = (registration[:fullname].split(" ").first if registration[:fullname]) || (registration["http://axschema.org/namePerson/first"][0] if registration["http://axschema.org/namePerson/first"]) || registration["nickname"] || self.profile.email.to_s[/[^@]*/] if self.profile.first_name.blank? self.profile.last_name = (registration[:fullname].split(" ").last if registration[:fullname]) || registration["http://axschema.org/namePerson/last"].to_s if self.profile.last_name.blank? end end
Then open the generated create_profiles.rb
migration and change it to something like:
class CreateProfiles < ActiveRecord::Migration def self.up create_table :profiles do |t| t.string :first_name t.string :last_name t.string :email t.timestamps end add_column :users, :profile_id, :integer User.find(:all).each do |u| u.profile = Profile.create( :first_name => u.first_name, :last_name => u.last_name, :email => u.email ) end remove_column :users, :first_name remove_column :users, :last_name remove_column :users, :email end def self.down remove_column :users, :profile_id add_column :users, :first_name, :string add_column :users, :last_name, :string add_column :users, :email, :string drop_table :profiles end end
The above migration should port over all existing profile data, though I haven’t tested that function yet myself.
Your UsersController might then have this method:
def create @user = User.new(params[:user]) if(!current_user && params[:user] && params[:user][:openid_identifier] && User.find_by_openid_identifier(params[:user][:openid_identifier])) # This is so that if the OpenID exists as a User, we authenticate rather than register. redirect_to "/login/other?openid_identifier=#{params[:user][:openid_identifier]}" # The above redirect must be a GET request which is why it looks odd. else if(current_user) @user.profile = current_user.profile end @user.save do |result| if result flash[:notice] = "Registration successful." redirect_to root_url else render :action => 'new' end end end end
This way you can easily add other OpenIDs to your profile when signed-in. I also added a bit of code to redirect if an ID already exists as a user. This way the Registration page can be somewhat used as a sign-in page. (I can’t replace it with the sign-in page, as you would still want to say “username taken” rather than “password incorrect”)
Authlogic OpenID is an extension of the Authlogic library to add OpenID support. Authlogic v2.0 introduced an enhanced API that makes “plugging in” alternate authentication methods as easy as installing a gem.
* Documentation: authlogic-oid.rubyforge.org * Authlogic: github.com/binarylogic/authlogic * Live example: authlogicexample.binarylogic.com
class AddUsersOpenidField < ActiveRecord::Migration def self.up add_column :users, :openid_identifier, :string add_index :users, :openid_identifier change_column :users, :login, :string, :default => nil, :null => true change_column :users, :crypted_password, :string, :default => nil, :null => true change_column :users, :password_salt, :string, :default => nil, :null => true end def self.down remove_column :users, :openid_identifier [:login, :crypted_password, :password_salt].each do |field| User.all(:conditions => "#{field} is NULL").each { |user| user.update_attribute(field, "") if user.send(field).nil? } change_column :users, field, :string, :default => "", :null => false end end end
$ script/plugin install git://github.com/rails/open_id_authentication.git
For more information on how to configure the plugin, checkout it’s README: github.com/rails/open_id_authentication/tree/master
$ sudo gem install authlogic-oid
Now add the gem dependency in your config:
config.gem "authlogic-oid", :lib => "authlogic_openid"
Or for older version of rails, install it as a plugin:
$ script/plugin install git://github.com/binarylogic/authlogic_openid.git
You only need to save your objects this way if you want the user to authenticate with their OpenID provider.
That being said, you probably want to do this in your controllers. You should do this for BOTH your User objects and UserSession objects (assuming you are authenticating users). It should look something like this:
@user_session.save do |result| if result flash[:notice] = "Login successful!" redirect_back_or_default account_url else render :action => :new end end
You should save your @user objects this way as well, because you also want the user to verify that they own the OpenID identifier that they supplied.
Notice we are saving with a block. Why? Because we need to redirect the user to their OpenID provider so that they can authenticate. When we do this, we don’t want to execute that block of code, because if we do, we will get a DoubleRender error. This lets us skip that entire block and send the user along their way without any problems.
That’s it! The rest is taken care of for you.
If you are interested, I explain myself below. Regardless, if you don’t feel comfortable with the organization of the logic,you can easily do this using the traditional method. As you saw in the setup instructions, this library leverages the open_id_authentication rails plugin. After the user has been authenticated just do this:
UserSession.create(@user)
It’s that simple. For more information there is a great OpenID tutorial at: railscasts.com/episodes/68-openid-authentication
Now, here are my thoughts on the subject:
You are probably thinking: “Ben, you can’t handle controller responsibilities in models”. I agree with you on that comment, but my personal opinion is that these are not controller responsibilities. The fact that OpenID authentication requires a redirect should not effect the location of the logic / code. It’s all part of the authentication process, which is the entire purpose of this library. This library is not one big module of code, its a collection of modules that all deal with OpenID authentication. These modules get included wherever it makes sense. That’s the whole idea behind modules. To group common logic.
Let’s take a step back and look at the traditional method of OpenID authentication in rails. What if you wanted to authenticate with OpenID in multiple controllers in your application (Ex: registration and loggin in)? You would probably pull out the common code into a module and include it in the respective controllers. Even better, you might create a class that elegantly handles this process and then place it in your lib directory. Then, if you really wanted to be slick, you might take it another step further and have your models trigger this class during certain actions. Then what do we have? This exact library, that’s exactly what this is.
The last thing I will leave you with, to get you thinking, is… where do sweepers lie in the MVC pattern? Without this, things like caching would be extremely difficult. There is a big difference between misplacing code / logic, and organizing logic into a separate module and hooking it in using the API provided by your models. Especially when the logic needs to be triggered by actions invoked on models.
Regardless, if I still haven’t convinced you, I hope this library is of some benefit to you. At the very least an example of how to extend Authlogic.
Copyright © 2009 Ben Johnson of [Binary Logic](www.binarylogic.com), released under the MIT license