3.6. Bot Protection via Authorization Email

One of the most serious security issues facing any kind of social content site is the issue of bots and spam. This involves fake user accounts being set up for no other reason than to post spam messages to your unsuspecting little site. There are a few methods available to help protect your site. This section and the next discuss two popular mechanisms for ensuring that there is a real person behind every new account created for Soups OnLine. Neither of these mechanisms is perfect, and either could be defeated by a determined spammer. But they are both enough of a hurdle to make attacking your site less inviting, when there are so many other easy sites to exploit.

The first mechanism is the authorization email, and is very popular for mailing lists and other kinds of forums. When users create a new account, they are sent an email with a special URL. They need to retrieve the email and open the URL in their browser to validate the account. Although, in theory, this is defeatable by anybody willing to automatically read and parse the email, in practice this seems to be rarely done.

The main piece of data you need to implement this is some kind of token. Exactly what doesn't matter much as long as it's random enough not to be guessable. You need to associate the token with a newly created user account so that when the token comes back to the server, you know which user account to unlock.

3.6.1. Generating the Model and Migration

The token will be implemented as a simple Rails model. It doesn't need to be a full-fledged resource because you don't need the entire suite of CRUD methods in a web-based interface. The other alternative would be to make the token a column in the user table, but I think the token concept will be useful in enough contexts that it warrants its own table. Here's the simple token model:

$  ruby script/generate model token

      exists  app/models/
      exists  test/unit/
      exists  test/fixtures/
      create  app/models/token.rb
      create  test/unit/token_test.rb
      create  test/fixtures/tokens.yml
      exists  db/migrate
      create  db/migrate/004_create_tokens.rb

The token class needs a string for the actual token, and you'll give it an integer link to a user ID. You'll also define an optional string for a value, which won't be used here, but which will be used later on. The token should also have a time at which it ceases to be valid. For this to work, the user class also needs a flag to specify whether the account is actually active. Place the follwing text in db/migrate/004_create_tokens.rb:

class CreateTokens < ActiveRecord::Migration
  def self.up
    create_table :tokens do |t|
      t.string :token
      t.integer :user_id

t.string :value
      t.datetime :expires_at
      t.timestamps
    end

    add_column :users, :is_active, :boolean
  end

  def self.down
    drop_table :tokens
    remove_column :users, :is_active
  end
end

This will need a new action in the user controller called activate. Again, this needs a new RESTful route, so you define the action as follows:

map.resources :users, :new => {:login => :post},
      :member => {:logout => :get, :activate => :get}

3.6.2. Test First

The flow of control here is that a new user will have the is_active column set to false, but that a token will be placed in the tokens table with an expiration date two days in the future. The controller test covering that functionality looks like this, in test/functional/users_controller_test.rb:

def test_should_create_user
    assert_difference('User.count') do
      post :create, :user => user_form
    end
    assert !assigns(:user).is_active
    tokens = Token.find_all_by_user_id(assigns(:user).id)
    assert_equal 1, tokens.size
    assert_equal 2, (tokens[0].expires_at - Date.today)
    assert_redirected_to user_path(assigns(:user))
  end

To pass this test, you need to change the create action of the user controller as follows (the new lines are highlighted):

def create
    @user = User.new(params[:user])
    @user.is_active = false
    respond_to do |format|
      if @user.save
        flash[:notice] = 'User was successfully created.'

        Token.create_email_token(@user)
        format.html { redirect_to(@user) }
        format.xml  { render :xml => @user, :status => :created,
           :location => @user }
      else

format.html { render :action => "new" }
        format.xml  { render :xml => @user.errors,
            :status => :unprocessable_entity }
      end
    end
  end

The actual token is created in the token class method in app/model/token.rb, like this:

def self.create_email_token(user)
    Token.create(:user_id => user.id,
        :token => Standards.random_string(25),
        :expires_at => DateTime.now + 2)
  end

