Initial version of LDAP user sync support.

This commit is contained in:
Thorsten Eckel 2017-04-19 12:09:54 +02:00
parent 51dfc921ea
commit c9b2255e4f
62 changed files with 4320 additions and 383 deletions

View 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'
)

View file

@ -9,6 +9,133 @@
#= require_tree ./lib/app_post
class App extends Spine.Controller
helper =
# define print name helper
P: (object, attributeName, attributes) ->
App.viewPrint(object, attributeName, attributes)
# define date format helper
date: (time) ->
return '' if !time
timeObject = new Date(time)
d = App.Utils.formatTime(timeObject.getDate(), 2)
m = App.Utils.formatTime(timeObject.getMonth() + 1, 2)
y = timeObject.getFullYear()
"#{y}-#{m}-#{d}"
# define datetime format helper
datetime: (time) ->
return '' if !time
timeObject = new Date(time)
d = App.Utils.formatTime(timeObject.getDate(), 2)
m = App.Utils.formatTime(timeObject.getMonth() + 1, 2)
y = timeObject.getFullYear()
S = App.Utils.formatTime(timeObject.getSeconds(), 2)
M = App.Utils.formatTime(timeObject.getMinutes(), 2)
H = App.Utils.formatTime(timeObject.getHours(), 2)
"#{y}-#{m}-#{d} #{H}:#{M}:#{S}"
# define decimal format helper
decimal: (data, positions = 2) ->
App.Utils.decimal(data, positions)
# define mask helper
M: (item, start = 1, end = 2) ->
return '' if !item
string = ''
end = item.length - end - 1
for n in [0..item.length-1]
if start <= n && end >= n
string += '*'
else
string += item[n]
string
# define translation helper
T: (item, args...) ->
App.i18n.translateContent(item, args...)
# define translation inline helper
Ti: (item, args...) ->
App.i18n.translateInline(item, args...)
# define translation for date helper
Tdate: (item, args...) ->
App.i18n.translateDate(item, args...)
# define translation for timestamp helper
Ttimestamp: (item, args...) ->
App.i18n.translateTimestamp(item, args...)
# define linkify helper
L: (item) ->
if item && typeof item is 'string'
return App.Utils.linkify(item)
item
# define config helper
C: (key) ->
App.Config.get(key)
# define session helper
S: (key) ->
App.Session.get(key)
# define address line helper
AddressLine: (line) ->
return '' if !line
items = emailAddresses.parseAddressList(line)
# line was not parsable
return App.Utils.htmlEscape(line) if !items
# set markup
result = ''
for item in items
if result
result = result + ', '
if item.name
item.name = item.name
.replace(',', '')
.replace(';', '')
.replace('"', '')
.replace('\'', '')
if item.name.match(/\@|,|;|\^|\+|#|§|\$|%|&|\/|\(|\)|=|\?|\*/)
item.name = "\"#{item.name}\""
result = "#{result}#{App.Utils.htmlEscape(item.name)} "
if item.address
result = result + " <span class=\"text-muted\">&lt;#{App.Utils.htmlEscape(item.address)}&gt</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) ->
if !attributes
attributes = {}
@ -136,122 +263,7 @@ class App extends Spine.Controller
@view: (name) ->
template = (params = {}) ->
# define print name helper
params.P = (object, attributeName, attributes) ->
App.viewPrint(object, attributeName, attributes)
# define date format helper
params.date = (time) ->
return '' if !time
timeObject = new Date(time)
d = App.Utils.formatTime(timeObject.getDate(), 2)
m = App.Utils.formatTime(timeObject.getMonth() + 1, 2)
y = timeObject.getFullYear()
"#{y}-#{m}-#{d}"
# define datetime format helper
params.datetime = (time) ->
return '' if !time
timeObject = new Date(time)
d = App.Utils.formatTime(timeObject.getDate(), 2)
m = App.Utils.formatTime(timeObject.getMonth() + 1, 2)
y = timeObject.getFullYear()
S = App.Utils.formatTime(timeObject.getSeconds(), 2)
M = App.Utils.formatTime(timeObject.getMinutes(), 2)
H = App.Utils.formatTime(timeObject.getHours(), 2)
"#{y}-#{m}-#{d} #{H}:#{M}:#{S}"
# define decimal format helper
params.decimal = (data, positions = 2) ->
App.Utils.decimal(data, positions)
# define translation helper
params.T = (item, args...) ->
App.i18n.translateContent(item, args...)
# define translation inline helper
params.Ti = (item, args...) ->
App.i18n.translateInline(item, args...)
# define translation for date helper
params.Tdate = (item, args...) ->
App.i18n.translateDate(item, args...)
# define translation for timestamp helper
params.Ttimestamp = (item, args...) ->
App.i18n.translateTimestamp(item, args...)
# define linkify helper
params.L = (item) ->
if item && typeof item is 'string'
return App.Utils.linkify(item)
item
# define config helper
params.C = (key) ->
App.Config.get(key)
# define session helper
params.S = (key) ->
App.Session.get(key)
# define address line helper
params.AddressLine = (line) ->
return '' if !line
items = emailAddresses.parseAddressList(line)
# line was not parsable
return App.Utils.htmlEscape(line) if !items
# set markup
result = ''
for item in items
if result
result = result + ', '
if item.name
item.name = item.name
.replace(',', '')
.replace(';', '')
.replace('"', '')
.replace('\'', '')
if item.name.match(/\@|,|;|\^|\+|#|§|\$|%|&|\/|\(|\)|=|\?|\*/)
item.name = "\"#{item.name}\""
result = "#{result}#{App.Utils.htmlEscape(item.name)} "
if item.address
result = result + " <span class=\"text-muted\">&lt;#{App.Utils.htmlEscape(item.address)}&gt</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)
JST["app/views/#{name}"](_.extend(params, helper))
template
class App.UiElement

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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">

View 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>

View file

@ -7474,6 +7474,11 @@ output {
.searchableSelect-main {
position: relative;
&.form-control--small ~ .searchableSelect-autocomplete {
top: 7px;
left: 9px;
}
}
.searchableSelect-shadow {

View file

@ -13,17 +13,17 @@ module ApplicationController::HandlesErrors
end
def not_found(e)
log_error_exception(e)
logger.error e
respond_to_exception(e, :not_found)
end
def unprocessable_entity(e)
log_error_exception(e)
logger.error e
respond_to_exception(e, :unprocessable_entity)
end
def internal_server_error(e)
log_error_exception(e)
logger.error e
respond_to_exception(e, :internal_server_error)
end
@ -35,11 +35,6 @@ module ApplicationController::HandlesErrors
private
def log_error_exception(e)
logger.error e.message
logger.error e.backtrace.inspect
end
def respond_to_exception(e, status)
status_code = Rack::Utils.status_code(status)

View file

@ -22,8 +22,7 @@ class CalendarSubscriptionsController < ApplicationController
disposition: 'inline'
)
rescue => e
logger.error e.message
logger.error e.backtrace.inspect
logger.error e
render json: { error: e.message }, status: :unprocessable_entity
end
@ -45,8 +44,7 @@ class CalendarSubscriptionsController < ApplicationController
disposition: 'inline'
)
rescue => e
logger.error e.message
logger.error e.backtrace.inspect
logger.error e
render json: { error: e.message }, status: :unprocessable_entity
end

View 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

View file

@ -14,6 +14,7 @@ class ApplicationModel < ActiveRecord::Base
include ApplicationModel::HasAssociations
include ApplicationModel::HasAttachments
include ApplicationModel::HasLatestChangeTimestamp
include ApplicationModel::HasExternalSync
include ApplicationModel::Importable
include ApplicationModel::HistoryLoggable
include ApplicationModel::TouchesReferences

View 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

View file

@ -23,8 +23,7 @@ touch references by params
return if !object
object.touch
rescue => e
logger.error e.message
logger.error e.backtrace.inspect
logger.error e
end
end
end

View file

@ -426,8 +426,7 @@ returns
p message # rubocop:disable Rails/Output
p 'ERROR: ' + e.inspect # rubocop:disable Rails/Output
Rails.logger.error message
Rails.logger.error 'ERROR: ' + e.inspect
Rails.logger.error 'ERROR: ' + e.backtrace.inspect
Rails.logger.error e
File.open(filename, 'wb') { |file|
file.write msg
}

View file

@ -68,7 +68,7 @@ class ExternalSync < ApplicationModel
break if !value
storable = value.class.ancestors.any? do |ancestor|
%w(String Integer Float Bool).include?(ancestor.to_s)
%w(String Integer Float Bool Array).include?(ancestor.to_s)
end
if storable

71
app/models/import_job.rb Normal file
View 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

View file

@ -218,30 +218,61 @@ returns
# do not authenticate with nothing
return if username.blank? || password.blank?
user = User.identify(username)
return if !user
return if !Auth.can_login?(user)
return user if Auth.valid?(user, password)
sleep 1
user.login_failed += 1
user.save
nil
end
=begin
checks if a user has reached the maximum of failed login tries
user = User.find(123)
result = user.max_login_failed?
returns
result = true | false
=end
def max_login_failed?
max_login_failed = Setting.get('password_max_login_failed').to_i || 10
login_failed > max_login_failed
end
=begin
tries to find the matching instance by the given identifier. Currently email and login is supported.
user = User.indentify('User123')
# or
user = User.indentify('user-123@example.com')
returns
# User instance
user.login # 'user123'
=end
def self.identify(identifier)
# try to find user based on login
user = User.find_by(login: username.downcase, active: true)
user = User.find_by(login: identifier.downcase)
return user if user
# try second lookup with email
user ||= User.find_by(email: username.downcase, active: true)
# check failed logins
max_login_failed = Setting.get('password_max_login_failed').to_i || 10
if user && user.login_failed > max_login_failed
logger.info "Max login failed reached for user #{user.login}."
return false
end
user_auth = Auth.check(username, password, user)
# set login failed +1
if !user_auth && user
sleep 1
user.login_failed += 1
user.save
end
# auth ok
user_auth
User.find_by(email: identifier.downcase)
end
=begin

View 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

View file

@ -539,6 +539,20 @@ class CreateBase < ActiveRecord::Migration
add_index :external_syncs, [:source, :source_id, :object, :o_id], name: 'index_external_syncs_on_source_and_source_id_and_object_o_id'
add_index :external_syncs, [:object, :o_id]
create_table :import_jobs do |t|
t.string :name, limit: 250, null: false
t.boolean :dry_run, default: false
t.text :payload, limit: 80_000
t.text :result, limit: 80_000
t.datetime :started_at
t.datetime :finished_at
t.timestamps null: false
end
create_table :cti_logs do |t|
t.string :direction, limit: 20, null: false
t.string :state, limit: 20, null: false

View 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

View file

@ -2485,6 +2485,46 @@ Setting.create_if_not_exists(
},
frontend: false
)
Setting.create_if_not_exists(
title: 'LDAP integration',
name: 'ldap_integration',
area: 'Integration::Switch',
description: 'Defines if LDAP is enabled or not.',
options: {
form: [
{
display: '',
null: true,
name: 'ldap_integration',
tag: 'boolean',
options: {
true => 'yes',
false => 'no',
},
},
],
},
state: false,
preferences: {
prio: 1,
authentication: true,
permission: ['admin.integration'],
},
frontend: true
)
Setting.create_if_not_exists(
title: 'LDAP config',
name: 'ldap_config',
area: 'Integration::LDAP',
description: 'Defines the LDAP config.',
options: {},
state: {},
preferences: {
prio: 2,
permission: ['admin.integration'],
},
frontend: false,
)
Setting.create_if_not_exists(
title: 'Defines sync transaction backend.',
name: '0100_trigger',
@ -5344,21 +5384,21 @@ ObjectManager::Attribute.add(
Scheduler.create_if_not_exists(
name: 'Process pending tickets',
method: 'Ticket.process_pending',
period: 60 * 15,
period: 15.minutes,
prio: 1,
active: true,
)
Scheduler.create_if_not_exists(
name: 'Process escalation tickets',
method: 'Ticket.process_escalation',
period: 60 * 5,
period: 5.minutes,
prio: 1,
active: true,
)
Scheduler.create_if_not_exists(
name: 'Import OTRS diff load',
method: 'Import::OTRS.diff_worker',
period: 60 * 3,
period: 3.minutes,
prio: 1,
active: true,
updated_by_id: 1,
@ -5367,7 +5407,7 @@ Scheduler.create_if_not_exists(
Scheduler.create_if_not_exists(
name: 'Check Channels',
method: 'Channel.fetch',
period: 30,
period: 30.seconds,
prio: 1,
active: true,
updated_by_id: 1,
@ -5376,7 +5416,7 @@ Scheduler.create_if_not_exists(
Scheduler.create_if_not_exists(
name: 'Check streams for Channel',
method: 'Channel.stream',
period: 60,
period: 60.seconds,
prio: 1,
active: true,
updated_by_id: 1,
@ -5385,7 +5425,7 @@ Scheduler.create_if_not_exists(
Scheduler.create_if_not_exists(
name: 'Generate Session data',
method: 'Sessions.jobs',
period: 60,
period: 60.seconds,
prio: 1,
active: true,
updated_by_id: 1,
@ -5394,7 +5434,7 @@ Scheduler.create_if_not_exists(
Scheduler.create_if_not_exists(
name: 'Execute jobs',
method: 'Job.run',
period: 5 * 60,
period: 5.minutes,
prio: 2,
active: true,
updated_by_id: 1,
@ -5448,7 +5488,7 @@ Scheduler.create_or_update(
Scheduler.create_or_update(
name: 'Closed chat sessions where participients are offline.',
method: 'Chat.cleanup_close',
period: 60 * 15,
period: 15.minutes,
prio: 2,
active: true,
updated_by_id: 1,
@ -5493,12 +5533,21 @@ Scheduler.create_or_update(
Scheduler.create_if_not_exists(
name: 'Cleanup HttpLog',
method: 'HttpLog.cleanup',
period: 24 * 60 * 60,
period: 1.day,
prio: 2,
active: true,
updated_by_id: 1,
created_by_id: 1,
)
Scheduler.create_if_not_exists(
name: 'Import Jobs',
method: 'ImportJob.start',
period: 1.hour,
prio: 1,
active: true,
updated_by_id: 1,
created_by_id: 1
)
Trigger.create_or_update(
name: 'auto reply (on new tickets)',

View file

@ -5,17 +5,83 @@ class Auth
=begin
authenticate user via username and password
checks if a given user can login. Checks for
- valid user
- active state
- max failed logins
result = Auth.check(username, password, user)
result = Auth.can_login?(user)
returns
result = user_model # if authentication was successfully
result = true | false
=end
def self.check(username, password, user)
def self.can_login?(user)
return false if !user.is_a?(User)
return false if !user.active?
return true if !user.max_login_failed?
Rails.logger.info "Max login failed reached for user #{user.login}."
false
end
=begin
checks if a given user and password match against multiple auth backends
- valid user
- active state
- max failed logins
result = Auth.valid?(user, password)
returns
result = true | false
=end
def self.valid?(user, password)
# try to login against configure auth backends
backends.any? do |config|
next if !backend_validates?(
config: config,
user: user,
password: password,
)
Rails.logger.info "Authentication against #{config[:adapter]} for user #{user.login} ok."
# remember last login date
user.update_last_login
true
end
end
=begin
returns a list of all Auth backend configurations
result = Auth.backends
returns
result = [
{
adapter: 'Auth::Internal',
},
{
adapter: 'Auth::Developer',
},
...
]
=end
def self.backends
# use std. auth backends
config = [
@ -28,33 +94,24 @@ returns
]
# added configured backends
Setting.where(area: 'Security::Authentication').each { |setting|
if setting.state_current[:value]
config.push setting.state_current[:value]
end
}
Setting.where(area: 'Security::Authentication').each do |setting|
next if setting.state_current[:value].blank?
config.push setting.state_current[:value]
end
# try to login against configure auth backends
user_auth = nil
config.each { |config_item|
next if !config_item[:adapter]
# load backend
backend = load_adapter(config_item[:adapter])
next if !backend
user_auth = backend.check(username, password, config_item, user)
# auth not ok
next if !user_auth
Rails.logger.info "Authentication against #{config_item[:adapter]} for user #{user_auth.login} ok."
# remember last login date
user_auth.update_last_login
return user_auth
}
nil
config
end
def self.backend_validates?(config:, user:, password:)
return false if !config[:adapter]
# load backend
backend = load_adapter(config[:adapter])
return false if !backend
instance = backend.new(config)
instance.valid?(user, password)
end
private_class_method :backend_validates?
end

14
lib/auth/base.rb Normal file
View 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

View file

@ -1,14 +1,14 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
module Auth::Developer
def self.check(username, password, _config, user)
class Auth
class Developer < Auth::Base
# development systems
return false if !username
return false if !user
return false if Setting.get('developer_mode') != true
return false if password != 'test'
Rails.logger.info "System in developer mode, authentication for user #{user.login} ok."
user
def valid?(user, password)
return false if user.blank?
return false if Setting.get('developer_mode') != true
return false if password != 'test'
Rails.logger.info "System in developer mode, authentication for user #{user.login} ok."
true
end
end
end

View file

@ -1,30 +1,25 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
module Auth::Internal
class Auth
class Internal < Auth::Base
# rubocop:disable Style/ModuleFunction
extend self
def valid?(user, password)
def check(username, password, _config, user)
return false if user.blank?
# return if no user exists
return false if !username
return false if !user
if PasswordHash.legacy?(user.password, password)
update_password(user, password)
return true
end
if PasswordHash.legacy?(user.password, password)
update_password(user, password)
return user
PasswordHash.verified?(user.password, password)
end
return false if !PasswordHash.verified?(user.password, password)
private
user
end
private
def update_password(user, password)
user.password = PasswordHash.crypt(password)
user.save
def update_password(user, password)
user.password = PasswordHash.crypt(password)
user.save
end
end
end

View file

@ -1,124 +1,60 @@
# Copyright (C) 2012-2016 Zammad Foundation, http://zammad-foundation.org/
require 'net/ldap'
require 'ldap'
require 'ldap/user'
module Auth::Ldap
def self.check(username, password, config, user)
class Auth
class Ldap < Auth::Base
scope = Net::LDAP::SearchScope_WholeSubtree
def valid?(user, password)
return false if !Setting.get('ldap_integration')
ldap_user = ::Ldap::User.new()
# ldap connect
ldap = Net::LDAP.new( host: config[:host], port: config[:port] )
# get from config or fallback to login
# for a list of user attributes which should
# be used for logging in
login_attributes = @config[:login_attributes] || %w(login)
# set auth data if needed
if config[:bind_dn] && config[:bind_pw]
ldap.auth config[:bind_dn], config[:bind_pw]
end
# ldap bind
begin
if !ldap.bind
Rails.logger.info "Can't bind to '#{config[:host]}', #{ldap.get_operation_result.code}, #{ldap.get_operation_result.message}"
return
authed = login_attributes.any? do |attribute|
ldap_user.valid?(user[attribute], password)
end
log_auth_result(user, authed)
authed
rescue => e
Rails.logger.info "Can't connect to '#{config[:host]}', #{e}"
return
message = "Can't connect to ldap backend, #{e}"
Rails.logger.info message
log(
user: user,
status: 'failed',
response: message,
)
false
end
# search user
filter = "(#{config[:uid]}=#{username})"
if config[:always_filter] && !config[:always_filter].empty?
filter = "(&#{filter}#{config[:always_filter]})"
end
user_dn = nil
user_data = {}
ldap.search( base: config[:base], filter: filter, scope: scope ) do |entry|
user_data = {}
user_dn = entry.dn
private
# remember attributes for :sync_params
entry.each do |attribute, values|
user_data[ attribute.downcase.to_sym ] = ''
values.each do |value|
user_data[ attribute.downcase.to_sym ] = value
end
end
def log_auth_result(user, authed)
result = authed ? 'success' : 'failed'
log(
user: user,
status: result,
)
end
if user_dn.nil?
Rails.logger.info "ldap entry found for user '#{username}' with filter #{filter} failed!"
return nil
end
# try ldap bind with user credentals
auth = ldap.authenticate user_dn, password
if !ldap.bind( auth )
Rails.logger.info "ldap bind with '#{user_dn}' failed!"
return false
end
# create/update user
if config[:sync_params]
user_attributes = {
source: 'ldap',
def log(user:, status:, response: nil)
HttpLog.create(
direction: 'out',
facility: 'ldap',
url: "bind -> #{user.login}",
status: status,
ip: nil,
request: { content: user.login },
response: { content: response || status },
method: 'tcp',
created_by_id: 1,
updated_by_id: 1,
}
config[:sync_params].each { |local_data, ldap_data|
if user_data[ ldap_data.downcase.to_sym ]
user_attributes[ local_data.downcase.to_sym] = user_data[ ldap_data.downcase.to_sym ]
end
}
if !user
user_attributes[:created_by_id] = 1
user = User.create( user_attributes )
Rails.logger.debug "user created '#{user.login}'"
else
user.update_attributes( user_attributes )
Rails.logger.debug "user updated '#{user.login}'"
end
)
end
# return if it was not possible to create user
return if !user
# sync roles
# FIXME
# sync groups
# FIXME
# set always roles
if config[:always_roles]
role_ids = user.role_ids
config[:always_roles].each { |role_name|
role = Role.where( name: role_name ).first
next if !role
if !role_ids.include?( role.id )
role_ids.push role.id
end
}
user.role_ids = role_ids
user.save
end
# set always groups
if config[:always_groups]
group_ids = user.group_ids
config[:always_groups].each { |group_name|
group = Group.where( name: group_name ).first
next if !group
if !group_ids.include?( group.id )
group_ids.push group.id
end
}
user.group_ids = group_ids
user.save
end
# take session down
# - not needed, done by Net::LDAP -
user
end
end

View 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

View 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

View file

@ -611,8 +611,7 @@ returns
}
end
rescue => e
Rails.logger.error e.message
Rails.logger.error e.backtrace.inspect
Rails.logger.error e
end
mxs
end

View file

@ -2,26 +2,62 @@ module Import
class BaseResource
include Import::Helper
attr_reader :resource, :remote_id, :errors
def initialize(resource, *args)
handle_args(resource, *args)
import(resource, *args)
end
def import_class
raise "#{self.class.name} has no implmentation of the needed 'import_class' method"
raise NoMethodError, "#{self.class.name} has no implementation of the needed 'import_class' method"
end
def source
raise "#{self.class.name} has no implmentation of the needed 'source' method"
import_class_namespace
end
def remote_id(resource, *_args)
@remote_id ||= resource.delete(:id)
end
def action
return :failed if errors.present?
return :skipped if !@resource
return :unchanged if !attributes_changed?
return :created if created?
:updated
end
def attributes_changed?
return true if changed_attributes.present?
@associations_init != associations_state(@resource)
end
def changed_attributes
return if @resource.blank?
# dry run
return @resource.changes if @resource.changed?
# live run
@resource.previous_changes
end
def created?
return false if @resource.blank?
# dry run
return @resource.created_at.nil? if @resource.changed?
# live run
@resource.created_at == @resource.updated_at
end
private
def import(resource, *args)
create_or_update(map(resource, *args), *args)
rescue => e
# Don't catch own thrown exceptions from above
raise if e.is_a?(NoMethodError)
handle_error(e)
end
def create_or_update(resource, *args)
@ -32,39 +68,78 @@ module Import
def updated?(resource, *args)
@resource = lookup_existing(resource, *args)
return false if !@resource
@resource.update_attributes!(resource)
post_update(
instance: @resource,
attributes: resource
)
# delete since we have an update and
# the record is already created
resource.delete(:created_by_id)
@resource.assign_attributes(resource)
return true if @dry_run
@resource.save
true
end
def lookup_existing(resource, *_args)
instance = ExternalSync.find_by(
synced_instance = ExternalSync.find_by(
source: source,
source_id: remote_id(resource),
object: import_class.name,
)
return if !instance
import_class.find_by(id: instance.o_id)
return if !synced_instance
instance = import_class.find_by(id: synced_instance.o_id)
store_associations_state(instance)
instance
end
def store_associations_state(instance)
@associations_init = associations_state(instance)
end
def associations_state(instance)
state = {}
tracked_associations.each do |association|
state[association] = instance.send(association)
end
state
end
def tracked_associations
# loop over all reflections
import_class.reflect_on_all_associations.collect do |reflection|
# refection name is something like groups or organization (singular/plural)
reflection_name = reflection.name.to_s
# key is something like group_id or organization_id (singular)
key = reflection.klass.name.foreign_key
# add trailing 's' to get pluralized key
if reflection_name.singularize != reflection_name
key = "#{key}s"
end
key.to_sym
end
end
def create(resource, *_args)
@resource = import_class.new(resource)
@resource.save!
return if @dry_run
@resource.save
external_sync_create(
local: @resource,
remote: resource,
)
end
def external_sync_create(local:, remote:)
ExternalSync.create(
source: source,
source_id: remote_id(resource),
source_id: remote_id(remote),
object: import_class.name,
o_id: @resource.id
)
post_create(
instance: @resource,
attributes: resource
o_id: local.id
)
end
@ -82,7 +157,8 @@ module Import
end
def from_mapping(resource, *args)
return resource if !mapping(*args)
mapping = mapping(*args)
return resource if !mapping
ExternalSync.map(
mapping: mapping,
@ -95,13 +171,31 @@ module Import
end
def mapping_config(*_args)
self.class.name.to_s.sub('Import::', '').gsub('::', '_').underscore + '_mapping'
import_class_namespace.gsub('::', '_').underscore + '_mapping'
end
def post_create(_args)
def import_class_namespace
self.class.name.to_s.sub('Import::', '')
end
def post_update(_args)
def handle_args(_resource, *args)
return if !args
return if !args.is_a?(Array)
return if args.empty?
last_arg = args.last
return if !last_arg.is_a?(Hash)
handle_modifiers(last_arg)
end
def handle_modifiers(modifiers)
@dry_run = modifiers.fetch(:dry_run, false)
end
def handle_error(e)
@errors ||= []
@errors.push(e)
Rails.logger.error e
end
end
end

31
lib/import/ldap.rb Normal file
View 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
View 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

View 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

View file

@ -11,8 +11,12 @@ module Import
private
def post_create(_args)
reset_primary_key_sequence(model_name.underscore.pluralize)
def create(resource, *_args)
result = super
if !@dry_run
reset_primary_key_sequence(model_name.underscore.pluralize)
end
result
end
end
end

View file

@ -28,8 +28,7 @@ module Import
rescue => e
status_update_thread.exit
status_update_thread.join
Rails.logger.error e.message
Rails.logger.error e.backtrace.inspect
Rails.logger.error e
result = {
message: e.message,
result: 'error',

View 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

View file

@ -31,8 +31,7 @@ module Import
rescue => e
status_update_thread.exit
status_update_thread.join
Rails.logger.error e.message
Rails.logger.error e.backtrace.inspect
Rails.logger.error e
result = {
message: e.message,
result: 'error',

206
lib/ldap.rb Normal file
View 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
View 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
View 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
View 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

View file

@ -21,8 +21,7 @@ class Sessions::Event::ChatSessionInit < Sessions::Event::ChatBase
dns_name = result.to_s
end
rescue => e
Rails.logger.error e.message
Rails.logger.error e.backtrace.inspect
Rails.logger.error e
end
end

View file

@ -23,7 +23,7 @@ FactoryGirl.define do
end
factory :user_legacy_password_sha2, parent: :user do
after(:build) { |user| user.class.skip_callback(:validation, :before, :ensure_password) }
after(:build) { |user| user.class.skip_callback(:validation, :before, :ensure_password, if: -> { password && password.start_with?('{sha2}') }) }
password '{sha2}dd9c764fa7ea18cd992c8600006d3dc3ac983d1ba22e9ba2d71f6207456be0ba' # zammad
end
end

11
spec/factories/vendor/net/ldap/entry.rb vendored Normal file
View 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

View file

@ -0,0 +1,6 @@
RSpec.shared_examples 'Auth backend' do
it 'responds to #valid?' do
expect(instance).to respond_to(:valid?)
end
end

View 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

View 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

View file

@ -1,31 +1,33 @@
require 'rails_helper'
require 'lib/auth/backend_examples'
RSpec.describe Auth::Internal do
it 'authenticates via password' do
user = create(:user)
password = 'zammad'
result = described_class.check(user.login, password, {}, user)
let(:user) { create(:user) }
let(:instance) { described_class.new({ adapter: described_class.name }) }
expect(result).to be_an_instance_of(User)
end
context '#valid?' do
it_behaves_like 'Auth backend'
it "doesn't authenticate via plain password" do
user = create(:user)
result = described_class.check(user.login, user.password, {}, user)
it 'authenticates via password' do
result = instance.valid?(user, 'zammad')
expect(result).to be true
end
expect(result).to be_falsy
end
it "doesn't authenticate via plain password" do
result = instance.valid?(user, user.password)
expect(result).to be_falsy
end
it 'converts legacy sha2 passwords' do
user = create(:user_legacy_password_sha2)
password = 'zammad'
it 'converts legacy sha2 passwords' do
user = create(:user_legacy_password_sha2)
expect(PasswordHash.crypted?(user.password)).to be_falsy
expect(PasswordHash.crypted?(user.password)).to be_falsy
result = described_class.check(user.login, password, {}, user)
result = instance.valid?(user, 'zammad')
expect(result).to be true
expect(result).to be_an_instance_of(User)
expect(PasswordHash.crypted?(user.password)).to be true
expect(PasswordHash.crypted?(user.password)).to be true
end
end
end

View 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
View 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

View file

@ -1,10 +1,119 @@
require 'rails_helper'
RSpec::Matchers.define_negated_matcher :not_change, :change
RSpec.describe Import::BaseResource do
it "needs an implementation of the 'import_class' method" do
expect {
described_class.new(attributes_for(:group))
}.to raise_error(RuntimeError)
}.to raise_error(NoMethodError)
end
context "implemented 'import_class' method" do
before do
module Import
module Test
class Group < Import::BaseResource
def import_class
::Group
end
def source
'RSpec-TEST'
end
end
end
end
end
after do
Import::Test.send(:remove_const, :Group)
end
let(:attributes) {
attributes = attributes_for(:group)
attributes[:id] = 1337
attributes
}
context 'live run' do
it 'creates new resources' do
expect do
Import::Test::Group.new(attributes)
end
.to change {
Group.count
}.by(1)
.and change {
ExternalSync.count
}.by(1)
end
it 'updates existing resources' do
# initial import
Import::Test::Group.new(attributes)
group = Group.last
# simulate next import run
travel 20.minutes
attributes[:note] = 'TEST'
expect do
Import::Test::Group.new(attributes)
group.reload
end
.to change {
group.note
}
end
end
context 'dry run' do
it "doesn't create new resources" do
expect do
Import::Test::Group.new(attributes, dry_run: true)
end
.to not_change {
Group.count
}
.and not_change {
ExternalSync.count
}
end
it "doesn't update existing resources" do
# initial import
Import::Test::Group.new(attributes)
group = Group.last
# simulate next import run
travel 20.minutes
attributes[:note] = 'TEST'
expect do
Import::Test::Group.new(attributes, dry_run: true)
group.reload
end
.to not_change {
group.note
}
.and not_change {
Group.count
}
.and not_change {
ExternalSync.count
}
end
end
end
end

View 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

View 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

View file

@ -14,6 +14,10 @@ RSpec.describe Import::ModelResource do
end
end
after do
Import::Test.send(:remove_const, :Group)
end
let(:group_data) { attributes_for(:group).merge(id: 1337) }
it 'creates model Objects by class name' do

View 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
View 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
View 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
View 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

View file

@ -15,7 +15,96 @@ RSpec.describe User do
end
end
context '#by_reset_token' do
context '#max_login_failed?' do
it 'responds to max_login_failed?' do
user = create(:user)
expect(user).to respond_to(:max_login_failed?)
end
it 'checks if a user has reached the maximum of failed logins' do
user = create(:user)
expect(user.max_login_failed?).to be false
user.login_failed = 999
user.save
expect(user.max_login_failed?).to be true
end
end
context '.identify' do
it 'returns users found by login' do
user = create(:user)
found_user = User.identify(user.login)
expect(found_user).to be_an(User)
expect(found_user.id).to eq user.id
end
it 'returns users found by email' do
user = create(:user)
found_user = User.identify(user.email)
expect(found_user).to be_an(User)
expect(found_user.id).to eq user.id
end
end
context '.authenticate' do
it 'authenticates by username and password' do
user = create(:user)
result = described_class.authenticate(user.login, 'zammad')
expect(result).to be_an(User)
end
context 'failure' do
it 'increases login_failed on failed logins' do
user = create(:user)
expect do
described_class.authenticate(user.login, 'wrongpw')
user.reload
end
.to change { user.login_failed }.by(1)
end
it 'fails for unknown users' do
result = described_class.authenticate('john.doe', 'zammad')
expect(result).to be nil
end
it 'fails for inactive users' do
user = create(:user, active: false)
result = described_class.authenticate(user.login, 'zammad')
expect(result).to be nil
end
it 'fails for users with too many failed logins' do
user = create(:user, login_failed: 999)
result = described_class.authenticate(user.login, 'zammad')
expect(result).to be nil
end
it 'fails for wrong passwords' do
user = create(:user)
result = described_class.authenticate(user.login, 'wrongpw')
expect(result).to be nil
end
it 'fails for empty username parameter' do
result = described_class.authenticate('', 'zammad')
expect(result).to be nil
end
it 'fails for empty password parameter' do
result = described_class.authenticate('username', '')
expect(result).to be nil
end
end
end
context '.by_reset_token' do
it 'returns a User instance for existing tokens' do
token = create(:token_password_reset)
@ -27,7 +116,7 @@ RSpec.describe User do
end
end
context '#password_reset_via_token' do
context '.password_reset_via_token' do
it 'changes the password of the token user and destroys the token' do
token = create(:token_password_reset)