Merge pull request #16 from jstepien/bcrypt-v2

Hash passwords with BCrypt instead of SHA1
This commit is contained in:
Reinier Balt 2011-09-08 08:08:33 -07:00
commit 50875cfa40
18 changed files with 195 additions and 39 deletions

View file

@ -16,7 +16,7 @@ gem "actionwebservice", :git => "git://github.com/dejan/actionwebservice.git"
gem "rubycas-client" gem "rubycas-client"
gem "ruby-openid", :require => "openid" gem "ruby-openid", :require => "openid"
gem "sqlite3" gem "sqlite3"
gem 'bcrypt-ruby', '~> 2.1.4'
gem "webrat", ">=0.7.0", :groups => [:cucumber, :test] gem "webrat", ">=0.7.0", :groups => [:cucumber, :test]
gem "database_cleaner", ">=0.5.0", :groups => [:cucumber, :selenium] gem "database_cleaner", ">=0.5.0", :groups => [:cucumber, :selenium]

View file

@ -24,6 +24,7 @@ GEM
activeresource (2.3.14) activeresource (2.3.14)
activesupport (= 2.3.14) activesupport (= 2.3.14)
activesupport (2.3.14) activesupport (2.3.14)
bcrypt-ruby (2.1.4)
builder (3.0.0) builder (3.0.0)
cgi_multipart_eof_fix (2.5.0) cgi_multipart_eof_fix (2.5.0)
cucumber (1.0.2) cucumber (1.0.2)
@ -96,6 +97,7 @@ DEPENDENCIES
ZenTest (>= 4.0.0) ZenTest (>= 4.0.0)
aasm (= 2.2.0) aasm (= 2.2.0)
actionwebservice! actionwebservice!
bcrypt-ruby (~> 2.1.4)
cucumber-rails (~> 0.3.0) cucumber-rails (~> 0.3.0)
database_cleaner (>= 0.5.0) database_cleaner (>= 0.5.0)
flexmock flexmock

View file

@ -16,6 +16,7 @@ class ApplicationController < ActionController::Base
layout proc{ |controller| controller.mobile? ? "mobile" : "standard" } layout proc{ |controller| controller.mobile? ? "mobile" : "standard" }
exempt_from_layout /\.js\.erb$/ exempt_from_layout /\.js\.erb$/
before_filter :check_for_deprecated_password_hash
before_filter :set_session_expiration before_filter :set_session_expiration
before_filter :set_time_zone before_filter :set_time_zone
before_filter :set_zindex_counter before_filter :set_zindex_counter
@ -58,6 +59,15 @@ class ApplicationController < ActionController::Base
end end
end end
end end
# Redirects to change_password_user_path if the current user uses a
# deprecated password hashing algorithm.
def check_for_deprecated_password_hash
if current_user and current_user.uses_deprecated_password?
notify :warning, t('users.you_have_to_reset_your_password')
redirect_to change_password_user_path current_user
end
end
def render_failure message, status = 404 def render_failure message, status = 404
render :text => message, :status => status render :text => message, :status => status

View file

@ -1,6 +1,8 @@
class UsersController < ApplicationController class UsersController < ApplicationController
before_filter :admin_login_required, :only => [ :index, :show, :destroy ] before_filter :admin_login_required, :only => [ :index, :show, :destroy ]
skip_before_filter :login_required, :only => [ :new, :create ] skip_before_filter :login_required, :only => [ :new, :create ]
skip_before_filter :check_for_deprecated_password_hash,
:only => [ :change_password, :update_password ]
prepend_before_filter :login_optional, :only => [ :new, :create ] prepend_before_filter :login_optional, :only => [ :new, :create ]
# GET /users GET /users.xml # GET /users GET /users.xml

View file

