When creating any rails project, restful_authentication is usually one of the first things I install. Although it is a great plugin, adding all the extra login stuff can be quite a task. I found this railsforum thread very helpful, but not exactly what I wanted. What follows is what I’ve come up with so far. Some bits of code from the railsforum thread do not readily appear to be used, but have been left in to be used at a later point.
The first thing I did after creating my project was too install these two plugins which have information available at these links.
Install the plugins and the generate the base code we’ll work with from these commands. The articles scaffold is not entirely needed and you could choose to create a different model entirely. It is used here to help illustrate. Each line starting with ./script is one line. Be careful if you are copying and pasting.
./script/plugin install http://svn.techno-weenie.net/projects/plugins/restful_authentication
./script/plugin install http://elitists.textdriven.com/svn/plugins/acts_as_state_machine/trunk
./script/generate authenticated user sessions --include-activation --stateful
./script/generate scaffold article title:string body:text
./script/generate controller passwords new create edit update
./script/generate controller settings password
Before we forget, delete the index.html file in the public directory as well as the app/views/layouts/articles.html.erb layout file which was generated by the scaffolding. Our users migration is a little different. It has been changed here to reflect how it is done with rails 2.0, but that is not necessary. Note the role column and absence of a password_reset_code.
class CreateUsers < ActiveRecord::Migration
def self.up
create_table "users", :force => true do |t|
t.string :login, :email, :null => :no
t.string :crypted_password, :salt, :limit => 40
t.string :remember_token
t.datetime :remember_token_expires_at, :activated_at, :deleted_at
t.string :activation_code, :limit => 40
t.string :state, :null => :no, :default => 'passive'
t.string :role, :null => :no, :default => 'subscriber'
t.boolean :enabled, :default => false
t.timestamps
end
end
def self.down
drop_table "users"
end
end
With rails 2.0, sqlite3 is the default db. You do not need to configure anything else to get your app up and running. I will skip configuring a mysql db because of this. If mysql is your thing, go ahead and get that set up now. Run the migration with this command
rake db:migrate
Next, instead of creating a separate config/environment/mail.rb file I just added to config/environment.rb. Don’t forget about the user_observer line.
config.active_record.observers = :user_observer
config.action_mailer.smtp_settings = {
:address => "mail.yourapp.com",
:port => 25,
:domain => "yourapp.com",
:authentication => :login,
:user_name => "username",
:password => "password"
}
Now we need to add a couple of methods to our app/models/user_mailer.rb file. Make sure you do not add these as protected methods. At this point in time, I’m not requiring users to to verify their email address, because of this, a small change is needed to the signup_notification method. Add the appropriate info to the setup_email method as well.
# change the @body[:url] line in this method
def signup_notification(user)
setup_email(user)
@subject += 'Please activate your new account'
@body[:url] = "http://yoursite.com/"
end
# add the two methods below
def forgot_password(user)
setup_email(user)
@subject += 'You have requested to change your password'
@body[:url] = "http://yoursite.com/reset_password/#{user.activation_code}"
end
def reset_password(user)
setup_email(user)
@subject += 'Your password has been reset.'
end
We can skip the generation of roles and permissions and move straight onto editing our user model. Below are the additions I made to the user model.
# Add this as the first line underneath the class declaration
ROLES = ['subscriber', 'admin']
...
# Add this to the block of validation methods
validates_inclusion_of :role, :in => ROLES, :on => :create
...
# These are the public methods I added
def check_auth(pw)
if User.authenticate(self.login, pw)
@change_pw = true
else
errors.add(:old_password, "does not equal current password")
@change_pw = false
end
end
def forgot_password
@forgot_pw = true
self.make_activation_code
self.save
end
def reset_password(pw, confirm)
@reset_pw = true
update_attribute(:activation_code, nil)
update_attributes(:password => pw, :password_confirmation => confirm)
end
def forgot_pw?
@forgot_pw
end
def reset_pw?
@reset_pw
end
def change_pw?
@change_pw
end
def self.find_for_forget(email)
find :first, :conditions => ['email = ? and activation_code is null', email]
end
def has_role?(name)
self.role.eql?(name) ? true : false
end
# Modify this private method
def password_required?
crypted_password.blank? || !password.blank? || @change_pw || @reset_pw
end
I had not thought about using instance variables to hold boolean values in the way that they are used here. While doing this I ran into a few mysterious problems that were traced right back to them. What caused the problems was that I was expecting them to be set but they weren’t being set till after they were being used. Consequently, things just weren’t working. Bottom line, be careful where you place those boolean variables when using them in this way. Another thing to note, the validates_inclusion_of_role only happens on create right now. This is not an ideal solution because users roles should only be what is included in the list in the model and this could allow the role to be changed to anything after they have been created.
I know that adding this code to lib/authenticated_system.rb is not a best practice. For now though, it will do it as a temporary solution.
def not_logged_in_required
!logged_in? || permission_denied
end
def check_role(role)
if logged_in?
@current_user.has_role?(role) ? true : permission_denied
else
access_denied
end
end
def admin_required
check_role('administrator')
end
# this is not the full method like the one provided at the railsform
# thread. it gets the job done now so we can flesh out the rest
def permission_denied
respond_to do |format|
format.html do
flash[:error] = "You don't have permission to complete that action."
redirect_to home_path
end
end
end
My users and sessions controllers stayed mostly the same. Don’t forget to move the ‘include authenticatedSystem’ line into the application controller. Filters have been added to both and I changed the create function of the users_controller to reflect not needing to verify email address’ after signup. Users pass up pending and go straight to active.
# users_controller.rb
# these are the filters
before_filter :admini_required, :only => [:destroy, :suspend, :unsuspend, :purge]
before_filter :not_logged_in_required, :only => [:new, :create]
before_filter :find_user, :only => [:suspend, :unsuspend, :destroy, :purge]
# this is my modified create action. Still pretty much the same
def create
cookies.delete :auth_token
@user = User.new(params[:user])
if @user.save
self.current_user = @user
current_user.activate!
redirect_back_or_default(home_path)
flash[:notice] = "Thanks for signing up!"
else
render :action => 'new'
end
end
# sessions_controller.rb
# these are the filters I used, nothing else changed here
before_filter :login_required, :only => :destroy
before_filter :not_logged_in_required, :only => [:new, :create]
Part of the create method provided by restful_authentication looks like this
@user = User.new(params[:user]) @user.register? if @user.valid? if @users.errors.empty? finish rest of the method...
I am not sure why they are checked that way. Hopefully it is not for plugging up a security hole. If anyone knows why it is done this way, please leave a comment.
If you checked out the bells and whistles walk through, you will notice a few different choices in implementation here. After messing around with the code a bit, I decided to use the passwords scaffold like the tutorial, but instead of the accounts controller, I created a settings controller. Very similar yes, but still different. The settings controller deviates a bit from the restful philosophy and could potentially cause some to complain, but it appears to work well at this point.
The passwords controller is used only if you have forgotten your password and need to reset it. This separates the logic required to determine if a password reset is needed as opposed to users who just want to change their passwords voluntarily.
The settings controller will not only control changing passwords, but also other personal information related to each user such as their email address, profile information and even their profile image. Of course, the only part we are worried about right now is the password part. If you’re a member of digg, you might notice this app is being set up in a similar manner to the way their urls are laid out. Users can change some of their settings with urls in the form of settings/password or settings/email. For a few of the important settings, this doesn’t seem too bad. I can see it getting ugly if not careful, but a few key settings should be fine.
If you used the generate command from above for your passwords controller, you can delete the create and edit views too. The code for the passwords_controller is
class PasswordsController < ApplicationController
before_filter :not_logged_in_required
#/forgot_password
def new
end
#/mail_reset_code
def create
return unless request.post?
@user = User.find_by_email(params[:email])
if @user
@user.forgot_password
flash[:notice] = "A password reset link has been sent to #{@user.email}."
redirect_to home_path
else
flash[:error] = "Could not find a user with that email address."
redirect_to forgot_password_path
end
end
#/reset_password/:password_reset_code
def edit
@code = params[:activation_code]
@user = User.find_by_activation_code(@code)
if request.post?
@password, @confirmation = params[:password], params[:password_confirmation]
if @user.reset_password(@password, @confirmation)
flash[:notice] = "Password reset. You can now login"
redirect_to login_path
end
else
unless @user
flash[:error] = "Sorry, that is an invalid password reset code"
redirect_to forgot_password_path
end
end
end
end
and for now the code for the settings_controller is
class SettingsController < ApplicationController
before_filter :login_required, :find_user
def password
if request.put?
@pw, @confirm = params[:password], params[:password_confirmation]
if @user.check_auth(params[:old_password]) &&
@user.update_attributes(:password => @pw, :password_confirmation => @confirm)
flash[:notice] = "Your password has been changed"
redirect_to password_path && return
end
end
end
protected
def find_user
@user = User.find(current_user.id)
end
end
Now it is time to create all of our views.
My layout/application.rb looks like this
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
<head>
<meta http-equiv="content-type" content="text/html;charset=UTF-8" />
<title>Fix the title</title>
<%= stylesheet_link_tag 'base' %>
<%= javascript_include_tag :defaults %>
</head>
<body>
<div id="wrapper">
<div id="header">
<h1><%= link_to "This is a site", home_path %></h1>
<% if logged_in? %>
<p>Hello <%= current_user.login %> | <%= link_to 'Sign Out', logout_path %> | <%= link_to 'Change Password', password_path %></p>
<% else %>
<p><%= link_to 'Sign Up', signup_path %> or <%= link_to 'Login', login_path %></p>
<% end %>
</div>
<div id="content">
<% flash.each do |key, msg| %>
<%= content_tag :div, msg, :id => key %>
<% end %>
<%= yield %>
</div>
<div id="footer">
<p>copyright 2008 Dan Engle</p>
</div>
</div>
</body>
</html>
The views are as follows
# app/views/passwords/edit.html.erb
<h3>Choose a new password</h3>
<%= error_messages_for :user %>
<% form_tag url_for(reset_password_path) do %>
<%= hidden_field_tag :activation_code, @user.activation_code %>
<p>
<label for="password">Password:</label>
<%= password_field_tag :password %>
</p>
<p>
<label for="confirm_password">Confirm Password:</label>
<%= password_field_tag :password_confirmation %>
</p>
</p>
<%= submit_tag "Reset Your Password" %>
</p>
<% end %>
# app/views/passwords/new.html.erb
<h3>Forgot Password</h3>
<% form_tag url_for(mail_reset_code_path) do %>
<p>
<label for="email">What is the email address used to create your account?</label>
<%= text_field_tag :email %>
</p>
<p>
<%= submit_tag 'Reset Password' %>
</p>
<% end %>
# app/views/settings/password.html.erb
<h3>Change your password</h3>
<%= error_messages_for :user %>
<% form_for(@user, :url => password_path) do %>
<p>
<label for="old_password">Old Password</label>
<%= password_field_tag :old_password %>
</p>
<p>
<label for="new_password">New Password</label>
<%= password_field_tag :password %>
</p>
<p>
<label for="old_password">Confirm Password</label>
<%= password_field_tag :password_confirmation %>
</p>
<p>
<%= submit_tag "Change Password" %>
</p>
<% end %>
# app/views/sessions/new.html.erb
<h3>Login</h3>
<% form_tag session_path do -%>
<p>
<label for="login">Login</label>
<%= text_field_tag 'login' %>
</p>
<p>
<label for="password">Password</label>
<%= password_field_tag 'password' %>
</p>
<p>
<label for="remember_me">Remember me:</label>
<%= check_box_tag 'remember_me' %>
</p>
<p>
<%= submit_tag 'Log in' %>
</p>
<% end -%>
<p><%= link_to 'Forgot password?', forgot_password_path %></p>
# app/views/user_mailer/forgot_password.html.erb
<%=h @user.login %>, to reset your password, please visit
<%= @url %>
# app/views/user_mailer/reset_password.html.erb
<%= @user.login %>, Your password has been reset
The only views left are those of the articles model. I am including them as a quick reference for those who created the articles scaffold and just want this to work.
# app/views/articles/_article.html.erb
<% div_for article do -%>
<h3><%= article.title %></h3>
<p><%= article.body %></p>
<% end -%>
# app/views/articles/_form.html.erb
<p>
<label for="title">Title:</label>
<%= f.text_field :title %>
</p>
<p>
<label for="body">Body:</label>
<%= f.text_area :body %>
</p>
# app/views/articles/edit.html.erb
<h1>Editing article</h1>
<%= error_messages_for :article %>
<% form_for(@article) do |f| %>
<%= render :partial => 'form', :locals => {:f => f} %>
<p>
<%= f.submit "Update" %>
</p>
<% end %>
<%= link_to 'Show', @article %> |
<%= link_to 'Back', articles_path %>
# app/views/articles/index.html.erb
<h1>Listing articles</h1>
<%= render :partial => @articles %>
<% if admin? %>
<%= link_to 'New article', new_article_path %>
<% end %>
# app/views/articles/new.html.erb
<h1>New article</h1>
<%= error_messages_for :article %>
<% form_for(@article) do |f| %>
<%= render :partial => 'form', :locals => {:f => f} %>
<p>
<%= f.submit "Create" %>
</p>
<% end %>
<%= link_to 'Back', articles_path %>
# app/views/articles/show.html.erb
<% if admin? %>
<p><%= link_to 'Edit', edit_article_path(@article) %></p>
<% end %>
<h1><%= @article.title %></h1>
<p><%= @article.body %></p>
<p><%= link_to 'Back', articles_path %></p>
Some of the views in article only display links if you are an administrator. In order to make that happen, you need to add this helper to app/helpers/application_helper.rb
def admin?
return true if logged_in? && current_user.has_role?('administrator')
end
To finish up, config/routes.rb file should look like this
ActionController::Routing::Routes.draw do |map|
map.home '/', :controller => 'articles', :action => 'index'
map.resources :articles
map.resources :users, :member => {:suspend => :put,
:unsuspend => :put,
:purge => :delete}
map.activate '/activate/:activation_code', :controller => 'users', :action => 'activate'
map.forgot_password '/forgot_password', :controller => 'passwords', :action => 'new'
map.mail_reset_code '/mail_reset_code', :controller => 'passwords', :action => 'create'
map.reset_password '/reset_password/:activation_code', :controller => 'passwords', :action => 'edit'
map.password '/settings/password', :controller => 'settings', :action => 'password'
map.resource :session
map.signup '/signup', :controller => 'users', :action => 'new'
map.login '/login', :controller => 'sessions', :action => 'new'
map.logout '/logout', :controller => 'sessions', :action => 'destroy'
end
That about completes this portion of enhancing restful_authentication. Users can register themselves, reset their passwords if they forget them, and change them voluntarily. It is far from complete, but I didn’t realize how much writing and posting of code I would actually be doing. Either way, I hope you found this portion useful. Let me know if any corrections are needed or if you have any ideas on improvements.
If you find any amateur mistakes, please let me know. There are bound to be several. Don’t worry though, after I gain more experience writing, my writing will improve and I’ll and catch more of the details I may have missed this time.
UPDATE: I added openid support and you can now download the app from this post
Tags: plugins, restful_authentication, Ruby on Rails
6 Responses
Anton
March 4th, 2008 at 10:15 am
1Give download a complete sample app. Thanks!
Eric Jensen
March 4th, 2008 at 4:25 pm
2Looks like you forgot to include the changes to user_observer.rb I added the two lines I thought would do the trick, but they are not:
UserMailer.deliver_forgot_password(user) if user.forgot_pw?
UserMailer.deliver_reset_password(user) if user.reset_pw?
I never get the emails for forgotton/changed pw’s. Perhaps this has something to do with the boolean instance variable trouble you were having?
dan
March 6th, 2008 at 5:27 pm
3You’re right Eric, I did forget that part. I was having issues with the app sending emails at the wrong time so I removed some of those lines and forgot to add the code. A problem I ran into with the instance variables was setting them to true, after the model was saved. Because of that, the observer would always see false values and never send emails or react the correct way. So if you know your mail settings are correct, check where you are setting those variables to true.
I am in the process of rewriting this code and will include a complete sample app to download too. Should be ready in a day or so.
barty
March 30th, 2008 at 10:22 am
4I noticed:
def forgot_password
@forget_pw = true
self.make_activation_code
self.save
end
def forgot_pw?
@forgot_pw
end
@forgEt_pw vs @forgOt_pw. could it be a simple typo?
barty
March 30th, 2008 at 12:53 pm
5one minor note: you should add
self.activation_code = nil
before update_attributes … in your User#reset_password. A non nil activation_code indicates the user is not activated and may cause problems elsewhere.
dan
April 7th, 2008 at 9:12 pm
6Thanks for pointing out the typo and missing statement. I just added update_attribute(:activation_code, nil) inside that method. Would it be better to change these assignments to the self.attribute = value form and then call save at the end or just use those update_attribute methods?
RSS feed for comments on this post · TrackBack URI
Leave a reply
Categories
Archives
Links
Support MoreBS.com
Del.icio.us Links
© 2008 MoreBS.com | BloggingPro theme by: Design Disease