diff --git a/app/assets/javascripts/app/controllers/_integration/ldap.coffee b/app/assets/javascripts/app/controllers/_integration/ldap.coffee new file mode 100644 index 000000000..c652f92b8 --- /dev/null +++ b/app/assets/javascripts/app/controllers/_integration/ldap.coffee @@ -0,0 +1,531 @@ +class Index extends App.ControllerIntegrationBase + featureIntegration: 'ldap_integration' + featureName: 'LDAP' + featureConfig: 'ldap_config' + description: [ + ['This service enables Zammad to connect with your LDAP server.'] + ] + events: + 'change .js-switch input': 'switch' + + render: => + super + new Form( + el: @$('.js-form') + ) + + #new App.ImportJob( + # el: @$('.js-importJob') + # facility: 'ldap' + #) + + new App.HttpLog( + el: @$('.js-log') + facility: 'ldap' + ) + +class Form extends App.Controller + elements: + '.js-lastImport': 'lastImport' + '.js-wizard': 'wizardButton' + events: + 'click .js-wizard': 'startWizard' + + constructor: -> + super + @render() + @lastResult() + @activeDryRun() + + currentConfig: -> + App.Setting.get('ldap_config') || {} + + setConfig: (value) => + App.Setting.set('ldap_config', value, {notify: true}) + @ajax( + id: 'jobs_config' + type: 'POST' + url: "#{@apiPath}/integration/ldap/job_start" + processData: true + success: (data, status, xhr) => + @render(true) + ) + + render: (top = false) => + @config = @currentConfig() + + @html App.view('integration/ldap')( + config: @config + ) + if _.isEmpty(@config) + @$('.js-notConfigured').removeClass('hide') + @$('.js-summary').addClass('hide') + else + @$('.js-notConfigured').addClass('hide') + @$('.js-summary').removeClass('hide') + + if top + a = => + @scrollToIfNeeded($('.content.active .page-header')) + @delay(a, 500) + + startWizard: (e) => + e.preventDefault() + new ConnectionWizard( + container: @el.closest('.content') + config: @config + callback: (config) => + @setConfig(config) + ) + + lastResult: => + @ajax( + id: 'jobs_start_index' + type: 'GET' + url: "#{@apiPath}/integration/ldap/job_start" + processData: true + success: (job, status, xhr) => + if !_.isEmpty(job) + if !@lastResultShowJob || @lastResultShowJob.updated_at != job.updated_at + @lastResultShowJob = job + @lastResultShow(job) + if job.finished_at + @wizardButton.attr('disabled', false) + else + @wizardButton.attr('disabled', true) + @delay(@lastResult, 5000) + ) + + lastResultShow: (job) => + if _.isEmpty(job) + @lastImport.html('') + return + countDone = job.result.created + job.result.updated + job.result.unchanged + job.result.skipped + if !job.result.roles + job.result.roles = {} + for role_id, statistic of job.result.role_ids + role = App.Role.find(role_id) + job.result.roles[role.displayName()] = statistic + el = $(App.view('integration/ldap_last_import')(job: job, countDone: countDone)) + @lastImport.html(el) + + activeDryRun: => + @ajax( + id: 'jobs_try_index' + type: 'GET' + url: "#{@apiPath}/integration/ldap/job_try" + data: + finished: false + processData: true + success: (job, status, xhr) => + return if _.isEmpty(job) + + # show analyzing + new ConnectionWizard( + container: @el.closest('.content') + config: job.payload + start: 'tryLoop' + callback: (config) => + @wizardButton.attr('disabled', false) + @setConfig(config) + ) + @wizardButton.attr('disabled', true) + ) + +class State + @current: -> + App.Setting.get('ldap_integration') + +class ConnectionWizard extends App.WizardModal + wizardConfig: {} + slideMethod: + 'js-bind': 'bindShow' + 'js-mapping': 'mappingShow' + + events: + 'submit form.js-discover': 'discover' + 'submit form.js-bind': 'bindChange' + 'click .js-mapping .js-submitTry': 'mappingChange' + 'click .js-try .js-submitSave': 'save' + 'click .js-close': 'hide' + 'click .js-remove': 'removeRow' + 'click .js-userMappingForm .js-add': 'addUserMapping' + 'click .js-groupRoleForm .js-add': 'addGroupRoleMapping' + 'click .js-goToSlide': 'goToSlide' + 'input .js-hostUrl': 'checkSslVerifyDisabled' + + elements: + '.modal-body': 'body' + '.js-userMappingForm': 'userMappingForm' + '.js-groupRoleForm': 'groupRoleForm' + + constructor: -> + super + + if !_.isEmpty(@config) + @wizardConfig = @config + + if @container + @el.addClass('modal--local') + + @render() + + @el.modal + keyboard: true + show: true + backdrop: true + container: @container + .on + 'show.bs.modal': @onShow + 'shown.bs.modal': @onShown + 'hidden.bs.modal': => + @el.remove() + + if @slide + @showSlide(@slide) + else + @showHost() + + if @start + @[@start]() + + render: => + @html App.view('integration/ldap_wizard')() + + save: (e) => + e.preventDefault() + @callback(@wizardConfig) + @hide(e) + + showSlide: (slide) => + method = @slideMethod[slide] + if method && @[method] + @[method](true) + super + + showHost: => + @$('.js-discover input[name="host_url"]').val(@wizardConfig.host_url) + @showSslVerify() + + showSslVerify: => + disabled = true + if @wizardConfig.host_url && @wizardConfig.host_url.startsWith('ldaps') + disabled = false + + ssl_verify = true + if typeof @wizardConfig.ssl_verify != 'undefined' + ssl_verify = @wizardConfig.ssl_verify + + sslVerifyElement = App.UiElement.boolean.render( + name: 'ssl_verify' + null: false + options: { true: 'yes', false: 'no' } + default: ssl_verify + disabled: disabled + translate: true + class: 'form-control form-control--small' + ) + @$('.js-discover .js-sslVerify').html sslVerifyElement + + checkSslVerifyDisabled: (e) => + enabled = $(e.currentTarget).val().startsWith('ldaps') + @$('.js-discover .js-sslVerify select[name="ssl_verify"]').prop('disabled', !enabled) + + discover: (e) => + e.preventDefault() + @showSlide('js-connect') + params = @formParam(e.target) + @ajax( + id: 'ldap_discover' + type: 'POST' + url: "#{@apiPath}/integration/ldap/discover" + data: JSON.stringify(params) + processData: true + success: (data, status, xhr) => + if data.result isnt 'ok' + @showSlide('js-discover') + @showAlert('js-discover', data.message) + return + + @wizardConfig.host_url = params.host_url + @wizardConfig.ssl_verify = params.ssl_verify + + option = '' + options = {} + for dn in data.attributes.namingcontexts + options[dn] = dn + if option is '' + option = dn + if option.length > dn.length + option = dn + + @wizardConfig.options = options + @wizardConfig.option = option + + @bindShow() + + error: (xhr, statusText, error) => + detailsRaw = xhr.responseText + details = {} + if !_.isEmpty(detailsRaw) + details = JSON.parse(detailsRaw) + @showSlide('js-discover') + @showAlert('js-discover', details.error || 'Unable to perform backend.') + ) + + + bindShow: (alreadyShown) => + @showSlide('js-bind') if !alreadyShown + @$('.js-bind .js-baseDn').html(@createSelection('base_dn', @wizardConfig.options, @wizardConfig.option)) + @$('.js-bind input[name="bind_user"]').val(@wizardConfig.bind_user) + @$('.js-bind input[name="bind_pw"]').val(@wizardConfig.bind_pw) + + bindChange: (e) => + e.preventDefault() + @showSlide('js-analyze') + params = @formParam(e.target) + params.host_url = @wizardConfig.host_url + params.ssl_verify = @wizardConfig.ssl_verify + @ajax( + id: 'ldap_bind' + type: 'POST' + url: "#{@apiPath}/integration/ldap/bind" + data: JSON.stringify(params) + processData: true + success: (data, status, xhr) => + if data.result isnt 'ok' + @showSlide('js-bind') + @showAlert('js-bind', data.message) + return + + if _.isEmpty(data.user_attributes) + @showSlide('js-bind') + @showAlert('js-bind', 'Unable to retrive user information, please check your bind user permissions.') + return + + if _.isEmpty(data.groups) + @showSlide('js-bind') + @showAlert('js-bind', 'Unable to retrive group information, please check your bind user permissions.') + return + + # update config if successfull + for key, value of params + @wizardConfig[key] = value + + # remember payload + user_attributes = {} + for key, value of App.User.attributesGet() + continue if key == 'login' + if (value.tag is 'input' || value.tag is 'richtext' || value.tag is 'textarea') && value.type isnt 'password' + user_attributes[key] = value.display || key + roles = {} + for role in App.Role.findAllByAttribute('active', true) + roles[role.id] = role.displayName() + + # update wizard data + @wizardConfig.wizardData= {} + @wizardConfig.wizardData.backend_user_attributes = data.user_attributes + @wizardConfig.wizardData.backend_groups = data.groups + @wizardConfig.wizardData.user_attributes = user_attributes + @wizardConfig.wizardData.roles = roles + + for key in ['user_uid', 'user_filter', 'group_uid', 'group_filter'] + @wizardConfig[key] = data[key] + + @mappingShow() + + error: (xhr, statusText, error) => + detailsRaw = xhr.responseText + details = {} + if !_.isEmpty(detailsRaw) + details = JSON.parse(detailsRaw) + @showSlide('js-bind') + @showAlert('js-bind', details.error || 'Unable to perform backend.') + ) + + mappingShow: (alreadyShown) => + @showSlide('js-mapping') if !alreadyShown + user_attribute_map = @wizardConfig.user_attributes + if _.isEmpty(user_attribute_map) + user_attribute_map = + givenname: 'firstname' + sn: 'lastname' + mail: 'email' + telephonenumber: 'phone' + + @userMappingForm.find('tbody tr.js-entry').remove() + @userMappingForm.find('tbody tr').before(@buildRowsUserMap(user_attribute_map)) + @groupRoleForm.find('tbody tr.js-entry').remove() + @groupRoleForm.find('tbody tr').before(@buildRowsGroupRole(@wizardConfig.group_role_map)) + + mappingChange: (e) => + e.preventDefault() + + # user map + user_attributes = @formParam(@userMappingForm) + for key in ['source', 'dest'] + if !_.isArray(user_attributes[key]) + user_attributes[key] = [user_attributes[key]] + user_attributes_local = + "#{@wizardConfig['user_uid']}": 'login' + length = user_attributes.source.length-1 + for count in [0..length] + if user_attributes.source[count] && user_attributes.dest[count] + user_attributes_local[user_attributes.source[count]] = user_attributes.dest[count] + @wizardConfig.user_attributes = user_attributes_local + + # group role map + group_role_map = @formParam(@groupRoleForm) + for key in ['source', 'dest'] + if !_.isArray(group_role_map[key]) + group_role_map[key] = [group_role_map[key]] + group_role_map_local = {} + length = group_role_map.source.length-1 + for count in [0..length] + if group_role_map.source[count] && group_role_map.dest[count] + group_role_map_local[group_role_map.source[count]] = group_role_map.dest[count] + @wizardConfig.group_role_map = group_role_map_local + + @tryShow() + + buildRowsUserMap: (user_attribute_map) => + + # show static login row + userUidDisplayValue = @wizardConfig.wizardData.backend_user_attributes[ @wizardConfig['user_uid'] ] + + el = [ + $(App.view('integration/ldap_user_attribute_row_read_only')( + key: userUidDisplayValue, + value: 'Login' + )) + ] + for source, dest of user_attribute_map + continue if source == @wizardConfig['user_uid'] + continue if !(source of @wizardConfig.wizardData.backend_user_attributes) + el.push @buildRowUserAttribute(source, dest) + el + + buildRowUserAttribute: (source, dest) => + el = $(App.view('integration/ldap_user_attribute_row')()) + el.find('.js-ldapAttribute').html(@createSelection('source', @wizardConfig.wizardData.backend_user_attributes, source)) + el.find('.js-userAttribute').html(@createSelection('dest', @wizardConfig.wizardData.user_attributes, dest)) + el + + buildRowsGroupRole: (group_role_map) => + el = [] + for source, dest of group_role_map + el.push @buildRowGroupRole(source, dest) + el + + buildRowGroupRole: (source, dest) => + el = $(App.view('integration/ldap_group_role_row')()) + el.find('.js-ldapList').html(@createSelection('source', @wizardConfig.wizardData.backend_groups, source)) + el.find('.js-roleList').html(@createSelection('dest', @wizardConfig.wizardData.roles, dest)) + el + + createSelection: (name, options, selected) -> + return App.UiElement.searchable_select.render( + name: name + multiple: false + limit: 100 + null: false + nulloption: false + options: options + value: selected + class: 'form-control--small' + ) + + removeRow: (e) -> + e.preventDefault() + $(e.target).closest('tr').remove() + + addUserMapping: (e) => + e.preventDefault() + @userMappingForm.find('tbody tr').last().before(@buildRowUserAttribute()) + + addGroupRoleMapping: (e) => + e.preventDefault() + @groupRoleForm.find('tbody tr').last().before(@buildRowGroupRole()) + + tryShow: (e) => + if e + e.preventDefault() + @showSlide('js-analyze') + + # create import job + @ajax( + id: 'ldap_try' + type: 'POST' + url: "#{@apiPath}/integration/ldap/job_try" + data: JSON.stringify(@wizardConfig) + processData: true + success: (data, status, xhr) => + @tryLoop() + ) + + tryLoop: => + @showSlide('js-dry') + @ajax( + id: 'jobs_try_index' + type: 'GET' + url: "#{@apiPath}/integration/ldap/job_try" + data: + finished: true + processData: true + success: (job, status, xhr) => + if job.result && job.result.error + @showSlide('js-error') + @showAlert('js-error', job.result.error) + return + + if job.result && job.result.sum + @$('.js-preprogress').addClass('hide') + @$('.js-analyzing').removeClass('hide') + total = 0 + if job.result.created + total += job.result.created + if job.result.failed + total += job.result.failed + if job.result.skipped + total += job.result.skipped + if job.result.unchanged + total += job.result.unchanged + if job.result.updated + total += job.result.updated + @$('.js-progress progress').attr('value', total) + @$('.js-progress progress').attr('max', job.result.sum) + if job.finished_at + # reset initial state in case the back button is used + @$('.js-preprogress').removeClass('hide') + @$('.js-analyzing').addClass('hide') + + @tryResult(job) + return + else + @delay(@tryLoop, 4000) + return + @hide() + ) + + tryResult: (job) => + if !job.result.roles + job.result.roles = {} + for role_id, statistic of job.result.role_ids + role = App.Role.find(role_id) + job.result.roles[role.displayName()] = statistic + countDone = job.result.created + job.result.updated + job.result.unchanged + job.result.skipped + @showSlide('js-try') + el = $(App.view('integration/ldap_summary')(job: job, countDone: countDone)) + @el.find('.js-summary').html(el) + +App.Config.set( + 'IntegrationLDAP' + { + name: 'LDAP' + target: '#system/integration/ldap' + description: 'LDAP integration for user management.' + controller: Index + state: State + } + 'NavBarIntegrations' +) diff --git a/app/assets/javascripts/app/index.coffee b/app/assets/javascripts/app/index.coffee index 0f0db9b9d..953beb12c 100644 --- a/app/assets/javascripts/app/index.coffee +++ b/app/assets/javascripts/app/index.coffee @@ -9,6 +9,133 @@ #= require_tree ./lib/app_post class App extends Spine.Controller + helper = + + # define print name helper + P: (object, attributeName, attributes) -> + App.viewPrint(object, attributeName, attributes) + + # define date format helper + date: (time) -> + return '' if !time + + timeObject = new Date(time) + d = App.Utils.formatTime(timeObject.getDate(), 2) + m = App.Utils.formatTime(timeObject.getMonth() + 1, 2) + y = timeObject.getFullYear() + "#{y}-#{m}-#{d}" + + # define datetime format helper + datetime: (time) -> + return '' if !time + + timeObject = new Date(time) + d = App.Utils.formatTime(timeObject.getDate(), 2) + m = App.Utils.formatTime(timeObject.getMonth() + 1, 2) + y = timeObject.getFullYear() + S = App.Utils.formatTime(timeObject.getSeconds(), 2) + M = App.Utils.formatTime(timeObject.getMinutes(), 2) + H = App.Utils.formatTime(timeObject.getHours(), 2) + "#{y}-#{m}-#{d} #{H}:#{M}:#{S}" + + # define decimal format helper + decimal: (data, positions = 2) -> + App.Utils.decimal(data, positions) + + # define mask helper + M: (item, start = 1, end = 2) -> + return '' if !item + string = '' + end = item.length - end - 1 + for n in [0..item.length-1] + if start <= n && end >= n + string += '*' + else + string += item[n] + string + + # define translation helper + T: (item, args...) -> + App.i18n.translateContent(item, args...) + + # define translation inline helper + Ti: (item, args...) -> + App.i18n.translateInline(item, args...) + + # define translation for date helper + Tdate: (item, args...) -> + App.i18n.translateDate(item, args...) + + # define translation for timestamp helper + Ttimestamp: (item, args...) -> + App.i18n.translateTimestamp(item, args...) + + # define linkify helper + L: (item) -> + if item && typeof item is 'string' + return App.Utils.linkify(item) + item + + # define config helper + C: (key) -> + App.Config.get(key) + + # define session helper + S: (key) -> + App.Session.get(key) + + # define address line helper + AddressLine: (line) -> + return '' if !line + items = emailAddresses.parseAddressList(line) + + # line was not parsable + return App.Utils.htmlEscape(line) if !items + + # set markup + result = '' + for item in items + if result + result = result + ', ' + if item.name + item.name = item.name + .replace(',', '') + .replace(';', '') + .replace('"', '') + .replace('\'', '') + if item.name.match(/\@|,|;|\^|\+|#|§|\$|%|&|\/|\(|\)|=|\?|\*/) + item.name = "\"#{item.name}\"" + result = "#{result}#{App.Utils.htmlEscape(item.name)} " + if item.address + result = result + " <#{App.Utils.htmlEscape(item.address)}>" + result + + # define file size helper + humanFileSize: (size) -> + App.Utils.humanFileSize(size) + + # define pretty/human time helper + humanTime: (time, escalation = false, cssClass = '') -> + timestamp = App.i18n.translateTimestamp(time) + if escalation + cssClass += ' escalation' + humanTime = App.PrettyDate.humanTime(time, escalation) + "" + + # define icon helper + Icon: (name, className = '') -> + App.Utils.icon(name, className) + + # define richtext helper + RichText: (string) -> + return string if !string + if string.match(/@T\('/) + string = string.replace(/@T\('(.+?)'\)/g, (match, capture) -> + App.i18n.translateContent(capture) + ) + return marked(string) + App.i18n.translateContent(string) + @viewPrint: (object, attributeName, attributes) -> if !attributes attributes = {} @@ -136,122 +263,7 @@ class App extends Spine.Controller @view: (name) -> template = (params = {}) -> - - # define print name helper - params.P = (object, attributeName, attributes) -> - App.viewPrint(object, attributeName, attributes) - - # define date format helper - params.date = (time) -> - return '' if !time - - timeObject = new Date(time) - d = App.Utils.formatTime(timeObject.getDate(), 2) - m = App.Utils.formatTime(timeObject.getMonth() + 1, 2) - y = timeObject.getFullYear() - "#{y}-#{m}-#{d}" - - # define datetime format helper - params.datetime = (time) -> - return '' if !time - - timeObject = new Date(time) - d = App.Utils.formatTime(timeObject.getDate(), 2) - m = App.Utils.formatTime(timeObject.getMonth() + 1, 2) - y = timeObject.getFullYear() - S = App.Utils.formatTime(timeObject.getSeconds(), 2) - M = App.Utils.formatTime(timeObject.getMinutes(), 2) - H = App.Utils.formatTime(timeObject.getHours(), 2) - "#{y}-#{m}-#{d} #{H}:#{M}:#{S}" - - # define decimal format helper - params.decimal = (data, positions = 2) -> - App.Utils.decimal(data, positions) - - # define translation helper - params.T = (item, args...) -> - App.i18n.translateContent(item, args...) - - # define translation inline helper - params.Ti = (item, args...) -> - App.i18n.translateInline(item, args...) - - # define translation for date helper - params.Tdate = (item, args...) -> - App.i18n.translateDate(item, args...) - - # define translation for timestamp helper - params.Ttimestamp = (item, args...) -> - App.i18n.translateTimestamp(item, args...) - - # define linkify helper - params.L = (item) -> - if item && typeof item is 'string' - return App.Utils.linkify(item) - item - - # define config helper - params.C = (key) -> - App.Config.get(key) - - # define session helper - params.S = (key) -> - App.Session.get(key) - - # define address line helper - params.AddressLine = (line) -> - return '' if !line - items = emailAddresses.parseAddressList(line) - - # line was not parsable - return App.Utils.htmlEscape(line) if !items - - # set markup - result = '' - for item in items - if result - result = result + ', ' - if item.name - item.name = item.name - .replace(',', '') - .replace(';', '') - .replace('"', '') - .replace('\'', '') - if item.name.match(/\@|,|;|\^|\+|#|§|\$|%|&|\/|\(|\)|=|\?|\*/) - item.name = "\"#{item.name}\"" - result = "#{result}#{App.Utils.htmlEscape(item.name)} " - if item.address - result = result + " <#{App.Utils.htmlEscape(item.address)}>" - result - - # define file size helper - params.humanFileSize = (size) -> - App.Utils.humanFileSize(size) - - # define pretty/human time helper - params.humanTime = (time, escalation = false, cssClass = '') -> - timestamp = App.i18n.translateTimestamp(time) - if escalation - cssClass += ' escalation' - humanTime = App.PrettyDate.humanTime(time, escalation) - "" - - # define icon helper - params.Icon = (name, className = '') -> - App.Utils.icon(name, className) - - # define richtext helper - params.RichText = (string) -> - return string if !string - if string.match(/@T\('/) - string = string.replace(/@T\('(.+?)'\)/g, (match, capture) -> - App.i18n.translateContent(capture) - ) - return marked(string) - App.i18n.translateContent(string) - - # define template - JST["app/views/#{name}"](params) + JST["app/views/#{name}"](_.extend(params, helper)) template class App.UiElement diff --git a/app/assets/javascripts/app/views/integration/ldap.jst.eco b/app/assets/javascripts/app/views/integration/ldap.jst.eco new file mode 100644 index 000000000..5cfed344f --- /dev/null +++ b/app/assets/javascripts/app/views/integration/ldap.jst.eco @@ -0,0 +1,87 @@ +
+
+