Notice that I've also factored the functionality of creating a random string, used earlier in creating the salt for the user to a common method. The new token has the user ID, a random string for a value, and an expiration date two days in the future.

The activate method will activate the user if the user_id and the token match the entry in the database, and if the token has not expired. That's one test for success, and failure tests for an incorrect user ID, incorrect token, and expired token. All four of the tests have the same basic skeleton, so you should factor that out to a common method. Place the following in test/functional/users_controller_test.rb:

def assert_token_test(user_id, token, should_be_valid,
        should_be_deleted, date_offset=2)
    Token.create(:user_id => 1, :token => "qwerty",
        :expires_at => Date.today + date_offset)
    get :activate, :id => user_id, :token => token
    assert_equal should_be_valid, assigns(:is_valid)
    user = User.find(1)
    assert_equal should_be_valid, user.is_active
    tokens = Token.find_all_by_user_id(1)
    assert_equal should_be_deleted, tokens.empty?
  end

This test pre-creates a token, and then simulates a GET request to attempt an activation from the given user ID and token. The test then checks to see if the user valid state has been changed appropriately and that the token is consumed if expected. The four individual tests then become simple one-liners that call that assert function, like this:

def test_should_activate_successfully
    assert_token_test(1, "qwerty", true, true)
  end

  def test_should_not_activate_wrong_user
    assert_token_test(2, "qwerty", false, false)
  end

  def test_should_not_activate_wrong_token
    assert_token_test(1, "banana", false, false)

end

  def test_should_not_activate_timed_out
    assert_token_test(1, "qwerty", false, true, −90)
  end

The successful test passes the correct user ID and token, and asserts that the user should be valid and the token should be consumed. The next two tests send an incorrect token challenge — the user should still be invalid, and the token should remain. The final test shows the result of testing against an expired token — it is invalid, and the token is also consumed.

3.6.3. Controller Logic

The activate controller method in app/controllers/users_controller./rb turns out to be simple — as usual, the heavy lifting is placed in a model:

def activate
    find_user
    @is_valid = Token.is_valid_for_user(@user, params[:token])
    @user.update_attributes(:is_active => @is_valid)
  end

  private

  def find_user
    @user = User.find(params[:id])
  end

The controller simply asks the token class if the request is valid, and updates the user object appropriately, the change made via update_attributes is immediately saved to the database.

The main logic is placed in the token class, app/models/token.rb, as you can see here:

def self.is_valid_for_user(user, incoming_token)
    actual_token = Token.find_by_user_id(user.id)
    return false unless actual_token
    time_valid = actual_token.expires_at > Time.now
    token_valid = actual_token.token == incoming_token
    is_valid = time_valid && token_valid
    actual_token.destroy if token_valid
    is_valid
  end

A quick note — this wasn't quite as clean when I first wrote it, I had all the logic in the controller and it was a bit convoluted. Moving the logic to the model class cleaned up both the logic and the controller response.

The validation method checks to see if there is a token for the requested user — if not, the method is done immediately. Then the method confirms that the token codes match and the token has not expired. If the token codes match, the token is removed from the database, and the result of the validity test is returned.

At this point, all the tests should pass. Wait a moment while I commit my changes to Subversion.

3.6.4. Sending the Email

With the logic in place, now you need to handle the actual sending of the email. For this to work, you need to set the mail settings as appropriate for your system. For most cases, the development default of SMTP will work just fine, but you may need to set the config.action_mailer.server_settings object in the environment.rb file as follows:

ActionMailler::Base.server_settings = {
  :address => "<SMTP HOSTNAME -- default is localhost>",
  :domain => "<DOMAIN OF SMTP HOST>",
  :port =>[{[SPACE]}]<SMTP PORT -- default is 25>
}

In addition, if your SMTP host requires authentication, you need to set :user_name and :password options, as well as an :authentication option that is :cram_md5, :login, or :plain.

