So here is part 3 of 3 for my Rails 2.0 Forum Tutorial for Beginners. Today we are going to put authentication on top of our application, using the restful_authentication and acts_as_state_machine plugins.
NOTE: If you don't want to do Parts 1 and 2, you can download the code for them here, and use this as a stand-alone tutorial. Please note that our Topic and Reply models both belongs_to a user.
I got a lot of this stuff from
RubyPlus's RESTful Authentication tutorial. Let's get started!
cd myforum
ruby script/plugin install http://elitists.textdriven.com/svn/plugins/acts_as_state_machine/trunk
ruby script/plugin install http://svn.techno-weenie.net/projects/plugins/restful_authentication
ruby script/generate authenticated user sessions --include-activation --stateful
To break down that last command - authenticated is part of the restful_authentication. user is the model to be generated for the user. sessions will be the controller to handle logging in/out, --include-activation will help with creating the stuff needed to mail an activation link to the user's email, and --stateful is for support for acts_as_state_machine plugin.
Now that our files are generated, let's head to /config/routes.rb and make a couple of changes. Notice that the map.resources :users and map.resources :session lines have already been generated by the generate authenticated call. The version of restful_authentication used in the tutorial on RubyPlus did not automatically generate them.
So in routes.rb:
#CHANGE THIS LINE
map.resources :users
#TO THIS
map.resources :users, :member => { :suspend => :put,
:unsuspend => :put,
:purge => :delete }
#AND THEN ADD THE FOLLOWING LINES
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/:id', :controller => 'users',
:action => 'reset_password'
The :member => part sets up some restful actions for the users controller. All the lines that we added sets up named routes for user and session controller/action pairs.
Now, open up /config/environment.rb:
#BELOW THE LINE
Rails::Initializer.run do |config|
#ADD THIS
config.active_record.observers = :user_observer
The observer watches the user model and will be used to send an EMail whenever a new user is created. Next, lets open up /db/migrate/004_create_users.rb and do this.
#OLD 004_create_users.rb
class CreateUsers < ActiveRecord::Migration
def self.up
create_table "users", :force => true do |t|
t.column :login, :string
t.column :email, :string
t.column :crypted_password, :string, :limit => 40
t.column :salt, :string, :limit => 40
t.column :created_at, :datetime
t.column :updated_at, :datetime
t.column :remember_token, :string
t.column :remember_token_expires_at, :datetime
t.column :activation_code, :string, :limit => 40
t.column :activated_at, :datetime
t.column :state, :string, :null => :no, :default => 'passive'
t.column :deleted_at, :datetime
end
end
def self.down
drop_table "users"
end
end
#NEW 004_create_users.rb
class CreateUsers < ActiveRecord::Migration
def self.up
create_table "users", :force => true do |t|
t.string :login, :email, :remember_token
t.string :crypted_password, :limit => 40
t.string :password_reset_code, :limit => 40
t.string :salt, :limit => 40
t.string :activation_code, :limit => 40
t.datetime :remember_token_expires_at, :activated_at, :deleted_at
t.string :state, :null => :no, :default => 'passive'
t.timestamps
end
end
def self.down
drop_table "users"
end
end
There we are just cleaning up the code a little bit. We did add a :password_reset_code column - will be used if a user forgets their password(more on that later). Now just run the migration to create our new tables...
rake db:migrate
Next, open up app/controllers/sessions_controller and copy the line include AuthenticatedSystem to /app/controllers/application.rb. Also, in users_controller, remove the same line. This will make sure that the restful_authentication functions are included on every page.
Now, remove these lines from users_controller in the create function:
self.current_user = @user
redirect_back_or_default('/')
We removed the first line because we do not want to log the user in when they register, we want to verify their EMail. The second line was removed so that Rails would look for create.html.erb after running the create action. Now, create a file /app/views/users/create.html.erb and put something like this in it:
<fieldset>
<legend>Your forum account</legend>
<p>
An EMail has been sent to <%= @user.email %> with instructions to activate
your account.
<li>
If your email is not valid, you must <%= link_to "signup", signup_path %>
again and provide a valid email address.
</li>
<li>
If you don't recieve an email, check your bulk or trash folder, as your spam
filter may have inadvertantly caught the registration email.
</li>
</p>
</fieldset>
Also, create the files app/views/users/change_password.html.erb, app/views/users/forgot_password.html.erb, app/views/users/reset_password.html.erb:
#change_password.html.erb
<% form_for :action => 'change_password' do |f| %>
<fieldset>
<dl>
<dt><label for="old_password" class="block">Old Password</label></dt>
<dd><%= password_field_tag 'old_password', @old_password, :size => 45, :class => 'text' %></dd>
<dt><label for="password" class="block">New Password</label></dt>
<dd><%= password_field_tag 'password', {}, :size => 45, :class => 'text' %>
<div><small>Between 4 and 40 characters</small></div></dd>
<dt><label for="password_confirmation" class="block">Confirm new password</label></dt>
<dd><%= password_field_tag 'password_confirmation', {}, :size => 45, :class => 'text' %></dd>
</dl>
<%= submit_tag 'Change password' %>
</fieldset>
<%end%>
#forgot_password.html.erb
<% form_for :user, :url => {:action => 'forgot_password'} do |form| %>
<fieldset>
<legend>Password Reset Request</legend>
<p>Enter your email address that we have on our file and click send. We will send you a
password reset link email to your email address.</p>
<p>
<label for="user_email" >Email Address:</label><br/>
<%= form.text_field :email, :size => 35, :class => 'text' %>
</p>
<%= submit_tag 'Send' %>
</fieldset>
<% end %>
#reset_password.html.erb
<% form_for :user, :url => {:action => "reset_password"} do |form| %>
<fieldset>
<legend>Reset Password</legend>
<p>
<label for="user_password" >Password</label><br/>
<%= form.password_field :password, :size => 45, :class => 'text' %>
</p>
<p>
<label for="user_password_confirmation" >Confirm Password</label><br/>
<%= form.password_field :password_confirmation, :size => 45, :class => 'text' %>
</p>
<%= submit_tag "Reset your password" %>
</fieldset>
<% end %>
</pre>
<p>Standard stuff there.</p>
<p>Now, open up app/helpers/application_helper.rb and add this code:</p>
<pre>
def user_logged_in?
session[:user_id]
end
We can use this in our views to see if the user is logged in.
Create app/views/layouts/application.html.erb:
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en-US">
<head>
<title><%= @page_title || 'Rails 2.0 Forum' %></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<%= stylesheet_link_tag 'style' %>
<%= javascript_include_tag :defaults %>
</head>
<body>
<!-- ##### Header ##### -->
<div id="header">
<div class="superHeader">
<% if user_logged_in? %>
<span>You are logged in as: </span>
<%= current_user.login %>
<% else %>
<span>Welcome Guest</span>
<% end %>
</div>
<div class="midHeader">
<h1 class="headerTitle" lang="la">Rails 2.0 Forum</h1>
</div>
<div class="subHeader">
<span class="doNotDisplay">Navigation:</span>
<% if user_logged_in? %>
<%= link_to 'Logout', logout_url %>
<% else %>
<%= link_to "Signup", signup_url %>
| <%= link_to 'Login', login_url %>
<% end %>
| <%= link_to 'Contact Us', 'contact' %>
</div>
</div>
<!-- ##### Main Copy ##### -->
<div id="main-copy">
<% flash.each do |key,value| %>
<div id="flash" class="flash_<%= key %>" >
<span class="message"><%= value %></span>
</div>
<% end -%>
<%= yield :layout %>
</div>
</body>
</html>
And then delete forums.html.erb, replies.html.erb, and topics.html.erb so that only the application.html.erb will show.
Lets go ahead and get some different styling in this. Download this stylesheet and toss it in /public/stylesheets.
Alright! Now we should be able to fire up the server and have a look at what we've got.
Next, open up app/views/sessions/new.html.erb. Uncomment the 'Remember me' section, and then next to the submit button, add:
<%= link_to 'Forgot Password?', forgot_password_url %>
Create /app/views/users/change_password.html.erb:
<% form_for :action => 'change_password' do |f| %>
<fieldset>
<dl>
<dt><label for="old_password" class="block">Old Password</label></dt>
<dd><%= password_field_tag 'old_password', @old_password, :size => 45,
:class => 'text' %></dd>
<dt><label for="password" class="block">New Password</label></dt>
<dd><%= password_field_tag 'password', {}, :size => 45, :class => 'text' %>
<div><small>Between 4 and 40 characters</small></div></dd>
<dt><label for="password_confirmation" class="block">
Confirm new password</label></dt>
<dd><%= password_field_tag 'password_confirmation', {}, :size => 45,
:class => 'text' %></dd>
</dl>
<%= submit_tag 'Change password' %>
</fieldset>
<%end%>
Create /app/views/users/forgot_password.html.erb
<% form_for :user, :url => {:action => 'forgot_password'} do |form| %>
<fieldset>
<legend>Password Reset Request</legend>
<p>Enter your email address that we have on our file and click send. We will
send you a password reset link email to your email address.</p>
<p>
<label for="user_email" >Email Address:</label><br/>
<%= form.text_field :email, :size => 35, :class => 'text' %>
</p>
<%= submit_tag 'Send' %>
</fieldset>
<% end %>
And create /app/views/users/reset_password.html.erb
<% form_for :user, :url => {:action => "reset_password"} do |form| %>
<fieldset>
<legend>Reset Password</legend>
<p>
<label for="user_password" >Password</label><br/>
<%= form.password_field :password, :size => 45, :class => 'text' %>
</p>
<p>
<label for="user_password_confirmation" >Confirm Password</label><br/>
<%= form.password_field :password_confirmation, :size => 45, :class => 'text' %>
</p>
<%= submit_tag "Reset your password" %>
</fieldset>
<% end %>
We are moving kind of fast here, but most of this stuff should make sense to you, you just need to see it in action. If you have any questions over how anything works, leave a comment and I will get back to you.
Now, in app/controllers/users_controller.rb, lets add in the actions for forgot_password, reset_password, and change_password:
def change_password
return unless request.post?
if User.authenticate(current_user.login, params[:old_password])
if ((params[:password] == params[:password_confirmation]) &&
!params[:password_confirmation].blank?)
current_user.password_confirmation = params[:password_confirmation]
current_user.password = params[:password]
if current_user.save
flash[:notice] = "Password successfully updated"
redirect_to profile_url(current_user.login)
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
#gain email address
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"
else
flash[:alert] = "Could not find a user with that email address"
end
end
#reset password
def reset_password
@user = User.find_by_password_reset_code(params[:id])
return if @user unless params[:user]
if ((params[:user][:password] && params[:user][:password_confirmation]) &&
!params[:user][:password_confirmation].blank?)
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 success." : "Password reset failed."
redirect_back_or_default('/')
else
flash[:alert] = "Password mismatch"
end
end
In config/environments/development.rb you need to add SITE="http://localhost:3000" at the bottom, we will get to this in just a sec.
Now we will take a look at the user_mailer views. They are used to format emails sent out to users. In app/views/user_mailer/activation.html.erb, remove the part where it says "You may now start adding your plugins:". Now lets create app/views/user_mailer/forgot_password.html.erb, and app/views/user_mailer/reset_password.html.erb:
#forgot_password.html.erb
Dear <%= @user.login %>,
We have had a request to reset your password, please visit
<%= @url %>
#reset_password.html.erb
Your have reset the password for your account successfully.
Username: <%= @user.login %>
Password: <%= @user.password %>
Now lets edit app/models/user.rb. Near the bottom, somewhere after the protected keyword, add the follow function:
def make_password_reset_code
self.password_reset_code = Digest::SHA1.hexdigest( Time.now.to_s.split(//).sort_by {rand}.join )
end
And then above protected, add these functions:
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 email notifications.
update_attributes(:password_reset_code => nil)
@reset_password = true
end
#used in user_observer
def recently_forgot_password?
@forgotten_password
end
def recently_reset_password?
@reset_password
end
def recently_activated?
@recent_active
end
And now we need to set up the user_mailer model to use all of this. Change app/models/user_mailer.rb to look like this:
class UserMailer < ActionMailer::Base
def signup_notification(user)
setup_email(user)
@subject += 'Please activate your new account'
@body[:url] = "#{SITE}/activate/#{user.activation_code}"
end
def activation(user)
setup_email(user)
@subject += 'Your account has been activated!'
@body[:url] = "#{SITE}/"
end
def forgot_password(user)
setup_email(user)
@subject += 'You have requested to change your password'
@body[:url] = "#{SITE}/reset_password/#{user.password_reset_code}"
end
def reset_password(user)
setup_email(user)
@subject += 'Your password has been reset.'
end
protected
def setup_email(user)
recipients "#{user.email}"
from %("Rails Forum Admin" ) # Sets the User FROM Name and Email
subject "[Rails Forum] New account information "
body :user => user
sent_on Time.now
end
end
Note where we used the SITE constant that we set in development.rb earlier. Lets stop the server and then restart it since we changed the development.rb. Now, we should be able to go back and sign up.
If you check the console running your server, you will see SQL queries adding the new user to the database, and the email being sent(it won't actually send an email since we are in development)
Go to think link from the console(localhost:3000/activate/whatever) and you will get a message saying 'Signup complete' and you will be logged in. It will also send an email saying that your account has been activated. You can test everything by logging out and logging back in again.
Alright, we are almost done! Next, open up app/models/user_observer.rb and change the after_save function to this:
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
Just more setting up for the mailer.
So, now we have a Signup, a few ways for taking care of forgotten passwords, and email authentication. But how do we block the adding posts and replies to only people logged in? How do we restrict creating forums and deleting posts to only an admin? Open up the file vendor/plugins/restful_authentication/generators/authenticated/templates/authenticated_system.rb and read through some of the comments about the functions:
# Returns true or false if the <%= file_name %> is logged in.
# Preloads @current_<%= file_name %> with the <%= file_name %> model if they're logged in.
def logged_in?
current_<%= file_name %> != :false
end
# Accesses the current <%= file_name %> from the session. Set it to :false if login fails
# so that future calls do not hit the database.
def current_<%= file_name %>
@current_<%= file_name %> ||= (login_from_session || login_from_basic_auth || login_from_cookie || :false)
end
logged_in? checks the value of current_user and returns true if it does not equal :false. current_user tries to login from a session, from basic auth, and from a cookie. If it is unable to log them in, it will return :false and cause the value of logged_in to be false.
# Check if the <%= file_name %> is authorized
#
# Override this method in your controllers if you want to restrict access
# to only a few actions or if you want to check if the <%= file_name %>
# has the correct rights.
#
# Example:
#
# # only allow nonbobs
# def authorized?
# current_<%= file_name %>.login != "bob"
# end
def authorized?
logged_in?
end
# Filter method to enforce a login requirement.
#
# To require logins for all actions, use this in your controllers:
#
# before_filter :login_required
#
# To require logins for specific actions, use this in your controllers:
#
# before_filter :login_required, :only => [ :edit, :update ]
#
# To skip this in a subclassed controller:
#
# skip_before_filter :login_required
#
def login_required
authorized? || access_denied
end
We can use before_filter :login_required to stop people from going to /topic/1/posts/2/edit and being able to edit the post. logged_in? can be used in forum and topic's show views, to stop the form for new topics and replies from being shown. This can all be setup however you want, there are plenty of possibilities. What I am going to do is show you how to display different things based on who is logged in, and how to limit certain actions in your controllers. That should give you enough to figure out what you want to do for your specific application. First, open up app/views/forums/show.html.erb:
#find these 3 lines
<h2>New Post</h2>
<%= render :partial => @topic = Topic.new, :locals => { :button_name => 'Create' } %>
<%= link_to 'New Topic', new_forum_topic_path(@forum) %> |
#and put this around them
<% if logged_in? %>
<h2>New Post</h2>
<%= render :partial => @topic = Topic.new, :locals => { :button_name => 'Create' } %>
<%= link_to 'New Topic', new_forum_topic_path(@forum) %> |
<%end>
#and then change this line
<%= link_to 'Edit', edit_forum_path(@forum) %> |
#to this
<% if is_admin? %>
<%= link_to 'Edit', edit_forum_path(@forum) %> |
<% end %>
And now, go to app/controllers/application.rb. In this file we can write functions that will be available to all controllers. Add this to it:
helper_method :is_admin?
def is_admin?
if logged_in? && current_user.login == "admin"
true
else
false
end
end
def admin_required
is_admin? || admin_denied
end
def admin_denied
respond_to do |format|
format.html do
store_location
flash[:notice] = 'You must be an admin to do that.'
redirect_to forums_path
end
end
end
The first line, helper_method :is_admin? will let this function be used in the view. current_user.login is the username for the person logged in. You can change this method however you want to determine whether or not a user is an admin. admin_required and admin_denied will be used in our controllers to restrict access to actions - lets go to our app/controllers/forums_controller.rb and put that in:
#at the very top, right after class ForumsController < ApplicationController, add this line:
before_filter :admin_required, :only => [ :edit, :update, :create, :new, :destroy ]
Now, when you try to access edit, update, create, new, or destroy actions in your controller, if your username is not 'admin', it will go to the show forums page and have a flash saying 'You must be an admin to do that'.
Well, I guess that is it for now. We've added the ability to create users, reset passwords, verify email, and restrict actions to admins. This isn't a completely finished web app. We didn't look into any kind of input validation and we didn't go over all the things we would need to limit to users logged in(create post and create reply), and we didn't limit a lot of things to admins that we should have(such as editing and destroying posts/replies). Hopefully, you've learned enough from this to be able to figure it out yourself, but if you have any trouble feel free to leave a comment. Also, let me know if there are any typos, mistakes, or questions about the tutorial, as well if you have any suggestions. Thanks for reading!
-Ralph