<%- @T('No %s configured.', 'LDAP') %>

+ +
+
+

<%- @T('Settings') %>

+ + + + + + + + + + + + + + + +
<%- @T('Name') %> + <%- @T('Value') %> +
<%- @T('LDAP Host') %> + <%= @config.host_url %> +
<%- @T('Base DN') %> + <%= @config.base_dn %> +
<%- @T('Bind User') %> + <%= @config.bind_user %> +
<%- @T('Bind Password') %> + <%= @M(@config.bind_pw) %> +
<%- @T('UID') %> + <%= @config.user_uid %> +
<%- @T('User Filter') %> + <%= @config.user_filter %> +
<%- @T('GID') %> + <%= @config.group_uid %> +
<%- @T('Group Filter') %> + <%= @config.group_filter %> +
+ +

<%- @T('Mapping') %>

+ +

<%- @T('User') %>

+ <% if _.isEmpty(@config.user_attributes): %> + +
<%- @T('No Entries') %> +
+ <% else: %> + + + + + + +
<%- @T('LDAP') %> + <%- @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: %> + + + + + <% for key, value of @config.group_role_map: %> + +
<%- @T('LDAP') %> + <%- @T('Zammad') %> +
<%= 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: %> + + <% else: %> +

<%- @T('Job is waiting to get started...') %>

+ <% end %> + <% else: %> + <% if @job.finished_at: %> +

<%- @Ttimestamp(@job.started_at) %> - <%- @Ttimestamp(@job.finished_at) %>

+ <% if @job.result && @job.result.error: %> + + <% end %> + <% else: %> + <% if @job.result && @job.result.error: %> +

<%- @Ttimestamp(@job.started_at) %>

+ + <% else if !@countDone: %> +

<%- @Ttimestamp(@job.started_at) %> - <%- @T('Counting entries. This may take a while.') %>

+ <% else: %> +

<%- @Ttimestamp(@job.started_at) %> - <%- @T('Running...') %>

+
+ +
+ + <% end %> + <% end %> + <% end %> +
diff --git a/app/assets/javascripts/app/views/integration/ldap_summary.jst.eco b/app/assets/javascripts/app/views/integration/ldap_summary.jst.eco new file mode 100644 index 000000000..1031ba1df --- /dev/null +++ b/app/assets/javascripts/app/views/integration/ldap_summary.jst.eco @@ -0,0 +1,11 @@ + 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 @@ + 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)