<%- @T('Zammad') %>
+ <% for key, value of @config.user_attributes: %>
+
+
<%= key %>
+
<%= value %>
+ <% end %>
+
+
+
+ <% end %>
+
+
<%- @T('Role') %>
+ <% if _.isEmpty(@config.group_role_map): %>
+
+
<%- @T('No Entries') %>
+
+ <% else: %>
+
+
+
+
<%- @T('LDAP') %>
+
<%- @T('Zammad') %>
+
+ <% for key, value of @config.group_role_map: %>
+
+
<%= key %>
+
<%= App.Role.find(value).displayName() %>
+ <% end %>
+ <% end %>
+
+
+
+
diff --git a/app/assets/javascripts/app/views/integration/ldap_group_role_row.jst.eco b/app/assets/javascripts/app/views/integration/ldap_group_role_row.jst.eco
new file mode 100644
index 000000000..7e7158d56
--- /dev/null
+++ b/app/assets/javascripts/app/views/integration/ldap_group_role_row.jst.eco
@@ -0,0 +1,7 @@
+
+
+
+
+
+ <%- @Icon('trash') %> <%- @T('Remove') %>
+
\ No newline at end of file
diff --git a/app/assets/javascripts/app/views/integration/ldap_last_import.jst.eco b/app/assets/javascripts/app/views/integration/ldap_last_import.jst.eco
new file mode 100644
index 000000000..4fb5328e5
--- /dev/null
+++ b/app/assets/javascripts/app/views/integration/ldap_last_import.jst.eco
@@ -0,0 +1,40 @@
+
+
<%- @T('Last sync') %>
+ <% if _.isEmpty(@job.started_at): %>
+ <% if @job.result && @job.result.error: %>
+
diff --git a/app/assets/javascripts/app/views/integration/ldap_user_attribute_row.jst.eco b/app/assets/javascripts/app/views/integration/ldap_user_attribute_row.jst.eco
new file mode 100644
index 000000000..1759a8a59
--- /dev/null
+++ b/app/assets/javascripts/app/views/integration/ldap_user_attribute_row.jst.eco
@@ -0,0 +1,7 @@
+
+
+
+
+
+ <%- @Icon('trash') %> <%- @T('Remove') %>
+
\ No newline at end of file
diff --git a/app/assets/javascripts/app/views/integration/ldap_user_attribute_row_read_only.jst.eco b/app/assets/javascripts/app/views/integration/ldap_user_attribute_row_read_only.jst.eco
new file mode 100644
index 000000000..96c5bb33e
--- /dev/null
+++ b/app/assets/javascripts/app/views/integration/ldap_user_attribute_row_read_only.jst.eco
@@ -0,0 +1,6 @@
+
+
+
<%= @key %>
+
+ <%= @value %>
+
diff --git a/app/assets/javascripts/app/views/integration/ldap_wizard.jst.eco b/app/assets/javascripts/app/views/integration/ldap_wizard.jst.eco
new file mode 100644
index 000000000..524799dff
--- /dev/null
+++ b/app/assets/javascripts/app/views/integration/ldap_wizard.jst.eco
@@ -0,0 +1,254 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ <%- @Icon('diagonal-cross') %>
+
+
<%- @T('LDAP') %> <%- @T('Mapping') %>
+
+
+
+
+
+
<%- @T('User') %>
+
+
+
<%- @T('Roles') %>
+
<%- @T('Note: All not mapped users will get the default signup roles.') %>
+
+
+
+
+
+
+
+
+
+
+ <%- @Icon('diagonal-cross') %>
+
+
<%- @T('LDAP') %> <%- @T('Configuration') %>
+
+
+
+
+
<%- @T('With your current configuration the following will happen') %>:
+
+
+
+
+
+
+
+
+
diff --git a/app/assets/stylesheets/zammad.scss b/app/assets/stylesheets/zammad.scss
index 6abd9b2af..5974a9550 100644
--- a/app/assets/stylesheets/zammad.scss
+++ b/app/assets/stylesheets/zammad.scss
@@ -7474,6 +7474,11 @@ output {
.searchableSelect-main {
position: relative;
+
+ &.form-control--small ~ .searchableSelect-autocomplete {
+ top: 7px;
+ left: 9px;
+ }
}
.searchableSelect-shadow {
diff --git a/app/controllers/application_controller/handles_errors.rb b/app/controllers/application_controller/handles_errors.rb
index 14fc510ae..10a5b5141 100644
--- a/app/controllers/application_controller/handles_errors.rb
+++ b/app/controllers/application_controller/handles_errors.rb
@@ -13,17 +13,17 @@ module ApplicationController::HandlesErrors
end
def not_found(e)
- log_error_exception(e)
+ logger.error e
respond_to_exception(e, :not_found)
end
def unprocessable_entity(e)
- log_error_exception(e)
+ logger.error e
respond_to_exception(e, :unprocessable_entity)
end
def internal_server_error(e)
- log_error_exception(e)
+ logger.error e
respond_to_exception(e, :internal_server_error)
end
@@ -35,11 +35,6 @@ module ApplicationController::HandlesErrors
private
- def log_error_exception(e)
- logger.error e.message
- logger.error e.backtrace.inspect
- end
-
def respond_to_exception(e, status)
status_code = Rack::Utils.status_code(status)
diff --git a/app/controllers/calendar_subscriptions_controller.rb b/app/controllers/calendar_subscriptions_controller.rb
index 1c8d34d50..82e81fe7d 100644
--- a/app/controllers/calendar_subscriptions_controller.rb
+++ b/app/controllers/calendar_subscriptions_controller.rb
@@ -22,8 +22,7 @@ class CalendarSubscriptionsController < ApplicationController
disposition: 'inline'
)
rescue => e
- logger.error e.message
- logger.error e.backtrace.inspect
+ logger.error e
render json: { error: e.message }, status: :unprocessable_entity
end
@@ -45,8 +44,7 @@ class CalendarSubscriptionsController < ApplicationController
disposition: 'inline'
)
rescue => e
- logger.error e.message
- logger.error e.backtrace.inspect
+ logger.error e
render json: { error: e.message }, status: :unprocessable_entity
end
diff --git a/app/controllers/integration/ldap_controller.rb b/app/controllers/integration/ldap_controller.rb
new file mode 100644
index 000000000..0fff21396
--- /dev/null
+++ b/app/controllers/integration/ldap_controller.rb
@@ -0,0 +1,95 @@
+# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
+require 'ldap'
+require 'ldap/user'
+require 'ldap/group'
+
+class Integration::LdapController < ApplicationController
+ prepend_before_action { authentication_check(permission: 'admin.integration.ldap') }
+
+ def discover
+ ldap = ::Ldap.new(params)
+
+ render json: {
+ result: 'ok',
+ attributes: ldap.preferences,
+ }
+ rescue => e
+ logger.error e
+
+ render json: {
+ result: 'failed',
+ message: e.message,
+ }
+ end
+
+ def bind
+ # create single instance so
+ # User and Group don't have to
+ # open new connections
+ ldap = ::Ldap.new(params)
+ user = ::Ldap::User.new(params, ldap: ldap)
+ group = ::Ldap::Group.new(params, ldap: ldap)
+
+ render json: {
+ result: 'ok',
+
+ # the order of these calls is relevant!
+ user_filter: user.filter,
+ user_attributes: user.attributes,
+ user_uid: user.uid_attribute,
+
+ # the order of these calls is relevant!
+ group_filter: group.filter,
+ groups: group.list,
+ group_uid: group.uid_attribute,
+ }
+ rescue => e
+ logger.error e
+
+ render json: {
+ result: 'failed',
+ message: e.message,
+ }
+ end
+
+ def job_try_index
+ job_index(
+ dry_run: true,
+ take_finished: params[:finished] == 'true'
+ )
+ end
+
+ def job_try_create
+ ImportJob.dry_run(name: 'Import::Ldap', payload: params)
+ render json: {
+ result: 'ok',
+ }
+ end
+
+ def job_start_index
+ job_index(dry_run: false)
+ end
+
+ def job_start_create
+ job = ImportJob.create(name: 'Import::Ldap', payload: Setting.get('ldap_config'))
+ job.delay.start
+ render json: {
+ result: 'ok',
+ }
+ end
+
+ private
+
+ def job_index(dry_run:, take_finished: true)
+ job = ImportJob.find_by(name: 'Import::Ldap', dry_run: dry_run, finished_at: nil)
+ if !job && take_finished
+ job = ImportJob.where(name: 'Import::Ldap', dry_run: dry_run).order(created_at: :desc).limit(1).first
+ end
+
+ if job
+ model_show_render_item(job)
+ else
+ render json: {}
+ end
+ end
+end
diff --git a/app/models/application_model.rb b/app/models/application_model.rb
index 71a12a3fe..1190dcff3 100644
--- a/app/models/application_model.rb
+++ b/app/models/application_model.rb
@@ -14,6 +14,7 @@ class ApplicationModel < ActiveRecord::Base
include ApplicationModel::HasAssociations
include ApplicationModel::HasAttachments
include ApplicationModel::HasLatestChangeTimestamp
+ include ApplicationModel::HasExternalSync
include ApplicationModel::Importable
include ApplicationModel::HistoryLoggable
include ApplicationModel::TouchesReferences
diff --git a/app/models/application_model/has_external_sync.rb b/app/models/application_model/has_external_sync.rb
new file mode 100644
index 000000000..fccd022e9
--- /dev/null
+++ b/app/models/application_model/has_external_sync.rb
@@ -0,0 +1,15 @@
+# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
+module ApplicationModel::HasExternalSync
+ extend ActiveSupport::Concern
+
+ included do
+ after_destroy :external_sync_destroy
+ end
+
+ def external_sync_destroy
+ ExternalSync.where(
+ object: self.class.to_s,
+ o_id: id,
+ ).destroy_all
+ end
+end
diff --git a/app/models/application_model/touches_references.rb b/app/models/application_model/touches_references.rb
index aff47ab21..84fad3692 100644
--- a/app/models/application_model/touches_references.rb
+++ b/app/models/application_model/touches_references.rb
@@ -23,8 +23,7 @@ touch references by params
return if !object
object.touch
rescue => e
- logger.error e.message
- logger.error e.backtrace.inspect
+ logger.error e
end
end
end
diff --git a/app/models/channel/email_parser.rb b/app/models/channel/email_parser.rb
index be476651b..e061092b6 100644
--- a/app/models/channel/email_parser.rb
+++ b/app/models/channel/email_parser.rb
@@ -426,8 +426,7 @@ returns
p message # rubocop:disable Rails/Output
p 'ERROR: ' + e.inspect # rubocop:disable Rails/Output
Rails.logger.error message
- Rails.logger.error 'ERROR: ' + e.inspect
- Rails.logger.error 'ERROR: ' + e.backtrace.inspect
+ Rails.logger.error e
File.open(filename, 'wb') { |file|
file.write msg
}
diff --git a/app/models/external_sync.rb b/app/models/external_sync.rb
index 73f8a1c5d..88413f96a 100644
--- a/app/models/external_sync.rb
+++ b/app/models/external_sync.rb
@@ -68,7 +68,7 @@ class ExternalSync < ApplicationModel
break if !value
storable = value.class.ancestors.any? do |ancestor|
- %w(String Integer Float Bool).include?(ancestor.to_s)
+ %w(String Integer Float Bool Array).include?(ancestor.to_s)
end
if storable
diff --git a/app/models/import_job.rb b/app/models/import_job.rb
new file mode 100644
index 000000000..75f58ed16
--- /dev/null
+++ b/app/models/import_job.rb
@@ -0,0 +1,71 @@
+# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
+
+class ImportJob < ApplicationModel
+
+ store :payload
+ store :result
+
+ # Starts the import backend class based on the name attribute.
+ # Import backend class is initialized with the current instance.
+ # Logs the start and end time (if ended successfully) and logs
+ # exceptions into result if they happen.
+ #
+ # @example
+ # import = ImportJob.new(name: 'Import::Ldap', payload: Setting.get('ldap_config'))
+ # import.start
+ #
+ # return [nil]
+ def start
+ self.started_at = Time.zone.now
+ save
+ name.constantize.new(self)
+ rescue => e
+ Rails.logger.error e
+
+ # rubocop:disable Style/RedundantSelf
+ if !self.result.is_a?(Hash)
+ self.result = {}
+ end
+ self.result[:error] = e.message
+ # rubocop:enable Style/RedundantSelf
+ ensure
+ self.finished_at = Time.zone.now
+ save
+ end
+
+ # Convenience wrapper around the start method for starting (delayed) dry runs.
+ # Logs the start and end time (if ended successfully) and logs
+ # exceptions into result if they happen.
+ # Only one running or pending dry run per backend is possible at the same time.
+ #
+ # @param [Hash] params the params used to initialize the ImportJob instance.
+ # @option params [Boolean] :delay Defines if job should get executed delayed. Default is true.
+
+ # @example
+ # import = ImportJob.dry_run(name: 'Import::Ldap', payload: Setting.get('ldap_config'), delay: false)
+ #
+ # return [nil]
+ def self.dry_run(params)
+
+ return if exists?(name: params[:name], dry_run: true, finished_at: nil)
+
+ params[:dry_run] = true
+ instance = create(params.except(:delay))
+
+ if params.fetch(:delay, true)
+ instance.delay.start
+ else
+ instance.start
+ end
+ end
+
+ # Starts all import jobs that have not started yet and are no dry runs.
+ #
+ # @example
+ # ImportJob.start
+ #
+ # return [nil]
+ def self.start
+ where(started_at: nil, dry_run: false).each(&:start)
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 93474015c..66a53ad8c 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -218,30 +218,61 @@ returns
# do not authenticate with nothing
return if username.blank? || password.blank?
+ user = User.identify(username)
+ return if !user
+
+ return if !Auth.can_login?(user)
+
+ return user if Auth.valid?(user, password)
+
+ sleep 1
+ user.login_failed += 1
+ user.save
+ nil
+ end
+
+=begin
+
+checks if a user has reached the maximum of failed login tries
+
+ user = User.find(123)
+ result = user.max_login_failed?
+
+returns
+
+ result = true | false
+
+=end
+
+ def max_login_failed?
+ max_login_failed = Setting.get('password_max_login_failed').to_i || 10
+ login_failed > max_login_failed
+ end
+
+=begin
+
+tries to find the matching instance by the given identifier. Currently email and login is supported.
+
+ user = User.indentify('User123')
+
+ # or
+
+ user = User.indentify('user-123@example.com')
+
+returns
+
+ # User instance
+ user.login # 'user123'
+
+=end
+
+ def self.identify(identifier)
# try to find user based on login
- user = User.find_by(login: username.downcase, active: true)
+ user = User.find_by(login: identifier.downcase)
+ return user if user
# try second lookup with email
- user ||= User.find_by(email: username.downcase, active: true)
-
- # check failed logins
- max_login_failed = Setting.get('password_max_login_failed').to_i || 10
- if user && user.login_failed > max_login_failed
- logger.info "Max login failed reached for user #{user.login}."
- return false
- end
-
- user_auth = Auth.check(username, password, user)
-
- # set login failed +1
- if !user_auth && user
- sleep 1
- user.login_failed += 1
- user.save
- end
-
- # auth ok
- user_auth
+ User.find_by(email: identifier.downcase)
end
=begin
diff --git a/config/routes/integration_ldap.rb b/config/routes/integration_ldap.rb
new file mode 100644
index 000000000..ec5c84ce0
--- /dev/null
+++ b/config/routes/integration_ldap.rb
@@ -0,0 +1,10 @@
+Zammad::Application.routes.draw do
+ api_path = Rails.configuration.api_path
+
+ match api_path + '/integration/ldap/discover', to: 'integration/ldap#discover', via: :post
+ match api_path + '/integration/ldap/bind', to: 'integration/ldap#bind', via: :post
+ match api_path + '/integration/ldap/job_try', to: 'integration/ldap#job_try_index', via: :get
+ match api_path + '/integration/ldap/job_try', to: 'integration/ldap#job_try_create', via: :post
+ match api_path + '/integration/ldap/job_start', to: 'integration/ldap#job_start_index', via: :get
+ match api_path + '/integration/ldap/job_start', to: 'integration/ldap#job_start_create', via: :post
+end
diff --git a/db/migrate/20120101000001_create_base.rb b/db/migrate/20120101000001_create_base.rb
index 192bb3f8e..722e5c517 100644
--- a/db/migrate/20120101000001_create_base.rb
+++ b/db/migrate/20120101000001_create_base.rb
@@ -539,6 +539,20 @@ class CreateBase < ActiveRecord::Migration
add_index :external_syncs, [:source, :source_id, :object, :o_id], name: 'index_external_syncs_on_source_and_source_id_and_object_o_id'
add_index :external_syncs, [:object, :o_id]
+ create_table :import_jobs do |t|
+ t.string :name, limit: 250, null: false
+
+ t.boolean :dry_run, default: false
+
+ t.text :payload, limit: 80_000
+ t.text :result, limit: 80_000
+
+ t.datetime :started_at
+ t.datetime :finished_at
+
+ t.timestamps null: false
+ end
+
create_table :cti_logs do |t|
t.string :direction, limit: 20, null: false
t.string :state, limit: 20, null: false
diff --git a/db/migrate/20170321000001_ldap_support.rb b/db/migrate/20170321000001_ldap_support.rb
new file mode 100644
index 000000000..762f37b14
--- /dev/null
+++ b/db/migrate/20170321000001_ldap_support.rb
@@ -0,0 +1,91 @@
+class LdapSupport < ActiveRecord::Migration
+ def up
+
+ # return if it's a new setup
+ return if !Setting.find_by(name: 'system_init_done')
+
+ create_table :import_jobs do |t|
+ t.string :name, limit: 250, null: false
+
+ t.boolean :dry_run, default: false
+
+ t.text :payload, limit: 80_000
+ t.text :result, limit: 80_000
+
+ t.datetime :started_at
+ t.datetime :finished_at
+
+ t.timestamps null: false
+ end
+
+ Setting.create_or_update(
+ title: 'Authentication via %s',
+ name: 'auth_ldap',
+ area: 'Security::Authentication',
+ description: 'Enables user authentication via %s.',
+ preferences: {
+ title_i18n: ['LDAP'],
+ description_i18n: ['LDAP'],
+ permission: ['admin.security'],
+ },
+ state: {
+ adapter: 'Auth::Ldap',
+ login_attributes: %w(login email),
+ },
+ frontend: false
+ )
+
+ Setting.create_if_not_exists(
+ title: 'LDAP integration',
+ name: 'ldap_integration',
+ area: 'Integration::Switch',
+ description: 'Defines if LDAP is enabled or not.',
+ options: {
+ form: [
+ {
+ display: '',
+ null: true,
+ name: 'ldap_integration',
+ tag: 'boolean',
+ options: {
+ true => 'yes',
+ false => 'no',
+ },
+ },
+ ],
+ },
+ state: false,
+ preferences: {
+ prio: 1,
+ authentication: true,
+ permission: ['admin.integration'],
+ },
+ frontend: true
+ )
+ Setting.create_if_not_exists(
+ title: 'LDAP config',
+ name: 'ldap_config',
+ area: 'Integration::LDAP',
+ description: 'Defines the LDAP config.',
+ options: {},
+ state: {},
+ preferences: {
+ prio: 2,
+ permission: ['admin.integration'],
+ },
+ frontend: false,
+ )
+
+ Scheduler.create_or_update(
+ name: 'Import Jobs',
+ method: 'ImportJob.start',
+ period: 1.hour,
+ prio: 1,
+ active: true,
+ updated_by_id: 1,
+ created_by_id: 1
+ )
+
+ end
+
+end
diff --git a/db/seeds.rb b/db/seeds.rb
index eb3b59312..2665db4a9 100644
--- a/db/seeds.rb
+++ b/db/seeds.rb
@@ -2485,6 +2485,46 @@ Setting.create_if_not_exists(
},
frontend: false
)
+Setting.create_if_not_exists(
+ title: 'LDAP integration',
+ name: 'ldap_integration',
+ area: 'Integration::Switch',
+ description: 'Defines if LDAP is enabled or not.',
+ options: {
+ form: [
+ {
+ display: '',
+ null: true,
+ name: 'ldap_integration',
+ tag: 'boolean',
+ options: {
+ true => 'yes',
+ false => 'no',
+ },
+ },
+ ],
+ },
+ state: false,
+ preferences: {
+ prio: 1,
+ authentication: true,
+ permission: ['admin.integration'],
+ },
+ frontend: true
+)
+Setting.create_if_not_exists(
+ title: 'LDAP config',
+ name: 'ldap_config',
+ area: 'Integration::LDAP',
+ description: 'Defines the LDAP config.',
+ options: {},
+ state: {},
+ preferences: {
+ prio: 2,
+ permission: ['admin.integration'],
+ },
+ frontend: false,
+)
Setting.create_if_not_exists(
title: 'Defines sync transaction backend.',
name: '0100_trigger',
@@ -5344,21 +5384,21 @@ ObjectManager::Attribute.add(
Scheduler.create_if_not_exists(
name: 'Process pending tickets',
method: 'Ticket.process_pending',
- period: 60 * 15,
+ period: 15.minutes,
prio: 1,
active: true,
)
Scheduler.create_if_not_exists(
name: 'Process escalation tickets',
method: 'Ticket.process_escalation',
- period: 60 * 5,
+ period: 5.minutes,
prio: 1,
active: true,
)
Scheduler.create_if_not_exists(
name: 'Import OTRS diff load',
method: 'Import::OTRS.diff_worker',
- period: 60 * 3,
+ period: 3.minutes,
prio: 1,
active: true,
updated_by_id: 1,
@@ -5367,7 +5407,7 @@ Scheduler.create_if_not_exists(
Scheduler.create_if_not_exists(
name: 'Check Channels',
method: 'Channel.fetch',
- period: 30,
+ period: 30.seconds,
prio: 1,
active: true,
updated_by_id: 1,
@@ -5376,7 +5416,7 @@ Scheduler.create_if_not_exists(
Scheduler.create_if_not_exists(
name: 'Check streams for Channel',
method: 'Channel.stream',
- period: 60,
+ period: 60.seconds,
prio: 1,
active: true,
updated_by_id: 1,
@@ -5385,7 +5425,7 @@ Scheduler.create_if_not_exists(
Scheduler.create_if_not_exists(
name: 'Generate Session data',
method: 'Sessions.jobs',
- period: 60,
+ period: 60.seconds,
prio: 1,
active: true,
updated_by_id: 1,
@@ -5394,7 +5434,7 @@ Scheduler.create_if_not_exists(
Scheduler.create_if_not_exists(
name: 'Execute jobs',
method: 'Job.run',
- period: 5 * 60,
+ period: 5.minutes,
prio: 2,
active: true,
updated_by_id: 1,
@@ -5448,7 +5488,7 @@ Scheduler.create_or_update(
Scheduler.create_or_update(
name: 'Closed chat sessions where participients are offline.',
method: 'Chat.cleanup_close',
- period: 60 * 15,
+ period: 15.minutes,
prio: 2,
active: true,
updated_by_id: 1,
@@ -5493,12 +5533,21 @@ Scheduler.create_or_update(
Scheduler.create_if_not_exists(
name: 'Cleanup HttpLog',
method: 'HttpLog.cleanup',
- period: 24 * 60 * 60,
+ period: 1.day,
prio: 2,
active: true,
updated_by_id: 1,
created_by_id: 1,
)
+Scheduler.create_if_not_exists(
+ name: 'Import Jobs',
+ method: 'ImportJob.start',
+ period: 1.hour,
+ prio: 1,
+ active: true,
+ updated_by_id: 1,
+ created_by_id: 1
+)
Trigger.create_or_update(
name: 'auto reply (on new tickets)',
diff --git a/lib/auth.rb b/lib/auth.rb
index 3a4c9ecae..6ade62a6a 100644
--- a/lib/auth.rb
+++ b/lib/auth.rb
@@ -5,17 +5,83 @@ class Auth
=begin
-authenticate user via username and password
+checks if a given user can login. Checks for
+ - valid user
+ - active state
+ - max failed logins
- result = Auth.check(username, password, user)
+ result = Auth.can_login?(user)
returns
- result = user_model # if authentication was successfully
+ result = true | false
=end
- def self.check(username, password, user)
+ def self.can_login?(user)
+ return false if !user.is_a?(User)
+ return false if !user.active?
+
+ return true if !user.max_login_failed?
+ Rails.logger.info "Max login failed reached for user #{user.login}."
+
+ false
+ end
+
+=begin
+
+checks if a given user and password match against multiple auth backends
+ - valid user
+ - active state
+ - max failed logins
+
+ result = Auth.valid?(user, password)
+
+returns
+
+ result = true | false
+
+=end
+
+ def self.valid?(user, password)
+ # try to login against configure auth backends
+ backends.any? do |config|
+ next if !backend_validates?(
+ config: config,
+ user: user,
+ password: password,
+ )
+
+ Rails.logger.info "Authentication against #{config[:adapter]} for user #{user.login} ok."
+
+ # remember last login date
+ user.update_last_login
+
+ true
+ end
+ end
+
+=begin
+
+returns a list of all Auth backend configurations
+
+ result = Auth.backends
+
+returns
+
+ result = [
+ {
+ adapter: 'Auth::Internal',
+ },
+ {
+ adapter: 'Auth::Developer',
+ },
+ ...
+ ]
+
+=end
+
+ def self.backends
# use std. auth backends
config = [
@@ -28,33 +94,24 @@ returns
]
# added configured backends
- Setting.where(area: 'Security::Authentication').each { |setting|
- if setting.state_current[:value]
- config.push setting.state_current[:value]
- end
- }
+ Setting.where(area: 'Security::Authentication').each do |setting|
+ next if setting.state_current[:value].blank?
+ config.push setting.state_current[:value]
+ end
- # try to login against configure auth backends
- user_auth = nil
- config.each { |config_item|
- next if !config_item[:adapter]
-
- # load backend
- backend = load_adapter(config_item[:adapter])
- next if !backend
-
- user_auth = backend.check(username, password, config_item, user)
-
- # auth not ok
- next if !user_auth
-
- Rails.logger.info "Authentication against #{config_item[:adapter]} for user #{user_auth.login} ok."
-
- # remember last login date
- user_auth.update_last_login
-
- return user_auth
- }
- nil
+ config
end
+
+ def self.backend_validates?(config:, user:, password:)
+ return false if !config[:adapter]
+
+ # load backend
+ backend = load_adapter(config[:adapter])
+ return false if !backend
+
+ instance = backend.new(config)
+
+ instance.valid?(user, password)
+ end
+ private_class_method :backend_validates?
end
diff --git a/lib/auth/base.rb b/lib/auth/base.rb
new file mode 100644
index 000000000..2bc661534
--- /dev/null
+++ b/lib/auth/base.rb
@@ -0,0 +1,14 @@
+# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
+
+class Auth
+ class Base
+
+ def initialize(config)
+ @config = config
+ end
+
+ def valid?(_user, _password)
+ raise "Missing implementation of method 'valid?' for class '#{self.class.name}'"
+ end
+ end
+end
diff --git a/lib/auth/developer.rb b/lib/auth/developer.rb
index dd2f566a1..99476113d 100644
--- a/lib/auth/developer.rb
+++ b/lib/auth/developer.rb
@@ -1,14 +1,14 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
-module Auth::Developer
- def self.check(username, password, _config, user)
+class Auth
+ class Developer < Auth::Base
- # development systems
- return false if !username
- return false if !user
- return false if Setting.get('developer_mode') != true
- return false if password != 'test'
- Rails.logger.info "System in developer mode, authentication for user #{user.login} ok."
- user
+ def valid?(user, password)
+ return false if user.blank?
+ return false if Setting.get('developer_mode') != true
+ return false if password != 'test'
+ Rails.logger.info "System in developer mode, authentication for user #{user.login} ok."
+ true
+ end
end
end
diff --git a/lib/auth/internal.rb b/lib/auth/internal.rb
index b8a26a751..5da889903 100644
--- a/lib/auth/internal.rb
+++ b/lib/auth/internal.rb
@@ -1,30 +1,25 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
-module Auth::Internal
+class Auth
+ class Internal < Auth::Base
- # rubocop:disable Style/ModuleFunction
- extend self
+ def valid?(user, password)
- def check(username, password, _config, user)
+ return false if user.blank?
- # return if no user exists
- return false if !username
- return false if !user
+ if PasswordHash.legacy?(user.password, password)
+ update_password(user, password)
+ return true
+ end
- if PasswordHash.legacy?(user.password, password)
- update_password(user, password)
- return user
+ PasswordHash.verified?(user.password, password)
end
- return false if !PasswordHash.verified?(user.password, password)
+ private
- user
- end
-
- private
-
- def update_password(user, password)
- user.password = PasswordHash.crypt(password)
- user.save
+ def update_password(user, password)
+ user.password = PasswordHash.crypt(password)
+ user.save
+ end
end
end
diff --git a/lib/auth/ldap.rb b/lib/auth/ldap.rb
index 686bc7a3d..475cf3be4 100644
--- a/lib/auth/ldap.rb
+++ b/lib/auth/ldap.rb
@@ -1,124 +1,60 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
-require 'net/ldap'
+require 'ldap'
+require 'ldap/user'
-module Auth::Ldap
- def self.check(username, password, config, user)
+class Auth
+ class Ldap < Auth::Base
- scope = Net::LDAP::SearchScope_WholeSubtree
+ def valid?(user, password)
+ return false if !Setting.get('ldap_integration')
+ ldap_user = ::Ldap::User.new()
- # ldap connect
- ldap = Net::LDAP.new( host: config[:host], port: config[:port] )
+ # get from config or fallback to login
+ # for a list of user attributes which should
+ # be used for logging in
+ login_attributes = @config[:login_attributes] || %w(login)
- # set auth data if needed
- if config[:bind_dn] && config[:bind_pw]
- ldap.auth config[:bind_dn], config[:bind_pw]
- end
-
- # ldap bind
- begin
- if !ldap.bind
- Rails.logger.info "Can't bind to '#{config[:host]}', #{ldap.get_operation_result.code}, #{ldap.get_operation_result.message}"
- return
+ authed = login_attributes.any? do |attribute|
+ ldap_user.valid?(user[attribute], password)
end
+
+ log_auth_result(user, authed)
+ authed
rescue => e
- Rails.logger.info "Can't connect to '#{config[:host]}', #{e}"
- return
+ message = "Can't connect to ldap backend, #{e}"
+ Rails.logger.info message
+ log(
+ user: user,
+ status: 'failed',
+ response: message,
+ )
+ false
end
- # search user
- filter = "(#{config[:uid]}=#{username})"
- if config[:always_filter] && !config[:always_filter].empty?
- filter = "({filter}#{config[:always_filter]})"
- end
- user_dn = nil
- user_data = {}
- ldap.search( base: config[:base], filter: filter, scope: scope ) do |entry|
- user_data = {}
- user_dn = entry.dn
+ private
- # remember attributes for :sync_params
- entry.each do |attribute, values|
- user_data[ attribute.downcase.to_sym ] = ''
- values.each do |value|
- user_data[ attribute.downcase.to_sym ] = value
- end
- end
+ def log_auth_result(user, authed)
+ result = authed ? 'success' : 'failed'
+ log(
+ user: user,
+ status: result,
+ )
end
- if user_dn.nil?
- Rails.logger.info "ldap entry found for user '#{username}' with filter #{filter} failed!"
- return nil
- end
-
- # try ldap bind with user credentals
- auth = ldap.authenticate user_dn, password
- if !ldap.bind( auth )
- Rails.logger.info "ldap bind with '#{user_dn}' failed!"
- return false
- end
-
- # create/update user
- if config[:sync_params]
- user_attributes = {
- source: 'ldap',
+ def log(user:, status:, response: nil)
+ HttpLog.create(
+ direction: 'out',
+ facility: 'ldap',
+ url: "bind -> #{user.login}",
+ status: status,
+ ip: nil,
+ request: { content: user.login },
+ response: { content: response || status },
+ method: 'tcp',
+ created_by_id: 1,
updated_by_id: 1,
- }
- config[:sync_params].each { |local_data, ldap_data|
- if user_data[ ldap_data.downcase.to_sym ]
- user_attributes[ local_data.downcase.to_sym] = user_data[ ldap_data.downcase.to_sym ]
- end
- }
- if !user
- user_attributes[:created_by_id] = 1
- user = User.create( user_attributes )
- Rails.logger.debug "user created '#{user.login}'"
- else
- user.update_attributes( user_attributes )
- Rails.logger.debug "user updated '#{user.login}'"
- end
+ )
end
-
- # return if it was not possible to create user
- return if !user
-
- # sync roles
- # FIXME
-
- # sync groups
- # FIXME
-
- # set always roles
- if config[:always_roles]
- role_ids = user.role_ids
- config[:always_roles].each { |role_name|
- role = Role.where( name: role_name ).first
- next if !role
- if !role_ids.include?( role.id )
- role_ids.push role.id
- end
- }
- user.role_ids = role_ids
- user.save
- end
-
- # set always groups
- if config[:always_groups]
- group_ids = user.group_ids
- config[:always_groups].each { |group_name|
- group = Group.where( name: group_name ).first
- next if !group
- if !group_ids.include?( group.id )
- group_ids.push group.id
- end
- }
- user.group_ids = group_ids
- user.save
- end
-
- # take session down
- # - not needed, done by Net::LDAP -
-
- user
end
end
diff --git a/lib/core_ext/activesupport/lib/active_support/logger.rb b/lib/core_ext/activesupport/lib/active_support/logger.rb
new file mode 100644
index 000000000..70213ee8a
--- /dev/null
+++ b/lib/core_ext/activesupport/lib/active_support/logger.rb
@@ -0,0 +1,29 @@
+# This customization provides the possiblity to log exception backtraces via the Rails.logger.
+#
+# @example:
+# begin
+# instance = "String :)"
+# instance.invalid_method
+# rescue => e
+# Rails.logger.error e
+# end
+# #=> undefined method `invalid_method' for "String :)":String
+# # ... backtrace ...
+# https://github.com/rails/rails/blob/308e84e982b940983b4b3d5b41b0b3ac11fbae40/activesupport/lib/active_support/logger.rb#L101
+module ActiveSupport
+ class Logger < ::Logger
+ class SimpleFormatter < ::Logger::Formatter
+ # original behaviour:
+ # rubocop:disable Lint/UnusedMethodArgument, Style/CaseEquality
+ # This method is invoked when a log event occurs
+ def call(severity, timestamp, progname, msg)
+ return "#{String === msg ? msg : msg.inspect}\n" if !msg.is_a?(Exception)
+ # rubocop:enable Lint/UnusedMethodArgument, Style/CaseEquality
+ # custom -> print only the message if no backtrace is present
+ return "#{msg.message}\n" if !msg.backtrace
+ # otherwise combination of message and backtrace
+ "#{msg.message}\n#{msg.backtrace.join("\n")}\n"
+ end
+ end
+ end
+end
diff --git a/lib/core_ext/net/ldap/entry.rb b/lib/core_ext/net/ldap/entry.rb
new file mode 100644
index 000000000..b5cd977dc
--- /dev/null
+++ b/lib/core_ext/net/ldap/entry.rb
@@ -0,0 +1,19 @@
+# Extends the 'net/ldap' class Net::LDAP::Entry
+# without overwriting methods.
+class Net::LDAP::Entry
+
+ # Creates a duplicate of the internal Hash containing the
+ # attributes of the entry.
+ #
+ # @see Net::LDAP::Entry#initialize
+ # @see Net::LDAP::Entry#attribute_names
+ #
+ # @example get the Hash
+ # entry.to_h
+ # #=> {dn: ['...'], another_attribute: ['...', ...], ...}
+ #
+ # @return [Hash{Symbol=>Array}] A duplicate of the internal Hash with the entries attributes.
+ def to_h
+ @myhash.dup
+ end
+end
diff --git a/lib/email_helper.rb b/lib/email_helper.rb
index d7dac17aa..c9537c84f 100644
--- a/lib/email_helper.rb
+++ b/lib/email_helper.rb
@@ -611,8 +611,7 @@ returns
}
end
rescue => e
- Rails.logger.error e.message
- Rails.logger.error e.backtrace.inspect
+ Rails.logger.error e
end
mxs
end
diff --git a/lib/import/base_resource.rb b/lib/import/base_resource.rb
index e76a17528..ed115ebd9 100644
--- a/lib/import/base_resource.rb
+++ b/lib/import/base_resource.rb
@@ -2,26 +2,62 @@ module Import
class BaseResource
include Import::Helper
+ attr_reader :resource, :remote_id, :errors
+
def initialize(resource, *args)
+ handle_args(resource, *args)
import(resource, *args)
end
def import_class
- raise "#{self.class.name} has no implmentation of the needed 'import_class' method"
+ raise NoMethodError, "#{self.class.name} has no implementation of the needed 'import_class' method"
end
def source
- raise "#{self.class.name} has no implmentation of the needed 'source' method"
+ import_class_namespace
end
def remote_id(resource, *_args)
@remote_id ||= resource.delete(:id)
end
+ def action
+ return :failed if errors.present?
+ return :skipped if !@resource
+ return :unchanged if !attributes_changed?
+ return :created if created?
+ :updated
+ end
+
+ def attributes_changed?
+ return true if changed_attributes.present?
+ @associations_init != associations_state(@resource)
+ end
+
+ def changed_attributes
+ return if @resource.blank?
+ # dry run
+ return @resource.changes if @resource.changed?
+ # live run
+ @resource.previous_changes
+ end
+
+ def created?
+ return false if @resource.blank?
+ # dry run
+ return @resource.created_at.nil? if @resource.changed?
+ # live run
+ @resource.created_at == @resource.updated_at
+ end
+
private
def import(resource, *args)
create_or_update(map(resource, *args), *args)
+ rescue => e
+ # Don't catch own thrown exceptions from above
+ raise if e.is_a?(NoMethodError)
+ handle_error(e)
end
def create_or_update(resource, *args)
@@ -32,39 +68,78 @@ module Import
def updated?(resource, *args)
@resource = lookup_existing(resource, *args)
return false if !@resource
- @resource.update_attributes!(resource)
- post_update(
- instance: @resource,
- attributes: resource
- )
+
+ # delete since we have an update and
+ # the record is already created
+ resource.delete(:created_by_id)
+
+ @resource.assign_attributes(resource)
+
+ return true if @dry_run
+ @resource.save
true
end
def lookup_existing(resource, *_args)
- instance = ExternalSync.find_by(
+ synced_instance = ExternalSync.find_by(
source: source,
source_id: remote_id(resource),
object: import_class.name,
)
- return if !instance
- import_class.find_by(id: instance.o_id)
+ return if !synced_instance
+ instance = import_class.find_by(id: synced_instance.o_id)
+
+ store_associations_state(instance)
+
+ instance
+ end
+
+ def store_associations_state(instance)
+ @associations_init = associations_state(instance)
+ end
+
+ def associations_state(instance)
+ state = {}
+ tracked_associations.each do |association|
+ state[association] = instance.send(association)
+ end
+ state
+ end
+
+ def tracked_associations
+ # loop over all reflections
+ import_class.reflect_on_all_associations.collect do |reflection|
+ # refection name is something like groups or organization (singular/plural)
+ reflection_name = reflection.name.to_s
+ # key is something like group_id or organization_id (singular)
+ key = reflection.klass.name.foreign_key
+
+ # add trailing 's' to get pluralized key
+ if reflection_name.singularize != reflection_name
+ key = "#{key}s"
+ end
+
+ key.to_sym
+ end
end
def create(resource, *_args)
@resource = import_class.new(resource)
- @resource.save!
+ return if @dry_run
+ @resource.save
+ external_sync_create(
+ local: @resource,
+ remote: resource,
+ )
+ end
+ def external_sync_create(local:, remote:)
ExternalSync.create(
source: source,
- source_id: remote_id(resource),
+ source_id: remote_id(remote),
object: import_class.name,
- o_id: @resource.id
- )
-
- post_create(
- instance: @resource,
- attributes: resource
+ o_id: local.id
)
end
@@ -82,7 +157,8 @@ module Import
end
def from_mapping(resource, *args)
- return resource if !mapping(*args)
+ mapping = mapping(*args)
+ return resource if !mapping
ExternalSync.map(
mapping: mapping,
@@ -95,13 +171,31 @@ module Import
end
def mapping_config(*_args)
- self.class.name.to_s.sub('Import::', '').gsub('::', '_').underscore + '_mapping'
+ import_class_namespace.gsub('::', '_').underscore + '_mapping'
end
- def post_create(_args)
+ def import_class_namespace
+ self.class.name.to_s.sub('Import::', '')
end
- def post_update(_args)
+ def handle_args(_resource, *args)
+ return if !args
+ return if !args.is_a?(Array)
+ return if args.empty?
+
+ last_arg = args.last
+ return if !last_arg.is_a?(Hash)
+ handle_modifiers(last_arg)
+ end
+
+ def handle_modifiers(modifiers)
+ @dry_run = modifiers.fetch(:dry_run, false)
+ end
+
+ def handle_error(e)
+ @errors ||= []
+ @errors.push(e)
+ Rails.logger.error e
end
end
end
diff --git a/lib/import/ldap.rb b/lib/import/ldap.rb
new file mode 100644
index 000000000..632aeba2a
--- /dev/null
+++ b/lib/import/ldap.rb
@@ -0,0 +1,31 @@
+require 'ldap'
+require 'ldap/group'
+
+module Import
+ class Ldap
+
+ def initialize(import_job)
+ @import_job = import_job
+
+ if !Setting.get('ldap_integration') && !@import_job.dry_run
+ raise "LDAP integration deactivated, check Setting 'ldap_integration'."
+ end
+
+ start_import
+ end
+
+ private
+
+ def start_import
+ Import::Ldap::UserFactory.reset_statistics
+
+ Import::Ldap::UserFactory.import(
+ config: @import_job.payload,
+ dry_run: @import_job.dry_run,
+ import_job: @import_job
+ )
+
+ @import_job.result = Import::Ldap::UserFactory.statistics
+ end
+ end
+end
diff --git a/lib/import/ldap/user.rb b/lib/import/ldap/user.rb
new file mode 100644
index 000000000..26525b449
--- /dev/null
+++ b/lib/import/ldap/user.rb
@@ -0,0 +1,193 @@
+module Import
+ class Ldap
+ class User < Import::ModelResource
+
+ def remote_id(_resource, *_args)
+ @remote_id
+ end
+
+ private
+
+ def import(resource, *args)
+ normalized_entry = normalize_entry(resource)
+
+ # extract the uid attribute and store it as
+ # the remote ID so we can access later
+ # when working with ExternalSync
+ @remote_id = normalized_entry[ @ldap_config[:user_uid].to_sym ]
+
+ super(normalized_entry, *args)
+ end
+
+ def normalize_entry(resource)
+ normalized_entry = resource.to_h
+
+ normalized_entry.each do |key, values|
+ normalized_entry[key] = values.first
+ end
+
+ normalized_entry
+ end
+
+ def create_or_update(resource, *args)
+ return if skip?(resource)
+ resource[:role_ids] = role_ids(resource)
+ result = super(resource, *args)
+
+ ldap_log(
+ action: "#{action} -> #{@resource.login}",
+ status: 'success',
+ request: resource,
+ )
+
+ result
+ end
+
+ def skip?(resource)
+ return true if resource[:login].blank?
+
+ # skip resource if only ignored attributes are set
+ ignored_attributes = %i(login dn created_by_id updated_by_id)
+ !resource.except(*ignored_attributes).values.any?(&:present?)
+ end
+
+ def role_ids(resource)
+ # remove remporary added and get value
+ dn = resource.delete(:dn)
+ # use signup roles if no dn is present
+ return @signup_role_ids if !dn
+ # check if roles are mapped for the found dn
+ roles = @dn_roles[ dn.downcase ]
+ # use signup roles if no mapped roles were found
+ return @signup_role_ids if !roles
+ # return found roles
+ roles
+ end
+
+ def updated?(resource, *_args)
+ super
+ rescue => e
+ ldap_log(
+ action: "update -> #{resource[:login]}",
+ status: 'failed',
+ request: resource,
+ response: e.message,
+ )
+ raise
+ end
+
+ def lookup_existing(resource, *args)
+ instance = super
+
+ return instance if instance.present?
+
+ # in some cases the User will get created in
+ # Zammad before it's created in the LDAP
+ # therefore we have to make a local lookup, too
+ instance = local_lookup(resource)
+
+ # create an external sync entry to connect
+ # the LDAP and local account for future runs
+ if instance.present?
+ external_sync_create(
+ local: instance,
+ remote: resource,
+ )
+
+ store_associations_state(instance)
+ end
+
+ instance
+ end
+
+ def local_lookup(resource, *_args)
+ instance = import_class.identify(@remote_id)
+
+ if instance.blank?
+ checked_values = [@remote_id]
+ %i(login email).each do |attribute|
+ check_value = resource[attribute]
+ next if check_value.blank?
+ next if checked_values.include?(check_value)
+ instance = import_class.identify(check_value)
+ break if instance.present?
+ checked_values.push(check_value)
+ end
+ end
+ instance
+ end
+
+ def tracked_associations
+ [:role_ids]
+ end
+
+ def create(resource, *_args)
+ super
+ rescue => e
+ ldap_log(
+ action: "create -> #{resource[:login]}",
+ status: 'failed',
+ request: resource,
+ response: e.message,
+ )
+ raise
+ end
+
+ def map(_resource, *_args)
+ mapped = super
+
+ # we have to manually downcase the login and email
+ # to avoid wrong attribute change detection
+ %i(login email).each do |attribute|
+ next if mapped[attribute].blank?
+ mapped[attribute] = mapped[attribute].downcase
+ end
+
+ mapped
+ end
+
+ def mapping(*_args)
+ @mapping ||= begin
+ mapping = @ldap_config[:user_attributes]
+
+ # add temporary dn to mapping so we can use it
+ # for the role lookup later and delete it afterwards
+ mapping['dn'] = 'dn'
+
+ # fallback to uid if no login is given via mapping
+ if !mapping.values.include?('login')
+ mapping[ @ldap_config[:user_uid] ] = 'login'
+ end
+
+ mapping
+ end
+ end
+
+ def handle_args(resource, *args)
+ @ldap_config = args.shift
+ @dn_roles = args.shift
+ @signup_role_ids = args.shift
+
+ super(resource, *args)
+ end
+
+ def ldap_log(action:, status:, request:, response: nil)
+ return if @dry_run
+
+ HttpLog.create(
+ direction: 'out',
+ facility: 'ldap',
+ url: action,
+ status: status,
+ ip: nil,
+ request: { content: request.to_json },
+ response: { message: response || status },
+ method: 'tcp',
+ created_by_id: 1,
+ updated_by_id: 1,
+ )
+ end
+
+ end
+ end
+end
diff --git a/lib/import/ldap/user_factory.rb b/lib/import/ldap/user_factory.rb
new file mode 100644
index 000000000..3aa4031bf
--- /dev/null
+++ b/lib/import/ldap/user_factory.rb
@@ -0,0 +1,99 @@
+module Import
+ class Ldap
+ module UserFactory
+ extend Import::StatisticalFactory
+
+ def self.import(config: nil, ldap: nil, **kargs)
+
+ config ||= Setting.get('ldap_config')
+ ldap ||= ::Ldap.new(config)
+
+ @config = config
+ @ldap = ldap
+
+ user_roles = user_roles(ldap: @ldap, config: config)
+ signup_role_ids = Role.signup_role_ids.sort
+
+ @dry_run = kargs[:dry_run]
+ pre_import_hook([], config, user_roles, signup_role_ids, kargs)
+
+ import_job = kargs[:import_job]
+ import_job_count = 0
+ @ldap.search(config[:user_filter]) do |entry|
+ backend_instance = create_instance(entry, config, user_roles, signup_role_ids, kargs)
+ post_import_hook(entry, backend_instance, config, user_roles, signup_role_ids, kargs)
+
+ next if import_job.blank?
+ import_job_count += 1
+ next if import_job_count < 100
+
+ import_job.result = @statistics
+ import_job.save
+
+ import_job_count = 0
+ end
+
+ end
+
+ def self.pre_import_hook(_records, *_args)
+ super
+
+ #cache_key = "#{@ldap.host}::#{@ldap.port}::#{@ldap.ssl}::#{@ldap.base_dn}"
+ #if !@dry_run
+ # sum = Cache.get(cache_key)
+ #end
+
+ sum ||= @ldap.count(@config[:user_filter])
+
+ @statistics[:sum] = sum
+
+ return if !@dry_run
+ #Cache.write(cache_key, sum, { expires_in: 1.hour })
+ end
+
+ def self.add_to_statistics(backend_instance)
+ super
+
+ # no need to count if no resource was created
+ resource = backend_instance.resource
+ return if resource.blank?
+
+ action = backend_instance.action
+
+ known_actions = {
+ created: 0,
+ updated: 0,
+ unchanged: 0,
+ failed: 0,
+ }
+
+ if !@statistics[:role_ids]
+ @statistics[:role_ids] = {}
+ end
+
+ resource.role_ids.each do |role_id|
+
+ next if !known_actions.key?(action)
+
+ @statistics[:role_ids][role_id] ||= known_actions.dup
+
+ # exit early if we have an unloggable action
+ break if @statistics[:role_ids][role_id][action].nil?
+
+ @statistics[:role_ids][role_id][action] += 1
+ end
+
+ action
+ end
+
+ def self.user_roles(ldap:, config:)
+ group_config = {
+ filter: config[:group_filter]
+ }
+
+ ldap_group = ::Ldap::Group.new(group_config, ldap: ldap)
+ ldap_group.user_roles(config[:group_role_map])
+ end
+ end
+ end
+end
diff --git a/lib/import/model_resource.rb b/lib/import/model_resource.rb
index 3c3c23398..3313d2535 100644
--- a/lib/import/model_resource.rb
+++ b/lib/import/model_resource.rb
@@ -11,8 +11,12 @@ module Import
private
- def post_create(_args)
- reset_primary_key_sequence(model_name.underscore.pluralize)
+ def create(resource, *_args)
+ result = super
+ if !@dry_run
+ reset_primary_key_sequence(model_name.underscore.pluralize)
+ end
+ result
end
end
end
diff --git a/lib/import/otrs/async.rb b/lib/import/otrs/async.rb
index fd54b1a3a..0e7d22641 100644
--- a/lib/import/otrs/async.rb
+++ b/lib/import/otrs/async.rb
@@ -28,8 +28,7 @@ module Import
rescue => e
status_update_thread.exit
status_update_thread.join
- Rails.logger.error e.message
- Rails.logger.error e.backtrace.inspect
+ Rails.logger.error e
result = {
message: e.message,
result: 'error',
diff --git a/lib/import/statistical_factory.rb b/lib/import/statistical_factory.rb
new file mode 100644
index 000000000..1b882d87c
--- /dev/null
+++ b/lib/import/statistical_factory.rb
@@ -0,0 +1,37 @@
+module Import
+ module StatisticalFactory
+ include Import::Factory
+
+ # rubocop:disable Style/ModuleFunction
+ extend self
+
+ attr_reader :statistics
+
+ def import(records, *args)
+ super
+ end
+
+ def reset_statistics
+ @statistics = {
+ skipped: 0,
+ created: 0,
+ updated: 0,
+ unchanged: 0,
+ failed: 0,
+ }
+ end
+
+ def pre_import_hook(_records, *_args)
+ reset_statistics if @statistics.blank?
+ end
+
+ def post_import_hook(_record, backend_instance, *_args)
+ add_to_statistics(backend_instance)
+ end
+
+ def add_to_statistics(backend_instance)
+ action = backend_instance.action
+ @statistics[action] += 1
+ end
+ end
+end
diff --git a/lib/import/zendesk/async.rb b/lib/import/zendesk/async.rb
index 31df71b68..77b7bc3b6 100644
--- a/lib/import/zendesk/async.rb
+++ b/lib/import/zendesk/async.rb
@@ -31,8 +31,7 @@ module Import
rescue => e
status_update_thread.exit
status_update_thread.join
- Rails.logger.error e.message
- Rails.logger.error e.backtrace.inspect
+ Rails.logger.error e
result = {
message: e.message,
result: 'error',
diff --git a/lib/ldap.rb b/lib/ldap.rb
new file mode 100644
index 000000000..f96398c08
--- /dev/null
+++ b/lib/ldap.rb
@@ -0,0 +1,206 @@
+# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
+require 'net/ldap'
+require 'net/ldap/entry'
+
+# Class for establishing LDAP connections. A wrapper around Net::LDAP needed for Auth and Sync.
+# ATTENTION: Loads custom 'net/ldap/entry' from 'lib/core_ext' which extends the Net::LDAP::Entry class.
+#
+# @!attribute [r] connection
+# @return [Net::LDAP] the Net::LDAP instance with the established connection
+# @!attribute [r] base_dn
+# @return [String] the base dn used while initializing the connection
+class Ldap
+
+ attr_reader :connection, :base_dn, :host, :port, :ssl
+
+ # Initializes a LDAP connection.
+ #
+ # @param [Hash] config the configuration for establishing a LDAP connection. Default is Setting 'ldap_config'.
+ # @option config [String] :host_url The LDAP host URL in the format '*protocol*://*host*:*port*'.
+ # @option config [String] :host The LDAP explicit host. May contain the port. Gets overwritten by host_url if given.
+ # @option config [Number] :port The LDAP port. Default is 389 LDAP or 636 for LDAPS. Gets overwritten by host_url if given.
+ # @option config [Boolean] :ssl The LDAP SSL setting. Is set automatically for 'ldaps' protocol. Sets Port to 636 if non other is given.
+ # @option config [String] :base_dn The base DN searches etc. are applied to.
+ # @option config [String] :bind_user The username which should be used for bind.
+ # @option config [String] :bind_pw The password which should be used for bind.
+ #
+ # @example
+ # ldap = Ldap.new
+ #
+ # @return [nil]
+ def initialize(config = nil)
+ @config = config
+
+ if @config.blank?
+ @config = Setting.get('ldap_config')
+ end
+
+ connect
+ end
+
+ # Requests the rootDSE (the root of the directory data tree on a directory server).
+ #
+ # @example
+ # ldap.preferences
+ # #=> [:namingcontexts=>["DC=domain,DC=tld", "CN=Configuration,DC=domain,DC=tld"], :supportedldapversion=>["3", "2"], ...]
+ #
+ # @return [Hash{String => Array}] The found RootDSEs.
+ def preferences
+ @connection.search_root_dse.to_h
+ end
+
+ # Performs a LDAP search and yields over the found LDAP entries.
+ #
+ # @param filter [String] The filter that should get applied to the search.
+ # @param base [String] The base DN on which the search should get executed. Default is initialization parameter.
+ # @param scope [Net::LDAP::SearchScope] The search scope as defined in Net::LDAP SearchScopes. Default is WholeSubtree.
+ #
+ # @example
+ # ldap.search('(objectClass=group)') do |entry|
+ # p entry
+ # end
+ # #=>
+ #
+ # @return [true] Returns always true
+ def search(filter, base: nil, scope: nil)
+
+ base ||= base_dn()
+ scope ||= Net::LDAP::SearchScope_WholeSubtree
+
+ @connection.search(
+ base: base,
+ filter: filter,
+ scope: scope,
+ return_result: false, # improves performance
+ ) do |entry|
+ # needed for the #entries? method -> returns nil on break
+ break if !block_given?
+ yield entry
+ end
+ end
+
+ # Checks if there are any entries for the given search criteria.
+ #
+ # @param (see Ldap#search)
+ #
+ # @example
+ # ldap.entries?('(objectClass=group)')
+ # #=> true
+ #
+ # @return [Boolean] Returns true if entries are present false if not.
+ def entries?(*args)
+ # since #search returns nil if entries are found (due to the break in the yield block)
+ # and returns true otherwise we have to invert the result which matches the
+ # expected result of a ...? method and suites our needs since it checks one entry max.
+ !search(*args)
+ end
+
+ # Counts the entries for the given search criteria.
+ #
+ # @param (see Ldap#search)
+ #
+ # @example
+ # ldap.entries?('(objectClass=group)')
+ # #=> 10
+ #
+ # @return [Number] The count of matching entries.
+ def count(*args)
+ counter = 0
+ search(*args) do |_entry|
+ counter += 1
+ end
+ counter
+ end
+
+ private
+
+ def connect
+ @connection ||= begin
+ attributes_from_config
+ binded_connection
+ end
+ end
+
+ def binded_connection
+ # ldap connect
+ ldap = Net::LDAP.new(connection_params)
+
+ # set auth data if needed
+ if @bind_user && @bind_pw
+ ldap.auth @bind_user, @bind_pw
+ end
+
+ return ldap if ldap.bind
+
+ result = ldap.get_operation_result
+ raise Exceptions::UnprocessableEntity, "Can't bind to '#{@host}', #{result.code}, #{result.message}"
+ rescue => e
+ raise Exceptions::UnprocessableEntity, "Can't connect to '#{@host}' on port '#{@port}', #{e}"
+ end
+
+ def connection_params
+ params = {
+ host: @host,
+ port: @port,
+ }
+
+ if @encryption
+ params[:encryption] = @encryption
+ end
+
+ params
+ end
+
+ def attributes_from_config
+ # might change below
+ @host = @config[:host]
+ @port = @config[:port]
+ @ssl = @config.fetch(:ssl, false)
+
+ parse_host_url
+ parse_host
+ handle_ssl_config
+ handle_bind_crendentials
+
+ @base_dn = @config[:base_dn]
+
+ # fallback to default
+ # port if none given
+ @port ||= 389
+ end
+
+ def parse_host_url
+ @host_url = @config[:host_url]
+ return if @host_url.blank?
+ raise "Invalid host url '#{@host_url}'" if @host_url !~ %r{\A([^:]+)://(.+?)/?\z}
+ @protocol = $1.to_sym
+ @host = $2
+ @ssl = @protocol == :ldaps
+ end
+
+ def parse_host
+ return if @host !~ /\A([^:]+):(.+?)\z/
+ @host = $1
+ @port = $2.to_i
+ end
+
+ def handle_ssl_config
+ return if !@ssl
+ @port ||= @config.fetch(:port, 636)
+ @encryption = {
+ method: :simple_tls,
+ }
+
+ if !@config[:ssl_verify]
+ @encryption[:tls_options] = {
+ verify_mode: OpenSSL::SSL::VERIFY_NONE
+ }
+ end
+ end
+
+ def handle_bind_crendentials
+ @bind_user = @config[:bind_user]
+ @bind_pw = @config[:bind_pw]
+ end
+
+end
diff --git a/lib/ldap/filter_lookup.rb b/lib/ldap/filter_lookup.rb
new file mode 100644
index 000000000..ee0680758
--- /dev/null
+++ b/lib/ldap/filter_lookup.rb
@@ -0,0 +1,22 @@
+class Ldap
+ module FilterLookup
+
+ # Returns the first of a list of filters which has entries.
+ #
+ # @example
+ # instance.lookup_filter(['filter1', 'filter2'])
+ # #=> 'filter2'
+ #
+ # @return [String, nil] The first filter with entries or nil.
+ def lookup_filter(possible_filters)
+ result = nil
+ possible_filters.each do |possible_filter|
+ next if !@ldap.entries?(possible_filter)
+ result = possible_filter
+ break
+ end
+ result
+ end
+
+ end
+end
diff --git a/lib/ldap/group.rb b/lib/ldap/group.rb
new file mode 100644
index 000000000..d7c5f2bf7
--- /dev/null
+++ b/lib/ldap/group.rb
@@ -0,0 +1,134 @@
+class Ldap
+
+ # Class for handling LDAP Groups.
+ # ATTENTION: Make sure to add the following lines to your code if accessing this class.
+ # Otherwise Rails will autoload the Group model or might throw parameter errors if crearing
+ # an ::Ldap instance.
+ #
+ # @example
+ # require 'ldap'
+ # require 'ldap/group'
+ class Group
+ include Ldap::FilterLookup
+
+ # Returns the uid attribute.
+ #
+ # @example
+ # Ldap::Group.uid_attribute
+ #
+ # @return [String] The uid attribute.
+ def self.uid_attribute
+ 'dn'
+ end
+
+ # Initializes a wrapper around Net::LDAP and ::Ldap to handle LDAP groups.
+ #
+ # @param [Hash] config the configuration for establishing a LDAP connection. Default is Setting 'ldap_config'.
+ # @option config [String] :uid_attribute The uid attribute. Default is determined automatically.
+ # @option config [String] :filter The filter for LDAP groups. Default is determined automatically.
+ # @param ldap [Ldap] An optional existing Ldap class instance. Default is a new connection with given configuration.
+ #
+ # @example
+ # require 'ldap'
+ # require 'ldap/group'
+ # ldap_group = Ldap::Group.new
+ #
+ # @return [nil]
+ def initialize(config = nil, ldap: nil)
+ @ldap = ldap || ::Ldap.new(config)
+
+ handle_config(config)
+ end
+
+ # Lists available LDAP groups.
+ #
+ # @param filter [String] The filter for listing groups. Default is initialization parameter.
+ # @param base_dn [String] The applied base DN for listing groups. Default is Ldap#base_dn.
+ #
+ # @example
+ # ldap_group.list
+ # #=> {"cn=zamamd role admin,ou=zamamd groups,ou=test,dc=domain,dc=tld"=>"cn=zamamd role admin,ou=zamamd groups,ou=test,dc=domain,dc=tld", ...}
+ #
+ # @return [Hash{String=>String}] List of available LDAP groups.
+ def list(filter: nil, base_dn: nil)
+
+ filter ||= filter()
+
+ # don't start a search if no filter was found
+ return {} if filter.blank?
+
+ groups = {}
+ @ldap.search(filter, base: base_dn) { |entry|
+ groups[entry.dn.downcase] = entry.dn.downcase
+ }
+ groups
+ end
+
+ # Creates a mapping for user DN and local role IDs based on a given group DN to local role ID mapping.
+ #
+ # @param mapping [Hash{String=>String}] The group DN to local role mapping.
+ # @param filter [String] The filter for finding groups. Default is initialization parameter.
+ #
+ # @example
+ # mapping = {"cn=access control assistance operators,cn=builtin,dc=domain,dc=tld"=>"1", ...}
+ # ldap_group.user_roles(mapping)
+ # #=> {"cn=s-1-5-11,cn=foreignsecurityprincipals,dc=domain,dc=tld"=>[1, 2], ...}
+ #
+ # @return [Hash{String=>Array}] The user DN to local role IDs mapping.
+ def user_roles(mapping, filter: nil)
+
+ filter ||= filter()
+
+ result = {}
+ @ldap.search(filter) do |entry|
+
+ members = entry[:member]
+ next if members.blank?
+
+ role = mapping[entry.dn.downcase]
+ next if role.blank?
+ role = role.to_i
+
+ members.each do |user_dn|
+ user_dn_key = user_dn.downcase
+
+ result[user_dn_key] ||= []
+ next if result[user_dn_key].include?(role)
+ result[user_dn_key].push(role)
+ end
+ end
+
+ result
+ end
+
+ # The active filter of the instance. If none give on initialization an automatic lookup is performed.
+ #
+ # @example
+ # ldap_group.filter
+ # #=> '(objectClass=group)'
+ #
+ # @return [String, nil] The active or found filter or nil if none could be found.
+ def filter
+ @filter ||= lookup_filter(['(objectClass=group)'])
+ end
+
+ # The active uid attribute of the instance. If none give on initialization an automatic lookup is performed.
+ #
+ # @example
+ # ldap_group.uid_attribute
+ # #=> 'dn'
+ #
+ # @return [String, nil] The active or found uid attribute or nil if none could be found.
+ def uid_attribute
+ @uid_attribute ||= self.class.uid_attribute
+ end
+
+ private
+
+ def handle_config(config)
+ return if config.blank?
+ @uid_attribute = config[:uid_attribute]
+ @filter = config[:filter]
+ end
+ end
+end
diff --git a/lib/ldap/user.rb b/lib/ldap/user.rb
new file mode 100644
index 000000000..9cb25bdbd
--- /dev/null
+++ b/lib/ldap/user.rb
@@ -0,0 +1,187 @@
+class Ldap
+
+ # Class for handling LDAP Groups.
+ # ATTENTION: Make sure to add the following lines to your code if accessing this class.
+ # Otherwise Rails will autoload the Group model or might throw parameter errors if crearing
+ # an ::Ldap instance.
+ #
+ # @example
+ # require 'ldap'
+ # require 'ldap/user'
+ class User
+ include Ldap::FilterLookup
+
+ BLACKLISTED = [
+ :admincount,
+ :accountexpires,
+ :badpasswordtime,
+ :badpwdcount,
+ :countrycode,
+ :distinguishedname,
+ :dnshostname,
+ :dscorepropagationdata,
+ :instancetype,
+ :iscriticalsystemobject,
+ :useraccountcontrol,
+ :usercertificate,
+ :objectclass,
+ :objectcategory,
+ :objectguid,
+ :objectsid,
+ :primarygroupid,
+ :pwdlastset,
+ :lastlogoff,
+ :lastlogon,
+ :lastlogontimestamp,
+ :localpolicyflags,
+ :lockouttime,
+ :logoncount,
+ :logonhours,
+ :'msdfsr-computerreferencebl',
+ :'msds-supportedencryptiontypes',
+ :ridsetreferences,
+ :samaccounttype,
+ :memberof,
+ :serverreferencebl,
+ :serviceprincipalname,
+ :showinadvancedviewonly,
+ :usnchanged,
+ :usncreated,
+ :whenchanged,
+ :whencreated,
+ ].freeze
+
+ # Returns the uid attribute.
+ #
+ # @param attributes [Hash{Symbol=>Array}] A list of LDAP User attributes which should get checked for available uids.
+ #
+ # @example
+ # Ldap::User.uid_attribute(attributes)
+ #
+ # @return [String] The uid attribute.
+ def self.uid_attribute(attributes)
+ result = nil
+ %i(samaccountname userprincipalname uid dn).each { |attribute|
+ next if attributes[attribute].blank?
+ result = attribute.to_s
+ break
+ }
+ result
+ end
+
+ # Initializes a wrapper around Net::LDAP and ::Ldap to handle LDAP users.
+ #
+ # @param [Hash] config the configuration for establishing a LDAP connection. Default is Setting 'ldap_config'.
+ # @option config [String] :uid_attribute The uid attribute. Default is determined automatically.
+ # @option config [String] :filter The filter for LDAP users. Default is determined automatically.
+ # @param ldap [Ldap] An optional existing Ldap class instance. Default is a new connection with given configuration.
+ #
+ # @example
+ # require 'ldap'
+ # require 'ldap/user'
+ # ldap_user = Ldap::User.new
+ #
+ # @return [nil]
+ def initialize(config = nil, ldap: nil)
+ @ldap = ldap || ::Ldap.new(config)
+
+ handle_config(config)
+ end
+
+ # Checks if given username and password combination is valid for the connected LDAP.
+ #
+ # @param username [String] The username.
+ # @param password [String] The password.
+ #
+ # @example
+ # ldap_user.valid?('example_user', 'pw1234')
+ # #=> true
+ #
+ # @return [Boolean] The valid state of the username and password combination.
+ def valid?(username, password)
+ bind_success = @ldap.connection.bind_as(
+ base: @ldap.base_dn,
+ filter: "(#{uid_attribute}=#{username})",
+ password: password
+ )
+
+ message = bind_success ? 'successful' : 'failed'
+ Rails.logger.info "ldap authentication for user '#{username}' (#{uid_attribute}) #{message}!"
+ bind_success.present?
+ end
+
+ # Determines possible User attributes with example values.
+ #
+ # @param filter [String] The filter for listing users. Default is initialization parameter.
+ # @param base_dn [String] The applied base DN for listing users. Default is Ldap#base_dn.
+ #
+ # @example
+ # ldap_user.attributes
+ # #=> {:dn=>"dn (e. g. CN=Administrator,CN=Users,DC=domain,DC=tld)", ...}
+ #
+ # @return [Hash{Symbol=>String}] The available User attributes as key and the name and an example as value.
+ def attributes(filter: nil, base_dn: nil)
+
+ filter ||= filter()
+
+ attributes = {}
+ known_attributes = BLACKLISTED.dup
+ lookup_counter = 1
+
+ @ldap.search(filter, base: base_dn) do |entry|
+ new_attributes = entry.attribute_names - known_attributes
+
+ if new_attributes.blank?
+ lookup_counter += 1
+ # check max 50 entries with
+ # the same attributes in a row
+ break if lookup_counter == 50
+ next
+ end
+
+ new_attributes.each do |attribute|
+ value = entry[attribute]
+ next if value.blank?
+ next if value[0].blank?
+
+ example_value = value[0].force_encoding('UTF-8').encode('utf-8', 'binary', invalid: :replace, undef: :replace, replace: '?')
+ attributes[attribute] = "#{attribute} (e. g. #{example_value})"
+ end
+
+ known_attributes.concat(new_attributes)
+ lookup_counter = 0
+ end
+ attributes
+ end
+
+ # The active filter of the instance. If none give on initialization an automatic lookup is performed.
+ #
+ # @example
+ # ldap_user.filter
+ # #=> '(objectClass=user)'
+ #
+ # @return [String, nil] The active or found filter or nil if none could be found.
+ def filter
+ @filter ||= lookup_filter(['(&(objectClass=user)(samaccountname=*)(!(samaccountname=*$)))', '(objectClass=user)'])
+ end
+
+ # The active uid attribute of the instance. If none give on initialization an automatic lookup is performed.
+ #
+ # @example
+ # ldap_user.uid_attribute
+ # #=> 'samaccountname'
+ #
+ # @return [String, nil] The active or found uid attribute or nil if none could be found.
+ def uid_attribute
+ @uid_attribute ||= self.class.uid_attribute(attributes)
+ end
+
+ private
+
+ def handle_config(config)
+ return if config.blank?
+ @uid_attribute = config[:uid_attribute]
+ @filter = config[:filter]
+ end
+ end
+end
diff --git a/lib/sessions/event/chat_session_init.rb b/lib/sessions/event/chat_session_init.rb
index 3f2dae0f8..0cc264360 100644
--- a/lib/sessions/event/chat_session_init.rb
+++ b/lib/sessions/event/chat_session_init.rb
@@ -21,8 +21,7 @@ class Sessions::Event::ChatSessionInit < Sessions::Event::ChatBase
dns_name = result.to_s
end
rescue => e
- Rails.logger.error e.message
- Rails.logger.error e.backtrace.inspect
+ Rails.logger.error e
end
end
diff --git a/spec/factories/user.rb b/spec/factories/user.rb
index c27cbc040..599125ec3 100644
--- a/spec/factories/user.rb
+++ b/spec/factories/user.rb
@@ -23,7 +23,7 @@ FactoryGirl.define do
end
factory :user_legacy_password_sha2, parent: :user do
- after(:build) { |user| user.class.skip_callback(:validation, :before, :ensure_password) }
+ after(:build) { |user| user.class.skip_callback(:validation, :before, :ensure_password, if: -> { password && password.start_with?('{sha2}') }) }
password '{sha2}dd9c764fa7ea18cd992c8600006d3dc3ac983d1ba22e9ba2d71f6207456be0ba' # zammad
end
end
diff --git a/spec/factories/vendor/net/ldap/entry.rb b/spec/factories/vendor/net/ldap/entry.rb
new file mode 100644
index 000000000..605b368eb
--- /dev/null
+++ b/spec/factories/vendor/net/ldap/entry.rb
@@ -0,0 +1,11 @@
+FactoryGirl.define do
+
+ # add custom attributes via:
+ # mocked_entry = build(:ldap_entry)
+ # mocked_entry['attr'] = [value, another_value]
+ factory :ldap_entry, class: Net::LDAP::Entry do
+ initialize_with do
+ new('dc=com')
+ end
+ end
+end
diff --git a/spec/lib/auth/backend_examples.rb b/spec/lib/auth/backend_examples.rb
new file mode 100644
index 000000000..d1216b508
--- /dev/null
+++ b/spec/lib/auth/backend_examples.rb
@@ -0,0 +1,6 @@
+RSpec.shared_examples 'Auth backend' do
+
+ it 'responds to #valid?' do
+ expect(instance).to respond_to(:valid?)
+ end
+end
diff --git a/spec/lib/auth/base_spec.rb b/spec/lib/auth/base_spec.rb
new file mode 100644
index 000000000..1282a7e84
--- /dev/null
+++ b/spec/lib/auth/base_spec.rb
@@ -0,0 +1,19 @@
+require 'rails_helper'
+require 'lib/auth/backend_examples'
+
+RSpec.describe Auth::Base do
+
+ let(:user) { create(:user) }
+ let(:instance) { described_class.new({ adapter: described_class.name }) }
+
+ context '#valid?' do
+ it_behaves_like 'Auth backend'
+
+ it "requires an implementation of the 'valid?' method" do
+
+ expect do
+ instance.valid?(user, 'password')
+ end.to raise_error(RuntimeError)
+ end
+ end
+end
diff --git a/spec/lib/auth/developer_spec.rb b/spec/lib/auth/developer_spec.rb
new file mode 100644
index 000000000..7f3bef677
--- /dev/null
+++ b/spec/lib/auth/developer_spec.rb
@@ -0,0 +1,47 @@
+require 'rails_helper'
+require 'lib/auth/backend_examples'
+
+RSpec.describe Auth::Developer do
+
+ let(:user) { create(:user) }
+ let(:instance) { described_class.new({ adapter: described_class.name }) }
+
+ context '#valid?' do
+ it_behaves_like 'Auth backend'
+
+ it "authenticates users with password 'test'" do
+
+ allow(Setting).to receive(:get)
+ expect(Setting).to receive(:get).with('developer_mode').and_return(true)
+
+ result = instance.valid?(user, 'test')
+
+ expect(result).to be true
+ end
+
+ context 'invalid' do
+
+ let(:password) { 'zammad' }
+
+ it "doesn't authenticate if developer mode is off" do
+
+ allow(Setting).to receive(:get)
+ expect(Setting).to receive(:get).with('developer_mode').and_return(false)
+
+ result = instance.valid?(user, password)
+
+ expect(result).to be false
+ end
+
+ it "doesn't authenticate with correct password" do
+
+ allow(Setting).to receive(:get)
+ expect(Setting).to receive(:get).with('developer_mode').and_return(true)
+
+ result = instance.valid?(user, password)
+
+ expect(result).to be false
+ end
+ end
+ end
+end
diff --git a/spec/lib/auth/internal_spec.rb b/spec/lib/auth/internal_spec.rb
index df50bd5cf..b5aa1b7a2 100644
--- a/spec/lib/auth/internal_spec.rb
+++ b/spec/lib/auth/internal_spec.rb
@@ -1,31 +1,33 @@
require 'rails_helper'
+require 'lib/auth/backend_examples'
RSpec.describe Auth::Internal do
- it 'authenticates via password' do
- user = create(:user)
- password = 'zammad'
- result = described_class.check(user.login, password, {}, user)
+ let(:user) { create(:user) }
+ let(:instance) { described_class.new({ adapter: described_class.name }) }
- expect(result).to be_an_instance_of(User)
- end
+ context '#valid?' do
+ it_behaves_like 'Auth backend'
- it "doesn't authenticate via plain password" do
- user = create(:user)
- result = described_class.check(user.login, user.password, {}, user)
+ it 'authenticates via password' do
+ result = instance.valid?(user, 'zammad')
+ expect(result).to be true
+ end
- expect(result).to be_falsy
- end
+ it "doesn't authenticate via plain password" do
+ result = instance.valid?(user, user.password)
+ expect(result).to be_falsy
+ end
- it 'converts legacy sha2 passwords' do
- user = create(:user_legacy_password_sha2)
- password = 'zammad'
+ it 'converts legacy sha2 passwords' do
+ user = create(:user_legacy_password_sha2)
- expect(PasswordHash.crypted?(user.password)).to be_falsy
+ expect(PasswordHash.crypted?(user.password)).to be_falsy
- result = described_class.check(user.login, password, {}, user)
+ result = instance.valid?(user, 'zammad')
+ expect(result).to be true
- expect(result).to be_an_instance_of(User)
- expect(PasswordHash.crypted?(user.password)).to be true
+ expect(PasswordHash.crypted?(user.password)).to be true
+ end
end
end
diff --git a/spec/lib/auth/ldap_spec.rb b/spec/lib/auth/ldap_spec.rb
new file mode 100644
index 000000000..d14294fe5
--- /dev/null
+++ b/spec/lib/auth/ldap_spec.rb
@@ -0,0 +1,69 @@
+require 'rails_helper'
+require 'lib/auth/backend_examples'
+require 'auth/ldap'
+
+RSpec.describe ::Auth::Ldap do
+
+ let(:user) { create(:user) }
+ let(:password) { 'somepassword' }
+ let(:instance) { described_class.new({ adapter: described_class.name }) }
+
+ context '#valid?' do
+ it_behaves_like 'Auth backend'
+
+ it 'authenticates users' do
+
+ allow(Setting).to receive(:get)
+ expect(Setting).to receive(:get).with('ldap_integration').and_return(true)
+
+ ldap_user = double(valid?: true)
+ expect(::Ldap::User).to receive(:new).and_return(ldap_user)
+
+ result = instance.valid?(user, password)
+ expect(result).to be true
+ end
+
+ it 'authenticates via configurable user attributes' do
+
+ allow(Setting).to receive(:get)
+ expect(Setting).to receive(:get).with('ldap_integration').and_return(true)
+
+ instance = described_class.new(
+ adapter: described_class.name,
+ login_attributes: %w(firstname),
+ )
+
+ ldap_user = double
+ expect(ldap_user).to receive(:valid?).with(user.firstname, password).and_return(true)
+
+ expect(::Ldap::User).to receive(:new).and_return(ldap_user)
+
+ result = instance.valid?(user, password)
+ expect(result).to be true
+ end
+
+ context 'invalid' do
+
+ it "doesn't authenticate if 'ldap_integration' Setting is disabled" do
+
+ allow(Setting).to receive(:get)
+ expect(Setting).to receive(:get).with('ldap_integration').and_return(false)
+
+ result = instance.valid?(user, password)
+ expect(result).to be false
+ end
+
+ it "doesn't authenticate if ldap says 'nope'" do
+
+ allow(Setting).to receive(:get)
+ expect(Setting).to receive(:get).with('ldap_integration').and_return(true)
+
+ ldap_user = double(valid?: false)
+ expect(::Ldap::User).to receive(:new).and_return(ldap_user)
+
+ result = instance.valid?(user, password)
+ expect(result).to be false
+ end
+ end
+ end
+end
diff --git a/spec/lib/auth_spec.rb b/spec/lib/auth_spec.rb
new file mode 100644
index 000000000..6bc122b12
--- /dev/null
+++ b/spec/lib/auth_spec.rb
@@ -0,0 +1,60 @@
+require 'rails_helper'
+
+RSpec.describe Auth do
+
+ context '.can_login?' do
+ it 'responds to can_login?' do
+ expect(described_class).to respond_to(:can_login?)
+ end
+
+ it 'checks if users can login' do
+ user = create(:user)
+ result = described_class.can_login?(user)
+ expect(result).to be true
+ end
+
+ context 'not loginable' do
+
+ it 'fails if user has too many failed logins' do
+ user = create(:user, login_failed: 999)
+ result = described_class.can_login?(user)
+ expect(result).to be false
+ end
+
+ it "fails if user isn't active" do
+ user = create(:user, active: false)
+ result = described_class.can_login?(user)
+ expect(result).to be false
+ end
+
+ it 'fails if parameter is no User instance' do
+ result = described_class.can_login?('user')
+ expect(result).to be false
+ end
+ end
+ end
+
+ context '.valid?' do
+ it 'responds to valid?' do
+ expect(described_class).to respond_to(:valid?)
+ end
+
+ it 'authenticates users' do
+ user = create(:user)
+ result = described_class.valid?(user, 'zammad')
+ expect(result).to be true
+ end
+ end
+
+ context '.backends' do
+ it 'responds to backends' do
+ expect(described_class).to respond_to(:backends)
+ end
+
+ it 'returns a list of Hashes' do
+ result = described_class.backends
+ expect(result).to be_an(Array)
+ expect(result.first).to be_a(Hash)
+ end
+ end
+end
diff --git a/spec/lib/import/base_resource_spec.rb b/spec/lib/import/base_resource_spec.rb
index 13a79d4aa..8fedd9370 100644
--- a/spec/lib/import/base_resource_spec.rb
+++ b/spec/lib/import/base_resource_spec.rb
@@ -1,10 +1,119 @@
require 'rails_helper'
+RSpec::Matchers.define_negated_matcher :not_change, :change
+
RSpec.describe Import::BaseResource do
it "needs an implementation of the 'import_class' method" do
expect {
described_class.new(attributes_for(:group))
- }.to raise_error(RuntimeError)
+ }.to raise_error(NoMethodError)
end
+
+ context "implemented 'import_class' method" do
+
+ before do
+ module Import
+ module Test
+ class Group < Import::BaseResource
+
+ def import_class
+ ::Group
+ end
+
+ def source
+ 'RSpec-TEST'
+ end
+ end
+ end
+ end
+ end
+
+ after do
+ Import::Test.send(:remove_const, :Group)
+ end
+
+ let(:attributes) {
+ attributes = attributes_for(:group)
+ attributes[:id] = 1337
+ attributes
+ }
+
+ context 'live run' do
+
+ it 'creates new resources' do
+ expect do
+ Import::Test::Group.new(attributes)
+ end
+ .to change {
+ Group.count
+ }.by(1)
+ .and change {
+ ExternalSync.count
+ }.by(1)
+ end
+
+ it 'updates existing resources' do
+
+ # initial import
+ Import::Test::Group.new(attributes)
+ group = Group.last
+
+ # simulate next import run
+ travel 20.minutes
+
+ attributes[:note] = 'TEST'
+
+ expect do
+ Import::Test::Group.new(attributes)
+ group.reload
+ end
+ .to change {
+ group.note
+ }
+ end
+ end
+
+ context 'dry run' do
+
+ it "doesn't create new resources" do
+ expect do
+ Import::Test::Group.new(attributes, dry_run: true)
+ end
+ .to not_change {
+ Group.count
+ }
+ .and not_change {
+ ExternalSync.count
+ }
+ end
+
+ it "doesn't update existing resources" do
+
+ # initial import
+ Import::Test::Group.new(attributes)
+ group = Group.last
+
+ # simulate next import run
+ travel 20.minutes
+
+ attributes[:note] = 'TEST'
+
+ expect do
+ Import::Test::Group.new(attributes, dry_run: true)
+ group.reload
+ end
+ .to not_change {
+ group.note
+ }
+ .and not_change {
+ Group.count
+ }
+ .and not_change {
+ ExternalSync.count
+ }
+ end
+ end
+ end
+
end
diff --git a/spec/lib/import/ldap/user_factory_spec.rb b/spec/lib/import/ldap/user_factory_spec.rb
new file mode 100644
index 000000000..80f1a4c31
--- /dev/null
+++ b/spec/lib/import/ldap/user_factory_spec.rb
@@ -0,0 +1,229 @@
+require 'rails_helper'
+
+RSpec.describe Import::Ldap::UserFactory do
+
+ describe '.import' do
+
+ it 'responds to .import' do
+ expect(described_class).to respond_to(:import)
+ end
+
+ it 'imports users matching the configured filter' do
+
+ config = {
+ user_filter: '(objectClass=user)',
+ group_filter: '(objectClass=group)',
+ user_uid: 'uid',
+ user_attributes: {
+ 'uid' => 'login',
+ 'email' => 'email',
+ }
+ }
+
+ mocked_entry = build(:ldap_entry)
+
+ mocked_entry['uid'] = ['exampleuid']
+ mocked_entry['email'] = ['example@example.com']
+
+ mocked_ldap = double(
+ host: 'ldap.example.com',
+ port: 636,
+ ssl: true,
+ base_dn: 'dc=example,dc=com'
+ )
+
+ # group user role mapping
+ expect(mocked_ldap).to receive(:search)
+ # user counting
+ expect(mocked_ldap).to receive(:count).and_return(1)
+ # user search
+ expect(mocked_ldap).to receive(:search).and_yield(mocked_entry)
+
+ expect do
+ described_class.import(
+ config: config,
+ ldap: mocked_ldap
+ )
+ end.to change {
+ User.count
+ }.by(1)
+ end
+
+ it 'supports dry run' do
+
+ config = {
+ user_filter: '(objectClass=user)',
+ group_filter: '(objectClass=group)',
+ user_uid: 'uid',
+ user_attributes: {
+ 'uid' => 'login',
+ 'email' => 'email',
+ }
+ }
+
+ mocked_entry = build(:ldap_entry)
+
+ mocked_entry['uid'] = ['exampleuid']
+ mocked_entry['email'] = ['example@example.com']
+
+ mocked_ldap = double(
+ host: 'ldap.example.com',
+ port: 636,
+ ssl: true,
+ base_dn: 'dc=example,dc=com'
+ )
+
+ # group user role mapping
+ expect(mocked_ldap).to receive(:search)
+ # user counting
+ expect(mocked_ldap).to receive(:count).and_return(1)
+ # user search
+ expect(mocked_ldap).to receive(:search).and_yield(mocked_entry)
+
+ expect do
+ described_class.import(
+ config: config,
+ ldap: mocked_ldap,
+ dry_run: true
+ )
+ end.not_to change {
+ User.count
+ }
+ end
+ end
+
+ describe '.add_to_statistics' do
+
+ it 'responds to .add_to_statistics' do
+ expect(described_class).to respond_to(:add_to_statistics)
+ end
+
+ it 'adds statistics per user role' do
+
+ mocked_backend_instance = double(
+ action: :created,
+ resource: double(
+ role_ids: [1, 2]
+ )
+ )
+
+ # initialize empty statistic
+ described_class.reset_statistics
+
+ described_class.add_to_statistics(mocked_backend_instance)
+
+ expected = {
+ role_ids: {
+ 1 => {
+ created: 1,
+ updated: 0,
+ unchanged: 0,
+ failed: 0
+ },
+ 2 => {
+ created: 1,
+ updated: 0,
+ unchanged: 0,
+ failed: 0
+ },
+ },
+ skipped: 0,
+ created: 1,
+ updated: 0,
+ unchanged: 0,
+ failed: 0,
+ }
+
+ expect(described_class.statistics).to include(expected)
+ end
+
+ it 'skips not created instances' do
+
+ mocked_backend_instance = double(
+ action: :skipped,
+ resource: nil,
+ )
+
+ # initialize empty statistic
+ described_class.reset_statistics
+
+ described_class.add_to_statistics(mocked_backend_instance)
+
+ expected = {
+ skipped: 1,
+ created: 0,
+ updated: 0,
+ unchanged: 0,
+ failed: 0,
+ }
+
+ expect(described_class.statistics).to include(expected)
+ end
+
+ it 'skips unwanted actions instances' do
+
+ mocked_backend_instance = double(
+ action: :skipped,
+ resource: double(
+ role_ids: [1, 2]
+ )
+ )
+
+ # initialize empty statistic
+ described_class.reset_statistics
+
+ described_class.add_to_statistics(mocked_backend_instance)
+
+ expected = {
+ skipped: 1,
+ created: 0,
+ updated: 0,
+ unchanged: 0,
+ failed: 0,
+ }
+
+ expect(described_class.statistics).to include(expected)
+ end
+
+ end
+
+ describe '.user_roles' do
+
+ it 'responds to .user_roles' do
+ expect(described_class).to respond_to(:user_roles)
+ end
+
+ it 'fetches the user DN to local role mapping' do
+
+ group_dn = 'dn=... admin group...'
+ user_dn = 'dn=... admin user...'
+
+ config = {
+ group_filter: '(objectClass=group)',
+ group_role_map: {
+ group_dn => '1',
+ }
+ }
+
+ mocked_entry = build(:ldap_entry)
+
+ mocked_entry['dn'] = group_dn
+ mocked_entry['member'] = [user_dn]
+
+ mocked_ldap = double()
+ expect(mocked_ldap).to receive(:search).and_yield(mocked_entry)
+
+ user_roles = described_class.user_roles(
+ ldap: mocked_ldap,
+ config: config,
+ )
+
+ expected = {
+ user_dn => [1]
+ }
+
+ expect(user_roles).to be_a(Hash)
+ expect(user_roles).to eq(expected)
+ end
+ end
+end
diff --git a/spec/lib/import/ldap/user_spec.rb b/spec/lib/import/ldap/user_spec.rb
new file mode 100644
index 000000000..69f46ad22
--- /dev/null
+++ b/spec/lib/import/ldap/user_spec.rb
@@ -0,0 +1,155 @@
+require 'rails_helper'
+require 'import/ldap/user'
+
+RSpec::Matchers.define_negated_matcher :not_change, :change
+
+RSpec.describe Import::Ldap::User do
+
+ let(:uid) { 'exampleuid' }
+
+ let(:ldap_config) do
+ {
+ user_uid: 'uid',
+ user_attributes: {
+ 'uid' => 'login',
+ 'email' => 'email',
+ }
+ }
+ end
+
+ let(:user_entry) do
+ user_entry = build(:ldap_entry)
+
+ user_entry['uid'] = [uid]
+ user_entry['email'] = ['example@example.com']
+
+ user_entry
+ end
+
+ let(:user_roles) do
+ {
+ user_entry.dn => [1]
+ }
+ end
+
+ let(:signup_role_ids) do
+ Role.signup_role_ids.sort
+ end
+
+ context 'create' do
+
+ it 'creates users from LDAP Entry' do
+ expect do
+ described_class.new(user_entry, ldap_config, user_roles, signup_role_ids)
+ end.to change {
+ User.count
+ }.by(1).and change {
+ ExternalSync.count
+ }.by(1)
+ end
+
+ it 'creates an HTTP Log entry' do
+ expect do
+ described_class.new(user_entry, ldap_config, user_roles, signup_role_ids)
+ end.to change {
+ HttpLog.count
+ }.by(1)
+
+ expect(HttpLog.last.status).to eq('success')
+ end
+
+ it 'uses mapped roles from group role' do
+ described_class.new(user_entry, ldap_config, user_roles, signup_role_ids)
+ expect(User.last.role_ids).not_to eq(signup_role_ids)
+ end
+
+ it 'uses Signup roles if no group role mapping was found' do
+
+ # update old
+ user_roles[ user_entry.dn ] = [1, 2]
+
+ # change dn so no mapping will match
+ user_entry['dn'] = ['some_unmapped_dn']
+
+ described_class.new(user_entry, ldap_config, user_roles, signup_role_ids)
+ expect(User.last.role_ids).to eq(signup_role_ids)
+ end
+
+ it 'skips User entries without attributes' do
+
+ skip_entry = build(:ldap_entry)
+
+ skip_entry['uid'] = [uid]
+
+ expect do
+ described_class.new(skip_entry, ldap_config, user_roles, signup_role_ids)
+ end.to not_change {
+ User.count
+ }
+ end
+
+ it 'logs failures to HTTP Log' do
+ expect_any_instance_of(User).to receive(:save).and_raise('SOME ERROR')
+ described_class.new(user_entry, ldap_config, user_roles, signup_role_ids)
+
+ expect(HttpLog.last.status).to eq('failed')
+ end
+ end
+
+ context 'update' do
+
+ before(:each) do
+ user = create(:user, login: uid)
+
+ ExternalSync.create(
+ source: 'Ldap::User',
+ source_id: uid,
+ object: 'User',
+ o_id: user.id
+ )
+ end
+
+ it 'updates users from LDAP Entry' do
+ expect do
+ described_class.new(user_entry, ldap_config, user_roles, signup_role_ids)
+ end.to not_change {
+ User.count
+ }.and not_change {
+ ExternalSync.count
+ }
+ end
+
+ it 'creates an HTTP Log entry' do
+ expect do
+ described_class.new(user_entry, ldap_config, user_roles, signup_role_ids)
+ end.to change {
+ HttpLog.count
+ }.by(1)
+
+ expect(HttpLog.last.status).to eq('success')
+ end
+
+ it 'finds existing Users without ExternalSync entries' do
+ ExternalSync.find_by(
+ source: 'Ldap::User',
+ source_id: uid,
+ object: 'User',
+ ).destroy
+
+ expect do
+ described_class.new(user_entry, ldap_config, user_roles, signup_role_ids)
+ end.to not_change {
+ User.count
+ }.and change {
+ ExternalSync.count
+ }.by(1)
+ end
+
+ it 'logs failures to HTTP Log' do
+ expect_any_instance_of(User).to receive(:save).and_raise('SOME ERROR')
+ described_class.new(user_entry, ldap_config, user_roles, signup_role_ids)
+
+ expect(HttpLog.last.status).to eq('failed')
+ end
+ end
+end
diff --git a/spec/lib/import/model_resource_spec.rb b/spec/lib/import/model_resource_spec.rb
index 61d675977..64f2edaef 100644
--- a/spec/lib/import/model_resource_spec.rb
+++ b/spec/lib/import/model_resource_spec.rb
@@ -14,6 +14,10 @@ RSpec.describe Import::ModelResource do
end
end
+ after do
+ Import::Test.send(:remove_const, :Group)
+ end
+
let(:group_data) { attributes_for(:group).merge(id: 1337) }
it 'creates model Objects by class name' do
diff --git a/spec/lib/import/statistical_factory_spec.rb b/spec/lib/import/statistical_factory_spec.rb
new file mode 100644
index 000000000..06e3d11b2
--- /dev/null
+++ b/spec/lib/import/statistical_factory_spec.rb
@@ -0,0 +1,96 @@
+require 'rails_helper'
+require 'lib/import/factory_examples'
+
+RSpec.describe Import::StatisticalFactory do
+ it_behaves_like 'Import::Factory'
+
+ before do
+ module Import
+ module Test
+
+ module GroupFactory
+ extend Import::StatisticalFactory
+ end
+
+ class Group < Import::ModelResource
+ def source
+ 'RSpec-Test'
+ end
+ end
+ end
+ end
+ end
+
+ after do
+ Import::Test.send(:remove_const, :GroupFactory)
+ Import::Test.send(:remove_const, :Group)
+ end
+
+ before(:each) do
+ Import::Test::GroupFactory.reset_statistics
+ end
+
+ let(:attributes) {
+ attributes = attributes_for(:group)
+ attributes[:id] = 1337
+ attributes
+ }
+
+ context 'statistics' do
+
+ it 'tracks created instances' do
+
+ Import::Test::GroupFactory.import([attributes])
+
+ statistics = {
+ created: 1,
+ updated: 0,
+ unchanged: 0,
+ skipped: 0,
+ failed: 0,
+ }
+ expect(Import::Test::GroupFactory.statistics).to eq(statistics)
+ end
+
+ it 'tracks updated instances' do
+
+ Import::Test::GroupFactory.import([attributes])
+
+ # simulate next import run
+ travel 20.minutes
+ Import::Test::GroupFactory.reset_statistics
+
+ attributes[:note] = 'TEST'
+ Import::Test::GroupFactory.import([attributes])
+
+ statistics = {
+ created: 0,
+ updated: 1,
+ unchanged: 0,
+ skipped: 0,
+ failed: 0,
+ }
+ expect(Import::Test::GroupFactory.statistics).to eq(statistics)
+ end
+
+ it 'tracks unchanged instances' do
+
+ Import::Test::GroupFactory.import([attributes])
+
+ # simulate next import run
+ travel 20.minutes
+ Import::Test::GroupFactory.reset_statistics
+
+ Import::Test::GroupFactory.import([attributes])
+
+ statistics = {
+ created: 0,
+ updated: 0,
+ unchanged: 1,
+ skipped: 0,
+ failed: 0,
+ }
+ expect(Import::Test::GroupFactory.statistics).to eq(statistics)
+ end
+ end
+end
diff --git a/spec/lib/ldap/group_spec.rb b/spec/lib/ldap/group_spec.rb
new file mode 100644
index 000000000..906439ec0
--- /dev/null
+++ b/spec/lib/ldap/group_spec.rb
@@ -0,0 +1,122 @@
+require 'rails_helper'
+# rails autoloading issue
+require 'ldap'
+require 'ldap/group'
+
+RSpec.describe Ldap::Group do
+
+ context '.uid_attribute' do
+
+ it 'responds to .uid_attribute' do
+ expect(described_class).to respond_to(:uid_attribute)
+ end
+
+ it 'returns uid attribute' do
+ expect(described_class.uid_attribute).to be_a(String)
+ end
+ end
+
+ # required as 'let' to perform test based
+ # expectations and reuse it in 'let' instance
+ # as additional parameter
+ let(:mocked_ldap) { double() }
+
+ context 'initialization config parameters' do
+
+ it 'reuses given Ldap instance if given' do
+ config = {}
+ expect(Ldap).not_to receive(:new).with(config)
+ instance = described_class.new(config, ldap: mocked_ldap)
+ end
+
+ it 'takes optional filter' do
+
+ filter = '(objectClass=custom)'
+ config = {
+ filter: filter
+ }
+
+ instance = described_class.new(config, ldap: mocked_ldap)
+
+ expect(instance.filter).to eq(filter)
+ end
+
+ it 'takes optional uid_attribute' do
+
+ uid_attribute = 'dn'
+ config = {
+ uid_attribute: uid_attribute
+ }
+
+ instance = described_class.new(config, ldap: mocked_ldap)
+
+ expect(instance.uid_attribute).to eq(uid_attribute)
+ end
+
+ it 'creates own Ldap instance if none given' do
+ expect(Ldap).to receive(:new)
+ expect(described_class.new())
+ end
+ end
+
+ context 'instance methods' do
+
+ let(:initialization_config) {
+ {
+ uid_attribute: 'dn',
+ filter: '(objectClass=group)',
+ }
+ }
+
+ let(:instance) {
+ described_class.new(initialization_config, ldap: mocked_ldap)
+ }
+
+ context '#list' do
+
+ it 'responds to #list' do
+ expect(instance).to respond_to(:list)
+ end
+
+ it 'returns a Hash of groups' do
+ ldap_entry = build(:ldap_entry)
+ expect(mocked_ldap).to receive(:search).and_return(ldap_entry)
+ expect(instance.list).to be_a(Hash)
+ end
+ end
+
+ context '#filter' do
+
+ let(:initialization_config) {
+ {
+ uid_attribute: 'dn',
+ }
+ }
+
+ it 'responds to #filter' do
+ expect(instance).to respond_to(:filter)
+ end
+
+ it 'tries filters and returns first one with entries' do
+ expect(mocked_ldap).to receive(:entries?).and_return(true)
+ expect(instance.filter).to be_a(String)
+ end
+
+ it 'fails if no filter found entries' do
+ allow(mocked_ldap).to receive(:entries?).and_return(false)
+ expect(instance.filter).to be nil
+ end
+ end
+
+ context '#uid_attribute' do
+
+ it 'responds to #uid_attribute' do
+ expect(instance).to respond_to(:uid_attribute)
+ end
+
+ it 'returns the uid attribute' do
+ expect(instance.uid_attribute).to be_a(String)
+ end
+ end
+ end
+end
diff --git a/spec/lib/ldap/user_spec.rb b/spec/lib/ldap/user_spec.rb
new file mode 100644
index 000000000..913146b31
--- /dev/null
+++ b/spec/lib/ldap/user_spec.rb
@@ -0,0 +1,198 @@
+require 'rails_helper'
+# rails autoloading issue
+require 'ldap'
+require 'ldap/user'
+
+RSpec.describe Ldap::User do
+
+ context '.uid_attribute' do
+
+ it 'responds to .uid_attribute' do
+ expect(described_class).to respond_to(:uid_attribute)
+ end
+
+ it 'returns uid attribute string from given attribute strucutre' do
+ attributes = {
+ samaccountname: 'TEST',
+ custom: 'value',
+ }
+ expect(described_class.uid_attribute(attributes)).to eq('samaccountname')
+ end
+
+ it 'returns nil if no attribute could be found' do
+ attributes = {
+ custom: 'value',
+ }
+ expect(described_class.uid_attribute(attributes)).to be nil
+ end
+
+ end
+
+ # required as 'let' to perform test based
+ # expectations and reuse it in 'let' instance
+ # as additional parameter
+ let(:mocked_ldap) { double() }
+
+ context 'initialization config parameters' do
+
+ it 'reuses given Ldap instance if given' do
+ expect(Ldap).not_to receive(:new)
+ instance = described_class.new(ldap: mocked_ldap)
+ end
+
+ it 'takes optional filter' do
+
+ filter = '(objectClass=custom)'
+ config = {
+ filter: filter
+ }
+
+ instance = described_class.new(config, ldap: mocked_ldap)
+
+ expect(instance.filter).to eq(filter)
+ end
+
+ it 'takes optional uid_attribute' do
+
+ uid_attribute = 'samaccountname'
+ config = {
+ uid_attribute: uid_attribute
+ }
+
+ instance = described_class.new(config, ldap: mocked_ldap)
+
+ expect(instance.uid_attribute).to eq(uid_attribute)
+ end
+
+ it 'creates own Ldap instance if none given' do
+ expect(Ldap).to receive(:new)
+ expect(described_class.new())
+ end
+ end
+
+ context 'instance methods' do
+
+ let(:initialization_config) {
+ {
+ uid_attribute: 'samaccountname',
+ filter: '(objectClass=user)',
+ }
+ }
+
+ let(:instance) {
+ described_class.new(initialization_config, ldap: mocked_ldap)
+ }
+
+ context '#valid?' do
+
+ it 'responds to #valid?' do
+ expect(instance).to respond_to(:valid?)
+ end
+
+ it 'validates username and password' do
+ connection = double()
+ expect(mocked_ldap).to receive(:connection).and_return(connection)
+
+ ldap_entry = build(:ldap_entry)
+
+ expect(mocked_ldap).to receive(:base_dn)
+ expect(connection).to receive(:bind_as).and_return(true)
+
+ expect(instance.valid?('example_username', 'password')).to be true
+ end
+
+ it 'fails for invalid credentials' do
+ connection = double()
+ expect(mocked_ldap).to receive(:connection).and_return(connection)
+
+ ldap_entry = build(:ldap_entry)
+
+ expect(mocked_ldap).to receive(:base_dn)
+ expect(connection).to receive(:bind_as).and_return(false)
+
+ expect(instance.valid?('example_username', 'wrong_password')).to be false
+ end
+ end
+
+ context '#attributes' do
+
+ it 'responds to #attributes' do
+ expect(instance).to respond_to(:attributes)
+ end
+
+ it 'lists user attributes with example values' do
+ ldap_entry = build(:ldap_entry)
+
+ # selectable attribute
+ ldap_entry['mail'] = 'test@example.com'
+
+ # blacklisted attribute
+ ldap_entry['lastlogon'] = DateTime.current
+
+ expect(mocked_ldap).to receive(:search).and_yield(ldap_entry)
+
+ attributes = instance.attributes
+
+ expected_attributes = {
+ dn: String,
+ mail: String,
+ }
+
+ expect(attributes).to include(expected_attributes)
+ expect(attributes).not_to include(:lastlogon)
+ end
+ end
+
+ context '#filter' do
+
+ let(:initialization_config) {
+ {
+ uid_attribute: 'samaccountname',
+ }
+ }
+
+ it 'responds to #filter' do
+ expect(instance).to respond_to(:filter)
+ end
+
+ it 'tries filters and returns first one with entries' do
+ expect(mocked_ldap).to receive(:entries?).and_return(true)
+ expect(instance.filter).to be_a(String)
+ end
+
+ it 'fails if no filter found entries' do
+ allow(mocked_ldap).to receive(:entries?).and_return(false)
+ expect(instance.filter).to be nil
+ end
+ end
+
+ context '#uid_attribute' do
+
+ let(:initialization_config) {
+ {
+ filter: '(objectClass=user)',
+ }
+ }
+
+ it 'responds to #uid_attribute' do
+ expect(instance).to respond_to(:uid_attribute)
+ end
+
+ it 'tries to find uid attribute in example attributes' do
+ ldap_entry = build(:ldap_entry)
+
+ # selectable attribute
+ ldap_entry['samaccountname'] = 'test@example.com'
+
+ expect(mocked_ldap).to receive(:search).and_yield(ldap_entry)
+
+ expect(instance.uid_attribute).to be_a(String)
+ end
+
+ it 'fails if no uid attribute could be found' do
+ expect(mocked_ldap).to receive(:search)
+ expect(instance.uid_attribute).to be nil
+ end
+ end
+ end
+end
diff --git a/spec/lib/ldap_rspec.rb b/spec/lib/ldap_rspec.rb
new file mode 100644
index 000000000..e3cd1dacf
--- /dev/null
+++ b/spec/lib/ldap_rspec.rb
@@ -0,0 +1,340 @@
+require 'rails_helper'
+
+RSpec.describe Ldap do
+
+ context 'initialization config parameters' do
+
+ # required as 'let' to perform test based
+ # expectations and reuse it in mock_initialization
+ # as return param of Net::LDAP.new
+ let(:mocked_ldap) { double(bind: true) }
+
+ def mock_initialization(given:, expected:)
+ expect(Net::LDAP).to receive(:new).with(expected).and_return(mocked_ldap)
+ described_class.new(given)
+ end
+
+ it 'uses explicit host and port' do
+
+ config = {
+ host: 'localhost',
+ port: 1337,
+ }
+
+ mock_initialization(
+ given: config,
+ expected: config,
+ )
+ end
+
+ context 'bind credentials' do
+
+ it 'uses given credentials' do
+
+ config = {
+ host: 'localhost',
+ port: 1337,
+ bind_user: 'JohnDoe',
+ bind_pw: 'zammad',
+ }
+
+ params = {
+ host: 'localhost',
+ port: 1337,
+ }
+
+ expect(mocked_ldap).to receive(:auth).with(config[:bind_user], config[:bind_pw])
+
+ mock_initialization(
+ given: config,
+ expected: params,
+ )
+ end
+
+ it 'requires bind_user' do
+
+ config = {
+ host: 'localhost',
+ port: 1337,
+ bind_pw: 'zammad',
+ }
+
+ params = {
+ host: 'localhost',
+ port: 1337,
+ }
+
+ expect(mocked_ldap).not_to receive(:auth).with(config[:bind_user], config[:bind_pw])
+
+ mock_initialization(
+ given: config,
+ expected: params,
+ )
+ end
+
+ it 'requires bind_pw' do
+
+ config = {
+ host: 'localhost',
+ port: 1337,
+ bind_user: 'JohnDoe',
+ }
+
+ params = {
+ host: 'localhost',
+ port: 1337,
+ }
+
+ expect(mocked_ldap).not_to receive(:auth).with(config[:bind_user], config[:bind_pw])
+
+ mock_initialization(
+ given: config,
+ expected: params,
+ )
+ end
+ end
+
+ it 'extracts port from host' do
+
+ config = {
+ host: 'localhost:1337'
+ }
+
+ params = {
+ host: 'localhost',
+ port: 1337,
+ }
+
+ mock_initialization(
+ given: config,
+ expected: params,
+ )
+ end
+
+ context 'host_url' do
+ it 'parses protocol and host' do
+ config = {
+ host_url: 'ldaps://localhost'
+ }
+
+ params = {
+ host: 'localhost',
+ port: 636,
+ encryption: Hash
+ }
+
+ mock_initialization(
+ given: config,
+ expected: params,
+ )
+ end
+
+ it 'prefers parsing over explicit parameters' do
+ config = {
+ host: 'anotherhost',
+ port: 7777,
+ host_url: 'ldap://localhost:389'
+ }
+
+ params = {
+ host: 'localhost',
+ port: 389,
+ }
+
+ mock_initialization(
+ given: config,
+ expected: params,
+ )
+ end
+ end
+
+ it 'falls back to default ldap port' do
+ config = {
+ host: 'localhost',
+ }
+
+ params = {
+ host: 'localhost',
+ port: 389,
+ }
+
+ mock_initialization(
+ given: config,
+ expected: params,
+ )
+ end
+
+ it 'uses explicit ssl' do
+
+ config = {
+ host: 'localhost',
+ port: 1337,
+ ssl: true,
+ }
+
+ expected = {
+ host: 'localhost',
+ port: 1337,
+ encryption: Hash,
+ }
+
+ mock_initialization(
+ given: config,
+ expected: expected,
+ )
+ end
+
+ it "uses 'ldap_config' Setting as fallback" do
+
+ config = {
+ host: 'localhost',
+ port: 1337,
+ }
+
+ expect(Setting).to receive(:get).with('ldap_config').and_return(config)
+
+ mock_initialization(
+ given: nil,
+ expected: config,
+ )
+ end
+ end
+
+ context 'instance methods' do
+
+ # required as 'let' to perform test based
+ # expectations and reuse it in 'let' instance
+ # as return param of Net::LDAP.new
+ let(:mocked_ldap) { double(bind: true) }
+ let(:instance) {
+ expect(Net::LDAP).to receive(:new).and_return(mocked_ldap)
+ described_class.new(
+ host: 'localhost',
+ port: 1337,
+ )
+ }
+
+ context '#preferences' do
+
+ it 'responds to #preferences' do
+ expect(instance).to respond_to(:preferences)
+ end
+
+ it 'returns preferences' do
+
+ attributes = {
+ namingcontexts: ['ou=dep1,ou=org', 'ou=dep2,ou=org']
+ }
+
+ expect(mocked_ldap).to receive(:search_root_dse).and_return(attributes)
+ expect(instance.preferences).to eq(attributes)
+ end
+ end
+
+ context '#search' do
+
+ it 'responds to #search' do
+ expect(instance).to respond_to(:search)
+ end
+
+ let(:filter) { '(objectClass=user)' }
+ let(:base) { 'DC=domain,DC=tld' }
+
+ it 'performs search for a filter, base and scope and yields of returned entries' do
+
+ scope = Net::LDAP::SearchScope_BaseObject
+
+ additional = {
+ base: base,
+ scope: scope,
+ }
+
+ expected = {
+ filter: filter,
+ base: base,
+ scope: scope,
+ }
+
+ yield_entry = build(:ldap_entry)
+ expect(mocked_ldap).to receive(:search).with(include(expected)).and_yield(yield_entry).and_return(true)
+
+ check_entry = nil
+ instance.search(filter, additional) { |entry| check_entry = entry }
+ expect(check_entry).to eq(yield_entry)
+ end
+
+ it 'falls back to whole subtree scope search' do
+
+ additional = {
+ base: base,
+ }
+
+ expected = {
+ filter: filter,
+ base: base,
+ scope: Net::LDAP::SearchScope_WholeSubtree,
+ }
+
+ yield_entry = build(:ldap_entry)
+ expect(mocked_ldap).to receive(:search).with(include(expected)).and_yield(yield_entry).and_return(true)
+
+ check_entry = nil
+ instancesearch(filter, additional) { |entry| check_entry = entry }
+ expect(check_entry).to eq(yield_entry)
+ end
+
+ it 'falls back to base_dn configuration parameter' do
+
+ expected = {
+ filter: filter,
+ base: base,
+ scope: Net::LDAP::SearchScope_WholeSubtree,
+ }
+
+ expect(Net::LDAP).to receive(:new).and_return(mocked_ldap)
+ instance = described_class.new(
+ host: 'localhost',
+ port: 1337,
+ base_dn: base,
+ )
+
+ yield_entry = build(:ldap_entry)
+ expect(mocked_ldap).to receive(:search).with(include(expected)).and_yield(yield_entry).and_return(true)
+
+ check_entry = nil
+ instance.search(filter) { |entry| check_entry = entry }
+ expect(check_entry).to eq(yield_entry)
+ end
+ end
+
+ context '#entries?' do
+
+ it 'responds to #entries?' do
+ expect(instance).to respond_to(:entries?)
+ end
+
+ let(:filter) { '(objectClass=user)' }
+
+ it 'returns true if entries are present' do
+
+ params = {
+ filter: filter
+ }
+
+ expect(mocked_ldap).to receive(:search).with(include(params)).and_yield(build(:ldap_entry)).and_return(nil)
+ expect(instance.entries?(filter)).to be true
+ end
+
+ it 'returns false if no entries are present' do
+
+ params = {
+ filter: filter
+ }
+
+ expect(mocked_ldap).to receive(:search).with(include(params)).and_return(true)
+ expect(instance.entries?(filter)).to be false
+ end
+
+ end
+ end
+end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index ca76a7fb0..3625352d4 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -15,7 +15,96 @@ RSpec.describe User do
end
end
- context '#by_reset_token' do
+ context '#max_login_failed?' do
+
+ it 'responds to max_login_failed?' do
+ user = create(:user)
+ expect(user).to respond_to(:max_login_failed?)
+ end
+
+ it 'checks if a user has reached the maximum of failed logins' do
+
+ user = create(:user)
+ expect(user.max_login_failed?).to be false
+
+ user.login_failed = 999
+ user.save
+ expect(user.max_login_failed?).to be true
+ end
+ end
+
+ context '.identify' do
+
+ it 'returns users found by login' do
+ user = create(:user)
+ found_user = User.identify(user.login)
+ expect(found_user).to be_an(User)
+ expect(found_user.id).to eq user.id
+ end
+
+ it 'returns users found by email' do
+ user = create(:user)
+ found_user = User.identify(user.email)
+ expect(found_user).to be_an(User)
+ expect(found_user.id).to eq user.id
+ end
+ end
+
+ context '.authenticate' do
+
+ it 'authenticates by username and password' do
+ user = create(:user)
+ result = described_class.authenticate(user.login, 'zammad')
+ expect(result).to be_an(User)
+ end
+
+ context 'failure' do
+
+ it 'increases login_failed on failed logins' do
+ user = create(:user)
+ expect do
+ described_class.authenticate(user.login, 'wrongpw')
+ user.reload
+ end
+ .to change { user.login_failed }.by(1)
+ end
+
+ it 'fails for unknown users' do
+ result = described_class.authenticate('john.doe', 'zammad')
+ expect(result).to be nil
+ end
+
+ it 'fails for inactive users' do
+ user = create(:user, active: false)
+ result = described_class.authenticate(user.login, 'zammad')
+ expect(result).to be nil
+ end
+
+ it 'fails for users with too many failed logins' do
+ user = create(:user, login_failed: 999)
+ result = described_class.authenticate(user.login, 'zammad')
+ expect(result).to be nil
+ end
+
+ it 'fails for wrong passwords' do
+ user = create(:user)
+ result = described_class.authenticate(user.login, 'wrongpw')
+ expect(result).to be nil
+ end
+
+ it 'fails for empty username parameter' do
+ result = described_class.authenticate('', 'zammad')
+ expect(result).to be nil
+ end
+
+ it 'fails for empty password parameter' do
+ result = described_class.authenticate('username', '')
+ expect(result).to be nil
+ end
+ end
+ end
+
+ context '.by_reset_token' do
it 'returns a User instance for existing tokens' do
token = create(:token_password_reset)
@@ -27,7 +116,7 @@ RSpec.describe User do
end
end
- context '#password_reset_via_token' do
+ context '.password_reset_via_token' do
it 'changes the password of the token user and destroys the token' do
token = create(:token_password_reset)