@ -1,4 +1,5 @@
require 'digest/sha1' require 'digest/sha1'
require 'bcrypt'
class User < ActiveRecord::Base class User < ActiveRecord::Base
# Virtual attribute for the unencrypted password # Virtual attribute for the unencrypted password
@ -123,7 +124,8 @@ class User < ActiveRecord::Base
return nil if candidate.nil? return nil if candidate.nil?
if Tracks::Config.auth_schemes.include?('database') if Tracks::Config.auth_schemes.include?('database')
return candidate if candidate.auth_type == 'database' && candidate.crypted_password == sha1(pass) return candidate if candidate.auth_type == 'database' and
candidate.password_matches? pass
end end
if Tracks::Config.auth_schemes.include?('ldap') if Tracks::Config.auth_schemes.include?('ldap')
@ -190,7 +192,7 @@ class User < ActiveRecord::Base
end end
def generate_token def generate_token
self.token = Digest::SHA1.hexdigest "#{Time.now.to_i}#{rand}" self.token = self.class.sha1 "#{Time.now.to_i}#{rand}"
end end
def remember_token? def remember_token?
@ -210,15 +212,36 @@ class User < ActiveRecord::Base
save(false) save(false)
end end
# Returns true if the user has a password hashed using SHA-1.
def uses_deprecated_password?
crypted_password =~ /^[a-f0-9]{40}$/i
end
def password_matches?(pass)
if uses_deprecated_password?
crypted_password == User.sha1(pass)
else
BCrypt::Password.new(crypted_password) == pass
end
end
protected protected
def self.salted(s)
"#{Tracks::Config.salt}--#{s}--"
end
def self.sha1(s) def self.sha1(s)
Digest::SHA1.hexdigest("#{Tracks::Config.salt}--#{s}--") Digest::SHA1.hexdigest salted s
end
def self.hash(s)
BCrypt::Password.create s
end end
def crypt_password def crypt_password
return if password.blank? return if password.blank?
write_attribute("crypted_password", self.class.sha1(password)) if password == password_confirmation write_attribute("crypted_password", self.class.hash(password)) if password == password_confirmation
end end
def password_required? def password_required?
@ -229,10 +252,6 @@ protected
auth_type == 'open_id' auth_type == 'open_id'
end end
def password_matches?(pass)
crypted_password == sha1(pass)
end
def normalize_open_id_url def normalize_open_id_url
return if open_id_url.nil? return if open_id_url.nil?

View file

@ -778,6 +778,7 @@ en:
register_with_cas: With your CAS username register_with_cas: With your CAS username
label_auth_type: Authentication type label_auth_type: Authentication type
new_password_label: New password new_password_label: New password
you_have_to_reset_your_password: "You have to reset your password"
new_user_title: TRACKS::Sign up as the admin user new_user_title: TRACKS::Sign up as the admin user
destroy_user: Destroy user destroy_user: Destroy user
total_users_count: You have a total of %{count} users total_users_count: You have a total of %{count} users

View file

@ -0,0 +1,20 @@
class ChangeCryptedPasswordLength < ActiveRecord::Migration
def self.up
change_column 'users', 'crypted_password', :string, :limit => 60
end
def self.down
# Begin with setting all passwords hashed with BCrypt to SHA-1 ones as
# BCrypt's format won't fit into a narrower column.
User.transaction do
User.all.each do |user|
if user.auth_type == 'database' and not user.uses_deprecated_password?
user.password = user.password_confirmation = nil
user.crypted_password = User.sha1 'change_me'
user.save!
end
end
end
change_column 'users', 'crypted_password', :string, :limit => 40
end
end

View file

@ -0,0 +1,35 @@
Feature: Handling users with deprecated passwords hashes
In order to have my password hashed with BCrypt
As a user with password hashed with SHA1
I have to be redirected to the password resetting form
Background:
Given the following user records
| login | password_with_algorithm |
| new_hash_user | first_secret bcrypt |
| old_hash_user | another_secret sha1 |
Scenario Outline: A user with SHA1 password
Given I have logged in as "old_hash_user" with password "another_secret"
When I go to the <name> page
Then I should be redirected to the change password page
And I should see "You have to reset your password"
When I change my password to "newer_better_password"
Then I should be redirected to the preference page
Examples:
| name |
| home |
| preferences |
| notes |
| tickler |
Scenario: A user with SHA1 password goes straight to the change password page
Given I have logged in as "old_hash_user" with password "another_secret"
When I go to the change password page
Then I should be on the change password page
Scenario: A user with BCrypt password
Given I have logged in as "new_hash_user" with password "first_secret"
When I go to the homepage
Then I should be on the homepage

