Improved password security by using proper password hash module backed by Argon2 (official winner of the Password Hashing Competition) - thanks to @nomoketo and @benbe.

This commit is contained in:
Thorsten Eckel 2017-01-27 09:17:03 +01:00
parent 50a31f4c1f
commit 0862bf7e6a
12 changed files with 255 additions and 31 deletions

View file

@ -66,6 +66,9 @@ require 'yaml'
gem 'net-ldap' gem 'net-ldap'
# password security
gem 'argon2'
gem 'writeexcel' gem 'writeexcel'
gem 'icalendar' gem 'icalendar'
gem 'browser' gem 'browser'
@ -85,7 +88,7 @@ gem 'diffy'
# in production environments by default. # in production environments by default.
group :development, :test do group :development, :test do
gem 'rspec-rails' gem 'rspec-rails', '~> 3.5'
gem 'test-unit' gem 'test-unit'
gem 'spring' gem 'spring'
gem 'spring-commands-rspec' gem 'spring-commands-rspec'
@ -115,6 +118,9 @@ group :development, :test do
# Setting ENV for testing purposes # Setting ENV for testing purposes
gem 'figaro' gem 'figaro'
# Use Factory Girl for generating random test data
gem 'factory_girl_rails'
end end
gem 'puma', group: :puma gem 'puma', group: :puma

View file

@ -46,6 +46,9 @@ GEM
tzinfo (~> 1.1) tzinfo (~> 1.1)
addressable (2.4.0) addressable (2.4.0)
arel (6.0.3) arel (6.0.3)
argon2 (1.1.1)
ffi (~> 1.9)
ffi-compiler (~> 0.1)
ast (2.3.0) ast (2.3.0)
autoprefixer-rails (6.4.1.1) autoprefixer-rails (6.4.1.1)
execjs execjs
@ -107,11 +110,19 @@ GEM
erubis (2.7.0) erubis (2.7.0)
eventmachine (1.2.0.1) eventmachine (1.2.0.1)
execjs (2.7.0) execjs (2.7.0)
factory_girl (4.8.0)
activesupport (>= 3.0.0)
factory_girl_rails (4.8.0)
factory_girl (~> 4.8.0)
railties (>= 3.0.0)
faraday (0.9.2) faraday (0.9.2)
multipart-post (>= 1.2, < 3) multipart-post (>= 1.2, < 3)
faraday-http-cache (1.3.1) faraday-http-cache (1.3.1)
faraday (~> 0.8) faraday (~> 0.8)
ffi (1.9.14) ffi (1.9.14)
ffi-compiler (0.1.3)
ffi (>= 1.0.0)
rake
figaro (1.1.1) figaro (1.1.1)
thor (~> 0.14) thor (~> 0.14)
formatador (0.2.5) formatador (0.2.5)
@ -396,6 +407,7 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
activerecord-nulldb-adapter activerecord-nulldb-adapter
activerecord-session_store activerecord-session_store
argon2
autoprefixer-rails autoprefixer-rails
biz biz
browser browser
@ -413,6 +425,7 @@ DEPENDENCIES
email_verifier email_verifier
eventmachine eventmachine
execjs execjs
factory_girl_rails
figaro figaro
github_changelog_generator github_changelog_generator
guard guard
@ -442,7 +455,7 @@ DEPENDENCIES
rails (= 4.2.7.1) rails (= 4.2.7.1)
rails-observers rails-observers
rb-fsevent rb-fsevent
rspec-rails rspec-rails (~> 3.5)
rubocop rubocop
sass-rails sass-rails
selenium-webdriver selenium-webdriver

View file

