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