##// END OF EJS Templates
authentication: introduce a group sync flag for plugins....
marcink -
r2495:4f076134 default
parent child Browse files
Show More
@@ -1,713 +1,719 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 Authentication modules
23 23 """
24 24
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 from zope.cachedescriptors.property import Lazy as LazyProperty
35 35
36 36 from rhodecode.authentication.interface import IAuthnPluginRegistry
37 37 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
38 38 from rhodecode.lib import caches
39 39 from rhodecode.lib.auth import PasswordGenerator, _RhodeCodeCryptoBCrypt
40 40 from rhodecode.lib.utils2 import safe_int
41 41 from rhodecode.lib.utils2 import safe_str
42 42 from rhodecode.model.db import User
43 43 from rhodecode.model.meta import Session
44 44 from rhodecode.model.settings import SettingsModel
45 45 from rhodecode.model.user import UserModel
46 46 from rhodecode.model.user_group import UserGroupModel
47 47
48 48
49 49 log = logging.getLogger(__name__)
50 50
51 51 # auth types that authenticate() function can receive
52 52 VCS_TYPE = 'vcs'
53 53 HTTP_TYPE = 'http'
54 54
55 55
56 56 class hybrid_property(object):
57 57 """
58 58 a property decorator that works both for instance and class
59 59 """
60 60 def __init__(self, fget, fset=None, fdel=None, expr=None):
61 61 self.fget = fget
62 62 self.fset = fset
63 63 self.fdel = fdel
64 64 self.expr = expr or fget
65 65 functools.update_wrapper(self, fget)
66 66
67 67 def __get__(self, instance, owner):
68 68 if instance is None:
69 69 return self.expr(owner)
70 70 else:
71 71 return self.fget(instance)
72 72
73 73 def __set__(self, instance, value):
74 74 self.fset(instance, value)
75 75
76 76 def __delete__(self, instance):
77 77 self.fdel(instance)
78 78
79 79
80
81 80 class LazyFormencode(object):
82 81 def __init__(self, formencode_obj, *args, **kwargs):
83 82 self.formencode_obj = formencode_obj
84 83 self.args = args
85 84 self.kwargs = kwargs
86 85
87 86 def __call__(self, *args, **kwargs):
88 87 from inspect import isfunction
89 88 formencode_obj = self.formencode_obj
90 89 if isfunction(formencode_obj):
91 90 # case we wrap validators into functions
92 91 formencode_obj = self.formencode_obj(*args, **kwargs)
93 92 return formencode_obj(*self.args, **self.kwargs)
94 93
95 94
96 95 class RhodeCodeAuthPluginBase(object):
97 96 # cache the authentication request for N amount of seconds. Some kind
98 97 # of authentication methods are very heavy and it's very efficient to cache
99 98 # the result of a call. If it's set to None (default) cache is off
100 99 AUTH_CACHE_TTL = None
101 100 AUTH_CACHE = {}
102 101
103 102 auth_func_attrs = {
104 103 "username": "unique username",
105 104 "firstname": "first name",
106 105 "lastname": "last name",
107 106 "email": "email address",
108 107 "groups": '["list", "of", "groups"]',
108 "user_group_sync":
109 'True|False defines if returned user groups should be synced',
109 110 "extern_name": "name in external source of record",
110 111 "extern_type": "type of external source of record",
111 112 "admin": 'True|False defines if user should be RhodeCode super admin',
112 113 "active":
113 114 'True|False defines active state of user internally for RhodeCode',
114 115 "active_from_extern":
115 116 "True|False\None, active state from the external auth, "
116 117 "None means use definition from RhodeCode extern_type active value"
118
117 119 }
118 120 # set on authenticate() method and via set_auth_type func.
119 121 auth_type = None
120 122
121 123 # set on authenticate() method and via set_calling_scope_repo, this is a
122 124 # calling scope repository when doing authentication most likely on VCS
123 125 # operations
124 126 acl_repo_name = None
125 127
126 128 # List of setting names to store encrypted. Plugins may override this list
127 129 # to store settings encrypted.
128 130 _settings_encrypted = []
129 131
130 132 # Mapping of python to DB settings model types. Plugins may override or
131 133 # extend this mapping.
132 134 _settings_type_map = {
133 135 colander.String: 'unicode',
134 136 colander.Integer: 'int',
135 137 colander.Boolean: 'bool',
136 138 colander.List: 'list',
137 139 }
138 140
139 141 # list of keys in settings that are unsafe to be logged, should be passwords
140 142 # or other crucial credentials
141 143 _settings_unsafe_keys = []
142 144
143 145 def __init__(self, plugin_id):
144 146 self._plugin_id = plugin_id
145 147
146 148 def __str__(self):
147 149 return self.get_id()
148 150
149 151 def _get_setting_full_name(self, name):
150 152 """
151 153 Return the full setting name used for storing values in the database.
152 154 """
153 155 # TODO: johbo: Using the name here is problematic. It would be good to
154 156 # introduce either new models in the database to hold Plugin and
155 157 # PluginSetting or to use the plugin id here.
156 158 return 'auth_{}_{}'.format(self.name, name)
157 159
158 160 def _get_setting_type(self, name):
159 161 """
160 162 Return the type of a setting. This type is defined by the SettingsModel
161 163 and determines how the setting is stored in DB. Optionally the suffix
162 164 `.encrypted` is appended to instruct SettingsModel to store it
163 165 encrypted.
164 166 """
165 167 schema_node = self.get_settings_schema().get(name)
166 168 db_type = self._settings_type_map.get(
167 169 type(schema_node.typ), 'unicode')
168 170 if name in self._settings_encrypted:
169 171 db_type = '{}.encrypted'.format(db_type)
170 172 return db_type
171 173
172 174 @LazyProperty
173 175 def plugin_settings(self):
174 176 settings = SettingsModel().get_all_settings()
175 177 return settings
176 178
177 179 def is_enabled(self):
178 180 """
179 181 Returns true if this plugin is enabled. An enabled plugin can be
180 182 configured in the admin interface but it is not consulted during
181 183 authentication.
182 184 """
183 185 auth_plugins = SettingsModel().get_auth_plugins()
184 186 return self.get_id() in auth_plugins
185 187
186 188 def is_active(self):
187 189 """
188 190 Returns true if the plugin is activated. An activated plugin is
189 191 consulted during authentication, assumed it is also enabled.
190 192 """
191 193 return self.get_setting_by_name('enabled')
192 194
193 195 def get_id(self):
194 196 """
195 197 Returns the plugin id.
196 198 """
197 199 return self._plugin_id
198 200
199 201 def get_display_name(self):
200 202 """
201 203 Returns a translation string for displaying purposes.
202 204 """
203 205 raise NotImplementedError('Not implemented in base class')
204 206
205 207 def get_settings_schema(self):
206 208 """
207 209 Returns a colander schema, representing the plugin settings.
208 210 """
209 211 return AuthnPluginSettingsSchemaBase()
210 212
211 213 def get_setting_by_name(self, name, default=None, cache=True):
212 214 """
213 215 Returns a plugin setting by name.
214 216 """
215 217 full_name = 'rhodecode_{}'.format(self._get_setting_full_name(name))
216 218 if cache:
217 219 plugin_settings = self.plugin_settings
218 220 else:
219 221 plugin_settings = SettingsModel().get_all_settings()
220 222
221 223 if full_name in plugin_settings:
222 224 return plugin_settings[full_name]
223 225 else:
224 226 return default
225 227
226 228 def create_or_update_setting(self, name, value):
227 229 """
228 230 Create or update a setting for this plugin in the persistent storage.
229 231 """
230 232 full_name = self._get_setting_full_name(name)
231 233 type_ = self._get_setting_type(name)
232 234 db_setting = SettingsModel().create_or_update_setting(
233 235 full_name, value, type_)
234 236 return db_setting.app_settings_value
235 237
236 238 def get_settings(self):
237 239 """
238 240 Returns the plugin settings as dictionary.
239 241 """
240 242 settings = {}
241 243 for node in self.get_settings_schema():
242 244 settings[node.name] = self.get_setting_by_name(node.name)
243 245 return settings
244 246
245 247 def log_safe_settings(self, settings):
246 248 """
247 249 returns a log safe representation of settings, without any secrets
248 250 """
249 251 settings_copy = copy.deepcopy(settings)
250 252 for k in self._settings_unsafe_keys:
251 253 if k in settings_copy:
252 254 del settings_copy[k]
253 255 return settings_copy
254 256
255 257 @hybrid_property
256 258 def name(self):
257 259 """
258 260 Returns the name of this authentication plugin.
259 261
260 262 :returns: string
261 263 """
262 264 raise NotImplementedError("Not implemented in base class")
263 265
264 266 def get_url_slug(self):
265 267 """
266 268 Returns a slug which should be used when constructing URLs which refer
267 269 to this plugin. By default it returns the plugin name. If the name is
268 270 not suitable for using it in an URL the plugin should override this
269 271 method.
270 272 """
271 273 return self.name
272 274
273 275 @property
274 276 def is_headers_auth(self):
275 277 """
276 278 Returns True if this authentication plugin uses HTTP headers as
277 279 authentication method.
278 280 """
279 281 return False
280 282
281 283 @hybrid_property
282 284 def is_container_auth(self):
283 285 """
284 286 Deprecated method that indicates if this authentication plugin uses
285 287 HTTP headers as authentication method.
286 288 """
287 289 warnings.warn(
288 290 'Use is_headers_auth instead.', category=DeprecationWarning)
289 291 return self.is_headers_auth
290 292
291 293 @hybrid_property
292 294 def allows_creating_users(self):
293 295 """
294 296 Defines if Plugin allows users to be created on-the-fly when
295 297 authentication is called. Controls how external plugins should behave
296 298 in terms if they are allowed to create new users, or not. Base plugins
297 299 should not be allowed to, but External ones should be !
298 300
299 301 :return: bool
300 302 """
301 303 return False
302 304
303 305 def set_auth_type(self, auth_type):
304 306 self.auth_type = auth_type
305 307
306 308 def set_calling_scope_repo(self, acl_repo_name):
307 309 self.acl_repo_name = acl_repo_name
308 310
309 311 def allows_authentication_from(
310 312 self, user, allows_non_existing_user=True,
311 313 allowed_auth_plugins=None, allowed_auth_sources=None):
312 314 """
313 315 Checks if this authentication module should accept a request for
314 316 the current user.
315 317
316 318 :param user: user object fetched using plugin's get_user() method.
317 319 :param allows_non_existing_user: if True, don't allow the
318 320 user to be empty, meaning not existing in our database
319 321 :param allowed_auth_plugins: if provided, users extern_type will be
320 322 checked against a list of provided extern types, which are plugin
321 323 auth_names in the end
322 324 :param allowed_auth_sources: authentication type allowed,
323 325 `http` or `vcs` default is both.
324 326 defines if plugin will accept only http authentication vcs
325 327 authentication(git/hg) or both
326 328 :returns: boolean
327 329 """
328 330 if not user and not allows_non_existing_user:
329 331 log.debug('User is empty but plugin does not allow empty users,'
330 332 'not allowed to authenticate')
331 333 return False
332 334
333 335 expected_auth_plugins = allowed_auth_plugins or [self.name]
334 336 if user and (user.extern_type and
335 337 user.extern_type not in expected_auth_plugins):
336 338 log.debug(
337 339 'User `%s` is bound to `%s` auth type. Plugin allows only '
338 340 '%s, skipping', user, user.extern_type, expected_auth_plugins)
339 341
340 342 return False
341 343
342 344 # by default accept both
343 345 expected_auth_from = allowed_auth_sources or [HTTP_TYPE, VCS_TYPE]
344 346 if self.auth_type not in expected_auth_from:
345 347 log.debug('Current auth source is %s but plugin only allows %s',
346 348 self.auth_type, expected_auth_from)
347 349 return False
348 350
349 351 return True
350 352
351 353 def get_user(self, username=None, **kwargs):
352 354 """
353 355 Helper method for user fetching in plugins, by default it's using
354 356 simple fetch by username, but this method can be custimized in plugins
355 357 eg. headers auth plugin to fetch user by environ params
356 358
357 359 :param username: username if given to fetch from database
358 360 :param kwargs: extra arguments needed for user fetching.
359 361 """
360 362 user = None
361 363 log.debug(
362 364 'Trying to fetch user `%s` from RhodeCode database', username)
363 365 if username:
364 366 user = User.get_by_username(username)
365 367 if not user:
366 368 log.debug('User not found, fallback to fetch user in '
367 369 'case insensitive mode')
368 370 user = User.get_by_username(username, case_insensitive=True)
369 371 else:
370 372 log.debug('provided username:`%s` is empty skipping...', username)
371 373 if not user:
372 374 log.debug('User `%s` not found in database', username)
373 375 else:
374 376 log.debug('Got DB user:%s', user)
375 377 return user
376 378
377 379 def user_activation_state(self):
378 380 """
379 381 Defines user activation state when creating new users
380 382
381 383 :returns: boolean
382 384 """
383 385 raise NotImplementedError("Not implemented in base class")
384 386
385 387 def auth(self, userobj, username, passwd, settings, **kwargs):
386 388 """
387 389 Given a user object (which may be null), username, a plaintext
388 390 password, and a settings object (containing all the keys needed as
389 391 listed in settings()), authenticate this user's login attempt.
390 392
391 393 Return None on failure. On success, return a dictionary of the form:
392 394
393 395 see: RhodeCodeAuthPluginBase.auth_func_attrs
394 396 This is later validated for correctness
395 397 """
396 398 raise NotImplementedError("not implemented in base class")
397 399
398 400 def _authenticate(self, userobj, username, passwd, settings, **kwargs):
399 401 """
400 402 Wrapper to call self.auth() that validates call on it
401 403
402 404 :param userobj: userobj
403 405 :param username: username
404 406 :param passwd: plaintext password
405 407 :param settings: plugin settings
406 408 """
407 409 auth = self.auth(userobj, username, passwd, settings, **kwargs)
408 410 if auth:
409 411 auth['_plugin'] = self.name
410 412 auth['_ttl_cache'] = self.get_ttl_cache(settings)
411 413 # check if hash should be migrated ?
412 414 new_hash = auth.get('_hash_migrate')
413 415 if new_hash:
414 416 self._migrate_hash_to_bcrypt(username, passwd, new_hash)
417 if 'user_group_sync' not in auth:
418 auth['user_group_sync'] = False
415 419 return self._validate_auth_return(auth)
416
417 420 return auth
418 421
419 422 def _migrate_hash_to_bcrypt(self, username, password, new_hash):
420 423 new_hash_cypher = _RhodeCodeCryptoBCrypt()
421 424 # extra checks, so make sure new hash is correct.
422 425 password_encoded = safe_str(password)
423 426 if new_hash and new_hash_cypher.hash_check(
424 427 password_encoded, new_hash):
425 428 cur_user = User.get_by_username(username)
426 429 cur_user.password = new_hash
427 430 Session().add(cur_user)
428 431 Session().flush()
429 432 log.info('Migrated user %s hash to bcrypt', cur_user)
430 433
431 434 def _validate_auth_return(self, ret):
432 435 if not isinstance(ret, dict):
433 436 raise Exception('returned value from auth must be a dict')
434 437 for k in self.auth_func_attrs:
435 438 if k not in ret:
436 439 raise Exception('Missing %s attribute from returned data' % k)
437 440 return ret
438 441
439 442 def get_ttl_cache(self, settings=None):
440 443 plugin_settings = settings or self.get_settings()
441 444 cache_ttl = 0
442 445
443 446 if isinstance(self.AUTH_CACHE_TTL, (int, long)):
444 447 # plugin cache set inside is more important than the settings value
445 448 cache_ttl = self.AUTH_CACHE_TTL
446 449 elif plugin_settings.get('cache_ttl'):
447 450 cache_ttl = safe_int(plugin_settings.get('cache_ttl'), 0)
448 451
449 452 plugin_cache_active = bool(cache_ttl and cache_ttl > 0)
450 453 return plugin_cache_active, cache_ttl
451 454
452 455
453 456 class RhodeCodeExternalAuthPlugin(RhodeCodeAuthPluginBase):
454 457
455 458 @hybrid_property
456 459 def allows_creating_users(self):
457 460 return True
458 461
459 462 def use_fake_password(self):
460 463 """
461 464 Return a boolean that indicates whether or not we should set the user's
462 465 password to a random value when it is authenticated by this plugin.
463 466 If your plugin provides authentication, then you will generally
464 467 want this.
465 468
466 469 :returns: boolean
467 470 """
468 471 raise NotImplementedError("Not implemented in base class")
469 472
470 473 def _authenticate(self, userobj, username, passwd, settings, **kwargs):
471 474 # at this point _authenticate calls plugin's `auth()` function
472 475 auth = super(RhodeCodeExternalAuthPlugin, self)._authenticate(
473 476 userobj, username, passwd, settings, **kwargs)
474 477
475 478 if auth:
476 479 # maybe plugin will clean the username ?
477 480 # we should use the return value
478 481 username = auth['username']
479 482
480 483 # if external source tells us that user is not active, we should
481 484 # skip rest of the process. This can prevent from creating users in
482 485 # RhodeCode when using external authentication, but if it's
483 486 # inactive user we shouldn't create that user anyway
484 487 if auth['active_from_extern'] is False:
485 488 log.warning(
486 489 "User %s authenticated against %s, but is inactive",
487 490 username, self.__module__)
488 491 return None
489 492
490 493 cur_user = User.get_by_username(username, case_insensitive=True)
491 494 is_user_existing = cur_user is not None
492 495
493 496 if is_user_existing:
494 497 log.debug('Syncing user `%s` from '
495 498 '`%s` plugin', username, self.name)
496 499 else:
497 500 log.debug('Creating non existing user `%s` from '
498 501 '`%s` plugin', username, self.name)
499 502
500 503 if self.allows_creating_users:
501 504 log.debug('Plugin `%s` allows to '
502 505 'create new users', self.name)
503 506 else:
504 507 log.debug('Plugin `%s` does not allow to '
505 508 'create new users', self.name)
506 509
507 510 user_parameters = {
508 511 'username': username,
509 512 'email': auth["email"],
510 513 'firstname': auth["firstname"],
511 514 'lastname': auth["lastname"],
512 515 'active': auth["active"],
513 516 'admin': auth["admin"],
514 517 'extern_name': auth["extern_name"],
515 518 'extern_type': self.name,
516 519 'plugin': self,
517 520 'allow_to_create_user': self.allows_creating_users,
518 521 }
519 522
520 523 if not is_user_existing:
521 524 if self.use_fake_password():
522 525 # Randomize the PW because we don't need it, but don't want
523 526 # them blank either
524 527 passwd = PasswordGenerator().gen_password(length=16)
525 528 user_parameters['password'] = passwd
526 529 else:
527 530 # Since the password is required by create_or_update method of
528 531 # UserModel, we need to set it explicitly.
529 532 # The create_or_update method is smart and recognises the
530 533 # password hashes as well.
531 534 user_parameters['password'] = cur_user.password
532 535
533 536 # we either create or update users, we also pass the flag
534 537 # that controls if this method can actually do that.
535 538 # raises NotAllowedToCreateUserError if it cannot, and we try to.
536 539 user = UserModel().create_or_update(**user_parameters)
537 540 Session().flush()
538 541 # enforce user is just in given groups, all of them has to be ones
539 542 # created from plugins. We store this info in _group_data JSON
540 543 # field
541 try:
542 groups = auth['groups'] or []
543 log.debug(
544 'Performing user_group sync based on set `%s` '
545 'returned by this plugin', groups)
546 UserGroupModel().enforce_groups(user, groups, self.name)
547 except Exception:
548 # for any reason group syncing fails, we should
549 # proceed with login
550 log.error(traceback.format_exc())
544
545 if auth['user_group_sync']:
546 try:
547 groups = auth['groups'] or []
548 log.debug(
549 'Performing user_group sync based on set `%s` '
550 'returned by `%s` plugin', groups, self.name)
551 UserGroupModel().enforce_groups(user, groups, self.name)
552 except Exception:
553 # for any reason group syncing fails, we should
554 # proceed with login
555 log.error(traceback.format_exc())
556
551 557 Session().commit()
552 558 return auth
553 559
554 560
555 561 def loadplugin(plugin_id):
556 562 """
557 563 Loads and returns an instantiated authentication plugin.
558 564 Returns the RhodeCodeAuthPluginBase subclass on success,
559 565 or None on failure.
560 566 """
561 567 # TODO: Disusing pyramids thread locals to retrieve the registry.
562 568 authn_registry = get_authn_registry()
563 569 plugin = authn_registry.get_plugin(plugin_id)
564 570 if plugin is None:
565 571 log.error('Authentication plugin not found: "%s"', plugin_id)
566 572 return plugin
567 573
568 574
569 575 def get_authn_registry(registry=None):
570 576 registry = registry or get_current_registry()
571 577 authn_registry = registry.getUtility(IAuthnPluginRegistry)
572 578 return authn_registry
573 579
574 580
575 581 def get_auth_cache_manager(custom_ttl=None):
576 582 return caches.get_cache_manager(
577 583 'auth_plugins', 'rhodecode.authentication', custom_ttl)
578 584
579 585
580 586 def get_perms_cache_manager(custom_ttl=None):
581 587 return caches.get_cache_manager(
582 588 'auth_plugins', 'rhodecode.permissions', custom_ttl)
583 589
584 590
585 591 def authenticate(username, password, environ=None, auth_type=None,
586 592 skip_missing=False, registry=None, acl_repo_name=None):
587 593 """
588 594 Authentication function used for access control,
589 595 It tries to authenticate based on enabled authentication modules.
590 596
591 597 :param username: username can be empty for headers auth
592 598 :param password: password can be empty for headers auth
593 599 :param environ: environ headers passed for headers auth
594 600 :param auth_type: type of authentication, either `HTTP_TYPE` or `VCS_TYPE`
595 601 :param skip_missing: ignores plugins that are in db but not in environment
596 602 :returns: None if auth failed, plugin_user dict if auth is correct
597 603 """
598 604 if not auth_type or auth_type not in [HTTP_TYPE, VCS_TYPE]:
599 605 raise ValueError('auth type must be on of http, vcs got "%s" instead'
600 606 % auth_type)
601 607 headers_only = environ and not (username and password)
602 608
603 609 authn_registry = get_authn_registry(registry)
604 610 plugins_to_check = authn_registry.get_plugins_for_authentication()
605 611 log.debug('Starting ordered authentication chain using %s plugins',
606 612 plugins_to_check)
607 613 for plugin in plugins_to_check:
608 614 plugin.set_auth_type(auth_type)
609 615 plugin.set_calling_scope_repo(acl_repo_name)
610 616
611 617 if headers_only and not plugin.is_headers_auth:
612 618 log.debug('Auth type is for headers only and plugin `%s` is not '
613 619 'headers plugin, skipping...', plugin.get_id())
614 620 continue
615 621
616 622 # load plugin settings from RhodeCode database
617 623 plugin_settings = plugin.get_settings()
618 624 plugin_sanitized_settings = plugin.log_safe_settings(plugin_settings)
619 625 log.debug('Plugin settings:%s', plugin_sanitized_settings)
620 626
621 627 log.debug('Trying authentication using ** %s **', plugin.get_id())
622 628 # use plugin's method of user extraction.
623 629 user = plugin.get_user(username, environ=environ,
624 630 settings=plugin_settings)
625 631 display_user = user.username if user else username
626 632 log.debug(
627 633 'Plugin %s extracted user is `%s`', plugin.get_id(), display_user)
628 634
629 635 if not plugin.allows_authentication_from(user):
630 636 log.debug('Plugin %s does not accept user `%s` for authentication',
631 637 plugin.get_id(), display_user)
632 638 continue
633 639 else:
634 640 log.debug('Plugin %s accepted user `%s` for authentication',
635 641 plugin.get_id(), display_user)
636 642
637 643 log.info('Authenticating user `%s` using %s plugin',
638 644 display_user, plugin.get_id())
639 645
640 646 plugin_cache_active, cache_ttl = plugin.get_ttl_cache(plugin_settings)
641 647
642 648 # get instance of cache manager configured for a namespace
643 649 cache_manager = get_auth_cache_manager(custom_ttl=cache_ttl)
644 650
645 651 log.debug('AUTH_CACHE_TTL for plugin `%s` active: %s (TTL: %s)',
646 652 plugin.get_id(), plugin_cache_active, cache_ttl)
647 653
648 654 # for environ based password can be empty, but then the validation is
649 655 # on the server that fills in the env data needed for authentication
650 656
651 657 _password_hash = caches.compute_key_from_params(
652 658 plugin.name, username, (password or ''))
653 659
654 660 # _authenticate is a wrapper for .auth() method of plugin.
655 661 # it checks if .auth() sends proper data.
656 662 # For RhodeCodeExternalAuthPlugin it also maps users to
657 663 # Database and maps the attributes returned from .auth()
658 664 # to RhodeCode database. If this function returns data
659 665 # then auth is correct.
660 666 start = time.time()
661 667 log.debug('Running plugin `%s` _authenticate method', plugin.get_id())
662 668
663 669 def auth_func():
664 670 """
665 671 This function is used internally in Cache of Beaker to calculate
666 672 Results
667 673 """
668 674 log.debug('auth: calculating password access now...')
669 675 return plugin._authenticate(
670 676 user, username, password, plugin_settings,
671 677 environ=environ or {})
672 678
673 679 if plugin_cache_active:
674 log.debug('Trying to fetch cached auth by %s', _password_hash[:6])
680 log.debug('Trying to fetch cached auth by `...%s`', _password_hash[:6])
675 681 plugin_user = cache_manager.get(
676 682 _password_hash, createfunc=auth_func)
677 683 else:
678 684 plugin_user = auth_func()
679 685
680 686 auth_time = time.time() - start
681 687 log.debug('Authentication for plugin `%s` completed in %.3fs, '
682 688 'expiration time of fetched cache %.1fs.',
683 689 plugin.get_id(), auth_time, cache_ttl)
684 690
685 691 log.debug('PLUGIN USER DATA: %s', plugin_user)
686 692
687 693 if plugin_user:
688 694 log.debug('Plugin returned proper authentication data')
689 695 return plugin_user
690 696 # we failed to Auth because .auth() method didn't return proper user
691 697 log.debug("User `%s` failed to authenticate against %s",
692 698 display_user, plugin.get_id())
693 699
694 700 # case when we failed to authenticate against all defined plugins
695 701 return None
696 702
697 703
698 704 def chop_at(s, sub, inclusive=False):
699 705 """Truncate string ``s`` at the first occurrence of ``sub``.
700 706
701 707 If ``inclusive`` is true, truncate just after ``sub`` rather than at it.
702 708
703 709 >>> chop_at("plutocratic brats", "rat")
704 710 'plutoc'
705 711 >>> chop_at("plutocratic brats", "rat", True)
706 712 'plutocrat'
707 713 """
708 714 pos = s.find(sub)
709 715 if pos == -1:
710 716 return s
711 717 if inclusive:
712 718 return s[:pos+len(sub)]
713 719 return s[:pos]
@@ -1,284 +1,285 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 RhodeCode authentication plugin for Atlassian CROWD
23 23 """
24 24
25 25
26 26 import colander
27 27 import base64
28 28 import logging
29 29 import urllib2
30 30
31 31 from rhodecode.translation import _
32 32 from rhodecode.authentication.base import (
33 33 RhodeCodeExternalAuthPlugin, hybrid_property)
34 34 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
35 35 from rhodecode.authentication.routes import AuthnPluginResourceBase
36 36 from rhodecode.lib.colander_utils import strip_whitespace
37 37 from rhodecode.lib.ext_json import json, formatted_json
38 38 from rhodecode.model.db import User
39 39
40 40 log = logging.getLogger(__name__)
41 41
42 42
43 43 def plugin_factory(plugin_id, *args, **kwds):
44 44 """
45 45 Factory function that is called during plugin discovery.
46 46 It returns the plugin instance.
47 47 """
48 48 plugin = RhodeCodeAuthPlugin(plugin_id)
49 49 return plugin
50 50
51 51
52 52 class CrowdAuthnResource(AuthnPluginResourceBase):
53 53 pass
54 54
55 55
56 56 class CrowdSettingsSchema(AuthnPluginSettingsSchemaBase):
57 57 host = colander.SchemaNode(
58 58 colander.String(),
59 59 default='127.0.0.1',
60 60 description=_('The FQDN or IP of the Atlassian CROWD Server'),
61 61 preparer=strip_whitespace,
62 62 title=_('Host'),
63 63 widget='string')
64 64 port = colander.SchemaNode(
65 65 colander.Int(),
66 66 default=8095,
67 67 description=_('The Port in use by the Atlassian CROWD Server'),
68 68 preparer=strip_whitespace,
69 69 title=_('Port'),
70 70 validator=colander.Range(min=0, max=65536),
71 71 widget='int')
72 72 app_name = colander.SchemaNode(
73 73 colander.String(),
74 74 default='',
75 75 description=_('The Application Name to authenticate to CROWD'),
76 76 preparer=strip_whitespace,
77 77 title=_('Application Name'),
78 78 widget='string')
79 79 app_password = colander.SchemaNode(
80 80 colander.String(),
81 81 default='',
82 82 description=_('The password to authenticate to CROWD'),
83 83 preparer=strip_whitespace,
84 84 title=_('Application Password'),
85 85 widget='password')
86 86 admin_groups = colander.SchemaNode(
87 87 colander.String(),
88 88 default='',
89 89 description=_('A comma separated list of group names that identify '
90 90 'users as RhodeCode Administrators'),
91 91 missing='',
92 92 preparer=strip_whitespace,
93 93 title=_('Admin Groups'),
94 94 widget='string')
95 95
96 96
97 97 class CrowdServer(object):
98 98 def __init__(self, *args, **kwargs):
99 99 """
100 100 Create a new CrowdServer object that points to IP/Address 'host',
101 101 on the given port, and using the given method (https/http). user and
102 102 passwd can be set here or with set_credentials. If unspecified,
103 103 "version" defaults to "latest".
104 104
105 105 example::
106 106
107 107 cserver = CrowdServer(host="127.0.0.1",
108 108 port="8095",
109 109 user="some_app",
110 110 passwd="some_passwd",
111 111 version="1")
112 112 """
113 113 if not "port" in kwargs:
114 114 kwargs["port"] = "8095"
115 115 self._logger = kwargs.get("logger", logging.getLogger(__name__))
116 116 self._uri = "%s://%s:%s/crowd" % (kwargs.get("method", "http"),
117 117 kwargs.get("host", "127.0.0.1"),
118 118 kwargs.get("port", "8095"))
119 119 self.set_credentials(kwargs.get("user", ""),
120 120 kwargs.get("passwd", ""))
121 121 self._version = kwargs.get("version", "latest")
122 122 self._url_list = None
123 123 self._appname = "crowd"
124 124
125 125 def set_credentials(self, user, passwd):
126 126 self.user = user
127 127 self.passwd = passwd
128 128 self._make_opener()
129 129
130 130 def _make_opener(self):
131 131 mgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
132 132 mgr.add_password(None, self._uri, self.user, self.passwd)
133 133 handler = urllib2.HTTPBasicAuthHandler(mgr)
134 134 self.opener = urllib2.build_opener(handler)
135 135
136 136 def _request(self, url, body=None, headers=None,
137 137 method=None, noformat=False,
138 138 empty_response_ok=False):
139 139 _headers = {"Content-type": "application/json",
140 140 "Accept": "application/json"}
141 141 if self.user and self.passwd:
142 142 authstring = base64.b64encode("%s:%s" % (self.user, self.passwd))
143 143 _headers["Authorization"] = "Basic %s" % authstring
144 144 if headers:
145 145 _headers.update(headers)
146 146 log.debug("Sent crowd: \n%s"
147 147 % (formatted_json({"url": url, "body": body,
148 148 "headers": _headers})))
149 149 request = urllib2.Request(url, body, _headers)
150 150 if method:
151 151 request.get_method = lambda: method
152 152
153 153 global msg
154 154 msg = ""
155 155 try:
156 156 rdoc = self.opener.open(request)
157 157 msg = "".join(rdoc.readlines())
158 158 if not msg and empty_response_ok:
159 159 rval = {}
160 160 rval["status"] = True
161 161 rval["error"] = "Response body was empty"
162 162 elif not noformat:
163 163 rval = json.loads(msg)
164 164 rval["status"] = True
165 165 else:
166 166 rval = "".join(rdoc.readlines())
167 167 except Exception as e:
168 168 if not noformat:
169 169 rval = {"status": False,
170 170 "body": body,
171 171 "error": str(e) + "\n" + msg}
172 172 else:
173 173 rval = None
174 174 return rval
175 175
176 176 def user_auth(self, username, password):
177 177 """Authenticate a user against crowd. Returns brief information about
178 178 the user."""
179 179 url = ("%s/rest/usermanagement/%s/authentication?username=%s"
180 180 % (self._uri, self._version, username))
181 181 body = json.dumps({"value": password})
182 182 return self._request(url, body)
183 183
184 184 def user_groups(self, username):
185 185 """Retrieve a list of groups to which this user belongs."""
186 186 url = ("%s/rest/usermanagement/%s/user/group/nested?username=%s"
187 187 % (self._uri, self._version, username))
188 188 return self._request(url)
189 189
190 190
191 191 class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin):
192 192 _settings_unsafe_keys = ['app_password']
193 193
194 194 def includeme(self, config):
195 195 config.add_authn_plugin(self)
196 196 config.add_authn_resource(self.get_id(), CrowdAuthnResource(self))
197 197 config.add_view(
198 198 'rhodecode.authentication.views.AuthnPluginViewBase',
199 199 attr='settings_get',
200 200 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
201 201 request_method='GET',
202 202 route_name='auth_home',
203 203 context=CrowdAuthnResource)
204 204 config.add_view(
205 205 'rhodecode.authentication.views.AuthnPluginViewBase',
206 206 attr='settings_post',
207 207 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
208 208 request_method='POST',
209 209 route_name='auth_home',
210 210 context=CrowdAuthnResource)
211 211
212 212 def get_settings_schema(self):
213 213 return CrowdSettingsSchema()
214 214
215 215 def get_display_name(self):
216 216 return _('CROWD')
217 217
218 218 @hybrid_property
219 219 def name(self):
220 220 return "crowd"
221 221
222 222 def use_fake_password(self):
223 223 return True
224 224
225 225 def user_activation_state(self):
226 226 def_user_perms = User.get_default_user().AuthUser().permissions['global']
227 227 return 'hg.extern_activate.auto' in def_user_perms
228 228
229 229 def auth(self, userobj, username, password, settings, **kwargs):
230 230 """
231 231 Given a user object (which may be null), username, a plaintext password,
232 232 and a settings object (containing all the keys needed as listed in settings()),
233 233 authenticate this user's login attempt.
234 234
235 235 Return None on failure. On success, return a dictionary of the form:
236 236
237 237 see: RhodeCodeAuthPluginBase.auth_func_attrs
238 238 This is later validated for correctness
239 239 """
240 240 if not username or not password:
241 241 log.debug('Empty username or password skipping...')
242 242 return None
243 243
244 244 log.debug("Crowd settings: \n%s" % (formatted_json(settings)))
245 245 server = CrowdServer(**settings)
246 246 server.set_credentials(settings["app_name"], settings["app_password"])
247 247 crowd_user = server.user_auth(username, password)
248 248 log.debug("Crowd returned: \n%s" % (formatted_json(crowd_user)))
249 249 if not crowd_user["status"]:
250 250 return None
251 251
252 252 res = server.user_groups(crowd_user["name"])
253 253 log.debug("Crowd groups: \n%s" % (formatted_json(res)))
254 254 crowd_user["groups"] = [x["name"] for x in res["groups"]]
255 255
256 256 # old attrs fetched from RhodeCode database
257 257 admin = getattr(userobj, 'admin', False)
258 258 active = getattr(userobj, 'active', True)
259 259 email = getattr(userobj, 'email', '')
260 260 username = getattr(userobj, 'username', username)
261 261 firstname = getattr(userobj, 'firstname', '')
262 262 lastname = getattr(userobj, 'lastname', '')
263 263 extern_type = getattr(userobj, 'extern_type', '')
264 264
265 265 user_attrs = {
266 266 'username': username,
267 267 'firstname': crowd_user["first-name"] or firstname,
268 268 'lastname': crowd_user["last-name"] or lastname,
269 269 'groups': crowd_user["groups"],
270 'user_group_sync': True,
270 271 'email': crowd_user["email"] or email,
271 272 'admin': admin,
272 273 'active': active,
273 274 'active_from_extern': crowd_user.get('active'),
274 275 'extern_name': crowd_user["name"],
275 276 'extern_type': extern_type,
276 277 }
277 278
278 279 # set an admin if we're in admin_groups of crowd
279 280 for group in settings["admin_groups"]:
280 281 if group in user_attrs["groups"]:
281 282 user_attrs["admin"] = True
282 283 log.debug("Final crowd user object: \n%s" % (formatted_json(user_attrs)))
283 284 log.info('user %s authenticated correctly' % user_attrs['username'])
284 285 return user_attrs
@@ -1,224 +1,225 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 import colander
22 22 import logging
23 23
24 24 from rhodecode.translation import _
25 25 from rhodecode.authentication.base import (
26 26 RhodeCodeExternalAuthPlugin, hybrid_property)
27 27 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
28 28 from rhodecode.authentication.routes import AuthnPluginResourceBase
29 29 from rhodecode.lib.colander_utils import strip_whitespace
30 30 from rhodecode.lib.utils2 import str2bool, safe_unicode
31 31 from rhodecode.model.db import User
32 32
33 33
34 34 log = logging.getLogger(__name__)
35 35
36 36
37 37 def plugin_factory(plugin_id, *args, **kwds):
38 38 """
39 39 Factory function that is called during plugin discovery.
40 40 It returns the plugin instance.
41 41 """
42 42 plugin = RhodeCodeAuthPlugin(plugin_id)
43 43 return plugin
44 44
45 45
46 46 class HeadersAuthnResource(AuthnPluginResourceBase):
47 47 pass
48 48
49 49
50 50 class HeadersSettingsSchema(AuthnPluginSettingsSchemaBase):
51 51 header = colander.SchemaNode(
52 52 colander.String(),
53 53 default='REMOTE_USER',
54 54 description=_('Header to extract the user from'),
55 55 preparer=strip_whitespace,
56 56 title=_('Header'),
57 57 widget='string')
58 58 fallback_header = colander.SchemaNode(
59 59 colander.String(),
60 60 default='HTTP_X_FORWARDED_USER',
61 61 description=_('Header to extract the user from when main one fails'),
62 62 preparer=strip_whitespace,
63 63 title=_('Fallback header'),
64 64 widget='string')
65 65 clean_username = colander.SchemaNode(
66 66 colander.Boolean(),
67 67 default=True,
68 68 description=_('Perform cleaning of user, if passed user has @ in '
69 69 'username then first part before @ is taken. '
70 70 'If there\'s \\ in the username only the part after '
71 71 ' \\ is taken'),
72 72 missing=False,
73 73 title=_('Clean username'),
74 74 widget='bool')
75 75
76 76
77 77 class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin):
78 78
79 79 def includeme(self, config):
80 80 config.add_authn_plugin(self)
81 81 config.add_authn_resource(self.get_id(), HeadersAuthnResource(self))
82 82 config.add_view(
83 83 'rhodecode.authentication.views.AuthnPluginViewBase',
84 84 attr='settings_get',
85 85 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
86 86 request_method='GET',
87 87 route_name='auth_home',
88 88 context=HeadersAuthnResource)
89 89 config.add_view(
90 90 'rhodecode.authentication.views.AuthnPluginViewBase',
91 91 attr='settings_post',
92 92 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
93 93 request_method='POST',
94 94 route_name='auth_home',
95 95 context=HeadersAuthnResource)
96 96
97 97 def get_display_name(self):
98 98 return _('Headers')
99 99
100 100 def get_settings_schema(self):
101 101 return HeadersSettingsSchema()
102 102
103 103 @hybrid_property
104 104 def name(self):
105 105 return 'headers'
106 106
107 107 @property
108 108 def is_headers_auth(self):
109 109 return True
110 110
111 111 def use_fake_password(self):
112 112 return True
113 113
114 114 def user_activation_state(self):
115 115 def_user_perms = User.get_default_user().AuthUser().permissions['global']
116 116 return 'hg.extern_activate.auto' in def_user_perms
117 117
118 118 def _clean_username(self, username):
119 119 # Removing realm and domain from username
120 120 username = username.split('@')[0]
121 121 username = username.rsplit('\\')[-1]
122 122 return username
123 123
124 124 def _get_username(self, environ, settings):
125 125 username = None
126 126 environ = environ or {}
127 127 if not environ:
128 128 log.debug('got empty environ: %s' % environ)
129 129
130 130 settings = settings or {}
131 131 if settings.get('header'):
132 132 header = settings.get('header')
133 133 username = environ.get(header)
134 134 log.debug('extracted %s:%s' % (header, username))
135 135
136 136 # fallback mode
137 137 if not username and settings.get('fallback_header'):
138 138 header = settings.get('fallback_header')
139 139 username = environ.get(header)
140 140 log.debug('extracted %s:%s' % (header, username))
141 141
142 142 if username and str2bool(settings.get('clean_username')):
143 143 log.debug('Received username `%s` from headers' % username)
144 144 username = self._clean_username(username)
145 145 log.debug('New cleanup user is:%s' % username)
146 146 return username
147 147
148 148 def get_user(self, username=None, **kwargs):
149 149 """
150 150 Helper method for user fetching in plugins, by default it's using
151 151 simple fetch by username, but this method can be custimized in plugins
152 152 eg. headers auth plugin to fetch user by environ params
153 153 :param username: username if given to fetch
154 154 :param kwargs: extra arguments needed for user fetching.
155 155 """
156 156 environ = kwargs.get('environ') or {}
157 157 settings = kwargs.get('settings') or {}
158 158 username = self._get_username(environ, settings)
159 159 # we got the username, so use default method now
160 160 return super(RhodeCodeAuthPlugin, self).get_user(username)
161 161
162 162 def auth(self, userobj, username, password, settings, **kwargs):
163 163 """
164 164 Get's the headers_auth username (or email). It tries to get username
165 165 from REMOTE_USER if this plugin is enabled, if that fails
166 166 it tries to get username from HTTP_X_FORWARDED_USER if fallback header
167 167 is set. clean_username extracts the username from this data if it's
168 168 having @ in it.
169 169 Return None on failure. On success, return a dictionary of the form:
170 170
171 171 see: RhodeCodeAuthPluginBase.auth_func_attrs
172 172
173 173 :param userobj:
174 174 :param username:
175 175 :param password:
176 176 :param settings:
177 177 :param kwargs:
178 178 """
179 179 environ = kwargs.get('environ')
180 180 if not environ:
181 181 log.debug('Empty environ data skipping...')
182 182 return None
183 183
184 184 if not userobj:
185 185 userobj = self.get_user('', environ=environ, settings=settings)
186 186
187 187 # we don't care passed username/password for headers auth plugins.
188 188 # only way to log in is using environ
189 189 username = None
190 190 if userobj:
191 191 username = getattr(userobj, 'username')
192 192
193 193 if not username:
194 194 # we don't have any objects in DB user doesn't exist extract
195 195 # username from environ based on the settings
196 196 username = self._get_username(environ, settings)
197 197
198 198 # if cannot fetch username, it's a no-go for this plugin to proceed
199 199 if not username:
200 200 return None
201 201
202 202 # old attrs fetched from RhodeCode database
203 203 admin = getattr(userobj, 'admin', False)
204 204 active = getattr(userobj, 'active', True)
205 205 email = getattr(userobj, 'email', '')
206 206 firstname = getattr(userobj, 'firstname', '')
207 207 lastname = getattr(userobj, 'lastname', '')
208 208 extern_type = getattr(userobj, 'extern_type', '')
209 209
210 210 user_attrs = {
211 211 'username': username,
212 212 'firstname': safe_unicode(firstname or username),
213 213 'lastname': safe_unicode(lastname or ''),
214 214 'groups': [],
215 'user_group_sync': False,
215 216 'email': email or '',
216 217 'admin': admin or False,
217 218 'active': active,
218 219 'active_from_extern': True,
219 220 'extern_name': username,
220 221 'extern_type': extern_type,
221 222 }
222 223
223 224 log.info('user `%s` authenticated correctly' % user_attrs['username'])
224 225 return user_attrs
@@ -1,166 +1,167 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 RhodeCode authentication plugin for Jasig CAS
23 23 http://www.jasig.org/cas
24 24 """
25 25
26 26
27 27 import colander
28 28 import logging
29 29 import rhodecode
30 30 import urllib
31 31 import urllib2
32 32
33 33 from rhodecode.translation import _
34 34 from rhodecode.authentication.base import (
35 35 RhodeCodeExternalAuthPlugin, hybrid_property)
36 36 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
37 37 from rhodecode.authentication.routes import AuthnPluginResourceBase
38 38 from rhodecode.lib.colander_utils import strip_whitespace
39 39 from rhodecode.lib.utils2 import safe_unicode
40 40 from rhodecode.model.db import User
41 41
42 42 log = logging.getLogger(__name__)
43 43
44 44
45 45 def plugin_factory(plugin_id, *args, **kwds):
46 46 """
47 47 Factory function that is called during plugin discovery.
48 48 It returns the plugin instance.
49 49 """
50 50 plugin = RhodeCodeAuthPlugin(plugin_id)
51 51 return plugin
52 52
53 53
54 54 class JasigCasAuthnResource(AuthnPluginResourceBase):
55 55 pass
56 56
57 57
58 58 class JasigCasSettingsSchema(AuthnPluginSettingsSchemaBase):
59 59 service_url = colander.SchemaNode(
60 60 colander.String(),
61 61 default='https://domain.com/cas/v1/tickets',
62 62 description=_('The url of the Jasig CAS REST service'),
63 63 preparer=strip_whitespace,
64 64 title=_('URL'),
65 65 widget='string')
66 66
67 67
68 68 class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin):
69 69
70 70 def includeme(self, config):
71 71 config.add_authn_plugin(self)
72 72 config.add_authn_resource(self.get_id(), JasigCasAuthnResource(self))
73 73 config.add_view(
74 74 'rhodecode.authentication.views.AuthnPluginViewBase',
75 75 attr='settings_get',
76 76 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
77 77 request_method='GET',
78 78 route_name='auth_home',
79 79 context=JasigCasAuthnResource)
80 80 config.add_view(
81 81 'rhodecode.authentication.views.AuthnPluginViewBase',
82 82 attr='settings_post',
83 83 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
84 84 request_method='POST',
85 85 route_name='auth_home',
86 86 context=JasigCasAuthnResource)
87 87
88 88 def get_settings_schema(self):
89 89 return JasigCasSettingsSchema()
90 90
91 91 def get_display_name(self):
92 92 return _('Jasig-CAS')
93 93
94 94 @hybrid_property
95 95 def name(self):
96 96 return "jasig-cas"
97 97
98 98 @property
99 99 def is_headers_auth(self):
100 100 return True
101 101
102 102 def use_fake_password(self):
103 103 return True
104 104
105 105 def user_activation_state(self):
106 106 def_user_perms = User.get_default_user().AuthUser().permissions['global']
107 107 return 'hg.extern_activate.auto' in def_user_perms
108 108
109 109 def auth(self, userobj, username, password, settings, **kwargs):
110 110 """
111 111 Given a user object (which may be null), username, a plaintext password,
112 112 and a settings object (containing all the keys needed as listed in settings()),
113 113 authenticate this user's login attempt.
114 114
115 115 Return None on failure. On success, return a dictionary of the form:
116 116
117 117 see: RhodeCodeAuthPluginBase.auth_func_attrs
118 118 This is later validated for correctness
119 119 """
120 120 if not username or not password:
121 121 log.debug('Empty username or password skipping...')
122 122 return None
123 123
124 124 log.debug("Jasig CAS settings: %s", settings)
125 125 params = urllib.urlencode({'username': username, 'password': password})
126 126 headers = {"Content-type": "application/x-www-form-urlencoded",
127 127 "Accept": "text/plain",
128 128 "User-Agent": "RhodeCode-auth-%s" % rhodecode.__version__}
129 129 url = settings["service_url"]
130 130
131 131 log.debug("Sent Jasig CAS: \n%s",
132 132 {"url": url, "body": params, "headers": headers})
133 133 request = urllib2.Request(url, params, headers)
134 134 try:
135 135 response = urllib2.urlopen(request)
136 136 except urllib2.HTTPError as e:
137 137 log.debug("HTTPError when requesting Jasig CAS (status code: %d)" % e.code)
138 138 return None
139 139 except urllib2.URLError as e:
140 140 log.debug("URLError when requesting Jasig CAS url: %s " % url)
141 141 return None
142 142
143 143 # old attrs fetched from RhodeCode database
144 144 admin = getattr(userobj, 'admin', False)
145 145 active = getattr(userobj, 'active', True)
146 146 email = getattr(userobj, 'email', '')
147 147 username = getattr(userobj, 'username', username)
148 148 firstname = getattr(userobj, 'firstname', '')
149 149 lastname = getattr(userobj, 'lastname', '')
150 150 extern_type = getattr(userobj, 'extern_type', '')
151 151
152 152 user_attrs = {
153 153 'username': username,
154 154 'firstname': safe_unicode(firstname or username),
155 155 'lastname': safe_unicode(lastname or ''),
156 156 'groups': [],
157 'user_group_sync': False,
157 158 'email': email or '',
158 159 'admin': admin or False,
159 160 'active': active,
160 161 'active_from_extern': True,
161 162 'extern_name': username,
162 163 'extern_type': extern_type,
163 164 }
164 165
165 166 log.info('user %s authenticated correctly' % user_attrs['username'])
166 167 return user_attrs
@@ -1,480 +1,481 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 RhodeCode authentication plugin for LDAP
23 23 """
24 24
25 25
26 26 import colander
27 27 import logging
28 28 import traceback
29 29
30 30 from rhodecode.translation import _
31 31 from rhodecode.authentication.base import (
32 32 RhodeCodeExternalAuthPlugin, chop_at, hybrid_property)
33 33 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
34 34 from rhodecode.authentication.routes import AuthnPluginResourceBase
35 35 from rhodecode.lib.colander_utils import strip_whitespace
36 36 from rhodecode.lib.exceptions import (
37 37 LdapConnectionError, LdapUsernameError, LdapPasswordError, LdapImportError
38 38 )
39 39 from rhodecode.lib.utils2 import safe_unicode, safe_str
40 40 from rhodecode.model.db import User
41 41 from rhodecode.model.validators import Missing
42 42
43 43 log = logging.getLogger(__name__)
44 44
45 45 try:
46 46 import ldap
47 47 except ImportError:
48 48 # means that python-ldap is not installed, we use Missing object to mark
49 49 # ldap lib is Missing
50 50 ldap = Missing
51 51
52 52
53 53 def plugin_factory(plugin_id, *args, **kwds):
54 54 """
55 55 Factory function that is called during plugin discovery.
56 56 It returns the plugin instance.
57 57 """
58 58 plugin = RhodeCodeAuthPlugin(plugin_id)
59 59 return plugin
60 60
61 61
62 62 class LdapAuthnResource(AuthnPluginResourceBase):
63 63 pass
64 64
65 65
66 66 class LdapSettingsSchema(AuthnPluginSettingsSchemaBase):
67 67 tls_kind_choices = ['PLAIN', 'LDAPS', 'START_TLS']
68 68 tls_reqcert_choices = ['NEVER', 'ALLOW', 'TRY', 'DEMAND', 'HARD']
69 69 search_scope_choices = ['BASE', 'ONELEVEL', 'SUBTREE']
70 70
71 71 host = colander.SchemaNode(
72 72 colander.String(),
73 73 default='',
74 74 description=_('Host[s] of the LDAP Server \n'
75 75 '(e.g., 192.168.2.154, or ldap-server.domain.com.\n '
76 76 'Multiple servers can be specified using commas'),
77 77 preparer=strip_whitespace,
78 78 title=_('LDAP Host'),
79 79 widget='string')
80 80 port = colander.SchemaNode(
81 81 colander.Int(),
82 82 default=389,
83 83 description=_('Custom port that the LDAP server is listening on. '
84 84 'Default value is: 389'),
85 85 preparer=strip_whitespace,
86 86 title=_('Port'),
87 87 validator=colander.Range(min=0, max=65536),
88 88 widget='int')
89 89 dn_user = colander.SchemaNode(
90 90 colander.String(),
91 91 default='',
92 92 description=_('Optional user DN/account to connect to LDAP if authentication is required. \n'
93 93 'e.g., cn=admin,dc=mydomain,dc=com, or '
94 94 'uid=root,cn=users,dc=mydomain,dc=com, or admin@mydomain.com'),
95 95 missing='',
96 96 preparer=strip_whitespace,
97 97 title=_('Account'),
98 98 widget='string')
99 99 dn_pass = colander.SchemaNode(
100 100 colander.String(),
101 101 default='',
102 102 description=_('Password to authenticate for given user DN.'),
103 103 missing='',
104 104 preparer=strip_whitespace,
105 105 title=_('Password'),
106 106 widget='password')
107 107 tls_kind = colander.SchemaNode(
108 108 colander.String(),
109 109 default=tls_kind_choices[0],
110 110 description=_('TLS Type'),
111 111 title=_('Connection Security'),
112 112 validator=colander.OneOf(tls_kind_choices),
113 113 widget='select')
114 114 tls_reqcert = colander.SchemaNode(
115 115 colander.String(),
116 116 default=tls_reqcert_choices[0],
117 117 description=_('Require Cert over TLS?. Self-signed and custom '
118 118 'certificates can be used when\n `RhodeCode Certificate` '
119 119 'found in admin > settings > system info page is extended.'),
120 120 title=_('Certificate Checks'),
121 121 validator=colander.OneOf(tls_reqcert_choices),
122 122 widget='select')
123 123 base_dn = colander.SchemaNode(
124 124 colander.String(),
125 125 default='',
126 126 description=_('Base DN to search. Dynamic bind is supported. Add `$login` marker '
127 127 'in it to be replaced with current user credentials \n'
128 128 '(e.g., dc=mydomain,dc=com, or ou=Users,dc=mydomain,dc=com)'),
129 129 missing='',
130 130 preparer=strip_whitespace,
131 131 title=_('Base DN'),
132 132 widget='string')
133 133 filter = colander.SchemaNode(
134 134 colander.String(),
135 135 default='',
136 136 description=_('Filter to narrow results \n'
137 137 '(e.g., (&(objectCategory=Person)(objectClass=user)), or \n'
138 138 '(memberof=cn=rc-login,ou=groups,ou=company,dc=mydomain,dc=com)))'),
139 139 missing='',
140 140 preparer=strip_whitespace,
141 141 title=_('LDAP Search Filter'),
142 142 widget='string')
143 143
144 144 search_scope = colander.SchemaNode(
145 145 colander.String(),
146 146 default=search_scope_choices[2],
147 147 description=_('How deep to search LDAP. If unsure set to SUBTREE'),
148 148 title=_('LDAP Search Scope'),
149 149 validator=colander.OneOf(search_scope_choices),
150 150 widget='select')
151 151 attr_login = colander.SchemaNode(
152 152 colander.String(),
153 153 default='uid',
154 154 description=_('LDAP Attribute to map to user name (e.g., uid, or sAMAccountName)'),
155 155 preparer=strip_whitespace,
156 156 title=_('Login Attribute'),
157 157 missing_msg=_('The LDAP Login attribute of the CN must be specified'),
158 158 widget='string')
159 159 attr_firstname = colander.SchemaNode(
160 160 colander.String(),
161 161 default='',
162 162 description=_('LDAP Attribute to map to first name (e.g., givenName)'),
163 163 missing='',
164 164 preparer=strip_whitespace,
165 165 title=_('First Name Attribute'),
166 166 widget='string')
167 167 attr_lastname = colander.SchemaNode(
168 168 colander.String(),
169 169 default='',
170 170 description=_('LDAP Attribute to map to last name (e.g., sn)'),
171 171 missing='',
172 172 preparer=strip_whitespace,
173 173 title=_('Last Name Attribute'),
174 174 widget='string')
175 175 attr_email = colander.SchemaNode(
176 176 colander.String(),
177 177 default='',
178 178 description=_('LDAP Attribute to map to email address (e.g., mail).\n'
179 179 'Emails are a crucial part of RhodeCode. \n'
180 180 'If possible add a valid email attribute to ldap users.'),
181 181 missing='',
182 182 preparer=strip_whitespace,
183 183 title=_('Email Attribute'),
184 184 widget='string')
185 185
186 186
187 187 class AuthLdap(object):
188 188
189 189 def _build_servers(self):
190 190 return ', '.join(
191 191 ["{}://{}:{}".format(
192 192 self.ldap_server_type, host.strip(), self.LDAP_SERVER_PORT)
193 193 for host in self.SERVER_ADDRESSES])
194 194
195 195 def __init__(self, server, base_dn, port=389, bind_dn='', bind_pass='',
196 196 tls_kind='PLAIN', tls_reqcert='DEMAND', ldap_version=3,
197 197 search_scope='SUBTREE', attr_login='uid',
198 198 ldap_filter=''):
199 199 if ldap == Missing:
200 200 raise LdapImportError("Missing or incompatible ldap library")
201 201
202 202 self.debug = False
203 203 self.ldap_version = ldap_version
204 204 self.ldap_server_type = 'ldap'
205 205
206 206 self.TLS_KIND = tls_kind
207 207
208 208 if self.TLS_KIND == 'LDAPS':
209 209 port = port or 689
210 210 self.ldap_server_type += 's'
211 211
212 212 OPT_X_TLS_DEMAND = 2
213 213 self.TLS_REQCERT = getattr(ldap, 'OPT_X_TLS_%s' % tls_reqcert,
214 214 OPT_X_TLS_DEMAND)
215 215 # split server into list
216 216 self.SERVER_ADDRESSES = server.split(',')
217 217 self.LDAP_SERVER_PORT = port
218 218
219 219 # USE FOR READ ONLY BIND TO LDAP SERVER
220 220 self.attr_login = attr_login
221 221
222 222 self.LDAP_BIND_DN = safe_str(bind_dn)
223 223 self.LDAP_BIND_PASS = safe_str(bind_pass)
224 224 self.LDAP_SERVER = self._build_servers()
225 225 self.SEARCH_SCOPE = getattr(ldap, 'SCOPE_%s' % search_scope)
226 226 self.BASE_DN = safe_str(base_dn)
227 227 self.LDAP_FILTER = safe_str(ldap_filter)
228 228
229 229 def _get_ldap_server(self):
230 230 if self.debug:
231 231 ldap.set_option(ldap.OPT_DEBUG_LEVEL, 255)
232 232 if hasattr(ldap, 'OPT_X_TLS_CACERTDIR'):
233 233 ldap.set_option(ldap.OPT_X_TLS_CACERTDIR,
234 234 '/etc/openldap/cacerts')
235 235 ldap.set_option(ldap.OPT_REFERRALS, ldap.OPT_OFF)
236 236 ldap.set_option(ldap.OPT_RESTART, ldap.OPT_ON)
237 237 ldap.set_option(ldap.OPT_NETWORK_TIMEOUT, 60 * 10)
238 238 ldap.set_option(ldap.OPT_TIMEOUT, 60 * 10)
239 239
240 240 if self.TLS_KIND != 'PLAIN':
241 241 ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, self.TLS_REQCERT)
242 242 server = ldap.initialize(self.LDAP_SERVER)
243 243 if self.ldap_version == 2:
244 244 server.protocol = ldap.VERSION2
245 245 else:
246 246 server.protocol = ldap.VERSION3
247 247
248 248 if self.TLS_KIND == 'START_TLS':
249 249 server.start_tls_s()
250 250
251 251 if self.LDAP_BIND_DN and self.LDAP_BIND_PASS:
252 252 log.debug('Trying simple_bind with password and given login DN: %s',
253 253 self.LDAP_BIND_DN)
254 254 server.simple_bind_s(self.LDAP_BIND_DN, self.LDAP_BIND_PASS)
255 255
256 256 return server
257 257
258 258 def get_uid(self, username):
259 259 uid = username
260 260 for server_addr in self.SERVER_ADDRESSES:
261 261 uid = chop_at(username, "@%s" % server_addr)
262 262 return uid
263 263
264 264 def fetch_attrs_from_simple_bind(self, server, dn, username, password):
265 265 try:
266 266 log.debug('Trying simple bind with %s', dn)
267 267 server.simple_bind_s(dn, safe_str(password))
268 268 user = server.search_ext_s(
269 269 dn, ldap.SCOPE_BASE, '(objectClass=*)', )[0]
270 270 _, attrs = user
271 271 return attrs
272 272
273 273 except ldap.INVALID_CREDENTIALS:
274 274 log.debug(
275 275 "LDAP rejected password for user '%s': %s, org_exc:",
276 276 username, dn, exc_info=True)
277 277
278 278 def authenticate_ldap(self, username, password):
279 279 """
280 280 Authenticate a user via LDAP and return his/her LDAP properties.
281 281
282 282 Raises AuthenticationError if the credentials are rejected, or
283 283 EnvironmentError if the LDAP server can't be reached.
284 284
285 285 :param username: username
286 286 :param password: password
287 287 """
288 288
289 289 uid = self.get_uid(username)
290 290
291 291 if not password:
292 292 msg = "Authenticating user %s with blank password not allowed"
293 293 log.warning(msg, username)
294 294 raise LdapPasswordError(msg)
295 295 if "," in username:
296 296 raise LdapUsernameError(
297 297 "invalid character `,` in username: `{}`".format(username))
298 298 try:
299 299 server = self._get_ldap_server()
300 300 filter_ = '(&%s(%s=%s))' % (
301 301 self.LDAP_FILTER, self.attr_login, username)
302 302 log.debug("Authenticating %r filter %s at %s", self.BASE_DN,
303 303 filter_, self.LDAP_SERVER)
304 304 lobjects = server.search_ext_s(
305 305 self.BASE_DN, self.SEARCH_SCOPE, filter_)
306 306
307 307 if not lobjects:
308 308 log.debug("No matching LDAP objects for authentication "
309 309 "of UID:'%s' username:(%s)", uid, username)
310 310 raise ldap.NO_SUCH_OBJECT()
311 311
312 312 log.debug('Found matching ldap object, trying to authenticate')
313 313 for (dn, _attrs) in lobjects:
314 314 if dn is None:
315 315 continue
316 316
317 317 user_attrs = self.fetch_attrs_from_simple_bind(
318 318 server, dn, username, password)
319 319 if user_attrs:
320 320 break
321 321
322 322 else:
323 323 raise LdapPasswordError(
324 324 'Failed to authenticate user `{}`'
325 325 'with given password'.format(username))
326 326
327 327 except ldap.NO_SUCH_OBJECT:
328 328 log.debug("LDAP says no such user '%s' (%s), org_exc:",
329 329 uid, username, exc_info=True)
330 330 raise LdapUsernameError('Unable to find user')
331 331 except ldap.SERVER_DOWN:
332 332 org_exc = traceback.format_exc()
333 333 raise LdapConnectionError(
334 334 "LDAP can't access authentication "
335 335 "server, org_exc:%s" % org_exc)
336 336
337 337 return dn, user_attrs
338 338
339 339
340 340 class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin):
341 341 # used to define dynamic binding in the
342 342 DYNAMIC_BIND_VAR = '$login'
343 343 _settings_unsafe_keys = ['dn_pass']
344 344
345 345 def includeme(self, config):
346 346 config.add_authn_plugin(self)
347 347 config.add_authn_resource(self.get_id(), LdapAuthnResource(self))
348 348 config.add_view(
349 349 'rhodecode.authentication.views.AuthnPluginViewBase',
350 350 attr='settings_get',
351 351 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
352 352 request_method='GET',
353 353 route_name='auth_home',
354 354 context=LdapAuthnResource)
355 355 config.add_view(
356 356 'rhodecode.authentication.views.AuthnPluginViewBase',
357 357 attr='settings_post',
358 358 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
359 359 request_method='POST',
360 360 route_name='auth_home',
361 361 context=LdapAuthnResource)
362 362
363 363 def get_settings_schema(self):
364 364 return LdapSettingsSchema()
365 365
366 366 def get_display_name(self):
367 367 return _('LDAP')
368 368
369 369 @hybrid_property
370 370 def name(self):
371 371 return "ldap"
372 372
373 373 def use_fake_password(self):
374 374 return True
375 375
376 376 def user_activation_state(self):
377 377 def_user_perms = User.get_default_user().AuthUser().permissions['global']
378 378 return 'hg.extern_activate.auto' in def_user_perms
379 379
380 380 def try_dynamic_binding(self, username, password, current_args):
381 381 """
382 382 Detects marker inside our original bind, and uses dynamic auth if
383 383 present
384 384 """
385 385
386 386 org_bind = current_args['bind_dn']
387 387 passwd = current_args['bind_pass']
388 388
389 389 def has_bind_marker(username):
390 390 if self.DYNAMIC_BIND_VAR in username:
391 391 return True
392 392
393 393 # we only passed in user with "special" variable
394 394 if org_bind and has_bind_marker(org_bind) and not passwd:
395 395 log.debug('Using dynamic user/password binding for ldap '
396 396 'authentication. Replacing `%s` with username',
397 397 self.DYNAMIC_BIND_VAR)
398 398 current_args['bind_dn'] = org_bind.replace(
399 399 self.DYNAMIC_BIND_VAR, username)
400 400 current_args['bind_pass'] = password
401 401
402 402 return current_args
403 403
404 404 def auth(self, userobj, username, password, settings, **kwargs):
405 405 """
406 406 Given a user object (which may be null), username, a plaintext password,
407 407 and a settings object (containing all the keys needed as listed in
408 408 settings()), authenticate this user's login attempt.
409 409
410 410 Return None on failure. On success, return a dictionary of the form:
411 411
412 412 see: RhodeCodeAuthPluginBase.auth_func_attrs
413 413 This is later validated for correctness
414 414 """
415 415
416 416 if not username or not password:
417 417 log.debug('Empty username or password skipping...')
418 418 return None
419 419
420 420 ldap_args = {
421 421 'server': settings.get('host', ''),
422 422 'base_dn': settings.get('base_dn', ''),
423 423 'port': settings.get('port'),
424 424 'bind_dn': settings.get('dn_user'),
425 425 'bind_pass': settings.get('dn_pass'),
426 426 'tls_kind': settings.get('tls_kind'),
427 427 'tls_reqcert': settings.get('tls_reqcert'),
428 428 'search_scope': settings.get('search_scope'),
429 429 'attr_login': settings.get('attr_login'),
430 430 'ldap_version': 3,
431 431 'ldap_filter': settings.get('filter'),
432 432 }
433 433
434 434 ldap_attrs = self.try_dynamic_binding(username, password, ldap_args)
435 435
436 436 log.debug('Checking for ldap authentication.')
437 437
438 438 try:
439 439 aldap = AuthLdap(**ldap_args)
440 440 (user_dn, ldap_attrs) = aldap.authenticate_ldap(username, password)
441 441 log.debug('Got ldap DN response %s', user_dn)
442 442
443 443 def get_ldap_attr(k):
444 444 return ldap_attrs.get(settings.get(k), [''])[0]
445 445
446 446 # old attrs fetched from RhodeCode database
447 447 admin = getattr(userobj, 'admin', False)
448 448 active = getattr(userobj, 'active', True)
449 449 email = getattr(userobj, 'email', '')
450 450 username = getattr(userobj, 'username', username)
451 451 firstname = getattr(userobj, 'firstname', '')
452 452 lastname = getattr(userobj, 'lastname', '')
453 453 extern_type = getattr(userobj, 'extern_type', '')
454 454
455 455 groups = []
456 456 user_attrs = {
457 457 'username': username,
458 458 'firstname': safe_unicode(
459 459 get_ldap_attr('attr_firstname') or firstname),
460 460 'lastname': safe_unicode(
461 461 get_ldap_attr('attr_lastname') or lastname),
462 462 'groups': groups,
463 'user_group_sync': False,
463 464 'email': get_ldap_attr('attr_email') or email,
464 465 'admin': admin,
465 466 'active': active,
466 467 'active_from_extern': None,
467 468 'extern_name': user_dn,
468 469 'extern_type': extern_type,
469 470 }
470 471 log.debug('ldap user: %s', user_attrs)
471 472 log.info('user %s authenticated correctly', user_attrs['username'])
472 473
473 474 return user_attrs
474 475
475 476 except (LdapUsernameError, LdapPasswordError, LdapImportError):
476 477 log.exception("LDAP related exception")
477 478 return None
478 479 except (Exception,):
479 480 log.exception("Other exception")
480 481 return None
@@ -1,160 +1,161 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 RhodeCode authentication library for PAM
23 23 """
24 24
25 25 import colander
26 26 import grp
27 27 import logging
28 28 import pam
29 29 import pwd
30 30 import re
31 31 import socket
32 32
33 33 from rhodecode.translation import _
34 34 from rhodecode.authentication.base import (
35 35 RhodeCodeExternalAuthPlugin, hybrid_property)
36 36 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
37 37 from rhodecode.authentication.routes import AuthnPluginResourceBase
38 38 from rhodecode.lib.colander_utils import strip_whitespace
39 39
40 40 log = logging.getLogger(__name__)
41 41
42 42
43 43 def plugin_factory(plugin_id, *args, **kwds):
44 44 """
45 45 Factory function that is called during plugin discovery.
46 46 It returns the plugin instance.
47 47 """
48 48 plugin = RhodeCodeAuthPlugin(plugin_id)
49 49 return plugin
50 50
51 51
52 52 class PamAuthnResource(AuthnPluginResourceBase):
53 53 pass
54 54
55 55
56 56 class PamSettingsSchema(AuthnPluginSettingsSchemaBase):
57 57 service = colander.SchemaNode(
58 58 colander.String(),
59 59 default='login',
60 60 description=_('PAM service name to use for authentication.'),
61 61 preparer=strip_whitespace,
62 62 title=_('PAM service name'),
63 63 widget='string')
64 64 gecos = colander.SchemaNode(
65 65 colander.String(),
66 66 default='(?P<last_name>.+),\s*(?P<first_name>\w+)',
67 67 description=_('Regular expression for extracting user name/email etc. '
68 68 'from Unix userinfo.'),
69 69 preparer=strip_whitespace,
70 70 title=_('Gecos Regex'),
71 71 widget='string')
72 72
73 73
74 74 class RhodeCodeAuthPlugin(RhodeCodeExternalAuthPlugin):
75 75 # PAM authentication can be slow. Repository operations involve a lot of
76 76 # auth calls. Little caching helps speedup push/pull operations significantly
77 77 AUTH_CACHE_TTL = 4
78 78
79 79 def includeme(self, config):
80 80 config.add_authn_plugin(self)
81 81 config.add_authn_resource(self.get_id(), PamAuthnResource(self))
82 82 config.add_view(
83 83 'rhodecode.authentication.views.AuthnPluginViewBase',
84 84 attr='settings_get',
85 85 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
86 86 request_method='GET',
87 87 route_name='auth_home',
88 88 context=PamAuthnResource)
89 89 config.add_view(
90 90 'rhodecode.authentication.views.AuthnPluginViewBase',
91 91 attr='settings_post',
92 92 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
93 93 request_method='POST',
94 94 route_name='auth_home',
95 95 context=PamAuthnResource)
96 96
97 97 def get_display_name(self):
98 98 return _('PAM')
99 99
100 100 @hybrid_property
101 101 def name(self):
102 102 return "pam"
103 103
104 104 def get_settings_schema(self):
105 105 return PamSettingsSchema()
106 106
107 107 def use_fake_password(self):
108 108 return True
109 109
110 110 def auth(self, userobj, username, password, settings, **kwargs):
111 111 if not username or not password:
112 112 log.debug('Empty username or password skipping...')
113 113 return None
114 114
115 115 auth_result = pam.authenticate(username, password, settings["service"])
116 116
117 117 if not auth_result:
118 118 log.error("PAM was unable to authenticate user: %s" % (username, ))
119 119 return None
120 120
121 121 log.debug('Got PAM response %s' % (auth_result, ))
122 122
123 123 # old attrs fetched from RhodeCode database
124 124 default_email = "%s@%s" % (username, socket.gethostname())
125 125 admin = getattr(userobj, 'admin', False)
126 126 active = getattr(userobj, 'active', True)
127 127 email = getattr(userobj, 'email', '') or default_email
128 128 username = getattr(userobj, 'username', username)
129 129 firstname = getattr(userobj, 'firstname', '')
130 130 lastname = getattr(userobj, 'lastname', '')
131 131 extern_type = getattr(userobj, 'extern_type', '')
132 132
133 133 user_attrs = {
134 134 'username': username,
135 135 'firstname': firstname,
136 136 'lastname': lastname,
137 137 'groups': [g.gr_name for g in grp.getgrall()
138 138 if username in g.gr_mem],
139 'user_group_sync': True,
139 140 'email': email,
140 141 'admin': admin,
141 142 'active': active,
142 143 'active_from_extern': None,
143 144 'extern_name': username,
144 145 'extern_type': extern_type,
145 146 }
146 147
147 148 try:
148 149 user_data = pwd.getpwnam(username)
149 150 regex = settings["gecos"]
150 151 match = re.search(regex, user_data.pw_gecos)
151 152 if match:
152 153 user_attrs["firstname"] = match.group('first_name')
153 154 user_attrs["lastname"] = match.group('last_name')
154 155 except Exception:
155 156 log.warning("Cannot extract additional info for PAM user")
156 157 pass
157 158
158 159 log.debug("pamuser: %s", user_attrs)
159 160 log.info('user %s authenticated correctly' % user_attrs['username'])
160 161 return user_attrs
@@ -1,142 +1,143 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 RhodeCode authentication plugin for built in internal auth
23 23 """
24 24
25 25 import logging
26 26
27 27 from rhodecode.translation import _
28 28
29 29 from rhodecode.authentication.base import RhodeCodeAuthPluginBase, hybrid_property
30 30 from rhodecode.authentication.routes import AuthnPluginResourceBase
31 31 from rhodecode.lib.utils2 import safe_str
32 32 from rhodecode.model.db import User
33 33
34 34 log = logging.getLogger(__name__)
35 35
36 36
37 37 def plugin_factory(plugin_id, *args, **kwds):
38 38 plugin = RhodeCodeAuthPlugin(plugin_id)
39 39 return plugin
40 40
41 41
42 42 class RhodecodeAuthnResource(AuthnPluginResourceBase):
43 43 pass
44 44
45 45
46 46 class RhodeCodeAuthPlugin(RhodeCodeAuthPluginBase):
47 47
48 48 def includeme(self, config):
49 49 config.add_authn_plugin(self)
50 50 config.add_authn_resource(self.get_id(), RhodecodeAuthnResource(self))
51 51 config.add_view(
52 52 'rhodecode.authentication.views.AuthnPluginViewBase',
53 53 attr='settings_get',
54 54 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
55 55 request_method='GET',
56 56 route_name='auth_home',
57 57 context=RhodecodeAuthnResource)
58 58 config.add_view(
59 59 'rhodecode.authentication.views.AuthnPluginViewBase',
60 60 attr='settings_post',
61 61 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
62 62 request_method='POST',
63 63 route_name='auth_home',
64 64 context=RhodecodeAuthnResource)
65 65
66 66 def get_display_name(self):
67 67 return _('Rhodecode')
68 68
69 69 @hybrid_property
70 70 def name(self):
71 71 return "rhodecode"
72 72
73 73 def user_activation_state(self):
74 74 def_user_perms = User.get_default_user().AuthUser().permissions['global']
75 75 return 'hg.register.auto_activate' in def_user_perms
76 76
77 77 def allows_authentication_from(
78 78 self, user, allows_non_existing_user=True,
79 79 allowed_auth_plugins=None, allowed_auth_sources=None):
80 80 """
81 81 Custom method for this auth that doesn't accept non existing users.
82 82 We know that user exists in our database.
83 83 """
84 84 allows_non_existing_user = False
85 85 return super(RhodeCodeAuthPlugin, self).allows_authentication_from(
86 86 user, allows_non_existing_user=allows_non_existing_user)
87 87
88 88 def auth(self, userobj, username, password, settings, **kwargs):
89 89 if not userobj:
90 90 log.debug('userobj was:%s skipping' % (userobj, ))
91 91 return None
92 92 if userobj.extern_type != self.name:
93 93 log.warning(
94 94 "userobj:%s extern_type mismatch got:`%s` expected:`%s`" %
95 95 (userobj, userobj.extern_type, self.name))
96 96 return None
97 97
98 98 user_attrs = {
99 99 "username": userobj.username,
100 100 "firstname": userobj.firstname,
101 101 "lastname": userobj.lastname,
102 102 "groups": [],
103 'user_group_sync': False,
103 104 "email": userobj.email,
104 105 "admin": userobj.admin,
105 106 "active": userobj.active,
106 107 "active_from_extern": userobj.active,
107 108 "extern_name": userobj.user_id,
108 109 "extern_type": userobj.extern_type,
109 110 }
110 111
111 112 log.debug("User attributes:%s" % (user_attrs, ))
112 113 if userobj.active:
113 114 from rhodecode.lib import auth
114 115 crypto_backend = auth.crypto_backend()
115 116 password_encoded = safe_str(password)
116 117 password_match, new_hash = crypto_backend.hash_check_with_upgrade(
117 118 password_encoded, userobj.password or '')
118 119
119 120 if password_match and new_hash:
120 121 log.debug('user %s properly authenticated, but '
121 122 'requires hash change to bcrypt', userobj)
122 123 # if password match, and we use OLD deprecated hash,
123 124 # we should migrate this user hash password to the new hash
124 125 # we store the new returned by hash_check_with_upgrade function
125 126 user_attrs['_hash_migrate'] = new_hash
126 127
127 128 if userobj.username == User.DEFAULT_USER and userobj.active:
128 129 log.info(
129 130 'user %s authenticated correctly as anonymous user', userobj)
130 131 return user_attrs
131 132
132 133 elif userobj.username == username and password_match:
133 134 log.info('user %s authenticated correctly', userobj)
134 135 return user_attrs
135 136 log.info("user %s had a bad password when "
136 137 "authenticating on this plugin", userobj)
137 138 return None
138 139 else:
139 140 log.warning(
140 141 'user `%s` failed to authenticate via %s, reason: account not '
141 142 'active.', username, self.name)
142 143 return None
@@ -1,146 +1,147 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2016-2018 RhodeCode GmbH
4 4 #
5 5 # This program is free software: you can redistribute it and/or modify
6 6 # it under the terms of the GNU Affero General Public License, version 3
7 7 # (only), as published by the Free Software Foundation.
8 8 #
9 9 # This program is distributed in the hope that it will be useful,
10 10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 12 # GNU General Public License for more details.
13 13 #
14 14 # You should have received a copy of the GNU Affero General Public License
15 15 # along with this program. If not, see <http://www.gnu.org/licenses/>.
16 16 #
17 17 # This program is dual-licensed. If you wish to learn more about the
18 18 # RhodeCode Enterprise Edition, including its added features, Support services,
19 19 # and proprietary license terms, please see https://rhodecode.com/licenses/
20 20
21 21 """
22 22 RhodeCode authentication token plugin for built in internal auth
23 23 """
24 24
25 25 import logging
26 26
27 27 from rhodecode.translation import _
28 28 from rhodecode.authentication.base import (
29 29 RhodeCodeAuthPluginBase, VCS_TYPE, hybrid_property)
30 30 from rhodecode.authentication.routes import AuthnPluginResourceBase
31 31 from rhodecode.model.db import User, UserApiKeys, Repository
32 32
33 33
34 34 log = logging.getLogger(__name__)
35 35
36 36
37 37 def plugin_factory(plugin_id, *args, **kwds):
38 38 plugin = RhodeCodeAuthPlugin(plugin_id)
39 39 return plugin
40 40
41 41
42 42 class RhodecodeAuthnResource(AuthnPluginResourceBase):
43 43 pass
44 44
45 45
46 46 class RhodeCodeAuthPlugin(RhodeCodeAuthPluginBase):
47 47 """
48 48 Enables usage of authentication tokens for vcs operations.
49 49 """
50 50
51 51 def includeme(self, config):
52 52 config.add_authn_plugin(self)
53 53 config.add_authn_resource(self.get_id(), RhodecodeAuthnResource(self))
54 54 config.add_view(
55 55 'rhodecode.authentication.views.AuthnPluginViewBase',
56 56 attr='settings_get',
57 57 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
58 58 request_method='GET',
59 59 route_name='auth_home',
60 60 context=RhodecodeAuthnResource)
61 61 config.add_view(
62 62 'rhodecode.authentication.views.AuthnPluginViewBase',
63 63 attr='settings_post',
64 64 renderer='rhodecode:templates/admin/auth/plugin_settings.mako',
65 65 request_method='POST',
66 66 route_name='auth_home',
67 67 context=RhodecodeAuthnResource)
68 68
69 69 def get_display_name(self):
70 70 return _('Rhodecode Token Auth')
71 71
72 72 @hybrid_property
73 73 def name(self):
74 74 return "authtoken"
75 75
76 76 def user_activation_state(self):
77 77 def_user_perms = User.get_default_user().AuthUser().permissions['global']
78 78 return 'hg.register.auto_activate' in def_user_perms
79 79
80 80 def allows_authentication_from(
81 81 self, user, allows_non_existing_user=True,
82 82 allowed_auth_plugins=None, allowed_auth_sources=None):
83 83 """
84 84 Custom method for this auth that doesn't accept empty users. And also
85 85 allows users from all other active plugins to use it and also
86 86 authenticate against it. But only via vcs mode
87 87 """
88 88 from rhodecode.authentication.base import get_authn_registry
89 89 authn_registry = get_authn_registry()
90 90
91 91 active_plugins = set(
92 92 [x.name for x in authn_registry.get_plugins_for_authentication()])
93 93 active_plugins.discard(self.name)
94 94
95 95 allowed_auth_plugins = [self.name] + list(active_plugins)
96 96 # only for vcs operations
97 97 allowed_auth_sources = [VCS_TYPE]
98 98
99 99 return super(RhodeCodeAuthPlugin, self).allows_authentication_from(
100 100 user, allows_non_existing_user=False,
101 101 allowed_auth_plugins=allowed_auth_plugins,
102 102 allowed_auth_sources=allowed_auth_sources)
103 103
104 104 def auth(self, userobj, username, password, settings, **kwargs):
105 105 if not userobj:
106 106 log.debug('userobj was:%s skipping' % (userobj, ))
107 107 return None
108 108
109 109 user_attrs = {
110 110 "username": userobj.username,
111 111 "firstname": userobj.firstname,
112 112 "lastname": userobj.lastname,
113 113 "groups": [],
114 'user_group_sync': False,
114 115 "email": userobj.email,
115 116 "admin": userobj.admin,
116 117 "active": userobj.active,
117 118 "active_from_extern": userobj.active,
118 119 "extern_name": userobj.user_id,
119 120 "extern_type": userobj.extern_type,
120 121 }
121 122
122 123 log.debug('Authenticating user with args %s', user_attrs)
123 124 if userobj.active:
124 125 # calling context repo for token scopes
125 126 scope_repo_id = None
126 127 if self.acl_repo_name:
127 128 repo = Repository.get_by_repo_name(self.acl_repo_name)
128 129 scope_repo_id = repo.repo_id if repo else None
129 130
130 131 token_match = userobj.authenticate_by_token(
131 132 password, roles=[UserApiKeys.ROLE_VCS],
132 133 scope_repo_id=scope_repo_id)
133 134
134 135 if userobj.username == username and token_match:
135 136 log.info(
136 137 'user `%s` successfully authenticated via %s',
137 138 user_attrs['username'], self.name)
138 139 return user_attrs
139 140 log.error(
140 141 'user `%s` failed to authenticate via %s, reason: bad or '
141 142 'inactive token.', username, self.name)
142 143 else:
143 144 log.warning(
144 145 'user `%s` failed to authenticate via %s, reason: account not '
145 146 'active.', username, self.name)
146 147 return None
General Comments 0
You need to be logged in to leave comments. Login now