The email will be managed via a Rails ActionMailer object, which you first need to generate, like this:

$ ruby script/generate mailer AuthorizationMailer authorize

This generates a new mailer with a single command that it recognizes, authorize. The ActionMailer object is something like a hybrid between a model and a controller. It's stored with the other models, but the mailer gets its own subdirectory under /app/views along with the other controllers, where the templates used to actually define the email are stored. The mailer is invoked from a controller object, and data passed to the mailer is merged with the template to create the body of the email. Headers for the email are handled by the instance method of the mailer called with the invocation.

The test for the mailer will be part of the creation test in the users_controller_test.rb file. Add the following lines to the setup method to allow other tests to track emails:

def setup
    @controller = UsersController.new
    @request    = ActionController::TestRequest.new
    @response   = ActionController::TestResponse.new
    @emails = ActionMailer::Base.deliveries
    @emails.clear
  end

The new test looks like this:

def test_should_create_user
    assert_difference('User.count') do
      post :create, :user => user_form
    end
    assert !assigns(:user).is_active
    tokens = Token.find_all_by_user_id(assigns(:user).id)
    assert_equal 1, tokens.size
    assert_equal 2, (tokens[0].expires_at.to_date - Date.today)
    assert_redirected_to user_path(assigns(:user))
    assert_equal 1, @emails.size

sent_email = @emails[0]
    assert_not_nil sent_email.body.index(
        "users/#{assigns(:user).id}/activate?token=#{tokens[0].token}")
  end

The last three lines test that an email was actually generated and that its body contains the URL fragment that will direct the user to the activation page.

Rails also generated a unit test for the mailer itself, but you don't want it. It compares the generated email against a text file. It's a nice scaffolding for a golden output test, but it will also be very fragile, breaking every time the text of the email template changes. So go into /test/unit/authorizaition_mailer_test.rb and disable it by commenting out the assert_equal line in the sample test method.

The generate script created a default set of values in the action mailer object itself, but those values aren't going to be suitable for your purposes. So, rewrite the authorize method as follows:

def authorize(user, token)
    @subject    = 'Welcome to Soups OnLine'
    @body       = {:user => user, :token => token}
    @recipients = user.email
    @from       = '[email protected]'
    @sent_on    = Time.now
    @headers    = {}
  end

Most of these instance variables should be more or less self-explanatory. The @body variable contains a hash of values that become instance variables in the actual mail template. The @recipient value can be either a single value string or multiple values as a list.

Rails also created a blank template in /app/views/authorization_mailer/authorize.erb. The exact text doesn't quite matter, but the gist should go like this:

Dear <%= @user.first_name %>,

Thank you for signing on to Soups OnLine. In order to activate your account, please
follow the URL below by clicking on it or by pasting the URL into your browser.

<%= url_for(:controller => 'users', :action => 'activate',
     :id => @user.id, :token => @token.token) %>
See you soon,

Soups OnLine

With that done, actually generating the email involves adding a single line to the if-successful clause of the UserController#create method. The new line is highlighted here:

flash[:notice] = 'User was successfully created.'
        session[:user_id] = @user.id
        token = Token.create_email_token(@user)
        AuthorizationMailer.deliver_authorize(@user, token)
        format.html { redirect_to(@user) }
        format.xml  { render :xml => @user, :status => :created,
             :location => @user }

The deliver_authorize call tells Rails to invoke the AuthorizationMailer using the authorize method, and then deliver the email right away. In contrast, you could choose create_authorize, which would return the email as a Rails object, allowing it to be delivered later. The user and token objects are passed to the authorization mail object, and then to the template to be included in the email body.

That should pass the tests you've created. The only remaining piece is to add something to the /app/views/users/activate.html.erb file to display a success or failure message to the user after the activation attempt. For example:

<div>
  Congratulations on successfully activating your account!  Enjoy
</div>

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.221.242.131