Initial version of LDAP user sync support.
This commit is contained in:
parent
51dfc921ea
commit
c9b2255e4f
62 changed files with 4320 additions and 383 deletions
531
app/assets/javascripts/app/controllers/_integration/ldap.coffee
Normal file
531
app/assets/javascripts/app/controllers/_integration/ldap.coffee
Normal file
|
@ -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'
|
||||||
|
)
|
|
@ -9,6 +9,133 @@
|
||||||
#= require_tree ./lib/app_post
|
#= require_tree ./lib/app_post
|
||||||
|
|
||||||
class App extends Spine.Controller
|
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 + " <span class=\"text-muted\"><#{App.Utils.htmlEscape(item.address)}></span>"
|
||||||
|
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)
|
||||||
|
"<time class=\"humanTimeFromNow #{cssClass}\" data-time=\"#{time}\" title=\"#{timestamp}\">#{humanTime}</time>"
|
||||||
|
|
||||||
|
# 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) ->
|
@viewPrint: (object, attributeName, attributes) ->
|
||||||
if !attributes
|
if !attributes
|
||||||
attributes = {}
|
attributes = {}
|
||||||
|
@ -136,122 +263,7 @@ class App extends Spine.Controller
|
||||||
|
|
||||||
@view: (name) ->
|
@view: (name) ->
|
||||||
template = (params = {}) ->
|
template = (params = {}) ->
|
||||||
|
JST["app/views/#{name}"](_.extend(params, helper))
|
||||||
# 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 + " <span class=\"text-muted\"><#{App.Utils.htmlEscape(item.address)}></span>"
|
|
||||||
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)
|
|
||||||
"<time class=\"humanTimeFromNow #{cssClass}\" data-time=\"#{time}\" title=\"#{timestamp}\">#{humanTime}</time>"
|
|
||||||
|
|
||||||
# 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)
|
|
||||||
template
|
template
|
||||||
|
|
||||||
class App.UiElement
|
class App.UiElement
|
||||||
|
|
87
app/assets/javascripts/app/views/integration/ldap.jst.eco
Normal file
87
app/assets/javascripts/app/views/integration/ldap.jst.eco
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
<div class="js-lastImport"></div>
|
||||||
|
<div class="js-notConfigured">
|
||||||
|
<p><%- @T('No %s configured.', 'LDAP') %></p>
|
||||||
|
<button type="submit" class="btn btn--primary js-wizard"><%- @T('Configure') %></button>
|
||||||
|
</div>
|
||||||
|
<div class="js-summary hide">
|
||||||
|
<h2><%- @T('Settings') %></h2>
|
||||||
|
<table class="settings-list" style="width: 100%;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th width="30%"><%- @T('Name') %>
|
||||||
|
<th width="70%"><%- @T('Value') %>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control"><%- @T('LDAP Host') %>
|
||||||
|
<td class="settings-list-row-control"><%= @config.host_url %>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control"><%- @T('Base DN') %>
|
||||||
|
<td class="settings-list-row-control"><%= @config.base_dn %>
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control"><%- @T('Bind User') %>
|
||||||
|
<td class="settings-list-row-control"><%= @config.bind_user %>
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control"><%- @T('Bind Password') %>
|
||||||
|
<td class="settings-list-row-control"><%= @M(@config.bind_pw) %>
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control"><%- @T('UID') %>
|
||||||
|
<td class="settings-list-row-control"><%= @config.user_uid %>
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control"><%- @T('User Filter') %>
|
||||||
|
<td class="settings-list-row-control"><%= @config.user_filter %>
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control"><%- @T('GID') %>
|
||||||
|
<td class="settings-list-row-control"><%= @config.group_uid %>
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control"><%- @T('Group Filter') %>
|
||||||
|
<td class="settings-list-row-control"><%= @config.group_filter %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2><%- @T('Mapping') %></h2>
|
||||||
|
|
||||||
|
<h3><%- @T('User') %></h3>
|
||||||
|
<% if _.isEmpty(@config.user_attributes): %>
|
||||||
|
<table class="settings-list settings-list--stretch settings-list--placeholder">
|
||||||
|
<thead><tr><th><%- @T('No Entries') %>
|
||||||
|
</table>
|
||||||
|
<% else: %>
|
||||||
|
<table class="settings-list" style="width: 100%;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th width="40%"><%- @T('LDAP') %>
|
||||||
|
<th width="60%"><%- @T('Zammad') %>
|
||||||
|
<% for key, value of @config.user_attributes: %>
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control"><%= key %>
|
||||||
|
<td class="settings-list-row-control"><%= value %>
|
||||||
|
<% end %>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
</table>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<h3><%- @T('Role') %></h3>
|
||||||
|
<% if _.isEmpty(@config.group_role_map): %>
|
||||||
|
<table class="settings-list settings-list--stretch settings-list--placeholder">
|
||||||
|
<thead><tr><th><%- @T('No Entries') %>
|
||||||
|
</table>
|
||||||
|
<% else: %>
|
||||||
|
<table class="settings-list" style="width: 100%;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th width="40%"><%- @T('LDAP') %>
|
||||||
|
<th width="60%"><%- @T('Zammad') %>
|
||||||
|
<tbody>
|
||||||
|
<% for key, value of @config.group_role_map: %>
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control"><%= key %>
|
||||||
|
<td class="settings-list-row-control"><%= App.Role.find(value).displayName() %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn--primary js-wizard"><%- @T('Change') %></button>
|
||||||
|
</div>
|
|
@ -0,0 +1,7 @@
|
||||||
|
<tr class="js-entry">
|
||||||
|
<td style="max-width: 240px" class="settings-list-control-cell js-ldapList">
|
||||||
|
<td class="settings-list-control-cell js-roleList">
|
||||||
|
<td class="settings-list-row-control">
|
||||||
|
<div class="btn btn--text js-remove">
|
||||||
|
<%- @Icon('trash') %> <%- @T('Remove') %>
|
||||||
|
</div>
|
|
@ -0,0 +1,40 @@
|
||||||
|
<div class="box box--message">
|
||||||
|
<h2><%- @T('Last sync') %></h2>
|
||||||
|
<% if _.isEmpty(@job.started_at): %>
|
||||||
|
<% if @job.result && @job.result.error: %>
|
||||||
|
<div class="alert alert--danger" role="alert"><%- @T('An error occurred: %s', @job.result.error) %></div>
|
||||||
|
<% else: %>
|
||||||
|
<p><%- @T('Job is waiting to get started...') %></p>
|
||||||
|
<% end %>
|
||||||
|
<% else: %>
|
||||||
|
<% if @job.finished_at: %>
|
||||||
|
<p><%- @Ttimestamp(@job.started_at) %> - <%- @Ttimestamp(@job.finished_at) %></p>
|
||||||
|
<% if @job.result && @job.result.error: %>
|
||||||
|
<div class="alert alert--danger" role="alert"><%- @T('An error occurred: %s', @job.result.error) %></div>
|
||||||
|
<% end %>
|
||||||
|
<% else: %>
|
||||||
|
<% if @job.result && @job.result.error: %>
|
||||||
|
<p><%- @Ttimestamp(@job.started_at) %></p>
|
||||||
|
<div class="alert alert--danger" role="alert"><%- @T('An error occurred: %s', @job.result.error) %></div>
|
||||||
|
<% else if !@countDone: %>
|
||||||
|
<p><%- @Ttimestamp(@job.started_at) %> - <%- @T('Counting entries. This may take a while.') %></p>
|
||||||
|
<% else: %>
|
||||||
|
<p><%- @Ttimestamp(@job.started_at) %> - <%- @T('Running...') %></p>
|
||||||
|
<div class="flex">
|
||||||
|
<progress max="<%= @job.result.sum %>" value="<%= @countDone %>"></progress>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li><%- @T('Users') %>: <%= @countDone %>/<%= @job.result.sum %> (<%= @job.result.created %> <%- @T('created') %>, <%= @job.result.updated %> <%- @T('updated') %>, <%= @job.result.unchanged %> <%- @T('untouched') %>, <%= @job.result.skipped %> <%- @T('skipped') %>, <%= @job.result.failed %> <%- @T('failed') %>)
|
||||||
|
<% if !_.isEmpty(@job.result.roles): %>
|
||||||
|
<li><%- @T('LDAP groups to Zammad roles assignments') %>:
|
||||||
|
<ul>
|
||||||
|
<% for role, result of @job.result.roles: %>
|
||||||
|
<li> <%= @T(role) %>: <%= result.created %> <%- @T('created') %>, <%= result.updated %> <%- @T('updated') %>, <%= result.unchanged %> <%- @T('untouched') %>, <%= result.failed %> <%- @T('failed') %>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
|
@ -0,0 +1,11 @@
|
||||||
|
<ul>
|
||||||
|
<li><%- @T('Users') %>: <%= @countDone %> (<%= @job.result.created %> <%- @T('created') %>, <%= @job.result.updated %> <%- @T('updated') %>, <%= @job.result.unchanged %> <%- @T('untouched') %>, <%= @job.result.skipped %> <%- @T('skipped') %>, <%= @job.result.failed %> <%- @T('failed') %>)
|
||||||
|
<% if !_.isEmpty(@job.result.roles): %>
|
||||||
|
<li><%- @T('LDAP groups to Zammad roles assignments') %>:
|
||||||
|
<ul>
|
||||||
|
<% for role, result of @job.result.roles: %>
|
||||||
|
<li><%- @T(role) %>: <%= result.created %> <%- @T('created') %>, <%= result.updated %> <%- @T('updated') %>, <%= result.unchanged %> <%- @T('untouched') %>, <%= result.failed %> <%- @T('failed') %>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
|
@ -0,0 +1,7 @@
|
||||||
|
<tr class="js-entry">
|
||||||
|
<td style="max-width: 240px" class="settings-list-control-cell js-ldapAttribute">
|
||||||
|
<td class="settings-list-control-cell js-userAttribute">
|
||||||
|
<td class="settings-list-row-control">
|
||||||
|
<div class="btn btn--text js-remove">
|
||||||
|
<%- @Icon('trash') %> <%- @T('Remove') %>
|
||||||
|
</div>
|
|
@ -0,0 +1,6 @@
|
||||||
|
<tr class="js-entry">
|
||||||
|
<td style="max-width: 240px" class="settings-list-row-control">
|
||||||
|
<div class="u-textTruncate"><%= @key %></div>
|
||||||
|
<td class="settings-list-row-control">
|
||||||
|
<%= @value %>
|
||||||
|
<td class="settings-list-row-control">
|
254
app/assets/javascripts/app/views/integration/ldap_wizard.jst.eco
Normal file
254
app/assets/javascripts/app/views/integration/ldap_wizard.jst.eco
Normal file
|
@ -0,0 +1,254 @@
|
||||||
|
<div class="modal-dialog">
|
||||||
|
|
||||||
|
<form class="modal-content setup wizard js-discover">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-close js-close">
|
||||||
|
<%- @Icon('diagonal-cross') %>
|
||||||
|
</div>
|
||||||
|
<h1 class="modal-title"><%- @T('LDAP') %> <%- @T('Configuration') %></h1>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="wizard-body vertical justified">
|
||||||
|
<div class="alert alert--danger hide" role="alert"></div>
|
||||||
|
<table class="settings-list" style="width: 100%;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th width="30%"><%- @T('Name') %>
|
||||||
|
<th width="70%"><%- @T('Value') %>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control"><%- @T('Host') %>
|
||||||
|
<td class="settings-list-control-cell"><input type="text" name="host_url" class="form-control form-control--small js-hostUrl" value="" placeholder="ldaps://ldap.example.com" autocomplete="new-password">
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control"><%- @T('SSL verification') %>
|
||||||
|
<td class="settings-list-control-cell js-sslVerify">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div class="modal-rightFooter">
|
||||||
|
<button class="btn btn--primary align-right js-submit"><%- @T('Connect') %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="modal-content setup wizard hide js-connect">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-close js-close">
|
||||||
|
<%- @Icon('diagonal-cross') %>
|
||||||
|
</div>
|
||||||
|
<h1 class="modal-title"><%- @T('LDAP') %> <%- @T('Configuration') %></h1>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="wizard-body vertical justified">
|
||||||
|
<p class="wizard-loadingText">
|
||||||
|
<span class="loading icon"></span> <%- @T('Connecting ...') %> <span class="js-host"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer"></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="modal-content setup wizard hide js-bind">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-close js-close">
|
||||||
|
<%- @Icon('diagonal-cross') %>
|
||||||
|
</div>
|
||||||
|
<h1 class="modal-title"><%- @T('LDAP') %> <%- @T('Configuration') %></h1>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="wizard-body vertical justified">
|
||||||
|
<div class="alert alert--danger hide" role="alert"></div>
|
||||||
|
<table class="settings-list" style="width: 100%;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th width="30%"><%- @T('Name') %>
|
||||||
|
<th width="70%"><%- @T('Value') %>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control"><%- @T('Base DN') %>
|
||||||
|
<td class="settings-list-control-cell js-baseDn">
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control"><%- @T('Bind User') %>
|
||||||
|
<td class="settings-list-control-cell"><input type="text" name="bind_user" class="form-control form-control--small" value="" placeholder="" autocomplete="new-password">
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control"><%- @T('Bind Password') %>
|
||||||
|
<td class="settings-list-control-cell"><input type="password" name="bind_pw" class="form-control form-control--small" value="" autocomplete="new-password">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div class="modal-rightFooter">
|
||||||
|
<a class="btn btn--text btn--secondary js-goToSlide align-left" data-slide="js-discover"><%- @T('Go Back') %></a>
|
||||||
|
</div>
|
||||||
|
<div class="modal-rightFooter">
|
||||||
|
<button class="btn btn--primary align-right js-submit"><%- @T('Continue') %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="modal-content setup wizard hide js-analyze">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-close js-close">
|
||||||
|
<%- @Icon('diagonal-cross') %>
|
||||||
|
</div>
|
||||||
|
<h1 class="modal-title"><%- @T('LDAP') %> <%- @T('Configuration') %></h1>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="wizard-body vertical justified">
|
||||||
|
<p class="wizard-loadingText">
|
||||||
|
<span class="loading icon"></span> <%- @T('Analyzing structure...') %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer"></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form class="modal-content setup wizard hide js-dry">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-close js-close">
|
||||||
|
<%- @Icon('diagonal-cross') %>
|
||||||
|
</div>
|
||||||
|
<h1 class="modal-title"><%- @T('LDAP') %> <%- @T('Configuration') %></h1>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="wizard-body vertical justified">
|
||||||
|
<div class="js-preprogress">
|
||||||
|
<p class="wizard-loadingText">
|
||||||
|
<span class="loading icon"></span>
|
||||||
|
<%- @T('Counting entries. This may take a while.') %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="js-analyzing hide">
|
||||||
|
<p class="wizard-loadingText">
|
||||||
|
<%- @T('Analyzing entries with given configuration...') %>
|
||||||
|
</p>
|
||||||
|
<div class="centered js-progress">
|
||||||
|
<progress max="" value=""></progress>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer"></div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="modal-content setup wizard hide js-mapping">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-close js-close">
|
||||||
|
<%- @Icon('diagonal-cross') %>
|
||||||
|
</div>
|
||||||
|
<h1 class="modal-title"><%- @T('LDAP') %> <%- @T('Mapping') %></h1>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="wizard-body vertical justified">
|
||||||
|
<div class="alert alert--danger hide" role="alert"></div>
|
||||||
|
|
||||||
|
<h2><%- @T('User') %></h2>
|
||||||
|
<form class="js-userMappingForm">
|
||||||
|
<table class="settings-list js-userAttributeMap" style="width: 100%;">
|
||||||
|
<colgroup>
|
||||||
|
<col width="240">
|
||||||
|
<col>
|
||||||
|
<col>
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><%- @T('LDAP Attribute') %>
|
||||||
|
<th><%- @T('Zammad Attribute') %>
|
||||||
|
<th><%- @T('Action') %>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control" colspan="3">
|
||||||
|
<div class="btn btn--text btn--create js-add">
|
||||||
|
<%- @Icon('plus-small') %> <%- @T('Add') %>
|
||||||
|
</div>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<h2><%- @T('Roles') %></h2>
|
||||||
|
<p><%- @T('Note: All not mapped users will get the default signup roles.') %></p>
|
||||||
|
<form class="js-groupRoleForm">
|
||||||
|
<table class="settings-list js-groupRoleMap" style="width: 100%;">
|
||||||
|
<colgroup>
|
||||||
|
<col width="240">
|
||||||
|
<col>
|
||||||
|
<col>
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><%- @T('LDAP Group') %>
|
||||||
|
<th><%- @T('Zammad Role') %>
|
||||||
|
<th><%- @T('Action') %>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td class="settings-list-row-control" colspan="3">
|
||||||
|
<div class="btn btn--text btn--create js-add">
|
||||||
|
<%- @Icon('plus-small') %> <%- @T('Add') %>
|
||||||
|
</div>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div class="modal-rightFooter">
|
||||||
|
<a class="btn btn--text btn--secondary js-goToSlide align-left" data-slide="js-bind"><%- @T('Go Back') %></a>
|
||||||
|
</div>
|
||||||
|
<div class="modal-rightFooter">
|
||||||
|
<button class="btn btn--primary align-right js-submitTry"><%- @T('Continue') %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-content setup wizard hide js-try">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-close js-close">
|
||||||
|
<%- @Icon('diagonal-cross') %>
|
||||||
|
</div>
|
||||||
|
<h1 class="modal-title"><%- @T('LDAP') %> <%- @T('Configuration') %></h1>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="wizard-body vertical justified">
|
||||||
|
<div class="alert alert--danger hide" role="alert"></div>
|
||||||
|
<p><%- @T('With your current configuration the following will happen') %>:</p>
|
||||||
|
<div class="js-summary"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div class="modal-rightFooter">
|
||||||
|
<a class="btn btn--text btn--secondary js-goToSlide align-left" data-slide="js-mapping"><%- @T('Go Back') %></a>
|
||||||
|
</div>
|
||||||
|
<div class="modal-rightFooter">
|
||||||
|
<button class="btn btn--primary align-right js-submitSave"><%- @T('Save and start sync') %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="modal-content setup wizard hide js-error">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-close js-close">
|
||||||
|
<%- @Icon('diagonal-cross') %>
|
||||||
|
</div>
|
||||||
|
<h1 class="modal-title"><%- @T('LDAP') %></h1>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="wizard-body vertical justified">
|
||||||
|
<div class="alert alert--danger hide" role="alert"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<div class="modal-rightFooter">
|
||||||
|
<button class="btn btn--primary align-right"><%- @T('Cancel') %></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
|
@ -7474,6 +7474,11 @@ output {
|
||||||
|
|
||||||
.searchableSelect-main {
|
.searchableSelect-main {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
&.form-control--small ~ .searchableSelect-autocomplete {
|
||||||
|
top: 7px;
|
||||||
|
left: 9px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.searchableSelect-shadow {
|
.searchableSelect-shadow {
|
||||||
|
|
|
@ -13,17 +13,17 @@ module ApplicationController::HandlesErrors
|
||||||
end
|
end
|
||||||
|
|
||||||
def not_found(e)
|
def not_found(e)
|
||||||
log_error_exception(e)
|
logger.error e
|
||||||
respond_to_exception(e, :not_found)
|
respond_to_exception(e, :not_found)
|
||||||
end
|
end
|
||||||
|
|
||||||
def unprocessable_entity(e)
|
def unprocessable_entity(e)
|
||||||
log_error_exception(e)
|
logger.error e
|
||||||
respond_to_exception(e, :unprocessable_entity)
|
respond_to_exception(e, :unprocessable_entity)
|
||||||
end
|
end
|
||||||
|
|
||||||
def internal_server_error(e)
|
def internal_server_error(e)
|
||||||
log_error_exception(e)
|
logger.error e
|
||||||
respond_to_exception(e, :internal_server_error)
|
respond_to_exception(e, :internal_server_error)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -35,11 +35,6 @@ module ApplicationController::HandlesErrors
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def log_error_exception(e)
|
|
||||||
logger.error e.message
|
|
||||||
logger.error e.backtrace.inspect
|
|
||||||
end
|
|
||||||
|
|
||||||
def respond_to_exception(e, status)
|
def respond_to_exception(e, status)
|
||||||
status_code = Rack::Utils.status_code(status)
|
status_code = Rack::Utils.status_code(status)
|
||||||
|
|
||||||
|
|
|
@ -22,8 +22,7 @@ class CalendarSubscriptionsController < ApplicationController
|
||||||
disposition: 'inline'
|
disposition: 'inline'
|
||||||
)
|
)
|
||||||
rescue => e
|
rescue => e
|
||||||
logger.error e.message
|
logger.error e
|
||||||
logger.error e.backtrace.inspect
|
|
||||||
render json: { error: e.message }, status: :unprocessable_entity
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -45,8 +44,7 @@ class CalendarSubscriptionsController < ApplicationController
|
||||||
disposition: 'inline'
|
disposition: 'inline'
|
||||||
)
|
)
|
||||||
rescue => e
|
rescue => e
|
||||||
logger.error e.message
|
logger.error e
|
||||||
logger.error e.backtrace.inspect
|
|
||||||
render json: { error: e.message }, status: :unprocessable_entity
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
95
app/controllers/integration/ldap_controller.rb
Normal file
95
app/controllers/integration/ldap_controller.rb
Normal file
|
@ -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
|
|
@ -14,6 +14,7 @@ class ApplicationModel < ActiveRecord::Base
|
||||||
include ApplicationModel::HasAssociations
|
include ApplicationModel::HasAssociations
|
||||||
include ApplicationModel::HasAttachments
|
include ApplicationModel::HasAttachments
|
||||||
include ApplicationModel::HasLatestChangeTimestamp
|
include ApplicationModel::HasLatestChangeTimestamp
|
||||||
|
include ApplicationModel::HasExternalSync
|
||||||
include ApplicationModel::Importable
|
include ApplicationModel::Importable
|
||||||
include ApplicationModel::HistoryLoggable
|
include ApplicationModel::HistoryLoggable
|
||||||
include ApplicationModel::TouchesReferences
|
include ApplicationModel::TouchesReferences
|
||||||
|
|
15
app/models/application_model/has_external_sync.rb
Normal file
15
app/models/application_model/has_external_sync.rb
Normal file
|
@ -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
|
|
@ -23,8 +23,7 @@ touch references by params
|
||||||
return if !object
|
return if !object
|
||||||
object.touch
|
object.touch
|
||||||
rescue => e
|
rescue => e
|
||||||
logger.error e.message
|
logger.error e
|
||||||
logger.error e.backtrace.inspect
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -426,8 +426,7 @@ returns
|
||||||
p message # rubocop:disable Rails/Output
|
p message # rubocop:disable Rails/Output
|
||||||
p 'ERROR: ' + e.inspect # rubocop:disable Rails/Output
|
p 'ERROR: ' + e.inspect # rubocop:disable Rails/Output
|
||||||
Rails.logger.error message
|
Rails.logger.error message
|
||||||
Rails.logger.error 'ERROR: ' + e.inspect
|
Rails.logger.error e
|
||||||
Rails.logger.error 'ERROR: ' + e.backtrace.inspect
|
|
||||||
File.open(filename, 'wb') { |file|
|
File.open(filename, 'wb') { |file|
|
||||||
file.write msg
|
file.write msg
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,7 +68,7 @@ class ExternalSync < ApplicationModel
|
||||||
break if !value
|
break if !value
|
||||||
|
|
||||||
storable = value.class.ancestors.any? do |ancestor|
|
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
|
end
|
||||||
|
|
||||||
if storable
|
if storable
|
||||||
|
|
71
app/models/import_job.rb
Normal file
71
app/models/import_job.rb
Normal file
|
@ -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
|
|
@ -218,30 +218,61 @@ returns
|
||||||
# do not authenticate with nothing
|
# do not authenticate with nothing
|
||||||
return if username.blank? || password.blank?
|
return if username.blank? || password.blank?
|
||||||
|
|
||||||
# try to find user based on login
|
user = User.identify(username)
|
||||||
user = User.find_by(login: username.downcase, active: true)
|
return if !user
|
||||||
|
|
||||||
# try second lookup with email
|
return if !Auth.can_login?(user)
|
||||||
user ||= User.find_by(email: username.downcase, active: true)
|
|
||||||
|
|
||||||
# check failed logins
|
return user if Auth.valid?(user, password)
|
||||||
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
|
sleep 1
|
||||||
user.login_failed += 1
|
user.login_failed += 1
|
||||||
user.save
|
user.save
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
# auth ok
|
=begin
|
||||||
user_auth
|
|
||||||
|
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: identifier.downcase)
|
||||||
|
return user if user
|
||||||
|
|
||||||
|
# try second lookup with email
|
||||||
|
User.find_by(email: identifier.downcase)
|
||||||
end
|
end
|
||||||
|
|
||||||
=begin
|
=begin
|
||||||
|
|
10
config/routes/integration_ldap.rb
Normal file
10
config/routes/integration_ldap.rb
Normal file
|
@ -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
|
|
@ -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, [: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]
|
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|
|
create_table :cti_logs do |t|
|
||||||
t.string :direction, limit: 20, null: false
|
t.string :direction, limit: 20, null: false
|
||||||
t.string :state, limit: 20, null: false
|
t.string :state, limit: 20, null: false
|
||||||
|
|
91
db/migrate/20170321000001_ldap_support.rb
Normal file
91
db/migrate/20170321000001_ldap_support.rb
Normal file
|
@ -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
|
67
db/seeds.rb
67
db/seeds.rb
|
@ -2485,6 +2485,46 @@ Setting.create_if_not_exists(
|
||||||
},
|
},
|
||||||
frontend: false
|
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(
|
Setting.create_if_not_exists(
|
||||||
title: 'Defines sync transaction backend.',
|
title: 'Defines sync transaction backend.',
|
||||||
name: '0100_trigger',
|
name: '0100_trigger',
|
||||||
|
@ -5344,21 +5384,21 @@ ObjectManager::Attribute.add(
|
||||||
Scheduler.create_if_not_exists(
|
Scheduler.create_if_not_exists(
|
||||||
name: 'Process pending tickets',
|
name: 'Process pending tickets',
|
||||||
method: 'Ticket.process_pending',
|
method: 'Ticket.process_pending',
|
||||||
period: 60 * 15,
|
period: 15.minutes,
|
||||||
prio: 1,
|
prio: 1,
|
||||||
active: true,
|
active: true,
|
||||||
)
|
)
|
||||||
Scheduler.create_if_not_exists(
|
Scheduler.create_if_not_exists(
|
||||||
name: 'Process escalation tickets',
|
name: 'Process escalation tickets',
|
||||||
method: 'Ticket.process_escalation',
|
method: 'Ticket.process_escalation',
|
||||||
period: 60 * 5,
|
period: 5.minutes,
|
||||||
prio: 1,
|
prio: 1,
|
||||||
active: true,
|
active: true,
|
||||||
)
|
)
|
||||||
Scheduler.create_if_not_exists(
|
Scheduler.create_if_not_exists(
|
||||||
name: 'Import OTRS diff load',
|
name: 'Import OTRS diff load',
|
||||||
method: 'Import::OTRS.diff_worker',
|
method: 'Import::OTRS.diff_worker',
|
||||||
period: 60 * 3,
|
period: 3.minutes,
|
||||||
prio: 1,
|
prio: 1,
|
||||||
active: true,
|
active: true,
|
||||||
updated_by_id: 1,
|
updated_by_id: 1,
|
||||||
|
@ -5367,7 +5407,7 @@ Scheduler.create_if_not_exists(
|
||||||
Scheduler.create_if_not_exists(
|
Scheduler.create_if_not_exists(
|
||||||
name: 'Check Channels',
|
name: 'Check Channels',
|
||||||
method: 'Channel.fetch',
|
method: 'Channel.fetch',
|
||||||
period: 30,
|
period: 30.seconds,
|
||||||
prio: 1,
|
prio: 1,
|
||||||
active: true,
|
active: true,
|
||||||
updated_by_id: 1,
|
updated_by_id: 1,
|
||||||
|
@ -5376,7 +5416,7 @@ Scheduler.create_if_not_exists(
|
||||||
Scheduler.create_if_not_exists(
|
Scheduler.create_if_not_exists(
|
||||||
name: 'Check streams for Channel',
|
name: 'Check streams for Channel',
|
||||||
method: 'Channel.stream',
|
method: 'Channel.stream',
|
||||||
period: 60,
|
period: 60.seconds,
|
||||||
prio: 1,
|
prio: 1,
|
||||||
active: true,
|
active: true,
|
||||||
updated_by_id: 1,
|
updated_by_id: 1,
|
||||||
|
@ -5385,7 +5425,7 @@ Scheduler.create_if_not_exists(
|
||||||
Scheduler.create_if_not_exists(
|
Scheduler.create_if_not_exists(
|
||||||
name: 'Generate Session data',
|
name: 'Generate Session data',
|
||||||
method: 'Sessions.jobs',
|
method: 'Sessions.jobs',
|
||||||
period: 60,
|
period: 60.seconds,
|
||||||
prio: 1,
|
prio: 1,
|
||||||
active: true,
|
active: true,
|
||||||
updated_by_id: 1,
|
updated_by_id: 1,
|
||||||
|
@ -5394,7 +5434,7 @@ Scheduler.create_if_not_exists(
|
||||||
Scheduler.create_if_not_exists(
|
Scheduler.create_if_not_exists(
|
||||||
name: 'Execute jobs',
|
name: 'Execute jobs',
|
||||||
method: 'Job.run',
|
method: 'Job.run',
|
||||||
period: 5 * 60,
|
period: 5.minutes,
|
||||||
prio: 2,
|
prio: 2,
|
||||||
active: true,
|
active: true,
|
||||||
updated_by_id: 1,
|
updated_by_id: 1,
|
||||||
|
@ -5448,7 +5488,7 @@ Scheduler.create_or_update(
|
||||||
Scheduler.create_or_update(
|
Scheduler.create_or_update(
|
||||||
name: 'Closed chat sessions where participients are offline.',
|
name: 'Closed chat sessions where participients are offline.',
|
||||||
method: 'Chat.cleanup_close',
|
method: 'Chat.cleanup_close',
|
||||||
period: 60 * 15,
|
period: 15.minutes,
|
||||||
prio: 2,
|
prio: 2,
|
||||||
active: true,
|
active: true,
|
||||||
updated_by_id: 1,
|
updated_by_id: 1,
|
||||||
|
@ -5493,12 +5533,21 @@ Scheduler.create_or_update(
|
||||||
Scheduler.create_if_not_exists(
|
Scheduler.create_if_not_exists(
|
||||||
name: 'Cleanup HttpLog',
|
name: 'Cleanup HttpLog',
|
||||||
method: 'HttpLog.cleanup',
|
method: 'HttpLog.cleanup',
|
||||||
period: 24 * 60 * 60,
|
period: 1.day,
|
||||||
prio: 2,
|
prio: 2,
|
||||||
active: true,
|
active: true,
|
||||||
updated_by_id: 1,
|
updated_by_id: 1,
|
||||||
created_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(
|
Trigger.create_or_update(
|
||||||
name: 'auto reply (on new tickets)',
|
name: 'auto reply (on new tickets)',
|
||||||
|
|
107
lib/auth.rb
107
lib/auth.rb
|
@ -5,17 +5,83 @@ class Auth
|
||||||
|
|
||||||
=begin
|
=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
|
returns
|
||||||
|
|
||||||
result = user_model # if authentication was successfully
|
result = true | false
|
||||||
|
|
||||||
=end
|
=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
|
# use std. auth backends
|
||||||
config = [
|
config = [
|
||||||
|
@ -28,33 +94,24 @@ returns
|
||||||
]
|
]
|
||||||
|
|
||||||
# added configured backends
|
# added configured backends
|
||||||
Setting.where(area: 'Security::Authentication').each { |setting|
|
Setting.where(area: 'Security::Authentication').each do |setting|
|
||||||
if setting.state_current[:value]
|
next if setting.state_current[:value].blank?
|
||||||
config.push setting.state_current[:value]
|
config.push setting.state_current[:value]
|
||||||
end
|
end
|
||||||
}
|
|
||||||
|
|
||||||
# try to login against configure auth backends
|
config
|
||||||
user_auth = nil
|
end
|
||||||
config.each { |config_item|
|
|
||||||
next if !config_item[:adapter]
|
def self.backend_validates?(config:, user:, password:)
|
||||||
|
return false if !config[:adapter]
|
||||||
|
|
||||||
# load backend
|
# load backend
|
||||||
backend = load_adapter(config_item[:adapter])
|
backend = load_adapter(config[:adapter])
|
||||||
next if !backend
|
return false if !backend
|
||||||
|
|
||||||
user_auth = backend.check(username, password, config_item, user)
|
instance = backend.new(config)
|
||||||
|
|
||||||
# auth not ok
|
instance.valid?(user, password)
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
private_class_method :backend_validates?
|
||||||
end
|
end
|
||||||
|
|
14
lib/auth/base.rb
Normal file
14
lib/auth/base.rb
Normal file
|
@ -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
|
|
@ -1,14 +1,14 @@
|
||||||
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
|
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
|
||||||
|
|
||||||
module Auth::Developer
|
class Auth
|
||||||
def self.check(username, password, _config, user)
|
class Developer < Auth::Base
|
||||||
|
|
||||||
# development systems
|
def valid?(user, password)
|
||||||
return false if !username
|
return false if user.blank?
|
||||||
return false if !user
|
|
||||||
return false if Setting.get('developer_mode') != true
|
return false if Setting.get('developer_mode') != true
|
||||||
return false if password != 'test'
|
return false if password != 'test'
|
||||||
Rails.logger.info "System in developer mode, authentication for user #{user.login} ok."
|
Rails.logger.info "System in developer mode, authentication for user #{user.login} ok."
|
||||||
user
|
true
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,24 +1,18 @@
|
||||||
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
|
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
|
||||||
|
|
||||||
module Auth::Internal
|
class Auth
|
||||||
|
class Internal < Auth::Base
|
||||||
|
|
||||||
# rubocop:disable Style/ModuleFunction
|
def valid?(user, password)
|
||||||
extend self
|
|
||||||
|
|
||||||
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)
|
if PasswordHash.legacy?(user.password, password)
|
||||||
update_password(user, password)
|
update_password(user, password)
|
||||||
return user
|
return true
|
||||||
end
|
end
|
||||||
|
|
||||||
return false if !PasswordHash.verified?(user.password, password)
|
PasswordHash.verified?(user.password, password)
|
||||||
|
|
||||||
user
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -27,4 +21,5 @@ module Auth::Internal
|
||||||
user.password = PasswordHash.crypt(password)
|
user.password = PasswordHash.crypt(password)
|
||||||
user.save
|
user.save
|
||||||
end
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
148
lib/auth/ldap.rb
148
lib/auth/ldap.rb
|
@ -1,124 +1,60 @@
|
||||||
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
|
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
|
||||||
|
|
||||||
require 'net/ldap'
|
require 'ldap'
|
||||||
|
require 'ldap/user'
|
||||||
|
|
||||||
module Auth::Ldap
|
class Auth
|
||||||
def self.check(username, password, config, user)
|
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
|
# get from config or fallback to login
|
||||||
ldap = Net::LDAP.new( host: config[:host], port: config[:port] )
|
# 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
|
authed = login_attributes.any? do |attribute|
|
||||||
if config[:bind_dn] && config[:bind_pw]
|
ldap_user.valid?(user[attribute], password)
|
||||||
ldap.auth config[:bind_dn], config[:bind_pw]
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# ldap bind
|
log_auth_result(user, authed)
|
||||||
begin
|
authed
|
||||||
if !ldap.bind
|
|
||||||
Rails.logger.info "Can't bind to '#{config[:host]}', #{ldap.get_operation_result.code}, #{ldap.get_operation_result.message}"
|
|
||||||
return
|
|
||||||
end
|
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.info "Can't connect to '#{config[:host]}', #{e}"
|
message = "Can't connect to ldap backend, #{e}"
|
||||||
return
|
Rails.logger.info message
|
||||||
|
log(
|
||||||
|
user: user,
|
||||||
|
status: 'failed',
|
||||||
|
response: message,
|
||||||
|
)
|
||||||
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
# search user
|
private
|
||||||
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
|
|
||||||
|
|
||||||
# remember attributes for :sync_params
|
def log_auth_result(user, authed)
|
||||||
entry.each do |attribute, values|
|
result = authed ? 'success' : 'failed'
|
||||||
user_data[ attribute.downcase.to_sym ] = ''
|
log(
|
||||||
values.each do |value|
|
user: user,
|
||||||
user_data[ attribute.downcase.to_sym ] = value
|
status: result,
|
||||||
end
|
)
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if user_dn.nil?
|
def log(user:, status:, response: nil)
|
||||||
Rails.logger.info "ldap entry found for user '#{username}' with filter #{filter} failed!"
|
HttpLog.create(
|
||||||
return nil
|
direction: 'out',
|
||||||
end
|
facility: 'ldap',
|
||||||
|
url: "bind -> #{user.login}",
|
||||||
# try ldap bind with user credentals
|
status: status,
|
||||||
auth = ldap.authenticate user_dn, password
|
ip: nil,
|
||||||
if !ldap.bind( auth )
|
request: { content: user.login },
|
||||||
Rails.logger.info "ldap bind with '#{user_dn}' failed!"
|
response: { content: response || status },
|
||||||
return false
|
method: 'tcp',
|
||||||
end
|
created_by_id: 1,
|
||||||
|
|
||||||
# create/update user
|
|
||||||
if config[:sync_params]
|
|
||||||
user_attributes = {
|
|
||||||
source: 'ldap',
|
|
||||||
updated_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
|
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
|
||||||
end
|
end
|
||||||
|
|
29
lib/core_ext/activesupport/lib/active_support/logger.rb
Normal file
29
lib/core_ext/activesupport/lib/active_support/logger.rb
Normal file
|
@ -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
|
19
lib/core_ext/net/ldap/entry.rb
Normal file
19
lib/core_ext/net/ldap/entry.rb
Normal file
|
@ -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<String>}] A duplicate of the internal Hash with the entries attributes.
|
||||||
|
def to_h
|
||||||
|
@myhash.dup
|
||||||
|
end
|
||||||
|
end
|
|
@ -611,8 +611,7 @@ returns
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error e.message
|
Rails.logger.error e
|
||||||
Rails.logger.error e.backtrace.inspect
|
|
||||||
end
|
end
|
||||||
mxs
|
mxs
|
||||||
end
|
end
|
||||||
|
|
|
@ -2,26 +2,62 @@ module Import
|
||||||
class BaseResource
|
class BaseResource
|
||||||
include Import::Helper
|
include Import::Helper
|
||||||
|
|
||||||
|
attr_reader :resource, :remote_id, :errors
|
||||||
|
|
||||||
def initialize(resource, *args)
|
def initialize(resource, *args)
|
||||||
|
handle_args(resource, *args)
|
||||||
import(resource, *args)
|
import(resource, *args)
|
||||||
end
|
end
|
||||||
|
|
||||||
def import_class
|
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
|
end
|
||||||
|
|
||||||
def source
|
def source
|
||||||
raise "#{self.class.name} has no implmentation of the needed 'source' method"
|
import_class_namespace
|
||||||
end
|
end
|
||||||
|
|
||||||
def remote_id(resource, *_args)
|
def remote_id(resource, *_args)
|
||||||
@remote_id ||= resource.delete(:id)
|
@remote_id ||= resource.delete(:id)
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def import(resource, *args)
|
def import(resource, *args)
|
||||||
create_or_update(map(resource, *args), *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
|
end
|
||||||
|
|
||||||
def create_or_update(resource, *args)
|
def create_or_update(resource, *args)
|
||||||
|
@ -32,39 +68,78 @@ module Import
|
||||||
def updated?(resource, *args)
|
def updated?(resource, *args)
|
||||||
@resource = lookup_existing(resource, *args)
|
@resource = lookup_existing(resource, *args)
|
||||||
return false if !@resource
|
return false if !@resource
|
||||||
@resource.update_attributes!(resource)
|
|
||||||
post_update(
|
# delete since we have an update and
|
||||||
instance: @resource,
|
# the record is already created
|
||||||
attributes: resource
|
resource.delete(:created_by_id)
|
||||||
)
|
|
||||||
|
@resource.assign_attributes(resource)
|
||||||
|
|
||||||
|
return true if @dry_run
|
||||||
|
@resource.save
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def lookup_existing(resource, *_args)
|
def lookup_existing(resource, *_args)
|
||||||
|
|
||||||
instance = ExternalSync.find_by(
|
synced_instance = ExternalSync.find_by(
|
||||||
source: source,
|
source: source,
|
||||||
source_id: remote_id(resource),
|
source_id: remote_id(resource),
|
||||||
object: import_class.name,
|
object: import_class.name,
|
||||||
)
|
)
|
||||||
return if !instance
|
return if !synced_instance
|
||||||
import_class.find_by(id: instance.o_id)
|
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
|
end
|
||||||
|
|
||||||
def create(resource, *_args)
|
def create(resource, *_args)
|
||||||
@resource = import_class.new(resource)
|
@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(
|
ExternalSync.create(
|
||||||
source: source,
|
source: source,
|
||||||
source_id: remote_id(resource),
|
source_id: remote_id(remote),
|
||||||
object: import_class.name,
|
object: import_class.name,
|
||||||
o_id: @resource.id
|
o_id: local.id
|
||||||
)
|
|
||||||
|
|
||||||
post_create(
|
|
||||||
instance: @resource,
|
|
||||||
attributes: resource
|
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -82,7 +157,8 @@ module Import
|
||||||
end
|
end
|
||||||
|
|
||||||
def from_mapping(resource, *args)
|
def from_mapping(resource, *args)
|
||||||
return resource if !mapping(*args)
|
mapping = mapping(*args)
|
||||||
|
return resource if !mapping
|
||||||
|
|
||||||
ExternalSync.map(
|
ExternalSync.map(
|
||||||
mapping: mapping,
|
mapping: mapping,
|
||||||
|
@ -95,13 +171,31 @@ module Import
|
||||||
end
|
end
|
||||||
|
|
||||||
def mapping_config(*_args)
|
def mapping_config(*_args)
|
||||||
self.class.name.to_s.sub('Import::', '').gsub('::', '_').underscore + '_mapping'
|
import_class_namespace.gsub('::', '_').underscore + '_mapping'
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_create(_args)
|
def import_class_namespace
|
||||||
|
self.class.name.to_s.sub('Import::', '')
|
||||||
end
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|
31
lib/import/ldap.rb
Normal file
31
lib/import/ldap.rb
Normal file
|
@ -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
|
193
lib/import/ldap/user.rb
Normal file
193
lib/import/ldap/user.rb
Normal file
|
@ -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
|
99
lib/import/ldap/user_factory.rb
Normal file
99
lib/import/ldap/user_factory.rb
Normal file
|
@ -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
|
|
@ -11,8 +11,12 @@ module Import
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def post_create(_args)
|
def create(resource, *_args)
|
||||||
|
result = super
|
||||||
|
if !@dry_run
|
||||||
reset_primary_key_sequence(model_name.underscore.pluralize)
|
reset_primary_key_sequence(model_name.underscore.pluralize)
|
||||||
end
|
end
|
||||||
|
result
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -28,8 +28,7 @@ module Import
|
||||||
rescue => e
|
rescue => e
|
||||||
status_update_thread.exit
|
status_update_thread.exit
|
||||||
status_update_thread.join
|
status_update_thread.join
|
||||||
Rails.logger.error e.message
|
Rails.logger.error e
|
||||||
Rails.logger.error e.backtrace.inspect
|
|
||||||
result = {
|
result = {
|
||||||
message: e.message,
|
message: e.message,
|
||||||
result: 'error',
|
result: 'error',
|
||||||
|
|
37
lib/import/statistical_factory.rb
Normal file
37
lib/import/statistical_factory.rb
Normal file
|
@ -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
|
|
@ -31,8 +31,7 @@ module Import
|
||||||
rescue => e
|
rescue => e
|
||||||
status_update_thread.exit
|
status_update_thread.exit
|
||||||
status_update_thread.join
|
status_update_thread.join
|
||||||
Rails.logger.error e.message
|
Rails.logger.error e
|
||||||
Rails.logger.error e.backtrace.inspect
|
|
||||||
result = {
|
result = {
|
||||||
message: e.message,
|
message: e.message,
|
||||||
result: 'error',
|
result: 'error',
|
||||||
|
|
206
lib/ldap.rb
Normal file
206
lib/ldap.rb
Normal file
|
@ -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<String>}] 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
|
||||||
|
# #=> <Net::LDAP::Entry...>
|
||||||
|
#
|
||||||
|
# @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
|
22
lib/ldap/filter_lookup.rb
Normal file
22
lib/ldap/filter_lookup.rb
Normal file
|
@ -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
|
134
lib/ldap/group.rb
Normal file
134
lib/ldap/group.rb
Normal file
|
@ -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<Number>}] 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
|
187
lib/ldap/user.rb
Normal file
187
lib/ldap/user.rb
Normal file
|
@ -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<String>}] 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
|
|
@ -21,8 +21,7 @@ class Sessions::Event::ChatSessionInit < Sessions::Event::ChatBase
|
||||||
dns_name = result.to_s
|
dns_name = result.to_s
|
||||||
end
|
end
|
||||||
rescue => e
|
rescue => e
|
||||||
Rails.logger.error e.message
|
Rails.logger.error e
|
||||||
Rails.logger.error e.backtrace.inspect
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -23,7 +23,7 @@ FactoryGirl.define do
|
||||||
end
|
end
|
||||||
|
|
||||||
factory :user_legacy_password_sha2, parent: :user do
|
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
|
password '{sha2}dd9c764fa7ea18cd992c8600006d3dc3ac983d1ba22e9ba2d71f6207456be0ba' # zammad
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
11
spec/factories/vendor/net/ldap/entry.rb
vendored
Normal file
11
spec/factories/vendor/net/ldap/entry.rb
vendored
Normal file
|
@ -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
|
6
spec/lib/auth/backend_examples.rb
Normal file
6
spec/lib/auth/backend_examples.rb
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
RSpec.shared_examples 'Auth backend' do
|
||||||
|
|
||||||
|
it 'responds to #valid?' do
|
||||||
|
expect(instance).to respond_to(:valid?)
|
||||||
|
end
|
||||||
|
end
|
19
spec/lib/auth/base_spec.rb
Normal file
19
spec/lib/auth/base_spec.rb
Normal file
|
@ -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
|
47
spec/lib/auth/developer_spec.rb
Normal file
47
spec/lib/auth/developer_spec.rb
Normal file
|
@ -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
|
|
@ -1,31 +1,33 @@
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
require 'lib/auth/backend_examples'
|
||||||
|
|
||||||
RSpec.describe Auth::Internal do
|
RSpec.describe Auth::Internal do
|
||||||
|
|
||||||
it 'authenticates via password' do
|
let(:user) { create(:user) }
|
||||||
user = create(:user)
|
let(:instance) { described_class.new({ adapter: described_class.name }) }
|
||||||
password = 'zammad'
|
|
||||||
result = described_class.check(user.login, password, {}, user)
|
|
||||||
|
|
||||||
expect(result).to be_an_instance_of(User)
|
context '#valid?' do
|
||||||
|
it_behaves_like 'Auth backend'
|
||||||
|
|
||||||
|
it 'authenticates via password' do
|
||||||
|
result = instance.valid?(user, 'zammad')
|
||||||
|
expect(result).to be true
|
||||||
end
|
end
|
||||||
|
|
||||||
it "doesn't authenticate via plain password" do
|
it "doesn't authenticate via plain password" do
|
||||||
user = create(:user)
|
result = instance.valid?(user, user.password)
|
||||||
result = described_class.check(user.login, user.password, {}, user)
|
|
||||||
|
|
||||||
expect(result).to be_falsy
|
expect(result).to be_falsy
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'converts legacy sha2 passwords' do
|
it 'converts legacy sha2 passwords' do
|
||||||
user = create(:user_legacy_password_sha2)
|
user = create(:user_legacy_password_sha2)
|
||||||
password = 'zammad'
|
|
||||||
|
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
69
spec/lib/auth/ldap_spec.rb
Normal file
69
spec/lib/auth/ldap_spec.rb
Normal file
|
@ -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
|
60
spec/lib/auth_spec.rb
Normal file
60
spec/lib/auth_spec.rb
Normal file
|
@ -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
|
|
@ -1,10 +1,119 @@
|
||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec::Matchers.define_negated_matcher :not_change, :change
|
||||||
|
|
||||||
RSpec.describe Import::BaseResource do
|
RSpec.describe Import::BaseResource do
|
||||||
|
|
||||||
it "needs an implementation of the 'import_class' method" do
|
it "needs an implementation of the 'import_class' method" do
|
||||||
expect {
|
expect {
|
||||||
described_class.new(attributes_for(:group))
|
described_class.new(attributes_for(:group))
|
||||||
}.to raise_error(RuntimeError)
|
}.to raise_error(NoMethodError)
|
||||||
end
|
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
|
end
|
||||||
|
|
229
spec/lib/import/ldap/user_factory_spec.rb
Normal file
229
spec/lib/import/ldap/user_factory_spec.rb
Normal file
|
@ -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
|
155
spec/lib/import/ldap/user_spec.rb
Normal file
155
spec/lib/import/ldap/user_spec.rb
Normal file
|
@ -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
|
|
@ -14,6 +14,10 @@ RSpec.describe Import::ModelResource do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
after do
|
||||||
|
Import::Test.send(:remove_const, :Group)
|
||||||
|
end
|
||||||
|
|
||||||
let(:group_data) { attributes_for(:group).merge(id: 1337) }
|
let(:group_data) { attributes_for(:group).merge(id: 1337) }
|
||||||
|
|
||||||
it 'creates model Objects by class name' do
|
it 'creates model Objects by class name' do
|
||||||
|
|
96
spec/lib/import/statistical_factory_spec.rb
Normal file
96
spec/lib/import/statistical_factory_spec.rb
Normal file
|
@ -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
|
122
spec/lib/ldap/group_spec.rb
Normal file
122
spec/lib/ldap/group_spec.rb
Normal file
|
@ -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
|
198
spec/lib/ldap/user_spec.rb
Normal file
198
spec/lib/ldap/user_spec.rb
Normal file
|
@ -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
|
340
spec/lib/ldap_rspec.rb
Normal file
340
spec/lib/ldap_rspec.rb
Normal file
|
@ -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
|
|
@ -15,7 +15,96 @@ RSpec.describe User do
|
||||||
end
|
end
|
||||||
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
|
it 'returns a User instance for existing tokens' do
|
||||||
token = create(:token_password_reset)
|
token = create(:token_password_reset)
|
||||||
|
@ -27,7 +116,7 @@ RSpec.describe User do
|
||||||
end
|
end
|
||||||
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
|
it 'changes the password of the token user and destroys the token' do
|
||||||
token = create(:token_password_reset)
|
token = create(:token_password_reset)
|
||||||
|
|
Loading…
Reference in a new issue