- Create your rails application:
rails -d mysql myapp
cd myapp
rake db:create # you might need to edit config/database.yml first to match your db installation - Install the two plugins required:
script/plugin install http://elitists.textdriven.com/svn/plugins/acts_as_state_machine/trunk
# I needed to use trunk, as other versions have a missing const RailsStudio error
script/plugin source http://svn.techno-weenie.net/projects/plugins
script/plugin install restful_authentication
# obviously these last two lines can be combined - Create the users model and controllers:
script/generate authenticated user sessions --include-activation --stateful
# This will create the users model and the users and sessions controllers.
It also adds map.resource entries for these in the routes.rb file. 'include-activation' is for email activation and 'stateful' is the tie to the state machine (for easily managing user activation and login status) - Edit the routes.rb file to specify the user states and add some useful routes:
map.resources :users, :member => {
:suspend => :put,
:unsuspend => :put,
:purge => :delete
}
map.resource :session
map.activate '/activate/:activation_code', :controller => 'users', :action => 'activate'
map.signup '/signup', :controller => 'users', :action => 'new'
map.login '/login', :controller => 'sessions', :action => 'new'
map.logout '/logout', :controller => 'sessions', :action => 'destroy'
map.forgot_password '/forgot_password', :controller => 'users', :action => 'forgot_password'
map.reset_password '/reset_password/:code', :controller => 'users', :action => 'reset_password'
map.account '/account', :controller => 'users', :action => 'account' - Edit the environment.rb file to include the line:
config.active_record.observers = :user_observer
This allows for activation emails to be sent. - Edit the migration, in this case db/migrate/001_create_users.rb, and add lines for the 'forgot password' feature and the option to have administrator users:
t.column :password_reset_code, :string, :limit => 40
t.column :is_admin, :boolean, :default => false - Update the database:
rake db:migrate
- Remove the following line from sessions_controller and users_controller and add it to application_controller to enable authentication application wide:
include AuthenticatedSystem
- Remove or comment out these two lines from the UsersController.create method:
self.current_user = @user
This allows us to add further processing of the user registration request, by adding a create.html.erb view and email activation.
redirect_back_or_default('/') - Add the view/users/create.html.erb file with content similar to:
<fieldset>
<legend>New account</legend>
<p>Instructions for activating your account
have been sent to <%=h @user.email %>
If this address is incorrect, please
<%= link_to 'signup', signup_path %>
again. If you do not receive the email
soon, please check your spam filter.</p>
</fieldset> - Add the following helper methods to application_helper.rb:
def user_logged_in?
session[:user_id]
end
def user_is_admin?
session[:user_id] && (user = User.find(session[:user_id])) && user.is_admin
end - Add a 'forgot password?' link to the views/sessions/new.html.erb form (usually near the 'submit tag'):
<%= link_to 'Forgot password?', forgot_password_url %>
- Add links to login/out and signup to your main page or layout. For example, I used a fixed position div like this:
<div style="position: absolute; right: 0px; top: 0px; height: 20px;">
<% if user_logged_in? %>
<%= link_to 'Logout', logout_url %>
<% else %>
<%= link_to 'Signup', signup_url %>
| <%= link_to 'Login', login_url %>
<% end %> - Support admin restrictions with the following method in the application controller:
protected
# Protect controllers with code like:
# before_filter :admin_required, :only => [:suspend, :unsuspend, :destroy, :purge]
def admin_required
current_user.respond_to?('is_admin') && current_user.send('is_admin')
end - If you want admin control, add a before filter to the users_controller to restrict key actions to admin users only:
before_filter :admin_required, :only => [:suspend, :unsuspend, :destroy, :purge]
- Add actions in users_controller.rb for account, change_password, forgot_password and reset_password:
def account
if logged_in?
@user = current_user
else
flash[:alert] = 'You are not logged in - please login first'
render :controller => 'session', :action => 'new'
end
end
# action to perform when the user wants to change their password
def change_password
return unless request.post?
if User.authenticate(current_user.login, params[:old_password])
# if (params[:password] == params[:password_confirmation])
current_user.password_confirmation = params[:password_confirmation]
current_user.password = params[:password]
if current_user.save
flash[:notice] = "Password updated successfully"
redirect_to account_url
else
flash[:alert] = "Password not changed"
end
# else
# flash[:alert] = "New password mismatch"
# @old_password = params[:old_password]
# end
else
flash[:alert] = "Old password incorrect"
end
end
# action to perform when the users clicks forgot_password
def forgot_password
return unless request.post?
if @user = User.find_by_email(params[:user][:email])
@user.forgot_password
@user.save
redirect_back_or_default('/')
flash[:notice] = "A password reset link has been sent to your email address: #{params[:user][:email]}"
else
flash[:alert] = "Could not find a user with that email address: #{params[:user][:email]}"
end
end
# action to perform when the user resets the password
def reset_password
@user = User.find_by_password_reset_code(params[:code])
return if @user unless params[:user]
if ((params[:user][:password] && params[:user][:password_confirmation]))
self.current_user = @user # for the next two lines to work
current_user.password_confirmation = params[:user][:password_confirmation]
current_user.password = params[:user][:password]
@user.reset_password
flash[:notice] = current_user.save ? "Password reset successfully" : "Unable to reset password"
redirect_back_or_default('/')
else
flash[:alert] = "Password mismatch"
end
end - Create html.erb forms in views/users for the change_password, forgot_password and reset_password actions.
- Edit models/user_mailer.rb and replace YOURSITE and ADMINEMAIL with values appropriate for the new website. A good way of doing this is to define the variable SITE in the config/environments/*.rb files and then use that in the strings in the UserMailer with the "#{SITE}" format. Also add methods for forgot_password and reset_password (ie. send mails when those actions are invoked):
class UserMailer < ActionMailer::Base
def signup_notification(user)
setup_email(user,'Please activate your new account')
@body[:url] = "#{SITE}/activate/#{user.activation_code}"
end
def activation(user)
setup_email(user,'Your account has been activated!')
@body[:url] = "#{SITE}/"
end
def forgot_password(user)
setup_email(user,'You have requested to change your password')
@body[:url] = "#{SITE}/reset_password/#{user.password_reset_code}"
end
def reset_password(user)
setup_email(user,'Your password has been reset.')
end
protected
def setup_email(user,subj=nil)
recipients "#{user.email}"
from %{"Your Admin" <bounce@yourdomain.com>}
subject "[#{SITE}] #{subj}"
sent_on Time.now
body :user => user
end
end - Add forgot_password.html.erb and reset_password.html.erb to the user_mailer view.
- Add methods to models/user.rb: forgot_password, reset_password, recently_forgot_password, recently_reset_password and recently_activated. Also add protected method make_password_reset_code.
def forgot_password
@forgotten_password = true
self.make_password_reset_code
end
def reset_password
# First update the password_reset_code before setting the
# reset_password flag to avoid duplicate mail notifications.
update_attributes(:password_reset_code => nil)
@reset_password = nil
end
# Used in user_observer
def recently_forgot_password?
@forgotten_password
end
# Used in user_observer
def recently_reset_password?
@reset_password
end
# Used in user_observer
def recently_activated?
@activated
end
protected
def make_password_reset_code
self.password_reset_code = Digest::SHA1.hexdigest( Time.now.to_s.split(//).sort_by {rand}.join )
end - Modify UserObserver.after_save(user) to send activation only based on user.recently_activated? Also add to this method mail sending for forgot_password and reset_password events:
def after_save(user)
UserMailer.deliver_activation(user) if user.recently_activated?
UserMailer.deliver_forgot_password(user) if user.recently_forgot_password?
UserMailer.deliver_reset_password(user) if user.recently_reset_password?
end - Make sure your mail subsystem is properly prepared to send mail. I installed postfix on my development and deployment machines (both ubuntu, so I used 'apt-get install postfix'). It is a good idea to test this with a command-line mail like:
sendmail -f admin@mydomain.com me@myaddress.com
Subject: test
Hello, world!
. - Add appropriate administrative links. In my case I created an 'account' route to a new account action and view in the users controller, and in this displayed current user settings and provided a link to the 'change_password' action. Since this is very similar to many of the actions above, it is left as an exercise to the reader :-)
- Test everything, sign up a user, login, logout, click 'forgot password', respond to all emails sent, change the password, etc.
- What's next? Well, in my case I continued by adding a boolean 'is_admin' flag to the users table and then adding extra capability to my site for admin users. I also created a cool layout and used it for all controllers in my site. This is rails after all, the sky is the limit :-)
Mar 10, 2008
Rails authentication: restful_authentication and acts_as_state_machine
I have written a couple of rails apps with user authentication, but finally decided to start using some of the excellent plugins available for this. After a quick search I got the impression that restful_authentication was the current standard for rails (I'm using rails 2.0.2 at the moment), and especially with the link to the state machine plugin. However, my initial quick search did not yield a decent quick 'howto' for fast-tracking getting this all working. So I started writing notes on my findings here in my blog:
Subscribe to:
Post Comments (Atom)
27 comments:
Great Writeup, book marked it.
burm.net
Nice writeup. Would be a little more helpful if you included the mods to the User model in step 20.
Tim Jones
Barbary Codes and Data
www.barbarycodes.com
Newbie question, what is in the methods in Step 20? For example what's to be done in user.forgot_password. Thanks.
Thanks everyone for the positive reviews. I'm sorry I left a few pieces out, and can only blame the fact that it felt like it was taking longer to write the blog than do the actual coding :-) Once I got my stuff working, I published and moved on. Anyway, I've now edited and added the code for point 20, and it is pretty simple code too, which is nice.
Since I'm commenting anyway, I thought I'd mention that I've since written a per-model, but app-wide, plugin for setting user access levels regarding 'create, updated and destroy' of models or resources. I'm using it to control the existance of show/edit/delete icons on tables of resources in my apps. Unsurprisingly I called it 'AmanziResource', and it adds a class method to ActiveRecord::Base allowing you to, for example, type 'amanzi_resource :admin' in your model class definition, and then that class has appropriate methods that return 'false' if caled by 'non-admin' users. It's my first plugin, so I've not advertised it, but perhaps if there is interest I could clean it up and do so.
While I'm on a role, I could even mention I'm working on a plugin inspired by the great ext_scaffold by Martin Rehfeld. Mine builds a tree view using models of different types. By this I mean it tries to use the associations to build the tree. Very nice, but not completely automatic (yet).
It would be great if you could publish your code.
My tree builder is working well for me, but I've not tested all its combinations, so I feel wary of releasing it into the wild just yet. I'll see if I can find the time to close a few loose ends, and release an alpha version for others to try. I'm confident that if there are bugs, they are going to be trivial to fix.
I love this tutorial! Its great!
For some reason (probably my stupidity!) I keep getting an error whenever I try to sign up.
I get this:
NoMethodError in UsersController#create
You have a nil object when you didn't expect it!
The error occurred while evaluating nil.to_sym
Anyone have any ideas?
The to_sym method occurs in many places in the acts_as_state_machine and restful_authentication plugins, but no-where in the code I suggested, so it is hard to see what is causing your problem without a full stack trace. I suggest look at your stack trace and in particular the last line before it exits your app and enters the plugin code, and see what's going on there.
hey there,
quick question about the forgotten_password.html.erb view...
what url action are you passing in the form_for?
it's not create, so is it simply :action => 'forgotten_password'
although that didn't work either, just curious. it's the last piece i'm missing. thanks
never mind i figured it out. i just used the ones from here, if someone was wondering
http://railsforum.com/viewtopic.php?id=11962
My solution was:
form_for :user, :url => {:action => 'forgot_password'}
So I perhaps it did not work for you only because you spelled it 'forgotten'?
I never got round to posting my erb files to this blog, largely because they should be 'sort of' self explanatory, but also because I thought I should rather write a plugin with a generator that does all this work for you. Needless to say I've not written that plugin (yet). However, I have now learned enough about writing plugins with generators that I now know how to do this, so perhaps I'll get this done soon (on my next project that requires user authentication).
My ext-js tree view scaffold plugin is looking pretty good right now, but still not really releasable. Hopefully I will close the loose ends soon.
Hi,excellent post, are there any updates that you know of that are needed to work with rails 2.2.2?
We've upgraded to 2.1.2 with no trouble, and I think, but cannot swear on it, that we tried out 2.2.2 also recently. We've made a number of other changes to our code since this blog, and so it's always possible that something else we coded helped the upgrades. But, this is rails, so it's easy to track and fix a problem. Try it with 2.2.2 and if there are issues you should be able to resolve them quickly. Let me know if you do have trouble.
ok, great I'll be getting started soon on it and let you know if I come across anything. Did you update any user auth code too? I'd like to collaborate further as I work through your examples.
We should take this discussion off-line, so email me. My contact details can be found on our company website at http://www.amanzitel.com.
I'm getting strange behavior and I'm wondering if anyone else has seen this: When a new user signs up, their data is put into the database. Then an email is sent to the user with the activation code. However, the activation code sent does not match the one in the database?!! The email is triggered with an observer. Here is the model:
class UserMailer < ActionMailer::Base
def signup_notification(user)
setup_email(user)
@subject += 'Please activate your new account'
@body[:url] = "#{SITE_ADDR}/activate/#{user.activation_code}"
end
[snip]
user.activation_code works in the console, yet the email is sent with a bogus string. Any Ideas?
Matt
I did see something like that last year, after a rails upgrade I think. If I remember correctly it turned out to be that the user.make_activation_code was being called twice. I just changed the line self.activation_code = Digest... to self.activation_code ||= Digest... and the problem was fixed.
Craig,
You're my hero. Your fix worked like a charm.
I've been banging my head against the wall for two days now with this. Especially since I had one site (2.0.2) working and another site (2.2.2) with nearly identical code not working.
How could I get more debugging/log info so I could trace things like this down in the future. I had an idea as to what the problem was, but didn't know how to find it.
Thanks again,
Matt
Well, there are probably many ways of debugging this I should know about, but in my case I just searched for code where the activation_code was set, and then added logger statements around it (with RAILS_DEFAULT_LOGGER.info() calls). Then I saw it was called twice, so I made the fix.
Another option worth trying is to run script/console, create a user object manually (with create) and simulate registration by calling the appropriate state machine even triggers on it (like user.register! and user.activate!), and watching the results in the database and in the object attributes.
Thanks Craig.
Craig,
BTW, I found a security hole, where if a user goes to http://localhost:3000/reset_password (by typing that url, not by following the link after forgot_password) then in users_controller.rb reset password User.find_by_password_reset_code(params[:id]) will find the first user in the database that has not asked fo his password to be reset (because :id is nil) and reset that. Here is how I patched it:
def reset_password
#### - fixed this so a NIL :id will not find any users
return unless (params[:id != nil])
@user = User.find_by_password_reset_code(params[:id])
return if @user unless params[:user]
Hope this helps others.
oops, typo!
That should be:
return unless (params[:id] != nil)
Matt, the route is declared this way:
map.reset_password '/reset_password/:code', ...
Since the code is necessary, the controller should never receive param[:code] with nil (it won't hurt to check it, though)
Well, I know I'm very late to the party but I'd love to see your tests.
Dankie, broer!
Post a Comment