View file

@ -32,3 +32,9 @@ Then "I should be an admin" do
# just check on the presence of the menu item for managing users # just check on the presence of the menu item for managing users
Then "I should see \"Manage users\"" Then "I should see \"Manage users\""
end end
When /^I change my password to "([^"]*)"$/ do |password|
Then 'I should be on the change password page'
%w{new confirm}.each { |name| fill_in name + ' password', :with => password }
click_button
end

View file

@ -104,6 +104,8 @@ module NavigationHelpers
when /the tag page for "([^"]*)"/i when /the tag page for "([^"]*)"/i
@source_view = "tag" @source_view = "tag"
tag_path($1, options) tag_path($1, options)
when /the change password page/
change_password_user_path @current_user
# Add more mappings here. # Add more mappings here.
# Here is an example that pulls values out of the Regexp: # Here is an example that pulls values out of the Regexp:

18
features/support/user.rb Normal file
View file

@ -0,0 +1,18 @@
class User
# A method used in features' user records definitions. It accepts a string
# with a password and the name of a hashing algorithm ('sha1' or 'bcrypt')
# concatenated with a space. It encrypts user's password using the given
# mechanism and the given password value.
def password_with_algorithm=(x)
pass, algorithm = *x.split
case algorithm
when 'bcrypt'
change_password pass, pass
when 'sha1'
self.crypted_password = User.sha1 pass
self.password = self.password_confirmation = nil
else
raise "Unknown hashing algorithm: #{algorithm}"
end
end
end

View file

@ -1,21 +1,25 @@
Factory.define :user do |u| begin
u.sequence(:login) { |n| "testuser#{n}" } Factory.define :user do |u|
u.password "secret" u.sequence(:login) { |n| "testuser#{n}" }
u.password_confirmation { |user| user.password } u.password "secret"
u.is_admin false u.password_confirmation { |user| user.password }
end u.is_admin false
end
Factory.define :context do |c| Factory.define :context do |c|
c.sequence(:name) { |n| "testcontext#{n}" } c.sequence(:name) { |n| "testcontext#{n}" }
c.hide false c.hide false
c.created_at Time.now.utc c.created_at Time.now.utc
end end
Factory.define :project do |p| Factory.define :project do |p|
p.sequence(:name) { |n| "testproject#{n}" } p.sequence(:name) { |n| "testproject#{n}" }
end end
Factory.define :todo do |t| Factory.define :todo do |t|
t.sequence(:description) { |n| "testtodo#{n}" } t.sequence(:description) { |n| "testtodo#{n}" }
t.association :context t.association :context
end
rescue FactoryGirl::DuplicateDefinitionError
# No problem, apparently this file was included already.
end end

View file

@ -1,7 +1,7 @@
# Read about fixtures at http://ar.rubyonrails.org/classes/Fixtures.html # Read about fixtures at http://ar.rubyonrails.org/classes/Fixtures.html
admin_user: admin_user:
login: admin login: admin
crypted_password: <%= Digest::SHA1.hexdigest("#{Tracks::Config.salt}--abracadabra--") %> crypted_password: <%= BCrypt::Password.create("abracadabra") %>
token: <%= Digest::SHA1.hexdigest("adminSat Feb 25 17:14:00 GMT 20060.236961325863376") %> token: <%= Digest::SHA1.hexdigest("adminSat Feb 25 17:14:00 GMT 20060.236961325863376") %>
is_admin: true is_admin: true
first_name: Admin first_name: Admin
@ -10,7 +10,7 @@ admin_user:
other_user: other_user:
login: jane login: jane
crypted_password: <%= Digest::SHA1.hexdigest("#{Tracks::Config.salt}--sesame--") %> crypted_password: <%= BCrypt::Password.create("sesame") %>
token: <%= Digest::SHA1.hexdigest("janeSun Feb 19 14:42:45 GMT 20060.408173979260027") %> token: <%= Digest::SHA1.hexdigest("janeSun Feb 19 14:42:45 GMT 20060.408173979260027") %>
is_admin: false is_admin: false
first_name: Jane first_name: Jane

View file

@ -109,7 +109,7 @@ describe User do
end end
it 'authenticates user' do it 'authenticates user' do
User.authenticate('simon', 'foobarspam').should == @user User.authenticate('simon', 'foobarspam').id.should be @user.id
end end
it 'resets password' do it 'resets password' do
@ -117,12 +117,13 @@ describe User do
:password => 'new password', :password => 'new password',
:password_confirmation => 'new password' :password_confirmation => 'new password'
) )
User.authenticate('simon', 'new password').should == @user User.authenticate('simon', 'foobarspam').should be_nil
User.authenticate('simon', 'new password').id.should be @user.id
end end
it 'does not rehash password after update of login' do it 'does not rehash password after update of login' do
@user.update_attribute(:login, 'foobar') @user.update_attribute(:login, 'foobar')
User.authenticate('foobar', 'foobarspam').should == @user User.authenticate('foobar', 'foobarspam').id.should be @user.id
end end
it 'sets remember token' do it 'sets remember token' do

