Maintenance: Improved handling of long passwords.
This commit is contained in:
parent
177952f2f9
commit
858474dc4c
16 changed files with 223 additions and 30 deletions
|
@ -56,26 +56,32 @@ class ProfilePassword extends App.ControllerSubContent
|
||||||
data: JSON.stringify(params)
|
data: JSON.stringify(params)
|
||||||
processData: true
|
processData: true
|
||||||
success: @success
|
success: @success
|
||||||
|
error: @error
|
||||||
)
|
)
|
||||||
|
|
||||||
success: (data) =>
|
success: =>
|
||||||
if data.message is 'ok'
|
@render()
|
||||||
@render()
|
|
||||||
@notify(
|
@notify(
|
||||||
type: 'success'
|
type: 'success'
|
||||||
msg: App.i18n.translateContent( 'Password changed successfully!' )
|
msg: App.i18n.translateContent( 'Password changed successfully!' )
|
||||||
)
|
)
|
||||||
else
|
|
||||||
if data.notice
|
error: (xhr, status, error) =>
|
||||||
@notify
|
return if xhr.status != 422
|
||||||
type: 'error'
|
|
||||||
msg: App.i18n.translateContent( data.notice[0], data.notice[1] )
|
data = xhr.responseJSON
|
||||||
removeAll: true
|
|
||||||
else
|
message = if data.notice
|
||||||
@notify
|
App.i18n.translateContent( data.notice[0], data.notice[1] )
|
||||||
type: 'error'
|
else
|
||||||
msg: __('The password could not be set. Please contact your administrator.')
|
__('The password could not be set. Please contact your administrator.')
|
||||||
removeAll: true
|
|
||||||
@formEnable( @$('form') )
|
@notify
|
||||||
|
type: 'error'
|
||||||
|
msg: message
|
||||||
|
removeAll: true
|
||||||
|
|
||||||
|
@formEnable( @$('form') )
|
||||||
|
|
||||||
App.Config.set('Password', { prio: 2000, name: __('Password'), parent: '#profile', target: '#profile/password', controller: ProfilePassword, permission: ['user_preferences.password'] }, 'NavBarProfile')
|
App.Config.set('Password', { prio: 2000, name: __('Password'), parent: '#profile', target: '#profile/password', controller: ProfilePassword, permission: ['user_preferences.password'] }, 'NavBarProfile')
|
||||||
|
|
|
@ -557,26 +557,26 @@ curl http://localhost/api/v1/users/password_change -v -u #{login}:#{password} -H
|
||||||
def password_change
|
def password_change
|
||||||
|
|
||||||
# check old password
|
# check old password
|
||||||
if !params[:password_old]
|
if !params[:password_old] || !PasswordPolicy::MaxLength.valid?(params[:password_old])
|
||||||
render json: { message: 'failed', notice: [__('Current password needed!')] }, status: :ok
|
render json: { message: 'failed', notice: [__('Current password needed!')] }, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
current_password_verified = PasswordHash.verified?(current_user.password, params[:password_old])
|
current_password_verified = PasswordHash.verified?(current_user.password, params[:password_old])
|
||||||
if !current_password_verified
|
if !current_password_verified
|
||||||
render json: { message: 'failed', notice: [__('Current password is wrong!')] }, status: :ok
|
render json: { message: 'failed', notice: [__('Current password is wrong!')] }, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# set new password
|
# set new password
|
||||||
if !params[:password_new]
|
if !params[:password_new]
|
||||||
render json: { message: 'failed', notice: [__('Please supply your new password!')] }, status: :ok
|
render json: { message: 'failed', notice: [__('Please supply your new password!')] }, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
result = PasswordPolicy.new(params[:password_new])
|
result = PasswordPolicy.new(params[:password_new])
|
||||||
if !result.valid?
|
if !result.valid?
|
||||||
render json: { message: 'failed', notice: result.error }, status: :ok
|
render json: { message: 'failed', notice: result.error }, status: :unprocessable_entity
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1181,6 +1181,11 @@ raise 'At least one user need to have admin permissions'
|
||||||
# don't re-hash passwords
|
# don't re-hash passwords
|
||||||
return password if PasswordHash.crypted?(password)
|
return password if PasswordHash.crypted?(password)
|
||||||
|
|
||||||
|
if !PasswordPolicy::MaxLength.valid? password
|
||||||
|
errors.add :base, PasswordPolicy::MaxLength.error
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
# hash the plaintext password
|
# hash the plaintext password
|
||||||
PasswordHash.crypt(password)
|
PasswordHash.crypt(password)
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||||
|
|
||||||
|
class ObjectManagerUpdateUserPassword < ActiveRecord::Migration[6.0]
|
||||||
|
def up
|
||||||
|
# return if it's a new setup
|
||||||
|
return if !Setting.exists?(name: 'system_init_done')
|
||||||
|
|
||||||
|
UserInfo.current_user_id = 1
|
||||||
|
|
||||||
|
object_type = ObjectLookup.find_by(name: 'User')
|
||||||
|
attr = ObjectManager::Attribute.find_by object_lookup_id: object_type.id, name: 'password'
|
||||||
|
|
||||||
|
# password length is capped at 1000 in PasswordPolicy::MaxLength::MAX_LENGTH
|
||||||
|
# if user copy-pastes a very long string
|
||||||
|
# this ensures that max length check is triggered preventing saving of truncated password
|
||||||
|
attr.data_option[:maxlength] = 1001
|
||||||
|
attr.save!
|
||||||
|
end
|
||||||
|
end
|
|
@ -1166,7 +1166,10 @@ ObjectManager::Attribute.add(
|
||||||
data_type: 'input',
|
data_type: 'input',
|
||||||
data_option: {
|
data_option: {
|
||||||
type: 'password',
|
type: 'password',
|
||||||
maxlength: 100,
|
# password length is capped at 1000 in PasswordPolicy::MaxLength::MAX_LENGTH
|
||||||
|
# if user copy-pastes a very long string
|
||||||
|
# this ensures that max length check is triggered preventing saving of truncated password
|
||||||
|
maxlength: 1001,
|
||||||
null: true,
|
null: true,
|
||||||
autocomplete: 'new-password',
|
autocomplete: 'new-password',
|
||||||
item_class: 'formGroup--halfSize',
|
item_class: 'formGroup--halfSize',
|
||||||
|
|
|
@ -5176,6 +5176,10 @@ msgstr ""
|
||||||
msgid "Invalid password, it must be at least %s characters long!"
|
msgid "Invalid password, it must be at least %s characters long!"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/password_policy/max_length.rb
|
||||||
|
msgid "Invalid password, it must be shorter than %s characters!"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
#: lib/password_policy/digit.rb
|
#: lib/password_policy/digit.rb
|
||||||
msgid "Invalid password, it must contain at least 1 digit!"
|
msgid "Invalid password, it must contain at least 1 digit!"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
|
@ -27,6 +27,12 @@ class Auth
|
||||||
end
|
end
|
||||||
|
|
||||||
def hash_matches?
|
def hash_matches?
|
||||||
|
# makes sure that very long strings supplied as password
|
||||||
|
# rejected early and not even tried to match to password
|
||||||
|
if !PasswordPolicy::MaxLength.valid? password
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
# Because of legacy reason a special check exists and afterwards the
|
# Because of legacy reason a special check exists and afterwards the
|
||||||
# password will be saved in the current format.
|
# password will be saved in the current format.
|
||||||
if PasswordHash.legacy?(user.password, password)
|
if PasswordHash.legacy?(user.password, password)
|
||||||
|
|
27
lib/password_policy/max_length.rb
Normal file
27
lib/password_policy/max_length.rb
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||||
|
|
||||||
|
class PasswordPolicy
|
||||||
|
class MaxLength < PasswordPolicy::Backend
|
||||||
|
MAX_LENGTH = 1_000
|
||||||
|
|
||||||
|
def valid?
|
||||||
|
self.class.valid? @password
|
||||||
|
end
|
||||||
|
|
||||||
|
def error
|
||||||
|
self.class.error
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.applicable?
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.valid?(input)
|
||||||
|
input.length <= MAX_LENGTH
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.error
|
||||||
|
[__('Invalid password, it must be shorter than %s characters!'), MAX_LENGTH]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
19
spec/db/migrate/object_manager_update_user_password_spec.rb
Normal file
19
spec/db/migrate/object_manager_update_user_password_spec.rb
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe ObjectManagerUpdateUserPassword, type: :db_migration do
|
||||||
|
let(:attr) do
|
||||||
|
object_type = ObjectLookup.find_by(name: 'User')
|
||||||
|
ObjectManager::Attribute.find_by object_lookup_id: object_type.id, name: 'password'
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
attr.data_option['maxlength'] = 123
|
||||||
|
attr.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'changes maxlength' do
|
||||||
|
expect { migrate }.to change { attr.reload.data_option[:maxlength] }.from(123).to(1001)
|
||||||
|
end
|
||||||
|
end
|
|
@ -21,6 +21,30 @@ RSpec.describe Auth::Backend::Internal do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when very long password is given' do
|
||||||
|
let(:password) { Faker::Lorem.characters(number: 1_111) }
|
||||||
|
let(:user) do
|
||||||
|
# temporary override constant to create a test user with a very long password
|
||||||
|
initial = PasswordPolicy::MaxLength::MAX_LENGTH
|
||||||
|
stub_const 'PasswordPolicy::MaxLength::MAX_LENGTH', 99_999
|
||||||
|
user = create(:user, password: password)
|
||||||
|
stub_const 'PasswordPolicy::MaxLength::MAX_LENGTH', initial
|
||||||
|
user
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not try to verify it' do
|
||||||
|
allow(PasswordHash).to receive(:verified?)
|
||||||
|
|
||||||
|
instance.valid?
|
||||||
|
|
||||||
|
expect(PasswordHash).not_to have_received(:verified?)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false even though password is matching' do
|
||||||
|
expect(instance).not_to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'when given password matches stored hash' do
|
context 'when given password matches stored hash' do
|
||||||
|
|
||||||
let(:password) { user.password }
|
let(:password) { user.password }
|
||||||
|
|
45
spec/lib/password_policy/max_length_spec.rb
Normal file
45
spec/lib/password_policy/max_length_spec.rb
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
# Copyright (C) 2012-2022 Zammad Foundation, https://zammad-foundation.org/
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
require 'lib/password_policy/error_examples'
|
||||||
|
|
||||||
|
RSpec.describe PasswordPolicy::MaxLength do
|
||||||
|
let(:long_string) { Faker::Lorem.characters(number: 1_111) }
|
||||||
|
|
||||||
|
it_behaves_like 'declaring an error'
|
||||||
|
|
||||||
|
describe '.applicable?' do
|
||||||
|
it 'returns true' do
|
||||||
|
expect(described_class).to be_applicable
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#valid?' do
|
||||||
|
it 'long string is invalid' do
|
||||||
|
instance = described_class.new long_string
|
||||||
|
expect(instance).not_to be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'short string is valid' do
|
||||||
|
instance = described_class.new Faker::Lorem.sentence
|
||||||
|
expect(instance).to be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#error' do
|
||||||
|
it 'includes value of MAX_LENGTH' do
|
||||||
|
instance = described_class.new(long_string)
|
||||||
|
expect(instance.error.last).to eq described_class::MAX_LENGTH
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '.valid?' do
|
||||||
|
it 'long string is invalid' do
|
||||||
|
expect(described_class).not_to be_valid(long_string)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'short string is valid' do
|
||||||
|
expect(described_class).to be_valid(Faker::Lorem.sentence)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -814,6 +814,16 @@ RSpec.describe User, type: :model do
|
||||||
expect(user.password).not_to eq(another_user.password)
|
expect(user.password).not_to eq(another_user.password)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'when saving a very long password' do
|
||||||
|
let(:long_string) { "asd1ASDasd!#{Faker::Lorem.characters(number: 1_000)}" }
|
||||||
|
|
||||||
|
it 'marks object as invalid by adding error' do
|
||||||
|
user.update(password: long_string)
|
||||||
|
|
||||||
|
expect(user.errors.full_messages).to eq([['Invalid password, it must be shorter than %s characters!', 1000]])
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe '#phone' do
|
describe '#phone' do
|
||||||
|
|
|
@ -35,7 +35,7 @@ RSpec.describe 'Overviews', type: :request do
|
||||||
|
|
||||||
agent = create(:agent, password: 'we need a password here')
|
agent = create(:agent, password: 'we need a password here')
|
||||||
|
|
||||||
authenticated_as(agent)
|
authenticated_as(agent, password: 'wrong password')
|
||||||
post '/api/v1/overviews', params: params, as: :json
|
post '/api/v1/overviews', params: params, as: :json
|
||||||
expect(response).to have_http_status(:unauthorized)
|
expect(response).to have_http_status(:unauthorized)
|
||||||
expect(json_response).to be_a_kind_of(Hash)
|
expect(json_response).to be_a_kind_of(Hash)
|
||||||
|
|
|
@ -46,7 +46,7 @@ RSpec.describe 'Packages', type: :request do
|
||||||
it 'does packages index with inactive admin' do
|
it 'does packages index with inactive admin' do
|
||||||
admin = create(:admin, active: false, password: 'we need a password here')
|
admin = create(:admin, active: false, password: 'we need a password here')
|
||||||
|
|
||||||
authenticated_as(admin)
|
authenticated_as(admin, password: 'wrong password')
|
||||||
get '/api/v1/packages', as: :json
|
get '/api/v1/packages', as: :json
|
||||||
|
|
||||||
expect(response).to have_http_status(:unauthorized)
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
|
|
@ -77,8 +77,8 @@ RSpec.describe 'User', type: :request do
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
before do
|
before do |example|
|
||||||
configure_elasticsearch(rebuild: true)
|
configure_elasticsearch(rebuild: true) if example.metadata[:searchindex]
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'does user create tests - no user' do
|
it 'does user create tests - no user' do
|
||||||
|
@ -1160,6 +1160,31 @@ RSpec.describe 'User', type: :request do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'ultra long password', authenticated_as: :user, searchindex: false do
|
||||||
|
let(:user) { create :agent, :with_valid_password }
|
||||||
|
let(:long_string) { "asd1ASDasd!#{Faker::Lorem.characters(number: 1_000)}" }
|
||||||
|
|
||||||
|
it 'does not reach verifying when old password is too long' do
|
||||||
|
allow(PasswordHash).to receive(:verified?).and_call_original
|
||||||
|
|
||||||
|
post '/api/v1/users/password_change', params: { password_old: long_string, password_new: long_string }, as: :json
|
||||||
|
|
||||||
|
expect(PasswordHash).not_to have_received(:verified?).with(any_args, long_string)
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json_response['message']).to eq('failed')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'does not reach hashing when saving' do
|
||||||
|
allow(PasswordHash).to receive(:crypt).and_call_original
|
||||||
|
|
||||||
|
post '/api/v1/users/password_change', params: { password_old: user.password_plain, password_new: long_string }, as: :json
|
||||||
|
|
||||||
|
expect(PasswordHash).not_to have_received(:crypt)
|
||||||
|
expect(response).to have_http_status(:unprocessable_entity)
|
||||||
|
expect(json_response['message']).to eq('failed')
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe 'POST /api/v1/users', authenticated_as: -> { create(:admin) }, searchindex: false do
|
describe 'POST /api/v1/users', authenticated_as: -> { create(:admin) }, searchindex: false do
|
||||||
|
|
|
@ -67,7 +67,7 @@ module ZammadSpecSupportRequest
|
||||||
#
|
#
|
||||||
# @return nil
|
# @return nil
|
||||||
def authenticated_as(user, via: :api_client, **options)
|
def authenticated_as(user, via: :api_client, **options)
|
||||||
password = options[:password] || user.password.to_s
|
password = options[:password] || user.try(:password_plain) || user.password.to_s
|
||||||
login = options[:login] || user.login
|
login = options[:login] || user.login
|
||||||
|
|
||||||
case via
|
case via
|
||||||
|
|
Loading…
Reference in a new issue