From a3d9f0bbfdae40889c49c78c33044e2001012559 Mon Sep 17 00:00:00 2001 From: AntonioTerceiro Date: Wed, 18 Jul 2007 19:18:12 +0000 Subject: [PATCH] ActionItem9: adding user accounts model and controller, just generated acts_as_authenticated code --- app/controllers/account_controller.rb | 43 +++++++++++++++++++++++++++++++++++++++++++ app/helpers/account_helper.rb | 2 ++ app/models/user.rb | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/views/account/index.rhtml | 56 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ app/views/account/login.rhtml | 14 ++++++++++++++ app/views/account/signup.rhtml | 16 ++++++++++++++++ app/views/layouts/account.rhtml | 16 ++++++++++++++++ config/routes.rb | 3 +++ db/migrate/006_create_users.rb | 18 ++++++++++++++++++ lib/authenticated_system.rb | 120 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ lib/authenticated_test_helper.rb | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ test/fixtures/users.yml | 17 +++++++++++++++++ test/functional/account_controller_test.rb | 129 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ test/integration/routing_test.rb | 4 ++++ test/unit/user_test.rb | 75 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 15 files changed, 690 insertions(+), 0 deletions(-) create mode 100644 app/controllers/account_controller.rb create mode 100644 app/helpers/account_helper.rb create mode 100644 app/models/user.rb create mode 100644 app/views/account/index.rhtml create mode 100644 app/views/account/login.rhtml create mode 100644 app/views/account/signup.rhtml create mode 100644 app/views/layouts/account.rhtml create mode 100644 db/migrate/006_create_users.rb create mode 100644 lib/authenticated_system.rb create mode 100644 lib/authenticated_test_helper.rb create mode 100644 test/fixtures/users.yml create mode 100644 test/functional/account_controller_test.rb create mode 100644 test/unit/user_test.rb diff --git a/app/controllers/account_controller.rb b/app/controllers/account_controller.rb new file mode 100644 index 0000000..e85b91e --- /dev/null +++ b/app/controllers/account_controller.rb @@ -0,0 +1,43 @@ +class AccountController < ApplicationController + # Be sure to include AuthenticationSystem in Application Controller instead + include AuthenticatedSystem + # If you want "remember me" functionality, add this before_filter to Application Controller + before_filter :login_from_cookie + + # say something nice, you goof! something sweet. + def index + redirect_to(:action => 'signup') unless logged_in? || User.count > 0 + end + + def login + return unless request.post? + self.current_user = User.authenticate(params[:login], params[:password]) + if logged_in? + 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 + redirect_back_or_default(:controller => '/account', :action => 'index') + flash[:notice] = "Logged in successfully" + end + end + + def signup + @user = User.new(params[:user]) + return unless request.post? + @user.save! + self.current_user = @user + redirect_back_or_default(:controller => '/account', :action => 'index') + flash[:notice] = "Thanks for signing up!" + rescue ActiveRecord::RecordInvalid + render :action => 'signup' + end + + def logout + self.current_user.forget_me if logged_in? + cookies.delete :auth_token + reset_session + flash[:notice] = "You have been logged out." + redirect_back_or_default(:controller => '/account', :action => 'index') + end +end diff --git a/app/helpers/account_helper.rb b/app/helpers/account_helper.rb new file mode 100644 index 0000000..1b63056 --- /dev/null +++ b/app/helpers/account_helper.rb @@ -0,0 +1,2 @@ +module AccountHelper +end \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..a7905f1 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,64 @@ +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 => 3..100 + validates_uniqueness_of :login, :email, :case_sensitive => false + before_save :encrypt_password + + # Authenticates a user by their login name and unencrypted password. Returns the user or nil. + def self.authenticate(login, password) + u = find_by_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 + self.remember_token_expires_at = 2.weeks.from_now.utc + 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 + + 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 +end diff --git a/app/views/account/index.rhtml b/app/views/account/index.rhtml new file mode 100644 index 0000000..d600d59 --- /dev/null +++ b/app/views/account/index.rhtml @@ -0,0 +1,56 @@ +

