(first posted: Sep 21, 2007)
(26195 Reads)
keywords: acts as state machine restful authentication
Permalink
stateful authentication
Overview
The idea is to enhance restful_authentication with additional user statuses.
- :passive - not authorized to log in, but known to the system, such as for mailing lists
- :pending - user has registered but has not activated his account yet
- :active - user account is active, user can log in
- :suspended - user account suspended, not allowed to log in, can be re-enabled
- :deleted - user id remains valid but account deleted, login name and email can be reused
Passive users may have signed up on the system, given their email and name or whatever, but don't have the right to log in. Passive users can register later with login privileges.
An active user can log in. (As for roles, authorization is handled separately from authentication, see my earlier post ).
Any user can be suspended by the administrator, for whatever reason. Suspended accounts can be re-enabled (un-suspended).
Keeping deleted user records is handy, for example, if some other objects such as a Comment references this user id, you can still retrieve some info about the poster even if he's no longer around. (A separate admin purge action can actually call destroy on deleted users). A deleted user will have its login and email changed with the string "[DELETED datetime]" appended, so they can be reused in a new user record.
"john@gmail.com [DELETED Fri Sep 21 13:35:07 -0400 2007]"
In restful_authentication, the User model includes columns for login name, email, crypted_password, activation_code (allows user to active the account), and activated_at date (once account is activated). You could say an :active status is implied when activated_at is not blank?; and a :pending status is implied when activation_code is not blank?.
Our case extends this. You could say a :passive status is when both activated_at and activation_code are nil. But any user can be suspended or deleted regardless of these attribute values.
Setup
This article assumes you have installed the restful_authentication plugin and the acts_as_state_machine plugin. Note, I generated RA to require user activation (--include-activation).
$ script/plugin source http://svn.techno-weenie.net/projects/plugins
$ script/plugin install restful_authentication
$ script/generate authenticated user sessions --include-activation
$ rake db:migrate
add observer to environment.rb
add named routes to routes.rb
move lines from users_controller.rb to application.rb
edit user_notifier.rb as needed
$ script/plugin install http://elitists.textdriven.com/svn/plugins/acts_as_state_machine/trunk/
I'll leave other details to others, e.g.
http://svn.techno-weenie.net/projects/plugins/restful_authentication/README
http://weblog.techno-weenie.net/2006/8/1/restful-authentication-plugin
http://agilewebdevelopment.com/plugins/restful_authentication
http://www.urbanpuddle.com/articles/2007/03/05/restful-authentication-for-ruby-on-rails-apps
http://elitists.textdriven.com/svn/plugins/acts_as_state_machine/trunk/README
http://rails.aizatto.com/2007/05/24/ruby-on-rails-finite-state-machine-plugin-acts_as_state_machine/
http://www.slideshare.net/dhpeterson/a-simple-workflow-system-using-state-machines
http://iamruinous.com/2007/3/6/automatically-calling-acts_as_state_machine-events-on-update
Maybe I should consider making this a whole new plugin, but for now here's instructions to DIY (do it yourself).
User model
Make the User a state-machine. First, add a status attribute to the User model (we'll call it status rather than state)
$ script/generate migration add_status_to_users
edit with
add_column :users, :status, :string, :null => :no, :default => "passive"
and then
$ rake db:migrate
To models/user.rb, add:
acts_as_state_machine :initial => :passive, :column => :status
state :passive
state :pending, :enter => :make_activation_code
state :active, :enter => :do_activate
state :suspended
state :deleted, :enter => :do_delete
Add the state machine transition events:
event :register do
transitions :from => :passive, :to => :pending, :guard => Proc.new {|u| !u.crypted_password.blank? }
end
event :activate do
transitions :from => :pending, :to => :active
end
event :suspend do
transitions :from => [:passive, :pending, :active], :to => :suspended
end
event :delete do
transitions :from => [:passive, :pending, :active, :suspended], :to => :deleted
end
event :unsuspend do
transitions :from => :suspended, :to => :active, :guard => Proc.new {|u| !u.activated_at.blank? }
transitions :from => :suspended, :to => :pending, :guard => Proc.new {|u| !u.activation_code.blank? }
transitions :from => :suspended, :to => :passive
end
Thus when a new user is created, it's :passive. Then, user.register! will move a user from :passive to :pending (provided it has a password), and an activation_code is generated).
user.activate! will activate the account. To avoid confusion with the original #activate method, rename the old one to #do_activate. And make a small change to preserve the activated_at date (like when transitioning from suspended back to active).
# rename old activate to do_activate
def do_activate
@activated = true
self.activated_at ||= Time.now.utc
self.activation_code = nil
end
Also, now the activated? method is handled by our new active? method:
# retain for compatibility, redundant with active?
def activated?
active?
end
Add a do_delete method (can be in protected)
def do_delete
t = Time.now.to_s
self.login += " [DELETED #{t}]" self.email += " [DELETED #{t}]"end
Comment out the following because a new passive user doesn't need an activation code. Rather, this gets called in the :pending :enter callback:
#before_create :make_activation_code
When is password required? In original RA, it was always required if the crypted_password attribute is blank (new user) or if the password is being changed. Now, users don't need a password unless they're going to be activating an account, and guard that in the :register event transition. So the password (and password_confirmation) is validated only if it is present, thus, change password_required to
def password_required?
!password.blank?
end
One last thing, we only want to authenticate users who are active. Change self.authenticate to check:
def self.authenticate(login, password)
u = find :first, :conditions => ['login = ? and status = "active"', login]
u && u.authenticated?(password) ? u : nil
end
Note that anywhere in your code that you do a Users.find, you now need to scope that by the requested state, such as
users = User.find_all_by_status(:active)
UsersController
Okeydokey, next is the controller...
In users_controller.rb, the create action can now create either a passive or pending user. We'll use the presence of a password field to signal a new registration otherwise its a passive signup. Note, create does double duty, if the user exists as passive, it still moves ahead (or you could add an update action if this disturbs your REST, see this ).
def create
@user = User.find_by_email_and_status(params[:user][:email], :passive) if params[:user][:email]
if @user.nil?
@user = User.new(params[:user])
@user.save!
end
@user.register! if params[:password]
self.current_user = @user if @user.pending? # lets user be logged in before activating
redirect_back_or_default('/')flash[:notice] = "Thanks for signing up!"
rescue ActiveRecord::RecordInvalid
render :action => 'new'
end
(Notes: I decided to key off a unique email rather than login name, seems more intuitive. Could be either. #create must fail if there's no params[:email]; we let the User.new handle that validation and rescue).
In the activate action, the only change is call the new #activate! rather than #activate
def activate
self.current_user = User.find_by_activation_code(params[:activation_code])
if logged_in? && !current_user.activated?
current_user.activate!
flash[:notice] = "Signup complete!"
end
redirect_back_or_default('/')end
If you've included the forgot password stuff then in #forgot_password, change this line:
#if @user = User.find_by_email(params[:user][:email])
if @user = User.find_by_email_and_status(params[:user][:email], "active")
Finally, we can add #suspend, #unsuspend, #destroy and #purge actions (which should be available only to authorized admin users-- you'll need your own :admin_required helper for this).
before_filter :admin_required, :only => [:suspend, :unsuspend, :destroy, :purge]
# PUT /users/1/suspend
def suspend
@user = User.find(params[:id])
@user.suspend!
redirect_to(users_url)
end
# PUT /users/1/unsuspend
def unsuspend
@user = User.find(params[:id])
@user.unsuspend!
redirect_to(users_url)
end
# DELETE /users/1
def destroy
@user = User.find(params[:id])
@user.delete!
redirect_to(users_url)
end
# DELETE /users/1/purge
def purge
@user = User.find(params[:id])
@user.destroy
redirect_to(users_url)
end
Make sure the corresponding routes are mapped in routes.rb. This is how I'm doing it,
map.resources :users,
:member => { :suspend => :put,
:unsuspend => :put,
:purge => :delete }
along with all the other named routes for the RA plugin (e.g. map.signup, map.activate, etc).
Observer / Notifier
The signup notification email (with activation_code) should be sent only when the user is pending. In user_observer.rb, remove the entire after_create method and add the following line to after_save
UserNotifier.deliver_signup_notification(user) if user.pending?
Views
You could also adjust your views, but basically now a user can sign up with or without a password. When password is omitted, a passive user is captured (say, for subscription to a newsletter). When password is provided, he goes through full registration. Passive users can register later by supplying his email and a new password.
Tests
To get the restful_authentication tests to work with our changes, do the following:
in users.yml,
add "status: active" to the quentin user record
add "status: pending" to the aaron user record
in user_test.rb:
remove the test_should_require_password, since its not required
in users_controller_test.rb:
remove the test_should_require_password_on_signup, since its not required
To extend the tests for our new features... well, yes you should do that. Here's a handful that'd go into user_test.rb, certainly not full coverage.
def test_should_create_passive_user
assert_difference User, :count do
user = create_user(:password => nil, :password_confirmation => nil)
assert !user.new_record?, "#{user.errors.full_messages.to_sentence}"assert user.passive?
end
end
def test_should_register_passive_user
user = create_user(:password => nil, :password_confirmation => nil)
assert user.passive?
user.update_attributes(:password => 'new password', :password_confirmation => 'new password')
user.register!
assert user.pending?
end
def test_should_suspend_user
users(:quentin).suspend!
assert users(:quentin).suspended?
end
def test_suspended_user_should_not_authenticate
users(:quentin).suspend!
assert_not_equal users(:quentin), User.authenticate('quentin', 'test')end
def test_should_unsuspend_user
users(:quentin).suspend!
assert users(:quentin).suspended?
users(:quentin).unsuspend!
assert users(:quentin).active?
end
def test_should_delete_user
users(:quentin).delete!
assert_not_equal users(:quentin).login, 'quentin'
assert_not_equal users(:quentin).email, 'quentin@example.com'
assert users(:quentin).deleted?
end




stateful authentication
Posted by: Hendy Irawan on November 06, 2007 12:34 PM#