##// END OF EJS Templates
auth: use cache_ttl from a plugin to also cache permissions....
marcink -
r2154:574d07a8 default
parent child Browse files
Show More

The requested changes are too big and content was truncated. Show full diff

@@ -1,711 +1,730 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 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 from rhodecode.lib.utils2 import md5_safe, safe_int
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 80
81 81 class LazyFormencode(object):
82 82 def __init__(self, formencode_obj, *args, **kwargs):
83 83 self.formencode_obj = formencode_obj
84 84 self.args = args
85 85 self.kwargs = kwargs
86 86
87 87 def __call__(self, *args, **kwargs):
88 88 from inspect import isfunction
89 89 formencode_obj = self.formencode_obj
90 90 if isfunction(formencode_obj):
91 91 # case we wrap validators into functions
92 92 formencode_obj = self.formencode_obj(*args, **kwargs)
93 93 return formencode_obj(*self.args, **self.kwargs)
94 94
95 95
96 96 class RhodeCodeAuthPluginBase(object):
97 97 # cache the authentication request for N amount of seconds. Some kind
98 98 # of authentication methods are very heavy and it's very efficient to cache
99 99 # the result of a call. If it's set to None (default) cache is off
100 100 AUTH_CACHE_TTL = None
101 101 AUTH_CACHE = {}
102 102
103 103 auth_func_attrs = {
104 104 "username": "unique username",
105 105 "firstname": "first name",
106 106 "lastname": "last name",
107 107 "email": "email address",
108 108 "groups": '["list", "of", "groups"]',
109 109 "extern_name": "name in external source of record",
110 110 "extern_type": "type of external source of record",
111 111 "admin": 'True|False defines if user should be RhodeCode super admin',
112 112 "active":
113 113 'True|False defines active state of user internally for RhodeCode',
114 114 "active_from_extern":
115 115 "True|False\None, active state from the external auth, "
116 116 "None means use definition from RhodeCode extern_type active value"
117 117 }
118 118 # set on authenticate() method and via set_auth_type func.
119 119 auth_type = None
120 120
121 121 # set on authenticate() method and via set_calling_scope_repo, this is a
122 122 # calling scope repository when doing authentication most likely on VCS
123 123 # operations
124 124 acl_repo_name = None
125 125
126 126 # List of setting names to store encrypted. Plugins may override this list
127 127 # to store settings encrypted.
128 128 _settings_encrypted = []
129 129
130 130 # Mapping of python to DB settings model types. Plugins may override or
131 131 # extend this mapping.
132 132 _settings_type_map = {
133 133 colander.String: 'unicode',
134 134 colander.Integer: 'int',
135 135 colander.Boolean: 'bool',
136 136 colander.List: 'list',
137 137 }
138 138
139 139 # list of keys in settings that are unsafe to be logged, should be passwords
140 140 # or other crucial credentials
141 141 _settings_unsafe_keys = []
142 142
143 143 def __init__(self, plugin_id):
144 144 self._plugin_id = plugin_id
145 145
146 146 def __str__(self):
147 147 return self.get_id()
148 148
149 149 def _get_setting_full_name(self, name):
150 150 """
151 151 Return the full setting name used for storing values in the database.
152 152 """
153 153 # TODO: johbo: Using the name here is problematic. It would be good to
154 154 # introduce either new models in the database to hold Plugin and
155 155 # PluginSetting or to use the plugin id here.
156 156 return 'auth_{}_{}'.format(self.name, name)
157 157
158 158 def _get_setting_type(self, name):
159 159 """
160 160 Return the type of a setting. This type is defined by the SettingsModel
161 161 and determines how the setting is stored in DB. Optionally the suffix
162 162 `.encrypted` is appended to instruct SettingsModel to store it
163 163 encrypted.
164 164 """
165 165 schema_node = self.get_settings_schema().get(name)
166 166 db_type = self._settings_type_map.get(
167 167 type(schema_node.typ), 'unicode')
168 168 if name in self._settings_encrypted:
169 169 db_type = '{}.encrypted'.format(db_type)
170 170 return db_type
171 171
172 172 @LazyProperty
173 173 def plugin_settings(self):
174 174 settings = SettingsModel().get_all_settings()
175 175 return settings
176 176
177 177 def is_enabled(self):
178 178 """
179 179 Returns true if this plugin is enabled. An enabled plugin can be
180 180 configured in the admin interface but it is not consulted during
181 181 authentication.
182 182 """
183 183 auth_plugins = SettingsModel().get_auth_plugins()
184 184 return self.get_id() in auth_plugins
185 185
186 186 def is_active(self):
187 187 """
188 188 Returns true if the plugin is activated. An activated plugin is
189 189 consulted during authentication, assumed it is also enabled.
190 190 """
191 191 return self.get_setting_by_name('enabled')
192 192
193 193 def get_id(self):
194 194 """
195 195 Returns the plugin id.
196 196 """
197 197 return self._plugin_id
198 198
199 199 def get_display_name(self):
200 200 """
201 201 Returns a translation string for displaying purposes.
202 202 """
203 203 raise NotImplementedError('Not implemented in base class')
204 204
205 205 def get_settings_schema(self):
206 206 """
207 207 Returns a colander schema, representing the plugin settings.
208 208 """
209 209 return AuthnPluginSettingsSchemaBase()
210 210
211 211 def get_setting_by_name(self, name, default=None):
212 212 """
213 213 Returns a plugin setting by name.
214 214 """
215 215 full_name = 'rhodecode_{}'.format(self._get_setting_full_name(name))
216 216 plugin_settings = self.plugin_settings
217 217
218 218 return plugin_settings.get(full_name) or default
219 219
220 220 def create_or_update_setting(self, name, value):
221 221 """
222 222 Create or update a setting for this plugin in the persistent storage.
223 223 """
224 224 full_name = self._get_setting_full_name(name)
225 225 type_ = self._get_setting_type(name)
226 226 db_setting = SettingsModel().create_or_update_setting(
227 227 full_name, value, type_)
228 228 return db_setting.app_settings_value
229 229
230 230 def get_settings(self):
231 231 """
232 232 Returns the plugin settings as dictionary.
233 233 """
234 234 settings = {}
235 235 for node in self.get_settings_schema():
236 236 settings[node.name] = self.get_setting_by_name(node.name)
237 237 return settings
238 238
239 239 def log_safe_settings(self, settings):
240 240 """
241 241 returns a log safe representation of settings, without any secrets
242 242 """
243 243 settings_copy = copy.deepcopy(settings)
244 244 for k in self._settings_unsafe_keys:
245 245 if k in settings_copy:
246 246 del settings_copy[k]
247 247 return settings_copy
248 248
249 249 @property
250 250 def validators(self):
251 251 """
252 252 Exposes RhodeCode validators modules
253 253 """
254 254 # this is a hack to overcome issues with pylons threadlocals and
255 255 # translator object _() not being registered properly.
256 256 class LazyCaller(object):
257 257 def __init__(self, name):
258 258 self.validator_name = name
259 259
260 260 def __call__(self, *args, **kwargs):
261 261 from rhodecode.model import validators as v
262 262 obj = getattr(v, self.validator_name)
263 263 # log.debug('Initializing lazy formencode object: %s', obj)
264 264 return LazyFormencode(obj, *args, **kwargs)
265 265
266 266 class ProxyGet(object):
267 267 def __getattribute__(self, name):
268 268 return LazyCaller(name)
269 269
270 270 return ProxyGet()
271 271
272 272 @hybrid_property
273 273 def name(self):
274 274 """
275 275 Returns the name of this authentication plugin.
276 276
277 277 :returns: string
278 278 """
279 279 raise NotImplementedError("Not implemented in base class")
280 280
281 281 def get_url_slug(self):
282 282 """
283 283 Returns a slug which should be used when constructing URLs which refer
284 284 to this plugin. By default it returns the plugin name. If the name is
285 285 not suitable for using it in an URL the plugin should override this
286 286 method.
287 287 """
288 288 return self.name
289 289
290 290 @property
291 291 def is_headers_auth(self):
292 292 """
293 293 Returns True if this authentication plugin uses HTTP headers as
294 294 authentication method.
295 295 """
296 296 return False
297 297
298 298 @hybrid_property
299 299 def is_container_auth(self):
300 300 """
301 301 Deprecated method that indicates if this authentication plugin uses
302 302 HTTP headers as authentication method.
303 303 """
304 304 warnings.warn(
305 305 'Use is_headers_auth instead.', category=DeprecationWarning)
306 306 return self.is_headers_auth
307 307
308 308 @hybrid_property
309 309 def allows_creating_users(self):
310 310 """
311 311 Defines if Plugin allows users to be created on-the-fly when
312 312 authentication is called. Controls how external plugins should behave
313 313 in terms if they are allowed to create new users, or not. Base plugins
314 314 should not be allowed to, but External ones should be !
315 315
316 316 :return: bool
317 317 """
318 318 return False
319 319
320 320 def set_auth_type(self, auth_type):
321 321 self.auth_type = auth_type
322 322
323 323 def set_calling_scope_repo(self, acl_repo_name):
324 324 self.acl_repo_name = acl_repo_name
325 325
326 326 def allows_authentication_from(
327 327 self, user, allows_non_existing_user=True,
328 328 allowed_auth_plugins=None, allowed_auth_sources=None):
329 329 """
330 330 Checks if this authentication module should accept a request for
331 331 the current user.
332 332
333 333 :param user: user object fetched using plugin's get_user() method.
334 334 :param allows_non_existing_user: if True, don't allow the
335 335 user to be empty, meaning not existing in our database
336 336 :param allowed_auth_plugins: if provided, users extern_type will be
337 337 checked against a list of provided extern types, which are plugin
338 338 auth_names in the end
339 339 :param allowed_auth_sources: authentication type allowed,
340 340 `http` or `vcs` default is both.
341 341 defines if plugin will accept only http authentication vcs
342 342 authentication(git/hg) or both
343 343 :returns: boolean
344 344 """
345 345 if not user and not allows_non_existing_user:
346 346 log.debug('User is empty but plugin does not allow empty users,'
347 347 'not allowed to authenticate')
348 348 return False
349 349
350 350 expected_auth_plugins = allowed_auth_plugins or [self.name]
351 351 if user and (user.extern_type and
352 352 user.extern_type not in expected_auth_plugins):
353 353 log.debug(
354 354 'User `%s` is bound to `%s` auth type. Plugin allows only '
355 355 '%s, skipping', user, user.extern_type, expected_auth_plugins)
356 356
357 357 return False
358 358
359 359 # by default accept both
360 360 expected_auth_from = allowed_auth_sources or [HTTP_TYPE, VCS_TYPE]
361 361 if self.auth_type not in expected_auth_from:
362 362 log.debug('Current auth source is %s but plugin only allows %s',
363 363 self.auth_type, expected_auth_from)
364 364 return False
365 365
366 366 return True
367 367
368 368 def get_user(self, username=None, **kwargs):
369 369 """
370 370 Helper method for user fetching in plugins, by default it's using
371 371 simple fetch by username, but this method can be custimized in plugins
372 372 eg. headers auth plugin to fetch user by environ params
373 373
374 374 :param username: username if given to fetch from database
375 375 :param kwargs: extra arguments needed for user fetching.
376 376 """
377 377 user = None
378 378 log.debug(
379 379 'Trying to fetch user `%s` from RhodeCode database', username)
380 380 if username:
381 381 user = User.get_by_username(username)
382 382 if not user:
383 383 log.debug('User not found, fallback to fetch user in '
384 384 'case insensitive mode')
385 385 user = User.get_by_username(username, case_insensitive=True)
386 386 else:
387 387 log.debug('provided username:`%s` is empty skipping...', username)
388 388 if not user:
389 389 log.debug('User `%s` not found in database', username)
390 390 else:
391 391 log.debug('Got DB user:%s', user)
392 392 return user
393 393
394 394 def user_activation_state(self):
395 395 """
396 396 Defines user activation state when creating new users
397 397
398 398 :returns: boolean
399 399 """
400 400 raise NotImplementedError("Not implemented in base class")
401 401
402 402 def auth(self, userobj, username, passwd, settings, **kwargs):
403 403 """
404 404 Given a user object (which may be null), username, a plaintext
405 405 password, and a settings object (containing all the keys needed as
406 406 listed in settings()), authenticate this user's login attempt.
407 407
408 408 Return None on failure. On success, return a dictionary of the form:
409 409
410 410 see: RhodeCodeAuthPluginBase.auth_func_attrs
411 411 This is later validated for correctness
412 412 """
413 413 raise NotImplementedError("not implemented in base class")
414 414
415 415 def _authenticate(self, userobj, username, passwd, settings, **kwargs):
416 416 """
417 417 Wrapper to call self.auth() that validates call on it
418 418
419 419 :param userobj: userobj
420 420 :param username: username
421 421 :param passwd: plaintext password
422 422 :param settings: plugin settings
423 423 """
424 424 auth = self.auth(userobj, username, passwd, settings, **kwargs)
425 425 if auth:
426 auth['_plugin'] = self.name
427 auth['_ttl_cache'] = self.get_ttl_cache(settings)
426 428 # check if hash should be migrated ?
427 429 new_hash = auth.get('_hash_migrate')
428 430 if new_hash:
429 431 self._migrate_hash_to_bcrypt(username, passwd, new_hash)
430 432 return self._validate_auth_return(auth)
433
431 434 return auth
432 435
433 436 def _migrate_hash_to_bcrypt(self, username, password, new_hash):
434 437 new_hash_cypher = _RhodeCodeCryptoBCrypt()
435 438 # extra checks, so make sure new hash is correct.
436 439 password_encoded = safe_str(password)
437 440 if new_hash and new_hash_cypher.hash_check(
438 441 password_encoded, new_hash):
439 442 cur_user = User.get_by_username(username)
440 443 cur_user.password = new_hash
441 444 Session().add(cur_user)
442 445 Session().flush()
443 446 log.info('Migrated user %s hash to bcrypt', cur_user)
444 447
445 448 def _validate_auth_return(self, ret):
446 449 if not isinstance(ret, dict):
447 450 raise Exception('returned value from auth must be a dict')
448 451 for k in self.auth_func_attrs:
449 452 if k not in ret:
450 453 raise Exception('Missing %s attribute from returned data' % k)
451 454 return ret
452 455
456 def get_ttl_cache(self, settings=None):
457 plugin_settings = settings or self.get_settings()
458 cache_ttl = 0
459
460 if isinstance(self.AUTH_CACHE_TTL, (int, long)):
461 # plugin cache set inside is more important than the settings value
462 cache_ttl = self.AUTH_CACHE_TTL
463 elif plugin_settings.get('cache_ttl'):
464 cache_ttl = safe_int(plugin_settings.get('cache_ttl'), 0)
465
466 plugin_cache_active = bool(cache_ttl and cache_ttl > 0)
467 return plugin_cache_active, cache_ttl
468
453 469
454 470 class RhodeCodeExternalAuthPlugin(RhodeCodeAuthPluginBase):
455 471
456 472 @hybrid_property
457 473 def allows_creating_users(self):
458 474 return True
459 475
460 476 def use_fake_password(self):
461 477 """
462 478 Return a boolean that indicates whether or not we should set the user's
463 479 password to a random value when it is authenticated by this plugin.
464 480 If your plugin provides authentication, then you will generally
465 481 want this.
466 482
467 483 :returns: boolean
468 484 """
469 485 raise NotImplementedError("Not implemented in base class")
470 486
471 487 def _authenticate(self, userobj, username, passwd, settings, **kwargs):
472 488 # at this point _authenticate calls plugin's `auth()` function
473 489 auth = super(RhodeCodeExternalAuthPlugin, self)._authenticate(
474 490 userobj, username, passwd, settings, **kwargs)
475 491
476 492 if auth:
477 493 # maybe plugin will clean the username ?
478 494 # we should use the return value
479 495 username = auth['username']
480 496
481 497 # if external source tells us that user is not active, we should
482 498 # skip rest of the process. This can prevent from creating users in
483 499 # RhodeCode when using external authentication, but if it's
484 500 # inactive user we shouldn't create that user anyway
485 501 if auth['active_from_extern'] is False:
486 502 log.warning(
487 503 "User %s authenticated against %s, but is inactive",
488 504 username, self.__module__)
489 505 return None
490 506
491 507 cur_user = User.get_by_username(username, case_insensitive=True)
492 508 is_user_existing = cur_user is not None
493 509
494 510 if is_user_existing:
495 511 log.debug('Syncing user `%s` from '
496 512 '`%s` plugin', username, self.name)
497 513 else:
498 514 log.debug('Creating non existing user `%s` from '
499 515 '`%s` plugin', username, self.name)
500 516
501 517 if self.allows_creating_users:
502 518 log.debug('Plugin `%s` allows to '
503 519 'create new users', self.name)
504 520 else:
505 521 log.debug('Plugin `%s` does not allow to '
506 522 'create new users', self.name)
507 523
508 524 user_parameters = {
509 525 'username': username,
510 526 'email': auth["email"],
511 527 'firstname': auth["firstname"],
512 528 'lastname': auth["lastname"],
513 529 'active': auth["active"],
514 530 'admin': auth["admin"],
515 531 'extern_name': auth["extern_name"],
516 532 'extern_type': self.name,
517 533 'plugin': self,
518 534 'allow_to_create_user': self.allows_creating_users,
519 535 }
520 536
521 537 if not is_user_existing:
522 538 if self.use_fake_password():
523 539 # Randomize the PW because we don't need it, but don't want
524 540 # them blank either
525 541 passwd = PasswordGenerator().gen_password(length=16)
526 542 user_parameters['password'] = passwd
527 543 else:
528 544 # Since the password is required by create_or_update method of
529 545 # UserModel, we need to set it explicitly.
530 546 # The create_or_update method is smart and recognises the
531 547 # password hashes as well.
532 548 user_parameters['password'] = cur_user.password
533 549
534 550 # we either create or update users, we also pass the flag
535 551 # that controls if this method can actually do that.
536 552 # raises NotAllowedToCreateUserError if it cannot, and we try to.
537 553 user = UserModel().create_or_update(**user_parameters)
538 554 Session().flush()
539 555 # enforce user is just in given groups, all of them has to be ones
540 556 # created from plugins. We store this info in _group_data JSON
541 557 # field
542 558 try:
543 559 groups = auth['groups'] or []
544 560 log.debug(
545 561 'Performing user_group sync based on set `%s` '
546 562 'returned by this plugin', groups)
547 563 UserGroupModel().enforce_groups(user, groups, self.name)
548 564 except Exception:
549 565 # for any reason group syncing fails, we should
550 566 # proceed with login
551 567 log.error(traceback.format_exc())
552 568 Session().commit()
553 569 return auth
554 570
555 571
556 572 def loadplugin(plugin_id):
557 573 """
558 574 Loads and returns an instantiated authentication plugin.
559 575 Returns the RhodeCodeAuthPluginBase subclass on success,
560 576 or None on failure.
561 577 """
562 578 # TODO: Disusing pyramids thread locals to retrieve the registry.
563 579 authn_registry = get_authn_registry()
564 580 plugin = authn_registry.get_plugin(plugin_id)
565 581 if plugin is None:
566 582 log.error('Authentication plugin not found: "%s"', plugin_id)
567 583 return plugin
568 584
569 585
570 586 def get_authn_registry(registry=None):
571 587 registry = registry or get_current_registry()
572 588 authn_registry = registry.getUtility(IAuthnPluginRegistry)
573 589 return authn_registry
574 590
575 591
576 592 def get_auth_cache_manager(custom_ttl=None):
577 593 return caches.get_cache_manager(
578 594 'auth_plugins', 'rhodecode.authentication', custom_ttl)
579 595
580 596
597 def get_perms_cache_manager(custom_ttl=None):
598 return caches.get_cache_manager(
599 'auth_plugins', 'rhodecode.permissions', custom_ttl)
600
601
581 602 def authenticate(username, password, environ=None, auth_type=None,
582 603 skip_missing=False, registry=None, acl_repo_name=None):
583 604 """
584 605 Authentication function used for access control,
585 606 It tries to authenticate based on enabled authentication modules.
586 607
587 608 :param username: username can be empty for headers auth
588 609 :param password: password can be empty for headers auth
589 610 :param environ: environ headers passed for headers auth
590 611 :param auth_type: type of authentication, either `HTTP_TYPE` or `VCS_TYPE`
591 612 :param skip_missing: ignores plugins that are in db but not in environment
592 613 :returns: None if auth failed, plugin_user dict if auth is correct
593 614 """
594 615 if not auth_type or auth_type not in [HTTP_TYPE, VCS_TYPE]:
595 616 raise ValueError('auth type must be on of http, vcs got "%s" instead'
596 617 % auth_type)
597 618 headers_only = environ and not (username and password)
598 619
599 620 authn_registry = get_authn_registry(registry)
600 621 plugins_to_check = authn_registry.get_plugins_for_authentication()
601 622 log.debug('Starting ordered authentication chain using %s plugins',
602 623 plugins_to_check)
603 624 for plugin in plugins_to_check:
604 625 plugin.set_auth_type(auth_type)
605 626 plugin.set_calling_scope_repo(acl_repo_name)
606 627
607 628 if headers_only and not plugin.is_headers_auth:
608 629 log.debug('Auth type is for headers only and plugin `%s` is not '
609 630 'headers plugin, skipping...', plugin.get_id())
610 631 continue
611 632
612 633 # load plugin settings from RhodeCode database
613 634 plugin_settings = plugin.get_settings()
614 635 plugin_sanitized_settings = plugin.log_safe_settings(plugin_settings)
615 636 log.debug('Plugin settings:%s', plugin_sanitized_settings)
616 637
617 638 log.debug('Trying authentication using ** %s **', plugin.get_id())
618 639 # use plugin's method of user extraction.
619 640 user = plugin.get_user(username, environ=environ,
620 641 settings=plugin_settings)
621 642 display_user = user.username if user else username
622 643 log.debug(
623 644 'Plugin %s extracted user is `%s`', plugin.get_id(), display_user)
624 645
625 646 if not plugin.allows_authentication_from(user):
626 647 log.debug('Plugin %s does not accept user `%s` for authentication',
627 648 plugin.get_id(), display_user)
628 649 continue
629 650 else:
630 651 log.debug('Plugin %s accepted user `%s` for authentication',
631 652 plugin.get_id(), display_user)
632 653
633 654 log.info('Authenticating user `%s` using %s plugin',
634 655 display_user, plugin.get_id())
635 656
636 _cache_ttl = 0
637
638 if isinstance(plugin.AUTH_CACHE_TTL, (int, long)):
639 # plugin cache set inside is more important than the settings value
640 _cache_ttl = plugin.AUTH_CACHE_TTL
641 elif plugin_settings.get('cache_ttl'):
642 _cache_ttl = safe_int(plugin_settings.get('cache_ttl'), 0)
643
644 plugin_cache_active = bool(_cache_ttl and _cache_ttl > 0)
657 plugin_cache_active, cache_ttl = plugin.get_ttl_cache(plugin_settings)
645 658
646 659 # get instance of cache manager configured for a namespace
647 cache_manager = get_auth_cache_manager(custom_ttl=_cache_ttl)
660 cache_manager = get_auth_cache_manager(custom_ttl=cache_ttl)
648 661
649 662 log.debug('AUTH_CACHE_TTL for plugin `%s` active: %s (TTL: %s)',
650 plugin.get_id(), plugin_cache_active, _cache_ttl)
663 plugin.get_id(), plugin_cache_active, cache_ttl)
651 664
652 665 # for environ based password can be empty, but then the validation is
653 666 # on the server that fills in the env data needed for authentication
654 _password_hash = md5_safe(plugin.name + username + (password or ''))
667
668 _password_hash = caches.compute_key_from_params(
669 plugin.name, username, (password or ''))
655 670
656 671 # _authenticate is a wrapper for .auth() method of plugin.
657 672 # it checks if .auth() sends proper data.
658 673 # For RhodeCodeExternalAuthPlugin it also maps users to
659 674 # Database and maps the attributes returned from .auth()
660 675 # to RhodeCode database. If this function returns data
661 676 # then auth is correct.
662 677 start = time.time()
663 678 log.debug('Running plugin `%s` _authenticate method', plugin.get_id())
664 679
665 680 def auth_func():
666 681 """
667 682 This function is used internally in Cache of Beaker to calculate
668 683 Results
669 684 """
685 log.debug('auth: calculating password access now...')
670 686 return plugin._authenticate(
671 687 user, username, password, plugin_settings,
672 688 environ=environ or {})
673 689
674 690 if plugin_cache_active:
691 log.debug('Trying to fetch cached auth by %s', _password_hash[:6])
675 692 plugin_user = cache_manager.get(
676 693 _password_hash, createfunc=auth_func)
677 694 else:
678 695 plugin_user = auth_func()
679 696
680 697 auth_time = time.time() - start
681 698 log.debug('Authentication for plugin `%s` completed in %.3fs, '
682 699 'expiration time of fetched cache %.1fs.',
683 plugin.get_id(), auth_time, _cache_ttl)
700 plugin.get_id(), auth_time, cache_ttl)
684 701
685 702 log.debug('PLUGIN USER DATA: %s', plugin_user)
686 703
687 704 if plugin_user:
688 705 log.debug('Plugin returned proper authentication data')
689 706 return plugin_user
690 707 # we failed to Auth because .auth() method didn't return proper user
691 708 log.debug("User `%s` failed to authenticate against %s",
692 709 display_user, plugin.get_id())
710
711 # case when we failed to authenticate against all defined plugins
693 712 return None
694 713
695 714
696 715 def chop_at(s, sub, inclusive=False):
697 716 """Truncate string ``s`` at the first occurrence of ``sub``.
698 717
699 718 If ``inclusive`` is true, truncate just after ``sub`` rather than at it.
700 719
701 720 >>> chop_at("plutocratic brats", "rat")
702 721 'plutoc'
703 722 >>> chop_at("plutocratic brats", "rat", True)
704 723 'plutocrat'
705 724 """
706 725 pos = s.find(sub)
707 726 if pos == -1:
708 727 return s
709 728 if inclusive:
710 729 return s[:pos+len(sub)]
711 730 return s[:pos]
@@ -1,52 +1,51 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2012-2017 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
23 23 from rhodecode.translation import _
24 24
25 25
26 26 class AuthnPluginSettingsSchemaBase(colander.MappingSchema):
27 27 """
28 28 This base schema is intended for use in authentication plugins.
29 29 It adds a few default settings (e.g., "enabled"), so that plugin
30 30 authors don't have to maintain a bunch of boilerplate.
31 31 """
32 32 enabled = colander.SchemaNode(
33 33 colander.Bool(),
34 34 default=False,
35 35 description=_('Enable or disable this authentication plugin.'),
36 36 missing=False,
37 37 title=_('Enabled'),
38 38 widget='bool',
39 39 )
40 40 cache_ttl = colander.SchemaNode(
41 41 colander.Int(),
42 42 default=0,
43 description=_('Amount of seconds to cache the authentication response'
44 'call for this plugin. \n'
45 'Useful for long calls like LDAP to improve the '
46 'performance of the authentication system '
47 '(0 means disabled).'),
43 description=_('Amount of seconds to cache the authentication and '
44 'permissions check response call for this plugin. \n'
45 'Useful for expensive calls like LDAP to improve the '
46 'performance of the system (0 means disabled).'),
48 47 missing=0,
49 48 title=_('Auth Cache TTL'),
50 49 validator=colander.Range(min=0, max=None),
51 50 widget='int',
52 51 )
@@ -1,630 +1,631 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 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 The base Controller API
23 23 Provides the BaseController class for subclassing. And usage in different
24 24 controllers
25 25 """
26 26
27 27 import logging
28 28 import socket
29 29
30 30 import markupsafe
31 31 import ipaddress
32 32 import pyramid.threadlocal
33 33
34 34 from paste.auth.basic import AuthBasicAuthenticator
35 35 from paste.httpexceptions import HTTPUnauthorized, HTTPForbidden, get_exception
36 36 from paste.httpheaders import WWW_AUTHENTICATE, AUTHORIZATION
37 37
38 38 import rhodecode
39 39 from rhodecode.authentication.base import VCS_TYPE
40 40 from rhodecode.lib import auth, utils2
41 41 from rhodecode.lib import helpers as h
42 42 from rhodecode.lib.auth import AuthUser, CookieStoreWrapper
43 43 from rhodecode.lib.exceptions import UserCreationError
44 44 from rhodecode.lib.utils import (
45 45 get_repo_slug, set_rhodecode_config, password_changed,
46 46 get_enabled_hook_classes)
47 47 from rhodecode.lib.utils2 import (
48 48 str2bool, safe_unicode, AttributeDict, safe_int, md5, aslist, safe_str)
49 49 from rhodecode.model import meta
50 50 from rhodecode.model.db import Repository, User, ChangesetComment
51 51 from rhodecode.model.notification import NotificationModel
52 52 from rhodecode.model.scm import ScmModel
53 53 from rhodecode.model.settings import VcsSettingsModel, SettingsModel
54 54
55 55 # NOTE(marcink): remove after base controller is no longer required
56 56 from pylons.controllers import WSGIController
57 57 from pylons.i18n import translation
58 58
59 59 log = logging.getLogger(__name__)
60 60
61 61
62 62 # hack to make the migration to pyramid easier
63 63 def render(template_name, extra_vars=None, cache_key=None,
64 64 cache_type=None, cache_expire=None):
65 65 """Render a template with Mako
66 66
67 67 Accepts the cache options ``cache_key``, ``cache_type``, and
68 68 ``cache_expire``.
69 69
70 70 """
71 71 from pylons.templating import literal
72 72 from pylons.templating import cached_template, pylons_globals
73 73
74 74 # Create a render callable for the cache function
75 75 def render_template():
76 76 # Pull in extra vars if needed
77 77 globs = extra_vars or {}
78 78
79 79 # Second, get the globals
80 80 globs.update(pylons_globals())
81 81
82 82 globs['_ungettext'] = globs['ungettext']
83 83 # Grab a template reference
84 84 template = globs['app_globals'].mako_lookup.get_template(template_name)
85 85
86 86 return literal(template.render_unicode(**globs))
87 87
88 88 return cached_template(template_name, render_template, cache_key=cache_key,
89 89 cache_type=cache_type, cache_expire=cache_expire)
90 90
91 91 def _filter_proxy(ip):
92 92 """
93 93 Passed in IP addresses in HEADERS can be in a special format of multiple
94 94 ips. Those comma separated IPs are passed from various proxies in the
95 95 chain of request processing. The left-most being the original client.
96 96 We only care about the first IP which came from the org. client.
97 97
98 98 :param ip: ip string from headers
99 99 """
100 100 if ',' in ip:
101 101 _ips = ip.split(',')
102 102 _first_ip = _ips[0].strip()
103 103 log.debug('Got multiple IPs %s, using %s', ','.join(_ips), _first_ip)
104 104 return _first_ip
105 105 return ip
106 106
107 107
108 108 def _filter_port(ip):
109 109 """
110 110 Removes a port from ip, there are 4 main cases to handle here.
111 111 - ipv4 eg. 127.0.0.1
112 112 - ipv6 eg. ::1
113 113 - ipv4+port eg. 127.0.0.1:8080
114 114 - ipv6+port eg. [::1]:8080
115 115
116 116 :param ip:
117 117 """
118 118 def is_ipv6(ip_addr):
119 119 if hasattr(socket, 'inet_pton'):
120 120 try:
121 121 socket.inet_pton(socket.AF_INET6, ip_addr)
122 122 except socket.error:
123 123 return False
124 124 else:
125 125 # fallback to ipaddress
126 126 try:
127 127 ipaddress.IPv6Address(safe_unicode(ip_addr))
128 128 except Exception:
129 129 return False
130 130 return True
131 131
132 132 if ':' not in ip: # must be ipv4 pure ip
133 133 return ip
134 134
135 135 if '[' in ip and ']' in ip: # ipv6 with port
136 136 return ip.split(']')[0][1:].lower()
137 137
138 138 # must be ipv6 or ipv4 with port
139 139 if is_ipv6(ip):
140 140 return ip
141 141 else:
142 142 ip, _port = ip.split(':')[:2] # means ipv4+port
143 143 return ip
144 144
145 145
146 146 def get_ip_addr(environ):
147 147 proxy_key = 'HTTP_X_REAL_IP'
148 148 proxy_key2 = 'HTTP_X_FORWARDED_FOR'
149 149 def_key = 'REMOTE_ADDR'
150 150 _filters = lambda x: _filter_port(_filter_proxy(x))
151 151
152 152 ip = environ.get(proxy_key)
153 153 if ip:
154 154 return _filters(ip)
155 155
156 156 ip = environ.get(proxy_key2)
157 157 if ip:
158 158 return _filters(ip)
159 159
160 160 ip = environ.get(def_key, '0.0.0.0')
161 161 return _filters(ip)
162 162
163 163
164 164 def get_server_ip_addr(environ, log_errors=True):
165 165 hostname = environ.get('SERVER_NAME')
166 166 try:
167 167 return socket.gethostbyname(hostname)
168 168 except Exception as e:
169 169 if log_errors:
170 170 # in some cases this lookup is not possible, and we don't want to
171 171 # make it an exception in logs
172 172 log.exception('Could not retrieve server ip address: %s', e)
173 173 return hostname
174 174
175 175
176 176 def get_server_port(environ):
177 177 return environ.get('SERVER_PORT')
178 178
179 179
180 180 def get_access_path(environ):
181 181 path = environ.get('PATH_INFO')
182 182 org_req = environ.get('pylons.original_request')
183 183 if org_req:
184 184 path = org_req.environ.get('PATH_INFO')
185 185 return path
186 186
187 187
188 188 def get_user_agent(environ):
189 189 return environ.get('HTTP_USER_AGENT')
190 190
191 191
192 192 def vcs_operation_context(
193 193 environ, repo_name, username, action, scm, check_locking=True,
194 194 is_shadow_repo=False):
195 195 """
196 196 Generate the context for a vcs operation, e.g. push or pull.
197 197
198 198 This context is passed over the layers so that hooks triggered by the
199 199 vcs operation know details like the user, the user's IP address etc.
200 200
201 201 :param check_locking: Allows to switch of the computation of the locking
202 202 data. This serves mainly the need of the simplevcs middleware to be
203 203 able to disable this for certain operations.
204 204
205 205 """
206 206 # Tri-state value: False: unlock, None: nothing, True: lock
207 207 make_lock = None
208 208 locked_by = [None, None, None]
209 209 is_anonymous = username == User.DEFAULT_USER
210 210 if not is_anonymous and check_locking:
211 211 log.debug('Checking locking on repository "%s"', repo_name)
212 212 user = User.get_by_username(username)
213 213 repo = Repository.get_by_repo_name(repo_name)
214 214 make_lock, __, locked_by = repo.get_locking_state(
215 215 action, user.user_id)
216 216
217 217 settings_model = VcsSettingsModel(repo=repo_name)
218 218 ui_settings = settings_model.get_ui_settings()
219 219
220 220 extras = {
221 221 'ip': get_ip_addr(environ),
222 222 'username': username,
223 223 'action': action,
224 224 'repository': repo_name,
225 225 'scm': scm,
226 226 'config': rhodecode.CONFIG['__file__'],
227 227 'make_lock': make_lock,
228 228 'locked_by': locked_by,
229 229 'server_url': utils2.get_server_url(environ),
230 230 'user_agent': get_user_agent(environ),
231 231 'hooks': get_enabled_hook_classes(ui_settings),
232 232 'is_shadow_repo': is_shadow_repo,
233 233 }
234 234 return extras
235 235
236 236
237 237 class BasicAuth(AuthBasicAuthenticator):
238 238
239 239 def __init__(self, realm, authfunc, registry, auth_http_code=None,
240 240 initial_call_detection=False, acl_repo_name=None):
241 241 self.realm = realm
242 242 self.initial_call = initial_call_detection
243 243 self.authfunc = authfunc
244 244 self.registry = registry
245 245 self.acl_repo_name = acl_repo_name
246 246 self._rc_auth_http_code = auth_http_code
247 247
248 248 def _get_response_from_code(self, http_code):
249 249 try:
250 250 return get_exception(safe_int(http_code))
251 251 except Exception:
252 252 log.exception('Failed to fetch response for code %s' % http_code)
253 253 return HTTPForbidden
254 254
255 255 def get_rc_realm(self):
256 256 return safe_str(self.registry.rhodecode_settings.get('rhodecode_realm'))
257 257
258 258 def build_authentication(self):
259 259 head = WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm)
260 260 if self._rc_auth_http_code and not self.initial_call:
261 261 # return alternative HTTP code if alternative http return code
262 262 # is specified in RhodeCode config, but ONLY if it's not the
263 263 # FIRST call
264 264 custom_response_klass = self._get_response_from_code(
265 265 self._rc_auth_http_code)
266 266 return custom_response_klass(headers=head)
267 267 return HTTPUnauthorized(headers=head)
268 268
269 269 def authenticate(self, environ):
270 270 authorization = AUTHORIZATION(environ)
271 271 if not authorization:
272 272 return self.build_authentication()
273 273 (authmeth, auth) = authorization.split(' ', 1)
274 274 if 'basic' != authmeth.lower():
275 275 return self.build_authentication()
276 276 auth = auth.strip().decode('base64')
277 277 _parts = auth.split(':', 1)
278 278 if len(_parts) == 2:
279 279 username, password = _parts
280 if self.authfunc(
280 auth_data = self.authfunc(
281 281 username, password, environ, VCS_TYPE,
282 registry=self.registry, acl_repo_name=self.acl_repo_name):
283 return username
282 registry=self.registry, acl_repo_name=self.acl_repo_name)
283 if auth_data:
284 return {'username': username, 'auth_data': auth_data}
284 285 if username and password:
285 286 # we mark that we actually executed authentication once, at
286 287 # that point we can use the alternative auth code
287 288 self.initial_call = False
288 289
289 290 return self.build_authentication()
290 291
291 292 __call__ = authenticate
292 293
293 294
294 295 def calculate_version_hash(config):
295 296 return md5(
296 297 config.get('beaker.session.secret', '') +
297 298 rhodecode.__version__)[:8]
298 299
299 300
300 301 def get_current_lang(request):
301 302 # NOTE(marcink): remove after pyramid move
302 303 try:
303 304 return translation.get_lang()[0]
304 305 except:
305 306 pass
306 307
307 308 return getattr(request, '_LOCALE_', request.locale_name)
308 309
309 310
310 311 def attach_context_attributes(context, request, user_id):
311 312 """
312 313 Attach variables into template context called `c`, please note that
313 314 request could be pylons or pyramid request in here.
314 315 """
315 316 # NOTE(marcink): remove check after pyramid migration
316 317 if hasattr(request, 'registry'):
317 318 config = request.registry.settings
318 319 else:
319 320 from pylons import config
320 321
321 322 rc_config = SettingsModel().get_all_settings(cache=True)
322 323
323 324 context.rhodecode_version = rhodecode.__version__
324 325 context.rhodecode_edition = config.get('rhodecode.edition')
325 326 # unique secret + version does not leak the version but keep consistency
326 327 context.rhodecode_version_hash = calculate_version_hash(config)
327 328
328 329 # Default language set for the incoming request
329 330 context.language = get_current_lang(request)
330 331
331 332 # Visual options
332 333 context.visual = AttributeDict({})
333 334
334 335 # DB stored Visual Items
335 336 context.visual.show_public_icon = str2bool(
336 337 rc_config.get('rhodecode_show_public_icon'))
337 338 context.visual.show_private_icon = str2bool(
338 339 rc_config.get('rhodecode_show_private_icon'))
339 340 context.visual.stylify_metatags = str2bool(
340 341 rc_config.get('rhodecode_stylify_metatags'))
341 342 context.visual.dashboard_items = safe_int(
342 343 rc_config.get('rhodecode_dashboard_items', 100))
343 344 context.visual.admin_grid_items = safe_int(
344 345 rc_config.get('rhodecode_admin_grid_items', 100))
345 346 context.visual.repository_fields = str2bool(
346 347 rc_config.get('rhodecode_repository_fields'))
347 348 context.visual.show_version = str2bool(
348 349 rc_config.get('rhodecode_show_version'))
349 350 context.visual.use_gravatar = str2bool(
350 351 rc_config.get('rhodecode_use_gravatar'))
351 352 context.visual.gravatar_url = rc_config.get('rhodecode_gravatar_url')
352 353 context.visual.default_renderer = rc_config.get(
353 354 'rhodecode_markup_renderer', 'rst')
354 355 context.visual.comment_types = ChangesetComment.COMMENT_TYPES
355 356 context.visual.rhodecode_support_url = \
356 357 rc_config.get('rhodecode_support_url') or h.route_url('rhodecode_support')
357 358
358 359 context.visual.affected_files_cut_off = 60
359 360
360 361 context.pre_code = rc_config.get('rhodecode_pre_code')
361 362 context.post_code = rc_config.get('rhodecode_post_code')
362 363 context.rhodecode_name = rc_config.get('rhodecode_title')
363 364 context.default_encodings = aslist(config.get('default_encoding'), sep=',')
364 365 # if we have specified default_encoding in the request, it has more
365 366 # priority
366 367 if request.GET.get('default_encoding'):
367 368 context.default_encodings.insert(0, request.GET.get('default_encoding'))
368 369 context.clone_uri_tmpl = rc_config.get('rhodecode_clone_uri_tmpl')
369 370
370 371 # INI stored
371 372 context.labs_active = str2bool(
372 373 config.get('labs_settings_active', 'false'))
373 374 context.visual.allow_repo_location_change = str2bool(
374 375 config.get('allow_repo_location_change', True))
375 376 context.visual.allow_custom_hooks_settings = str2bool(
376 377 config.get('allow_custom_hooks_settings', True))
377 378 context.debug_style = str2bool(config.get('debug_style', False))
378 379
379 380 context.rhodecode_instanceid = config.get('instance_id')
380 381
381 382 context.visual.cut_off_limit_diff = safe_int(
382 383 config.get('cut_off_limit_diff'))
383 384 context.visual.cut_off_limit_file = safe_int(
384 385 config.get('cut_off_limit_file'))
385 386
386 387 # AppEnlight
387 388 context.appenlight_enabled = str2bool(config.get('appenlight', 'false'))
388 389 context.appenlight_api_public_key = config.get(
389 390 'appenlight.api_public_key', '')
390 391 context.appenlight_server_url = config.get('appenlight.server_url', '')
391 392
392 393 # JS template context
393 394 context.template_context = {
394 395 'repo_name': None,
395 396 'repo_type': None,
396 397 'repo_landing_commit': None,
397 398 'rhodecode_user': {
398 399 'username': None,
399 400 'email': None,
400 401 'notification_status': False
401 402 },
402 403 'visual': {
403 404 'default_renderer': None
404 405 },
405 406 'commit_data': {
406 407 'commit_id': None
407 408 },
408 409 'pull_request_data': {'pull_request_id': None},
409 410 'timeago': {
410 411 'refresh_time': 120 * 1000,
411 412 'cutoff_limit': 1000 * 60 * 60 * 24 * 7
412 413 },
413 414 'pyramid_dispatch': {
414 415
415 416 },
416 417 'extra': {'plugins': {}}
417 418 }
418 419 # END CONFIG VARS
419 420
420 421 # TODO: This dosn't work when called from pylons compatibility tween.
421 422 # Fix this and remove it from base controller.
422 423 # context.repo_name = get_repo_slug(request) # can be empty
423 424
424 425 diffmode = 'sideside'
425 426 if request.GET.get('diffmode'):
426 427 if request.GET['diffmode'] == 'unified':
427 428 diffmode = 'unified'
428 429 elif request.session.get('diffmode'):
429 430 diffmode = request.session['diffmode']
430 431
431 432 context.diffmode = diffmode
432 433
433 434 if request.session.get('diffmode') != diffmode:
434 435 request.session['diffmode'] = diffmode
435 436
436 437 context.csrf_token = auth.get_csrf_token(session=request.session)
437 438 context.backends = rhodecode.BACKENDS.keys()
438 439 context.backends.sort()
439 440 context.unread_notifications = NotificationModel().get_unread_cnt_for_user(user_id)
440 441
441 442 # NOTE(marcink): when migrated to pyramid we don't need to set this anymore,
442 443 # given request will ALWAYS be pyramid one
443 444 pyramid_request = pyramid.threadlocal.get_current_request()
444 445 context.pyramid_request = pyramid_request
445 446
446 447 # web case
447 448 if hasattr(pyramid_request, 'user'):
448 449 context.auth_user = pyramid_request.user
449 450 context.rhodecode_user = pyramid_request.user
450 451
451 452 # api case
452 453 if hasattr(pyramid_request, 'rpc_user'):
453 454 context.auth_user = pyramid_request.rpc_user
454 455 context.rhodecode_user = pyramid_request.rpc_user
455 456
456 457 # attach the whole call context to the request
457 458 request.call_context = context
458 459
459 460
460 461 def get_auth_user(request):
461 462 environ = request.environ
462 463 session = request.session
463 464
464 465 ip_addr = get_ip_addr(environ)
465 466 # make sure that we update permissions each time we call controller
466 467 _auth_token = (request.GET.get('auth_token', '') or
467 468 request.GET.get('api_key', ''))
468 469
469 470 if _auth_token:
470 471 # when using API_KEY we assume user exists, and
471 472 # doesn't need auth based on cookies.
472 473 auth_user = AuthUser(api_key=_auth_token, ip_addr=ip_addr)
473 474 authenticated = False
474 475 else:
475 476 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
476 477 try:
477 478 auth_user = AuthUser(user_id=cookie_store.get('user_id', None),
478 479 ip_addr=ip_addr)
479 480 except UserCreationError as e:
480 481 h.flash(e, 'error')
481 482 # container auth or other auth functions that create users
482 483 # on the fly can throw this exception signaling that there's
483 484 # issue with user creation, explanation should be provided
484 485 # in Exception itself. We then create a simple blank
485 486 # AuthUser
486 487 auth_user = AuthUser(ip_addr=ip_addr)
487 488
488 489 if password_changed(auth_user, session):
489 490 session.invalidate()
490 491 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
491 492 auth_user = AuthUser(ip_addr=ip_addr)
492 493
493 494 authenticated = cookie_store.get('is_authenticated')
494 495
495 496 if not auth_user.is_authenticated and auth_user.is_user_object:
496 497 # user is not authenticated and not empty
497 498 auth_user.set_authenticated(authenticated)
498 499
499 500 return auth_user
500 501
501 502
502 503 class BaseController(WSGIController):
503 504
504 505 def __before__(self):
505 506 """
506 507 __before__ is called before controller methods and after __call__
507 508 """
508 509 # on each call propagate settings calls into global settings.
509 510 from pylons import config
510 511 from pylons import tmpl_context as c, request, url
511 512 set_rhodecode_config(config)
512 513 attach_context_attributes(c, request, self._rhodecode_user.user_id)
513 514
514 515 # TODO: Remove this when fixed in attach_context_attributes()
515 516 c.repo_name = get_repo_slug(request) # can be empty
516 517
517 518 self.cut_off_limit_diff = safe_int(config.get('cut_off_limit_diff'))
518 519 self.cut_off_limit_file = safe_int(config.get('cut_off_limit_file'))
519 520 self.sa = meta.Session
520 521 self.scm_model = ScmModel(self.sa)
521 522
522 523 # set user language
523 524 user_lang = getattr(c.pyramid_request, '_LOCALE_', None)
524 525 if user_lang:
525 526 translation.set_lang(user_lang)
526 527 log.debug('set language to %s for user %s',
527 528 user_lang, self._rhodecode_user)
528 529
529 530 def _dispatch_redirect(self, with_url, environ, start_response):
530 531 from webob.exc import HTTPFound
531 532 resp = HTTPFound(with_url)
532 533 environ['SCRIPT_NAME'] = '' # handle prefix middleware
533 534 environ['PATH_INFO'] = with_url
534 535 return resp(environ, start_response)
535 536
536 537 def __call__(self, environ, start_response):
537 538 """Invoke the Controller"""
538 539 # WSGIController.__call__ dispatches to the Controller method
539 540 # the request is routed to. This routing information is
540 541 # available in environ['pylons.routes_dict']
541 542 from rhodecode.lib import helpers as h
542 543 from pylons import tmpl_context as c, request, url
543 544
544 545 # Provide the Pylons context to Pyramid's debugtoolbar if it asks
545 546 if environ.get('debugtoolbar.wants_pylons_context', False):
546 547 environ['debugtoolbar.pylons_context'] = c._current_obj()
547 548
548 549 _route_name = '.'.join([environ['pylons.routes_dict']['controller'],
549 550 environ['pylons.routes_dict']['action']])
550 551
551 552 self.rc_config = SettingsModel().get_all_settings(cache=True)
552 553 self.ip_addr = get_ip_addr(environ)
553 554
554 555 # The rhodecode auth user is looked up and passed through the
555 556 # environ by the pylons compatibility tween in pyramid.
556 557 # So we can just grab it from there.
557 558 auth_user = environ['rc_auth_user']
558 559
559 560 # set globals for auth user
560 561 request.user = auth_user
561 562 self._rhodecode_user = auth_user
562 563
563 564 log.info('IP: %s User: %s accessed %s [%s]' % (
564 565 self.ip_addr, auth_user, safe_unicode(get_access_path(environ)),
565 566 _route_name)
566 567 )
567 568
568 569 user_obj = auth_user.get_instance()
569 570 if user_obj and user_obj.user_data.get('force_password_change'):
570 571 h.flash('You are required to change your password', 'warning',
571 572 ignore_duplicate=True)
572 573 return self._dispatch_redirect(
573 574 url('my_account_password'), environ, start_response)
574 575
575 576 return WSGIController.__call__(self, environ, start_response)
576 577
577 578
578 579 def h_filter(s):
579 580 """
580 581 Custom filter for Mako templates. Mako by standard uses `markupsafe.escape`
581 582 we wrap this with additional functionality that converts None to empty
582 583 strings
583 584 """
584 585 if s is None:
585 586 return markupsafe.Markup()
586 587 return markupsafe.escape(s)
587 588
588 589
589 590 def add_events_routes(config):
590 591 """
591 592 Adds routing that can be used in events. Because some events are triggered
592 593 outside of pyramid context, we need to bootstrap request with some
593 594 routing registered
594 595 """
595 596 config.add_route(name='home', pattern='/')
596 597
597 598 config.add_route(name='repo_summary', pattern='/{repo_name}')
598 599 config.add_route(name='repo_summary_explicit', pattern='/{repo_name}/summary')
599 600 config.add_route(name='repo_group_home', pattern='/{repo_group_name}')
600 601
601 602 config.add_route(name='pullrequest_show',
602 603 pattern='/{repo_name}/pull-request/{pull_request_id}')
603 604 config.add_route(name='pull_requests_global',
604 605 pattern='/pull-request/{pull_request_id}')
605 606
606 607 config.add_route(name='repo_commit',
607 608 pattern='/{repo_name}/changeset/{commit_id}')
608 609 config.add_route(name='repo_files',
609 610 pattern='/{repo_name}/files/{commit_id}/{f_path}')
610 611
611 612
612 613 def bootstrap_request(**kwargs):
613 614 import pyramid.testing
614 615
615 616 class TestRequest(pyramid.testing.DummyRequest):
616 617 application_url = kwargs.pop('application_url', 'http://example.com')
617 618 host = kwargs.pop('host', 'example.com:80')
618 619 domain = kwargs.pop('domain', 'example.com')
619 620
620 621 class TestDummySession(pyramid.testing.DummySession):
621 622 def save(*arg, **kw):
622 623 pass
623 624
624 625 request = TestRequest(**kwargs)
625 626 request.session = TestDummySession()
626 627
627 628 config = pyramid.testing.setUp(request=request)
628 629 add_events_routes(config)
629 630 return request
630 631
@@ -1,540 +1,599 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-2017 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 SimpleVCS middleware for handling protocol request (push/clone etc.)
23 23 It's implemented with basic auth function
24 24 """
25 25
26 26 import os
27 import re
27 28 import logging
28 29 import importlib
29 import re
30 30 from functools import wraps
31 31
32 import time
32 33 from paste.httpheaders import REMOTE_USER, AUTH_TYPE
33 34 from webob.exc import (
34 35 HTTPNotFound, HTTPForbidden, HTTPNotAcceptable, HTTPInternalServerError)
35 36
36 37 import rhodecode
37 from rhodecode.authentication.base import authenticate, VCS_TYPE
38 from rhodecode.authentication.base import (
39 authenticate, get_perms_cache_manager, VCS_TYPE)
40 from rhodecode.lib import caches
38 41 from rhodecode.lib.auth import AuthUser, HasPermissionAnyMiddleware
39 42 from rhodecode.lib.base import (
40 43 BasicAuth, get_ip_addr, get_user_agent, vcs_operation_context)
41 44 from rhodecode.lib.exceptions import (
42 45 HTTPLockedRC, HTTPRequirementError, UserCreationError,
43 46 NotAllowedToCreateUserError)
44 47 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
45 48 from rhodecode.lib.middleware import appenlight
46 49 from rhodecode.lib.middleware.utils import scm_app_http
47 from rhodecode.lib.utils import (
48 is_valid_repo, get_rhodecode_base_path, SLUG_RE)
50 from rhodecode.lib.utils import is_valid_repo, SLUG_RE
49 51 from rhodecode.lib.utils2 import safe_str, fix_PATH, str2bool, safe_unicode
50 52 from rhodecode.lib.vcs.conf import settings as vcs_settings
51 53 from rhodecode.lib.vcs.backends import base
52 54 from rhodecode.model import meta
53 55 from rhodecode.model.db import User, Repository, PullRequest
54 56 from rhodecode.model.scm import ScmModel
55 57 from rhodecode.model.pull_request import PullRequestModel
56 58 from rhodecode.model.settings import SettingsModel
57 59
58 60 log = logging.getLogger(__name__)
59 61
60 62
61 63 def initialize_generator(factory):
62 64 """
63 65 Initializes the returned generator by draining its first element.
64 66
65 67 This can be used to give a generator an initializer, which is the code
66 68 up to the first yield statement. This decorator enforces that the first
67 69 produced element has the value ``"__init__"`` to make its special
68 70 purpose very explicit in the using code.
69 71 """
70 72
71 73 @wraps(factory)
72 74 def wrapper(*args, **kwargs):
73 75 gen = factory(*args, **kwargs)
74 76 try:
75 77 init = gen.next()
76 78 except StopIteration:
77 79 raise ValueError('Generator must yield at least one element.')
78 80 if init != "__init__":
79 81 raise ValueError('First yielded element must be "__init__".')
80 82 return gen
81 83 return wrapper
82 84
83 85
84 86 class SimpleVCS(object):
85 87 """Common functionality for SCM HTTP handlers."""
86 88
87 89 SCM = 'unknown'
88 90
89 91 acl_repo_name = None
90 92 url_repo_name = None
91 93 vcs_repo_name = None
92 94
93 95 # We have to handle requests to shadow repositories different than requests
94 96 # to normal repositories. Therefore we have to distinguish them. To do this
95 97 # we use this regex which will match only on URLs pointing to shadow
96 98 # repositories.
97 99 shadow_repo_re = re.compile(
98 100 '(?P<groups>(?:{slug_pat}/)*)' # repo groups
99 101 '(?P<target>{slug_pat})/' # target repo
100 102 'pull-request/(?P<pr_id>\d+)/' # pull request
101 103 'repository$' # shadow repo
102 104 .format(slug_pat=SLUG_RE.pattern))
103 105
104 106 def __init__(self, application, config, registry):
105 107 self.registry = registry
106 108 self.application = application
107 109 self.config = config
108 110 # re-populated by specialized middleware
109 111 self.repo_vcs_config = base.Config()
110 112 self.rhodecode_settings = SettingsModel().get_all_settings(cache=True)
111 113 self.basepath = rhodecode.CONFIG['base_path']
112 114 registry.rhodecode_settings = self.rhodecode_settings
113 115 # authenticate this VCS request using authfunc
114 116 auth_ret_code_detection = \
115 117 str2bool(self.config.get('auth_ret_code_detection', False))
116 118 self.authenticate = BasicAuth(
117 119 '', authenticate, registry, config.get('auth_ret_code'),
118 120 auth_ret_code_detection)
119 121 self.ip_addr = '0.0.0.0'
120 122
121 123 def set_repo_names(self, environ):
122 124 """
123 125 This will populate the attributes acl_repo_name, url_repo_name,
124 126 vcs_repo_name and is_shadow_repo. In case of requests to normal (non
125 127 shadow) repositories all names are equal. In case of requests to a
126 128 shadow repository the acl-name points to the target repo of the pull
127 129 request and the vcs-name points to the shadow repo file system path.
128 130 The url-name is always the URL used by the vcs client program.
129 131
130 132 Example in case of a shadow repo:
131 133 acl_repo_name = RepoGroup/MyRepo
132 134 url_repo_name = RepoGroup/MyRepo/pull-request/3/repository
133 135 vcs_repo_name = /repo/base/path/RepoGroup/.__shadow_MyRepo_pr-3'
134 136 """
135 137 # First we set the repo name from URL for all attributes. This is the
136 138 # default if handling normal (non shadow) repo requests.
137 139 self.url_repo_name = self._get_repository_name(environ)
138 140 self.acl_repo_name = self.vcs_repo_name = self.url_repo_name
139 141 self.is_shadow_repo = False
140 142
141 143 # Check if this is a request to a shadow repository.
142 144 match = self.shadow_repo_re.match(self.url_repo_name)
143 145 if match:
144 146 match_dict = match.groupdict()
145 147
146 148 # Build acl repo name from regex match.
147 149 acl_repo_name = safe_unicode('{groups}{target}'.format(
148 150 groups=match_dict['groups'] or '',
149 151 target=match_dict['target']))
150 152
151 153 # Retrieve pull request instance by ID from regex match.
152 154 pull_request = PullRequest.get(match_dict['pr_id'])
153 155
154 156 # Only proceed if we got a pull request and if acl repo name from
155 157 # URL equals the target repo name of the pull request.
156 158 if pull_request and (acl_repo_name ==
157 159 pull_request.target_repo.repo_name):
158 160 # Get file system path to shadow repository.
159 161 workspace_id = PullRequestModel()._workspace_id(pull_request)
160 162 target_vcs = pull_request.target_repo.scm_instance()
161 163 vcs_repo_name = target_vcs._get_shadow_repository_path(
162 164 workspace_id)
163 165
164 166 # Store names for later usage.
165 167 self.vcs_repo_name = vcs_repo_name
166 168 self.acl_repo_name = acl_repo_name
167 169 self.is_shadow_repo = True
168 170
169 171 log.debug('Setting all VCS repository names: %s', {
170 172 'acl_repo_name': self.acl_repo_name,
171 173 'url_repo_name': self.url_repo_name,
172 174 'vcs_repo_name': self.vcs_repo_name,
173 175 })
174 176
175 177 @property
176 178 def scm_app(self):
177 179 custom_implementation = self.config['vcs.scm_app_implementation']
178 180 if custom_implementation == 'http':
179 181 log.info('Using HTTP implementation of scm app.')
180 182 scm_app_impl = scm_app_http
181 183 else:
182 184 log.info('Using custom implementation of scm_app: "{}"'.format(
183 185 custom_implementation))
184 186 scm_app_impl = importlib.import_module(custom_implementation)
185 187 return scm_app_impl
186 188
187 189 def _get_by_id(self, repo_name):
188 190 """
189 191 Gets a special pattern _<ID> from clone url and tries to replace it
190 192 with a repository_name for support of _<ID> non changeable urls
191 193 """
192 194
193 195 data = repo_name.split('/')
194 196 if len(data) >= 2:
195 197 from rhodecode.model.repo import RepoModel
196 198 by_id_match = RepoModel().get_repo_by_id(repo_name)
197 199 if by_id_match:
198 200 data[1] = by_id_match.repo_name
199 201
200 202 return safe_str('/'.join(data))
201 203
202 204 def _invalidate_cache(self, repo_name):
203 205 """
204 206 Set's cache for this repository for invalidation on next access
205 207
206 208 :param repo_name: full repo name, also a cache key
207 209 """
208 210 ScmModel().mark_for_invalidation(repo_name)
209 211
210 212 def is_valid_and_existing_repo(self, repo_name, base_path, scm_type):
211 213 db_repo = Repository.get_by_repo_name(repo_name)
212 214 if not db_repo:
213 215 log.debug('Repository `%s` not found inside the database.',
214 216 repo_name)
215 217 return False
216 218
217 219 if db_repo.repo_type != scm_type:
218 220 log.warning(
219 221 'Repository `%s` have incorrect scm_type, expected %s got %s',
220 222 repo_name, db_repo.repo_type, scm_type)
221 223 return False
222 224
223 225 return is_valid_repo(repo_name, base_path, explicit_scm=scm_type)
224 226
225 227 def valid_and_active_user(self, user):
226 228 """
227 229 Checks if that user is not empty, and if it's actually object it checks
228 230 if he's active.
229 231
230 232 :param user: user object or None
231 233 :return: boolean
232 234 """
233 235 if user is None:
234 236 return False
235 237
236 238 elif user.active:
237 239 return True
238 240
239 241 return False
240 242
241 243 @property
242 244 def is_shadow_repo_dir(self):
243 245 return os.path.isdir(self.vcs_repo_name)
244 246
245 def _check_permission(self, action, user, repo_name, ip_addr=None):
247 def _check_permission(self, action, user, repo_name, ip_addr=None,
248 plugin_id='', plugin_cache_active=False, cache_ttl=0):
246 249 """
247 250 Checks permissions using action (push/pull) user and repository
248 name
251 name. If plugin_cache and ttl is set it will use the plugin which
252 authenticated the user to store the cached permissions result for N
253 amount of seconds as in cache_ttl
249 254
250 255 :param action: push or pull action
251 256 :param user: user instance
252 257 :param repo_name: repository name
253 258 """
254 # check IP
255 inherit = user.inherit_default_permissions
256 ip_allowed = AuthUser.check_ip_allowed(user.user_id, ip_addr,
257 inherit_from_default=inherit)
258 if ip_allowed:
259 log.info('Access for IP:%s allowed', ip_addr)
260 else:
261 return False
259
260 # get instance of cache manager configured for a namespace
261 cache_manager = get_perms_cache_manager(custom_ttl=cache_ttl)
262 log.debug('AUTH_CACHE_TTL for permissions `%s` active: %s (TTL: %s)',
263 plugin_id, plugin_cache_active, cache_ttl)
264
265 # for environ based password can be empty, but then the validation is
266 # on the server that fills in the env data needed for authentication
267 _perm_calc_hash = caches.compute_key_from_params(
268 plugin_id, action, user.user_id, repo_name, ip_addr)
262 269
263 if action == 'push':
264 if not HasPermissionAnyMiddleware('repository.write',
265 'repository.admin')(user,
266 repo_name):
270 # _authenticate is a wrapper for .auth() method of plugin.
271 # it checks if .auth() sends proper data.
272 # For RhodeCodeExternalAuthPlugin it also maps users to
273 # Database and maps the attributes returned from .auth()
274 # to RhodeCode database. If this function returns data
275 # then auth is correct.
276 start = time.time()
277 log.debug('Running plugin `%s` permissions check', plugin_id)
278
279 def perm_func():
280 """
281 This function is used internally in Cache of Beaker to calculate
282 Results
283 """
284 log.debug('auth: calculating permission access now...')
285 # check IP
286 inherit = user.inherit_default_permissions
287 ip_allowed = AuthUser.check_ip_allowed(
288 user.user_id, ip_addr, inherit_from_default=inherit)
289 if ip_allowed:
290 log.info('Access for IP:%s allowed', ip_addr)
291 else:
267 292 return False
268 293
294 if action == 'push':
295 perms = ('repository.write', 'repository.admin')
296 if not HasPermissionAnyMiddleware(*perms)(user, repo_name):
297 return False
298
299 else:
300 # any other action need at least read permission
301 perms = (
302 'repository.read', 'repository.write', 'repository.admin')
303 if not HasPermissionAnyMiddleware(*perms)(user, repo_name):
304 return False
305
306 return True
307
308 if plugin_cache_active:
309 log.debug('Trying to fetch cached perms by %s', _perm_calc_hash[:6])
310 perm_result = cache_manager.get(
311 _perm_calc_hash, createfunc=perm_func)
269 312 else:
270 # any other action need at least read permission
271 if not HasPermissionAnyMiddleware('repository.read',
272 'repository.write',
273 'repository.admin')(user,
274 repo_name):
275 return False
313 perm_result = perm_func()
276 314
277 return True
315 auth_time = time.time() - start
316 log.debug('Permissions for plugin `%s` completed in %.3fs, '
317 'expiration time of fetched cache %.1fs.',
318 plugin_id, auth_time, cache_ttl)
319
320 return perm_result
278 321
279 322 def _check_ssl(self, environ, start_response):
280 323 """
281 324 Checks the SSL check flag and returns False if SSL is not present
282 325 and required True otherwise
283 326 """
284 327 org_proto = environ['wsgi._org_proto']
285 328 # check if we have SSL required ! if not it's a bad request !
286 329 require_ssl = str2bool(self.repo_vcs_config.get('web', 'push_ssl'))
287 330 if require_ssl and org_proto == 'http':
288 331 log.debug('proto is %s and SSL is required BAD REQUEST !',
289 332 org_proto)
290 333 return False
291 334 return True
292 335
293 336 def __call__(self, environ, start_response):
294 337 try:
295 338 return self._handle_request(environ, start_response)
296 339 except Exception:
297 340 log.exception("Exception while handling request")
298 341 appenlight.track_exception(environ)
299 342 return HTTPInternalServerError()(environ, start_response)
300 343 finally:
301 344 meta.Session.remove()
302 345
303 346 def _handle_request(self, environ, start_response):
304 347
305 348 if not self._check_ssl(environ, start_response):
306 349 reason = ('SSL required, while RhodeCode was unable '
307 350 'to detect this as SSL request')
308 351 log.debug('User not allowed to proceed, %s', reason)
309 352 return HTTPNotAcceptable(reason)(environ, start_response)
310 353
311 354 if not self.url_repo_name:
312 355 log.warning('Repository name is empty: %s', self.url_repo_name)
313 356 # failed to get repo name, we fail now
314 357 return HTTPNotFound()(environ, start_response)
315 358 log.debug('Extracted repo name is %s', self.url_repo_name)
316 359
317 360 ip_addr = get_ip_addr(environ)
318 361 user_agent = get_user_agent(environ)
319 362 username = None
320 363
321 364 # skip passing error to error controller
322 365 environ['pylons.status_code_redirect'] = True
323 366
324 367 # ======================================================================
325 368 # GET ACTION PULL or PUSH
326 369 # ======================================================================
327 370 action = self._get_action(environ)
328 371
329 372 # ======================================================================
330 373 # Check if this is a request to a shadow repository of a pull request.
331 374 # In this case only pull action is allowed.
332 375 # ======================================================================
333 376 if self.is_shadow_repo and action != 'pull':
334 377 reason = 'Only pull action is allowed for shadow repositories.'
335 378 log.debug('User not allowed to proceed, %s', reason)
336 379 return HTTPNotAcceptable(reason)(environ, start_response)
337 380
338 381 # Check if the shadow repo actually exists, in case someone refers
339 382 # to it, and it has been deleted because of successful merge.
340 383 if self.is_shadow_repo and not self.is_shadow_repo_dir:
341 384 return HTTPNotFound()(environ, start_response)
342 385
343 386 # ======================================================================
344 387 # CHECK ANONYMOUS PERMISSION
345 388 # ======================================================================
346 389 if action in ['pull', 'push']:
347 390 anonymous_user = User.get_default_user()
348 391 username = anonymous_user.username
349 392 if anonymous_user.active:
350 393 # ONLY check permissions if the user is activated
351 394 anonymous_perm = self._check_permission(
352 395 action, anonymous_user, self.acl_repo_name, ip_addr)
353 396 else:
354 397 anonymous_perm = False
355 398
356 399 if not anonymous_user.active or not anonymous_perm:
357 400 if not anonymous_user.active:
358 401 log.debug('Anonymous access is disabled, running '
359 402 'authentication')
360 403
361 404 if not anonymous_perm:
362 405 log.debug('Not enough credentials to access this '
363 406 'repository as anonymous user')
364 407
365 408 username = None
366 409 # ==============================================================
367 410 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
368 411 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
369 412 # ==============================================================
370 413
371 414 # try to auth based on environ, container auth methods
372 415 log.debug('Running PRE-AUTH for container based authentication')
373 416 pre_auth = authenticate(
374 417 '', '', environ, VCS_TYPE, registry=self.registry,
375 418 acl_repo_name=self.acl_repo_name)
376 419 if pre_auth and pre_auth.get('username'):
377 420 username = pre_auth['username']
378 421 log.debug('PRE-AUTH got %s as username', username)
422 if pre_auth:
423 log.debug('PRE-AUTH successful from %s',
424 pre_auth.get('auth_data', {}).get('_plugin'))
379 425
380 426 # If not authenticated by the container, running basic auth
381 427 # before inject the calling repo_name for special scope checks
382 428 self.authenticate.acl_repo_name = self.acl_repo_name
429
430 plugin_cache_active, cache_ttl = False, 0
431 plugin = None
383 432 if not username:
384 433 self.authenticate.realm = self.authenticate.get_rc_realm()
385 434
386 435 try:
387 result = self.authenticate(environ)
436 auth_result = self.authenticate(environ)
388 437 except (UserCreationError, NotAllowedToCreateUserError) as e:
389 438 log.error(e)
390 439 reason = safe_str(e)
391 440 return HTTPNotAcceptable(reason)(environ, start_response)
392 441
393 if isinstance(result, str):
442 if isinstance(auth_result, dict):
394 443 AUTH_TYPE.update(environ, 'basic')
395 REMOTE_USER.update(environ, result)
396 username = result
444 REMOTE_USER.update(environ, auth_result['username'])
445 username = auth_result['username']
446 plugin = auth_result.get('auth_data', {}).get('_plugin')
447 log.info(
448 'MAIN-AUTH successful for user `%s` from %s plugin',
449 username, plugin)
450
451 plugin_cache_active, cache_ttl = auth_result.get(
452 'auth_data', {}).get('_ttl_cache') or (False, 0)
397 453 else:
398 return result.wsgi_application(environ, start_response)
454 return auth_result.wsgi_application(
455 environ, start_response)
456
399 457
400 458 # ==============================================================
401 459 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
402 460 # ==============================================================
403 461 user = User.get_by_username(username)
404 462 if not self.valid_and_active_user(user):
405 463 return HTTPForbidden()(environ, start_response)
406 464 username = user.username
407 465 user.update_lastactivity()
408 466 meta.Session().commit()
409 467
410 468 # check user attributes for password change flag
411 469 user_obj = user
412 470 if user_obj and user_obj.username != User.DEFAULT_USER and \
413 471 user_obj.user_data.get('force_password_change'):
414 472 reason = 'password change required'
415 473 log.debug('User not allowed to authenticate, %s', reason)
416 474 return HTTPNotAcceptable(reason)(environ, start_response)
417 475
418 476 # check permissions for this repository
419 477 perm = self._check_permission(
420 action, user, self.acl_repo_name, ip_addr)
478 action, user, self.acl_repo_name, ip_addr,
479 plugin, plugin_cache_active, cache_ttl)
421 480 if not perm:
422 481 return HTTPForbidden()(environ, start_response)
423 482
424 483 # extras are injected into UI object and later available
425 # in hooks executed by rhodecode
484 # in hooks executed by RhodeCode
426 485 check_locking = _should_check_locking(environ.get('QUERY_STRING'))
427 486 extras = vcs_operation_context(
428 487 environ, repo_name=self.acl_repo_name, username=username,
429 488 action=action, scm=self.SCM, check_locking=check_locking,
430 489 is_shadow_repo=self.is_shadow_repo
431 490 )
432 491
433 492 # ======================================================================
434 493 # REQUEST HANDLING
435 494 # ======================================================================
436 495 repo_path = os.path.join(
437 496 safe_str(self.basepath), safe_str(self.vcs_repo_name))
438 497 log.debug('Repository path is %s', repo_path)
439 498
440 499 fix_PATH()
441 500
442 501 log.info(
443 502 '%s action on %s repo "%s" by "%s" from %s %s',
444 503 action, self.SCM, safe_str(self.url_repo_name),
445 504 safe_str(username), ip_addr, user_agent)
446 505
447 506 return self._generate_vcs_response(
448 507 environ, start_response, repo_path, extras, action)
449 508
450 509 @initialize_generator
451 510 def _generate_vcs_response(
452 511 self, environ, start_response, repo_path, extras, action):
453 512 """
454 513 Returns a generator for the response content.
455 514
456 515 This method is implemented as a generator, so that it can trigger
457 516 the cache validation after all content sent back to the client. It
458 517 also handles the locking exceptions which will be triggered when
459 518 the first chunk is produced by the underlying WSGI application.
460 519 """
461 520 callback_daemon, extras = self._prepare_callback_daemon(extras)
462 521 config = self._create_config(extras, self.acl_repo_name)
463 522 log.debug('HOOKS extras is %s', extras)
464 523 app = self._create_wsgi_app(repo_path, self.url_repo_name, config)
465 524
466 525 try:
467 526 with callback_daemon:
468 527 try:
469 528 response = app(environ, start_response)
470 529 finally:
471 530 # This statement works together with the decorator
472 531 # "initialize_generator" above. The decorator ensures that
473 532 # we hit the first yield statement before the generator is
474 533 # returned back to the WSGI server. This is needed to
475 534 # ensure that the call to "app" above triggers the
476 535 # needed callback to "start_response" before the
477 536 # generator is actually used.
478 537 yield "__init__"
479 538
480 539 for chunk in response:
481 540 yield chunk
482 541 except Exception as exc:
483 542 # TODO: martinb: Exceptions are only raised in case of the Pyro4
484 543 # backend. Refactor this except block after dropping Pyro4 support.
485 544 # TODO: johbo: Improve "translating" back the exception.
486 545 if getattr(exc, '_vcs_kind', None) == 'repo_locked':
487 546 exc = HTTPLockedRC(*exc.args)
488 547 _code = rhodecode.CONFIG.get('lock_ret_code')
489 548 log.debug('Repository LOCKED ret code %s!', (_code,))
490 549 elif getattr(exc, '_vcs_kind', None) == 'requirement':
491 550 log.debug(
492 551 'Repository requires features unknown to this Mercurial')
493 552 exc = HTTPRequirementError(*exc.args)
494 553 else:
495 554 raise
496 555
497 556 for chunk in exc(environ, start_response):
498 557 yield chunk
499 558 finally:
500 559 # invalidate cache on push
501 560 try:
502 561 if action == 'push':
503 562 self._invalidate_cache(self.url_repo_name)
504 563 finally:
505 564 meta.Session.remove()
506 565
507 566 def _get_repository_name(self, environ):
508 567 """Get repository name out of the environmnent
509 568
510 569 :param environ: WSGI environment
511 570 """
512 571 raise NotImplementedError()
513 572
514 573 def _get_action(self, environ):
515 574 """Map request commands into a pull or push command.
516 575
517 576 :param environ: WSGI environment
518 577 """
519 578 raise NotImplementedError()
520 579
521 580 def _create_wsgi_app(self, repo_path, repo_name, config):
522 581 """Return the WSGI app that will finally handle the request."""
523 582 raise NotImplementedError()
524 583
525 584 def _create_config(self, extras, repo_name):
526 585 """Create a safe config representation."""
527 586 raise NotImplementedError()
528 587
529 588 def _prepare_callback_daemon(self, extras):
530 589 return prepare_callback_daemon(
531 590 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
532 591 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
533 592
534 593
535 594 def _should_check_locking(query_string):
536 595 # this is kind of hacky, but due to how mercurial handles client-server
537 596 # server see all operation on commit; bookmarks, phases and
538 597 # obsolescence marker in different transaction, we don't want to check
539 598 # locking on those
540 599 return query_string not in ['cmd=listkeys']
1 NO CONTENT: modified file
The requested commit or file is too big and content was truncated. Show full diff
@@ -1,183 +1,188 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2017 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 mock
22 22 import pytest
23 23
24 24 from rhodecode.lib.auth import _RhodeCodeCryptoBCrypt
25 25 from rhodecode.authentication.base import RhodeCodeAuthPluginBase
26 26 from rhodecode.authentication.plugins.auth_ldap import RhodeCodeAuthPlugin
27 27 from rhodecode.model import db
28 28
29 29
30 class TestAuthPlugin(RhodeCodeAuthPluginBase):
31
32 def name(self):
33 return 'stub_auth'
34
30 35 def test_authenticate_returns_from_auth(stub_auth_data):
31 plugin = RhodeCodeAuthPluginBase('stub_id')
36 plugin = TestAuthPlugin('stub_id')
32 37 with mock.patch.object(plugin, 'auth') as auth_mock:
33 38 auth_mock.return_value = stub_auth_data
34 39 result = plugin._authenticate(mock.Mock(), 'test', 'password', {})
35 40 assert stub_auth_data == result
36 41
37 42
38 43 def test_authenticate_returns_empty_auth_data():
39 44 auth_data = {}
40 plugin = RhodeCodeAuthPluginBase('stub_id')
45 plugin = TestAuthPlugin('stub_id')
41 46 with mock.patch.object(plugin, 'auth') as auth_mock:
42 47 auth_mock.return_value = auth_data
43 48 result = plugin._authenticate(mock.Mock(), 'test', 'password', {})
44 49 assert auth_data == result
45 50
46 51
47 52 def test_authenticate_skips_hash_migration_if_mismatch(stub_auth_data):
48 53 stub_auth_data['_hash_migrate'] = 'new-hash'
49 plugin = RhodeCodeAuthPluginBase('stub_id')
54 plugin = TestAuthPlugin('stub_id')
50 55 with mock.patch.object(plugin, 'auth') as auth_mock:
51 56 auth_mock.return_value = stub_auth_data
52 57 result = plugin._authenticate(mock.Mock(), 'test', 'password', {})
53 58
54 59 user = db.User.get_by_username(stub_auth_data['username'])
55 60 assert user.password != 'new-hash'
56 61 assert result == stub_auth_data
57 62
58 63
59 64 def test_authenticate_migrates_to_new_hash(stub_auth_data):
60 65 new_password = b'new-password'
61 66 new_hash = _RhodeCodeCryptoBCrypt().hash_create(new_password)
62 67 stub_auth_data['_hash_migrate'] = new_hash
63 plugin = RhodeCodeAuthPluginBase('stub_id')
68 plugin = TestAuthPlugin('stub_id')
64 69 with mock.patch.object(plugin, 'auth') as auth_mock:
65 70 auth_mock.return_value = stub_auth_data
66 71 result = plugin._authenticate(
67 72 mock.Mock(), stub_auth_data['username'], new_password, {})
68 73
69 74 user = db.User.get_by_username(stub_auth_data['username'])
70 75 assert user.password == new_hash
71 76 assert result == stub_auth_data
72 77
73 78
74 79 @pytest.fixture
75 80 def stub_auth_data(user_util):
76 81 user = user_util.create_user()
77 82 data = {
78 83 'username': user.username,
79 84 'password': 'password',
80 85 'email': 'test@example.org',
81 86 'firstname': 'John',
82 87 'lastname': 'Smith',
83 88 'groups': [],
84 89 'active': True,
85 90 'admin': False,
86 91 'extern_name': 'test',
87 92 'extern_type': 'ldap',
88 93 'active_from_extern': True
89 94 }
90 95 return data
91 96
92 97
93 98 class TestRhodeCodeAuthPlugin(object):
94 99 def setup_method(self, method):
95 100 self.finalizers = []
96 101 self.user = mock.Mock()
97 102 self.user.username = 'test'
98 103 self.user.password = 'old-password'
99 104 self.fake_auth = {
100 105 'username': 'test',
101 106 'password': 'test',
102 107 'email': 'test@example.org',
103 108 'firstname': 'John',
104 109 'lastname': 'Smith',
105 110 'groups': [],
106 111 'active': True,
107 112 'admin': False,
108 113 'extern_name': 'test',
109 114 'extern_type': 'ldap',
110 115 'active_from_extern': True
111 116 }
112 117
113 118 def teardown_method(self, method):
114 119 if self.finalizers:
115 120 for finalizer in self.finalizers:
116 121 finalizer()
117 122 self.finalizers = []
118 123
119 124 def test_fake_password_is_created_for_the_new_user(self):
120 125 self._patch()
121 126 auth_plugin = RhodeCodeAuthPlugin('stub_id')
122 127 auth_plugin._authenticate(self.user, 'test', 'test', [])
123 128 self.password_generator_mock.assert_called_once_with(length=16)
124 129 create_user_kwargs = self.create_user_mock.call_args[1]
125 130 assert create_user_kwargs['password'] == 'new-password'
126 131
127 132 def test_fake_password_is_not_created_for_the_existing_user(self):
128 133 self._patch()
129 134 self.get_user_mock.return_value = self.user
130 135 auth_plugin = RhodeCodeAuthPlugin('stub_id')
131 136 auth_plugin._authenticate(self.user, 'test', 'test', [])
132 137 assert self.password_generator_mock.called is False
133 138 create_user_kwargs = self.create_user_mock.call_args[1]
134 139 assert create_user_kwargs['password'] == self.user.password
135 140
136 141 def _patch(self):
137 142 get_user_patch = mock.patch('rhodecode.model.db.User.get_by_username')
138 143 self.get_user_mock = get_user_patch.start()
139 144 self.get_user_mock.return_value = None
140 145 self.finalizers.append(get_user_patch.stop)
141 146
142 147 create_user_patch = mock.patch(
143 148 'rhodecode.model.user.UserModel.create_or_update')
144 149 self.create_user_mock = create_user_patch.start()
145 150 self.create_user_mock.return_value = None
146 151 self.finalizers.append(create_user_patch.stop)
147 152
148 153 auth_patch = mock.patch.object(RhodeCodeAuthPlugin, 'auth')
149 154 self.auth_mock = auth_patch.start()
150 155 self.auth_mock.return_value = self.fake_auth
151 156 self.finalizers.append(auth_patch.stop)
152 157
153 158 password_generator_patch = mock.patch(
154 159 'rhodecode.lib.auth.PasswordGenerator.gen_password')
155 160 self.password_generator_mock = password_generator_patch.start()
156 161 self.password_generator_mock.return_value = 'new-password'
157 162 self.finalizers.append(password_generator_patch.stop)
158 163
159 164
160 165 def test_missing_ldap():
161 166 from rhodecode.model.validators import Missing
162 167
163 168 try:
164 169 import ldap_not_existing
165 170 except ImportError:
166 171 # means that python-ldap is not installed
167 172 ldap_not_existing = Missing
168 173
169 174 # missing is singleton
170 175 assert ldap_not_existing == Missing
171 176
172 177
173 178 def test_import_ldap():
174 179 from rhodecode.model.validators import Missing
175 180
176 181 try:
177 182 import ldap
178 183 except ImportError:
179 184 # means that python-ldap is not installed
180 185 ldap = Missing
181 186
182 187 # missing is singleton
183 188 assert False is (ldap == Missing)
General Comments 0
You need to be logged in to leave comments. Login now