In the Caboose.

+ +<% content_for 'poem' do -%> +"Train delayed? and what's to say?" +"Blocked by last night's snow they say." +Seven hours or so to wait; +Well, that's pleasant! but there's the freight. +Depot loafing no one fancies, +We'll try the caboose and take our chances. + +Cool this morning in Watertown, +Somewhat frosty___mercury down; +Enter caboose___roaring fire, +With never an air-hole; heat so dire +That we shrivel and pant; we are roasted through- +Outside, thermometer thirty-two. + +We start with a jerk and suddenly stop. +"What's broke?" says one; another "What's up?", +"Oh, nothing," they answer, "That's our way: +You must stand the jerking, sorry to say." +We "stand it" with oft this painful thought: +Are our heads on yet, or are they not? + +Comrades in misery___let me see; +Girl like a statue opposite me; +Back and forth the others jostle___ +She never winks, nor moves a muscle; +See her, as she sits there now; +She's "well balanced," anyhow. + +Woman in trouble, tearful eyes, +Sits by the window, softly cries, +Pity___for griefs we may not know, +For breasts that ache, for tears that flow, +Though we know not why. Her eyelids red +Tell a sorrowful tale___some hope is dead. + +Man who follows the Golden Rule, +And lends his papers___a pocket full, +Has a blank book___once in a minute +Has an idea, and writes it in it. +Guess him? Yes, of course I can, +He's a___well___a newspaper man. + +Blue-eyed fairy, wrapped in fur; +Sweet young mother tending her. +Fairy thinks it's "awful far," +Wants to get off this "naughty car." +So do we, young golden-hair; +All this crowd are with you there! +<% end -%> + +<%= simple_format @content_for_poem %> + +

-- Ellen P. Allerton.

\ No newline at end of file diff --git a/app/views/account/login.rhtml b/app/views/account/login.rhtml new file mode 100644 index 0000000..a14ff99 --- /dev/null +++ b/app/views/account/login.rhtml @@ -0,0 +1,14 @@ +<% form_tag do -%> +


+<%= text_field_tag 'login' %>

+ +


+<%= password_field_tag 'password' %>

+ + + +

<%= submit_tag 'Log in' %>

+<% end -%> diff --git a/app/views/account/signup.rhtml b/app/views/account/signup.rhtml new file mode 100644 index 0000000..c0012a7 --- /dev/null +++ b/app/views/account/signup.rhtml @@ -0,0 +1,16 @@ +<%= error_messages_for :user %> +<% form_for :user do |f| -%> +


+<%= f.text_field :login %>

+ +


+<%= f.text_field :email %>

+ +


+<%= f.password_field :password %>

+ +


+<%= f.password_field :password_confirmation %>

+ +

<%= submit_tag 'Sign up' %>