View file

@ -2,7 +2,7 @@
admin_user: admin_user:
id: 1 id: 1
login: admin login: admin
crypted_password: <%= Digest::SHA1.hexdigest("#{Tracks::Config.salt}--abracadabra--") %> crypted_password: <%= BCrypt::Password.create("abracadabra") %>
token: <%= Digest::SHA1.hexdigest("adminSat Feb 25 17:14:00 GMT 20060.236961325863376") %> token: <%= Digest::SHA1.hexdigest("adminSat Feb 25 17:14:00 GMT 20060.236961325863376") %>
is_admin: true is_admin: true
first_name: Admin first_name: Admin
@ -12,7 +12,7 @@ admin_user:
other_user: other_user:
id: 2 id: 2
login: jane login: jane
crypted_password: <%= Digest::SHA1.hexdigest("#{Tracks::Config.salt}--sesame--") %> crypted_password: <%= BCrypt::Password.create("sesame") %>
token: <%= Digest::SHA1.hexdigest("janeSun Feb 19 14:42:45 GMT 20060.408173979260027") %> token: <%= Digest::SHA1.hexdigest("janeSun Feb 19 14:42:45 GMT 20060.408173979260027") %>
is_admin: false is_admin: false
first_name: Jane first_name: Jane
@ -32,7 +32,7 @@ ldap_user:
sms_user: sms_user:
id: 4 id: 4
login: sms_user login: sms_user
crypted_password: <%= Digest::SHA1.hexdigest("#{Tracks::Config.salt}--sesame--") %> crypted_password: <%= BCrypt::Password.create("sesame") %>
token: <%= Digest::SHA1.hexdigest("sms_userSun Feb 19 14:42:45 GMT 20060.408173979260027") %> token: <%= Digest::SHA1.hexdigest("sms_userSun Feb 19 14:42:45 GMT 20060.408173979260027") %>
is_admin: false is_admin: false
first_name: SMS first_name: SMS
@ -48,3 +48,13 @@ ldap_user:
first_name: International first_name: International
last_name: Harvester last_name: Harvester
auth_type: CAS auth_type: CAS
user_with_sha1_password:
id: 6
login: mr_deprecated
crypted_password: <%= Digest::SHA1::hexdigest("#{Tracks::Config.salt}--foobar--") %>
token: <%= Digest::SHA1.hexdigest("mr_deprecatedSun Feb 19 14:42:45 GMT 20060.408173979260027") %>
is_admin: false
first_name: Mister
last_name: Deprecated
auth_type: database

View file

