Original restful authentication tutorial, new tutorial based on the latest version of the restful authentication plugin is on the first page.
This tutorial was built and tested using rails version 2.0.2. Features covered include activation, changing passwords, forgotten passwords, enable/disable users, roles and OpenID.
#Updated 2/21/08 to work with version 2+ of the ruby-openid gem and open_id_authentication plugin.
First generate a new project and install restful authentication:
rails myproject -d mysql
cd myproject
ruby script/plugin install http://svn.techno-weenie.net/projects/p
ntication/
Then, so that there is some sort of skeleton content management system, let's create a Page resource, and then run the code to generate the files used by the restful_authentication plugin:
ruby script/generate scaffold Page title:string body:text
ruby script/generate authenticated user sessions --include-activation
Make sure the name of the sessions_controller.rb is plural, as well as the sessions view folder. Also check that the line "include AuthenticatedSystem" was added to the application.rb file.
Next, create a file mail.rb in config/initializers (these are an example of my email settings, which work on Dreamhost):
# Email settings
ActionMailer::Base.delivery_method = :smtp
ActionMailer::Base.smtp_settings = {
:address => "mail.yourapplication.com",
:port => 25,
:domain => "yourapplication.com",
:authentication => :login,
:user_name => "mail@yourapplication.com",
:password => "yourapplicationpassword"
}
Then in config/environment.rb, add the following line after "Rails::Initializer.run do |config|" so that the application sends email to the user after registration, activation, etc. (the user_observer.rb and user_mailer.rb files were generated by restful_authentication in app/models):
config.active_record.observers = :user_observer
Next, open user_mailer.rb in app/models and modify as desired (sample setting are below):
class UserMailer < ActionMailer::Base
def signup_notification(user)
setup_email(user)
@subject += 'Please activate your new account'
@body[:url] = "http://localhost:3000/activate/#{user.activation_code}"
end
def activation(user)
setup_email(user)
@subject += 'Your account has been activated!'
@body[:url] = "http://localhost:3000/"
end
def forgot_password(user)
setup_email(user)
@subject += 'You have requested to change your password'
@body[:url] = "http://localhost:3000/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 = "mail@yourapplication.com"
@subject = "YourApplication - "
@sent_on = Time.now
@body[:user] = user
end
end
Then modify user_observer.rb to incorporate the reset and forgotten password features:
class UserObserver < ActiveRecord::Observer
def after_create(user)
UserMailer.deliver_signup_notification(user)
end def after_save(user)
UserMailer.deliver_activation(user) if user.pending?
UserMailer.deliver_forgot_password(user) if user.recently_forgot_password?
UserMailer.deliver_reset_password(user) if user.recently_reset_password?
end
end
Now let's start setting up roles/permissions:
ruby script/generate scaffold Role rolename:string
ruby script/generate model Permission
Modify the XXX_create_permissions.rb file in db/migrate:
class CreatePermissions < ActiveRecord::Migration
def self.up
create_table :permissions do |t|
t.integer :role_id, :user_id, :null => false
t.timestamps
end
#Make sure the role migration file was generated first
Role.create(:rolename => 'administrator')
#Then, add default admin user
#Be sure change the password later or in this migration file
user = User.new
user.login = "admin"
user.email = "info@yourapplication.com"
user.password = "admin"
user.password_confirmation = "admin"
user.save(false)
user.send(:activate!)
role = Role.find_by_rolename('administrator')
user = User.find_by_login('admin')
permission = Permission.new
permission.role = role
permission.user = user
permission.save(false)
end def self.down
drop_table :permissions
Role.find_by_rolename('administrator').destroy
User.find_by_login('admin').destroy
end
end
Before we start modifying the controllers, we also need to add enabled and password reset code columns to the XXX_create_users.rb migration file in db/migrate:
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 :password_reset_code, :string, :limit => 40
t.column :enabled, :boolean, :default => true
end
end def self.down
drop_table "users"
end
end
Next, we need to make some changes to the files in app/models.
First, model the has_many :through relationship of roles and users.
role.rb:
class Role < ActiveRecord::Base
has_many :permissions
has_many :users, :through => :permissions
end
permission.rb
class Permission < ActiveRecord::Base
belongs_to :user
belongs_to :role
end
Then, some changes need to be made to user.rb, namely a tweak to the email length validation, the user/roles relationship, a check to see if a user has a certain role, code for forgotten passwords, and some changes to the way a user is activated.
require 'digest/sha1'
class User < ActiveRecord::Base
# Virtual attribute for the unencrypted password
attr_accessor :password validates_presence_of :login, :email
validates_presence_of :password, :if => :password_required?
validates_presence_of :password_confirmation, :if => :password_required?
validates_length_of :password, :within => 4..40, :if => :password_required?
validates_confirmation_of :password, :if => :password_required?
validates_length_of :login, :within => 3..40
validates_length_of :email, :within => 6..100
validates_uniqueness_of :login, :email, :case_sensitive => false
validates_format_of :email, :with => /(^([^@\s]+)@((?:[-_a-z0-9]+\.)+[a-z]{2,})$)|(^$)/i
has_many :permissions
has_many :roles, :through => :permissions
before_save :encrypt_password
before_create :make_activation_code
# prevents a user from submitting a crafted form that bypasses activation
# anything else you want your user to change should be added here.
attr_accessible :login, :email, :password, :password_confirmation
class ActivationCodeNotFound < StandardError; end
class AlreadyActivated < StandardError
attr_reader :user, :message;
def initialize(user, message=nil)
@message, @user = message, user
end
end
# Finds the user with the corresponding activation code, activates their account and returns the user.
#
# Raises:
# +User::ActivationCodeNotFound+ if there is no user with the corresponding activation code
# +User::AlreadyActivated+ if the user with the corresponding activation code has already activated their account
def self.find_and_activate!(activation_code)
raise ArgumentError if activation_code.nil?
user = find_by_activation_code(activation_code)
raise ActivationCodeNotFound if !user
raise AlreadyActivated.new(user) if user.active?
user.send(:activate!)
user
end
def active?
# the presence of an activation date means they have activated
!activated_at.nil?
end
# Returns true if the user has just been activated.
def pending?
@activated
end
# Authenticates a user by their login name and unencrypted password. Returns the user or nil.
# Updated 2/20/08
def self.authenticate(login, password)
u = find :first, :conditions => ['login = ?', login] # need to get the salt
u && u.authenticated?(password) ? u : nil
end
# Encrypts some data with the salt.
def self.encrypt(password, salt)
Digest::SHA1.hexdigest("--#{salt}--#{password}--")
end
# Encrypts the password with the user salt
def encrypt(password)
self.class.encrypt(password, salt)
end
def authenticated?(password)
crypted_password == encrypt(password)
end
def remember_token?
remember_token_expires_at && Time.now.utc < remember_token_expires_at
end
# These create and unset the fields required for remembering users between browser closes
def remember_me
remember_me_for 2.weeks
end
def remember_me_for(time)
remember_me_until time.from_now.utc
end
def remember_me_until(time)
self.remember_token_expires_at = time
self.remember_token = encrypt("#{email}--#{remember_token_expires_at}")
save(false)
end
def forget_me
self.remember_token_expires_at = nil
self.remember_token = nil
save(false)
end
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_attribute(: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 self.find_for_forget(email)
find :first, :conditions => ['email = ? and activated_at IS NOT NULL', email]
end
def has_role?(rolename)
self.roles.find_by_rolename(rolename) ? true : false
end
protected
# before filter
def encrypt_password
return if password.blank?
self.salt = Digest::SHA1.hexdigest("--#{Time.now.to_s}--#{login}--") if new_record?
self.crypted_password = encrypt(password)
end
def password_required?
crypted_password.blank? || !password.blank?
end
def make_activation_code
self.activation_code = Digest::SHA1.hexdigest( Time.now.to_s.split(//).sort_by {rand}.join )
end
def make_password_reset_code
self.password_reset_code = Digest::SHA1.hexdigest( Time.now.to_s.split(//).sort_by {rand}.join )
end
private
def activate!
@activated = true
self.update_attribute(:activated_at, Time.now.utc)
end
end
I also recommend some changes for the file authenticated_system.rb, located in the lib directory, which was generated by the restful_authentication plugin. I added the methods not_logged_in_required, check_role, check_administrator_role, and permission_denied.
module AuthenticatedSystem
protected
# Returns true or false if the user is logged in.
# Preloads @current_user with the user model if they're logged in.
def logged_in?
current_user != :false
end # Accesses the current user from the session. Set it to :false if login fails
# so that future calls do not hit the database.
def current_user
@current_user ||= (login_from_session || login_from_basic_auth || login_from_cookie || :false)
end
# Store the given user id in the session.
def current_user=(new_user)
session[:user_id] = (new_user.nil? || new_user.is_a?(Symbol)) ? nil : new_user.id
@current_user = new_user || :false
end
# Check if the user 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 user
# has the correct rights.
#
# Example:
#
# # only allow nonbobs
# def authorized?
# current_user.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
def not_logged_in_required
!logged_in? || permission_denied
end
def check_role(role)
unless logged_in? && @current_user.has_role?(role)
if logged_in?
permission_denied
else
store_referer
access_denied
end
end
end
def check_administrator_role
check_role('administrator')
end
# Redirect as appropriate when an access request fails.
#
# The default action is to redirect to the login screen.
#
# Override this method in your controllers if you want to have special
# behavior in case the user is not authorized
# to access the requested action. For example, a popup window might
# simply close itself.
def access_denied
respond_to do |format|
format.html do
store_location
flash[:error] = "You must be logged in to access this feature."
redirect_to :controller => '/session', :action => 'new'
end
format.xml do
request_http_basic_authentication 'Web Password'
end
end
end
def permission_denied
respond_to do |format|
format.html do
#Put your domain name here ex. http://www.example.com
domain_name = "http://localhost:3000"
http_referer = session[:refer_to]
if http_referer.nil?
store_referer
http_referer = ( session[:refer_to] || domain_name )
end
flash[:error] = "You don't have permission to complete that action."
#The [0..20] represents the 21 characters in http://localhost:3000
#You have to set that to the number of characters in your domain name
if http_referer[0..20] != domain_name
session[:refer_to] = nil
redirect_to root_path
else
redirect_to_referer_or_default(root_path)
end
end
format.xml do
headers["Status"] = "Unauthorized"
headers["WWW-Authenticate"] = %(Basic realm="Web Password")
render :text => "You don't have permission to complete this action.", :status => '401 Unauthorized'
end
end
end
# Store the URI of the current request in the session.
#
# We can return to this location by calling #redirect_back_or_default.
def store_location
session[:return_to] = request.request_uri
end
def store_referer
session[:refer_to] = request.env["HTTP_REFERER"]
end
# Redirect to the URI stored by the most recent store_location call or
# to the passed default.
def redirect_back_or_default(default)
redirect_to(session[:return_to] || default)
session[:return_to] = nil
end
def redirect_to_referer_or_default(default)
redirect_to(session[:refer_to] || default)
session[:refer_to] = nil
end
# Inclusion hook to make #current_user and #logged_in?
# available as ActionView helper methods.
def self.included(base)
base.send :helper_method, :current_user, :logged_in?
end
# Called from #current_user. First attempt to login by the user id stored in the session.
def login_from_session
self.current_user = User.find(session[:user_id]) if session[:user_id]
end
# Called from #current_user. Now, attempt to login by basic authentication information.
def login_from_basic_auth
authenticate_with_http_basic do |username, password|
self.current_user = User.authenticate(username, password)
end
end
# Called from #current_user. Finaly, attempt to login by an expiring token in the cookie.
def login_from_cookie
user = cookies[:auth_token] && User.find_by_remember_token(cookies[:auth_token])
if user && user.remember_token?
user.remember_me
cookies[:auth_token] = { :value => user.remember_token, :expires => user.remember_token_expires_at }
self.current_user = user
end
end
end
The permission_denied method is definitly an area I feel needs improvement. Its just something I threw together so that users would be redirected properly if they tried to access a resource they didn't have permission for. Its designed to redirect back to the last page they were on, unless that page is on another site or has the same address as the resource they're trying to access. Please let me know if you have any suggested improvements for that method.
Now, let's modify the controllers. I moved a lot of the actions generated by restful_authentication into their own controllers in order to follow restful conventions as close as possible and in case I wanted to extend the functionality in the future.
First, the users_controller.rb in app/controllers:
class UsersController < ApplicationController
layout 'application'
before_filter :not_logged_in_required, :only => [:new, :create]
before_filter :login_required, :only => [:show, :edit, :update]
before_filter :check_administrator_role, :only => [:index, :destroy, :enable]
def index
@users = User.find(:all)
end
#This show action only allows users to view their own profile
def show
@user = current_user
end
# render new.rhtml
def new
@user = User.new
end def create
cookies.delete :auth_token
@user = User.new(params[:user])
@user.save!
#Uncomment to have the user logged in after creating an account - Not Recommended
#self.current_user = @user
flash[:notice] = "Thanks for signing up! Please check your email to activate your account before logging in."
redirect_to login_path
rescue ActiveRecord::RecordInvalid
flash[:error] = "There was a problem creating your account."
render :action => 'new'
end
def edit
@user = current_user
end
def update
@user = User.find(current_user)
if @user.update_attributes(params[:user])
flash[:notice] = "User updated"
redirect_to :action => 'show', :id => current_user
else
render :action => 'edit'
end
end
def destroy
@user = User.find(params[:id])
if @user.update_attribute(:enabled, false)
flash[:notice] = "User disabled"
else
flash[:error] = "There was a problem disabling this user."
end
redirect_to :action => 'index'
end
def enable
@user = User.find(params[:id])
if @user.update_attribute(:enabled, true)
flash[:notice] = "User enabled"
else
flash[:error] = "There was a problem enabling this user."
end
redirect_to :action => 'index'
end
end
Then, sessions_controller.rb (setting up the controller this way will make it easier to integrate openid authentication):
# This controller handles the login/logout function of the site.
class SessionsController < ApplicationController
layout 'application'
before_filter :login_required, :only => :destroy
before_filter :not_logged_in_required, :only => [:new, :create]
# render new.rhtml
def new
end def create
password_authentication(params[:login], params[:password])
end
def destroy
self.current_user.forget_me if logged_in?
cookies.delete :auth_token
reset_session
flash[:notice] = "You have been logged out."
redirect_to login_path
end
protected
# Updated 2/20/08
def password_authentication(login, password)
user = User.authenticate(login, password)
if user == nil
failed_login("Your username or password is incorrect.")
elsif user.activated_at.blank?
failed_login("Your account is not active, please check your email for the activation code.")
elsif user.enabled == false
failed_login("Your account has been disabled.")
else
self.current_user = user
successful_login
end
end
private
def failed_login(message)
flash.now[:error] = message
render :action => 'new'
end
def successful_login
if params[:remember_me] == "1"
self.current_user.remember_me
cookies[:auth_token] = { :value => self.current_user.remember_token , :expires => self.current_user.remember_token_expires_at }
end
flash[:notice] = "Logged in successfully"
return_to = session[:return_to]
if return_to.nil?
redirect_to user_path(self.current_user)
else
redirect_to return_to
end
end
end
Next, generate two more controllers:
ruby script/generate controller Passwords
ruby script/generate controller Accounts
passwords_controller.rb:
class PasswordsController < ApplicationController
layout 'application'
before_filter :not_logged_in_required, :only => [:new, :create]
# Enter email address to recover password
def new
end
# Forgot password action
def create
return unless request.post?
if @user = User.find_for_forget(params[:email])
@user.forgot_password
@user.save
flash[:notice] = "A password reset link has been sent to your email address."
redirect_to login_path
else
flash[:notice] = "Could not find a user with that email address."
render :action => 'new'
end
end
# Action triggered by clicking on the /reset_password/:id link recieved via email
# Makes sure the id code is included
# Checks that the id code matches a user in the database
# Then if everything checks out, shows the password reset fields
def edit
if params[:id].nil?
render :action => 'new'
return
end
@user = User.find_by_password_reset_code(params[:id]) if params[:id]
raise if @user.nil?
rescue
logger.error "Invalid Reset Code entered."
flash[:notice] = "Sorry - That is an invalid password reset code. Please check your code and try again. (Perhaps your email client inserted a carriage return?)"
#redirect_back_or_default('/')
redirect_to new_user_path
end
# Reset password action /reset_password/:id
# Checks once again that an id is included and makes sure that the password field isn't blank
def update
if params[:id].nil?
render :action => 'new'
return
end
if params[:password].blank?
flash[:notice] = "Password field cannot be blank."
render :action => 'edit', :id => params[:id]
return
end
@user = User.find_by_password_reset_code(params[:id]) if params[:id]
raise if @user.nil?
return if @user unless params[:password]
if (params[:password] == params[:password_confirmation])
#Uncomment and comment lines with @user to have the user logged in after reset - not recommended
#self.current_user = @user #for the next two lines to work
#current_user.password_confirmation = params[:password_confirmation]
#current_user.password = params[:password]
#@user.reset_password
#flash[:notice] = current_user.save ? "Password reset" : "Password not reset"
@user.password_confirmation = params[:password_confirmation]
@user.password = params[:password]
@user.reset_password
flash[:notice] = @user.save ? "Password reset." : "Password not reset."
else
flash[:notice] = "Password mismatch."
render :action => 'edit', :id => params[:id]
return
end
redirect_to login_path
rescue
logger.error "Invalid Reset Code entered"
flash[:notice] = "Sorry - That is an invalid password reset code. Please check your code and try again. (Perhaps your email client inserted a carriage return?)"
redirect_to new_user_path
end
end
accounts_controller.rb:
class AccountsController < ApplicationController
layout 'application'
before_filter :login_required, :except => :show
before_filter :not_logged_in_required, :only => :show # Activate action
def show
# Uncomment and change paths to have user logged in after activation - not recommended
#self.current_user = User.find_and_activate!(params[:id])
User.find_and_activate!(params[:id])
flash[:notice] = "Your account has been activated! You can now login."
redirect_to login_path
rescue User::ArgumentError
flash[:notice] = 'Activation code not found. Please try creating a new account.'
redirect_to new_user_path
rescue User::ActivationCodeNotFound
flash[:notice] = 'Activation code not found. Please try creating a new account.'
redirect_to new_user_path
rescue User::AlreadyActivated
flash[:notice] = 'Your account has already been activated. You can log in below.'
redirect_to login_path
end
def edit
end
# Change password action
def update
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 root_path #profile_url(current_user.login)
else
flash[:error] = "An error occured, your password was not changed."
render :action => 'edit'
end
else
flash[:error] = "New password does not match the password confirmation."
@old_password = params[:old_password]
render :action => 'edit'
end
else
flash[:error] = "Your old password is incorrect."
render :action => 'edit'
end
end
end
roles_controller.rb
class RolesController < ApplicationController
layout 'application'
before_filter :check_administrator_role def index
@user = User.find(params[:user_id])
@all_roles = Role.find(:all)
end
def update
@user = User.find(params[:user_id])
@role = Role.find(params[:id])
unless @user.has_role?(@role.rolename)
@user.roles << @role
end
redirect_to :action => 'index'
end
def destroy
@user = User.find(params[:user_id])
@role = Role.find(params[:id])
if @user.has_role?(@role.rolename)
@user.roles.delete(@role)
end
redirect_to :action => 'index'
end
end
Now, the views. The first thing I would recommend is to create an application wide layout, that includes at least:
<ul>
<% if logged_in? %>
<li>Logged in as:</li>
<li><%= link_to h(current_user.login.capitalize), user_path(current_user) %></li>
<ul>
<li><%= link_to 'Edit Profile', edit_user_path(current_user) %></li>
<li><%= link_to 'Change Password', change_password_path %></li>
<li><%= link_to 'Log Out', logout_url %></li>
</ul>
<% if current_user.has_role?('administrator') %>
<li><%= link_to 'Administer Users', users_path %></li>
<% end %>
<% else %>
<li><%= link_to 'Log In', new_session_path %></li>
<li><%= link_to 'Sign Up', new_user_path %></li>
<li><%= link_to 'Forgot Password?', forgot_password_path %></li>
<% end %>
</ul>
app/views/passwords/edit.html.erb:
<% form_tag url_for(:action => "update", :id => params[:id]) do %>
Password:<br />
<%= password_field_tag :password %><br />
Confirm Password:<br />
<%= password_field_tag :password_confirmation %><br />
<%= submit_tag "Reset Your Password" %>
<% end %>
app/views/passwords/new.html.erb:
<h2>Forgot Password</h2>
<% form_tag url_for(:action => 'create') do %>
What is the email address used to create your account?<br />
<%= text_field_tag :email, "", :size => 50 %><br />
<%= submit_tag 'Reset Password' %>
<% end %>
app/views/accounts/edit.html.erb:
<% form_tag url_for(:action => "update") do %>
<p><label for="old_password" class="block">Old Password</label><br />
<%= password_field_tag 'old_password', @old_password, :size => 45 %></p> <p><label for="password" class="block">New Password</label><br />
<%= password_field_tag 'password', {}, :size => 45 %><br />
<small>Between 4 and 40 characters</small></p>
<p><label for="password_confirmation" class="block">Confirm new password</label><br />
<%= password_field_tag 'password_confirmation', {}, :size => 45 %></p>
<%= submit_tag 'Change password' %>
<% end %>
app/views/roles/_role.html.erb:
<li>
<%= role.rolename %>
<% if @user.has_role?(role.rolename) %>
<%= link_to 'remove role', user_role_url(:id => role.id, :user_id => @user.id), :method => :delete %>
<% else %>
<%= link_to 'assign role', user_role_url(:id => role.id, :user_id => @user.id), :method => :put %>
<% end %>
</li>
app/views/roles/index.html.erb:
<h2>Roles for <%=h @user.login.capitalize %></h2><h3>Roles assigned:</h3>
<ul><%= render :partial => 'role', :collection => @user.roles %></ul>
<h3>Roles available:</h3>
<ul><%= render :partial => 'role', :collection => (@all_roles - @user.roles) %></ul>
You can remove or extend the other views in the roles folder.
app/views/session/new.html.erb:
<h2>Login with User ID and Password:</h2>
<% form_tag session_path do %>
<p><label for="login">Login</label><br/>
<%= text_field_tag 'login' %></p><p><label for="password">Password</label><br />
<%= password_field_tag 'password' %></p>
<p><label for="remember_me">Remember me:</label>
<%= check_box_tag 'remember_me' %></p>
<p><%= submit_tag 'Log in' %><%= link_to "Sign Up", new_user_path %></p>
<% end %>
app/views/user_mailer/activation.html.erb:
<%=h @user.login %>, your account has been activated. To visit the site, follow the link below: <%= @url %>
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:
<%=h @user.login %>, Your password has been reset
app/views/user_mailer/signup_notification.html.erb:
Your account has been created. Username: <%=h @user.login %>
Visit this url to activate your account:
<%= @url %>
app/views/users/_user.html.erb
<tr class="<%= cycle('odd', 'even') %>">
<td><%=h user.login %></td>
<td><%=h user.email %></td>
<td><%= user.enabled ? 'yes' : 'no' %>
<% unless user == current_user %>
<% if user.enabled %>
<%= link_to('disable', user_path(user.id), :method => :delete) %>
<% else %>
<%= link_to('enable', enable_user_path(user.id), :method => :put) %>
<% end %>
<% end %>
</td>
<td><%= link_to 'edit roles', user_roles_path(user) %>]</td>
</tr>app/views/users/edit.html.erb:
<h2>Edit Your Account</h2>
<p><%= link_to 'Show Profile', user_path(@user) %> | <%= link_to 'Change Password', change_password_path %></p>
<%= error_messages_for :user %><% form_for :user, :url => user_url(@user), :html => { :method => :put } do |f| %>
<p>Email:<br /><%= f.text_field :email, :size => 60 %></p>
<%= submit_tag 'Save' %>
<% end %>
app/views/users/index.html.erb:
<h2>All Users</h2>
<table>
<tr>
<th>Username</th>
<th>Email</th>
<th>Enabled?</th>
<th>Roles</th>
</tr>
<%= render :partial => 'user', :collection => @users %>
</table>
app/views/users/new.html.erb:
<%= error_messages_for :user %>
<% form_for :user, :url => users_path do |f| %>
<p><label for="login">Login</label><br/>
<%= f.text_field :login %></p><p><label for="email">Email</label><br/>
<%= f.text_field :email %></p>
<p><label for="password">Password</label><br/>
<%= f.password_field :password %></p>
<p><label for="password_confirmation">Confirm Password</label><br/>
<%= f.password_field :password_confirmation %></p>
<p><%= submit_tag 'Sign up' %></p>
<% end %>
app/views/users/show.html.erb:
<h2>User: <%=h @user.login %></h2>
<p>Joined on: <%= @user.created_at.to_s(:long) %></p>
Finally, we need to modify the routes.rb in the config directory:
ActionController::Routing::Routes.draw do |map|
map.root :controller => "pages", :action => "index"
map.signup '/signup', :controller => 'users', :action => 'new'
map.login '/login', :controller => 'sessions', :action => 'new'
map.logout '/logout', :controller => 'sessions', :action => 'destroy'
map.activate '/activate/:id', :controller => 'accounts', :action => 'show'
map.forgot_password '/forgot_password', :controller => 'passwords', :action => 'new'
map.reset_password '/reset_password/:id', :controller => 'passwords', :action => 'edit'
map.change_password '/change_password', :controller => 'accounts', :action => 'edit'
# See how all your routes lay out with "rake routes"
map.resources :pages map.resources :users, :member => { :enable => :put } do |users|
users.resource :account
users.resources :roles
end
map.resource :session
map.resource :password
# Install the default routes as the lowest priority.
map.connect ':controller/:action/:id'
map.connect ':controller/:action/:id.:format'
end
Don't forget to delete index.html from the public directory.
To get the site up and running, we still need to create a database and run the migrations:
mysql -u root
create database myproject_development;
exitrake db:migrate
Start up the server and you should have a fully functioning user authentication system. Don't forget you have access to several before filters now, such as :login_required, :check_administrator_role, and :not_logged_in_required. Adding a new before filter is easy too, for example, you could add the following code to authenticated_system.rb to create a :check_moderator_role before filter. Make sure to add moderator to the roles table as well:
def check_moderator_role
check_role('moderator')
end
Stop here if you don't want to use OpenID.
Now that the open_id_authentication plugin is being patched to work with verson 2+ of the ruby-openid gem, I have added this section on openid authentication to the tutorial:
To get started, install the latest version of the openid gem:
# Installs ruby-openid-2.0.4
gem install ruby-openid
To install the plugin, you can use the git repository at
http://github.com/josh/open_id_authenti
y_openid_2If you don't have or can't use git, you can download a rar file of it
from
http://rapidshare.com/files/93608377/op
n.rar.html Unrar it and copy the folder into
vendor/plugins. I tried to put it on rubyforge so there would be SVN access but it takes 72 hours.
Run the migration for the open id authentication tables:
rake open_id_authentication:db:create
Open the migration file XXX_add_open_id_authentication, and add a column for the OpenID url in your users table:
add_column :users, :identity_url, :string
So that your migration file now looks like:
class AddOpenIdAuthenticationTables < ActiveRecord::Migration
def self.up
create_table :open_id_authentication_associations, :force => true do |t|
t.integer :issued, :lifetime
t.string :handle, :assoc_type
t.binary :server_url, :secret
end create_table :open_id_authentication_nonces, :force => true do |t|
t.integer :timestamp, :null => false
t.string :server_url, :null => true
t.string :salt, :null => false
end
add_column :users, :identity_url, :string
end
def self.down
remove_column :users, :identity_url
drop_table :open_id_authentication_associations
drop_table :open_id_authentication_nonces
end
end
Run the migration:
rake db:migrate
Add the route for OpenID authentication to your routes file:
map.open_id_complete 'session', :controller => "sessions", :action => "create",
:requirements => { :method => :get }
so that your routes.rb file looks like:
ActionController::Routing::Routes.draw do |map|
map.root :controller => "pages", :action => "index"
map.signup '/signup', :controller => 'users', :action => 'new'
map.login '/login', :controller => 'sessions', :action => 'new'
map.logout '/logout', :controller => 'sessions', :action => 'destroy'
map.activate '/activate/:id', :controller => 'accounts', :action => 'show'
map.forgot_password '/forgot_password', :controller => 'passwords', :action => 'new'
map.reset_password '/reset_password/:id', :controller => 'passwords', :action => 'edit'
map.change_password '/change_password', :controller => 'accounts', :action => 'edit'
map.open_id_complete 'session', :controller => "sessions", :action => "create", :requirements => { :method => :get } # See how all your routes lay out with "rake routes"
map.resources :pages
map.resources :users, :member => { :enable => :put } do |users|
users.resource :account
users.resources :roles
end
map.resource :session
map.resource :password
# Install the default routes as the lowest priority.
map.connect ':controller/:action/:id'
map.connect ':controller/:action/:id.:format'
end
Modify the attr_accesible line in your User model so that your users can set and change their OpenID:
attr_accessible :login, :email, :password, :password_confirmation, :identity_url
You also need to modify the password_required? method in the User model:
def password_required?
not_openid? && (crypted_password.blank? || !password.blank?)
end
and add below it:
def not_openid?
identity_url.blank?
end
Edit the New sessions view at sessions/new.html.erb to include an option for an OpenID login:
<h2>Login with User ID and Password:</h2>
<% form_tag session_path do -%>
<p><label for="login">Login</label><br/>
<%= text_field_tag 'login' %></p><p><label for="password">Password</label><br/>
<%= password_field_tag 'password' %><br />
<label for="remember_me">Remember me:</label>
<%= check_box_tag 'remember_me' %></p>
<p><br /><label for="openid_url">Optional - use your OpenID url:</label><br />
<%= text_field_tag "openid_url" %></p>
<p><%= submit_tag 'Log In' %> or <%= link_to "Sign Up", new_user_path %></p>
<% end -%>
If you want the OpenID logo used in this form field, add the following code to your CSS file:
input#openid_url {
background: url(http://openid.net/login-bg.gif) no-repeat;
background-color: #fff;
background-position: 0 50%;
color: #000;
padding-left: 18px;
}Next, modify the Sessions Controller to include OpenID authentication. Here we have added if using_open_id? and the methods open_id_authentication and create_open_id_user:
class SessionsController < ApplicationController
layout 'application'
before_filter :login_required, :only => :destroy
before_filter :not_logged_in_required, :only => [:new, :create]
# render new.rhtml
def new
end def create
if using_open_id?
open_id_authentication(params[:openid_url])
else
password_authentication(params[:login], params[:password])
end
end
def destroy
self.current_user.forget_me if logged_in?
cookies.delete :auth_token
reset_session
flash[:notice] = "You have been logged out."
redirect_to login_path
#redirect_back_or_default('/')
end
protected
def open_id_authentication(openid_url)
authenticate_with_open_id(openid_url, :required => [:nickname, :email]) do |result, identity_url, registration|
if result.successful?
@user = User.find_or_initialize_by_identity_url(identity_url)
if @user.new_record?
if !registration['nickname'].blank? && !registration['email'].blank?
@user.login = registration['nickname']
@user.email = registration['email']
create_open_id_user(@user)
else
flash[:error] = "Your persona must include at a minimum a nickname
and valid email address to use OpenID on this site."
render :action => 'new'
end
else
if @user.activated_at.blank?
failed_login("Your account is not active, please check your email for the activation code.")
elsif @user.enabled == false
failed_login("Your account has been disabled.")
else
self.current_user = @user
successful_login
end
end
else
failed_login result.message
end
end
end
def create_open_id_user(user)
user.save!
flash[:notice] = "Thanks for signing up! Please check your email to activate your account before logging in."
redirect_to login_path
rescue ActiveRecord::RecordInvalid
flash[:error] = "Someone has signed up with that nickname or email address. Please create
another persona for this site."
render :action => 'new'
end
def password_authentication(login, password)
user = User.authenticate(login, password)
if user == nil
failed_login("Your username or password is incorrect.")
elsif user.activated_at.blank?
failed_login("Your account is not active, please check your email for the activation code.")
elsif user.enabled == false
failed_login("Your account has been disabled.")
else
self.current_user = user
successful_login
end
end
private
def failed_login(message)
flash.now[:error] = message
render :action => 'new'
end
def successful_login
if params[:remember_me] == "1"
self.current_user.remember_me
cookies[:auth_token] = { :value => self.current_user.remember_token , :expires => self.current_user.remember_token_expires_at }
end
flash[:notice] = "Logged in successfully"
return_to = session[:return_to]
if return_to.nil?
redirect_to user_path(self.current_user)
else
redirect_to return_to
end
end
end
Even though nickname and email are listed as required openid fields, the application was still allowing a new user to be created without them, so I had to !registration['nickname'].blank? && !registration['email'].blank? validation to make sure they were actually present. If anyone knows any more about this let me know. Also, I couldn't get the remember me checkbox to work with openid authentication because of the redirect to your openid provider. Please let me know if anyone has a fix for this as well.
References / Additional Resources:
Restful Authentication:
- http://svn.techno-weenie.net/projects/p
ntication/ Thank you, Rick Olson (techno-weenie.net), without this plugin I'd still be developing my login system.
Setup:
- http://railscasts.com/episodes/67
Modifications:
- http://www.carmelyne.com/2007/8/21/rest
ion-mailer
- http://technoweenie.stikipad.com/plugin
+passwords
- http://technoweenie.stikipad.com/plugin
+Resetting
- http://railsforum.com/viewtopic.php?id=11962
Mailer Modification:
- http://technoweenie.stikipad.com/plugin
iler+Setup
Activation Method Modification:
- http://technoweenie.stikipad.com/plugin
Activation
- http://toolmantim.com/article/2007/1/31
activation
Routing Modifications:
- http://railsforum.com/viewtopic.php?id=13653
OpenID:
- http://railscasts.com/episodes/68
- http://github.com/josh/open_id_authenti
y_openid_2
- http://www.bencurtis.com/archives/2007/
enticated/
- http://openidenabled.com/ruby-openid/
- http://svn.rubyonrails.org/rails/plugin
ion/README
- http://dev.rubyonrails.org/ticket/10604
Highly recommended additional reading:
- Apress Practical Rails Social Networking Sites (http://www.amazon.com/Practical-Rails-S
1590598415). While Agile Web Development with Rails and The Rails Way are great books, this is my personal favorite; I felt I learned the most from this book. There are so many features covered that are easy to incorporate into your projects, and I liked the book more than RailsSpace because most of the code is restful.
Roles Plugin (not used in the tutorial):
- This is a promising plugin, but I like the has_many :through relationship used above more than the habtm relationship used in the plugin.
- http://code.google.com/p/rolerequirement/