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:
parent
50a31f4c1f
commit
0862bf7e6a
12 changed files with 255 additions and 31 deletions
8
Gemfile
8
Gemfile
|
@ -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
|
||||||
|
|
15
Gemfile.lock
15
Gemfile.lock
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
20
db/migrate/20170126091128_application_secret_setting.rb
Normal file
20
db/migrate/20170126091128_application_secret_setting.rb
Normal 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
|
12
db/seeds.rb
12
db/seeds.rb
|
@ -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',
|
||||||
|
|
|
@ -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
48
lib/password_hash.rb
Normal 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
|
31
spec/auth/internal_spec.rb
Normal file
31
spec/auth/internal_spec.rb
Normal 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
24
spec/factories/user.rb
Normal 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
|
60
spec/password_hash_spec.rb
Normal file
60
spec/password_hash_spec.rb
Normal 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
|
|
@ -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
|
||||||
|
|
3
spec/support/factory_girl.rb
Normal file
3
spec/support/factory_girl.rb
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
RSpec.configure do |config|
|
||||||
|
config.include FactoryGirl::Syntax::Methods
|
||||||
|
end
|
Loading…
Reference in a new issue