@ -32,7 +32,7 @@ class User < ApplicationModel
load 'user/search_index.rb' load 'user/search_index.rb'
include User::SearchIndex include User::SearchIndex
before_validation :check_name, :check_email, :check_login, :check_password before_validation :check_name, :check_email, :check_login, :ensure_password
before_create :check_preferences_default, :validate_roles, :domain_based_assignment before_create :check_preferences_default, :validate_roles, :domain_based_assignment
before_update :check_preferences_default, :validate_roles before_update :check_preferences_default, :validate_roles
after_create :avatar_for_email_check after_create :avatar_for_email_check
@ -906,26 +906,23 @@ returns
Avatar.remove('User', id) Avatar.remove('User', id)
end end
def check_password def ensure_password
return if password_empty?
# set old password again if not given return if PasswordHash.crypted?(password)
if password.blank? self.password = PasswordHash.crypt(password)
# get current record
if id
#current = User.find(self.id)
#self.password = current.password
self.password = password_was
end
end
# crypt password if not already crypted
return if !password
return if password =~ /^\{sha2\}/
crypted = Digest::SHA2.hexdigest(password)
self.password = "{sha2}#{crypted}"
end end
def password_empty?
# set old password again if not given
return if password.present?
# skip if it's not desired to set a password (yet)
return true if !password
# get current record
return if !id
self.password = password_was
true
end
end end

View file

@ -0,0 +1,20 @@
class ApplicationSecretSetting < ActiveRecord::Migration
def up
# return if it's a new setup
return if !Setting.find_by(name: 'system_init_done')
Setting.create_if_not_exists(
title: 'Application secret',
name: 'application_secret',
area: 'Core',
description: 'Defines the random application secret.',
options: {},
state: SecureRandom.hex(128),
preferences: {
permission: ['admin'],
},
frontend: false
)
end
end

View file

