##// END OF EJS Templates
authentication: run modernize for python3
super-admin -
r5094:71df309f default
parent child Browse files
Show More
@@ -1,99 +1,97 b''
1
2
3 1 # Copyright (C) 2012-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 import logging
22 20 import importlib
23 21
24 22 from pyramid.authentication import SessionAuthenticationHelper
25 23
26 24 from rhodecode.authentication.registry import AuthenticationPluginRegistry
27 25 from rhodecode.authentication.routes import root_factory
28 26 from rhodecode.authentication.routes import AuthnRootResource
29 27 from rhodecode.apps._base import ADMIN_PREFIX
30 28 from rhodecode.model.settings import SettingsModel
31 29
32 30 log = logging.getLogger(__name__)
33 31
34 32 legacy_plugin_prefix = 'py:'
35 33 plugin_default_auth_ttl = 30
36 34
37 35
38 36 def _import_legacy_plugin(plugin_id):
39 37 module_name = plugin_id.split(legacy_plugin_prefix, 1)[-1]
40 38 module = importlib.import_module(module_name)
41 39 return module.plugin_factory(plugin_id=plugin_id)
42 40
43 41
44 42 def discover_legacy_plugins(config, prefix=legacy_plugin_prefix):
45 43 """
46 44 Function that imports the legacy plugins stored in the 'auth_plugins'
47 45 setting in database which are using the specified prefix. Normally 'py:' is
48 46 used for the legacy plugins.
49 47 """
50 48
51 49 log.debug('authentication: running legacy plugin discovery for prefix %s',
52 50 legacy_plugin_prefix)
53 51 try:
54 52 auth_plugins = SettingsModel().get_setting_by_name('auth_plugins')
55 53 enabled_plugins = auth_plugins.app_settings_value
56 54 legacy_plugins = [id_ for id_ in enabled_plugins if id_.startswith(prefix)]
57 55 except Exception:
58 56 legacy_plugins = []
59 57
60 58 for plugin_id in legacy_plugins:
61 59 log.debug('Legacy plugin discovered: "%s"', plugin_id)
62 60 try:
63 61 plugin = _import_legacy_plugin(plugin_id)
64 62 config.include(plugin.includeme)
65 63 except Exception as e:
66 64 log.exception(
67 65 'Exception while loading legacy authentication plugin '
68 66 '"%s": %s', plugin_id, e)
69 67
70 68
71 69 def includeme(config):
72 70
73 71 config.set_security_policy(SessionAuthenticationHelper())
74 72
75 73 # Create authentication plugin registry and add it to the pyramid registry.
76 74 authn_registry = AuthenticationPluginRegistry(config.get_settings())
77 75 config.add_directive('add_authn_plugin', authn_registry.add_authn_plugin)
78 76 config.registry.registerUtility(authn_registry)
79 77
80 78 # Create authentication traversal root resource.
81 79 authn_root_resource = root_factory()
82 80 config.add_directive('add_authn_resource',
83 81 authn_root_resource.add_authn_resource)
84 82
85 83 # Add the authentication traversal route.
86 84 config.add_route('auth_home',
87 85 ADMIN_PREFIX + '/auth*traverse',
88 86 factory=root_factory)
89 87 # Add the authentication settings root views.
90 88 config.add_view('rhodecode.authentication.views.AuthSettingsView',
91 89 attr='index',
92 90 request_method='GET',
93 91 route_name='auth_home',
94 92 context=AuthnRootResource)
95 93 config.add_view('rhodecode.authentication.views.AuthSettingsView',
96 94 attr='auth_settings',
97 95 request_method='POST',
98 96 route_name='auth_home',
99 97 context=AuthnRootResource)
@@ -1,826 +1,826 b''
1 1
2 2 # Copyright (C) 2010-2023 RhodeCode GmbH
3 3 #
4 4 # This program is free software: you can redistribute it and/or modify
5 5 # it under the terms of the GNU Affero General Public License, version 3
6 6 # (only), as published by the Free Software Foundation.
7 7 #
8 8 # This program is distributed in the hope that it will be useful,
9 9 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 11 # GNU General Public License for more details.
12 12 #
13 13 # You should have received a copy of the GNU Affero General Public License
14 14 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 15 #
16 16 # This program is dual-licensed. If you wish to learn more about the
17 17 # RhodeCode Enterprise Edition, including its added features, Support services,
18 18 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 19
20 20 """
21 21 Authentication modules
22 22 """
23 23 import socket
24 24 import string
25 25 import colander
26 26 import copy
27 27 import logging
28 28 import time
29 29 import traceback
30 30 import warnings
31 31 import functools
32 32
33 33 from pyramid.threadlocal import get_current_registry
34 34
35 35 from rhodecode.authentication import AuthenticationPluginRegistry
36 36 from rhodecode.authentication.interface import IAuthnPluginRegistry
37 37 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
38 38 from rhodecode.lib import rc_cache
39 39 from rhodecode.lib.statsd_client import StatsdClient
40 40 from rhodecode.lib.auth import PasswordGenerator, _RhodeCodeCryptoBCrypt
41 41 from rhodecode.lib.str_utils import safe_bytes
42 42 from rhodecode.lib.utils2 import safe_int, safe_str
43 43 from rhodecode.lib.exceptions import (LdapConnectionError, LdapUsernameError, LdapPasswordError)
44 44 from rhodecode.model.db import User
45 45 from rhodecode.model.meta import Session
46 46 from rhodecode.model.settings import SettingsModel
47 47 from rhodecode.model.user import UserModel
48 48 from rhodecode.model.user_group import UserGroupModel
49 49
50 50
51 51 log = logging.getLogger(__name__)
52 52
53 53 # auth types that authenticate() function can receive
54 54 VCS_TYPE = 'vcs'
55 55 HTTP_TYPE = 'http'
56 56
57 57 external_auth_session_key = 'rhodecode.external_auth'
58 58
59 59
60 60 class hybrid_property(object):
61 61 """
62 62 a property decorator that works both for instance and class
63 63 """
64 64 def __init__(self, fget, fset=None, fdel=None, expr=None):
65 65 self.fget = fget
66 66 self.fset = fset
67 67 self.fdel = fdel
68 68 self.expr = expr or fget
69 69 functools.update_wrapper(self, fget)
70 70
71 71 def __get__(self, instance, owner):
72 72 if instance is None:
73 73 return self.expr(owner)
74 74 else:
75 75 return self.fget(instance)
76 76
77 77 def __set__(self, instance, value):
78 78 self.fset(instance, value)
79 79
80 80 def __delete__(self, instance):
81 81 self.fdel(instance)
82 82
83 83
84 84 class LazyFormencode(object):
85 85 def __init__(self, formencode_obj, *args, **kwargs):
86 86 self.formencode_obj = formencode_obj
87 87 self.args = args
88 88 self.kwargs = kwargs
89 89
90 90 def __call__(self, *args, **kwargs):
91 91 from inspect import isfunction
92 92 formencode_obj = self.formencode_obj
93 93 if isfunction(formencode_obj):
94 94 # case we wrap validators into functions
95 95 formencode_obj = self.formencode_obj(*args, **kwargs)
96 96 return formencode_obj(*self.args, **self.kwargs)
97 97
98 98
99 99 class RhodeCodeAuthPluginBase(object):
100 100 # UID is used to register plugin to the registry
101 101 uid = None
102 102
103 103 # cache the authentication request for N amount of seconds. Some kind
104 104 # of authentication methods are very heavy and it's very efficient to cache
105 105 # the result of a call. If it's set to None (default) cache is off
106 106 AUTH_CACHE_TTL = None
107 107 AUTH_CACHE = {}
108 108
109 109 auth_func_attrs = {
110 110 "username": "unique username",
111 111 "firstname": "first name",
112 112 "lastname": "last name",
113 113 "email": "email address",
114 114 "groups": '["list", "of", "groups"]',
115 115 "user_group_sync":
116 116 'True|False defines if returned user groups should be synced',
117 117 "extern_name": "name in external source of record",
118 118 "extern_type": "type of external source of record",
119 119 "admin": 'True|False defines if user should be RhodeCode super admin',
120 120 "active":
121 121 'True|False defines active state of user internally for RhodeCode',
122 122 "active_from_extern":
123 123 "True|False|None, active state from the external auth, "
124 124 "None means use definition from RhodeCode extern_type active value"
125 125
126 126 }
127 127 # set on authenticate() method and via set_auth_type func.
128 128 auth_type = None
129 129
130 130 # set on authenticate() method and via set_calling_scope_repo, this is a
131 131 # calling scope repository when doing authentication most likely on VCS
132 132 # operations
133 133 acl_repo_name = None
134 134
135 135 # List of setting names to store encrypted. Plugins may override this list
136 136 # to store settings encrypted.
137 137 _settings_encrypted = []
138 138
139 139 # Mapping of python to DB settings model types. Plugins may override or
140 140 # extend this mapping.
141 141 _settings_type_map = {
142 142 colander.String: 'unicode',
143 143 colander.Integer: 'int',
144 144 colander.Boolean: 'bool',
145 145 colander.List: 'list',
146 146 }
147 147
148 148 # list of keys in settings that are unsafe to be logged, should be passwords
149 149 # or other crucial credentials
150 150 _settings_unsafe_keys = []
151 151
152 152 def __init__(self, plugin_id):
153 153 self._plugin_id = plugin_id
154 154
155 155 def __str__(self):
156 156 return self.get_id()
157 157
158 158 def _get_setting_full_name(self, name):
159 159 """
160 160 Return the full setting name used for storing values in the database.
161 161 """
162 162 # TODO: johbo: Using the name here is problematic. It would be good to
163 163 # introduce either new models in the database to hold Plugin and
164 164 # PluginSetting or to use the plugin id here.
165 return 'auth_{}_{}'.format(self.name, name)
165 return f'auth_{self.name}_{name}'
166 166
167 167 def _get_setting_type(self, name):
168 168 """
169 169 Return the type of a setting. This type is defined by the SettingsModel
170 170 and determines how the setting is stored in DB. Optionally the suffix
171 171 `.encrypted` is appended to instruct SettingsModel to store it
172 172 encrypted.
173 173 """
174 174 schema_node = self.get_settings_schema().get(name)
175 175 db_type = self._settings_type_map.get(
176 176 type(schema_node.typ), 'unicode')
177 177 if name in self._settings_encrypted:
178 db_type = '{}.encrypted'.format(db_type)
178 db_type = f'{db_type}.encrypted'
179 179 return db_type
180 180
181 181 @classmethod
182 182 def docs(cls):
183 183 """
184 184 Defines documentation url which helps with plugin setup
185 185 """
186 186 return ''
187 187
188 188 @classmethod
189 189 def icon(cls):
190 190 """
191 191 Defines ICON in SVG format for authentication method
192 192 """
193 193 return ''
194 194
195 195 def is_enabled(self):
196 196 """
197 197 Returns true if this plugin is enabled. An enabled plugin can be
198 198 configured in the admin interface but it is not consulted during
199 199 authentication.
200 200 """
201 201 auth_plugins = SettingsModel().get_auth_plugins()
202 202 return self.get_id() in auth_plugins
203 203
204 204 def is_active(self, plugin_cached_settings=None):
205 205 """
206 206 Returns true if the plugin is activated. An activated plugin is
207 207 consulted during authentication, assumed it is also enabled.
208 208 """
209 209 return self.get_setting_by_name(
210 210 'enabled', plugin_cached_settings=plugin_cached_settings)
211 211
212 212 def get_id(self):
213 213 """
214 214 Returns the plugin id.
215 215 """
216 216 return self._plugin_id
217 217
218 218 def get_display_name(self, load_from_settings=False):
219 219 """
220 220 Returns a translation string for displaying purposes.
221 221 if load_from_settings is set, plugin settings can override the display name
222 222 """
223 223 raise NotImplementedError('Not implemented in base class')
224 224
225 225 def get_settings_schema(self):
226 226 """
227 227 Returns a colander schema, representing the plugin settings.
228 228 """
229 229 return AuthnPluginSettingsSchemaBase()
230 230
231 231 def _propagate_settings(self, raw_settings):
232 232 settings = {}
233 233 for node in self.get_settings_schema():
234 234 settings[node.name] = self.get_setting_by_name(
235 235 node.name, plugin_cached_settings=raw_settings)
236 236 return settings
237 237
238 238 def get_settings(self, use_cache=True):
239 239 """
240 240 Returns the plugin settings as dictionary.
241 241 """
242 242
243 243 raw_settings = SettingsModel().get_all_settings(cache=use_cache)
244 244 settings = self._propagate_settings(raw_settings)
245 245
246 246 return settings
247 247
248 248 def get_setting_by_name(self, name, default=None, plugin_cached_settings=None):
249 249 """
250 250 Returns a plugin setting by name.
251 251 """
252 full_name = 'rhodecode_{}'.format(self._get_setting_full_name(name))
252 full_name = f'rhodecode_{self._get_setting_full_name(name)}'
253 253 if plugin_cached_settings:
254 254 plugin_settings = plugin_cached_settings
255 255 else:
256 256 plugin_settings = SettingsModel().get_all_settings()
257 257
258 258 if full_name in plugin_settings:
259 259 return plugin_settings[full_name]
260 260 else:
261 261 return default
262 262
263 263 def create_or_update_setting(self, name, value):
264 264 """
265 265 Create or update a setting for this plugin in the persistent storage.
266 266 """
267 267 full_name = self._get_setting_full_name(name)
268 268 type_ = self._get_setting_type(name)
269 269 db_setting = SettingsModel().create_or_update_setting(
270 270 full_name, value, type_)
271 271 return db_setting.app_settings_value
272 272
273 273 def log_safe_settings(self, settings):
274 274 """
275 275 returns a log safe representation of settings, without any secrets
276 276 """
277 277 settings_copy = copy.deepcopy(settings)
278 278 for k in self._settings_unsafe_keys:
279 279 if k in settings_copy:
280 280 del settings_copy[k]
281 281 return settings_copy
282 282
283 283 @hybrid_property
284 284 def name(self):
285 285 """
286 286 Returns the name of this authentication plugin.
287 287
288 288 :returns: string
289 289 """
290 290 raise NotImplementedError("Not implemented in base class")
291 291
292 292 def get_url_slug(self):
293 293 """
294 294 Returns a slug which should be used when constructing URLs which refer
295 295 to this plugin. By default it returns the plugin name. If the name is
296 296 not suitable for using it in an URL the plugin should override this
297 297 method.
298 298 """
299 299 return self.name
300 300
301 301 @property
302 302 def is_headers_auth(self):
303 303 """
304 304 Returns True if this authentication plugin uses HTTP headers as
305 305 authentication method.
306 306 """
307 307 return False
308 308
309 309 @hybrid_property
310 310 def is_container_auth(self):
311 311 """
312 312 Deprecated method that indicates if this authentication plugin uses
313 313 HTTP headers as authentication method.
314 314 """
315 315 warnings.warn(
316 316 'Use is_headers_auth instead.', category=DeprecationWarning)
317 317 return self.is_headers_auth
318 318
319 319 @hybrid_property
320 320 def allows_creating_users(self):
321 321 """
322 322 Defines if Plugin allows users to be created on-the-fly when
323 323 authentication is called. Controls how external plugins should behave
324 324 in terms if they are allowed to create new users, or not. Base plugins
325 325 should not be allowed to, but External ones should be !
326 326
327 327 :return: bool
328 328 """
329 329 return False
330 330
331 331 def set_auth_type(self, auth_type):
332 332 self.auth_type = auth_type
333 333
334 334 def set_calling_scope_repo(self, acl_repo_name):
335 335 self.acl_repo_name = acl_repo_name
336 336
337 337 def allows_authentication_from(
338 338 self, user, allows_non_existing_user=True,
339 339 allowed_auth_plugins=None, allowed_auth_sources=None):
340 340 """
341 341 Checks if this authentication module should accept a request for
342 342 the current user.
343 343
344 344 :param user: user object fetched using plugin's get_user() method.
345 345 :param allows_non_existing_user: if True, don't allow the
346 346 user to be empty, meaning not existing in our database
347 347 :param allowed_auth_plugins: if provided, users extern_type will be
348 348 checked against a list of provided extern types, which are plugin
349 349 auth_names in the end
350 350 :param allowed_auth_sources: authentication type allowed,
351 351 `http` or `vcs` default is both.
352 352 defines if plugin will accept only http authentication vcs
353 353 authentication(git/hg) or both
354 354 :returns: boolean
355 355 """
356 356 if not user and not allows_non_existing_user:
357 357 log.debug('User is empty but plugin does not allow empty users,'
358 358 'not allowed to authenticate')
359 359 return False
360 360
361 361 expected_auth_plugins = allowed_auth_plugins or [self.name]
362 362 if user and (user.extern_type and
363 363 user.extern_type not in expected_auth_plugins):
364 364 log.debug(
365 365 'User `%s` is bound to `%s` auth type. Plugin allows only '
366 366 '%s, skipping', user, user.extern_type, expected_auth_plugins)
367 367
368 368 return False
369 369
370 370 # by default accept both
371 371 expected_auth_from = allowed_auth_sources or [HTTP_TYPE, VCS_TYPE]
372 372 if self.auth_type not in expected_auth_from:
373 373 log.debug('Current auth source is %s but plugin only allows %s',
374 374 self.auth_type, expected_auth_from)
375 375 return False
376 376
377 377 return True
378 378
379 379 def get_user(self, username=None, **kwargs):
380 380 """
381 381 Helper method for user fetching in plugins, by default it's using
382 382 simple fetch by username, but this method can be customized in plugins
383 383 eg. headers auth plugin to fetch user by environ params
384 384
385 385 :param username: username if given to fetch from database
386 386 :param kwargs: extra arguments needed for user fetching.
387 387 """
388 388
389 389 user = None
390 390 log.debug(
391 391 'Trying to fetch user `%s` from RhodeCode database', username)
392 392 if username:
393 393 user = User.get_by_username(username)
394 394 if not user:
395 395 log.debug('User not found, fallback to fetch user in '
396 396 'case insensitive mode')
397 397 user = User.get_by_username(username, case_insensitive=True)
398 398 else:
399 399 log.debug('provided username:`%s` is empty skipping...', username)
400 400 if not user:
401 401 log.debug('User `%s` not found in database', username)
402 402 else:
403 403 log.debug('Got DB user:%s', user)
404 404 return user
405 405
406 406 def user_activation_state(self):
407 407 """
408 408 Defines user activation state when creating new users
409 409
410 410 :returns: boolean
411 411 """
412 412 raise NotImplementedError("Not implemented in base class")
413 413
414 414 def auth(self, userobj, username, passwd, settings, **kwargs):
415 415 """
416 416 Given a user object (which may be null), username, a plaintext
417 417 password, and a settings object (containing all the keys needed as
418 418 listed in settings()), authenticate this user's login attempt.
419 419
420 420 Return None on failure. On success, return a dictionary of the form:
421 421
422 422 see: RhodeCodeAuthPluginBase.auth_func_attrs
423 423 This is later validated for correctness
424 424 """
425 425 raise NotImplementedError("not implemented in base class")
426 426
427 427 def _authenticate(self, userobj, username, passwd, settings, **kwargs):
428 428 """
429 429 Wrapper to call self.auth() that validates call on it
430 430
431 431 :param userobj: userobj
432 432 :param username: username
433 433 :param passwd: plaintext password
434 434 :param settings: plugin settings
435 435 """
436 436 auth = self.auth(userobj, username, passwd, settings, **kwargs)
437 437 if auth:
438 438 auth['_plugin'] = self.name
439 439 auth['_ttl_cache'] = self.get_ttl_cache(settings)
440 440 # check if hash should be migrated ?
441 441 new_hash = auth.get('_hash_migrate')
442 442 if new_hash:
443 443 # new_hash is a newly encrypted destination hash
444 444 self._migrate_hash_to_bcrypt(username, passwd, new_hash)
445 445 if 'user_group_sync' not in auth:
446 446 auth['user_group_sync'] = False
447 447 return self._validate_auth_return(auth)
448 448 return auth
449 449
450 450 def _migrate_hash_to_bcrypt(self, username, password, new_hash):
451 451 new_hash_cypher = _RhodeCodeCryptoBCrypt()
452 452 # extra checks, so make sure new hash is correct.
453 453 password_as_bytes = safe_bytes(password)
454 454
455 455 if new_hash and new_hash_cypher.hash_check(password_as_bytes, new_hash):
456 456 cur_user = User.get_by_username(username)
457 457 cur_user.password = new_hash
458 458 Session().add(cur_user)
459 459 Session().flush()
460 460 log.info('Migrated user %s hash to bcrypt', cur_user)
461 461
462 462 def _validate_auth_return(self, ret):
463 463 if not isinstance(ret, dict):
464 464 raise Exception('returned value from auth must be a dict')
465 465 for k in self.auth_func_attrs:
466 466 if k not in ret:
467 467 raise Exception('Missing %s attribute from returned data' % k)
468 468 return ret
469 469
470 470 def get_ttl_cache(self, settings=None):
471 471 plugin_settings = settings or self.get_settings()
472 472 # we set default to 30, we make a compromise here,
473 473 # performance > security, mostly due to LDAP/SVN, majority
474 474 # of users pick cache_ttl to be enabled
475 475 from rhodecode.authentication import plugin_default_auth_ttl
476 476 cache_ttl = plugin_default_auth_ttl
477 477
478 478 if isinstance(self.AUTH_CACHE_TTL, int):
479 479 # plugin cache set inside is more important than the settings value
480 480 cache_ttl = self.AUTH_CACHE_TTL
481 481 elif plugin_settings.get('cache_ttl'):
482 482 cache_ttl = safe_int(plugin_settings.get('cache_ttl'), 0)
483 483
484 484 plugin_cache_active = bool(cache_ttl and cache_ttl > 0)
485 485 return plugin_cache_active, cache_ttl
486 486
487 487
488 488 class RhodeCodeExternalAuthPlugin(RhodeCodeAuthPluginBase):
489 489
490 490 @hybrid_property
491 491 def allows_creating_users(self):
492 492 return True
493 493
494 494 def use_fake_password(self):
495 495 """
496 496 Return a boolean that indicates whether or not we should set the user's
497 497 password to a random value when it is authenticated by this plugin.
498 498 If your plugin provides authentication, then you will generally
499 499 want this.
500 500
501 501 :returns: boolean
502 502 """
503 503 raise NotImplementedError("Not implemented in base class")
504 504
505 505 def _authenticate(self, userobj, username, passwd, settings, **kwargs):
506 506 # at this point _authenticate calls plugin's `auth()` function
507 auth = super(RhodeCodeExternalAuthPlugin, self)._authenticate(
507 auth = super()._authenticate(
508 508 userobj, username, passwd, settings, **kwargs)
509 509
510 510 if auth:
511 511 # maybe plugin will clean the username ?
512 512 # we should use the return value
513 513 username = auth['username']
514 514
515 515 # if external source tells us that user is not active, we should
516 516 # skip rest of the process. This can prevent from creating users in
517 517 # RhodeCode when using external authentication, but if it's
518 518 # inactive user we shouldn't create that user anyway
519 519 if auth['active_from_extern'] is False:
520 520 log.warning(
521 521 "User %s authenticated against %s, but is inactive",
522 522 username, self.__module__)
523 523 return None
524 524
525 525 cur_user = User.get_by_username(username, case_insensitive=True)
526 526 is_user_existing = cur_user is not None
527 527
528 528 if is_user_existing:
529 529 log.debug('Syncing user `%s` from '
530 530 '`%s` plugin', username, self.name)
531 531 else:
532 532 log.debug('Creating non existing user `%s` from '
533 533 '`%s` plugin', username, self.name)
534 534
535 535 if self.allows_creating_users:
536 536 log.debug('Plugin `%s` allows to '
537 537 'create new users', self.name)
538 538 else:
539 539 log.debug('Plugin `%s` does not allow to '
540 540 'create new users', self.name)
541 541
542 542 user_parameters = {
543 543 'username': username,
544 544 'email': auth["email"],
545 545 'firstname': auth["firstname"],
546 546 'lastname': auth["lastname"],
547 547 'active': auth["active"],
548 548 'admin': auth["admin"],
549 549 'extern_name': auth["extern_name"],
550 550 'extern_type': self.name,
551 551 'plugin': self,
552 552 'allow_to_create_user': self.allows_creating_users,
553 553 }
554 554
555 555 if not is_user_existing:
556 556 if self.use_fake_password():
557 557 # Randomize the PW because we don't need it, but don't want
558 558 # them blank either
559 559 passwd = PasswordGenerator().gen_password(length=16)
560 560 user_parameters['password'] = passwd
561 561 else:
562 562 # Since the password is required by create_or_update method of
563 563 # UserModel, we need to set it explicitly.
564 564 # The create_or_update method is smart and recognises the
565 565 # password hashes as well.
566 566 user_parameters['password'] = cur_user.password
567 567
568 568 # we either create or update users, we also pass the flag
569 569 # that controls if this method can actually do that.
570 570 # raises NotAllowedToCreateUserError if it cannot, and we try to.
571 571 user = UserModel().create_or_update(**user_parameters)
572 572 Session().flush()
573 573 # enforce user is just in given groups, all of them has to be ones
574 574 # created from plugins. We store this info in _group_data JSON
575 575 # field
576 576
577 577 if auth['user_group_sync']:
578 578 try:
579 579 groups = auth['groups'] or []
580 580 log.debug(
581 581 'Performing user_group sync based on set `%s` '
582 582 'returned by `%s` plugin', groups, self.name)
583 583 UserGroupModel().enforce_groups(user, groups, self.name)
584 584 except Exception:
585 585 # for any reason group syncing fails, we should
586 586 # proceed with login
587 587 log.error(traceback.format_exc())
588 588
589 589 Session().commit()
590 590 return auth
591 591
592 592
593 593 class AuthLdapBase(object):
594 594
595 595 @classmethod
596 596 def _build_servers(cls, ldap_server_type, ldap_server, port, use_resolver=True):
597 597
598 598 def host_resolver(host, port, full_resolve=True):
599 599 """
600 600 Main work for this function is to prevent ldap connection issues,
601 601 and detect them early using a "greenified" sockets
602 602 """
603 603 host = host.strip()
604 604 if not full_resolve:
605 return '{}:{}'.format(host, port)
605 return f'{host}:{port}'
606 606
607 607 log.debug('LDAP: Resolving IP for LDAP host `%s`', host)
608 608 try:
609 609 ip = socket.gethostbyname(host)
610 610 log.debug('LDAP: Got LDAP host `%s` ip %s', host, ip)
611 611 except Exception:
612 raise LdapConnectionError('Failed to resolve host: `{}`'.format(host))
612 raise LdapConnectionError(f'Failed to resolve host: `{host}`')
613 613
614 614 log.debug('LDAP: Checking if IP %s is accessible', ip)
615 615 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
616 616 try:
617 617 s.connect((ip, int(port)))
618 618 s.shutdown(socket.SHUT_RD)
619 619 log.debug('LDAP: connection to %s successful', ip)
620 620 except Exception:
621 621 raise LdapConnectionError(
622 'Failed to connect to host: `{}:{}`'.format(host, port))
622 f'Failed to connect to host: `{host}:{port}`')
623 623
624 return '{}:{}'.format(host, port)
624 return f'{host}:{port}'
625 625
626 626 if len(ldap_server) == 1:
627 627 # in case of single server use resolver to detect potential
628 628 # connection issues
629 629 full_resolve = True
630 630 else:
631 631 full_resolve = False
632 632
633 633 return ', '.join(
634 634 ["{}://{}".format(
635 635 ldap_server_type,
636 636 host_resolver(host, port, full_resolve=use_resolver and full_resolve))
637 637 for host in ldap_server])
638 638
639 639 @classmethod
640 640 def _get_server_list(cls, servers):
641 641 return map(string.strip, servers.split(','))
642 642
643 643 @classmethod
644 644 def get_uid(cls, username, server_addresses):
645 645 uid = username
646 646 for server_addr in server_addresses:
647 647 uid = chop_at(username, "@%s" % server_addr)
648 648 return uid
649 649
650 650 @classmethod
651 651 def validate_username(cls, username):
652 652 if "," in username:
653 653 raise LdapUsernameError(
654 "invalid character `,` in username: `{}`".format(username))
654 f"invalid character `,` in username: `{username}`")
655 655
656 656 @classmethod
657 657 def validate_password(cls, username, password):
658 658 if not password:
659 659 msg = "Authenticating user %s with blank password not allowed"
660 660 log.warning(msg, username)
661 661 raise LdapPasswordError(msg)
662 662
663 663
664 664 def loadplugin(plugin_id):
665 665 """
666 666 Loads and returns an instantiated authentication plugin.
667 667 Returns the RhodeCodeAuthPluginBase subclass on success,
668 668 or None on failure.
669 669 """
670 670 # TODO: Disusing pyramids thread locals to retrieve the registry.
671 671 authn_registry = get_authn_registry()
672 672 plugin = authn_registry.get_plugin(plugin_id)
673 673 if plugin is None:
674 674 log.error('Authentication plugin not found: "%s"', plugin_id)
675 675 return plugin
676 676
677 677
678 678 def get_authn_registry(registry=None) -> AuthenticationPluginRegistry:
679 679 registry = registry or get_current_registry()
680 680 authn_registry = registry.queryUtility(IAuthnPluginRegistry)
681 681 return authn_registry
682 682
683 683
684 684 def authenticate(username, password, environ=None, auth_type=None,
685 685 skip_missing=False, registry=None, acl_repo_name=None):
686 686 """
687 687 Authentication function used for access control,
688 688 It tries to authenticate based on enabled authentication modules.
689 689
690 690 :param username: username can be empty for headers auth
691 691 :param password: password can be empty for headers auth
692 692 :param environ: environ headers passed for headers auth
693 693 :param auth_type: type of authentication, either `HTTP_TYPE` or `VCS_TYPE`
694 694 :param skip_missing: ignores plugins that are in db but not in environment
695 695 :param registry: pyramid registry
696 696 :param acl_repo_name: name of repo for ACL checks
697 697 :returns: None if auth failed, plugin_user dict if auth is correct
698 698 """
699 699 if not auth_type or auth_type not in [HTTP_TYPE, VCS_TYPE]:
700 700 raise ValueError(f'auth type must be on of http, vcs got "{auth_type}" instead')
701 701
702 702 auth_credentials = (username and password)
703 703 headers_only = environ and not auth_credentials
704 704
705 705 authn_registry = get_authn_registry(registry)
706 706
707 707 plugins_to_check = authn_registry.get_plugins_for_authentication()
708 708 log.debug('authentication: headers=%s, username_and_passwd=%s', headers_only, bool(auth_credentials))
709 709 log.debug('Starting ordered authentication chain using %s plugins',
710 710 [x.name for x in plugins_to_check])
711 711
712 712 for plugin in plugins_to_check:
713 713 plugin.set_auth_type(auth_type)
714 714 plugin.set_calling_scope_repo(acl_repo_name)
715 715
716 716 if headers_only and not plugin.is_headers_auth:
717 717 log.debug('Auth type is for headers only and plugin `%s` is not '
718 718 'headers plugin, skipping...', plugin.get_id())
719 719 continue
720 720
721 721 log.debug('Trying authentication using ** %s **', plugin.get_id())
722 722
723 723 # load plugin settings from RhodeCode database
724 724 plugin_settings = plugin.get_settings()
725 725 plugin_sanitized_settings = plugin.log_safe_settings(plugin_settings)
726 726 log.debug('Plugin `%s` settings:%s', plugin.get_id(), plugin_sanitized_settings)
727 727
728 728 # use plugin's method of user extraction.
729 729 user = plugin.get_user(username, environ=environ,
730 730 settings=plugin_settings)
731 731 display_user = user.username if user else username
732 732 log.debug(
733 733 'Plugin %s extracted user is `%s`', plugin.get_id(), display_user)
734 734
735 735 if not plugin.allows_authentication_from(user):
736 736 log.debug('Plugin %s does not accept user `%s` for authentication',
737 737 plugin.get_id(), display_user)
738 738 continue
739 739 else:
740 740 log.debug('Plugin %s accepted user `%s` for authentication',
741 741 plugin.get_id(), display_user)
742 742
743 743 log.info('Authenticating user `%s` using %s plugin',
744 744 display_user, plugin.get_id())
745 745
746 746 plugin_cache_active, cache_ttl = plugin.get_ttl_cache(plugin_settings)
747 747
748 748 log.debug('AUTH_CACHE_TTL for plugin `%s` active: %s (TTL: %s)',
749 749 plugin.get_id(), plugin_cache_active, cache_ttl)
750 750
751 751 user_id = user.user_id if user else 'no-user'
752 752 # don't cache for empty users
753 753 plugin_cache_active = plugin_cache_active and user_id
754 cache_namespace_uid = 'cache_user_auth.{}'.format(user_id)
754 cache_namespace_uid = f'cache_user_auth.{user_id}'
755 755 region = rc_cache.get_or_create_region('cache_perms', cache_namespace_uid)
756 756
757 757 @region.conditional_cache_on_arguments(namespace=cache_namespace_uid,
758 758 expiration_time=cache_ttl,
759 759 condition=plugin_cache_active)
760 760 def compute_auth(
761 761 cache_name, plugin_name, username, password):
762 762
763 763 # _authenticate is a wrapper for .auth() method of plugin.
764 764 # it checks if .auth() sends proper data.
765 765 # For RhodeCodeExternalAuthPlugin it also maps users to
766 766 # Database and maps the attributes returned from .auth()
767 767 # to RhodeCode database. If this function returns data
768 768 # then auth is correct.
769 769 log.debug('Running plugin `%s` _authenticate method '
770 770 'using username and password', plugin.get_id())
771 771 return plugin._authenticate(
772 772 user, username, password, plugin_settings,
773 773 environ=environ or {})
774 774
775 775 start = time.time()
776 776 # for environ based auth, password can be empty, but then the validation is
777 777 # on the server that fills in the env data needed for authentication
778 778 plugin_user = compute_auth('auth', plugin.name, username, (password or ''))
779 779
780 780 auth_time = time.time() - start
781 781 log.debug('Authentication for plugin `%s` completed in %.4fs, '
782 782 'expiration time of fetched cache %.1fs.',
783 783 plugin.get_id(), auth_time, cache_ttl,
784 784 extra={"plugin": plugin.get_id(), "time": auth_time})
785 785
786 786 log.debug('PLUGIN USER DATA: %s', plugin_user)
787 787
788 788 statsd = StatsdClient.statsd
789 789
790 790 if plugin_user:
791 791 log.debug('Plugin returned proper authentication data')
792 792 if statsd:
793 793 elapsed_time_ms = round(1000.0 * auth_time) # use ms only
794 794 statsd.incr('rhodecode_login_success_total')
795 795 statsd.timing("rhodecode_login_timing.histogram", elapsed_time_ms,
796 tags=["plugin:{}".format(plugin.get_id())],
796 tags=[f"plugin:{plugin.get_id()}"],
797 797 use_decimals=False
798 798 )
799 799 return plugin_user
800 800
801 801 # we failed to Auth because .auth() method didn't return proper user
802 802 log.debug("User `%s` failed to authenticate against %s",
803 803 display_user, plugin.get_id())
804 804 if statsd:
805 805 statsd.incr('rhodecode_login_fail_total')
806 806
807 807 # case when we failed to authenticate against all defined plugins
808 808 return None
809 809
810 810
811 811 def chop_at(s, sub, inclusive=False):
812 812 """Truncate string ``s`` at the first occurrence of ``sub``.
813 813
814 814 If ``inclusive`` is true, truncate just after ``sub`` rather than at it.
815 815
816 816 >>> chop_at("plutocratic brats", "rat")
817 817 'plutoc'
818 818 >>> chop_at("plutocratic brats", "rat", True)
819 819 'plutocrat'
820 820 """
821 821 pos = s.find(sub)
822 822 if pos == -1:
823 823 return s
824 824 if inclusive:
825 825 return s[:pos+len(sub)]
826 826 return s[:pos]
@@ -1,29 +1,27 b''
1
2
3 1 # Copyright (C) 2012-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 from zope.interface import Interface
22 20
23 21
24 22 class IAuthnPluginRegistry(Interface):
25 23 """
26 24 Interface for the authentication plugin registry. Currently this is only
27 25 used to register and retrieve it via pyramids registry.
28 26 """
29 27 pass
@@ -1,19 +1,17 b''
1
2
3 1 # Copyright (C) 2012-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
@@ -1,297 +1,295 b''
1
2
3 1 # Copyright (C) 2012-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 """
22 20 RhodeCode authentication plugin for Atlassian CROWD
23 21 """
24 22
25 23
26 24 import colander
27 25 import base64
28 26 import logging
29 27 import urllib.request
30 28 import urllib.error
31 29 import urllib.parse
32 30
33 31 from rhodecode.translation import _
34 32 from rhodecode.authentication.base import (
35 33 RhodeCodeExternalAuthPlugin, hybrid_property)
36 34 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
37 35 from rhodecode.authentication.routes import AuthnPluginResourceBase
38 36 from rhodecode.lib.colander_utils import strip_whitespace
39 37 from rhodecode.lib.ext_json import json, formatted_json
40 38 from rhodecode.model.db import User
41 39
42 40 log = logging.getLogger(__name__)
43 41
44 42
45 43 def plugin_factory(plugin_id, *args, **kwargs):
46 44 """
47 45 Factory function that is called during plugin discovery.
48 46 It returns the plugin instance.
49 47 """
50 48 plugin = RhodeCodeAuthPlugin(plugin_id)
51 49 return plugin
52 50
53 51
54 52 class CrowdAuthnResource(AuthnPluginResourceBase):
55 53 pass
56 54
57 55
58 56 class CrowdSettingsSchema(AuthnPluginSettingsSchemaBase):
59 57 host = colander.SchemaNode(
60 58 colander.String(),
61 59 default='127.0.0.1',
62 60 description=_('The FQDN or IP of the Atlassian CROWD Server'),
63 61 preparer=strip_whitespace,
64 62 title=_('Host'),
65 63 widget='string')
66 64 port = colander.SchemaNode(
67 65 colander.Int(),
68 66 default=8095,
69 67 description=_('The Port in use by the Atlassian CROWD Server'),
70 68 preparer=strip_whitespace,
71 69 title=_('Port'),
72 70 validator=colander.Range(min=0, max=65536),
73 71 widget='int')
74 72 app_name = colander.SchemaNode(
75 73 colander.String(),
76 74 default='',
77 75 description=_('The Application Name to authenticate to CROWD'),
78 76 preparer=strip_whitespace,
79 77 title=_('Application Name'),
80 78 widget='string')
81 79 app_password = colander.SchemaNode(
82 80 colander.String(),
83 81 default='',
84 82 description=_('The password to authenticate to CROWD'),
85 83 preparer=strip_whitespace,
86 84 title=_('Application Password'),
87 85 widget='password')
88 86 admin_groups = colander.SchemaNode(
89 87 colander.String(),
90 88 default='',
91 89 description=_('A comma separated list of group names that identify '
92 90 'users as RhodeCode Administrators'),
93 91 missing='',
94 92 preparer=strip_whitespace,
95 93 title=_('Admin Groups'),
96 94 widget='string')
97 95
98 96
99 97 class CrowdServer(object):
100 98 def __init__(self, *args, **kwargs):
101 99 """
102 100 Create a new CrowdServer object that points to IP/Address 'host',
103 101 on the given port, and using the given method (https/http). user and
104 102 passwd can be set here or with set_credentials. If unspecified,
105 103 "version" defaults to "latest".
106 104
107 105 example::
108 106
109 107 cserver = CrowdServer(host="127.0.0.1",
110 108 port="8095",
111 109 user="some_app",
112 110 passwd="some_passwd",
113 111 version="1")
114 112 """
115 113 if 'port' not in kwargs:
116 114 kwargs["port"] = "8095"
117 115 self._logger = kwargs.get("logger", logging.getLogger(__name__))
118 116 self._uri = "%s://%s:%s/crowd" % (kwargs.get("method", "http"),
119 117 kwargs.get("host", "127.0.0.1"),
120 118 kwargs.get("port", "8095"))
121 119 self.set_credentials(kwargs.get("user", ""),
122 120 kwargs.get("passwd", ""))
123 121 self._version = kwargs.get("version", "latest")
124 122 self._url_list = None
125 123 self._appname = "crowd"
126 124
127 125 def set_credentials(self, user, passwd):
128 126 self.user = user
129 127 self.passwd = passwd
130 128 self._make_opener()
131 129
132 130 def _make_opener(self):
133 131 mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
134 132 mgr.add_password(None, self._uri, self.user, self.passwd)
135 133 handler = urllib.request.HTTPBasicAuthHandler(mgr)
136 134 self.opener = urllib.request.build_opener(handler)
137 135
138 136 def _request(self, url, body=None, headers=None,
139 137 method=None, noformat=False,
140 138 empty_response_ok=False):
141 139 _headers = {"Content-type": "application/json",
142 140 "Accept": "application/json"}
143 141 if self.user and self.passwd:
144 authstring = base64.b64encode("%s:%s" % (self.user, self.passwd))
142 authstring = base64.b64encode("{}:{}".format(self.user, self.passwd))
145 143 _headers["Authorization"] = "Basic %s" % authstring
146 144 if headers:
147 145 _headers.update(headers)
148 146 log.debug("Sent crowd: \n%s"
149 147 % (formatted_json({"url": url, "body": body,
150 148 "headers": _headers})))
151 149 request = urllib.request.Request(url, body, _headers)
152 150 if method:
153 151 request.get_method = lambda: method
154 152
155 153 global msg
156 154 msg = ""
157 155 try:
158 156 ret_doc = self.opener.open(request)
159 157 msg = ret_doc.read()
160 158 if not msg and empty_response_ok:
161 159 ret_val = {}
162 160 ret_val["status"] = True
163 161 ret_val["error"] = "Response body was empty"
164 162 elif not noformat:
165 163 ret_val = json.loads(msg)
166 164 ret_val["status"] = True
167 165 else:
168 166 ret_val = msg
169 167 except Exception as e:
170 168 if not noformat:
171 169 ret_val = {"status": False,
172 170 "body": body,
173 "error": "{}\n{}".format(e, msg)}
171 "error": f"{e}\n{msg}"}
174 172 else:
175 173 ret_val = None
176 174 return ret_val
177 175
178 176 def user_auth(self, username, password):
179 177 """Authenticate a user against crowd. Returns brief information about
180 178 the user."""
181 179 url = ("%s/rest/usermanagement/%s/authentication?username=%s"
182 180 % (self._uri, self._version, username))
183 181 body = json.dumps({"value": password})
184 182 return self._request(url, body)
185 183
186 184 def user_groups(self, username):
187 185 """Retrieve a list of groups to which this user belongs."""
188 186 url = ("%s/rest/usermanagement/%s/user/group/nested?username=%s"
189 187 % (self._uri, self._version, username))
190 188 return self._request(url)
191 189
192 190
193 191 class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin):
194 192 uid = 'crowd'
195 193 _settings_unsafe_keys = ['app_password']
196 194
197 195 def includeme(self, config):
198 196 config.add_authn_plugin(self)
199 197 config.add_authn_resource(self.get_id(), CrowdAuthnResource(self))
200 198 config.add_view(
201 199 'rhodecode.authentication.views.AuthnPluginViewBase',
202 200 attr='settings_get',
203 201 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
204 202 request_method='GET',
205 203 route_name='auth_home',
206 204 context=CrowdAuthnResource)
207 205 config.add_view(
208 206 'rhodecode.authentication.views.AuthnPluginViewBase',
209 207 attr='settings_post',
210 208 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
211 209 request_method='POST',
212 210 route_name='auth_home',
213 211 context=CrowdAuthnResource)
214 212
215 213 def get_settings_schema(self):
216 214 return CrowdSettingsSchema()
217 215
218 216 def get_display_name(self, load_from_settings=False):
219 217 return _('CROWD')
220 218
221 219 @classmethod
222 220 def docs(cls):
223 221 return "https://docs.rhodecode.com/RhodeCode-Enterprise/auth/auth-crowd.html"
224 222
225 223 @hybrid_property
226 224 def name(self):
227 return u"crowd"
225 return "crowd"
228 226
229 227 def use_fake_password(self):
230 228 return True
231 229
232 230 def user_activation_state(self):
233 231 def_user_perms = User.get_default_user().AuthUser().permissions['global']
234 232 return 'hg.extern_activate.auto' in def_user_perms
235 233
236 234 def auth(self, userobj, username, password, settings, **kwargs):
237 235 """
238 236 Given a user object (which may be null), username, a plaintext password,
239 237 and a settings object (containing all the keys needed as listed in settings()),
240 238 authenticate this user's login attempt.
241 239
242 240 Return None on failure. On success, return a dictionary of the form:
243 241
244 242 see: RhodeCodeAuthPluginBase.auth_func_attrs
245 243 This is later validated for correctness
246 244 """
247 245 if not username or not password:
248 246 log.debug('Empty username or password skipping...')
249 247 return None
250 248
251 249 log.debug("Crowd settings: \n%s", formatted_json(settings))
252 250 server = CrowdServer(**settings)
253 251 server.set_credentials(settings["app_name"], settings["app_password"])
254 252 crowd_user = server.user_auth(username, password)
255 253 log.debug("Crowd returned: \n%s", formatted_json(crowd_user))
256 254 if not crowd_user["status"]:
257 255 return None
258 256
259 257 res = server.user_groups(crowd_user["name"])
260 258 log.debug("Crowd groups: \n%s", formatted_json(res))
261 259 crowd_user["groups"] = [x["name"] for x in res["groups"]]
262 260
263 261 # old attrs fetched from RhodeCode database
264 262 admin = getattr(userobj, 'admin', False)
265 263 active = getattr(userobj, 'active', True)
266 264 email = getattr(userobj, 'email', '')
267 265 username = getattr(userobj, 'username', username)
268 266 firstname = getattr(userobj, 'firstname', '')
269 267 lastname = getattr(userobj, 'lastname', '')
270 268 extern_type = getattr(userobj, 'extern_type', '')
271 269
272 270 user_attrs = {
273 271 'username': username,
274 272 'firstname': crowd_user["first-name"] or firstname,
275 273 'lastname': crowd_user["last-name"] or lastname,
276 274 'groups': crowd_user["groups"],
277 275 'user_group_sync': True,
278 276 'email': crowd_user["email"] or email,
279 277 'admin': admin,
280 278 'active': active,
281 279 'active_from_extern': crowd_user.get('active'),
282 280 'extern_name': crowd_user["name"],
283 281 'extern_type': extern_type,
284 282 }
285 283
286 284 # set an admin if we're in admin_groups of crowd
287 285 for group in settings["admin_groups"]:
288 286 if group in user_attrs["groups"]:
289 287 user_attrs["admin"] = True
290 288 log.debug("Final crowd user object: \n%s", formatted_json(user_attrs))
291 289 log.info('user `%s` authenticated correctly', user_attrs['username'])
292 290 return user_attrs
293 291
294 292
295 293 def includeme(config):
296 plugin_id = 'egg:rhodecode-enterprise-ce#{}'.format(RhodeCodeAuthPlugin.uid)
294 plugin_id = f'egg:rhodecode-enterprise-ce#{RhodeCodeAuthPlugin.uid}'
297 295 plugin_factory(plugin_id).includeme(config)
@@ -1,233 +1,231 b''
1
2
3 1 # Copyright (C) 2012-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 import colander
22 20 import logging
23 21
24 22 from rhodecode.translation import _
25 23 from rhodecode.authentication.base import (
26 24 RhodeCodeExternalAuthPlugin, hybrid_property)
27 25 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
28 26 from rhodecode.authentication.routes import AuthnPluginResourceBase
29 27 from rhodecode.lib.colander_utils import strip_whitespace
30 28 from rhodecode.lib.str_utils import safe_str
31 29 from rhodecode.lib.utils2 import str2bool
32 30 from rhodecode.model.db import User
33 31
34 32
35 33 log = logging.getLogger(__name__)
36 34
37 35
38 36 def plugin_factory(plugin_id, *args, **kwargs):
39 37 """
40 38 Factory function that is called during plugin discovery.
41 39 It returns the plugin instance.
42 40 """
43 41 plugin = RhodeCodeAuthPlugin(plugin_id)
44 42 return plugin
45 43
46 44
47 45 class HeadersAuthnResource(AuthnPluginResourceBase):
48 46 pass
49 47
50 48
51 49 class HeadersSettingsSchema(AuthnPluginSettingsSchemaBase):
52 50 header = colander.SchemaNode(
53 51 colander.String(),
54 52 default='REMOTE_USER',
55 53 description=_('Header to extract the user from'),
56 54 preparer=strip_whitespace,
57 55 title=_('Header'),
58 56 widget='string')
59 57 fallback_header = colander.SchemaNode(
60 58 colander.String(),
61 59 default='HTTP_X_FORWARDED_USER',
62 60 description=_('Header to extract the user from when main one fails'),
63 61 preparer=strip_whitespace,
64 62 title=_('Fallback header'),
65 63 widget='string')
66 64 clean_username = colander.SchemaNode(
67 65 colander.Boolean(),
68 66 default=True,
69 67 description=_('Perform cleaning of user, if passed user has @ in '
70 68 'username then first part before @ is taken. '
71 69 'If there\'s \\ in the username only the part after '
72 70 ' \\ is taken'),
73 71 missing=False,
74 72 title=_('Clean username'),
75 73 widget='bool')
76 74
77 75
78 76 class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin):
79 77 uid = 'headers'
80 78
81 79 def includeme(self, config):
82 80 config.add_authn_plugin(self)
83 81 config.add_authn_resource(self.get_id(), HeadersAuthnResource(self))
84 82 config.add_view(
85 83 'rhodecode.authentication.views.AuthnPluginViewBase',
86 84 attr='settings_get',
87 85 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
88 86 request_method='GET',
89 87 route_name='auth_home',
90 88 context=HeadersAuthnResource)
91 89 config.add_view(
92 90 'rhodecode.authentication.views.AuthnPluginViewBase',
93 91 attr='settings_post',
94 92 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
95 93 request_method='POST',
96 94 route_name='auth_home',
97 95 context=HeadersAuthnResource)
98 96
99 97 def get_display_name(self, load_from_settings=False):
100 98 return _('Headers')
101 99
102 100 def get_settings_schema(self):
103 101 return HeadersSettingsSchema()
104 102
105 103 @hybrid_property
106 104 def name(self):
107 return u"headers"
105 return "headers"
108 106
109 107 @property
110 108 def is_headers_auth(self):
111 109 return True
112 110
113 111 def use_fake_password(self):
114 112 return True
115 113
116 114 def user_activation_state(self):
117 115 def_user_perms = User.get_default_user().AuthUser().permissions['global']
118 116 return 'hg.extern_activate.auto' in def_user_perms
119 117
120 118 def _clean_username(self, username):
121 119 # Removing realm and domain from username
122 120 username = username.split('@')[0]
123 121 username = username.rsplit('\\')[-1]
124 122 return username
125 123
126 124 def _get_username(self, environ, settings):
127 125 username = None
128 126 environ = environ or {}
129 127 if not environ:
130 128 log.debug('got empty environ: %s', environ)
131 129
132 130 settings = settings or {}
133 131 if settings.get('header'):
134 132 header = settings.get('header')
135 133 username = environ.get(header)
136 134 log.debug('extracted %s:%s', header, username)
137 135
138 136 # fallback mode
139 137 if not username and settings.get('fallback_header'):
140 138 header = settings.get('fallback_header')
141 139 username = environ.get(header)
142 140 log.debug('extracted %s:%s', header, username)
143 141
144 142 if username and str2bool(settings.get('clean_username')):
145 143 log.debug('Received username `%s` from headers', username)
146 144 username = self._clean_username(username)
147 145 log.debug('New cleanup user is:%s', username)
148 146 return username
149 147
150 148 def get_user(self, username=None, **kwargs):
151 149 """
152 150 Helper method for user fetching in plugins, by default it's using
153 151 simple fetch by username, but this method can be customized in plugins
154 152 eg. headers auth plugin to fetch user by environ params
155 153 :param username: username if given to fetch
156 154 :param kwargs: extra arguments needed for user fetching.
157 155 """
158 156 environ = kwargs.get('environ') or {}
159 157 settings = kwargs.get('settings') or {}
160 158 username = self._get_username(environ, settings)
161 159 # we got the username, so use default method now
162 return super(RhodeCodeAuthPlugin, self).get_user(username)
160 return super().get_user(username)
163 161
164 162 def auth(self, userobj, username, password, settings, **kwargs):
165 163 """
166 164 Gets the headers_auth username (or email). It tries to get username
167 165 from REMOTE_USER if this plugin is enabled, if that fails
168 166 it tries to get username from HTTP_X_FORWARDED_USER if fallback header
169 167 is set. clean_username extracts the username from this data if it's
170 168 having @ in it.
171 169 Return None on failure. On success, return a dictionary of the form:
172 170
173 171 see: RhodeCodeAuthPluginBase.auth_func_attrs
174 172
175 173 :param userobj:
176 174 :param username:
177 175 :param password:
178 176 :param settings:
179 177 :param kwargs:
180 178 """
181 179 environ = kwargs.get('environ')
182 180 if not environ:
183 181 log.debug('Empty environ data skipping...')
184 182 return None
185 183
186 184 if not userobj:
187 185 userobj = self.get_user('', environ=environ, settings=settings)
188 186
189 187 # we don't care passed username/password for headers auth plugins.
190 188 # only way to log in is using environ
191 189 username = None
192 190 if userobj:
193 191 username = getattr(userobj, 'username')
194 192
195 193 if not username:
196 194 # we don't have any objects in DB user doesn't exist extract
197 195 # username from environ based on the settings
198 196 username = self._get_username(environ, settings)
199 197
200 198 # if cannot fetch username, it's a no-go for this plugin to proceed
201 199 if not username:
202 200 return None
203 201
204 202 # old attrs fetched from RhodeCode database
205 203 admin = getattr(userobj, 'admin', False)
206 204 active = getattr(userobj, 'active', True)
207 205 email = getattr(userobj, 'email', '')
208 206 firstname = getattr(userobj, 'firstname', '')
209 207 lastname = getattr(userobj, 'lastname', '')
210 208 extern_type = getattr(userobj, 'extern_type', '')
211 209
212 210 user_attrs = {
213 211 'username': username,
214 212 'firstname': safe_str(firstname or username),
215 213 'lastname': safe_str(lastname or ''),
216 214 'groups': [],
217 215 'user_group_sync': False,
218 216 'email': email or '',
219 217 'admin': admin or False,
220 218 'active': active,
221 219 'active_from_extern': True,
222 220 'extern_name': username,
223 221 'extern_type': extern_type,
224 222 }
225 223
226 224 log.info('user `%s` authenticated correctly', user_attrs['username'],
227 225 extra={"action": "user_auth_ok", "auth_module": "auth_headers", "username": user_attrs["username"]})
228 226 return user_attrs
229 227
230 228
231 229 def includeme(config):
232 plugin_id = 'egg:rhodecode-enterprise-ce#{}'.format(RhodeCodeAuthPlugin.uid)
230 plugin_id = f'egg:rhodecode-enterprise-ce#{RhodeCodeAuthPlugin.uid}'
233 231 plugin_factory(plugin_id).includeme(config)
@@ -1,175 +1,173 b''
1
2
3 1 # Copyright (C) 2012-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 """
22 20 RhodeCode authentication plugin for Jasig CAS
23 21 http://www.jasig.org/cas
24 22 """
25 23
26 24
27 25 import colander
28 26 import logging
29 27 import rhodecode
30 28 import urllib.request
31 29 import urllib.parse
32 30 import urllib.error
33 31
34 32
35 33 from rhodecode.translation import _
36 34 from rhodecode.authentication.base import (
37 35 RhodeCodeExternalAuthPlugin, hybrid_property)
38 36 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
39 37 from rhodecode.authentication.routes import AuthnPluginResourceBase
40 38 from rhodecode.lib.colander_utils import strip_whitespace
41 39 from rhodecode.model.db import User
42 40 from rhodecode.lib.str_utils import safe_str
43 41
44 42 log = logging.getLogger(__name__)
45 43
46 44
47 45 def plugin_factory(plugin_id, *args, **kwargs):
48 46 """
49 47 Factory function that is called during plugin discovery.
50 48 It returns the plugin instance.
51 49 """
52 50 plugin = RhodeCodeAuthPlugin(plugin_id)
53 51 return plugin
54 52
55 53
56 54 class JasigCasAuthnResource(AuthnPluginResourceBase):
57 55 pass
58 56
59 57
60 58 class JasigCasSettingsSchema(AuthnPluginSettingsSchemaBase):
61 59 service_url = colander.SchemaNode(
62 60 colander.String(),
63 61 default='https://domain.com/cas/v1/tickets',
64 62 description=_('The url of the Jasig CAS REST service'),
65 63 preparer=strip_whitespace,
66 64 title=_('URL'),
67 65 widget='string')
68 66
69 67
70 68 class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin):
71 69 uid = 'jasig_cas'
72 70
73 71 def includeme(self, config):
74 72 config.add_authn_plugin(self)
75 73 config.add_authn_resource(self.get_id(), JasigCasAuthnResource(self))
76 74 config.add_view(
77 75 'rhodecode.authentication.views.AuthnPluginViewBase',
78 76 attr='settings_get',
79 77 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
80 78 request_method='GET',
81 79 route_name='auth_home',
82 80 context=JasigCasAuthnResource)
83 81 config.add_view(
84 82 'rhodecode.authentication.views.AuthnPluginViewBase',
85 83 attr='settings_post',
86 84 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
87 85 request_method='POST',
88 86 route_name='auth_home',
89 87 context=JasigCasAuthnResource)
90 88
91 89 def get_settings_schema(self):
92 90 return JasigCasSettingsSchema()
93 91
94 92 def get_display_name(self, load_from_settings=False):
95 93 return _('Jasig-CAS')
96 94
97 95 @hybrid_property
98 96 def name(self):
99 return u"jasig-cas"
97 return "jasig-cas"
100 98
101 99 @property
102 100 def is_headers_auth(self):
103 101 return True
104 102
105 103 def use_fake_password(self):
106 104 return True
107 105
108 106 def user_activation_state(self):
109 107 def_user_perms = User.get_default_user().AuthUser().permissions['global']
110 108 return 'hg.extern_activate.auto' in def_user_perms
111 109
112 110 def auth(self, userobj, username, password, settings, **kwargs):
113 111 """
114 112 Given a user object (which may be null), username, a plaintext password,
115 113 and a settings object (containing all the keys needed as listed in settings()),
116 114 authenticate this user's login attempt.
117 115
118 116 Return None on failure. On success, return a dictionary of the form:
119 117
120 118 see: RhodeCodeAuthPluginBase.auth_func_attrs
121 119 This is later validated for correctness
122 120 """
123 121 if not username or not password:
124 122 log.debug('Empty username or password skipping...')
125 123 return None
126 124
127 125 log.debug("Jasig CAS settings: %s", settings)
128 126 params = urllib.parse.urlencode({'username': username, 'password': password})
129 127 headers = {"Content-type": "application/x-www-form-urlencoded",
130 128 "Accept": "text/plain",
131 129 "User-Agent": "RhodeCode-auth-%s" % rhodecode.__version__}
132 130 url = settings["service_url"]
133 131
134 132 log.debug("Sent Jasig CAS: \n%s",
135 133 {"url": url, "body": params, "headers": headers})
136 134 request = urllib.request.Request(url, params, headers)
137 135 try:
138 136 urllib.request.urlopen(request)
139 137 except urllib.error.HTTPError as e:
140 138 log.debug("HTTPError when requesting Jasig CAS (status code: %d)", e.code)
141 139 return None
142 140 except urllib.error.URLError as e:
143 141 log.debug("URLError when requesting Jasig CAS url: %s %s", url, e)
144 142 return None
145 143
146 144 # old attrs fetched from RhodeCode database
147 145 admin = getattr(userobj, 'admin', False)
148 146 active = getattr(userobj, 'active', True)
149 147 email = getattr(userobj, 'email', '')
150 148 username = getattr(userobj, 'username', username)
151 149 firstname = getattr(userobj, 'firstname', '')
152 150 lastname = getattr(userobj, 'lastname', '')
153 151 extern_type = getattr(userobj, 'extern_type', '')
154 152
155 153 user_attrs = {
156 154 'username': username,
157 155 'firstname': safe_str(firstname or username),
158 156 'lastname': safe_str(lastname or ''),
159 157 'groups': [],
160 158 'user_group_sync': False,
161 159 'email': email or '',
162 160 'admin': admin or False,
163 161 'active': active,
164 162 'active_from_extern': True,
165 163 'extern_name': username,
166 164 'extern_type': extern_type,
167 165 }
168 166
169 167 log.info('user `%s` authenticated correctly', user_attrs['username'])
170 168 return user_attrs
171 169
172 170
173 171 def includeme(config):
174 plugin_id = 'egg:rhodecode-enterprise-ce#{}'.format(RhodeCodeAuthPlugin.uid)
172 plugin_id = f'egg:rhodecode-enterprise-ce#{RhodeCodeAuthPlugin.uid}'
175 173 plugin_factory(plugin_id).includeme(config)
@@ -1,551 +1,550 b''
1
2 1 # Copyright (C) 2010-2023 RhodeCode GmbH
3 2 #
4 3 # This program is free software: you can redistribute it and/or modify
5 4 # it under the terms of the GNU Affero General Public License, version 3
6 5 # (only), as published by the Free Software Foundation.
7 6 #
8 7 # This program is distributed in the hope that it will be useful,
9 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
10 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 10 # GNU General Public License for more details.
12 11 #
13 12 # You should have received a copy of the GNU Affero General Public License
14 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
15 14 #
16 15 # This program is dual-licensed. If you wish to learn more about the
17 16 # RhodeCode Enterprise Edition, including its added features, Support services,
18 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
19 18
20 19 """
21 20 RhodeCode authentication plugin for LDAP
22 21 """
23 22
24 23 import logging
25 24 import traceback
26 25
27 26 import colander
28 27 from rhodecode.translation import _
29 28 from rhodecode.authentication.base import (
30 29 RhodeCodeExternalAuthPlugin, AuthLdapBase, hybrid_property)
31 30 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
32 31 from rhodecode.authentication.routes import AuthnPluginResourceBase
33 32 from rhodecode.lib.colander_utils import strip_whitespace
34 33 from rhodecode.lib.exceptions import (
35 34 LdapConnectionError, LdapUsernameError, LdapPasswordError, LdapImportError
36 35 )
37 36 from rhodecode.lib.str_utils import safe_str
38 37 from rhodecode.model.db import User
39 38 from rhodecode.model.validators import Missing
40 39
41 40 log = logging.getLogger(__name__)
42 41
43 42 try:
44 43 import ldap
45 44 except ImportError:
46 45 # means that python-ldap is not installed, we use Missing object to mark
47 46 # ldap lib is Missing
48 47 ldap = Missing
49 48
50 49
51 50 class LdapError(Exception):
52 51 pass
53 52
54 53
55 54 def plugin_factory(plugin_id, *args, **kwargs):
56 55 """
57 56 Factory function that is called during plugin discovery.
58 57 It returns the plugin instance.
59 58 """
60 59 plugin = RhodeCodeAuthPlugin(plugin_id)
61 60 return plugin
62 61
63 62
64 63 class LdapAuthnResource(AuthnPluginResourceBase):
65 64 pass
66 65
67 66
68 67 class AuthLdap(AuthLdapBase):
69 68 default_tls_cert_dir = '/etc/openldap/cacerts'
70 69
71 70 scope_labels = {
72 71 ldap.SCOPE_BASE: 'SCOPE_BASE',
73 72 ldap.SCOPE_ONELEVEL: 'SCOPE_ONELEVEL',
74 73 ldap.SCOPE_SUBTREE: 'SCOPE_SUBTREE',
75 74 }
76 75
77 76 def __init__(self, server, base_dn, port=389, bind_dn='', bind_pass='',
78 77 tls_kind='PLAIN', tls_reqcert='DEMAND', tls_cert_file=None,
79 78 tls_cert_dir=None, ldap_version=3,
80 79 search_scope='SUBTREE', attr_login='uid',
81 80 ldap_filter='', timeout=None):
82 81 if ldap == Missing:
83 82 raise LdapImportError("Missing or incompatible ldap library")
84 83
85 84 self.debug = False
86 85 self.timeout = timeout or 60 * 5
87 86 self.ldap_version = ldap_version
88 87 self.ldap_server_type = 'ldap'
89 88
90 89 self.TLS_KIND = tls_kind
91 90
92 91 if self.TLS_KIND == 'LDAPS':
93 92 port = port or 636
94 93 self.ldap_server_type += 's'
95 94
96 95 OPT_X_TLS_DEMAND = 2
97 96 self.TLS_REQCERT = getattr(ldap, 'OPT_X_TLS_%s' % tls_reqcert, OPT_X_TLS_DEMAND)
98 97 self.TLS_CERT_FILE = tls_cert_file or ''
99 98 self.TLS_CERT_DIR = tls_cert_dir or self.default_tls_cert_dir
100 99
101 100 # split server into list
102 101 self.SERVER_ADDRESSES = self._get_server_list(server)
103 102 self.LDAP_SERVER_PORT = port
104 103
105 104 # USE FOR READ ONLY BIND TO LDAP SERVER
106 105 self.attr_login = attr_login
107 106
108 107 self.LDAP_BIND_DN = safe_str(bind_dn)
109 108 self.LDAP_BIND_PASS = safe_str(bind_pass)
110 109
111 110 self.SEARCH_SCOPE = getattr(ldap, 'SCOPE_%s' % search_scope)
112 111 self.BASE_DN = safe_str(base_dn)
113 112 self.LDAP_FILTER = safe_str(ldap_filter)
114 113
115 114 def _get_ldap_conn(self):
116 115
117 116 if self.debug:
118 117 ldap.set_option(ldap.OPT_DEBUG_LEVEL, 255)
119 118
120 119 if self.TLS_CERT_FILE and hasattr(ldap, 'OPT_X_TLS_CACERTFILE'):
121 120 ldap.set_option(ldap.OPT_X_TLS_CACERTFILE, self.TLS_CERT_FILE)
122 121
123 122 elif hasattr(ldap, 'OPT_X_TLS_CACERTDIR'):
124 123 ldap.set_option(ldap.OPT_X_TLS_CACERTDIR, self.TLS_CERT_DIR)
125 124
126 125 if self.TLS_KIND != 'PLAIN':
127 126 ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, self.TLS_REQCERT)
128 127
129 128 ldap.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF)
130 129 ldap.set_option(ldap.OPT_RESTART, ldap.OPT_ON)
131 130
132 131 # init connection now
133 132 ldap_servers = self._build_servers(
134 133 self.ldap_server_type, self.SERVER_ADDRESSES, self.LDAP_SERVER_PORT)
135 134 log.debug('initializing LDAP connection to:%s', ldap_servers)
136 135 ldap_conn = ldap.initialize(ldap_servers)
137 136 ldap_conn.set_option(ldap.OPT_NETWORK_TIMEOUT, self.timeout)
138 137 ldap_conn.set_option(ldap.OPT_TIMEOUT, self.timeout)
139 138 ldap_conn.timeout = self.timeout
140 139
141 140 if self.ldap_version == 2:
142 141 ldap_conn.protocol = ldap.VERSION2
143 142 else:
144 143 ldap_conn.protocol = ldap.VERSION3
145 144
146 145 if self.TLS_KIND == 'START_TLS':
147 146 ldap_conn.start_tls_s()
148 147
149 148 if self.LDAP_BIND_DN and self.LDAP_BIND_PASS:
150 149 log.debug('Trying simple_bind with password and given login DN: %r',
151 150 self.LDAP_BIND_DN)
152 151 ldap_conn.simple_bind_s(self.LDAP_BIND_DN, self.LDAP_BIND_PASS)
153 152 log.debug('simple_bind successful')
154 153 return ldap_conn
155 154
156 155 def fetch_attrs_from_simple_bind(self, ldap_conn, dn, username, password):
157 156 scope = ldap.SCOPE_BASE
158 157 scope_label = self.scope_labels.get(scope)
159 158 ldap_filter = '(objectClass=*)'
160 159
161 160 try:
162 161 log.debug('Trying authenticated search bind with dn: %r SCOPE: %s (and filter: %s)',
163 162 dn, scope_label, ldap_filter)
164 163 ldap_conn.simple_bind_s(dn, safe_str(password))
165 164 response = ldap_conn.search_ext_s(dn, scope, ldap_filter, attrlist=['*', '+'])
166 165
167 166 if not response:
168 167 log.error('search bind returned empty results: %r', response)
169 168 return {}
170 169 else:
171 170 _dn, attrs = response[0]
172 171 return attrs
173 172
174 173 except ldap.INVALID_CREDENTIALS:
175 174 log.debug("LDAP rejected password for user '%s': %s, org_exc:",
176 175 username, dn, exc_info=True)
177 176
178 177 def authenticate_ldap(self, username, password):
179 178 """
180 179 Authenticate a user via LDAP and return his/her LDAP properties.
181 180
182 181 Raises AuthenticationError if the credentials are rejected, or
183 182 EnvironmentError if the LDAP server can't be reached.
184 183
185 184 :param username: username
186 185 :param password: password
187 186 """
188 187
189 188 uid = self.get_uid(username, self.SERVER_ADDRESSES)
190 189 user_attrs = {}
191 190 dn = ''
192 191
193 192 self.validate_password(username, password)
194 193 self.validate_username(username)
195 194 scope_label = self.scope_labels.get(self.SEARCH_SCOPE)
196 195
197 196 ldap_conn = None
198 197 try:
199 198 ldap_conn = self._get_ldap_conn()
200 199 filter_ = '(&%s(%s=%s))' % (
201 200 self.LDAP_FILTER, self.attr_login, username)
202 201 log.debug("Authenticating %r filter %s and scope: %s",
203 202 self.BASE_DN, filter_, scope_label)
204 203
205 204 ldap_objects = ldap_conn.search_ext_s(
206 205 self.BASE_DN, self.SEARCH_SCOPE, filter_, attrlist=['*', '+'])
207 206
208 207 if not ldap_objects:
209 208 log.debug("No matching LDAP objects for authentication "
210 209 "of UID:'%s' username:(%s)", uid, username)
211 210 raise ldap.NO_SUCH_OBJECT()
212 211
213 212 log.debug('Found %s matching ldap object[s], trying to authenticate on each one now...', len(ldap_objects))
214 213 for (dn, _attrs) in ldap_objects:
215 214 if dn is None:
216 215 continue
217 216
218 217 user_attrs = self.fetch_attrs_from_simple_bind(
219 218 ldap_conn, dn, username, password)
220 219
221 220 if user_attrs:
222 221 log.debug('Got authenticated user attributes from DN:%s', dn)
223 222 break
224 223 else:
225 224 raise LdapPasswordError(
226 'Failed to authenticate user `{}` with given password'.format(username))
225 f'Failed to authenticate user `{username}` with given password')
227 226
228 227 except ldap.NO_SUCH_OBJECT:
229 228 log.debug("LDAP says no such user '%s' (%s), org_exc:",
230 229 uid, username, exc_info=True)
231 230 raise LdapUsernameError('Unable to find user')
232 231 except ldap.SERVER_DOWN:
233 232 org_exc = traceback.format_exc()
234 233 raise LdapConnectionError(
235 234 "LDAP can't access authentication server, org_exc:%s" % org_exc)
236 235 finally:
237 236 if ldap_conn:
238 237 log.debug('ldap: connection release')
239 238 try:
240 239 ldap_conn.unbind_s()
241 240 except Exception:
242 241 # for any reason this can raise exception we must catch it
243 242 # to not crush the server
244 243 pass
245 244
246 245 return dn, user_attrs
247 246
248 247
249 248 class LdapSettingsSchema(AuthnPluginSettingsSchemaBase):
250 249 tls_kind_choices = ['PLAIN', 'LDAPS', 'START_TLS']
251 250 tls_reqcert_choices = ['NEVER', 'ALLOW', 'TRY', 'DEMAND', 'HARD']
252 251 search_scope_choices = ['BASE', 'ONELEVEL', 'SUBTREE']
253 252
254 253 host = colander.SchemaNode(
255 254 colander.String(),
256 255 default='',
257 256 description=_('Host[s] of the LDAP Server \n'
258 257 '(e.g., 192.168.2.154, or ldap-server.domain.com.\n '
259 258 'Multiple servers can be specified using commas'),
260 259 preparer=strip_whitespace,
261 260 title=_('LDAP Host'),
262 261 widget='string')
263 262 port = colander.SchemaNode(
264 263 colander.Int(),
265 264 default=389,
266 265 description=_('Custom port that the LDAP server is listening on. '
267 266 'Default value is: 389, use 636 for LDAPS (SSL)'),
268 267 preparer=strip_whitespace,
269 268 title=_('Port'),
270 269 validator=colander.Range(min=0, max=65536),
271 270 widget='int')
272 271
273 272 timeout = colander.SchemaNode(
274 273 colander.Int(),
275 274 default=60 * 5,
276 275 description=_('Timeout for LDAP connection'),
277 276 preparer=strip_whitespace,
278 277 title=_('Connection timeout'),
279 278 validator=colander.Range(min=1),
280 279 widget='int')
281 280
282 281 dn_user = colander.SchemaNode(
283 282 colander.String(),
284 283 default='',
285 284 description=_('Optional user DN/account to connect to LDAP if authentication is required. \n'
286 285 'e.g., cn=admin,dc=mydomain,dc=com, or '
287 286 'uid=root,cn=users,dc=mydomain,dc=com, or admin@mydomain.com'),
288 287 missing='',
289 288 preparer=strip_whitespace,
290 289 title=_('Bind account'),
291 290 widget='string')
292 291 dn_pass = colander.SchemaNode(
293 292 colander.String(),
294 293 default='',
295 294 description=_('Password to authenticate for given user DN.'),
296 295 missing='',
297 296 preparer=strip_whitespace,
298 297 title=_('Bind account password'),
299 298 widget='password')
300 299 tls_kind = colander.SchemaNode(
301 300 colander.String(),
302 301 default=tls_kind_choices[0],
303 302 description=_('TLS Type'),
304 303 title=_('Connection Security'),
305 304 validator=colander.OneOf(tls_kind_choices),
306 305 widget='select')
307 306 tls_reqcert = colander.SchemaNode(
308 307 colander.String(),
309 308 default=tls_reqcert_choices[0],
310 309 description=_('Require Cert over TLS?. Self-signed and custom '
311 310 'certificates can be used when\n `RhodeCode Certificate` '
312 311 'found in admin > settings > system info page is extended.'),
313 312 title=_('Certificate Checks'),
314 313 validator=colander.OneOf(tls_reqcert_choices),
315 314 widget='select')
316 315 tls_cert_file = colander.SchemaNode(
317 316 colander.String(),
318 317 default='',
319 318 description=_('This specifies the PEM-format file path containing '
320 319 'certificates for use in TLS connection.\n'
321 320 'If not specified `TLS Cert dir` will be used'),
322 321 title=_('TLS Cert file'),
323 322 missing='',
324 323 widget='string')
325 324 tls_cert_dir = colander.SchemaNode(
326 325 colander.String(),
327 326 default=AuthLdap.default_tls_cert_dir,
328 327 description=_('This specifies the path of a directory that contains individual '
329 328 'CA certificates in separate files.'),
330 329 title=_('TLS Cert dir'),
331 330 widget='string')
332 331 base_dn = colander.SchemaNode(
333 332 colander.String(),
334 333 default='',
335 334 description=_('Base DN to search. Dynamic bind is supported. Add `$login` marker '
336 335 'in it to be replaced with current user username \n'
337 336 '(e.g., dc=mydomain,dc=com, or ou=Users,dc=mydomain,dc=com)'),
338 337 missing='',
339 338 preparer=strip_whitespace,
340 339 title=_('Base DN'),
341 340 widget='string')
342 341 filter = colander.SchemaNode(
343 342 colander.String(),
344 343 default='',
345 344 description=_('Filter to narrow results \n'
346 345 '(e.g., (&(objectCategory=Person)(objectClass=user)), or \n'
347 346 '(memberof=cn=rc-login,ou=groups,ou=company,dc=mydomain,dc=com)))'),
348 347 missing='',
349 348 preparer=strip_whitespace,
350 349 title=_('LDAP Search Filter'),
351 350 widget='string')
352 351
353 352 search_scope = colander.SchemaNode(
354 353 colander.String(),
355 354 default=search_scope_choices[2],
356 355 description=_('How deep to search LDAP. If unsure set to SUBTREE'),
357 356 title=_('LDAP Search Scope'),
358 357 validator=colander.OneOf(search_scope_choices),
359 358 widget='select')
360 359 attr_login = colander.SchemaNode(
361 360 colander.String(),
362 361 default='uid',
363 362 description=_('LDAP Attribute to map to user name (e.g., uid, or sAMAccountName)'),
364 363 preparer=strip_whitespace,
365 364 title=_('Login Attribute'),
366 365 missing_msg=_('The LDAP Login attribute of the CN must be specified'),
367 366 widget='string')
368 367 attr_email = colander.SchemaNode(
369 368 colander.String(),
370 369 default='',
371 370 description=_('LDAP Attribute to map to email address (e.g., mail).\n'
372 371 'Emails are a crucial part of RhodeCode. \n'
373 372 'If possible add a valid email attribute to ldap users.'),
374 373 missing='',
375 374 preparer=strip_whitespace,
376 375 title=_('Email Attribute'),
377 376 widget='string')
378 377 attr_firstname = colander.SchemaNode(
379 378 colander.String(),
380 379 default='',
381 380 description=_('LDAP Attribute to map to first name (e.g., givenName)'),
382 381 missing='',
383 382 preparer=strip_whitespace,
384 383 title=_('First Name Attribute'),
385 384 widget='string')
386 385 attr_lastname = colander.SchemaNode(
387 386 colander.String(),
388 387 default='',
389 388 description=_('LDAP Attribute to map to last name (e.g., sn)'),
390 389 missing='',
391 390 preparer=strip_whitespace,
392 391 title=_('Last Name Attribute'),
393 392 widget='string')
394 393
395 394
396 395 class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin):
397 396 uid = 'ldap'
398 397 # used to define dynamic binding in the
399 398 DYNAMIC_BIND_VAR = '$login'
400 399 _settings_unsafe_keys = ['dn_pass']
401 400
402 401 def includeme(self, config):
403 402 config.add_authn_plugin(self)
404 403 config.add_authn_resource(self.get_id(), LdapAuthnResource(self))
405 404 config.add_view(
406 405 'rhodecode.authentication.views.AuthnPluginViewBase',
407 406 attr='settings_get',
408 407 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
409 408 request_method='GET',
410 409 route_name='auth_home',
411 410 context=LdapAuthnResource)
412 411 config.add_view(
413 412 'rhodecode.authentication.views.AuthnPluginViewBase',
414 413 attr='settings_post',
415 414 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
416 415 request_method='POST',
417 416 route_name='auth_home',
418 417 context=LdapAuthnResource)
419 418
420 419 def get_settings_schema(self):
421 420 return LdapSettingsSchema()
422 421
423 422 def get_display_name(self, load_from_settings=False):
424 423 return _('LDAP')
425 424
426 425 @classmethod
427 426 def docs(cls):
428 427 return "https://docs.rhodecode.com/RhodeCode-Enterprise/auth/auth-ldap.html"
429 428
430 429 @hybrid_property
431 430 def name(self):
432 return u"ldap"
431 return "ldap"
433 432
434 433 def use_fake_password(self):
435 434 return True
436 435
437 436 def user_activation_state(self):
438 437 def_user_perms = User.get_default_user().AuthUser().permissions['global']
439 438 return 'hg.extern_activate.auto' in def_user_perms
440 439
441 440 def try_dynamic_binding(self, username, password, current_args):
442 441 """
443 442 Detects marker inside our original bind, and uses dynamic auth if
444 443 present
445 444 """
446 445
447 446 org_bind = current_args['bind_dn']
448 447 passwd = current_args['bind_pass']
449 448
450 449 def has_bind_marker(username):
451 450 if self.DYNAMIC_BIND_VAR in username:
452 451 return True
453 452
454 453 # we only passed in user with "special" variable
455 454 if org_bind and has_bind_marker(org_bind) and not passwd:
456 455 log.debug('Using dynamic user/password binding for ldap '
457 456 'authentication. Replacing `%s` with username',
458 457 self.DYNAMIC_BIND_VAR)
459 458 current_args['bind_dn'] = org_bind.replace(
460 459 self.DYNAMIC_BIND_VAR, username)
461 460 current_args['bind_pass'] = password
462 461
463 462 return current_args
464 463
465 464 def auth(self, userobj, username, password, settings, **kwargs):
466 465 """
467 466 Given a user object (which may be null), username, a plaintext password,
468 467 and a settings object (containing all the keys needed as listed in
469 468 settings()), authenticate this user's login attempt.
470 469
471 470 Return None on failure. On success, return a dictionary of the form:
472 471
473 472 see: RhodeCodeAuthPluginBase.auth_func_attrs
474 473 This is later validated for correctness
475 474 """
476 475
477 476 if not username or not password:
478 477 log.debug('Empty username or password skipping...')
479 478 return None
480 479
481 480 ldap_args = {
482 481 'server': settings.get('host', ''),
483 482 'base_dn': settings.get('base_dn', ''),
484 483 'port': settings.get('port'),
485 484 'bind_dn': settings.get('dn_user'),
486 485 'bind_pass': settings.get('dn_pass'),
487 486 'tls_kind': settings.get('tls_kind'),
488 487 'tls_reqcert': settings.get('tls_reqcert'),
489 488 'tls_cert_file': settings.get('tls_cert_file'),
490 489 'tls_cert_dir': settings.get('tls_cert_dir'),
491 490 'search_scope': settings.get('search_scope'),
492 491 'attr_login': settings.get('attr_login'),
493 492 'ldap_version': 3,
494 493 'ldap_filter': settings.get('filter'),
495 494 'timeout': settings.get('timeout')
496 495 }
497 496
498 497 ldap_attrs = self.try_dynamic_binding(username, password, ldap_args)
499 498
500 499 log.debug('Checking for ldap authentication.')
501 500
502 501 try:
503 502 aldap = AuthLdap(**ldap_args)
504 503 (user_dn, ldap_attrs) = aldap.authenticate_ldap(username, password)
505 504 log.debug('Got ldap DN response %s', user_dn)
506 505
507 506 def get_ldap_attr(k):
508 507 return ldap_attrs.get(settings.get(k), [''])[0]
509 508
510 509 # old attrs fetched from RhodeCode database
511 510 admin = getattr(userobj, 'admin', False)
512 511 active = getattr(userobj, 'active', True)
513 512 email = getattr(userobj, 'email', '')
514 513 username = getattr(userobj, 'username', username)
515 514 firstname = getattr(userobj, 'firstname', '')
516 515 lastname = getattr(userobj, 'lastname', '')
517 516 extern_type = getattr(userobj, 'extern_type', '')
518 517
519 518 groups = []
520 519
521 520 user_attrs = {
522 521 'username': username,
523 522 'firstname': safe_str(get_ldap_attr('attr_firstname') or firstname),
524 523 'lastname': safe_str(get_ldap_attr('attr_lastname') or lastname),
525 524 'groups': groups,
526 525 'user_group_sync': False,
527 526 'email': get_ldap_attr('attr_email') or email,
528 527 'admin': admin,
529 528 'active': active,
530 529 'active_from_extern': None,
531 530 'extern_name': user_dn,
532 531 'extern_type': extern_type,
533 532 }
534 533
535 534 log.debug('ldap user: %s', user_attrs)
536 535 log.info('user `%s` authenticated correctly', user_attrs['username'],
537 536 extra={"action": "user_auth_ok", "auth_module": "auth_ldap", "username": user_attrs["username"]})
538 537
539 538 return user_attrs
540 539
541 540 except (LdapUsernameError, LdapPasswordError, LdapImportError):
542 541 log.exception("LDAP related exception")
543 542 return None
544 543 except (Exception,):
545 544 log.exception("Other exception")
546 545 return None
547 546
548 547
549 548 def includeme(config):
550 plugin_id = 'egg:rhodecode-enterprise-ce#{}'.format(RhodeCodeAuthPlugin.uid)
549 plugin_id = f'egg:rhodecode-enterprise-ce#{RhodeCodeAuthPlugin.uid}'
551 550 plugin_factory(plugin_id).includeme(config)
@@ -1,172 +1,170 b''
1
2
3 1 # Copyright (C) 2012-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 """
22 20 RhodeCode authentication library for PAM
23 21 """
24 22
25 23 import colander
26 24 import grp
27 25 import logging
28 26 import pam
29 27 import pwd
30 28 import re
31 29 import socket
32 30
33 31 from rhodecode.translation import _
34 32 from rhodecode.authentication.base import (
35 33 RhodeCodeExternalAuthPlugin, hybrid_property)
36 34 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
37 35 from rhodecode.authentication.routes import AuthnPluginResourceBase
38 36 from rhodecode.lib.colander_utils import strip_whitespace
39 37
40 38 log = logging.getLogger(__name__)
41 39
42 40
43 41 def plugin_factory(plugin_id, *args, **kwargs):
44 42 """
45 43 Factory function that is called during plugin discovery.
46 44 It returns the plugin instance.
47 45 """
48 46 plugin = RhodeCodeAuthPlugin(plugin_id)
49 47 return plugin
50 48
51 49
52 50 class PamAuthnResource(AuthnPluginResourceBase):
53 51 pass
54 52
55 53
56 54 class PamSettingsSchema(AuthnPluginSettingsSchemaBase):
57 55 service = colander.SchemaNode(
58 56 colander.String(),
59 57 default='login',
60 58 description=_('PAM service name to use for authentication.'),
61 59 preparer=strip_whitespace,
62 60 title=_('PAM service name'),
63 61 widget='string')
64 62 gecos = colander.SchemaNode(
65 63 colander.String(),
66 64 default=r'(?P<last_name>.+),\s*(?P<first_name>\w+)',
67 65 description=_('Regular expression for extracting user name/email etc. '
68 66 'from Unix userinfo.'),
69 67 preparer=strip_whitespace,
70 68 title=_('Gecos Regex'),
71 69 widget='string')
72 70
73 71
74 72 class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin):
75 73 uid = 'pam'
76 74 # PAM authentication can be slow. Repository operations involve a lot of
77 75 # auth calls. Little caching helps speedup push/pull operations significantly
78 76 AUTH_CACHE_TTL = 4
79 77
80 78 def includeme(self, config):
81 79 config.add_authn_plugin(self)
82 80 config.add_authn_resource(self.get_id(), PamAuthnResource(self))
83 81 config.add_view(
84 82 'rhodecode.authentication.views.AuthnPluginViewBase',
85 83 attr='settings_get',
86 84 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
87 85 request_method='GET',
88 86 route_name='auth_home',
89 87 context=PamAuthnResource)
90 88 config.add_view(
91 89 'rhodecode.authentication.views.AuthnPluginViewBase',
92 90 attr='settings_post',
93 91 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
94 92 request_method='POST',
95 93 route_name='auth_home',
96 94 context=PamAuthnResource)
97 95
98 96 def get_display_name(self, load_from_settings=False):
99 97 return _('PAM')
100 98
101 99 @classmethod
102 100 def docs(cls):
103 101 return "https://docs.rhodecode.com/RhodeCode-Enterprise/auth/auth-pam.html"
104 102
105 103 @hybrid_property
106 104 def name(self):
107 return u"pam"
105 return "pam"
108 106
109 107 def get_settings_schema(self):
110 108 return PamSettingsSchema()
111 109
112 110 def use_fake_password(self):
113 111 return True
114 112
115 113 def auth(self, userobj, username, password, settings, **kwargs):
116 114 if not username or not password:
117 115 log.debug('Empty username or password skipping...')
118 116 return None
119 117 _pam = pam.pam()
120 118 auth_result = _pam.authenticate(username, password, settings["service"])
121 119
122 120 if not auth_result:
123 121 log.error("PAM was unable to authenticate user: %s", username)
124 122 return None
125 123
126 124 log.debug('Got PAM response %s', auth_result)
127 125
128 126 # old attrs fetched from RhodeCode database
129 default_email = "%s@%s" % (username, socket.gethostname())
127 default_email = "{}@{}".format(username, socket.gethostname())
130 128 admin = getattr(userobj, 'admin', False)
131 129 active = getattr(userobj, 'active', True)
132 130 email = getattr(userobj, 'email', '') or default_email
133 131 username = getattr(userobj, 'username', username)
134 132 firstname = getattr(userobj, 'firstname', '')
135 133 lastname = getattr(userobj, 'lastname', '')
136 134 extern_type = getattr(userobj, 'extern_type', '')
137 135
138 136 user_attrs = {
139 137 'username': username,
140 138 'firstname': firstname,
141 139 'lastname': lastname,
142 140 'groups': [g.gr_name for g in grp.getgrall()
143 141 if username in g.gr_mem],
144 142 'user_group_sync': True,
145 143 'email': email,
146 144 'admin': admin,
147 145 'active': active,
148 146 'active_from_extern': None,
149 147 'extern_name': username,
150 148 'extern_type': extern_type,
151 149 }
152 150
153 151 try:
154 152 user_data = pwd.getpwnam(username)
155 153 regex = settings["gecos"]
156 154 match = re.search(regex, user_data.pw_gecos)
157 155 if match:
158 156 user_attrs["firstname"] = match.group('first_name')
159 157 user_attrs["lastname"] = match.group('last_name')
160 158 except Exception:
161 159 log.warning("Cannot extract additional info for PAM user")
162 160 pass
163 161
164 162 log.debug("pamuser: %s", user_attrs)
165 163 log.info('user `%s` authenticated correctly', user_attrs['username'],
166 164 extra={"action": "user_auth_ok", "auth_module": "auth_pam", "username": user_attrs["username"]})
167 165 return user_attrs
168 166
169 167
170 168 def includeme(config):
171 plugin_id = 'egg:rhodecode-enterprise-ce#{}'.format(RhodeCodeAuthPlugin.uid)
169 plugin_id = f'egg:rhodecode-enterprise-ce#{RhodeCodeAuthPlugin.uid}'
172 170 plugin_factory(plugin_id).includeme(config)
@@ -1,222 +1,220 b''
1
2
3 1 # Copyright (C) 2012-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 """
22 20 RhodeCode authentication plugin for built in internal auth
23 21 """
24 22
25 23 import logging
26 24
27 25 import colander
28 26
29 27 from rhodecode.translation import _
30 28 from rhodecode.lib.utils2 import safe_bytes
31 29 from rhodecode.model.db import User
32 30 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
33 31 from rhodecode.authentication.base import (
34 32 RhodeCodeAuthPluginBase, hybrid_property, HTTP_TYPE, VCS_TYPE)
35 33 from rhodecode.authentication.routes import AuthnPluginResourceBase
36 34
37 35 log = logging.getLogger(__name__)
38 36
39 37
40 38 def plugin_factory(plugin_id, *args, **kwargs):
41 39 plugin = RhodeCodeAuthPlugin(plugin_id)
42 40 return plugin
43 41
44 42
45 43 class RhodecodeAuthnResource(AuthnPluginResourceBase):
46 44 pass
47 45
48 46
49 47 class RhodeCodeAuthPlugin(RhodeCodeAuthPluginBase):
50 48 uid = 'rhodecode'
51 49 AUTH_RESTRICTION_NONE = 'user_all'
52 50 AUTH_RESTRICTION_SUPER_ADMIN = 'user_super_admin'
53 51 AUTH_RESTRICTION_SCOPE_ALL = 'scope_all'
54 52 AUTH_RESTRICTION_SCOPE_HTTP = 'scope_http'
55 53 AUTH_RESTRICTION_SCOPE_VCS = 'scope_vcs'
56 54
57 55 def includeme(self, config):
58 56 config.add_authn_plugin(self)
59 57 config.add_authn_resource(self.get_id(), RhodecodeAuthnResource(self))
60 58 config.add_view(
61 59 'rhodecode.authentication.views.AuthnPluginViewBase',
62 60 attr='settings_get',
63 61 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
64 62 request_method='GET',
65 63 route_name='auth_home',
66 64 context=RhodecodeAuthnResource)
67 65 config.add_view(
68 66 'rhodecode.authentication.views.AuthnPluginViewBase',
69 67 attr='settings_post',
70 68 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
71 69 request_method='POST',
72 70 route_name='auth_home',
73 71 context=RhodecodeAuthnResource)
74 72
75 73 def get_settings_schema(self):
76 74 return RhodeCodeSettingsSchema()
77 75
78 76 def get_display_name(self, load_from_settings=False):
79 77 return _('RhodeCode Internal')
80 78
81 79 @classmethod
82 80 def docs(cls):
83 81 return "https://docs.rhodecode.com/RhodeCode-Enterprise/auth/auth.html"
84 82
85 83 @hybrid_property
86 84 def name(self):
87 return u"rhodecode"
85 return "rhodecode"
88 86
89 87 def user_activation_state(self):
90 88 def_user_perms = User.get_default_user().AuthUser().permissions['global']
91 89 return 'hg.register.auto_activate' in def_user_perms
92 90
93 91 def allows_authentication_from(
94 92 self, user, allows_non_existing_user=True,
95 93 allowed_auth_plugins=None, allowed_auth_sources=None):
96 94 """
97 95 Custom method for this auth that doesn't accept non existing users.
98 96 We know that user exists in our database.
99 97 """
100 98 allows_non_existing_user = False
101 return super(RhodeCodeAuthPlugin, self).allows_authentication_from(
99 return super().allows_authentication_from(
102 100 user, allows_non_existing_user=allows_non_existing_user)
103 101
104 102 def auth(self, userobj, username, password, settings, **kwargs):
105 103 if not userobj:
106 104 log.debug('userobj was:%s skipping', userobj)
107 105 return None
108 106
109 107 if userobj.extern_type != self.name:
110 108 log.warning("userobj:%s extern_type mismatch got:`%s` expected:`%s`",
111 109 userobj, userobj.extern_type, self.name)
112 110 return None
113 111
114 112 # check scope of auth
115 113 scope_restriction = settings.get('scope_restriction', '')
116 114
117 115 if scope_restriction == self.AUTH_RESTRICTION_SCOPE_HTTP \
118 116 and self.auth_type != HTTP_TYPE:
119 117 log.warning("userobj:%s tried scope type %s and scope restriction is set to %s",
120 118 userobj, self.auth_type, scope_restriction)
121 119 return None
122 120
123 121 if scope_restriction == self.AUTH_RESTRICTION_SCOPE_VCS \
124 122 and self.auth_type != VCS_TYPE:
125 123 log.warning("userobj:%s tried scope type %s and scope restriction is set to %s",
126 124 userobj, self.auth_type, scope_restriction)
127 125 return None
128 126
129 127 # check super-admin restriction
130 128 auth_restriction = settings.get('auth_restriction', '')
131 129
132 130 if auth_restriction == self.AUTH_RESTRICTION_SUPER_ADMIN \
133 131 and userobj.admin is False:
134 132 log.warning("userobj:%s is not super-admin and auth restriction is set to %s",
135 133 userobj, auth_restriction)
136 134 return None
137 135
138 136 user_attrs = {
139 137 "username": userobj.username,
140 138 "firstname": userobj.firstname,
141 139 "lastname": userobj.lastname,
142 140 "groups": [],
143 141 'user_group_sync': False,
144 142 "email": userobj.email,
145 143 "admin": userobj.admin,
146 144 "active": userobj.active,
147 145 "active_from_extern": userobj.active,
148 146 "extern_name": userobj.user_id,
149 147 "extern_type": userobj.extern_type,
150 148 }
151 149
152 150 log.debug("User attributes:%s", user_attrs)
153 151 if userobj.active:
154 152 from rhodecode.lib import auth
155 153 crypto_backend = auth.crypto_backend()
156 154 password_encoded = safe_bytes(password)
157 155 password_match, new_hash = crypto_backend.hash_check_with_upgrade(
158 156 password_encoded, userobj.password or '')
159 157
160 158 if password_match and new_hash:
161 159 log.debug('user %s properly authenticated, but '
162 160 'requires hash change to bcrypt', userobj)
163 161 # if password match, and we use OLD deprecated hash,
164 162 # we should migrate this user hash password to the new hash
165 163 # we store the new returned by hash_check_with_upgrade function
166 164 user_attrs['_hash_migrate'] = new_hash
167 165
168 166 if userobj.username == User.DEFAULT_USER and userobj.active:
169 167 log.info('user `%s` authenticated correctly as anonymous user',
170 168 userobj.username,
171 169 extra={"action": "user_auth_ok", "auth_module": "auth_rhodecode_anon", "username": userobj.username})
172 170 return user_attrs
173 171
174 172 elif userobj.username == username and password_match:
175 173 log.info('user `%s` authenticated correctly', userobj.username,
176 174 extra={"action": "user_auth_ok", "auth_module": "auth_rhodecode", "username": userobj.username})
177 175 return user_attrs
178 176 log.warning("user `%s` used a wrong password when "
179 177 "authenticating on this plugin", userobj.username)
180 178 return None
181 179 else:
182 180 log.warning('user `%s` failed to authenticate via %s, reason: account not '
183 181 'active.', username, self.name)
184 182 return None
185 183
186 184
187 185 class RhodeCodeSettingsSchema(AuthnPluginSettingsSchemaBase):
188 186
189 187 auth_restriction_choices = [
190 188 (RhodeCodeAuthPlugin.AUTH_RESTRICTION_NONE, 'All users'),
191 189 (RhodeCodeAuthPlugin.AUTH_RESTRICTION_SUPER_ADMIN, 'Super admins only'),
192 190 ]
193 191
194 192 auth_scope_choices = [
195 193 (RhodeCodeAuthPlugin.AUTH_RESTRICTION_SCOPE_ALL, 'HTTP and VCS'),
196 194 (RhodeCodeAuthPlugin.AUTH_RESTRICTION_SCOPE_HTTP, 'HTTP only'),
197 195 ]
198 196
199 197 auth_restriction = colander.SchemaNode(
200 198 colander.String(),
201 199 default=auth_restriction_choices[0],
202 200 description=_('Allowed user types for authentication using this plugin.'),
203 201 title=_('User restriction'),
204 202 validator=colander.OneOf([x[0] for x in auth_restriction_choices]),
205 203 widget='select_with_labels',
206 204 choices=auth_restriction_choices
207 205 )
208 206 scope_restriction = colander.SchemaNode(
209 207 colander.String(),
210 208 default=auth_scope_choices[0],
211 209 description=_('Allowed protocols for authentication using this plugin. '
212 210 'VCS means GIT/HG/SVN. HTTP is web based login.'),
213 211 title=_('Scope restriction'),
214 212 validator=colander.OneOf([x[0] for x in auth_scope_choices]),
215 213 widget='select_with_labels',
216 214 choices=auth_scope_choices
217 215 )
218 216
219 217
220 218 def includeme(config):
221 plugin_id = 'egg:rhodecode-enterprise-ce#{}'.format(RhodeCodeAuthPlugin.uid)
219 plugin_id = f'egg:rhodecode-enterprise-ce#{RhodeCodeAuthPlugin.uid}'
222 220 plugin_factory(plugin_id).includeme(config)
@@ -1,177 +1,175 b''
1
2
3 1 # Copyright (C) 2016-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 """
22 20 RhodeCode authentication token plugin for built in internal auth
23 21 """
24 22
25 23 import logging
26 24 import colander
27 25
28 26 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
29 27 from rhodecode.translation import _
30 28 from rhodecode.authentication.base import (
31 29 RhodeCodeAuthPluginBase, VCS_TYPE, hybrid_property)
32 30 from rhodecode.authentication.routes import AuthnPluginResourceBase
33 31 from rhodecode.model.db import User, UserApiKeys, Repository
34 32
35 33
36 34 log = logging.getLogger(__name__)
37 35
38 36
39 37 def plugin_factory(plugin_id, *args, **kwargs):
40 38 plugin = RhodeCodeAuthPlugin(plugin_id)
41 39 return plugin
42 40
43 41
44 42 class RhodecodeAuthnResource(AuthnPluginResourceBase):
45 43 pass
46 44
47 45
48 46 class RhodeCodeAuthPlugin(RhodeCodeAuthPluginBase):
49 47 """
50 48 Enables usage of authentication tokens for vcs operations.
51 49 """
52 50 uid = 'token'
53 51 AUTH_RESTRICTION_SCOPE_VCS = 'scope_vcs'
54 52
55 53 def includeme(self, config):
56 54 config.add_authn_plugin(self)
57 55 config.add_authn_resource(self.get_id(), RhodecodeAuthnResource(self))
58 56 config.add_view(
59 57 'rhodecode.authentication.views.AuthnPluginViewBase',
60 58 attr='settings_get',
61 59 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
62 60 request_method='GET',
63 61 route_name='auth_home',
64 62 context=RhodecodeAuthnResource)
65 63 config.add_view(
66 64 'rhodecode.authentication.views.AuthnPluginViewBase',
67 65 attr='settings_post',
68 66 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
69 67 request_method='POST',
70 68 route_name='auth_home',
71 69 context=RhodecodeAuthnResource)
72 70
73 71 def get_settings_schema(self):
74 72 return RhodeCodeSettingsSchema()
75 73
76 74 def get_display_name(self, load_from_settings=False):
77 75 return _('Rhodecode Token')
78 76
79 77 @classmethod
80 78 def docs(cls):
81 79 return "https://docs.rhodecode.com/RhodeCode-Enterprise/auth/auth-token.html"
82 80
83 81 @hybrid_property
84 82 def name(self):
85 return u"authtoken"
83 return "authtoken"
86 84
87 85 def user_activation_state(self):
88 86 def_user_perms = User.get_default_user().AuthUser().permissions['global']
89 87 return 'hg.register.auto_activate' in def_user_perms
90 88
91 89 def allows_authentication_from(
92 90 self, user, allows_non_existing_user=True,
93 91 allowed_auth_plugins=None, allowed_auth_sources=None):
94 92 """
95 93 Custom method for this auth that doesn't accept empty users. And also
96 94 allows users from all other active plugins to use it and also
97 95 authenticate against it. But only via vcs mode
98 96 """
99 97 from rhodecode.authentication.base import get_authn_registry
100 98 authn_registry = get_authn_registry()
101 99
102 100 active_plugins = set(
103 101 [x.name for x in authn_registry.get_plugins_for_authentication()])
104 102 active_plugins.discard(self.name)
105 103
106 104 allowed_auth_plugins = [self.name] + list(active_plugins)
107 105 # only for vcs operations
108 106 allowed_auth_sources = [VCS_TYPE]
109 107
110 return super(RhodeCodeAuthPlugin, self).allows_authentication_from(
108 return super().allows_authentication_from(
111 109 user, allows_non_existing_user=False,
112 110 allowed_auth_plugins=allowed_auth_plugins,
113 111 allowed_auth_sources=allowed_auth_sources)
114 112
115 113 def auth(self, userobj, username, password, settings, **kwargs):
116 114 if not userobj:
117 115 log.debug('userobj was:%s skipping', userobj)
118 116 return None
119 117
120 118 user_attrs = {
121 119 "username": userobj.username,
122 120 "firstname": userobj.firstname,
123 121 "lastname": userobj.lastname,
124 122 "groups": [],
125 123 'user_group_sync': False,
126 124 "email": userobj.email,
127 125 "admin": userobj.admin,
128 126 "active": userobj.active,
129 127 "active_from_extern": userobj.active,
130 128 "extern_name": userobj.user_id,
131 129 "extern_type": userobj.extern_type,
132 130 }
133 131
134 132 log.debug('Authenticating user with args %s', user_attrs)
135 133 if userobj.active:
136 134 # calling context repo for token scopes
137 135 scope_repo_id = None
138 136 if self.acl_repo_name:
139 137 repo = Repository.get_by_repo_name(self.acl_repo_name)
140 138 scope_repo_id = repo.repo_id if repo else None
141 139
142 140 token_match = userobj.authenticate_by_token(
143 141 password, roles=[UserApiKeys.ROLE_VCS],
144 142 scope_repo_id=scope_repo_id)
145 143
146 144 if userobj.username == username and token_match:
147 145 log.info(
148 146 'user `%s` successfully authenticated via %s',
149 147 user_attrs['username'], self.name)
150 148 return user_attrs
151 149 log.warning('user `%s` failed to authenticate via %s, reason: bad or '
152 150 'inactive token.', username, self.name)
153 151 else:
154 152 log.warning('user `%s` failed to authenticate via %s, reason: account not '
155 153 'active.', username, self.name)
156 154 return None
157 155
158 156
159 157 def includeme(config):
160 plugin_id = 'egg:rhodecode-enterprise-ce#{}'.format(RhodeCodeAuthPlugin.uid)
158 plugin_id = f'egg:rhodecode-enterprise-ce#{RhodeCodeAuthPlugin.uid}'
161 159 plugin_factory(plugin_id).includeme(config)
162 160
163 161
164 162 class RhodeCodeSettingsSchema(AuthnPluginSettingsSchemaBase):
165 163 auth_scope_choices = [
166 164 (RhodeCodeAuthPlugin.AUTH_RESTRICTION_SCOPE_VCS, 'VCS only'),
167 165 ]
168 166
169 167 scope_restriction = colander.SchemaNode(
170 168 colander.String(),
171 169 default=auth_scope_choices[0],
172 170 description=_('Choose operation scope restriction when authenticating.'),
173 171 title=_('Scope restriction'),
174 172 validator=colander.OneOf([x[0] for x in auth_scope_choices]),
175 173 widget='select_with_labels',
176 174 choices=auth_scope_choices
177 175 )
@@ -1,141 +1,139 b''
1
2
3 1 # Copyright (C) 2012-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 import time
22 20 import logging
23 21
24 22 from pyramid.exceptions import ConfigurationError
25 23 from zope.interface import implementer
26 24
27 25 from rhodecode.authentication.interface import IAuthnPluginRegistry
28 26 from rhodecode.model.settings import SettingsModel
29 27 from rhodecode.lib.utils2 import safe_str
30 28 from rhodecode.lib.statsd_client import StatsdClient
31 29 from rhodecode.lib import rc_cache
32 30
33 31 log = logging.getLogger(__name__)
34 32
35 33
36 34 @implementer(IAuthnPluginRegistry)
37 35 class AuthenticationPluginRegistry(object):
38 36
39 37 # INI settings key to set a fallback authentication plugin.
40 38 fallback_plugin_key = 'rhodecode.auth_plugin_fallback'
41 39
42 40 def __init__(self, settings):
43 41 self._plugins = {}
44 42 self._fallback_plugin = settings.get(self.fallback_plugin_key, None)
45 43
46 44 def add_authn_plugin(self, config, plugin):
47 45 plugin_id = plugin.get_id()
48 46 if plugin_id in self._plugins.keys():
49 47 raise ConfigurationError(
50 48 'Cannot register authentication plugin twice: "%s"', plugin_id)
51 49 else:
52 50 log.debug('Register authentication plugin: "%s"', plugin_id)
53 51 self._plugins[plugin_id] = plugin
54 52
55 53 def get_plugins(self):
56 54 def sort_key(plugin):
57 55 return str.lower(safe_str(plugin.get_display_name()))
58 56
59 57 return sorted(self._plugins.values(), key=sort_key)
60 58
61 59 def get_plugin(self, plugin_id):
62 60 return self._plugins.get(plugin_id, None)
63 61
64 62 def get_plugin_by_uid(self, plugin_uid):
65 63 for plugin in self._plugins.values():
66 64 if plugin.uid == plugin_uid:
67 65 return plugin
68 66
69 67 def get_cache_call_method(self, cache=True):
70 68 region, _ns = self.get_cache_region()
71 69
72 70 @region.conditional_cache_on_arguments(condition=cache)
73 71 def _get_auth_plugins(name: str, key: str, fallback_plugin):
74 72 log.debug('auth-plugins: calculating plugins available for authentication')
75 73
76 74 _plugins = []
77 75 # Add all enabled and active plugins to the list. We iterate over the
78 76 # auth_plugins setting from DB because it also represents the ordering.
79 77 enabled_plugins = SettingsModel().get_auth_plugins()
80 78 raw_settings = SettingsModel().get_all_settings(cache=False)
81 79
82 80 for plugin_id in enabled_plugins:
83 81 plugin = self.get_plugin(plugin_id)
84 82 if plugin is not None and plugin.is_active(
85 83 plugin_cached_settings=raw_settings):
86 84
87 85 # inject settings into plugin, we can re-use the DB fetched settings here
88 86 plugin._settings = plugin._propagate_settings(raw_settings)
89 87 _plugins.append(plugin)
90 88
91 89 # Add the fallback plugin from ini file.
92 90 if fallback_plugin:
93 91 log.warning(
94 92 'Using fallback authentication plugin from INI file: "%s"',
95 93 fallback_plugin)
96 94 plugin = self.get_plugin(fallback_plugin)
97 95 if plugin is not None and plugin not in _plugins:
98 96 plugin._settings = plugin._propagate_settings(raw_settings)
99 97 _plugins.append(plugin)
100 98 return _plugins
101 99
102 100 return _get_auth_plugins
103 101
104 102 def get_plugins_for_authentication(self, cache=True):
105 103 """
106 104 Returns a list of plugins which should be consulted when authenticating
107 105 a user. It only returns plugins which are enabled and active.
108 106 Additionally, it includes the fallback plugin from the INI file, if
109 107 `rhodecode.auth_plugin_fallback` is set to a plugin ID.
110 108 """
111 109
112 110 _get_auth_plugins = self.get_cache_call_method(cache=cache)
113 111
114 112 start = time.time()
115 113 plugins = _get_auth_plugins('rhodecode_auth_plugins', 'v1', self._fallback_plugin)
116 114
117 115 compute_time = time.time() - start
118 116 log.debug('cached method:%s took %.4fs', _get_auth_plugins.__name__, compute_time)
119 117
120 118 statsd = StatsdClient.statsd
121 119 if statsd:
122 120 elapsed_time_ms = round(1000.0 * compute_time) # use ms only
123 121 statsd.timing("rhodecode_auth_plugins_timing.histogram", elapsed_time_ms,
124 122 use_decimals=False)
125 123
126 124 return plugins
127 125
128 126 @classmethod
129 127 def get_cache_region(cls):
130 128 cache_namespace_uid = 'auth_plugins'
131 129 region = rc_cache.get_or_create_region('cache_general', cache_namespace_uid)
132 130 return region, cache_namespace_uid
133 131
134 132 @classmethod
135 133 def invalidate_auth_plugins_cache(cls, hard=True):
136 134 region, namespace_key = cls.get_cache_region()
137 135 log.debug('Invalidation cache [%s] region %s for cache_key: %s',
138 136 'invalidate_auth_plugins_cache', region, namespace_key)
139 137
140 138 # we use hard cleanup if invalidation is sent
141 139 rc_cache.clear_cache_namespace(region, namespace_key, method=rc_cache.CLEAR_DELETE)
@@ -1,155 +1,153 b''
1
2
3 1 # Copyright (C) 2012-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 import logging
22 20 import collections
23 21
24 22 from pyramid.exceptions import ConfigurationError
25 23
26 24 from rhodecode.lib.utils2 import safe_str
27 25 from rhodecode.model.settings import SettingsModel
28 26 from rhodecode.translation import _
29 27
30 28
31 29 log = logging.getLogger(__name__)
32 30
33 31
34 32 class AuthnResourceBase(object):
35 33 __name__ = None
36 34 __parent__ = None
37 35
38 36 def get_root(self):
39 37 current = self
40 38 while current.__parent__ is not None:
41 39 current = current.__parent__
42 40 return current
43 41
44 42
45 43 class AuthnPluginResourceBase(AuthnResourceBase):
46 44
47 45 def __init__(self, plugin):
48 46 self.plugin = plugin
49 47 self.__name__ = plugin.get_url_slug()
50 48 self.display_name = plugin.get_display_name()
51 49
52 50
53 51 class AuthnRootResource(AuthnResourceBase):
54 52 """
55 53 This is the root traversal resource object for the authentication settings.
56 54 """
57 55
58 56 def __init__(self):
59 57 self._store = collections.OrderedDict()
60 58 self._resource_name_map = {}
61 59 self.display_name = _('Authentication Plugins')
62 60
63 61 def __getitem__(self, key):
64 62 """
65 63 Customized get item function to return only items (plugins) that are
66 64 activated.
67 65 """
68 66 if self._is_item_active(key):
69 67 return self._store[key]
70 68 else:
71 69 raise KeyError('Authentication plugin "{}" is not active.'.format(
72 70 key))
73 71
74 72 def __iter__(self):
75 73 for key in self._store.keys():
76 74 if self._is_item_active(key):
77 75 yield self._store[key]
78 76
79 77 def _is_item_active(self, key):
80 78 activated_plugins = SettingsModel().get_auth_plugins()
81 79 plugin_id = self.get_plugin_id(key)
82 80 return plugin_id in activated_plugins
83 81
84 82 def get_plugin_id(self, resource_name):
85 83 """
86 84 Return the plugin id for the given traversal resource name.
87 85 """
88 86 # TODO: Store this info in the resource element.
89 87 return self._resource_name_map[resource_name]
90 88
91 89 def get_sorted_list(self, sort_key=None):
92 90 """
93 91 Returns a sorted list of sub resources for displaying purposes.
94 92 """
95 93 def default_sort_key(resource):
96 94 return str.lower(safe_str(resource.display_name))
97 95
98 96 active = [item for item in self]
99 97 return sorted(active, key=sort_key or default_sort_key)
100 98
101 99 def get_nav_list(self, sort=True):
102 100 """
103 101 Returns a sorted list of resources for displaying the navigation.
104 102 """
105 103 if sort:
106 104 nav_list = self.get_sorted_list()
107 105 else:
108 106 nav_list = [item for item in self]
109 107
110 108 nav_list.insert(0, self)
111 109 return nav_list
112 110
113 111 def add_authn_resource(self, config, plugin_id, resource):
114 112 """
115 113 Register a traversal resource as a sub element to the authentication
116 114 settings. This method is registered as a directive on the pyramid
117 115 configurator object and called by plugins.
118 116 """
119 117
120 118 def _ensure_unique_name(name, limit=100):
121 119 counter = 1
122 120 current = name
123 121 while current in self._store.keys():
124 current = '{}{}'.format(name, counter)
122 current = f'{name}{counter}'
125 123 counter += 1
126 124 if counter > limit:
127 125 raise ConfigurationError(
128 126 'Cannot build unique name for traversal resource "%s" '
129 127 'registered by plugin "%s"', name, plugin_id)
130 128 return current
131 129
132 130 # Allow plugin resources with identical names by rename duplicates.
133 131 unique_name = _ensure_unique_name(resource.__name__)
134 132 if unique_name != resource.__name__:
135 133 log.warning('Name collision for traversal resource "%s" registered '
136 134 'by authentication plugin "%s"', resource.__name__,
137 135 plugin_id)
138 136 resource.__name__ = unique_name
139 137
140 138 log.debug('Register traversal resource "%s" for plugin "%s"',
141 139 unique_name, plugin_id)
142 140 self._resource_name_map[unique_name] = plugin_id
143 141 resource.__parent__ = self
144 142 self._store[unique_name] = resource
145 143
146 144
147 145 root = AuthnRootResource()
148 146
149 147
150 148 def root_factory(request=None):
151 149 """
152 150 Returns the root traversal resource instance used for the authentication
153 151 settings route.
154 152 """
155 153 return root
@@ -1,52 +1,50 b''
1
2
3 1 # Copyright (C) 2012-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 import colander
22 20
23 21 from rhodecode.authentication import plugin_default_auth_ttl
24 22 from rhodecode.translation import _
25 23
26 24
27 25 class AuthnPluginSettingsSchemaBase(colander.MappingSchema):
28 26 """
29 27 This base schema is intended for use in authentication plugins.
30 28 It adds a few default settings (e.g., "enabled"), so that plugin
31 29 authors don't have to maintain a bunch of boilerplate.
32 30 """
33 31 enabled = colander.SchemaNode(
34 32 colander.Bool(),
35 33 default=False,
36 34 description=_('Enable or disable this authentication plugin.'),
37 35 missing=False,
38 36 title=_('Enabled'),
39 37 widget='bool',
40 38 )
41 39 cache_ttl = colander.SchemaNode(
42 40 colander.Int(),
43 41 default=plugin_default_auth_ttl,
44 42 description=_('Amount of seconds to cache the authentication and '
45 43 'permissions check response call for this plugin. \n'
46 44 'Useful for expensive calls like LDAP to improve the '
47 45 'performance of the system (0 means disabled).'),
48 46 missing=0,
49 47 title=_('Auth Cache TTL'),
50 48 validator=colander.Range(min=0, max=None),
51 49 widget='int',
52 50 )
@@ -1,89 +1,87 b''
1
2
3 1 # Copyright (C) 2016-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19
22 20 import pytest
23 21
24 22
25 23 class EnabledAuthPlugin(object):
26 24 """
27 25 Context manager that updates the 'auth_plugins' setting in DB to enable
28 26 a plugin. Previous setting is restored on exit. The rhodecode auth plugin
29 27 is also enabled because it is needed to login the test users.
30 28 """
31 29
32 30 def __init__(self, plugin):
33 31 self.new_value = {'egg:rhodecode-enterprise-ce#rhodecode', plugin.get_id()}
34 32
35 33 def __enter__(self):
36 34 from rhodecode.model.settings import SettingsModel
37 35 self._old_value = SettingsModel().get_auth_plugins()
38 36 SettingsModel().create_or_update_setting(
39 37 'auth_plugins', ','.join(self.new_value))
40 38
41 39 def __exit__(self, type, value, traceback):
42 40 from rhodecode.model.settings import SettingsModel
43 41 SettingsModel().create_or_update_setting(
44 42 'auth_plugins', ','.join(self._old_value))
45 43
46 44
47 45 class DisabledAuthPlugin(object):
48 46 """
49 47 Context manager that updates the 'auth_plugins' setting in DB to disable
50 48 a plugin. Previous setting is restored on exit.
51 49 """
52 50
53 51 def __init__(self, plugin):
54 52 self.plugin_id = plugin.get_id()
55 53
56 54 def __enter__(self):
57 55 from rhodecode.model.settings import SettingsModel
58 56 self._old_value = SettingsModel().get_auth_plugins()
59 57 new_value = [id_ for id_ in self._old_value if id_ != self.plugin_id]
60 58 SettingsModel().create_or_update_setting(
61 59 'auth_plugins', ','.join(new_value))
62 60
63 61 def __exit__(self, type, value, traceback):
64 62 from rhodecode.model.settings import SettingsModel
65 63 SettingsModel().create_or_update_setting(
66 64 'auth_plugins', ','.join(self._old_value))
67 65
68 66
69 67 @pytest.fixture(params=[
70 68 ('rhodecode.authentication.plugins.auth_crowd', 'egg:rhodecode-enterprise-ce#crowd'),
71 69 ('rhodecode.authentication.plugins.auth_headers', 'egg:rhodecode-enterprise-ce#headers'),
72 70 ('rhodecode.authentication.plugins.auth_jasig_cas', 'egg:rhodecode-enterprise-ce#jasig_cas'),
73 71 ('rhodecode.authentication.plugins.auth_ldap', 'egg:rhodecode-enterprise-ce#ldap'),
74 72 ('rhodecode.authentication.plugins.auth_pam', 'egg:rhodecode-enterprise-ce#pam'),
75 73 ('rhodecode.authentication.plugins.auth_rhodecode', 'egg:rhodecode-enterprise-ce#rhodecode'),
76 74 ('rhodecode.authentication.plugins.auth_token', 'egg:rhodecode-enterprise-ce#token'),
77 75 ])
78 76 def auth_plugin(request):
79 77 """
80 78 Fixture that provides instance for each authentication plugin. These
81 79 instances are NOT the instances which are registered to the authentication
82 80 registry.
83 81 """
84 82 from importlib import import_module
85 83
86 84 # Create plugin instance.
87 85 module, plugin_id = request.param
88 86 plugin_module = import_module(module)
89 87 return plugin_module.plugin_factory(plugin_id)
@@ -1,77 +1,75 b''
1
2
3 1 # Copyright (C) 2016-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19
22 20 import pytest
23 21
24 22 from rhodecode.authentication.tests.conftest import (
25 23 EnabledAuthPlugin, DisabledAuthPlugin)
26 24 from rhodecode.apps._base import ADMIN_PREFIX
27 25
28 26
29 27 @pytest.mark.usefixtures('autologin_user', 'app')
30 28 class TestAuthenticationSettings:
31 29
32 30 def test_auth_settings_global_view_get(self, app):
33 url = '{prefix}/auth/'.format(prefix=ADMIN_PREFIX)
31 url = f'{ADMIN_PREFIX}/auth/'
34 32 response = app.get(url)
35 33 assert response.status_code == 200
36 34
37 35 def test_plugin_settings_view_get(self, app, auth_plugin):
38 36 url = '{prefix}/auth/{name}'.format(
39 37 prefix=ADMIN_PREFIX,
40 38 name=auth_plugin.name)
41 39 with EnabledAuthPlugin(auth_plugin):
42 40 response = app.get(url)
43 41 assert response.status_code == 200
44 42
45 43 def test_plugin_settings_view_post(self, app, auth_plugin, csrf_token):
46 44 url = '{prefix}/auth/{name}'.format(
47 45 prefix=ADMIN_PREFIX,
48 46 name=auth_plugin.name)
49 47 params = {
50 48 'enabled': True,
51 49 'cache_ttl': 0,
52 50 'csrf_token': csrf_token,
53 51 }
54 52 with EnabledAuthPlugin(auth_plugin):
55 53 response = app.post(url, params=params)
56 54 assert response.status_code in [200, 302]
57 55
58 56 def test_plugin_settings_view_get_404(self, app, auth_plugin):
59 57 url = '{prefix}/auth/{name}'.format(
60 58 prefix=ADMIN_PREFIX,
61 59 name=auth_plugin.name)
62 60 with DisabledAuthPlugin(auth_plugin):
63 61 response = app.get(url, status=404)
64 62 assert response.status_code == 404
65 63
66 64 def test_plugin_settings_view_post_404(self, app, auth_plugin, csrf_token):
67 65 url = '{prefix}/auth/{name}'.format(
68 66 prefix=ADMIN_PREFIX,
69 67 name=auth_plugin.name)
70 68 params = {
71 69 'enabled': True,
72 70 'cache_ttl': 0,
73 71 'csrf_token': csrf_token,
74 72 }
75 73 with DisabledAuthPlugin(auth_plugin):
76 74 response = app.post(url, params=params, status=404)
77 75 assert response.status_code == 404
@@ -1,180 +1,178 b''
1
2
3 1 # Copyright (C) 2012-2023 RhodeCode GmbH
4 2 #
5 3 # This program is free software: you can redistribute it and/or modify
6 4 # it under the terms of the GNU Affero General Public License, version 3
7 5 # (only), as published by the Free Software Foundation.
8 6 #
9 7 # This program is distributed in the hope that it will be useful,
10 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 10 # GNU General Public License for more details.
13 11 #
14 12 # You should have received a copy of the GNU Affero General Public License
15 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 14 #
17 15 # This program is dual-licensed. If you wish to learn more about the
18 16 # RhodeCode Enterprise Edition, including its added features, Support services,
19 17 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 18
21 19 import colander
22 20 import formencode.htmlfill
23 21 import logging
24 22
25 23 from pyramid.httpexceptions import HTTPFound
26 24 from pyramid.renderers import render
27 25 from pyramid.response import Response
28 26
29 27 from rhodecode.apps._base import BaseAppView
30 28 from rhodecode.authentication.base import get_authn_registry
31 29 from rhodecode.lib import helpers as h
32 30 from rhodecode.lib.auth import (
33 31 LoginRequired, HasPermissionAllDecorator, CSRFRequired)
34 32 from rhodecode.model.forms import AuthSettingsForm
35 33 from rhodecode.model.meta import Session
36 34 from rhodecode.model.settings import SettingsModel
37 35
38 36 log = logging.getLogger(__name__)
39 37
40 38
41 39 class AuthnPluginViewBase(BaseAppView):
42 40
43 41 def load_default_context(self):
44 42 c = self._get_local_tmpl_context()
45 43 self.plugin = self.context.plugin
46 44 return c
47 45
48 46 @LoginRequired()
49 47 @HasPermissionAllDecorator('hg.admin')
50 48 def settings_get(self, defaults=None, errors=None):
51 49 """
52 50 View that displays the plugin settings as a form.
53 51 """
54 52 c = self.load_default_context()
55 53 defaults = defaults or {}
56 54 errors = errors or {}
57 55 schema = self.plugin.get_settings_schema()
58 56
59 57 # Compute default values for the form. Priority is:
60 58 # 1. Passed to this method 2. DB value 3. Schema default
61 59 for node in schema:
62 60 if node.name not in defaults:
63 61 defaults[node.name] = self.plugin.get_setting_by_name(
64 62 node.name, node.default)
65 63
66 64 template_context = {
67 65 'defaults': defaults,
68 66 'errors': errors,
69 67 'plugin': self.context.plugin,
70 68 'resource': self.context,
71 69 }
72 70
73 71 return self._get_template_context(c, **template_context)
74 72
75 73 @LoginRequired()
76 74 @HasPermissionAllDecorator('hg.admin')
77 75 @CSRFRequired()
78 76 def settings_post(self):
79 77 """
80 78 View that validates and stores the plugin settings.
81 79 """
82 80 _ = self.request.translate
83 81 self.load_default_context()
84 82 schema = self.plugin.get_settings_schema()
85 83 data = self.request.params
86 84
87 85 try:
88 86 valid_data = schema.deserialize(data)
89 87 except colander.Invalid as e:
90 88 # Display error message and display form again.
91 89 h.flash(
92 90 _('Errors exist when saving plugin settings. '
93 91 'Please check the form inputs.'),
94 92 category='error')
95 93 defaults = {key: data[key] for key in data if key in schema}
96 94 return self.settings_get(errors=e.asdict(), defaults=defaults)
97 95
98 96 # Store validated data.
99 97 for name, value in valid_data.items():
100 98 self.plugin.create_or_update_setting(name, value)
101 99 Session().commit()
102 100 SettingsModel().invalidate_settings_cache()
103 101
104 102 # Display success message and redirect.
105 103 h.flash(_('Auth settings updated successfully.'), category='success')
106 104 redirect_to = self.request.resource_path(self.context, route_name='auth_home')
107 105
108 106 return HTTPFound(redirect_to)
109 107
110 108
111 109 class AuthSettingsView(BaseAppView):
112 110 def load_default_context(self):
113 111 c = self._get_local_tmpl_context()
114 112 return c
115 113
116 114 @LoginRequired()
117 115 @HasPermissionAllDecorator('hg.admin')
118 116 def index(self, defaults=None, errors=None, prefix_error=False):
119 117 c = self.load_default_context()
120 118
121 119 defaults = defaults or {}
122 120 authn_registry = get_authn_registry(self.request.registry)
123 121 enabled_plugins = SettingsModel().get_auth_plugins()
124 122
125 123 # Create template context and render it.
126 124 template_context = {
127 125 'resource': self.context,
128 126 'available_plugins': authn_registry.get_plugins(),
129 127 'enabled_plugins': enabled_plugins,
130 128 }
131 129 html = render('rhodecode:templates/admin/auth/auth_settings.mako',
132 130 self._get_template_context(c, **template_context),
133 131 self.request)
134 132
135 133 # Create form default values and fill the form.
136 134 form_defaults = {
137 135 'auth_plugins': ',\n'.join(enabled_plugins)
138 136 }
139 137 form_defaults.update(defaults)
140 138 html = formencode.htmlfill.render(
141 139 html,
142 140 defaults=form_defaults,
143 141 errors=errors,
144 142 prefix_error=prefix_error,
145 143 encoding="UTF-8",
146 144 force_defaults=False)
147 145
148 146 return Response(html)
149 147
150 148 @LoginRequired()
151 149 @HasPermissionAllDecorator('hg.admin')
152 150 @CSRFRequired()
153 151 def auth_settings(self):
154 152 _ = self.request.translate
155 153 try:
156 154 form = AuthSettingsForm(self.request.translate)()
157 155 form_result = form.to_python(self.request.POST)
158 156 plugins = ','.join(form_result['auth_plugins'])
159 157 setting = SettingsModel().create_or_update_setting(
160 158 'auth_plugins', plugins)
161 159 Session().add(setting)
162 160 Session().commit()
163 161 SettingsModel().invalidate_settings_cache()
164 162 h.flash(_('Auth settings updated successfully.'), category='success')
165 163 except formencode.Invalid as errors:
166 164 e = errors.error_dict or {}
167 165 h.flash(_('Errors exist when saving plugin setting. '
168 166 'Please check the form inputs.'), category='error')
169 167 return self.index(
170 168 defaults=errors.value,
171 169 errors=e,
172 170 prefix_error=False)
173 171 except Exception:
174 172 log.exception('Exception in auth_settings')
175 173 h.flash(_('Error occurred during update of auth settings.'),
176 174 category='error')
177 175
178 176 redirect_to = self.request.resource_path(self.context, route_name='auth_home')
179 177
180 178 return HTTPFound(redirect_to)
General Comments 0
You need to be logged in to leave comments. Login now