diff options
Diffstat (limited to 'packages/wekan-ldap')
-rw-r--r-- | packages/wekan-ldap/LICENSE | 21 | ||||
-rw-r--r-- | packages/wekan-ldap/README.md | 130 | ||||
-rw-r--r-- | packages/wekan-ldap/client/loginHelper.js | 52 | ||||
-rw-r--r-- | packages/wekan-ldap/package.js | 32 | ||||
-rw-r--r-- | packages/wekan-ldap/server/index.js | 1 | ||||
-rw-r--r-- | packages/wekan-ldap/server/ldap.js | 555 | ||||
-rw-r--r-- | packages/wekan-ldap/server/logger.js | 15 | ||||
-rw-r--r-- | packages/wekan-ldap/server/loginHandler.js | 224 | ||||
-rw-r--r-- | packages/wekan-ldap/server/sync.js | 447 | ||||
-rw-r--r-- | packages/wekan-ldap/server/syncUser.js | 29 | ||||
-rw-r--r-- | packages/wekan-ldap/server/testConnection.js | 39 |
11 files changed, 1545 insertions, 0 deletions
diff --git a/packages/wekan-ldap/LICENSE b/packages/wekan-ldap/LICENSE new file mode 100644 index 00000000..c2d69158 --- /dev/null +++ b/packages/wekan-ldap/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2019 The Wekan Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/wekan-ldap/README.md b/packages/wekan-ldap/README.md new file mode 100644 index 00000000..4f41d023 --- /dev/null +++ b/packages/wekan-ldap/README.md @@ -0,0 +1,130 @@ +# meteor-ldap + +This packages is based on the RocketChat ldap login package + +# settings definition + +LDAP_Enable: Self explanatory + +LDAP_Port: The port of the LDAP server + +LDAP_Host: The host server for the LDAP server + +LDAP_BaseDN: The base DN for the LDAP Tree + +LDAP_Login_Fallback: Fallback on the default authentication method + +LDAP_Reconnect: Reconnect to the server if the connection is lost + +LDAP_Timeout: self explanatory + +LDAP_Idle_Timeout: self explanatory + +LDAP_Connect_Timeout: self explanatory + +LDAP_Authentication: If the LDAP needs a user account to search + +LDAP_Authentication_UserDN: The search user DN + +LDAP_Authentication_Password: The password for the search user + +LDAP_Internal_Log_Level: The logging level for the module + +LDAP_Background_Sync: If the sync of the users should be done in the +background + +LDAP_Background_Sync_Interval: At which interval does the background task sync + +LDAP_Encryption: If using LDAPS, set it to 'ssl', else it will use 'ldap://' + +LDAP_CA_Cert: The certification for the LDAPS server + +LDAP_Reject_Unauthorized: Reject Unauthorized Certificate + +LDAP_User_Search_Filter: + +LDAP_User_Search_Scope: + +LDAP_User_Search_Field: Which field is used to find the user + +LDAP_Search_Page_Size: + +LDAP_Search_Size_Limit: + +LDAP_Group_Filter_Enable: enable group filtering + +LDAP_Group_Filter_ObjectClass: The object class for filtering + +LDAP_Group_Filter_Group_Id_Attribute: + +LDAP_Group_Filter_Group_Member_Attribute: + +LDAP_Group_Filter_Group_Member_Format: + +LDAP_Group_Filter_Group_Name: + +LDAP_Unique_Identifier_Field: This field is sometimes class GUID ( Globally Unique Identifier) + +UTF8_Names_Slugify: Convert the username to utf8 + +LDAP_Username_Field: Which field contains the ldap username + +LDAP_Fullname_Field: Which field contains the ldap full name + +LDAP_Email_Match_Enable: Allow existing account matching by e-mail address when username does not match + +LDAP_Email_Match_Require: Require existing account matching by e-mail address when username does match + +LDAP_Email_Match_Verified: Require existing account email address to be verified for matching + +LDAP_Email_Field: Which field contains the LDAP e-mail address + +LDAP_Sync_User_Data: + +LDAP_Sync_User_Data_FieldMap: + +Accounts_CustomFields: + +LDAP_Default_Domain: The default domain of the ldap it is used to create email if the field is not map correctly with the LDAP_Sync_User_Data_FieldMap + + + + +# example settings.json +``` +{ + "LDAP_Port": 389, + "LDAP_Host": "localhost", + "LDAP_BaseDN": "ou=user,dc=example,dc=org", + "LDAP_Login_Fallback": false, + "LDAP_Reconnect": true, + "LDAP_Timeout": 10000, + "LDAP_Idle_Timeout": 10000, + "LDAP_Connect_Timeout": 10000, + "LDAP_Authentication": true, + "LDAP_Authentication_UserDN": "cn=admin,dc=example,dc=org", + "LDAP_Authentication_Password": "admin", + "LDAP_Internal_Log_Level": "debug", + "LDAP_Background_Sync": false, + "LDAP_Background_Sync_Interval": "100", + "LDAP_Encryption": false, + "LDAP_Reject_Unauthorized": false, + "LDAP_Group_Filter_Enable": false, + "LDAP_Search_Page_Size": 0, + "LDAP_Search_Size_Limit": 0, + "LDAP_User_Search_Filter": "", + "LDAP_User_Search_Field": "uid", + "LDAP_User_Search_Scope": "", + "LDAP_Unique_Identifier_Field": "guid", + "LDAP_Username_Field": "uid", + "LDAP_Fullname_Field": "cn", + "LDAP_Email_Match_Enable": true, + "LDAP_Email_Match_Require": false, + "LDAP_Email_Match_Verified": false, + "LDAP_Email_Field": "mail", + "LDAP_Sync_User_Data": false, + "LDAP_Sync_User_Data_FieldMap": "{\"cn\":\"name\", \"mail\":\"email\"}", + "LDAP_Merge_Existing_Users": true, + "UTF8_Names_Slugify": true +} +``` diff --git a/packages/wekan-ldap/client/loginHelper.js b/packages/wekan-ldap/client/loginHelper.js new file mode 100644 index 00000000..48a290c1 --- /dev/null +++ b/packages/wekan-ldap/client/loginHelper.js @@ -0,0 +1,52 @@ +// Pass in username, password as normal +// customLdapOptions should be passed in if you want to override LDAP_DEFAULTS +// on any particular call (if you have multiple ldap servers you'd like to connect to) +// You'll likely want to set the dn value here {dn: "..."} +Meteor.loginWithLDAP = function(username, password, customLdapOptions, callback) { + // Retrieve arguments as array + const args = []; + for (let i = 0; i < arguments.length; i++) { + args.push(arguments[i]); + } + // Pull username and password + username = args.shift(); + password = args.shift(); + + // Check if last argument is a function + // if it is, pop it off and set callback to it + if (typeof args[args.length-1] === 'function') { + callback = args.pop(); + } else { + callback = null; + } + + // if args still holds options item, grab it + if (args.length > 0) { + customLdapOptions = args.shift(); + } else { + customLdapOptions = {}; + } + + // Set up loginRequest object + const loginRequest = { + ldap: true, + username, + ldapPass: password, + ldapOptions: customLdapOptions, + }; + + Accounts.callLoginMethod({ + // Call login method with ldap = true + // This will hook into our login handler for ldap + methodArguments: [loginRequest], + userCallback(error/*, result*/) { + if (error) { + if (callback) { + callback(error); + } + } else if (callback) { + callback(); + } + }, + }); +}; diff --git a/packages/wekan-ldap/package.js b/packages/wekan-ldap/package.js new file mode 100644 index 00000000..feda02e9 --- /dev/null +++ b/packages/wekan-ldap/package.js @@ -0,0 +1,32 @@ +Package.describe({ + name: 'wekan-ldap', + version: '0.0.2', + // Brief, one-line summary of the package. + summary: 'Basic meteor login with ldap', + // URL to the Git repository containing the source code for this package. + git: 'https://github.com/wekan/wekan-ldap', + // By default, Meteor will default to using README.md for documentation. + // To avoid submitting documentation, set this field to null. + documentation: 'README.md' +}); + + +Package.onUse(function(api) { + api.versionsFrom('1.0.3.1'); + api.use('yasaricli:slugify@0.0.5'); + api.use('ecmascript@0.9.0'); + api.use('underscore'); + api.use('sha'); + api.use('templating', 'client'); + + api.use('accounts-base', 'server'); + api.use('accounts-password', 'server'); + + api.addFiles('client/loginHelper.js', 'client'); + + api.mainModule('server/index.js', 'server'); +}); + +Npm.depends({ + ldapjs: '1.0.2', +}); diff --git a/packages/wekan-ldap/server/index.js b/packages/wekan-ldap/server/index.js new file mode 100644 index 00000000..e3ff85a1 --- /dev/null +++ b/packages/wekan-ldap/server/index.js @@ -0,0 +1 @@ +import './loginHandler'; diff --git a/packages/wekan-ldap/server/ldap.js b/packages/wekan-ldap/server/ldap.js new file mode 100644 index 00000000..555a30aa --- /dev/null +++ b/packages/wekan-ldap/server/ldap.js @@ -0,0 +1,555 @@ +import ldapjs from 'ldapjs'; +import util from 'util'; +import Bunyan from 'bunyan'; +import { log_debug, log_info, log_warn, log_error } from './logger'; + +export default class LDAP { + constructor(){ + this.ldapjs = ldapjs; + + this.connected = false; + + this.options = { + host: this.constructor.settings_get('LDAP_HOST'), + port: this.constructor.settings_get('LDAP_PORT'), + Reconnect: this.constructor.settings_get('LDAP_RECONNECT'), + timeout: this.constructor.settings_get('LDAP_TIMEOUT'), + connect_timeout: this.constructor.settings_get('LDAP_CONNECT_TIMEOUT'), + idle_timeout: this.constructor.settings_get('LDAP_IDLE_TIMEOUT'), + encryption: this.constructor.settings_get('LDAP_ENCRYPTION'), + ca_cert: this.constructor.settings_get('LDAP_CA_CERT'), + reject_unauthorized: this.constructor.settings_get('LDAP_REJECT_UNAUTHORIZED') || false, + Authentication: this.constructor.settings_get('LDAP_AUTHENTIFICATION'), + Authentication_UserDN: this.constructor.settings_get('LDAP_AUTHENTIFICATION_USERDN'), + Authentication_Password: this.constructor.settings_get('LDAP_AUTHENTIFICATION_PASSWORD'), + Authentication_Fallback: this.constructor.settings_get('LDAP_LOGIN_FALLBACK'), + BaseDN: this.constructor.settings_get('LDAP_BASEDN'), + Internal_Log_Level: this.constructor.settings_get('INTERNAL_LOG_LEVEL'), + User_Search_Filter: this.constructor.settings_get('LDAP_USER_SEARCH_FILTER'), + User_Search_Scope: this.constructor.settings_get('LDAP_USER_SEARCH_SCOPE'), + User_Search_Field: this.constructor.settings_get('LDAP_USER_SEARCH_FIELD'), + Search_Page_Size: this.constructor.settings_get('LDAP_SEARCH_PAGE_SIZE'), + Search_Size_Limit: this.constructor.settings_get('LDAP_SEARCH_SIZE_LIMIT'), + group_filter_enabled: this.constructor.settings_get('LDAP_GROUP_FILTER_ENABLE'), + group_filter_object_class: this.constructor.settings_get('LDAP_GROUP_FILTER_OBJECTCLASS'), + group_filter_group_id_attribute: this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE'), + group_filter_group_member_attribute: this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE'), + group_filter_group_member_format: this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT'), + group_filter_group_name: this.constructor.settings_get('LDAP_GROUP_FILTER_GROUP_NAME'), + }; + } + + static settings_get(name, ...args) { + let value = process.env[name]; + if (value !== undefined) { + if (value === 'true' || value === 'false') { + value = JSON.parse(value); + } else if (value !== '' && !isNaN(value)) { + value = Number(value); + } + return value; + } else { + log_warn(`Lookup for unset variable: ${name}`); + } + } + connectSync(...args) { + if (!this._connectSync) { + this._connectSync = Meteor.wrapAsync(this.connectAsync, this); + } + return this._connectSync(...args); + } + + searchAllSync(...args) { + if (!this._searchAllSync) { + this._searchAllSync = Meteor.wrapAsync(this.searchAllAsync, this); + } + return this._searchAllSync(...args); + } + + connectAsync(callback) { + log_info('Init setup'); + + let replied = false; + + const connectionOptions = { + url: `${ this.options.host }:${ this.options.port }`, + timeout: this.options.timeout, + connectTimeout: this.options.connect_timeout, + idleTimeout: this.options.idle_timeout, + reconnect: this.options.Reconnect, + }; + + if (this.options.Internal_Log_Level !== 'disabled') { + connectionOptions.log = new Bunyan({ + name: 'ldapjs', + component: 'client', + stream: process.stderr, + level: this.options.Internal_Log_Level, + }); + } + + const tlsOptions = { + rejectUnauthorized: this.options.reject_unauthorized, + }; + + if (this.options.ca_cert && this.options.ca_cert !== '') { + // Split CA cert into array of strings + const chainLines = this.constructor.settings_get('LDAP_CA_CERT').split('\n'); + let cert = []; + const ca = []; + chainLines.forEach((line) => { + cert.push(line); + if (line.match(/-END CERTIFICATE-/)) { + ca.push(cert.join('\n')); + cert = []; + } + }); + tlsOptions.ca = ca; + } + + if (this.options.encryption === 'ssl') { + connectionOptions.url = `ldaps://${ connectionOptions.url }`; + connectionOptions.tlsOptions = tlsOptions; + } else { + connectionOptions.url = `ldap://${ connectionOptions.url }`; + } + + log_info('Connecting', connectionOptions.url); + log_debug(`connectionOptions${ util.inspect(connectionOptions)}`); + + this.client = ldapjs.createClient(connectionOptions); + + this.bindSync = Meteor.wrapAsync(this.client.bind, this.client); + + this.client.on('error', (error) => { + log_error('connection', error); + if (replied === false) { + replied = true; + callback(error, null); + } + }); + + this.client.on('idle', () => { + log_info('Idle'); + this.disconnect(); + }); + + this.client.on('close', () => { + log_info('Closed'); + }); + + if (this.options.encryption === 'tls') { + // Set host parameter for tls.connect which is used by ldapjs starttls. This shouldn't be needed in newer nodejs versions (e.g v5.6.0). + // https://github.com/RocketChat/Rocket.Chat/issues/2035 + // https://github.com/mcavage/node-ldapjs/issues/349 + tlsOptions.host = this.options.host; + + log_info('Starting TLS'); + log_debug('tlsOptions', tlsOptions); + + this.client.starttls(tlsOptions, null, (error, response) => { + if (error) { + log_error('TLS connection', error); + if (replied === false) { + replied = true; + callback(error, null); + } + return; + } + + log_info('TLS connected'); + this.connected = true; + if (replied === false) { + replied = true; + callback(null, response); + } + }); + } else { + this.client.on('connect', (response) => { + log_info('LDAP connected'); + this.connected = true; + if (replied === false) { + replied = true; + callback(null, response); + } + }); + } + + setTimeout(() => { + if (replied === false) { + log_error('connection time out', connectionOptions.connectTimeout); + replied = true; + callback(new Error('Timeout')); + } + }, connectionOptions.connectTimeout); + } + + getUserFilter(username) { + const filter = []; + + if (this.options.User_Search_Filter !== '') { + if (this.options.User_Search_Filter[0] === '(') { + filter.push(`${ this.options.User_Search_Filter }`); + } else { + filter.push(`(${ this.options.User_Search_Filter })`); + } + } + + const usernameFilter = this.options.User_Search_Field.split(',').map((item) => `(${ item }=${ username })`); + + if (usernameFilter.length === 0) { + log_error('LDAP_LDAP_User_Search_Field not defined'); + } else if (usernameFilter.length === 1) { + filter.push(`${ usernameFilter[0] }`); + } else { + filter.push(`(|${ usernameFilter.join('') })`); + } + + return `(&${ filter.join('') })`; + } + + bindIfNecessary() { + if (this.domainBinded === true) { + return; + } + + if (this.options.Authentication !== true) { + return; + } + + log_info('Binding UserDN', this.options.Authentication_UserDN); + this.bindSync(this.options.Authentication_UserDN, this.options.Authentication_Password); + this.domainBinded = true; + } + + searchUsersSync(username, page) { + this.bindIfNecessary(); + + const searchOptions = { + filter: this.getUserFilter(username), + scope: this.options.User_Search_Scope || 'sub', + sizeLimit: this.options.Search_Size_Limit, + }; + + if (this.options.Search_Page_Size > 0) { + searchOptions.paged = { + pageSize: this.options.Search_Page_Size, + pagePause: !!page, + }; + } + + log_info('Searching user', username); + log_debug('searchOptions', searchOptions); + log_debug('BaseDN', this.options.BaseDN); + + if (page) { + return this.searchAllPaged(this.options.BaseDN, searchOptions, page); + } + + return this.searchAllSync(this.options.BaseDN, searchOptions); + } + + getUserByIdSync(id, attribute) { + this.bindIfNecessary(); + + const Unique_Identifier_Field = this.constructor.settings_get('LDAP_UNIQUE_IDENTIFIER_FIELD').split(','); + + let filter; + + if (attribute) { + filter = new this.ldapjs.filters.EqualityFilter({ + attribute, + value: new Buffer(id, 'hex'), + }); + } else { + const filters = []; + Unique_Identifier_Field.forEach((item) => { + filters.push(new this.ldapjs.filters.EqualityFilter({ + attribute: item, + value: new Buffer(id, 'hex'), + })); + }); + + filter = new this.ldapjs.filters.OrFilter({filters}); + } + + const searchOptions = { + filter, + scope: 'sub', + }; + + log_info('Searching by id', id); + log_debug('search filter', searchOptions.filter.toString()); + log_debug('BaseDN', this.options.BaseDN); + + const result = this.searchAllSync(this.options.BaseDN, searchOptions); + + if (!Array.isArray(result) || result.length === 0) { + return; + } + + if (result.length > 1) { + log_error('Search by id', id, 'returned', result.length, 'records'); + } + + return result[0]; + } + + getUserByUsernameSync(username) { + this.bindIfNecessary(); + + const searchOptions = { + filter: this.getUserFilter(username), + scope: this.options.User_Search_Scope || 'sub', + }; + + log_info('Searching user', username); + log_debug('searchOptions', searchOptions); + log_debug('BaseDN', this.options.BaseDN); + + const result = this.searchAllSync(this.options.BaseDN, searchOptions); + + if (!Array.isArray(result) || result.length === 0) { + return; + } + + if (result.length > 1) { + log_error('Search by username', username, 'returned', result.length, 'records'); + } + + return result[0]; + } + + getUserGroups(username, ldapUser){ + if (!this.options.group_filter_enabled) { + return true; + } + + const filter = ['(&']; + + if (this.options.group_filter_object_class !== '') { + filter.push(`(objectclass=${ this.options.group_filter_object_class })`); + } + + if (this.options.group_filter_group_member_attribute !== '') { + const format_value = ldapUser[this.options.group_filter_group_member_format]; + if( format_value ) { + filter.push(`(${ this.options.group_filter_group_member_attribute }=${ format_value })`); + } + } + + filter.push(')'); + + const searchOptions = { + filter: filter.join('').replace(/#{username}/g, username), + scope: 'sub', + }; + + log_debug('Group list filter LDAP:', searchOptions.filter); + + const result = this.searchAllSync(this.options.BaseDN, searchOptions); + + if (!Array.isArray(result) || result.length === 0) { + return []; + } + + const grp_identifier = this.options.group_filter_group_id_attribute || 'cn'; + const groups = []; + result.map((item) => { + groups.push( item[ grp_identifier ] ); + }); + log_debug(`Groups: ${ groups.join(', ')}`); + return groups; + + } + + isUserInGroup(username, ldapUser) { + if (!this.options.group_filter_enabled) { + return true; + } + + const grps = this.getUserGroups(username, ldapUser); + + const filter = ['(&']; + + if (this.options.group_filter_object_class !== '') { + filter.push(`(objectclass=${ this.options.group_filter_object_class })`); + } + + if (this.options.group_filter_group_member_attribute !== '') { + const format_value = ldapUser[this.options.group_filter_group_member_format]; + if( format_value ) { + filter.push(`(${ this.options.group_filter_group_member_attribute }=${ format_value })`); + } + } + + if (this.options.group_filter_group_id_attribute !== '') { + filter.push(`(${ this.options.group_filter_group_id_attribute }=${ this.options.group_filter_group_name })`); + } + filter.push(')'); + + const searchOptions = { + filter: filter.join('').replace(/#{username}/g, username), + scope: 'sub', + }; + + log_debug('Group filter LDAP:', searchOptions.filter); + + const result = this.searchAllSync(this.options.BaseDN, searchOptions); + + if (!Array.isArray(result) || result.length === 0) { + return false; + } + return true; + } + + extractLdapEntryData(entry) { + const values = { + _raw: entry.raw, + }; + + Object.keys(values._raw).forEach((key) => { + const value = values._raw[key]; + + if (!['thumbnailPhoto', 'jpegPhoto'].includes(key)) { + if (value instanceof Buffer) { + values[key] = value.toString(); + } else { + values[key] = value; + } + } + }); + + return values; + } + + searchAllPaged(BaseDN, options, page) { + this.bindIfNecessary(); + + const processPage = ({entries, title, end, next}) => { + log_info(title); + // Force LDAP idle to wait the record processing + this.client._updateIdle(true); + page(null, entries, {end, next: () => { + // Reset idle timer + this.client._updateIdle(); + next && next(); + }}); + }; + + this.client.search(BaseDN, options, (error, res) => { + if (error) { + log_error(error); + page(error); + return; + } + + res.on('error', (error) => { + log_error(error); + page(error); + return; + }); + + let entries = []; + + const internalPageSize = options.paged && options.paged.pageSize > 0 ? options.paged.pageSize * 2 : 500; + + res.on('searchEntry', (entry) => { + entries.push(this.extractLdapEntryData(entry)); + + if (entries.length >= internalPageSize) { + processPage({ + entries, + title: 'Internal Page', + end: false, + }); + entries = []; + } + }); + + res.on('page', (result, next) => { + if (!next) { + this.client._updateIdle(true); + processPage({ + entries, + title: 'Final Page', + end: true, + }); + } else if (entries.length) { + log_info('Page'); + processPage({ + entries, + title: 'Page', + end: false, + next, + }); + entries = []; + } + }); + + res.on('end', () => { + if (entries.length) { + processPage({ + entries, + title: 'Final Page', + end: true, + }); + entries = []; + } + }); + }); + } + + searchAllAsync(BaseDN, options, callback) { + this.bindIfNecessary(); + + this.client.search(BaseDN, options, (error, res) => { + if (error) { + log_error(error); + callback(error); + return; + } + + res.on('error', (error) => { + log_error(error); + callback(error); + return; + }); + + const entries = []; + + res.on('searchEntry', (entry) => { + entries.push(this.extractLdapEntryData(entry)); + }); + + res.on('end', () => { + log_info('Search result count', entries.length); + callback(null, entries); + }); + }); + } + + authSync(dn, password) { + log_info('Authenticating', dn); + + try { + if (password === '') { + throw new Error('Password is not provided'); + } + this.bindSync(dn, password); + log_info('Authenticated', dn); + return true; + } catch (error) { + log_info('Not authenticated', dn); + log_debug('error', error); + return false; + } + } + + disconnect() { + this.connected = false; + this.domainBinded = false; + log_info('Disconecting'); + this.client.unbind(); + } +} diff --git a/packages/wekan-ldap/server/logger.js b/packages/wekan-ldap/server/logger.js new file mode 100644 index 00000000..afd77112 --- /dev/null +++ b/packages/wekan-ldap/server/logger.js @@ -0,0 +1,15 @@ +const isLogEnabled = (process.env.LDAP_LOG_ENABLED === 'true'); + + +function log (level, message, data) { + if (isLogEnabled) { + console.log(`[${level}] ${message} ${ data ? JSON.stringify(data, null, 2) : '' }`); + } +} + +function log_debug (...args) { log('DEBUG', ...args); } +function log_info (...args) { log('INFO', ...args); } +function log_warn (...args) { log('WARN', ...args); } +function log_error (...args) { log('ERROR', ...args); } + +export { log, log_debug, log_info, log_warn, log_error }; diff --git a/packages/wekan-ldap/server/loginHandler.js b/packages/wekan-ldap/server/loginHandler.js new file mode 100644 index 00000000..a8f013d7 --- /dev/null +++ b/packages/wekan-ldap/server/loginHandler.js @@ -0,0 +1,224 @@ +import {slug, getLdapUsername, getLdapEmail, getLdapUserUniqueID, syncUserData, addLdapUser} from './sync'; +import LDAP from './ldap'; +import { log_debug, log_info, log_warn, log_error } from './logger'; + +function fallbackDefaultAccountSystem(bind, username, password) { + if (typeof username === 'string') { + if (username.indexOf('@') === -1) { + username = {username}; + } else { + username = {email: username}; + } + } + + log_info('Fallback to default account system: ', username ); + + const loginRequest = { + user: username, + password: { + digest: SHA256(password), + algorithm: 'sha-256', + }, + }; + log_debug('Fallback options: ', loginRequest); + + return Accounts._runLoginHandlers(bind, loginRequest); +} + +Accounts.registerLoginHandler('ldap', function(loginRequest) { + if (!loginRequest.ldap || !loginRequest.ldapOptions) { + return undefined; + } + + log_info('Init LDAP login', loginRequest.username); + + if (LDAP.settings_get('LDAP_ENABLE') !== true) { + return fallbackDefaultAccountSystem(this, loginRequest.username, loginRequest.ldapPass); + } + + const self = this; + const ldap = new LDAP(); + let ldapUser; + + try { + ldap.connectSync(); + const users = ldap.searchUsersSync(loginRequest.username); + + if (users.length !== 1) { + log_info('Search returned', users.length, 'record(s) for', loginRequest.username); + throw new Error('User not Found'); + } + + if (ldap.authSync(users[0].dn, loginRequest.ldapPass) === true) { + if (ldap.isUserInGroup(loginRequest.username, users[0])) { + ldapUser = users[0]; + } else { + throw new Error('User not in a valid group'); + } + } else { + log_info('Wrong password for', loginRequest.username); + } + } catch (error) { + log_error(error); + } + + if (ldapUser === undefined) { + if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') === true) { + return fallbackDefaultAccountSystem(self, loginRequest.username, loginRequest.ldapPass); + } + + throw new Meteor.Error('LDAP-login-error', `LDAP Authentication failed with provided username [${ loginRequest.username }]`); + } + + // Look to see if user already exists + + let userQuery; + + const Unique_Identifier_Field = getLdapUserUniqueID(ldapUser); + let user; + + // Attempt to find user by unique identifier + + if (Unique_Identifier_Field) { + userQuery = { + 'services.ldap.id': Unique_Identifier_Field.value, + }; + + log_info('Querying user'); + log_debug('userQuery', userQuery); + + user = Meteor.users.findOne(userQuery); + } + + // Attempt to find user by username + + let username; + let email; + + if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') { + username = slug(getLdapUsername(ldapUser)); + } else { + username = slug(loginRequest.username); + } + + if(LDAP.settings_get('LDAP_EMAIL_FIELD') !== '') { + email = getLdapEmail(ldapUser); + } + + if (!user) { + if(email && LDAP.settings_get('LDAP_EMAIL_MATCH_REQUIRE') === true) { + if(LDAP.settings_get('LDAP_EMAIL_MATCH_VERIFIED') === true) { + userQuery = { + '_id' : username, + 'emails.0.address' : email, + 'emails.0.verified' : true + }; + } else { + userQuery = { + '_id' : username, + 'emails.0.address' : email + }; + } + } else { + userQuery = { + username + }; + } + + log_debug('userQuery', userQuery); + + user = Meteor.users.findOne(userQuery); + } + + // Attempt to find user by e-mail address only + + if (!user && email && LDAP.settings_get('LDAP_EMAIL_MATCH_ENABLE') === true) { + + log_info('No user exists with username', username, '- attempting to find by e-mail address instead'); + + if(LDAP.settings_get('LDAP_EMAIL_MATCH_VERIFIED') === true) { + userQuery = { + 'emails.0.address': email, + 'emails.0.verified' : true + }; + } else { + userQuery = { + 'emails.0.address' : email + }; + } + + log_debug('userQuery', userQuery); + + user = Meteor.users.findOne(userQuery); + + } + + // Login user if they exist + if (user) { + if (user.authenticationMethod !== 'ldap' && LDAP.settings_get('LDAP_MERGE_EXISTING_USERS') !== true) { + log_info('User exists without "authenticationMethod : ldap"'); + throw new Meteor.Error('LDAP-login-error', `LDAP Authentication succeded, but there's already a matching Wekan account in MongoDB`); + } + + log_info('Logging user'); + + const stampedToken = Accounts._generateStampedLoginToken(); + const update_data = { + $push: { + 'services.resume.loginTokens': Accounts._hashStampedToken(stampedToken), + }, + }; + + if( LDAP.settings_get('LDAP_SYNC_GROUP_ROLES') === true ) { + log_debug('Updating Groups/Roles'); + const groups = ldap.getUserGroups(username, ldapUser); + + if( groups.length > 0 ) { + Roles.setUserRoles(user._id, groups ); + log_info(`Updated roles to:${ groups.join(',')}`); + } + } + + Meteor.users.update(user._id, update_data ); + + syncUserData(user, ldapUser); + + if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') === true) { + Accounts.setPassword(user._id, loginRequest.ldapPass, {logout: false}); + } + + return { + userId: user._id, + token: stampedToken.token, + }; + } + + // Create new user + + log_info('User does not exist, creating', username); + + if (LDAP.settings_get('LDAP_USERNAME_FIELD') === '') { + username = undefined; + } + + if (LDAP.settings_get('LDAP_LOGIN_FALLBACK') !== true) { + loginRequest.ldapPass = undefined; + } + + const result = addLdapUser(ldapUser, username, loginRequest.ldapPass); + + if( LDAP.settings_get('LDAP_SYNC_GROUP_ROLES') === true ) { + const groups = ldap.getUserGroups(username, ldapUser); + if( groups.length > 0 ) { + Roles.setUserRoles(result.userId, groups ); + log_info(`Set roles to:${ groups.join(',')}`); + } + } + + + if (result instanceof Error) { + throw result; + } + + return result; +}); diff --git a/packages/wekan-ldap/server/sync.js b/packages/wekan-ldap/server/sync.js new file mode 100644 index 00000000..141ef349 --- /dev/null +++ b/packages/wekan-ldap/server/sync.js @@ -0,0 +1,447 @@ +import _ from 'underscore'; +import LDAP from './ldap'; +import { log_debug, log_info, log_warn, log_error } from './logger'; + +Object.defineProperty(Object.prototype, "getLDAPValue", { + value: function (prop) { + const self = this; + for (let key in self) { + if (key.toLowerCase() == prop.toLowerCase()) { + return self[key]; + } + } + }, + + enumerable: false +}); + +export function slug(text) { + if (LDAP.settings_get('LDAP_UTF8_NAMES_SLUGIFY') !== true) { + return text; + } + text = slugify(text, '.'); + return text.replace(/[^0-9a-z-_.]/g, ''); +} + +function templateVarHandler (variable, object) { + + const templateRegex = /#{([\w\-]+)}/gi; + let match = templateRegex.exec(variable); + let tmpVariable = variable; + + if (match == null) { + if (!object.hasOwnProperty(variable)) { + return; + } + return object[variable]; + } else { + while (match != null) { + const tmplVar = match[0]; + const tmplAttrName = match[1]; + + if (!object.hasOwnProperty(tmplAttrName)) { + return; + } + + const attrVal = object[tmplAttrName]; + tmpVariable = tmpVariable.replace(tmplVar, attrVal); + match = templateRegex.exec(variable); + } + return tmpVariable; + } +} + +export function getPropertyValue(obj, key) { + try { + return _.reduce(key.split('.'), (acc, el) => acc[el], obj); + } catch (err) { + return undefined; + } +} + +export function getLdapUsername(ldapUser) { + const usernameField = LDAP.settings_get('LDAP_USERNAME_FIELD'); + + if (usernameField.indexOf('#{') > -1) { + return usernameField.replace(/#{(.+?)}/g, function(match, field) { + return ldapUser.getLDAPValue(field); + }); + } + + return ldapUser.getLDAPValue(usernameField); +} + +export function getLdapEmail(ldapUser) { + const emailField = LDAP.settings_get('LDAP_EMAIL_FIELD'); + + if (emailField.indexOf('#{') > -1) { + return emailField.replace(/#{(.+?)}/g, function(match, field) { + return ldapUser.getLDAPValue(field); + }); + } + + return ldapUser.getLDAPValue(emailField); +} + +export function getLdapFullname(ldapUser) { + const fullnameField = LDAP.settings_get('LDAP_FULLNAME_FIELD'); + if (fullnameField.indexOf('#{') > -1) { + return fullnameField.replace(/#{(.+?)}/g, function(match, field) { + return ldapUser.getLDAPValue(field); + }); + } + return ldapUser.getLDAPValue(fullnameField); +} + +export function getLdapUserUniqueID(ldapUser) { + let Unique_Identifier_Field = LDAP.settings_get('LDAP_UNIQUE_IDENTIFIER_FIELD'); + + if (Unique_Identifier_Field !== '') { + Unique_Identifier_Field = Unique_Identifier_Field.replace(/\s/g, '').split(','); + } else { + Unique_Identifier_Field = []; + } + + let User_Search_Field = LDAP.settings_get('LDAP_USER_SEARCH_FIELD'); + + if (User_Search_Field !== '') { + User_Search_Field = User_Search_Field.replace(/\s/g, '').split(','); + } else { + User_Search_Field = []; + } + + Unique_Identifier_Field = Unique_Identifier_Field.concat(User_Search_Field); + + if (Unique_Identifier_Field.length > 0) { + Unique_Identifier_Field = Unique_Identifier_Field.find((field) => { + return !_.isEmpty(ldapUser._raw.getLDAPValue(field)); + }); + if (Unique_Identifier_Field) { + log_debug(`Identifying user with: ${ Unique_Identifier_Field}`); + Unique_Identifier_Field = { + attribute: Unique_Identifier_Field, + value: ldapUser._raw.getLDAPValue(Unique_Identifier_Field).toString('hex'), + }; + } + return Unique_Identifier_Field; + } +} + +export function getDataToSyncUserData(ldapUser, user) { + const syncUserData = LDAP.settings_get('LDAP_SYNC_USER_DATA'); + const syncUserDataFieldMap = LDAP.settings_get('LDAP_SYNC_USER_DATA_FIELDMAP').trim(); + + const userData = {}; + + if (syncUserData && syncUserDataFieldMap) { + const whitelistedUserFields = ['email', 'name', 'customFields']; + const fieldMap = JSON.parse(syncUserDataFieldMap); + const emailList = []; + _.map(fieldMap, function(userField, ldapField) { + log_debug(`Mapping field ${ldapField} -> ${userField}`); + switch (userField) { + case 'email': + if (!ldapUser.hasOwnProperty(ldapField)) { + log_debug(`user does not have attribute: ${ ldapField }`); + return; + } + + if (_.isObject(ldapUser[ldapField])) { + _.map(ldapUser[ldapField], function(item) { + emailList.push({ address: item, verified: true }); + }); + } else { + emailList.push({ address: ldapUser[ldapField], verified: true }); + } + break; + + default: + const [outerKey, innerKeys] = userField.split(/\.(.+)/); + + if (!_.find(whitelistedUserFields, (el) => el === outerKey)) { + log_debug(`user attribute not whitelisted: ${ userField }`); + return; + } + + if (outerKey === 'customFields') { + let customFieldsMeta; + + try { + customFieldsMeta = JSON.parse(LDAP.settings_get('Accounts_CustomFields')); + } catch (e) { + log_debug('Invalid JSON for Custom Fields'); + return; + } + + if (!getPropertyValue(customFieldsMeta, innerKeys)) { + log_debug(`user attribute does not exist: ${ userField }`); + return; + } + } + + const tmpUserField = getPropertyValue(user, userField); + const tmpLdapField = templateVarHandler(ldapField, ldapUser); + + if (tmpLdapField && tmpUserField !== tmpLdapField) { + // creates the object structure instead of just assigning 'tmpLdapField' to + // 'userData[userField]' in order to avoid the "cannot use the part (...) + // to traverse the element" (MongoDB) error that can happen. Do not handle + // arrays. + // TODO: Find a better solution. + const dKeys = userField.split('.'); + const lastKey = _.last(dKeys); + _.reduce(dKeys, (obj, currKey) => + (currKey === lastKey) + ? obj[currKey] = tmpLdapField + : obj[currKey] = obj[currKey] || {} + , userData); + log_debug(`user.${ userField } changed to: ${ tmpLdapField }`); + } + } + }); + + if (emailList.length > 0) { + if (JSON.stringify(user.emails) !== JSON.stringify(emailList)) { + userData.emails = emailList; + } + } + } + + const uniqueId = getLdapUserUniqueID(ldapUser); + + if (uniqueId && (!user.services || !user.services.ldap || user.services.ldap.id !== uniqueId.value || user.services.ldap.idAttribute !== uniqueId.attribute)) { + userData['services.ldap.id'] = uniqueId.value; + userData['services.ldap.idAttribute'] = uniqueId.attribute; + } + + if (user.authenticationMethod !== 'ldap') { + userData.ldap = true; + } + + if (_.size(userData)) { + return userData; + } +} + + +export function syncUserData(user, ldapUser) { + log_info('Syncing user data'); + log_debug('user', {'email': user.email, '_id': user._id}); + // log_debug('ldapUser', ldapUser.object); + + if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') { + const username = slug(getLdapUsername(ldapUser)); + if (user && user._id && username !== user.username) { + log_info('Syncing user username', user.username, '->', username); + Meteor.users.findOne({ _id: user._id }, { $set: { username }}); + } + } + + if (LDAP.settings_get('LDAP_FULLNAME_FIELD') !== '') { + const fullname= getLdapFullname(ldapUser); + log_debug('fullname=',fullname); + if (user && user._id && fullname !== '') { + log_info('Syncing user fullname:', fullname); + Meteor.users.update({ _id: user._id }, { $set: { 'profile.fullname' : fullname, }}); + } + } + +} + +export function addLdapUser(ldapUser, username, password) { + const uniqueId = getLdapUserUniqueID(ldapUser); + + const userObject = { + }; + + if (username) { + userObject.username = username; + } + + const userData = getDataToSyncUserData(ldapUser, {}); + + if (userData && userData.emails && userData.emails[0] && userData.emails[0].address) { + if (Array.isArray(userData.emails[0].address)) { + userObject.email = userData.emails[0].address[0]; + } else { + userObject.email = userData.emails[0].address; + } + } else if (ldapUser.mail && ldapUser.mail.indexOf('@') > -1) { + userObject.email = ldapUser.mail; + } else if (LDAP.settings_get('LDAP_DEFAULT_DOMAIN') !== '') { + userObject.email = `${ username || uniqueId.value }@${ LDAP.settings_get('LDAP_DEFAULT_DOMAIN') }`; + } else { + const error = new Meteor.Error('LDAP-login-error', 'LDAP Authentication succeded, there is no email to create an account. Have you tried setting your Default Domain in LDAP Settings?'); + log_error(error); + throw error; + } + + log_debug('New user data', userObject); + + if (password) { + userObject.password = password; + } + + try { + // This creates the account with password service + userObject.ldap = true; + userObject._id = Accounts.createUser(userObject); + + // Add the services.ldap identifiers + Meteor.users.update({ _id: userObject._id }, { + $set: { + 'services.ldap': { id: uniqueId.value }, + 'emails.0.verified': true, + 'authenticationMethod': 'ldap', + }}); + } catch (error) { + log_error('Error creating user', error); + return error; + } + + syncUserData(userObject, ldapUser); + + return { + userId: userObject._id, + }; +} + +export function importNewUsers(ldap) { + if (LDAP.settings_get('LDAP_ENABLE') !== true) { + log_error('Can\'t run LDAP Import, LDAP is disabled'); + return; + } + + if (!ldap) { + ldap = new LDAP(); + ldap.connectSync(); + } + + let count = 0; + ldap.searchUsersSync('*', Meteor.bindEnvironment((error, ldapUsers, {next, end} = {}) => { + if (error) { + throw error; + } + + ldapUsers.forEach((ldapUser) => { + count++; + + const uniqueId = getLdapUserUniqueID(ldapUser); + // Look to see if user already exists + const userQuery = { + 'services.ldap.id': uniqueId.value, + }; + + log_debug('userQuery', userQuery); + + let username; + if (LDAP.settings_get('LDAP_USERNAME_FIELD') !== '') { + username = slug(getLdapUsername(ldapUser)); + } + + // Add user if it was not added before + let user = Meteor.users.findOne(userQuery); + + if (!user && username && LDAP.settings_get('LDAP_MERGE_EXISTING_USERS') === true) { + const userQuery = { + username, + }; + + log_debug('userQuery merge', userQuery); + + user = Meteor.users.findOne(userQuery); + if (user) { + syncUserData(user, ldapUser); + } + } + + if (!user) { + addLdapUser(ldapUser, username); + } + + if (count % 100 === 0) { + log_info('Import running. Users imported until now:', count); + } + }); + + if (end) { + log_info('Import finished. Users imported:', count); + } + + next(count); + })); +} + +function sync() { + if (LDAP.settings_get('LDAP_ENABLE') !== true) { + return; + } + + const ldap = new LDAP(); + + try { + ldap.connectSync(); + + let users; + if (LDAP.settings_get('LDAP_BACKGROUND_SYNC_KEEP_EXISTANT_USERS_UPDATED') === true) { + users = Meteor.users.find({ 'services.ldap': { $exists: true }}); + } + + if (LDAP.settings_get('LDAP_BACKGROUND_SYNC_IMPORT_NEW_USERS') === true) { + importNewUsers(ldap); + } + + if (LDAP.settings_get('LDAP_BACKGROUND_SYNC_KEEP_EXISTANT_USERS_UPDATED') === true) { + users.forEach(function(user) { + let ldapUser; + + if (user.services && user.services.ldap && user.services.ldap.id) { + ldapUser = ldap.getUserByIdSync(user.services.ldap.id, user.services.ldap.idAttribute); + } else { + ldapUser = ldap.getUserByUsernameSync(user.username); + } + + if (ldapUser) { + syncUserData(user, ldapUser); + } else { + log_info('Can\'t sync user', user.username); + } + }); + } + } catch (error) { + log_error(error); + return error; + } + return true; +} + +const jobName = 'LDAP_Sync'; + +const addCronJob = _.debounce(Meteor.bindEnvironment(function addCronJobDebounced() { + if (LDAP.settings_get('LDAP_BACKGROUND_SYNC') !== true) { + log_info('Disabling LDAP Background Sync'); + if (SyncedCron.nextScheduledAtDate(jobName)) { + SyncedCron.remove(jobName); + } + return; + } + + if (LDAP.settings_get('LDAP_BACKGROUND_SYNC_INTERVAL')) { + log_info('Enabling LDAP Background Sync'); + SyncedCron.add({ + name: jobName, + schedule: (parser) => parser.text(LDAP.settings_get('LDAP_BACKGROUND_SYNC_INTERVAL')), + job() { + sync(); + }, + }); + SyncedCron.start(); + } +}), 500); + +Meteor.startup(() => { + Meteor.defer(() => { + LDAP.settings_get('LDAP_BACKGROUND_SYNC', addCronJob); + LDAP.settings_get('LDAP_BACKGROUND_SYNC_INTERVAL', addCronJob); + }); +}); diff --git a/packages/wekan-ldap/server/syncUser.js b/packages/wekan-ldap/server/syncUser.js new file mode 100644 index 00000000..763ea836 --- /dev/null +++ b/packages/wekan-ldap/server/syncUser.js @@ -0,0 +1,29 @@ +import {importNewUsers} from './sync'; +import LDAP from './ldap'; + +Meteor.methods({ + ldap_sync_now() { + const user = Meteor.user(); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'ldap_sync_users' }); + } + + //TODO: This needs to be fixed - security issue -> alanning:meteor-roles + //if (!RocketChat.authz.hasRole(user._id, 'admin')) { + // throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'ldap_sync_users' }); + //} + + if (LDAP.settings_get('LDAP_ENABLE') !== true) { + throw new Meteor.Error('LDAP_disabled'); + } + + this.unblock(); + + importNewUsers(); + + return { + message: 'Sync_in_progress', + params: [], + }; + }, +}); diff --git a/packages/wekan-ldap/server/testConnection.js b/packages/wekan-ldap/server/testConnection.js new file mode 100644 index 00000000..02866ce5 --- /dev/null +++ b/packages/wekan-ldap/server/testConnection.js @@ -0,0 +1,39 @@ +import LDAP from './ldap'; + +Meteor.methods({ + ldap_test_connection() { + const user = Meteor.user(); + if (!user) { + throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'ldap_test_connection' }); + } + + //TODO: This needs to be fixed - security issue -> alanning:meteor-roles + //if (!RocketChat.authz.hasRole(user._id, 'admin')) { + // throw new Meteor.Error('error-not-authorized', 'Not authorized', { method: 'ldap_test_connection' }); + //} + + if (LDAP.settings_get(LDAP_ENABLE) !== true) { + throw new Meteor.Error('LDAP_disabled'); + } + + let ldap; + try { + ldap = new LDAP(); + ldap.connectSync(); + } catch (error) { + console.log(error); + throw new Meteor.Error(error.message); + } + + try { + ldap.bindIfNecessary(); + } catch (error) { + throw new Meteor.Error(error.name || error.message); + } + + return { + message: 'Connection_success', + params: [], + }; + }, +}); |