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:
  1. 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
  2. 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

  3. 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)

  4. 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'
  5. Edit the environment.rb file to include the line:
    config.active_record.observers = :user_observer
    This allows for activation emails to be sent.
  6. 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
  7. Update the database:
    rake db:migrate
  8. Remove the following line from sessions_controller and users_controller and add it to application_controller to enable authentication application wide:
    include AuthenticatedSystem
  9. Remove or comment out these two lines from the UsersController.create method:
    self.current_user = @user
    redirect_back_or_default('/')
    This allows us to add further processing of the user registration request, by adding a create.html.erb view and email activation.
  10. 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>
  11. 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
  12. 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 %>
  13. 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 %>
  14. 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
  15. 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]
  16. 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

  17. Create html.erb forms in views/users for the change_password, forgot_password and reset_password actions.
  18. 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
  19. Add forgot_password.html.erb and reset_password.html.erb to the user_mailer view.
  20. 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

  21. 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
  22. 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!
    .
  23. 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 :-)
  24. Test everything, sign up a user, login, logout, click 'forgot password', respond to all emails sent, change the password, etc.
  25. 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 7, 2008

Even Microsoft is getting cool

When you think of 'cool' modern companies, names like 'yahoo' and 'google' spring to mind. For many 'apple' is also synonymous with cool. But Microsoft generally never gets that classification. 'Serious', 'business focused', even 'ruthless'. But take a look at the photo gallery of research projects from Microsoft's seventh annual techFest. Now that is cool!