##// END OF EJS Templates
ldap: adde common ldap-ce na ldap-ee structure, and extend options...
marcink -
r3235:db5132ef default
parent child Browse files
Show More
@@ -1,202 +1,202 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import pytest
22 22
23 23 from rhodecode.tests import assert_session_flash
24 24 from rhodecode.tests.utils import AssertResponse
25 25 from rhodecode.model.db import Session
26 26 from rhodecode.model.settings import SettingsModel
27 27
28 28
29 29 def assert_auth_settings_updated(response):
30 30 assert response.status_int == 302, 'Expected response HTTP Found 302'
31 31 assert_session_flash(response, 'Auth settings updated successfully')
32 32
33 33
34 34 @pytest.mark.usefixtures("autologin_user", "app")
35 35 class TestAuthSettingsView(object):
36 36
37 37 def _enable_plugins(self, plugins_list, csrf_token, override=None,
38 38 verify_response=False):
39 39 test_url = '/_admin/auth'
40 40 params = {
41 41 'auth_plugins': plugins_list,
42 42 'csrf_token': csrf_token,
43 43 }
44 44 if override:
45 45 params.update(override)
46 46 _enabled_plugins = []
47 47 for plugin in plugins_list.split(','):
48 48 plugin_name = plugin.partition('#')[-1]
49 49 enabled_plugin = '%s_enabled' % plugin_name
50 50 cache_ttl = '%s_cache_ttl' % plugin_name
51 51
52 52 # default params that are needed for each plugin,
53 53 # `enabled` and `cache_ttl`
54 54 params.update({
55 55 enabled_plugin: True,
56 56 cache_ttl: 0
57 57 })
58 58 _enabled_plugins.append(enabled_plugin)
59 59
60 60 # we need to clean any enabled plugin before, since they require
61 61 # form params to be present
62 62 db_plugin = SettingsModel().get_setting_by_name('auth_plugins')
63 63 db_plugin.app_settings_value = \
64 64 'egg:rhodecode-enterprise-ce#rhodecode'
65 65 Session().add(db_plugin)
66 66 Session().commit()
67 67 for _plugin in _enabled_plugins:
68 68 db_plugin = SettingsModel().get_setting_by_name(_plugin)
69 69 if db_plugin:
70 70 Session().delete(db_plugin)
71 71 Session().commit()
72 72
73 73 response = self.app.post(url=test_url, params=params)
74 74
75 75 if verify_response:
76 76 assert_auth_settings_updated(response)
77 77 return params
78 78
79 79 def _post_ldap_settings(self, params, override=None, force=False):
80 80
81 81 params.update({
82 82 'filter': 'user',
83 83 'user_member_of': '',
84 84 'user_search_base': '',
85 85 'user_search_filter': 'test_filter',
86 86
87 87 'host': 'dc.example.com',
88 88 'port': '999',
89 89 'timeout': 3600,
90 90 'tls_kind': 'PLAIN',
91 91 'tls_reqcert': 'NEVER',
92
92 'tls_cert_dir':'/etc/openldap/cacerts',
93 93 'dn_user': 'test_user',
94 94 'dn_pass': 'test_pass',
95 95 'base_dn': 'test_base_dn',
96 96 'search_scope': 'BASE',
97 97 'attr_login': 'test_attr_login',
98 98 'attr_firstname': 'ima',
99 99 'attr_lastname': 'tester',
100 100 'attr_email': 'test@example.com',
101 101 'cache_ttl': '0',
102 102 })
103 103 if force:
104 104 params = {}
105 105 params.update(override or {})
106 106
107 107 test_url = '/_admin/auth/ldap/'
108 108
109 109 response = self.app.post(url=test_url, params=params)
110 110 return response
111 111
112 112 def test_index(self):
113 113 response = self.app.get('/_admin/auth')
114 114 response.mustcontain('Authentication Plugins')
115 115
116 116 @pytest.mark.parametrize("disable_plugin, needs_import", [
117 117 ('egg:rhodecode-enterprise-ce#headers', None),
118 118 ('egg:rhodecode-enterprise-ce#crowd', None),
119 119 ('egg:rhodecode-enterprise-ce#jasig_cas', None),
120 120 ('egg:rhodecode-enterprise-ce#ldap', None),
121 121 ('egg:rhodecode-enterprise-ce#pam', "pam"),
122 122 ])
123 123 def test_disable_plugin(self, csrf_token, disable_plugin, needs_import):
124 124 # TODO: johbo: "pam" is currently not available on darwin,
125 125 # although the docs state that it should work on darwin.
126 126 if needs_import:
127 127 pytest.importorskip(needs_import)
128 128
129 129 self._enable_plugins(
130 130 'egg:rhodecode-enterprise-ce#rhodecode,' + disable_plugin,
131 131 csrf_token, verify_response=True)
132 132
133 133 self._enable_plugins(
134 134 'egg:rhodecode-enterprise-ce#rhodecode', csrf_token,
135 135 verify_response=True)
136 136
137 137 def test_ldap_save_settings(self, csrf_token):
138 138 params = self._enable_plugins(
139 139 'egg:rhodecode-enterprise-ce#rhodecode,'
140 140 'egg:rhodecode-enterprise-ce#ldap',
141 141 csrf_token)
142 142 response = self._post_ldap_settings(params)
143 143 assert_auth_settings_updated(response)
144 144
145 145 new_settings = SettingsModel().get_auth_settings()
146 146 assert new_settings['auth_ldap_host'] == u'dc.example.com', \
147 147 'fail db write compare'
148 148
149 149 def test_ldap_error_form_wrong_port_number(self, csrf_token):
150 150 params = self._enable_plugins(
151 151 'egg:rhodecode-enterprise-ce#rhodecode,'
152 152 'egg:rhodecode-enterprise-ce#ldap',
153 153 csrf_token)
154 154 invalid_port_value = 'invalid-port-number'
155 155 response = self._post_ldap_settings(params, override={
156 156 'port': invalid_port_value,
157 157 })
158 158 assertr = AssertResponse(response)
159 159 assertr.element_contains(
160 160 '.form .field #port ~ .error-message',
161 161 invalid_port_value)
162 162
163 163 def test_ldap_error_form(self, csrf_token):
164 164 params = self._enable_plugins(
165 165 'egg:rhodecode-enterprise-ce#rhodecode,'
166 166 'egg:rhodecode-enterprise-ce#ldap',
167 167 csrf_token)
168 168 response = self._post_ldap_settings(params, override={
169 169 'attr_login': '',
170 170 })
171 171 response.mustcontain("""<span class="error-message">The LDAP Login"""
172 172 """ attribute of the CN must be specified""")
173 173
174 174 def test_post_ldap_group_settings(self, csrf_token):
175 175 params = self._enable_plugins(
176 176 'egg:rhodecode-enterprise-ce#rhodecode,'
177 177 'egg:rhodecode-enterprise-ce#ldap',
178 178 csrf_token)
179 179
180 180 response = self._post_ldap_settings(params, override={
181 181 'host': 'dc-legacy.example.com',
182 182 'port': '999',
183 183 'tls_kind': 'PLAIN',
184 184 'tls_reqcert': 'NEVER',
185 185 'dn_user': 'test_user',
186 186 'dn_pass': 'test_pass',
187 187 'base_dn': 'test_base_dn',
188 188 'filter': 'test_filter',
189 189 'search_scope': 'BASE',
190 190 'attr_login': 'test_attr_login',
191 191 'attr_firstname': 'ima',
192 192 'attr_lastname': 'tester',
193 193 'attr_email': 'test@example.com',
194 194 'cache_ttl': '60',
195 195 'csrf_token': csrf_token,
196 196 }
197 197 )
198 198 assert_auth_settings_updated(response)
199 199
200 200 new_settings = SettingsModel().get_auth_settings()
201 201 assert new_settings['auth_ldap_host'] == u'dc-legacy.example.com', \
202 202 'fail db write compare'
@@ -1,777 +1,792 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 Authentication modules
23 23 """
24 24 import socket
25 25 import string
26 26 import colander
27 27 import copy
28 28 import logging
29 29 import time
30 30 import traceback
31 31 import warnings
32 32 import functools
33 33
34 34 from pyramid.threadlocal import get_current_registry
35 35
36 36 from rhodecode.authentication.interface import IAuthnPluginRegistry
37 37 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
38 38 from rhodecode.lib import rc_cache
39 39 from rhodecode.lib.auth import PasswordGenerator, _RhodeCodeCryptoBCrypt
40 40 from rhodecode.lib.utils2 import safe_int, safe_str
41 from rhodecode.lib.exceptions import LdapConnectionError
41 from rhodecode.lib.exceptions import LdapConnectionError, LdapUsernameError, \
42 LdapPasswordError
42 43 from rhodecode.model.db import User
43 44 from rhodecode.model.meta import Session
44 45 from rhodecode.model.settings import SettingsModel
45 46 from rhodecode.model.user import UserModel
46 47 from rhodecode.model.user_group import UserGroupModel
47 48
48 49
49 50 log = logging.getLogger(__name__)
50 51
51 52 # auth types that authenticate() function can receive
52 53 VCS_TYPE = 'vcs'
53 54 HTTP_TYPE = 'http'
54 55
55 56
56 57 class hybrid_property(object):
57 58 """
58 59 a property decorator that works both for instance and class
59 60 """
60 61 def __init__(self, fget, fset=None, fdel=None, expr=None):
61 62 self.fget = fget
62 63 self.fset = fset
63 64 self.fdel = fdel
64 65 self.expr = expr or fget
65 66 functools.update_wrapper(self, fget)
66 67
67 68 def __get__(self, instance, owner):
68 69 if instance is None:
69 70 return self.expr(owner)
70 71 else:
71 72 return self.fget(instance)
72 73
73 74 def __set__(self, instance, value):
74 75 self.fset(instance, value)
75 76
76 77 def __delete__(self, instance):
77 78 self.fdel(instance)
78 79
79 80
80 81 class LazyFormencode(object):
81 82 def __init__(self, formencode_obj, *args, **kwargs):
82 83 self.formencode_obj = formencode_obj
83 84 self.args = args
84 85 self.kwargs = kwargs
85 86
86 87 def __call__(self, *args, **kwargs):
87 88 from inspect import isfunction
88 89 formencode_obj = self.formencode_obj
89 90 if isfunction(formencode_obj):
90 91 # case we wrap validators into functions
91 92 formencode_obj = self.formencode_obj(*args, **kwargs)
92 93 return formencode_obj(*self.args, **self.kwargs)
93 94
94 95
95 96 class RhodeCodeAuthPluginBase(object):
96 97 # cache the authentication request for N amount of seconds. Some kind
97 98 # of authentication methods are very heavy and it's very efficient to cache
98 99 # the result of a call. If it's set to None (default) cache is off
99 100 AUTH_CACHE_TTL = None
100 101 AUTH_CACHE = {}
101 102
102 103 auth_func_attrs = {
103 104 "username": "unique username",
104 105 "firstname": "first name",
105 106 "lastname": "last name",
106 107 "email": "email address",
107 108 "groups": '["list", "of", "groups"]',
108 109 "user_group_sync":
109 110 'True|False defines if returned user groups should be synced',
110 111 "extern_name": "name in external source of record",
111 112 "extern_type": "type of external source of record",
112 113 "admin": 'True|False defines if user should be RhodeCode super admin',
113 114 "active":
114 115 'True|False defines active state of user internally for RhodeCode',
115 116 "active_from_extern":
116 117 "True|False\None, active state from the external auth, "
117 118 "None means use definition from RhodeCode extern_type active value"
118 119
119 120 }
120 121 # set on authenticate() method and via set_auth_type func.
121 122 auth_type = None
122 123
123 124 # set on authenticate() method and via set_calling_scope_repo, this is a
124 125 # calling scope repository when doing authentication most likely on VCS
125 126 # operations
126 127 acl_repo_name = None
127 128
128 129 # List of setting names to store encrypted. Plugins may override this list
129 130 # to store settings encrypted.
130 131 _settings_encrypted = []
131 132
132 133 # Mapping of python to DB settings model types. Plugins may override or
133 134 # extend this mapping.
134 135 _settings_type_map = {
135 136 colander.String: 'unicode',
136 137 colander.Integer: 'int',
137 138 colander.Boolean: 'bool',
138 139 colander.List: 'list',
139 140 }
140 141
141 142 # list of keys in settings that are unsafe to be logged, should be passwords
142 143 # or other crucial credentials
143 144 _settings_unsafe_keys = []
144 145
145 146 def __init__(self, plugin_id):
146 147 self._plugin_id = plugin_id
147 148
148 149 def __str__(self):
149 150 return self.get_id()
150 151
151 152 def _get_setting_full_name(self, name):
152 153 """
153 154 Return the full setting name used for storing values in the database.
154 155 """
155 156 # TODO: johbo: Using the name here is problematic. It would be good to
156 157 # introduce either new models in the database to hold Plugin and
157 158 # PluginSetting or to use the plugin id here.
158 159 return 'auth_{}_{}'.format(self.name, name)
159 160
160 161 def _get_setting_type(self, name):
161 162 """
162 163 Return the type of a setting. This type is defined by the SettingsModel
163 164 and determines how the setting is stored in DB. Optionally the suffix
164 165 `.encrypted` is appended to instruct SettingsModel to store it
165 166 encrypted.
166 167 """
167 168 schema_node = self.get_settings_schema().get(name)
168 169 db_type = self._settings_type_map.get(
169 170 type(schema_node.typ), 'unicode')
170 171 if name in self._settings_encrypted:
171 172 db_type = '{}.encrypted'.format(db_type)
172 173 return db_type
173 174
174 175 @classmethod
175 176 def docs(cls):
176 177 """
177 178 Defines documentation url which helps with plugin setup
178 179 """
179 180 return ''
180 181
181 182 @classmethod
182 183 def icon(cls):
183 184 """
184 185 Defines ICON in SVG format for authentication method
185 186 """
186 187 return ''
187 188
188 189 def is_enabled(self):
189 190 """
190 191 Returns true if this plugin is enabled. An enabled plugin can be
191 192 configured in the admin interface but it is not consulted during
192 193 authentication.
193 194 """
194 195 auth_plugins = SettingsModel().get_auth_plugins()
195 196 return self.get_id() in auth_plugins
196 197
197 198 def is_active(self, plugin_cached_settings=None):
198 199 """
199 200 Returns true if the plugin is activated. An activated plugin is
200 201 consulted during authentication, assumed it is also enabled.
201 202 """
202 203 return self.get_setting_by_name(
203 204 'enabled', plugin_cached_settings=plugin_cached_settings)
204 205
205 206 def get_id(self):
206 207 """
207 208 Returns the plugin id.
208 209 """
209 210 return self._plugin_id
210 211
211 212 def get_display_name(self):
212 213 """
213 214 Returns a translation string for displaying purposes.
214 215 """
215 216 raise NotImplementedError('Not implemented in base class')
216 217
217 218 def get_settings_schema(self):
218 219 """
219 220 Returns a colander schema, representing the plugin settings.
220 221 """
221 222 return AuthnPluginSettingsSchemaBase()
222 223
223 224 def get_settings(self):
224 225 """
225 226 Returns the plugin settings as dictionary.
226 227 """
227 228 settings = {}
228 229 raw_settings = SettingsModel().get_all_settings()
229 230 for node in self.get_settings_schema():
230 231 settings[node.name] = self.get_setting_by_name(
231 232 node.name, plugin_cached_settings=raw_settings)
232 233 return settings
233 234
234 235 def get_setting_by_name(self, name, default=None, plugin_cached_settings=None):
235 236 """
236 237 Returns a plugin setting by name.
237 238 """
238 239 full_name = 'rhodecode_{}'.format(self._get_setting_full_name(name))
239 240 if plugin_cached_settings:
240 241 plugin_settings = plugin_cached_settings
241 242 else:
242 243 plugin_settings = SettingsModel().get_all_settings()
243 244
244 245 if full_name in plugin_settings:
245 246 return plugin_settings[full_name]
246 247 else:
247 248 return default
248 249
249 250 def create_or_update_setting(self, name, value):
250 251 """
251 252 Create or update a setting for this plugin in the persistent storage.
252 253 """
253 254 full_name = self._get_setting_full_name(name)
254 255 type_ = self._get_setting_type(name)
255 256 db_setting = SettingsModel().create_or_update_setting(
256 257 full_name, value, type_)
257 258 return db_setting.app_settings_value
258 259
259 260 def log_safe_settings(self, settings):
260 261 """
261 262 returns a log safe representation of settings, without any secrets
262 263 """
263 264 settings_copy = copy.deepcopy(settings)
264 265 for k in self._settings_unsafe_keys:
265 266 if k in settings_copy:
266 267 del settings_copy[k]
267 268 return settings_copy
268 269
269 270 @hybrid_property
270 271 def name(self):
271 272 """
272 273 Returns the name of this authentication plugin.
273 274
274 275 :returns: string
275 276 """
276 277 raise NotImplementedError("Not implemented in base class")
277 278
278 279 def get_url_slug(self):
279 280 """
280 281 Returns a slug which should be used when constructing URLs which refer
281 282 to this plugin. By default it returns the plugin name. If the name is
282 283 not suitable for using it in an URL the plugin should override this
283 284 method.
284 285 """
285 286 return self.name
286 287
287 288 @property
288 289 def is_headers_auth(self):
289 290 """
290 291 Returns True if this authentication plugin uses HTTP headers as
291 292 authentication method.
292 293 """
293 294 return False
294 295
295 296 @hybrid_property
296 297 def is_container_auth(self):
297 298 """
298 299 Deprecated method that indicates if this authentication plugin uses
299 300 HTTP headers as authentication method.
300 301 """
301 302 warnings.warn(
302 303 'Use is_headers_auth instead.', category=DeprecationWarning)
303 304 return self.is_headers_auth
304 305
305 306 @hybrid_property
306 307 def allows_creating_users(self):
307 308 """
308 309 Defines if Plugin allows users to be created on-the-fly when
309 310 authentication is called. Controls how external plugins should behave
310 311 in terms if they are allowed to create new users, or not. Base plugins
311 312 should not be allowed to, but External ones should be !
312 313
313 314 :return: bool
314 315 """
315 316 return False
316 317
317 318 def set_auth_type(self, auth_type):
318 319 self.auth_type = auth_type
319 320
320 321 def set_calling_scope_repo(self, acl_repo_name):
321 322 self.acl_repo_name = acl_repo_name
322 323
323 324 def allows_authentication_from(
324 325 self, user, allows_non_existing_user=True,
325 326 allowed_auth_plugins=None, allowed_auth_sources=None):
326 327 """
327 328 Checks if this authentication module should accept a request for
328 329 the current user.
329 330
330 331 :param user: user object fetched using plugin's get_user() method.
331 332 :param allows_non_existing_user: if True, don't allow the
332 333 user to be empty, meaning not existing in our database
333 334 :param allowed_auth_plugins: if provided, users extern_type will be
334 335 checked against a list of provided extern types, which are plugin
335 336 auth_names in the end
336 337 :param allowed_auth_sources: authentication type allowed,
337 338 `http` or `vcs` default is both.
338 339 defines if plugin will accept only http authentication vcs
339 340 authentication(git/hg) or both
340 341 :returns: boolean
341 342 """
342 343 if not user and not allows_non_existing_user:
343 344 log.debug('User is empty but plugin does not allow empty users,'
344 345 'not allowed to authenticate')
345 346 return False
346 347
347 348 expected_auth_plugins = allowed_auth_plugins or [self.name]
348 349 if user and (user.extern_type and
349 350 user.extern_type not in expected_auth_plugins):
350 351 log.debug(
351 352 'User `%s` is bound to `%s` auth type. Plugin allows only '
352 353 '%s, skipping', user, user.extern_type, expected_auth_plugins)
353 354
354 355 return False
355 356
356 357 # by default accept both
357 358 expected_auth_from = allowed_auth_sources or [HTTP_TYPE, VCS_TYPE]
358 359 if self.auth_type not in expected_auth_from:
359 360 log.debug('Current auth source is %s but plugin only allows %s',
360 361 self.auth_type, expected_auth_from)
361 362 return False
362 363
363 364 return True
364 365
365 366 def get_user(self, username=None, **kwargs):
366 367 """
367 368 Helper method for user fetching in plugins, by default it's using
368 369 simple fetch by username, but this method can be custimized in plugins
369 370 eg. headers auth plugin to fetch user by environ params
370 371
371 372 :param username: username if given to fetch from database
372 373 :param kwargs: extra arguments needed for user fetching.
373 374 """
374 375 user = None
375 376 log.debug(
376 377 'Trying to fetch user `%s` from RhodeCode database', username)
377 378 if username:
378 379 user = User.get_by_username(username)
379 380 if not user:
380 381 log.debug('User not found, fallback to fetch user in '
381 382 'case insensitive mode')
382 383 user = User.get_by_username(username, case_insensitive=True)
383 384 else:
384 385 log.debug('provided username:`%s` is empty skipping...', username)
385 386 if not user:
386 387 log.debug('User `%s` not found in database', username)
387 388 else:
388 389 log.debug('Got DB user:%s', user)
389 390 return user
390 391
391 392 def user_activation_state(self):
392 393 """
393 394 Defines user activation state when creating new users
394 395
395 396 :returns: boolean
396 397 """
397 398 raise NotImplementedError("Not implemented in base class")
398 399
399 400 def auth(self, userobj, username, passwd, settings, **kwargs):
400 401 """
401 402 Given a user object (which may be null), username, a plaintext
402 403 password, and a settings object (containing all the keys needed as
403 404 listed in settings()), authenticate this user's login attempt.
404 405
405 406 Return None on failure. On success, return a dictionary of the form:
406 407
407 408 see: RhodeCodeAuthPluginBase.auth_func_attrs
408 409 This is later validated for correctness
409 410 """
410 411 raise NotImplementedError("not implemented in base class")
411 412
412 413 def _authenticate(self, userobj, username, passwd, settings, **kwargs):
413 414 """
414 415 Wrapper to call self.auth() that validates call on it
415 416
416 417 :param userobj: userobj
417 418 :param username: username
418 419 :param passwd: plaintext password
419 420 :param settings: plugin settings
420 421 """
421 422 auth = self.auth(userobj, username, passwd, settings, **kwargs)
422 423 if auth:
423 424 auth['_plugin'] = self.name
424 425 auth['_ttl_cache'] = self.get_ttl_cache(settings)
425 426 # check if hash should be migrated ?
426 427 new_hash = auth.get('_hash_migrate')
427 428 if new_hash:
428 429 self._migrate_hash_to_bcrypt(username, passwd, new_hash)
429 430 if 'user_group_sync' not in auth:
430 431 auth['user_group_sync'] = False
431 432 return self._validate_auth_return(auth)
432 433 return auth
433 434
434 435 def _migrate_hash_to_bcrypt(self, username, password, new_hash):
435 436 new_hash_cypher = _RhodeCodeCryptoBCrypt()
436 437 # extra checks, so make sure new hash is correct.
437 438 password_encoded = safe_str(password)
438 439 if new_hash and new_hash_cypher.hash_check(
439 440 password_encoded, new_hash):
440 441 cur_user = User.get_by_username(username)
441 442 cur_user.password = new_hash
442 443 Session().add(cur_user)
443 444 Session().flush()
444 445 log.info('Migrated user %s hash to bcrypt', cur_user)
445 446
446 447 def _validate_auth_return(self, ret):
447 448 if not isinstance(ret, dict):
448 449 raise Exception('returned value from auth must be a dict')
449 450 for k in self.auth_func_attrs:
450 451 if k not in ret:
451 452 raise Exception('Missing %s attribute from returned data' % k)
452 453 return ret
453 454
454 455 def get_ttl_cache(self, settings=None):
455 456 plugin_settings = settings or self.get_settings()
456 457 # we set default to 30, we make a compromise here,
457 458 # performance > security, mostly due to LDAP/SVN, majority
458 459 # of users pick cache_ttl to be enabled
459 460 from rhodecode.authentication import plugin_default_auth_ttl
460 461 cache_ttl = plugin_default_auth_ttl
461 462
462 463 if isinstance(self.AUTH_CACHE_TTL, (int, long)):
463 464 # plugin cache set inside is more important than the settings value
464 465 cache_ttl = self.AUTH_CACHE_TTL
465 466 elif plugin_settings.get('cache_ttl'):
466 467 cache_ttl = safe_int(plugin_settings.get('cache_ttl'), 0)
467 468
468 469 plugin_cache_active = bool(cache_ttl and cache_ttl > 0)
469 470 return plugin_cache_active, cache_ttl
470 471
471 472
472 473 class RhodeCodeExternalAuthPlugin(RhodeCodeAuthPluginBase):
473 474
474 475 @hybrid_property
475 476 def allows_creating_users(self):
476 477 return True
477 478
478 479 def use_fake_password(self):
479 480 """
480 481 Return a boolean that indicates whether or not we should set the user's
481 482 password to a random value when it is authenticated by this plugin.
482 483 If your plugin provides authentication, then you will generally
483 484 want this.
484 485
485 486 :returns: boolean
486 487 """
487 488 raise NotImplementedError("Not implemented in base class")
488 489
489 490 def _authenticate(self, userobj, username, passwd, settings, **kwargs):
490 491 # at this point _authenticate calls plugin's `auth()` function
491 492 auth = super(RhodeCodeExternalAuthPlugin, self)._authenticate(
492 493 userobj, username, passwd, settings, **kwargs)
493 494
494 495 if auth:
495 496 # maybe plugin will clean the username ?
496 497 # we should use the return value
497 498 username = auth['username']
498 499
499 500 # if external source tells us that user is not active, we should
500 501 # skip rest of the process. This can prevent from creating users in
501 502 # RhodeCode when using external authentication, but if it's
502 503 # inactive user we shouldn't create that user anyway
503 504 if auth['active_from_extern'] is False:
504 505 log.warning(
505 506 "User %s authenticated against %s, but is inactive",
506 507 username, self.__module__)
507 508 return None
508 509
509 510 cur_user = User.get_by_username(username, case_insensitive=True)
510 511 is_user_existing = cur_user is not None
511 512
512 513 if is_user_existing:
513 514 log.debug('Syncing user `%s` from '
514 515 '`%s` plugin', username, self.name)
515 516 else:
516 517 log.debug('Creating non existing user `%s` from '
517 518 '`%s` plugin', username, self.name)
518 519
519 520 if self.allows_creating_users:
520 521 log.debug('Plugin `%s` allows to '
521 522 'create new users', self.name)
522 523 else:
523 524 log.debug('Plugin `%s` does not allow to '
524 525 'create new users', self.name)
525 526
526 527 user_parameters = {
527 528 'username': username,
528 529 'email': auth["email"],
529 530 'firstname': auth["firstname"],
530 531 'lastname': auth["lastname"],
531 532 'active': auth["active"],
532 533 'admin': auth["admin"],
533 534 'extern_name': auth["extern_name"],
534 535 'extern_type': self.name,
535 536 'plugin': self,
536 537 'allow_to_create_user': self.allows_creating_users,
537 538 }
538 539
539 540 if not is_user_existing:
540 541 if self.use_fake_password():
541 542 # Randomize the PW because we don't need it, but don't want
542 543 # them blank either
543 544 passwd = PasswordGenerator().gen_password(length=16)
544 545 user_parameters['password'] = passwd
545 546 else:
546 547 # Since the password is required by create_or_update method of
547 548 # UserModel, we need to set it explicitly.
548 549 # The create_or_update method is smart and recognises the
549 550 # password hashes as well.
550 551 user_parameters['password'] = cur_user.password
551 552
552 553 # we either create or update users, we also pass the flag
553 554 # that controls if this method can actually do that.
554 555 # raises NotAllowedToCreateUserError if it cannot, and we try to.
555 556 user = UserModel().create_or_update(**user_parameters)
556 557 Session().flush()
557 558 # enforce user is just in given groups, all of them has to be ones
558 559 # created from plugins. We store this info in _group_data JSON
559 560 # field
560 561
561 562 if auth['user_group_sync']:
562 563 try:
563 564 groups = auth['groups'] or []
564 565 log.debug(
565 566 'Performing user_group sync based on set `%s` '
566 567 'returned by `%s` plugin', groups, self.name)
567 568 UserGroupModel().enforce_groups(user, groups, self.name)
568 569 except Exception:
569 570 # for any reason group syncing fails, we should
570 571 # proceed with login
571 572 log.error(traceback.format_exc())
572 573
573 574 Session().commit()
574 575 return auth
575 576
576 577
577 578 class AuthLdapBase(object):
578 579
579 580 @classmethod
580 def _build_servers(cls, ldap_server_type, ldap_server, port):
581 def _build_servers(cls, ldap_server_type, ldap_server, port, use_resolver=True):
582
581 583 def host_resolver(host, port, full_resolve=True):
582 584 """
583 585 Main work for this function is to prevent ldap connection issues,
584 586 and detect them early using a "greenified" sockets
585 587 """
586 588 host = host.strip()
587 589 if not full_resolve:
588 590 return '{}:{}'.format(host, port)
589 591
590 592 log.debug('LDAP: Resolving IP for LDAP host %s', host)
591 593 try:
592 594 ip = socket.gethostbyname(host)
593 595 log.debug('Got LDAP server %s ip %s', host, ip)
594 596 except Exception:
595 597 raise LdapConnectionError(
596 598 'Failed to resolve host: `{}`'.format(host))
597 599
598 600 log.debug('LDAP: Checking if IP %s is accessible', ip)
599 601 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
600 602 try:
601 603 s.connect((ip, int(port)))
602 604 s.shutdown(socket.SHUT_RD)
603 605 except Exception:
604 606 raise LdapConnectionError(
605 607 'Failed to connect to host: `{}:{}`'.format(host, port))
606 608
607 609 return '{}:{}'.format(host, port)
608 610
609 611 if len(ldap_server) == 1:
610 612 # in case of single server use resolver to detect potential
611 613 # connection issues
612 614 full_resolve = True
613 615 else:
614 616 full_resolve = False
615 617
616 618 return ', '.join(
617 619 ["{}://{}".format(
618 620 ldap_server_type,
619 host_resolver(host, port, full_resolve=full_resolve))
621 host_resolver(host, port, full_resolve=use_resolver and full_resolve))
620 622 for host in ldap_server])
621 623
622 624 @classmethod
623 625 def _get_server_list(cls, servers):
624 626 return map(string.strip, servers.split(','))
625 627
626 628 @classmethod
627 629 def get_uid(cls, username, server_addresses):
628 630 uid = username
629 631 for server_addr in server_addresses:
630 632 uid = chop_at(username, "@%s" % server_addr)
631 633 return uid
632 634
635 @classmethod
636 def validate_username(cls, username):
637 if "," in username:
638 raise LdapUsernameError(
639 "invalid character `,` in username: `{}`".format(username))
640
641 @classmethod
642 def validate_password(cls, username, password):
643 if not password:
644 msg = "Authenticating user %s with blank password not allowed"
645 log.warning(msg, username)
646 raise LdapPasswordError(msg)
647
633 648
634 649 def loadplugin(plugin_id):
635 650 """
636 651 Loads and returns an instantiated authentication plugin.
637 652 Returns the RhodeCodeAuthPluginBase subclass on success,
638 653 or None on failure.
639 654 """
640 655 # TODO: Disusing pyramids thread locals to retrieve the registry.
641 656 authn_registry = get_authn_registry()
642 657 plugin = authn_registry.get_plugin(plugin_id)
643 658 if plugin is None:
644 659 log.error('Authentication plugin not found: "%s"', plugin_id)
645 660 return plugin
646 661
647 662
648 663 def get_authn_registry(registry=None):
649 664 registry = registry or get_current_registry()
650 665 authn_registry = registry.getUtility(IAuthnPluginRegistry)
651 666 return authn_registry
652 667
653 668
654 669 def authenticate(username, password, environ=None, auth_type=None,
655 670 skip_missing=False, registry=None, acl_repo_name=None):
656 671 """
657 672 Authentication function used for access control,
658 673 It tries to authenticate based on enabled authentication modules.
659 674
660 675 :param username: username can be empty for headers auth
661 676 :param password: password can be empty for headers auth
662 677 :param environ: environ headers passed for headers auth
663 678 :param auth_type: type of authentication, either `HTTP_TYPE` or `VCS_TYPE`
664 679 :param skip_missing: ignores plugins that are in db but not in environment
665 680 :returns: None if auth failed, plugin_user dict if auth is correct
666 681 """
667 682 if not auth_type or auth_type not in [HTTP_TYPE, VCS_TYPE]:
668 683 raise ValueError('auth type must be on of http, vcs got "%s" instead'
669 684 % auth_type)
670 685 headers_only = environ and not (username and password)
671 686
672 687 authn_registry = get_authn_registry(registry)
673 688 plugins_to_check = authn_registry.get_plugins_for_authentication()
674 689 log.debug('Starting ordered authentication chain using %s plugins',
675 690 [x.name for x in plugins_to_check])
676 691 for plugin in plugins_to_check:
677 692 plugin.set_auth_type(auth_type)
678 693 plugin.set_calling_scope_repo(acl_repo_name)
679 694
680 695 if headers_only and not plugin.is_headers_auth:
681 696 log.debug('Auth type is for headers only and plugin `%s` is not '
682 697 'headers plugin, skipping...', plugin.get_id())
683 698 continue
684 699
685 700 log.debug('Trying authentication using ** %s **', plugin.get_id())
686 701
687 702 # load plugin settings from RhodeCode database
688 703 plugin_settings = plugin.get_settings()
689 704 plugin_sanitized_settings = plugin.log_safe_settings(plugin_settings)
690 705 log.debug('Plugin `%s` settings:%s', plugin.get_id(), plugin_sanitized_settings)
691 706
692 707 # use plugin's method of user extraction.
693 708 user = plugin.get_user(username, environ=environ,
694 709 settings=plugin_settings)
695 710 display_user = user.username if user else username
696 711 log.debug(
697 712 'Plugin %s extracted user is `%s`', plugin.get_id(), display_user)
698 713
699 714 if not plugin.allows_authentication_from(user):
700 715 log.debug('Plugin %s does not accept user `%s` for authentication',
701 716 plugin.get_id(), display_user)
702 717 continue
703 718 else:
704 719 log.debug('Plugin %s accepted user `%s` for authentication',
705 720 plugin.get_id(), display_user)
706 721
707 722 log.info('Authenticating user `%s` using %s plugin',
708 723 display_user, plugin.get_id())
709 724
710 725 plugin_cache_active, cache_ttl = plugin.get_ttl_cache(plugin_settings)
711 726
712 727 log.debug('AUTH_CACHE_TTL for plugin `%s` active: %s (TTL: %s)',
713 728 plugin.get_id(), plugin_cache_active, cache_ttl)
714 729
715 730 user_id = user.user_id if user else None
716 731 # don't cache for empty users
717 732 plugin_cache_active = plugin_cache_active and user_id
718 733 cache_namespace_uid = 'cache_user_auth.{}'.format(user_id)
719 734 region = rc_cache.get_or_create_region('cache_perms', cache_namespace_uid)
720 735
721 736 @region.conditional_cache_on_arguments(namespace=cache_namespace_uid,
722 737 expiration_time=cache_ttl,
723 738 condition=plugin_cache_active)
724 739 def compute_auth(
725 740 cache_name, plugin_name, username, password):
726 741
727 742 # _authenticate is a wrapper for .auth() method of plugin.
728 743 # it checks if .auth() sends proper data.
729 744 # For RhodeCodeExternalAuthPlugin it also maps users to
730 745 # Database and maps the attributes returned from .auth()
731 746 # to RhodeCode database. If this function returns data
732 747 # then auth is correct.
733 748 log.debug('Running plugin `%s` _authenticate method '
734 749 'using username and password', plugin.get_id())
735 750 return plugin._authenticate(
736 751 user, username, password, plugin_settings,
737 752 environ=environ or {})
738 753
739 754 start = time.time()
740 755 # for environ based auth, password can be empty, but then the validation is
741 756 # on the server that fills in the env data needed for authentication
742 757 plugin_user = compute_auth('auth', plugin.name, username, (password or ''))
743 758
744 759 auth_time = time.time() - start
745 760 log.debug('Authentication for plugin `%s` completed in %.3fs, '
746 761 'expiration time of fetched cache %.1fs.',
747 762 plugin.get_id(), auth_time, cache_ttl)
748 763
749 764 log.debug('PLUGIN USER DATA: %s', plugin_user)
750 765
751 766 if plugin_user:
752 767 log.debug('Plugin returned proper authentication data')
753 768 return plugin_user
754 769 # we failed to Auth because .auth() method didn't return proper user
755 770 log.debug("User `%s` failed to authenticate against %s",
756 771 display_user, plugin.get_id())
757 772
758 773 # case when we failed to authenticate against all defined plugins
759 774 return None
760 775
761 776
762 777 def chop_at(s, sub, inclusive=False):
763 778 """Truncate string ``s`` at the first occurrence of ``sub``.
764 779
765 780 If ``inclusive`` is true, truncate just after ``sub`` rather than at it.
766 781
767 782 >>> chop_at("plutocratic brats", "rat")
768 783 'plutoc'
769 784 >>> chop_at("plutocratic brats", "rat", True)
770 785 'plutocrat'
771 786 """
772 787 pos = s.find(sub)
773 788 if pos == -1:
774 789 return s
775 790 if inclusive:
776 791 return s[:pos+len(sub)]
777 792 return s[:pos]
@@ -1,517 +1,528 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 RhodeCode authentication plugin for LDAP
23 23 """
24 24
25 import os
26 25 import logging
27 26 import traceback
28 27
29 28 import colander
30 29 from rhodecode.translation import _
31 30 from rhodecode.authentication.base import (
32 31 RhodeCodeExternalAuthPlugin, AuthLdapBase, hybrid_property)
33 32 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
34 33 from rhodecode.authentication.routes import AuthnPluginResourceBase
35 34 from rhodecode.lib.colander_utils import strip_whitespace
36 35 from rhodecode.lib.exceptions import (
37 36 LdapConnectionError, LdapUsernameError, LdapPasswordError, LdapImportError
38 37 )
39 38 from rhodecode.lib.utils2 import safe_unicode, safe_str
40 39 from rhodecode.model.db import User
41 40 from rhodecode.model.validators import Missing
42 41
43 42 log = logging.getLogger(__name__)
44 43
45 44 try:
46 45 import ldap
47 46 except ImportError:
48 47 # means that python-ldap is not installed, we use Missing object to mark
49 48 # ldap lib is Missing
50 49 ldap = Missing
51 50
52 51
53 52 class LdapError(Exception):
54 53 pass
55 54
56 55
57 56 def plugin_factory(plugin_id, *args, **kwds):
58 57 """
59 58 Factory function that is called during plugin discovery.
60 59 It returns the plugin instance.
61 60 """
62 61 plugin = RhodeCodeAuthPlugin(plugin_id)
63 62 return plugin
64 63
65 64
66 65 class LdapAuthnResource(AuthnPluginResourceBase):
67 66 pass
68 67
69 68
69 class AuthLdap(AuthLdapBase):
70 default_tls_cert_dir = '/etc/openldap/cacerts'
71
72 def __init__(self, server, base_dn, port=389, bind_dn='', bind_pass='',
73 tls_kind='PLAIN', tls_reqcert='DEMAND', tls_cert_file=None,
74 tls_cert_dir=None, ldap_version=3,
75 search_scope='SUBTREE', attr_login='uid',
76 ldap_filter='', timeout=None):
77 if ldap == Missing:
78 raise LdapImportError("Missing or incompatible ldap library")
79
80 self.debug = False
81 self.timeout = timeout or 60 * 5
82 self.ldap_version = ldap_version
83 self.ldap_server_type = 'ldap'
84
85 self.TLS_KIND = tls_kind
86
87 if self.TLS_KIND == 'LDAPS':
88 port = port or 689
89 self.ldap_server_type += 's'
90
91 OPT_X_TLS_DEMAND = 2
92 self.TLS_REQCERT = getattr(ldap, 'OPT_X_TLS_%s' % tls_reqcert, OPT_X_TLS_DEMAND)
93 self.TLS_CERT_FILE = tls_cert_file or ''
94 self.TLS_CERT_DIR = tls_cert_dir or self.default_tls_cert_dir
95
96 # split server into list
97 self.SERVER_ADDRESSES = self._get_server_list(server)
98 self.LDAP_SERVER_PORT = port
99
100 # USE FOR READ ONLY BIND TO LDAP SERVER
101 self.attr_login = attr_login
102
103 self.LDAP_BIND_DN = safe_str(bind_dn)
104 self.LDAP_BIND_PASS = safe_str(bind_pass)
105
106 self.SEARCH_SCOPE = getattr(ldap, 'SCOPE_%s' % search_scope)
107 self.BASE_DN = safe_str(base_dn)
108 self.LDAP_FILTER = safe_str(ldap_filter)
109
110 def _get_ldap_conn(self):
111
112 if self.debug:
113 ldap.set_option(ldap.OPT_DEBUG_LEVEL, 255)
114
115 if self.TLS_CERT_FILE and hasattr(ldap, 'OPT_X_TLS_CACERTFILE'):
116 ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, self.TLS_CERT_FILE)
117
118 elif hasattr(ldap, 'OPT_X_TLS_CACERTDIR'):
119 ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, self.TLS_CERT_DIR)
120
121 if self.TLS_KIND != 'PLAIN':
122 ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, self.TLS_REQCERT)
123
124 ldap.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF)
125 ldap.set_option(ldap.OPT_RESTART, ldap.OPT_ON)
126
127 # init connection now
128 ldap_servers = self._build_servers(
129 self.ldap_server_type, self.SERVER_ADDRESSES, self.LDAP_SERVER_PORT)
130 log.debug('initializing LDAP connection to:%s', ldap_servers)
131 ldap_conn = ldap.initialize(ldap_servers)
132 ldap_conn.set_option(ldap.OPT_NETWORK_TIMEOUT, self.timeout)
133 ldap_conn.set_option(ldap.OPT_TIMEOUT, self.timeout)
134 ldap_conn.timeout = self.timeout
135
136 if self.ldap_version == 2:
137 ldap_conn.protocol = ldap.VERSION2
138 else:
139 ldap_conn.protocol = ldap.VERSION3
140
141 if self.TLS_KIND == 'START_TLS':
142 ldap_conn.start_tls_s()
143
144 if self.LDAP_BIND_DN and self.LDAP_BIND_PASS:
145 log.debug('Trying simple_bind with password and given login DN: %s',
146 self.LDAP_BIND_DN)
147 ldap_conn.simple_bind_s(self.LDAP_BIND_DN, self.LDAP_BIND_PASS)
148
149 return ldap_conn
150
151 def fetch_attrs_from_simple_bind(self, server, dn, username, password):
152 try:
153 log.debug('Trying simple bind with %s', dn)
154 server.simple_bind_s(dn, safe_str(password))
155 user = server.search_ext_s(
156 dn, ldap.SCOPE_BASE, '(objectClass=*)', )[0]
157 _, attrs = user
158 return attrs
159
160 except ldap.INVALID_CREDENTIALS:
161 log.debug(
162 "LDAP rejected password for user '%s': %s, org_exc:",
163 username, dn, exc_info=True)
164
165 def authenticate_ldap(self, username, password):
166 """
167 Authenticate a user via LDAP and return his/her LDAP properties.
168
169 Raises AuthenticationError if the credentials are rejected, or
170 EnvironmentError if the LDAP server can't be reached.
171
172 :param username: username
173 :param password: password
174 """
175
176 uid = self.get_uid(username, self.SERVER_ADDRESSES)
177 user_attrs = {}
178 dn = ''
179
180 self.validate_password(username, password)
181 self.validate_username(username)
182
183 ldap_conn = None
184 try:
185 ldap_conn = self._get_ldap_conn()
186 filter_ = '(&%s(%s=%s))' % (
187 self.LDAP_FILTER, self.attr_login, username)
188 log.debug("Authenticating %r filter %s", self.BASE_DN, filter_)
189
190 lobjects = ldap_conn.search_ext_s(
191 self.BASE_DN, self.SEARCH_SCOPE, filter_)
192
193 if not lobjects:
194 log.debug("No matching LDAP objects for authentication "
195 "of UID:'%s' username:(%s)", uid, username)
196 raise ldap.NO_SUCH_OBJECT()
197
198 log.debug('Found matching ldap object, trying to authenticate')
199 for (dn, _attrs) in lobjects:
200 if dn is None:
201 continue
202
203 user_attrs = self.fetch_attrs_from_simple_bind(
204 ldap_conn, dn, username, password)
205 if user_attrs:
206 break
207 else:
208 raise LdapPasswordError(
209 'Failed to authenticate user `{}`'
210 'with given password'.format(username))
211
212 except ldap.NO_SUCH_OBJECT:
213 log.debug("LDAP says no such user '%s' (%s), org_exc:",
214 uid, username, exc_info=True)
215 raise LdapUsernameError('Unable to find user')
216 except ldap.SERVER_DOWN:
217 org_exc = traceback.format_exc()
218 raise LdapConnectionError(
219 "LDAP can't access authentication "
220 "server, org_exc:%s" % org_exc)
221 finally:
222 if ldap_conn:
223 log.debug('ldap: connection release')
224 try:
225 ldap_conn.unbind_s()
226 except Exception:
227 # for any reason this can raise exception we must catch it
228 # to not crush the server
229 pass
230
231 return dn, user_attrs
232
233
70 234 class LdapSettingsSchema(AuthnPluginSettingsSchemaBase):
71 235 tls_kind_choices = ['PLAIN', 'LDAPS', 'START_TLS']
72 236 tls_reqcert_choices = ['NEVER', 'ALLOW', 'TRY', 'DEMAND', 'HARD']
73 237 search_scope_choices = ['BASE', 'ONELEVEL', 'SUBTREE']
74 238
75 239 host = colander.SchemaNode(
76 240 colander.String(),
77 241 default='',
78 242 description=_('Host[s] of the LDAP Server \n'
79 243 '(e.g., 192.168.2.154, or ldap-server.domain.com.\n '
80 244 'Multiple servers can be specified using commas'),
81 245 preparer=strip_whitespace,
82 246 title=_('LDAP Host'),
83 247 widget='string')
84 248 port = colander.SchemaNode(
85 249 colander.Int(),
86 250 default=389,
87 251 description=_('Custom port that the LDAP server is listening on. '
88 252 'Default value is: 389'),
89 253 preparer=strip_whitespace,
90 254 title=_('Port'),
91 255 validator=colander.Range(min=0, max=65536),
92 256 widget='int')
93 257
94 258 timeout = colander.SchemaNode(
95 259 colander.Int(),
96 260 default=60 * 5,
97 261 description=_('Timeout for LDAP connection'),
98 262 preparer=strip_whitespace,
99 263 title=_('Connection timeout'),
100 264 validator=colander.Range(min=1),
101 265 widget='int')
102 266
103 267 dn_user = colander.SchemaNode(
104 268 colander.String(),
105 269 default='',
106 270 description=_('Optional user DN/account to connect to LDAP if authentication is required. \n'
107 271 'e.g., cn=admin,dc=mydomain,dc=com, or '
108 272 'uid=root,cn=users,dc=mydomain,dc=com, or admin@mydomain.com'),
109 273 missing='',
110 274 preparer=strip_whitespace,
111 275 title=_('Account'),
112 276 widget='string')
113 277 dn_pass = colander.SchemaNode(
114 278 colander.String(),
115 279 default='',
116 280 description=_('Password to authenticate for given user DN.'),
117 281 missing='',
118 282 preparer=strip_whitespace,
119 283 title=_('Password'),
120 284 widget='password')
121 285 tls_kind = colander.SchemaNode(
122 286 colander.String(),
123 287 default=tls_kind_choices[0],
124 288 description=_('TLS Type'),
125 289 title=_('Connection Security'),
126 290 validator=colander.OneOf(tls_kind_choices),
127 291 widget='select')
128 292 tls_reqcert = colander.SchemaNode(
129 293 colander.String(),
130 294 default=tls_reqcert_choices[0],
131 295 description=_('Require Cert over TLS?. Self-signed and custom '
132 296 'certificates can be used when\n `RhodeCode Certificate` '
133 297 'found in admin > settings > system info page is extended.'),
134 298 title=_('Certificate Checks'),
135 299 validator=colander.OneOf(tls_reqcert_choices),
136 300 widget='select')
301 tls_cert_file = colander.SchemaNode(
302 colander.String(),
303 default='',
304 description=_('This specifies the PEM-format file path containing '
305 'certificates for use in TLS connection.\n'
306 'If not specified `TLS Cert dir` will be used'),
307 title=_('TLS Cert file'),
308 missing='',
309 widget='string')
310 tls_cert_dir = colander.SchemaNode(
311 colander.String(),
312 default=AuthLdap.default_tls_cert_dir,
313 description=_('This specifies the path of a directory that contains individual '
314 'CA certificates in separate files.'),
315 title=_('TLS Cert dir'),
316 widget='string')
137 317 base_dn = colander.SchemaNode(
138 318 colander.String(),
139 319 default='',
140 320 description=_('Base DN to search. Dynamic bind is supported. Add `$login` marker '
141 321 'in it to be replaced with current user credentials \n'
142 322 '(e.g., dc=mydomain,dc=com, or ou=Users,dc=mydomain,dc=com)'),
143 323 missing='',
144 324 preparer=strip_whitespace,
145 325 title=_('Base DN'),
146 326 widget='string')
147 327 filter = colander.SchemaNode(
148 328 colander.String(),
149 329 default='',
150 330 description=_('Filter to narrow results \n'
151 331 '(e.g., (&(objectCategory=Person)(objectClass=user)), or \n'
152 332 '(memberof=cn=rc-login,ou=groups,ou=company,dc=mydomain,dc=com)))'),
153 333 missing='',
154 334 preparer=strip_whitespace,
155 335 title=_('LDAP Search Filter'),
156 336 widget='string')
157 337
158 338 search_scope = colander.SchemaNode(
159 339 colander.String(),
160 340 default=search_scope_choices[2],
161 341 description=_('How deep to search LDAP. If unsure set to SUBTREE'),
162 342 title=_('LDAP Search Scope'),
163 343 validator=colander.OneOf(search_scope_choices),
164 344 widget='select')
165 345 attr_login = colander.SchemaNode(
166 346 colander.String(),
167 347 default='uid',
168 348 description=_('LDAP Attribute to map to user name (e.g., uid, or sAMAccountName)'),
169 349 preparer=strip_whitespace,
170 350 title=_('Login Attribute'),
171 351 missing_msg=_('The LDAP Login attribute of the CN must be specified'),
172 352 widget='string')
173 353 attr_firstname = colander.SchemaNode(
174 354 colander.String(),
175 355 default='',
176 356 description=_('LDAP Attribute to map to first name (e.g., givenName)'),
177 357 missing='',
178 358 preparer=strip_whitespace,
179 359 title=_('First Name Attribute'),
180 360 widget='string')
181 361 attr_lastname = colander.SchemaNode(
182 362 colander.String(),
183 363 default='',
184 364 description=_('LDAP Attribute to map to last name (e.g., sn)'),
185 365 missing='',
186 366 preparer=strip_whitespace,
187 367 title=_('Last Name Attribute'),
188 368 widget='string')
189 369 attr_email = colander.SchemaNode(
190 370 colander.String(),
191 371 default='',
192 372 description=_('LDAP Attribute to map to email address (e.g., mail).\n'
193 373 'Emails are a crucial part of RhodeCode. \n'
194 374 'If possible add a valid email attribute to ldap users.'),
195 375 missing='',
196 376 preparer=strip_whitespace,
197 377 title=_('Email Attribute'),
198 378 widget='string')
199 379
200 380
201 class AuthLdap(AuthLdapBase):
202
203 def __init__(self, server, base_dn, port=389, bind_dn='', bind_pass='',
204 tls_kind='PLAIN', tls_reqcert='DEMAND', ldap_version=3,
205 search_scope='SUBTREE', attr_login='uid',
206 ldap_filter='', timeout=None):
207 if ldap == Missing:
208 raise LdapImportError("Missing or incompatible ldap library")
209
210 self.debug = False
211 self.timeout = timeout or 60 * 5
212 self.ldap_version = ldap_version
213 self.ldap_server_type = 'ldap'
214
215 self.TLS_KIND = tls_kind
216
217 if self.TLS_KIND == 'LDAPS':
218 port = port or 689
219 self.ldap_server_type += 's'
220
221 OPT_X_TLS_DEMAND = 2
222 self.TLS_REQCERT = getattr(ldap, 'OPT_X_TLS_%s' % tls_reqcert,
223 OPT_X_TLS_DEMAND)
224 self.LDAP_SERVER = server
225 # split server into list
226 self.SERVER_ADDRESSES = self._get_server_list(server)
227 self.LDAP_SERVER_PORT = port
228
229 # USE FOR READ ONLY BIND TO LDAP SERVER
230 self.attr_login = attr_login
231
232 self.LDAP_BIND_DN = safe_str(bind_dn)
233 self.LDAP_BIND_PASS = safe_str(bind_pass)
234
235 self.SEARCH_SCOPE = getattr(ldap, 'SCOPE_%s' % search_scope)
236 self.BASE_DN = safe_str(base_dn)
237 self.LDAP_FILTER = safe_str(ldap_filter)
238
239 def _get_ldap_conn(self):
240
241 if self.debug:
242 ldap.set_option(ldap.OPT_DEBUG_LEVEL, 255)
243
244 default_cert_path = os.environ.get('SSL_CERT_FILE')
245 default_cert_dir = os.environ.get('SSL_CERT_DIR', '/etc/openldap/cacerts')
246 if default_cert_path and hasattr(ldap, 'OPT_X_TLS_CACERTFILE'):
247 ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, default_cert_path)
248
249 elif hasattr(ldap, 'OPT_X_TLS_CACERTDIR'):
250 ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, default_cert_dir)
251
252 if self.TLS_KIND != 'PLAIN':
253 ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, self.TLS_REQCERT)
254
255 ldap.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF)
256 ldap.set_option(ldap.OPT_RESTART, ldap.OPT_ON)
257
258 # init connection now
259 ldap_servers = self._build_servers(
260 self.ldap_server_type, self.SERVER_ADDRESSES, self.LDAP_SERVER_PORT)
261 log.debug('initializing LDAP connection to:%s', ldap_servers)
262 ldap_conn = ldap.initialize(ldap_servers)
263 ldap_conn.set_option(ldap.OPT_NETWORK_TIMEOUT, self.timeout)
264 ldap_conn.set_option(ldap.OPT_TIMEOUT, self.timeout)
265 ldap_conn.timeout = self.timeout
266
267 if self.ldap_version == 2:
268 ldap_conn.protocol = ldap.VERSION2
269 else:
270 ldap_conn.protocol = ldap.VERSION3
271
272 if self.TLS_KIND == 'START_TLS':
273 ldap_conn.start_tls_s()
274
275 if self.LDAP_BIND_DN and self.LDAP_BIND_PASS:
276 log.debug('Trying simple_bind with password and given login DN: %s',
277 self.LDAP_BIND_DN)
278 ldap_conn.simple_bind_s(self.LDAP_BIND_DN, self.LDAP_BIND_PASS)
279
280 return ldap_conn
281
282 def fetch_attrs_from_simple_bind(self, server, dn, username, password):
283 try:
284 log.debug('Trying simple bind with %s', dn)
285 server.simple_bind_s(dn, safe_str(password))
286 user = server.search_ext_s(
287 dn, ldap.SCOPE_BASE, '(objectClass=*)', )[0]
288 _, attrs = user
289 return attrs
290
291 except ldap.INVALID_CREDENTIALS:
292 log.debug(
293 "LDAP rejected password for user '%s': %s, org_exc:",
294 username, dn, exc_info=True)
295
296 def authenticate_ldap(self, username, password):
297 """
298 Authenticate a user via LDAP and return his/her LDAP properties.
299
300 Raises AuthenticationError if the credentials are rejected, or
301 EnvironmentError if the LDAP server can't be reached.
302
303 :param username: username
304 :param password: password
305 """
306
307 uid = self.get_uid(username, self.SERVER_ADDRESSES)
308 user_attrs = {}
309 dn = ''
310
311 if not password:
312 msg = "Authenticating user %s with blank password not allowed"
313 log.warning(msg, username)
314 raise LdapPasswordError(msg)
315 if "," in username:
316 raise LdapUsernameError(
317 "invalid character `,` in username: `{}`".format(username))
318 ldap_conn = None
319 try:
320 ldap_conn = self._get_ldap_conn()
321 filter_ = '(&%s(%s=%s))' % (
322 self.LDAP_FILTER, self.attr_login, username)
323 log.debug(
324 "Authenticating %r filter %s", self.BASE_DN, filter_)
325 lobjects = ldap_conn.search_ext_s(
326 self.BASE_DN, self.SEARCH_SCOPE, filter_)
327
328 if not lobjects:
329 log.debug("No matching LDAP objects for authentication "
330 "of UID:'%s' username:(%s)", uid, username)
331 raise ldap.NO_SUCH_OBJECT()
332
333 log.debug('Found matching ldap object, trying to authenticate')
334 for (dn, _attrs) in lobjects:
335 if dn is None:
336 continue
337
338 user_attrs = self.fetch_attrs_from_simple_bind(
339 ldap_conn, dn, username, password)
340 if user_attrs:
341 break
342
343 else:
344 raise LdapPasswordError(
345 'Failed to authenticate user `{}`'
346 'with given password'.format(username))
347
348 except ldap.NO_SUCH_OBJECT:
349 log.debug("LDAP says no such user '%s' (%s), org_exc:",
350 uid, username, exc_info=True)
351 raise LdapUsernameError('Unable to find user')
352 except ldap.SERVER_DOWN:
353 org_exc = traceback.format_exc()
354 raise LdapConnectionError(
355 "LDAP can't access authentication "
356 "server, org_exc:%s" % org_exc)
357 finally:
358 if ldap_conn:
359 log.debug('ldap: connection release')
360 try:
361 ldap_conn.unbind_s()
362 except Exception:
363 # for any reason this can raise exception we must catch it
364 # to not crush the server
365 pass
366
367 return dn, user_attrs
368
369
370 381 class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin):
371 382 # used to define dynamic binding in the
372 383 DYNAMIC_BIND_VAR = '$login'
373 384 _settings_unsafe_keys = ['dn_pass']
374 385
375 386 def includeme(self, config):
376 387 config.add_authn_plugin(self)
377 388 config.add_authn_resource(self.get_id(), LdapAuthnResource(self))
378 389 config.add_view(
379 390 'rhodecode.authentication.views.AuthnPluginViewBase',
380 391 attr='settings_get',
381 392 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
382 393 request_method='GET',
383 394 route_name='auth_home',
384 395 context=LdapAuthnResource)
385 396 config.add_view(
386 397 'rhodecode.authentication.views.AuthnPluginViewBase',
387 398 attr='settings_post',
388 399 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
389 400 request_method='POST',
390 401 route_name='auth_home',
391 402 context=LdapAuthnResource)
392 403
393 404 def get_settings_schema(self):
394 405 return LdapSettingsSchema()
395 406
396 407 def get_display_name(self):
397 408 return _('LDAP')
398 409
399 410 @classmethod
400 411 def docs(cls):
401 412 return "https://docs.rhodecode.com/RhodeCode-Enterprise/auth/auth-ldap.html"
402 413
403 414 @hybrid_property
404 415 def name(self):
405 416 return "ldap"
406 417
407 418 def use_fake_password(self):
408 419 return True
409 420
410 421 def user_activation_state(self):
411 422 def_user_perms = User.get_default_user().AuthUser().permissions['global']
412 423 return 'hg.extern_activate.auto' in def_user_perms
413 424
414 425 def try_dynamic_binding(self, username, password, current_args):
415 426 """
416 427 Detects marker inside our original bind, and uses dynamic auth if
417 428 present
418 429 """
419 430
420 431 org_bind = current_args['bind_dn']
421 432 passwd = current_args['bind_pass']
422 433
423 434 def has_bind_marker(username):
424 435 if self.DYNAMIC_BIND_VAR in username:
425 436 return True
426 437
427 438 # we only passed in user with "special" variable
428 439 if org_bind and has_bind_marker(org_bind) and not passwd:
429 440 log.debug('Using dynamic user/password binding for ldap '
430 441 'authentication. Replacing `%s` with username',
431 442 self.DYNAMIC_BIND_VAR)
432 443 current_args['bind_dn'] = org_bind.replace(
433 444 self.DYNAMIC_BIND_VAR, username)
434 445 current_args['bind_pass'] = password
435 446
436 447 return current_args
437 448
438 449 def auth(self, userobj, username, password, settings, **kwargs):
439 450 """
440 451 Given a user object (which may be null), username, a plaintext password,
441 452 and a settings object (containing all the keys needed as listed in
442 453 settings()), authenticate this user's login attempt.
443 454
444 455 Return None on failure. On success, return a dictionary of the form:
445 456
446 457 see: RhodeCodeAuthPluginBase.auth_func_attrs
447 458 This is later validated for correctness
448 459 """
449 460
450 461 if not username or not password:
451 462 log.debug('Empty username or password skipping...')
452 463 return None
453 464
454 465 ldap_args = {
455 466 'server': settings.get('host', ''),
456 467 'base_dn': settings.get('base_dn', ''),
457 468 'port': settings.get('port'),
458 469 'bind_dn': settings.get('dn_user'),
459 470 'bind_pass': settings.get('dn_pass'),
460 471 'tls_kind': settings.get('tls_kind'),
461 472 'tls_reqcert': settings.get('tls_reqcert'),
473 'tls_cert_file': settings.get('tls_cert_file'),
474 'tls_cert_dir': settings.get('tls_cert_dir'),
462 475 'search_scope': settings.get('search_scope'),
463 476 'attr_login': settings.get('attr_login'),
464 477 'ldap_version': 3,
465 478 'ldap_filter': settings.get('filter'),
466 479 'timeout': settings.get('timeout')
467 480 }
468 481
469 482 ldap_attrs = self.try_dynamic_binding(username, password, ldap_args)
470 483
471 484 log.debug('Checking for ldap authentication.')
472 485
473 486 try:
474 487 aldap = AuthLdap(**ldap_args)
475 488 (user_dn, ldap_attrs) = aldap.authenticate_ldap(username, password)
476 489 log.debug('Got ldap DN response %s', user_dn)
477 490
478 491 def get_ldap_attr(k):
479 492 return ldap_attrs.get(settings.get(k), [''])[0]
480 493
481 494 # old attrs fetched from RhodeCode database
482 495 admin = getattr(userobj, 'admin', False)
483 496 active = getattr(userobj, 'active', True)
484 497 email = getattr(userobj, 'email', '')
485 498 username = getattr(userobj, 'username', username)
486 499 firstname = getattr(userobj, 'firstname', '')
487 500 lastname = getattr(userobj, 'lastname', '')
488 501 extern_type = getattr(userobj, 'extern_type', '')
489 502
490 503 groups = []
491 504 user_attrs = {
492 505 'username': username,
493 'firstname': safe_unicode(
494 get_ldap_attr('attr_firstname') or firstname),
495 'lastname': safe_unicode(
496 get_ldap_attr('attr_lastname') or lastname),
506 'firstname': safe_unicode(get_ldap_attr('attr_firstname') or firstname),
507 'lastname': safe_unicode(get_ldap_attr('attr_lastname') or lastname),
497 508 'groups': groups,
498 509 'user_group_sync': False,
499 510 'email': get_ldap_attr('attr_email') or email,
500 511 'admin': admin,
501 512 'active': active,
502 513 'active_from_extern': None,
503 514 'extern_name': user_dn,
504 515 'extern_type': extern_type,
505 516 }
517
506 518 log.debug('ldap user: %s', user_attrs)
507 519 log.info('user `%s` authenticated correctly', user_attrs['username'])
508 520
509 521 return user_attrs
510 522
511 523 except (LdapUsernameError, LdapPasswordError, LdapImportError):
512 524 log.exception("LDAP related exception")
513 525 return None
514 526 except (Exception,):
515 527 log.exception("Other exception")
516 528 return None
517
General Comments 0
You need to be logged in to leave comments. Login now