@ -31,7 +31,7 @@ class UsersControllerTest < ActionController::TestCase
get :index get :index
assert_response :success assert_response :success
assert_equal "TRACKS::Manage Users", assigns['page_title'] assert_equal "TRACKS::Manage Users", assigns['page_title']
assert_equal 4, assigns['total_users'] assert_equal 5, assigns['total_users']
assert_equal "/users", session['return-to'] assert_equal "/users", session['return-to']
end end
@ -68,7 +68,7 @@ class UsersControllerTest < ActionController::TestCase
post :update_password, :updateuser => {:password => 'newpassword', :password_confirmation => 'newpassword'} post :update_password, :updateuser => {:password => 'newpassword', :password_confirmation => 'newpassword'}
assert_redirected_to preferences_path assert_redirected_to preferences_path
@updated_user = User.find(users(:admin_user).id) @updated_user = User.find(users(:admin_user).id)
assert_equal @updated_user.crypted_password, Digest::SHA1.hexdigest("#{Tracks::Config.salt}--newpassword--") assert_not_nil User.authenticate(@updated_user.login, 'newpassword')
assert_equal "Password updated.", flash[:notice] assert_equal "Password updated.", flash[:notice]
end end

View file

@ -79,7 +79,7 @@ class UsersXmlApiTest < ActionController::IntegrationTest
get '/users.xml', {}, basic_auth_headers() get '/users.xml', {}, basic_auth_headers()
assert_response :success assert_response :success
assert_tag :tag => "users", assert_tag :tag => "users",
:children => { :count => 4, :only => { :tag => "user" } } :children => { :count => 5, :only => { :tag => "user" } }
assert_no_tag :tag => "password" assert_no_tag :tag => "password"
end end

View file

@ -33,7 +33,7 @@ class UserTest < ActiveSupport::TestCase
assert_kind_of User, @admin_user assert_kind_of User, @admin_user
assert_equal 1, @admin_user.id assert_equal 1, @admin_user.id
assert_equal "admin", @admin_user.login assert_equal "admin", @admin_user.login
assert_equal "#{Digest::SHA1.hexdigest("#{Tracks::Config.salt}--abracadabra--")}", @admin_user.crypted_password assert_not_nil @admin_user.crypted_password
assert_not_nil @admin_user.token assert_not_nil @admin_user.token
assert @admin_user.is_admin assert @admin_user.is_admin
end end
@ -43,7 +43,7 @@ class UserTest < ActiveSupport::TestCase
assert_kind_of User, @other_user assert_kind_of User, @other_user
assert_equal 2, @other_user.id assert_equal 2, @other_user.id
assert_equal "jane", @other_user.login assert_equal "jane", @other_user.login
assert_equal "#{Digest::SHA1.hexdigest("#{Tracks::Config.salt}--sesame--")}", @other_user.crypted_password assert_not_nil @other_user.crypted_password
assert_not_nil @other_user.token assert_not_nil @other_user.token
assert @other_user.is_admin == false || @other_user.is_admin == 0 assert @other_user.is_admin == false || @other_user.is_admin == 0
end end
@ -330,6 +330,32 @@ class UserTest < ActiveSupport::TestCase
assert_equal u.id, User.find_by_open_id_url(raw_open_id_url).id assert_equal u.id, User.find_by_open_id_url(raw_open_id_url).id
end end
end end
def test_should_discover_using_depracted_password
assert_nil @admin_user.uses_deprecated_password?
assert_nil @other_user.uses_deprecated_password?
assert users(:user_with_sha1_password).uses_deprecated_password?
end
def test_should_not_have_deprecated_password_after_update
u = users(:user_with_sha1_password)
assert u.uses_deprecated_password?
u.change_password("foobar", "foobar")
assert_nil u.uses_deprecated_password?
end
def test_should_authenticate_with_deprecated_password
assert_nil User.authenticate('mr_deprecated', 'wrong password')
assert_equal users(:user_with_sha1_password),
User.authenticate('mr_deprecated', 'foobar')
end
def test_password_matches
assert_not_nil User.authenticate(@admin_user.login, "abracadabra")
assert_nil User.authenticate(@admin_user.login, "incorrect")
assert_not_nil User.authenticate(users(:user_with_sha1_password).login, "foobar")
assert_nil User.authenticate(users(:user_with_sha1_password).login, "wrong")
end
protected protected