@ -10,6 +10,18 @@
# clear old caches to start from scratch # clear old caches to start from scratch
Cache.clear Cache.clear
Setting.create_if_not_exists(
title: 'Application secret',
name: 'application_secret',
area: 'Core',
description: 'Defines the random application secret.',
options: {},
state: SecureRandom.hex(128),
preferences: {
permission: ['admin'],
},
frontend: false
)
Setting.create_if_not_exists( Setting.create_if_not_exists(
title: 'System Init Done', title: 'System Init Done',
name: 'system_init_done', name: 'system_init_done',

View file

@ -1,21 +1,30 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/ # Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
module Auth::Internal module Auth::Internal
def self.check(username, password, _config, user)
# rubocop:disable Style/ModuleFunction
extend self
def check(username, password, _config, user)
# return if no user exists # return if no user exists
return false if !username return false if !username
return false if !user return false if !user
# sha auth check if PasswordHash.legacy?(user.password, password)
if user.password =~ /^\{sha2\}/ update_password(user, password)
crypted = Digest::SHA2.hexdigest(password) return user
return user if user.password == "{sha2}#{crypted}"
end end
# plain auth check return false if !PasswordHash.verified?(user.password, password)
return user if user.password == password
false user
end
private
def update_password(user, password)
user.password = PasswordHash.crypt(password)
user.save
end end
end end

48
lib/password_hash.rb Normal file
View file

@ -0,0 +1,48 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
module PasswordHash
include ApplicationLib
# rubocop:disable Style/ModuleFunction
extend self
def crypt(password)
argon2.create(password)
end
def verified?(pw_hash, password)
Argon2::Password.verify_password(password, pw_hash, secret)
rescue
false
end
def crypted?(pw_hash)
return if !pw_hash
# taken from: https://github.com/technion/ruby-argon2/blob/7e1f4a2634316e370ab84150e4f5fd91d9263713/lib/argon2.rb#L33
return if pw_hash !~ /^\$argon2i\$.{,112}/
true
end
def legacy?(pw_hash, password)
return if pw_hash.blank?
return if !password
legacy_sha2?(pw_hash, password)
end
private
def legacy_sha2?(pw_hash, password)
return if !pw_hash.start_with?('{sha2}')
crypted = Digest::SHA2.hexdigest(password)
pw_hash == "{sha2}#{crypted}"
end
def argon2
return @argon2 if @argon2
@argon2 = Argon2::Password.new(secret: secret)
end
def secret
Setting.get('application_secret')
end
end

View file

@ -0,0 +1,31 @@
require 'rails_helper'
RSpec.describe Auth::Internal do
it 'authenticates via password' do
user = create(:user)
password = 'zammad'
result = described_class.check(user.login, password, {}, user)
expect(result).to be_an_instance_of(User)
end
it "doesn't authenticate via plain password" do
user = create(:user)
result = described_class.check(user.login, user.password, {}, user)
expect(result).to be_falsy
end
it 'converts legacy sha2 passwords' do
user = create(:user_legacy_password_sha2)
password = 'zammad'
expect(PasswordHash.crypted?(user.password)).to be_falsy
result = described_class.check(user.login, password, {}, user)
expect(result).to be_an_instance_of(User)
expect(PasswordHash.crypted?(user.password)).to be true
end
end

24
spec/factories/user.rb Normal file
View file

@ -0,0 +1,24 @@
FactoryGirl.define do
sequence :email do |n|
"nicole.braun#{n}@zammad.org"
end
end
FactoryGirl.define do
factory :user do
login 'nicole.braun'
firstname 'Nicole'
lastname 'Braun'
email { generate(:email) }
password 'zammad'
active true
updated_by_id 1
created_by_id 1
end
factory :user_legacy_password_sha2, parent: :user do
after(:build) { |user| user.class.skip_callback(:validation, :before, :ensure_password) }
password '{sha2}dd9c764fa7ea18cd992c8600006d3dc3ac983d1ba22e9ba2d71f6207456be0ba' # zammad
end
end

View file

@ -0,0 +1,60 @@
require 'rails_helper'
RSpec.describe PasswordHash do
let(:pw_plain) { 'zammad' }
context 'stable API' do
it 'responds to crypt' do
expect(described_class).to respond_to(:crypt)
end
it 'responds to verified?' do
expect(described_class).to respond_to(:verified?)
end
it 'responds to crypted?' do
expect(described_class).to respond_to(:crypted?)
end
it 'responds to legacy?' do
expect(described_class).to respond_to(:legacy?)
end
end
context 'encryption' do
it 'crypts passwords' do
pw_crypted = described_class.crypt(pw_plain)
expect(pw_crypted).not_to eq(pw_plain)
end
it 'verifies crypted passwords' do
pw_crypted = described_class.crypt(pw_plain)
expect(described_class.verified?(pw_crypted, pw_plain)).to be true
end
it 'detects crypted passwords' do
pw_crypted = described_class.crypt(pw_plain)
expect(described_class.crypted?(pw_crypted)).to be true
end
end
context 'legacy' do
let(:zammad_sha2) { '{sha2}dd9c764fa7ea18cd992c8600006d3dc3ac983d1ba22e9ba2d71f6207456be0ba' }
it 'requires hash to be not blank' do
expect(described_class.legacy?(nil, pw_plain)).to be_falsy
expect(described_class.legacy?('', pw_plain)).to be_falsy
end
it 'requires password to be not nil' do
expect(described_class.legacy?(zammad_sha2, nil)).to be_falsy
end
it 'detects sha2 hashes' do
expect(described_class.legacy?(zammad_sha2, pw_plain)).to be true
end
end
end

View file

@ -6,6 +6,7 @@ require File.expand_path('../../config/environment', __FILE__)
abort('The Rails environment is running in production mode!') if Rails.env.production? abort('The Rails environment is running in production mode!') if Rails.env.production?
require 'spec_helper' require 'spec_helper'
require 'rspec/rails' require 'rspec/rails'
require 'support/factory_girl'
# Add additional requires below this line. Rails is not loaded until this point! # Add additional requires below this line. Rails is not loaded until this point!
# Requires supporting ruby files with custom matchers and macros, etc, in # Requires supporting ruby files with custom matchers and macros, etc, in

View file

@ -0,0 +1,3 @@
RSpec.configure do |config|
config.include FactoryGirl::Syntax::Methods
end