+<% end -%> diff --git a/app/views/layouts/account.rhtml b/app/views/layouts/account.rhtml new file mode 100644 index 0000000..2b7c9f1 --- /dev/null +++ b/app/views/layouts/account.rhtml @@ -0,0 +1,16 @@ + + + <%= javascript_include_tag :defaults %> + <%= javascript_include_tag_template @chosen_template %> + <%= stylesheet_link_tag_template @chosen_template %> + + + + +
+ <%= yield %> +
+ + + + diff --git a/config/routes.rb b/config/routes.rb index cb68772..253f512 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -13,6 +13,9 @@ ActionController::Routing::Routes.draw do |map| # -- just remember to delete public/index.html. map.connect '', :controller => "home" + # user account controller + map.connect 'account/:action', :controller => 'account' + # administrative tasks for a virtual community map.connect 'admin/:controller/:action/:id' diff --git a/db/migrate/006_create_users.rb b/db/migrate/006_create_users.rb new file mode 100644 index 0000000..9c78b5f --- /dev/null +++ b/db/migrate/006_create_users.rb @@ -0,0 +1,18 @@ +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 + end + end + + def self.down + drop_table "users" + end +end diff --git a/lib/authenticated_system.rb b/lib/authenticated_system.rb new file mode 100644 index 0000000..840d89a --- /dev/null +++ b/lib/authenticated_system.rb @@ -0,0 +1,120 @@ +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. + def current_user + @current_user ||= (session[:user] && User.find_by_id(session[:user])) || :false + end + + # Store the given user in the session. + def current_user=(new_user) + session[:user] = (new_user.nil? || new_user.is_a?(Symbol)) ? nil : new_user.id + @current_user = new_user + 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 authorize? + # current_user.login != "bob" + # end + def authorized? + true + 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 + username, passwd = get_auth_data + self.current_user ||= User.authenticate(username, passwd) || :false if username && passwd + logged_in? && authorized? ? true : access_denied + 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 |accepts| + accepts.html do + store_location + redirect_to :controller => '/account', :action => 'login' + end + accepts.xml do + headers["Status"] = "Unauthorized" + headers["WWW-Authenticate"] = %(Basic realm="Web Password") + render :text => "Could't authenticate you", :status => '401 Unauthorized' + end + end + false + 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 + + # Redirect to the URI stored by the most recent store_location call or + # to the passed default. + def redirect_back_or_default(default) + session[:return_to] ? redirect_to_url(session[:return_to]) : redirect_to(default) + session[:return_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 + + # When called with before_filter :login_from_cookie will check for an :auth_token + # cookie and log the user back in if apropriate + def login_from_cookie + return unless cookies[:auth_token] && !logged_in? + user = User.find_by_remember_token(cookies[:auth_token]) + if user && user.remember_token? + user.remember_me + self.current_user = user + cookies[:auth_token] = { :value => self.current_user.remember_token , :expires => self.current_user.remember_token_expires_at } + flash[:notice] = "Logged in successfully" + end + end + + private + @@http_auth_headers = %w(X-HTTP_AUTHORIZATION HTTP_AUTHORIZATION Authorization) + # gets BASIC auth info + def get_auth_data + auth_key = @@http_auth_headers.detect { |h| request.env.has_key?(h) } + auth_data = request.env[auth_key].to_s.split unless auth_key.blank? + return auth_data && auth_data[0] == 'Basic' ? Base64.decode64(auth_data[1]).split(':')[0..1] : [nil, nil] + end +end diff --git a/lib/authenticated_test_helper.rb b/lib/authenticated_test_helper.rb new file mode 100644 index 0000000..a704035 --- /dev/null +++ b/lib/authenticated_test_helper.rb @@ -0,0 +1,113 @@ +module AuthenticatedTestHelper + # Sets the current user in the session from the user fixtures. + def login_as(user) + @request.session[:user] = user ? users(user).id : nil + end + + def content_type(type) + @request.env['Content-Type'] = type + end + + def accept(accept) + @request.env["HTTP_ACCEPT"] = accept + end + + def authorize_as(user) + if user + @request.env["HTTP_AUTHORIZATION"] = "Basic #{Base64.encode64("#{users(user).login}:test")}" + accept 'application/xml' + content_type 'application/xml' + else + @request.env["HTTP_AUTHORIZATION"] = nil + accept nil + content_type nil + end + end + + # http://project.ioni.st/post/217#post-217 + # + # def test_new_publication + # assert_difference(Publication, :count) do + # post :create, :publication => {...} + # # ... + # end + # end + # + def assert_difference(object, method = nil, difference = 1) + initial_value = object.send(method) + yield + assert_equal initial_value + difference, object.send(method), "#{object}##{method}" + end + + def assert_no_difference(object, method, &block) + assert_difference object, method, 0, &block + end + + # Assert the block redirects to the login + # + # assert_requires_login(:bob) { |c| c.get :edit, :id => 1 } + # + def assert_requires_login(login = nil) + yield HttpLoginProxy.new(self, login) + end + + def assert_http_authentication_required(login = nil) + yield XmlLoginProxy.new(self, login) + end + + def reset!(*instance_vars) + instance_vars = [:controller, :request, :response] unless instance_vars.any? + instance_vars.collect! { |v| "@#{v}".to_sym } + instance_vars.each do |var| + instance_variable_set(var, instance_variable_get(var).class.new) + end + end +end + +class BaseLoginProxy + attr_reader :controller + attr_reader :options + def initialize(controller, login) + @controller = controller + @login = login + end + + private + def authenticated + raise NotImplementedError + end + + def check + raise NotImplementedError + end + + def method_missing(method, *args) + @controller.reset! + authenticate + @controller.send(method, *args) + check + end +end + +class HttpLoginProxy < BaseLoginProxy + protected + def authenticate + @controller.login_as @login if @login + end + + def check + @controller.assert_redirected_to :controller => 'account', :action => 'login' + end +end + +class XmlLoginProxy < BaseLoginProxy + protected + def authenticate + @controller.accept 'application/xml' + @controller.authorize_as @login if @login + end + + def check + @controller.assert_response 401 + end +end \ No newline at end of file diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..f7be9db --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,17 @@ +quentin: + id: 1 + login: quentin + email: quentin@example.com + salt: 7e3041ebc2fc05a40c60028e2c4901a81035d3cd + crypted_password: 00742970dc9e6319f8019fd54864d3ea740f04b1 # test + #crypted_password: "ce2/iFrNtQ8=\n" # quentin, use only if you're using 2-way encryption + created_at: <%= 5.days.ago.to_s :db %> + # activated_at: <%= 5.days.ago.to_s :db %> # only if you're activating new signups +aaron: + id: 2 + login: aaron + email: aaron@example.com + salt: 7e3041ebc2fc05a40c60028e2c4901a81035d3cd + crypted_password: 00742970dc9e6319f8019fd54864d3ea740f04b1 # test + # activation_code: aaronscode # only if you're activating new signups + created_at: <%= 1.days.ago.to_s :db %> \ No newline at end of file diff --git a/test/functional/account_controller_test.rb b/test/functional/account_controller_test.rb new file mode 100644 index 0000000..3c8cd22 --- /dev/null +++ b/test/functional/account_controller_test.rb @@ -0,0 +1,129 @@ +require File.dirname(__FILE__) + '/../test_helper' +require 'account_controller' + +# Re-raise errors caught by the controller. +class AccountController; def rescue_action(e) raise e end; end + +class AccountControllerTest < Test::Unit::TestCase + # Be sure to include AuthenticatedTestHelper in test/test_helper.rb instead + # Then, you can remove it from this and the units test. + include AuthenticatedTestHelper + + fixtures :users + + def setup + @controller = AccountController.new + @request = ActionController::TestRequest.new + @response = ActionController::TestResponse.new + end + + def test_should_login_and_redirect + post :login, :login => 'quentin', :password => 'test' + assert session[:user] + assert_response :redirect + end + + def test_should_fail_login_and_not_redirect + post :login, :login => 'quentin', :password => 'bad password' + assert_nil session[:user] + assert_response :success + end + + def test_should_allow_signup + assert_difference User, :count do + create_user + assert_response :redirect + end + end + + def test_should_require_login_on_signup + assert_no_difference User, :count do + create_user(:login => nil) + assert assigns(:user).errors.on(:login) + assert_response :success + end + end + + def test_should_require_password_on_signup + assert_no_difference User, :count do + create_user(:password => nil) + assert assigns(:user).errors.on(:password) + assert_response :success + end + end + + def test_should_require_password_confirmation_on_signup + assert_no_difference User, :count do + create_user(:password_confirmation => nil) + assert assigns(:user).errors.on(:password_confirmation) + assert_response :success + end + end + + def test_should_require_email_on_signup + assert_no_difference User, :count do + create_user(:email => nil) + assert assigns(:user).errors.on(:email) + assert_response :success + end + end + + def test_should_logout + login_as :quentin + get :logout + assert_nil session[:user] + assert_response :redirect + end + + def test_should_remember_me + post :login, :login => 'quentin', :password => 'test', :remember_me => "1" + assert_not_nil @response.cookies["auth_token"] + end + + def test_should_not_remember_me + post :login, :login => 'quentin', :password => 'test', :remember_me => "0" + assert_nil @response.cookies["auth_token"] + end + + def test_should_delete_token_on_logout + login_as :quentin + get :logout + assert_equal @response.cookies["auth_token"], [] + end + + def test_should_login_with_cookie + users(:quentin).remember_me + @request.cookies["auth_token"] = cookie_for(:quentin) + get :index + assert @controller.send(:logged_in?) + end + + def test_should_fail_expired_cookie_login + users(:quentin).remember_me + users(:quentin).update_attribute :remember_token_expires_at, 5.minutes.ago + @request.cookies["auth_token"] = cookie_for(:quentin) + get :index + assert !@controller.send(:logged_in?) + end + + def test_should_fail_cookie_login + users(:quentin).remember_me + @request.cookies["auth_token"] = auth_token('invalid_auth_token') + get :index + assert !@controller.send(:logged_in?) + end + + protected + def create_user(options = {}) + post :signup, :user => { :login => 'quire', :email => 'quire@example.com', + :password => 'quire', :password_confirmation => 'quire' }.merge(options) + end + + def auth_token(token) + CGI::Cookie.new('name' => 'auth_token', 'value' => token) + end + + def cookie_for(user) + auth_token users(user).remember_token + end +end diff --git a/test/integration/routing_test.rb b/test/integration/routing_test.rb index 0260e87..8785bf8 100644 --- a/test/integration/routing_test.rb +++ b/test/integration/routing_test.rb @@ -6,4 +6,8 @@ class RoutingTest < ActionController::IntegrationTest assert_routing('admin/features', :controller => 'features', :action => 'index') end + def test_account_controller + assert_routing('account', :controller => 'account', :action => 'index') + end + end diff --git a/test/unit/user_test.rb b/test/unit/user_test.rb new file mode 100644 index 0000000..8166c09 --- /dev/null +++ b/test/unit/user_test.rb @@ -0,0 +1,75 @@ +require File.dirname(__FILE__) + '/../test_helper' + +class UserTest < Test::Unit::TestCase + # Be sure to include AuthenticatedTestHelper in test/test_helper.rb instead. + # Then, you can remove it from this and the functional test. + include AuthenticatedTestHelper + fixtures :users + + def test_should_create_user + assert_difference User, :count do + user = create_user + assert !user.new_record?, "#{user.errors.full_messages.to_sentence}" + end + end + + def test_should_require_login + assert_no_difference User, :count do + u = create_user(:login => nil) + assert u.errors.on(:login) + end + end + + def test_should_require_password + assert_no_difference User, :count do + u = create_user(:password => nil) + assert u.errors.on(:password) + end + end + + def test_should_require_password_confirmation + assert_no_difference User, :count do + u = create_user(:password_confirmation => nil) + assert u.errors.on(:password_confirmation) + end + end + + def test_should_require_email + assert_no_difference User, :count do + u = create_user(:email => nil) + assert u.errors.on(:email) + end + end + + def test_should_reset_password + users(:quentin).update_attributes(:password => 'new password', :password_confirmation => 'new password') + assert_equal users(:quentin), User.authenticate('quentin', 'new password') + end + + def test_should_not_rehash_password + users(:quentin).update_attributes(:login => 'quentin2') + assert_equal users(:quentin), User.authenticate('quentin2', 'test') + end + + def test_should_authenticate_user + assert_equal users(:quentin), User.authenticate('quentin', 'test') + end + + def test_should_set_remember_token + users(:quentin).remember_me + assert_not_nil users(:quentin).remember_token + assert_not_nil users(:quentin).remember_token_expires_at + end + + def test_should_unset_remember_token + users(:quentin).remember_me + assert_not_nil users(:quentin).remember_token + users(:quentin).forget_me + assert_nil users(:quentin).remember_token + end + + protected + def create_user(options = {}) + User.create({ :login => 'quire', :email => 'quire@example.com', :password => 'quire', :password_confirmation => 'quire' }.merge(options)) + end +end -- libgit2 0.21.2