##// END OF EJS Templates
vcs: Pass registry to vcs for user authentication....
Martin Bornhold -
r591:bc63cba1 default
parent child Browse files
Show More
@@ -1,620 +1,620 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2016 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 logging
27 27 import time
28 28 import traceback
29 29 import warnings
30 30
31 31 from pyramid.threadlocal import get_current_registry
32 32 from sqlalchemy.ext.hybrid import hybrid_property
33 33
34 34 from rhodecode.authentication.interface import IAuthnPluginRegistry
35 35 from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
36 36 from rhodecode.lib import caches
37 37 from rhodecode.lib.auth import PasswordGenerator, _RhodeCodeCryptoBCrypt
38 38 from rhodecode.lib.utils2 import md5_safe, safe_int
39 39 from rhodecode.lib.utils2 import safe_str
40 40 from rhodecode.model.db import User
41 41 from rhodecode.model.meta import Session
42 42 from rhodecode.model.settings import SettingsModel
43 43 from rhodecode.model.user import UserModel
44 44 from rhodecode.model.user_group import UserGroupModel
45 45
46 46
47 47 log = logging.getLogger(__name__)
48 48
49 49 # auth types that authenticate() function can receive
50 50 VCS_TYPE = 'vcs'
51 51 HTTP_TYPE = 'http'
52 52
53 53
54 54 class LazyFormencode(object):
55 55 def __init__(self, formencode_obj, *args, **kwargs):
56 56 self.formencode_obj = formencode_obj
57 57 self.args = args
58 58 self.kwargs = kwargs
59 59
60 60 def __call__(self, *args, **kwargs):
61 61 from inspect import isfunction
62 62 formencode_obj = self.formencode_obj
63 63 if isfunction(formencode_obj):
64 64 # case we wrap validators into functions
65 65 formencode_obj = self.formencode_obj(*args, **kwargs)
66 66 return formencode_obj(*self.args, **self.kwargs)
67 67
68 68
69 69 class RhodeCodeAuthPluginBase(object):
70 70 # cache the authentication request for N amount of seconds. Some kind
71 71 # of authentication methods are very heavy and it's very efficient to cache
72 72 # the result of a call. If it's set to None (default) cache is off
73 73 AUTH_CACHE_TTL = None
74 74 AUTH_CACHE = {}
75 75
76 76 auth_func_attrs = {
77 77 "username": "unique username",
78 78 "firstname": "first name",
79 79 "lastname": "last name",
80 80 "email": "email address",
81 81 "groups": '["list", "of", "groups"]',
82 82 "extern_name": "name in external source of record",
83 83 "extern_type": "type of external source of record",
84 84 "admin": 'True|False defines if user should be RhodeCode super admin',
85 85 "active":
86 86 'True|False defines active state of user internally for RhodeCode',
87 87 "active_from_extern":
88 88 "True|False\None, active state from the external auth, "
89 89 "None means use definition from RhodeCode extern_type active value"
90 90 }
91 91 # set on authenticate() method and via set_auth_type func.
92 92 auth_type = None
93 93
94 94 # List of setting names to store encrypted. Plugins may override this list
95 95 # to store settings encrypted.
96 96 _settings_encrypted = []
97 97
98 98 # Mapping of python to DB settings model types. Plugins may override or
99 99 # extend this mapping.
100 100 _settings_type_map = {
101 101 colander.String: 'unicode',
102 102 colander.Integer: 'int',
103 103 colander.Boolean: 'bool',
104 104 colander.List: 'list',
105 105 }
106 106
107 107 def __init__(self, plugin_id):
108 108 self._plugin_id = plugin_id
109 109
110 110 def __str__(self):
111 111 return self.get_id()
112 112
113 113 def _get_setting_full_name(self, name):
114 114 """
115 115 Return the full setting name used for storing values in the database.
116 116 """
117 117 # TODO: johbo: Using the name here is problematic. It would be good to
118 118 # introduce either new models in the database to hold Plugin and
119 119 # PluginSetting or to use the plugin id here.
120 120 return 'auth_{}_{}'.format(self.name, name)
121 121
122 122 def _get_setting_type(self, name):
123 123 """
124 124 Return the type of a setting. This type is defined by the SettingsModel
125 125 and determines how the setting is stored in DB. Optionally the suffix
126 126 `.encrypted` is appended to instruct SettingsModel to store it
127 127 encrypted.
128 128 """
129 129 schema_node = self.get_settings_schema().get(name)
130 130 db_type = self._settings_type_map.get(
131 131 type(schema_node.typ), 'unicode')
132 132 if name in self._settings_encrypted:
133 133 db_type = '{}.encrypted'.format(db_type)
134 134 return db_type
135 135
136 136 def is_enabled(self):
137 137 """
138 138 Returns true if this plugin is enabled. An enabled plugin can be
139 139 configured in the admin interface but it is not consulted during
140 140 authentication.
141 141 """
142 142 auth_plugins = SettingsModel().get_auth_plugins()
143 143 return self.get_id() in auth_plugins
144 144
145 145 def is_active(self):
146 146 """
147 147 Returns true if the plugin is activated. An activated plugin is
148 148 consulted during authentication, assumed it is also enabled.
149 149 """
150 150 return self.get_setting_by_name('enabled')
151 151
152 152 def get_id(self):
153 153 """
154 154 Returns the plugin id.
155 155 """
156 156 return self._plugin_id
157 157
158 158 def get_display_name(self):
159 159 """
160 160 Returns a translation string for displaying purposes.
161 161 """
162 162 raise NotImplementedError('Not implemented in base class')
163 163
164 164 def get_settings_schema(self):
165 165 """
166 166 Returns a colander schema, representing the plugin settings.
167 167 """
168 168 return AuthnPluginSettingsSchemaBase()
169 169
170 170 def get_setting_by_name(self, name, default=None):
171 171 """
172 172 Returns a plugin setting by name.
173 173 """
174 174 full_name = self._get_setting_full_name(name)
175 175 db_setting = SettingsModel().get_setting_by_name(full_name)
176 176 return db_setting.app_settings_value if db_setting else default
177 177
178 178 def create_or_update_setting(self, name, value):
179 179 """
180 180 Create or update a setting for this plugin in the persistent storage.
181 181 """
182 182 full_name = self._get_setting_full_name(name)
183 183 type_ = self._get_setting_type(name)
184 184 db_setting = SettingsModel().create_or_update_setting(
185 185 full_name, value, type_)
186 186 return db_setting.app_settings_value
187 187
188 188 def get_settings(self):
189 189 """
190 190 Returns the plugin settings as dictionary.
191 191 """
192 192 settings = {}
193 193 for node in self.get_settings_schema():
194 194 settings[node.name] = self.get_setting_by_name(node.name)
195 195 return settings
196 196
197 197 @property
198 198 def validators(self):
199 199 """
200 200 Exposes RhodeCode validators modules
201 201 """
202 202 # this is a hack to overcome issues with pylons threadlocals and
203 203 # translator object _() not beein registered properly.
204 204 class LazyCaller(object):
205 205 def __init__(self, name):
206 206 self.validator_name = name
207 207
208 208 def __call__(self, *args, **kwargs):
209 209 from rhodecode.model import validators as v
210 210 obj = getattr(v, self.validator_name)
211 211 # log.debug('Initializing lazy formencode object: %s', obj)
212 212 return LazyFormencode(obj, *args, **kwargs)
213 213
214 214 class ProxyGet(object):
215 215 def __getattribute__(self, name):
216 216 return LazyCaller(name)
217 217
218 218 return ProxyGet()
219 219
220 220 @hybrid_property
221 221 def name(self):
222 222 """
223 223 Returns the name of this authentication plugin.
224 224
225 225 :returns: string
226 226 """
227 227 raise NotImplementedError("Not implemented in base class")
228 228
229 229 @property
230 230 def is_headers_auth(self):
231 231 """
232 232 Returns True if this authentication plugin uses HTTP headers as
233 233 authentication method.
234 234 """
235 235 return False
236 236
237 237 @hybrid_property
238 238 def is_container_auth(self):
239 239 """
240 240 Deprecated method that indicates if this authentication plugin uses
241 241 HTTP headers as authentication method.
242 242 """
243 243 warnings.warn(
244 244 'Use is_headers_auth instead.', category=DeprecationWarning)
245 245 return self.is_headers_auth
246 246
247 247 @hybrid_property
248 248 def allows_creating_users(self):
249 249 """
250 250 Defines if Plugin allows users to be created on-the-fly when
251 251 authentication is called. Controls how external plugins should behave
252 252 in terms if they are allowed to create new users, or not. Base plugins
253 253 should not be allowed to, but External ones should be !
254 254
255 255 :return: bool
256 256 """
257 257 return False
258 258
259 259 def set_auth_type(self, auth_type):
260 260 self.auth_type = auth_type
261 261
262 262 def allows_authentication_from(
263 263 self, user, allows_non_existing_user=True,
264 264 allowed_auth_plugins=None, allowed_auth_sources=None):
265 265 """
266 266 Checks if this authentication module should accept a request for
267 267 the current user.
268 268
269 269 :param user: user object fetched using plugin's get_user() method.
270 270 :param allows_non_existing_user: if True, don't allow the
271 271 user to be empty, meaning not existing in our database
272 272 :param allowed_auth_plugins: if provided, users extern_type will be
273 273 checked against a list of provided extern types, which are plugin
274 274 auth_names in the end
275 275 :param allowed_auth_sources: authentication type allowed,
276 276 `http` or `vcs` default is both.
277 277 defines if plugin will accept only http authentication vcs
278 278 authentication(git/hg) or both
279 279 :returns: boolean
280 280 """
281 281 if not user and not allows_non_existing_user:
282 282 log.debug('User is empty but plugin does not allow empty users,'
283 283 'not allowed to authenticate')
284 284 return False
285 285
286 286 expected_auth_plugins = allowed_auth_plugins or [self.name]
287 287 if user and (user.extern_type and
288 288 user.extern_type not in expected_auth_plugins):
289 289 log.debug(
290 290 'User `%s` is bound to `%s` auth type. Plugin allows only '
291 291 '%s, skipping', user, user.extern_type, expected_auth_plugins)
292 292
293 293 return False
294 294
295 295 # by default accept both
296 296 expected_auth_from = allowed_auth_sources or [HTTP_TYPE, VCS_TYPE]
297 297 if self.auth_type not in expected_auth_from:
298 298 log.debug('Current auth source is %s but plugin only allows %s',
299 299 self.auth_type, expected_auth_from)
300 300 return False
301 301
302 302 return True
303 303
304 304 def get_user(self, username=None, **kwargs):
305 305 """
306 306 Helper method for user fetching in plugins, by default it's using
307 307 simple fetch by username, but this method can be custimized in plugins
308 308 eg. headers auth plugin to fetch user by environ params
309 309
310 310 :param username: username if given to fetch from database
311 311 :param kwargs: extra arguments needed for user fetching.
312 312 """
313 313 user = None
314 314 log.debug(
315 315 'Trying to fetch user `%s` from RhodeCode database', username)
316 316 if username:
317 317 user = User.get_by_username(username)
318 318 if not user:
319 319 log.debug('User not found, fallback to fetch user in '
320 320 'case insensitive mode')
321 321 user = User.get_by_username(username, case_insensitive=True)
322 322 else:
323 323 log.debug('provided username:`%s` is empty skipping...', username)
324 324 if not user:
325 325 log.debug('User `%s` not found in database', username)
326 326 return user
327 327
328 328 def user_activation_state(self):
329 329 """
330 330 Defines user activation state when creating new users
331 331
332 332 :returns: boolean
333 333 """
334 334 raise NotImplementedError("Not implemented in base class")
335 335
336 336 def auth(self, userobj, username, passwd, settings, **kwargs):
337 337 """
338 338 Given a user object (which may be null), username, a plaintext
339 339 password, and a settings object (containing all the keys needed as
340 340 listed in settings()), authenticate this user's login attempt.
341 341
342 342 Return None on failure. On success, return a dictionary of the form:
343 343
344 344 see: RhodeCodeAuthPluginBase.auth_func_attrs
345 345 This is later validated for correctness
346 346 """
347 347 raise NotImplementedError("not implemented in base class")
348 348
349 349 def _authenticate(self, userobj, username, passwd, settings, **kwargs):
350 350 """
351 351 Wrapper to call self.auth() that validates call on it
352 352
353 353 :param userobj: userobj
354 354 :param username: username
355 355 :param passwd: plaintext password
356 356 :param settings: plugin settings
357 357 """
358 358 auth = self.auth(userobj, username, passwd, settings, **kwargs)
359 359 if auth:
360 360 # check if hash should be migrated ?
361 361 new_hash = auth.get('_hash_migrate')
362 362 if new_hash:
363 363 self._migrate_hash_to_bcrypt(username, passwd, new_hash)
364 364 return self._validate_auth_return(auth)
365 365 return auth
366 366
367 367 def _migrate_hash_to_bcrypt(self, username, password, new_hash):
368 368 new_hash_cypher = _RhodeCodeCryptoBCrypt()
369 369 # extra checks, so make sure new hash is correct.
370 370 password_encoded = safe_str(password)
371 371 if new_hash and new_hash_cypher.hash_check(
372 372 password_encoded, new_hash):
373 373 cur_user = User.get_by_username(username)
374 374 cur_user.password = new_hash
375 375 Session().add(cur_user)
376 376 Session().flush()
377 377 log.info('Migrated user %s hash to bcrypt', cur_user)
378 378
379 379 def _validate_auth_return(self, ret):
380 380 if not isinstance(ret, dict):
381 381 raise Exception('returned value from auth must be a dict')
382 382 for k in self.auth_func_attrs:
383 383 if k not in ret:
384 384 raise Exception('Missing %s attribute from returned data' % k)
385 385 return ret
386 386
387 387
388 388 class RhodeCodeExternalAuthPlugin(RhodeCodeAuthPluginBase):
389 389
390 390 @hybrid_property
391 391 def allows_creating_users(self):
392 392 return True
393 393
394 394 def use_fake_password(self):
395 395 """
396 396 Return a boolean that indicates whether or not we should set the user's
397 397 password to a random value when it is authenticated by this plugin.
398 398 If your plugin provides authentication, then you will generally
399 399 want this.
400 400
401 401 :returns: boolean
402 402 """
403 403 raise NotImplementedError("Not implemented in base class")
404 404
405 405 def _authenticate(self, userobj, username, passwd, settings, **kwargs):
406 406 # at this point _authenticate calls plugin's `auth()` function
407 407 auth = super(RhodeCodeExternalAuthPlugin, self)._authenticate(
408 408 userobj, username, passwd, settings, **kwargs)
409 409 if auth:
410 410 # maybe plugin will clean the username ?
411 411 # we should use the return value
412 412 username = auth['username']
413 413
414 414 # if external source tells us that user is not active, we should
415 415 # skip rest of the process. This can prevent from creating users in
416 416 # RhodeCode when using external authentication, but if it's
417 417 # inactive user we shouldn't create that user anyway
418 418 if auth['active_from_extern'] is False:
419 419 log.warning(
420 420 "User %s authenticated against %s, but is inactive",
421 421 username, self.__module__)
422 422 return None
423 423
424 424 cur_user = User.get_by_username(username, case_insensitive=True)
425 425 is_user_existing = cur_user is not None
426 426
427 427 if is_user_existing:
428 428 log.debug('Syncing user `%s` from '
429 429 '`%s` plugin', username, self.name)
430 430 else:
431 431 log.debug('Creating non existing user `%s` from '
432 432 '`%s` plugin', username, self.name)
433 433
434 434 if self.allows_creating_users:
435 435 log.debug('Plugin `%s` allows to '
436 436 'create new users', self.name)
437 437 else:
438 438 log.debug('Plugin `%s` does not allow to '
439 439 'create new users', self.name)
440 440
441 441 user_parameters = {
442 442 'username': username,
443 443 'email': auth["email"],
444 444 'firstname': auth["firstname"],
445 445 'lastname': auth["lastname"],
446 446 'active': auth["active"],
447 447 'admin': auth["admin"],
448 448 'extern_name': auth["extern_name"],
449 449 'extern_type': self.name,
450 450 'plugin': self,
451 451 'allow_to_create_user': self.allows_creating_users,
452 452 }
453 453
454 454 if not is_user_existing:
455 455 if self.use_fake_password():
456 456 # Randomize the PW because we don't need it, but don't want
457 457 # them blank either
458 458 passwd = PasswordGenerator().gen_password(length=16)
459 459 user_parameters['password'] = passwd
460 460 else:
461 461 # Since the password is required by create_or_update method of
462 462 # UserModel, we need to set it explicitly.
463 463 # The create_or_update method is smart and recognises the
464 464 # password hashes as well.
465 465 user_parameters['password'] = cur_user.password
466 466
467 467 # we either create or update users, we also pass the flag
468 468 # that controls if this method can actually do that.
469 469 # raises NotAllowedToCreateUserError if it cannot, and we try to.
470 470 user = UserModel().create_or_update(**user_parameters)
471 471 Session().flush()
472 472 # enforce user is just in given groups, all of them has to be ones
473 473 # created from plugins. We store this info in _group_data JSON
474 474 # field
475 475 try:
476 476 groups = auth['groups'] or []
477 477 UserGroupModel().enforce_groups(user, groups, self.name)
478 478 except Exception:
479 479 # for any reason group syncing fails, we should
480 480 # proceed with login
481 481 log.error(traceback.format_exc())
482 482 Session().commit()
483 483 return auth
484 484
485 485
486 486 def loadplugin(plugin_id):
487 487 """
488 488 Loads and returns an instantiated authentication plugin.
489 489 Returns the RhodeCodeAuthPluginBase subclass on success,
490 490 or None on failure.
491 491 """
492 492 # TODO: Disusing pyramids thread locals to retrieve the registry.
493 493 authn_registry = get_authn_registry()
494 494 plugin = authn_registry.get_plugin(plugin_id)
495 495 if plugin is None:
496 496 log.error('Authentication plugin not found: "%s"', plugin_id)
497 497 return plugin
498 498
499 499
500 500 def get_authn_registry(registry=None):
501 501 registry = registry or get_current_registry()
502 502 authn_registry = registry.getUtility(IAuthnPluginRegistry)
503 503 return authn_registry
504 504
505 505
506 506 def get_auth_cache_manager(custom_ttl=None):
507 507 return caches.get_cache_manager(
508 508 'auth_plugins', 'rhodecode.authentication', custom_ttl)
509 509
510 510
511 511 def authenticate(username, password, environ=None, auth_type=None,
512 skip_missing=False):
512 skip_missing=False, registry=None):
513 513 """
514 514 Authentication function used for access control,
515 515 It tries to authenticate based on enabled authentication modules.
516 516
517 517 :param username: username can be empty for headers auth
518 518 :param password: password can be empty for headers auth
519 519 :param environ: environ headers passed for headers auth
520 520 :param auth_type: type of authentication, either `HTTP_TYPE` or `VCS_TYPE`
521 521 :param skip_missing: ignores plugins that are in db but not in environment
522 522 :returns: None if auth failed, plugin_user dict if auth is correct
523 523 """
524 524 if not auth_type or auth_type not in [HTTP_TYPE, VCS_TYPE]:
525 525 raise ValueError('auth type must be on of http, vcs got "%s" instead'
526 526 % auth_type)
527 527 headers_only = environ and not (username and password)
528 528
529 authn_registry = get_authn_registry()
529 authn_registry = get_authn_registry(registry)
530 530 for plugin in authn_registry.get_plugins_for_authentication():
531 531 plugin.set_auth_type(auth_type)
532 532 user = plugin.get_user(username)
533 533 display_user = user.username if user else username
534 534
535 535 if headers_only and not plugin.is_headers_auth:
536 536 log.debug('Auth type is for headers only and plugin `%s` is not '
537 537 'headers plugin, skipping...', plugin.get_id())
538 538 continue
539 539
540 540 # load plugin settings from RhodeCode database
541 541 plugin_settings = plugin.get_settings()
542 542 log.debug('Plugin settings:%s', plugin_settings)
543 543
544 544 log.debug('Trying authentication using ** %s **', plugin.get_id())
545 545 # use plugin's method of user extraction.
546 546 user = plugin.get_user(username, environ=environ,
547 547 settings=plugin_settings)
548 548 display_user = user.username if user else username
549 549 log.debug(
550 550 'Plugin %s extracted user is `%s`', plugin.get_id(), display_user)
551 551
552 552 if not plugin.allows_authentication_from(user):
553 553 log.debug('Plugin %s does not accept user `%s` for authentication',
554 554 plugin.get_id(), display_user)
555 555 continue
556 556 else:
557 557 log.debug('Plugin %s accepted user `%s` for authentication',
558 558 plugin.get_id(), display_user)
559 559
560 560 log.info('Authenticating user `%s` using %s plugin',
561 561 display_user, plugin.get_id())
562 562
563 563 _cache_ttl = 0
564 564
565 565 if isinstance(plugin.AUTH_CACHE_TTL, (int, long)):
566 566 # plugin cache set inside is more important than the settings value
567 567 _cache_ttl = plugin.AUTH_CACHE_TTL
568 568 elif plugin_settings.get('cache_ttl'):
569 569 _cache_ttl = safe_int(plugin_settings.get('cache_ttl'), 0)
570 570
571 571 plugin_cache_active = bool(_cache_ttl and _cache_ttl > 0)
572 572
573 573 # get instance of cache manager configured for a namespace
574 574 cache_manager = get_auth_cache_manager(custom_ttl=_cache_ttl)
575 575
576 576 log.debug('AUTH_CACHE_TTL for plugin `%s` active: %s (TTL: %s)',
577 577 plugin.get_id(), plugin_cache_active, _cache_ttl)
578 578
579 579 # for environ based password can be empty, but then the validation is
580 580 # on the server that fills in the env data needed for authentication
581 581 _password_hash = md5_safe(plugin.name + username + (password or ''))
582 582
583 583 # _authenticate is a wrapper for .auth() method of plugin.
584 584 # it checks if .auth() sends proper data.
585 585 # For RhodeCodeExternalAuthPlugin it also maps users to
586 586 # Database and maps the attributes returned from .auth()
587 587 # to RhodeCode database. If this function returns data
588 588 # then auth is correct.
589 589 start = time.time()
590 590 log.debug('Running plugin `%s` _authenticate method', plugin.get_id())
591 591
592 592 def auth_func():
593 593 """
594 594 This function is used internally in Cache of Beaker to calculate
595 595 Results
596 596 """
597 597 return plugin._authenticate(
598 598 user, username, password, plugin_settings,
599 599 environ=environ or {})
600 600
601 601 if plugin_cache_active:
602 602 plugin_user = cache_manager.get(
603 603 _password_hash, createfunc=auth_func)
604 604 else:
605 605 plugin_user = auth_func()
606 606
607 607 auth_time = time.time() - start
608 608 log.debug('Authentication for plugin `%s` completed in %.3fs, '
609 609 'expiration time of fetched cache %.1fs.',
610 610 plugin.get_id(), auth_time, _cache_ttl)
611 611
612 612 log.debug('PLUGIN USER DATA: %s', plugin_user)
613 613
614 614 if plugin_user:
615 615 log.debug('Plugin returned proper authentication data')
616 616 return plugin_user
617 617 # we failed to Auth because .auth() method didn't return proper user
618 618 log.debug("User `%s` failed to authenticate against %s",
619 619 display_user, plugin.get_id())
620 620 return None
@@ -1,585 +1,587 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2016 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 ipaddress
31 31
32 32 from paste.auth.basic import AuthBasicAuthenticator
33 33 from paste.httpexceptions import HTTPUnauthorized, HTTPForbidden, get_exception
34 34 from paste.httpheaders import WWW_AUTHENTICATE, AUTHORIZATION
35 35 from pylons import config, tmpl_context as c, request, session, url
36 36 from pylons.controllers import WSGIController
37 37 from pylons.controllers.util import redirect
38 38 from pylons.i18n import translation
39 39 # marcink: don't remove this import
40 40 from pylons.templating import render_mako as render # noqa
41 41 from pylons.i18n.translation import _
42 42 from webob.exc import HTTPFound
43 43
44 44
45 45 import rhodecode
46 46 from rhodecode.authentication.base import VCS_TYPE
47 47 from rhodecode.lib import auth, utils2
48 48 from rhodecode.lib import helpers as h
49 49 from rhodecode.lib.auth import AuthUser, CookieStoreWrapper
50 50 from rhodecode.lib.exceptions import UserCreationError
51 51 from rhodecode.lib.utils import (
52 52 get_repo_slug, set_rhodecode_config, password_changed,
53 53 get_enabled_hook_classes)
54 54 from rhodecode.lib.utils2 import (
55 55 str2bool, safe_unicode, AttributeDict, safe_int, md5, aslist)
56 56 from rhodecode.lib.vcs.exceptions import RepositoryRequirementError
57 57 from rhodecode.model import meta
58 58 from rhodecode.model.db import Repository, User
59 59 from rhodecode.model.notification import NotificationModel
60 60 from rhodecode.model.scm import ScmModel
61 61 from rhodecode.model.settings import VcsSettingsModel, SettingsModel
62 62
63 63
64 64 log = logging.getLogger(__name__)
65 65
66 66
67 67 def _filter_proxy(ip):
68 68 """
69 69 Passed in IP addresses in HEADERS can be in a special format of multiple
70 70 ips. Those comma separated IPs are passed from various proxies in the
71 71 chain of request processing. The left-most being the original client.
72 72 We only care about the first IP which came from the org. client.
73 73
74 74 :param ip: ip string from headers
75 75 """
76 76 if ',' in ip:
77 77 _ips = ip.split(',')
78 78 _first_ip = _ips[0].strip()
79 79 log.debug('Got multiple IPs %s, using %s', ','.join(_ips), _first_ip)
80 80 return _first_ip
81 81 return ip
82 82
83 83
84 84 def _filter_port(ip):
85 85 """
86 86 Removes a port from ip, there are 4 main cases to handle here.
87 87 - ipv4 eg. 127.0.0.1
88 88 - ipv6 eg. ::1
89 89 - ipv4+port eg. 127.0.0.1:8080
90 90 - ipv6+port eg. [::1]:8080
91 91
92 92 :param ip:
93 93 """
94 94 def is_ipv6(ip_addr):
95 95 if hasattr(socket, 'inet_pton'):
96 96 try:
97 97 socket.inet_pton(socket.AF_INET6, ip_addr)
98 98 except socket.error:
99 99 return False
100 100 else:
101 101 # fallback to ipaddress
102 102 try:
103 103 ipaddress.IPv6Address(ip_addr)
104 104 except Exception:
105 105 return False
106 106 return True
107 107
108 108 if ':' not in ip: # must be ipv4 pure ip
109 109 return ip
110 110
111 111 if '[' in ip and ']' in ip: # ipv6 with port
112 112 return ip.split(']')[0][1:].lower()
113 113
114 114 # must be ipv6 or ipv4 with port
115 115 if is_ipv6(ip):
116 116 return ip
117 117 else:
118 118 ip, _port = ip.split(':')[:2] # means ipv4+port
119 119 return ip
120 120
121 121
122 122 def get_ip_addr(environ):
123 123 proxy_key = 'HTTP_X_REAL_IP'
124 124 proxy_key2 = 'HTTP_X_FORWARDED_FOR'
125 125 def_key = 'REMOTE_ADDR'
126 126 _filters = lambda x: _filter_port(_filter_proxy(x))
127 127
128 128 ip = environ.get(proxy_key)
129 129 if ip:
130 130 return _filters(ip)
131 131
132 132 ip = environ.get(proxy_key2)
133 133 if ip:
134 134 return _filters(ip)
135 135
136 136 ip = environ.get(def_key, '0.0.0.0')
137 137 return _filters(ip)
138 138
139 139
140 140 def get_server_ip_addr(environ, log_errors=True):
141 141 hostname = environ.get('SERVER_NAME')
142 142 try:
143 143 return socket.gethostbyname(hostname)
144 144 except Exception as e:
145 145 if log_errors:
146 146 # in some cases this lookup is not possible, and we don't want to
147 147 # make it an exception in logs
148 148 log.exception('Could not retrieve server ip address: %s', e)
149 149 return hostname
150 150
151 151
152 152 def get_server_port(environ):
153 153 return environ.get('SERVER_PORT')
154 154
155 155
156 156 def get_access_path(environ):
157 157 path = environ.get('PATH_INFO')
158 158 org_req = environ.get('pylons.original_request')
159 159 if org_req:
160 160 path = org_req.environ.get('PATH_INFO')
161 161 return path
162 162
163 163
164 164 def vcs_operation_context(
165 165 environ, repo_name, username, action, scm, check_locking=True):
166 166 """
167 167 Generate the context for a vcs operation, e.g. push or pull.
168 168
169 169 This context is passed over the layers so that hooks triggered by the
170 170 vcs operation know details like the user, the user's IP address etc.
171 171
172 172 :param check_locking: Allows to switch of the computation of the locking
173 173 data. This serves mainly the need of the simplevcs middleware to be
174 174 able to disable this for certain operations.
175 175
176 176 """
177 177 # Tri-state value: False: unlock, None: nothing, True: lock
178 178 make_lock = None
179 179 locked_by = [None, None, None]
180 180 is_anonymous = username == User.DEFAULT_USER
181 181 if not is_anonymous and check_locking:
182 182 log.debug('Checking locking on repository "%s"', repo_name)
183 183 user = User.get_by_username(username)
184 184 repo = Repository.get_by_repo_name(repo_name)
185 185 make_lock, __, locked_by = repo.get_locking_state(
186 186 action, user.user_id)
187 187
188 188 settings_model = VcsSettingsModel(repo=repo_name)
189 189 ui_settings = settings_model.get_ui_settings()
190 190
191 191 extras = {
192 192 'ip': get_ip_addr(environ),
193 193 'username': username,
194 194 'action': action,
195 195 'repository': repo_name,
196 196 'scm': scm,
197 197 'config': rhodecode.CONFIG['__file__'],
198 198 'make_lock': make_lock,
199 199 'locked_by': locked_by,
200 200 'server_url': utils2.get_server_url(environ),
201 201 'hooks': get_enabled_hook_classes(ui_settings),
202 202 }
203 203 return extras
204 204
205 205
206 206 class BasicAuth(AuthBasicAuthenticator):
207 207
208 def __init__(self, realm, authfunc, auth_http_code=None,
208 def __init__(self, realm, authfunc, registry, auth_http_code=None,
209 209 initial_call_detection=False):
210 210 self.realm = realm
211 211 self.initial_call = initial_call_detection
212 212 self.authfunc = authfunc
213 self.registry = registry
213 214 self._rc_auth_http_code = auth_http_code
214 215
215 216 def _get_response_from_code(self, http_code):
216 217 try:
217 218 return get_exception(safe_int(http_code))
218 219 except Exception:
219 220 log.exception('Failed to fetch response for code %s' % http_code)
220 221 return HTTPForbidden
221 222
222 223 def build_authentication(self):
223 224 head = WWW_AUTHENTICATE.tuples('Basic realm="%s"' % self.realm)
224 225 if self._rc_auth_http_code and not self.initial_call:
225 226 # return alternative HTTP code if alternative http return code
226 227 # is specified in RhodeCode config, but ONLY if it's not the
227 228 # FIRST call
228 229 custom_response_klass = self._get_response_from_code(
229 230 self._rc_auth_http_code)
230 231 return custom_response_klass(headers=head)
231 232 return HTTPUnauthorized(headers=head)
232 233
233 234 def authenticate(self, environ):
234 235 authorization = AUTHORIZATION(environ)
235 236 if not authorization:
236 237 return self.build_authentication()
237 238 (authmeth, auth) = authorization.split(' ', 1)
238 239 if 'basic' != authmeth.lower():
239 240 return self.build_authentication()
240 241 auth = auth.strip().decode('base64')
241 242 _parts = auth.split(':', 1)
242 243 if len(_parts) == 2:
243 244 username, password = _parts
244 245 if self.authfunc(
245 username, password, environ, VCS_TYPE):
246 username, password, environ, VCS_TYPE,
247 registry=self.registry):
246 248 return username
247 249 if username and password:
248 250 # we mark that we actually executed authentication once, at
249 251 # that point we can use the alternative auth code
250 252 self.initial_call = False
251 253
252 254 return self.build_authentication()
253 255
254 256 __call__ = authenticate
255 257
256 258
257 259 def attach_context_attributes(context, request):
258 260 """
259 261 Attach variables into template context called `c`, please note that
260 262 request could be pylons or pyramid request in here.
261 263 """
262 264 rc_config = SettingsModel().get_all_settings(cache=True)
263 265
264 266 context.rhodecode_version = rhodecode.__version__
265 267 context.rhodecode_edition = config.get('rhodecode.edition')
266 268 # unique secret + version does not leak the version but keep consistency
267 269 context.rhodecode_version_hash = md5(
268 270 config.get('beaker.session.secret', '') +
269 271 rhodecode.__version__)[:8]
270 272
271 273 # Default language set for the incoming request
272 274 context.language = translation.get_lang()[0]
273 275
274 276 # Visual options
275 277 context.visual = AttributeDict({})
276 278
277 279 # DB store
278 280 context.visual.show_public_icon = str2bool(
279 281 rc_config.get('rhodecode_show_public_icon'))
280 282 context.visual.show_private_icon = str2bool(
281 283 rc_config.get('rhodecode_show_private_icon'))
282 284 context.visual.stylify_metatags = str2bool(
283 285 rc_config.get('rhodecode_stylify_metatags'))
284 286 context.visual.dashboard_items = safe_int(
285 287 rc_config.get('rhodecode_dashboard_items', 100))
286 288 context.visual.admin_grid_items = safe_int(
287 289 rc_config.get('rhodecode_admin_grid_items', 100))
288 290 context.visual.repository_fields = str2bool(
289 291 rc_config.get('rhodecode_repository_fields'))
290 292 context.visual.show_version = str2bool(
291 293 rc_config.get('rhodecode_show_version'))
292 294 context.visual.use_gravatar = str2bool(
293 295 rc_config.get('rhodecode_use_gravatar'))
294 296 context.visual.gravatar_url = rc_config.get('rhodecode_gravatar_url')
295 297 context.visual.default_renderer = rc_config.get(
296 298 'rhodecode_markup_renderer', 'rst')
297 299 context.visual.rhodecode_support_url = \
298 300 rc_config.get('rhodecode_support_url') or url('rhodecode_support')
299 301
300 302 context.pre_code = rc_config.get('rhodecode_pre_code')
301 303 context.post_code = rc_config.get('rhodecode_post_code')
302 304 context.rhodecode_name = rc_config.get('rhodecode_title')
303 305 context.default_encodings = aslist(config.get('default_encoding'), sep=',')
304 306 # if we have specified default_encoding in the request, it has more
305 307 # priority
306 308 if request.GET.get('default_encoding'):
307 309 context.default_encodings.insert(0, request.GET.get('default_encoding'))
308 310 context.clone_uri_tmpl = rc_config.get('rhodecode_clone_uri_tmpl')
309 311
310 312 # INI stored
311 313 context.labs_active = str2bool(
312 314 config.get('labs_settings_active', 'false'))
313 315 context.visual.allow_repo_location_change = str2bool(
314 316 config.get('allow_repo_location_change', True))
315 317 context.visual.allow_custom_hooks_settings = str2bool(
316 318 config.get('allow_custom_hooks_settings', True))
317 319 context.debug_style = str2bool(config.get('debug_style', False))
318 320
319 321 context.rhodecode_instanceid = config.get('instance_id')
320 322
321 323 # AppEnlight
322 324 context.appenlight_enabled = str2bool(config.get('appenlight', 'false'))
323 325 context.appenlight_api_public_key = config.get(
324 326 'appenlight.api_public_key', '')
325 327 context.appenlight_server_url = config.get('appenlight.server_url', '')
326 328
327 329 # JS template context
328 330 context.template_context = {
329 331 'repo_name': None,
330 332 'repo_type': None,
331 333 'repo_landing_commit': None,
332 334 'rhodecode_user': {
333 335 'username': None,
334 336 'email': None,
335 337 'notification_status': False
336 338 },
337 339 'visual': {
338 340 'default_renderer': None
339 341 },
340 342 'commit_data': {
341 343 'commit_id': None
342 344 },
343 345 'pull_request_data': {'pull_request_id': None},
344 346 'timeago': {
345 347 'refresh_time': 120 * 1000,
346 348 'cutoff_limit': 1000 * 60 * 60 * 24 * 7
347 349 },
348 350 'pylons_dispatch': {
349 351 # 'controller': request.environ['pylons.routes_dict']['controller'],
350 352 # 'action': request.environ['pylons.routes_dict']['action'],
351 353 },
352 354 'pyramid_dispatch': {
353 355
354 356 },
355 357 'extra': {'plugins': {}}
356 358 }
357 359 # END CONFIG VARS
358 360
359 361 # TODO: This dosn't work when called from pylons compatibility tween.
360 362 # Fix this and remove it from base controller.
361 363 # context.repo_name = get_repo_slug(request) # can be empty
362 364
363 365 context.csrf_token = auth.get_csrf_token()
364 366 context.backends = rhodecode.BACKENDS.keys()
365 367 context.backends.sort()
366 368 context.unread_notifications = NotificationModel().get_unread_cnt_for_user(
367 369 context.rhodecode_user.user_id)
368 370
369 371
370 372 def get_auth_user(environ):
371 373 ip_addr = get_ip_addr(environ)
372 374 # make sure that we update permissions each time we call controller
373 375 _auth_token = (request.GET.get('auth_token', '') or
374 376 request.GET.get('api_key', ''))
375 377
376 378 if _auth_token:
377 379 # when using API_KEY we are sure user exists.
378 380 auth_user = AuthUser(api_key=_auth_token, ip_addr=ip_addr)
379 381 authenticated = False
380 382 else:
381 383 cookie_store = CookieStoreWrapper(session.get('rhodecode_user'))
382 384 try:
383 385 auth_user = AuthUser(user_id=cookie_store.get('user_id', None),
384 386 ip_addr=ip_addr)
385 387 except UserCreationError as e:
386 388 h.flash(e, 'error')
387 389 # container auth or other auth functions that create users
388 390 # on the fly can throw this exception signaling that there's
389 391 # issue with user creation, explanation should be provided
390 392 # in Exception itself. We then create a simple blank
391 393 # AuthUser
392 394 auth_user = AuthUser(ip_addr=ip_addr)
393 395
394 396 if password_changed(auth_user, session):
395 397 session.invalidate()
396 398 cookie_store = CookieStoreWrapper(
397 399 session.get('rhodecode_user'))
398 400 auth_user = AuthUser(ip_addr=ip_addr)
399 401
400 402 authenticated = cookie_store.get('is_authenticated')
401 403
402 404 if not auth_user.is_authenticated and auth_user.is_user_object:
403 405 # user is not authenticated and not empty
404 406 auth_user.set_authenticated(authenticated)
405 407
406 408 return auth_user
407 409
408 410
409 411 class BaseController(WSGIController):
410 412
411 413 def __before__(self):
412 414 """
413 415 __before__ is called before controller methods and after __call__
414 416 """
415 417 # on each call propagate settings calls into global settings.
416 418 set_rhodecode_config(config)
417 419 attach_context_attributes(c, request)
418 420
419 421 # TODO: Remove this when fixed in attach_context_attributes()
420 422 c.repo_name = get_repo_slug(request) # can be empty
421 423
422 424 self.cut_off_limit_diff = safe_int(config.get('cut_off_limit_diff'))
423 425 self.cut_off_limit_file = safe_int(config.get('cut_off_limit_file'))
424 426 self.sa = meta.Session
425 427 self.scm_model = ScmModel(self.sa)
426 428
427 429 default_lang = c.language
428 430 user_lang = c.language
429 431 try:
430 432 user_obj = self._rhodecode_user.get_instance()
431 433 if user_obj:
432 434 user_lang = user_obj.user_data.get('language')
433 435 except Exception:
434 436 log.exception('Failed to fetch user language for user %s',
435 437 self._rhodecode_user)
436 438
437 439 if user_lang and user_lang != default_lang:
438 440 log.debug('set language to %s for user %s', user_lang,
439 441 self._rhodecode_user)
440 442 translation.set_lang(user_lang)
441 443
442 444 def _dispatch_redirect(self, with_url, environ, start_response):
443 445 resp = HTTPFound(with_url)
444 446 environ['SCRIPT_NAME'] = '' # handle prefix middleware
445 447 environ['PATH_INFO'] = with_url
446 448 return resp(environ, start_response)
447 449
448 450 def __call__(self, environ, start_response):
449 451 """Invoke the Controller"""
450 452 # WSGIController.__call__ dispatches to the Controller method
451 453 # the request is routed to. This routing information is
452 454 # available in environ['pylons.routes_dict']
453 455 from rhodecode.lib import helpers as h
454 456
455 457 # Provide the Pylons context to Pyramid's debugtoolbar if it asks
456 458 if environ.get('debugtoolbar.wants_pylons_context', False):
457 459 environ['debugtoolbar.pylons_context'] = c._current_obj()
458 460
459 461 _route_name = '.'.join([environ['pylons.routes_dict']['controller'],
460 462 environ['pylons.routes_dict']['action']])
461 463
462 464 self.rc_config = SettingsModel().get_all_settings(cache=True)
463 465 self.ip_addr = get_ip_addr(environ)
464 466
465 467 # The rhodecode auth user is looked up and passed through the
466 468 # environ by the pylons compatibility tween in pyramid.
467 469 # So we can just grab it from there.
468 470 auth_user = environ['rc_auth_user']
469 471
470 472 # set globals for auth user
471 473 request.user = auth_user
472 474 c.rhodecode_user = self._rhodecode_user = auth_user
473 475
474 476 log.info('IP: %s User: %s accessed %s [%s]' % (
475 477 self.ip_addr, auth_user, safe_unicode(get_access_path(environ)),
476 478 _route_name)
477 479 )
478 480
479 481 # TODO: Maybe this should be move to pyramid to cover all views.
480 482 # check user attributes for password change flag
481 483 user_obj = auth_user.get_instance()
482 484 if user_obj and user_obj.user_data.get('force_password_change'):
483 485 h.flash('You are required to change your password', 'warning',
484 486 ignore_duplicate=True)
485 487
486 488 skip_user_check_urls = [
487 489 'error.document', 'login.logout', 'login.index',
488 490 'admin/my_account.my_account_password',
489 491 'admin/my_account.my_account_password_update'
490 492 ]
491 493 if _route_name not in skip_user_check_urls:
492 494 return self._dispatch_redirect(
493 495 url('my_account_password'), environ, start_response)
494 496
495 497 return WSGIController.__call__(self, environ, start_response)
496 498
497 499
498 500 class BaseRepoController(BaseController):
499 501 """
500 502 Base class for controllers responsible for loading all needed data for
501 503 repository loaded items are
502 504
503 505 c.rhodecode_repo: instance of scm repository
504 506 c.rhodecode_db_repo: instance of db
505 507 c.repository_requirements_missing: shows that repository specific data
506 508 could not be displayed due to the missing requirements
507 509 c.repository_pull_requests: show number of open pull requests
508 510 """
509 511
510 512 def __before__(self):
511 513 super(BaseRepoController, self).__before__()
512 514 if c.repo_name: # extracted from routes
513 515 db_repo = Repository.get_by_repo_name(c.repo_name)
514 516 if not db_repo:
515 517 return
516 518
517 519 log.debug(
518 520 'Found repository in database %s with state `%s`',
519 521 safe_unicode(db_repo), safe_unicode(db_repo.repo_state))
520 522 route = getattr(request.environ.get('routes.route'), 'name', '')
521 523
522 524 # allow to delete repos that are somehow damages in filesystem
523 525 if route in ['delete_repo']:
524 526 return
525 527
526 528 if db_repo.repo_state in [Repository.STATE_PENDING]:
527 529 if route in ['repo_creating_home']:
528 530 return
529 531 check_url = url('repo_creating_home', repo_name=c.repo_name)
530 532 return redirect(check_url)
531 533
532 534 self.rhodecode_db_repo = db_repo
533 535
534 536 missing_requirements = False
535 537 try:
536 538 self.rhodecode_repo = self.rhodecode_db_repo.scm_instance()
537 539 except RepositoryRequirementError as e:
538 540 missing_requirements = True
539 541 self._handle_missing_requirements(e)
540 542
541 543 if self.rhodecode_repo is None and not missing_requirements:
542 544 log.error('%s this repository is present in database but it '
543 545 'cannot be created as an scm instance', c.repo_name)
544 546
545 547 h.flash(_(
546 548 "The repository at %(repo_name)s cannot be located.") %
547 549 {'repo_name': c.repo_name},
548 550 category='error', ignore_duplicate=True)
549 551 redirect(url('home'))
550 552
551 553 # update last change according to VCS data
552 554 if not missing_requirements:
553 555 commit = db_repo.get_commit(
554 556 pre_load=["author", "date", "message", "parents"])
555 557 db_repo.update_commit_cache(commit)
556 558
557 559 # Prepare context
558 560 c.rhodecode_db_repo = db_repo
559 561 c.rhodecode_repo = self.rhodecode_repo
560 562 c.repository_requirements_missing = missing_requirements
561 563
562 564 self._update_global_counters(self.scm_model, db_repo)
563 565
564 566 def _update_global_counters(self, scm_model, db_repo):
565 567 """
566 568 Base variables that are exposed to every page of repository
567 569 """
568 570 c.repository_pull_requests = scm_model.get_pull_requests(db_repo)
569 571
570 572 def _handle_missing_requirements(self, error):
571 573 self.rhodecode_repo = None
572 574 log.error(
573 575 'Requirements are missing for repository %s: %s',
574 576 c.repo_name, error.message)
575 577
576 578 summary_url = url('summary_home', repo_name=c.repo_name)
577 579 statistics_url = url('edit_repo_statistics', repo_name=c.repo_name)
578 580 settings_update_url = url('repo', repo_name=c.repo_name)
579 581 path = request.path
580 582 should_redirect = (
581 583 path not in (summary_url, settings_update_url)
582 584 and '/settings' not in path or path == statistics_url
583 585 )
584 586 if should_redirect:
585 587 redirect(summary_url)
@@ -1,442 +1,444 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2014-2016 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 27 import logging
28 28 import importlib
29 29 from functools import wraps
30 30
31 31 from paste.httpheaders import REMOTE_USER, AUTH_TYPE
32 32 from webob.exc import (
33 33 HTTPNotFound, HTTPForbidden, HTTPNotAcceptable, HTTPInternalServerError)
34 34
35 35 import rhodecode
36 36 from rhodecode.authentication.base import authenticate, VCS_TYPE
37 37 from rhodecode.lib.auth import AuthUser, HasPermissionAnyMiddleware
38 38 from rhodecode.lib.base import BasicAuth, get_ip_addr, vcs_operation_context
39 39 from rhodecode.lib.exceptions import (
40 40 HTTPLockedRC, HTTPRequirementError, UserCreationError,
41 41 NotAllowedToCreateUserError)
42 42 from rhodecode.lib.hooks_daemon import prepare_callback_daemon
43 43 from rhodecode.lib.middleware import appenlight
44 44 from rhodecode.lib.middleware.utils import scm_app
45 45 from rhodecode.lib.utils import (
46 46 is_valid_repo, get_rhodecode_realm, get_rhodecode_base_path)
47 47 from rhodecode.lib.utils2 import safe_str, fix_PATH, str2bool
48 48 from rhodecode.lib.vcs.conf import settings as vcs_settings
49 49 from rhodecode.model import meta
50 50 from rhodecode.model.db import User, Repository
51 51 from rhodecode.model.scm import ScmModel
52 52 from rhodecode.model.settings import SettingsModel
53 53
54 54 log = logging.getLogger(__name__)
55 55
56 56
57 57 def initialize_generator(factory):
58 58 """
59 59 Initializes the returned generator by draining its first element.
60 60
61 61 This can be used to give a generator an initializer, which is the code
62 62 up to the first yield statement. This decorator enforces that the first
63 63 produced element has the value ``"__init__"`` to make its special
64 64 purpose very explicit in the using code.
65 65 """
66 66
67 67 @wraps(factory)
68 68 def wrapper(*args, **kwargs):
69 69 gen = factory(*args, **kwargs)
70 70 try:
71 71 init = gen.next()
72 72 except StopIteration:
73 73 raise ValueError('Generator must yield at least one element.')
74 74 if init != "__init__":
75 75 raise ValueError('First yielded element must be "__init__".')
76 76 return gen
77 77 return wrapper
78 78
79 79
80 80 class SimpleVCS(object):
81 81 """Common functionality for SCM HTTP handlers."""
82 82
83 83 SCM = 'unknown'
84 84
85 def __init__(self, application, config):
85 def __init__(self, application, config, registry):
86 self.registry = registry
86 87 self.application = application
87 88 self.config = config
88 89 # base path of repo locations
89 90 self.basepath = get_rhodecode_base_path()
90 91 # authenticate this VCS request using authfunc
91 92 auth_ret_code_detection = \
92 93 str2bool(self.config.get('auth_ret_code_detection', False))
93 self.authenticate = BasicAuth('', authenticate,
94 config.get('auth_ret_code'),
95 auth_ret_code_detection)
94 self.authenticate = BasicAuth(
95 '', authenticate, registry, config.get('auth_ret_code'),
96 auth_ret_code_detection)
96 97 self.ip_addr = '0.0.0.0'
97 98
98 99 @property
99 100 def scm_app(self):
100 101 custom_implementation = self.config.get('vcs.scm_app_implementation')
101 102 if custom_implementation:
102 103 log.info(
103 104 "Using custom implementation of scm_app: %s",
104 105 custom_implementation)
105 106 scm_app_impl = importlib.import_module(custom_implementation)
106 107 else:
107 108 scm_app_impl = scm_app
108 109 return scm_app_impl
109 110
110 111 def _get_by_id(self, repo_name):
111 112 """
112 113 Gets a special pattern _<ID> from clone url and tries to replace it
113 114 with a repository_name for support of _<ID> non changable urls
114 115
115 116 :param repo_name:
116 117 """
117 118
118 119 data = repo_name.split('/')
119 120 if len(data) >= 2:
120 121 from rhodecode.model.repo import RepoModel
121 122 by_id_match = RepoModel().get_repo_by_id(repo_name)
122 123 if by_id_match:
123 124 data[1] = by_id_match.repo_name
124 125
125 126 return safe_str('/'.join(data))
126 127
127 128 def _invalidate_cache(self, repo_name):
128 129 """
129 130 Set's cache for this repository for invalidation on next access
130 131
131 132 :param repo_name: full repo name, also a cache key
132 133 """
133 134 ScmModel().mark_for_invalidation(repo_name)
134 135
135 136 def is_valid_and_existing_repo(self, repo_name, base_path, scm_type):
136 137 db_repo = Repository.get_by_repo_name(repo_name)
137 138 if not db_repo:
138 139 log.debug('Repository `%s` not found inside the database.',
139 140 repo_name)
140 141 return False
141 142
142 143 if db_repo.repo_type != scm_type:
143 144 log.warning(
144 145 'Repository `%s` have incorrect scm_type, expected %s got %s',
145 146 repo_name, db_repo.repo_type, scm_type)
146 147 return False
147 148
148 149 return is_valid_repo(repo_name, base_path, expect_scm=scm_type)
149 150
150 151 def valid_and_active_user(self, user):
151 152 """
152 153 Checks if that user is not empty, and if it's actually object it checks
153 154 if he's active.
154 155
155 156 :param user: user object or None
156 157 :return: boolean
157 158 """
158 159 if user is None:
159 160 return False
160 161
161 162 elif user.active:
162 163 return True
163 164
164 165 return False
165 166
166 167 def _check_permission(self, action, user, repo_name, ip_addr=None):
167 168 """
168 169 Checks permissions using action (push/pull) user and repository
169 170 name
170 171
171 172 :param action: push or pull action
172 173 :param user: user instance
173 174 :param repo_name: repository name
174 175 """
175 176 # check IP
176 177 inherit = user.inherit_default_permissions
177 178 ip_allowed = AuthUser.check_ip_allowed(user.user_id, ip_addr,
178 179 inherit_from_default=inherit)
179 180 if ip_allowed:
180 181 log.info('Access for IP:%s allowed', ip_addr)
181 182 else:
182 183 return False
183 184
184 185 if action == 'push':
185 186 if not HasPermissionAnyMiddleware('repository.write',
186 187 'repository.admin')(user,
187 188 repo_name):
188 189 return False
189 190
190 191 else:
191 192 # any other action need at least read permission
192 193 if not HasPermissionAnyMiddleware('repository.read',
193 194 'repository.write',
194 195 'repository.admin')(user,
195 196 repo_name):
196 197 return False
197 198
198 199 return True
199 200
200 201 def _check_ssl(self, environ, start_response):
201 202 """
202 203 Checks the SSL check flag and returns False if SSL is not present
203 204 and required True otherwise
204 205 """
205 206 org_proto = environ['wsgi._org_proto']
206 207 # check if we have SSL required ! if not it's a bad request !
207 208 require_ssl = str2bool(
208 209 SettingsModel().get_ui_by_key('push_ssl').ui_value)
209 210 if require_ssl and org_proto == 'http':
210 211 log.debug('proto is %s and SSL is required BAD REQUEST !',
211 212 org_proto)
212 213 return False
213 214 return True
214 215
215 216 def __call__(self, environ, start_response):
216 217 try:
217 218 return self._handle_request(environ, start_response)
218 219 except Exception:
219 220 log.exception("Exception while handling request")
220 221 appenlight.track_exception(environ)
221 222 return HTTPInternalServerError()(environ, start_response)
222 223 finally:
223 224 meta.Session.remove()
224 225
225 226 def _handle_request(self, environ, start_response):
226 227
227 228 if not self._check_ssl(environ, start_response):
228 229 reason = ('SSL required, while RhodeCode was unable '
229 230 'to detect this as SSL request')
230 231 log.debug('User not allowed to proceed, %s', reason)
231 232 return HTTPNotAcceptable(reason)(environ, start_response)
232 233
233 234 ip_addr = get_ip_addr(environ)
234 235 username = None
235 236
236 237 # skip passing error to error controller
237 238 environ['pylons.status_code_redirect'] = True
238 239
239 240 # ======================================================================
240 241 # EXTRACT REPOSITORY NAME FROM ENV
241 242 # ======================================================================
242 243 environ['PATH_INFO'] = self._get_by_id(environ['PATH_INFO'])
243 244 repo_name = self._get_repository_name(environ)
244 245 environ['REPO_NAME'] = repo_name
245 246 log.debug('Extracted repo name is %s', repo_name)
246 247
247 248 # check for type, presence in database and on filesystem
248 249 if not self.is_valid_and_existing_repo(
249 250 repo_name, self.basepath, self.SCM):
250 251 return HTTPNotFound()(environ, start_response)
251 252
252 253 # ======================================================================
253 254 # GET ACTION PULL or PUSH
254 255 # ======================================================================
255 256 action = self._get_action(environ)
256 257
257 258 # ======================================================================
258 259 # CHECK ANONYMOUS PERMISSION
259 260 # ======================================================================
260 261 if action in ['pull', 'push']:
261 262 anonymous_user = User.get_default_user()
262 263 username = anonymous_user.username
263 264 if anonymous_user.active:
264 265 # ONLY check permissions if the user is activated
265 266 anonymous_perm = self._check_permission(
266 267 action, anonymous_user, repo_name, ip_addr)
267 268 else:
268 269 anonymous_perm = False
269 270
270 271 if not anonymous_user.active or not anonymous_perm:
271 272 if not anonymous_user.active:
272 273 log.debug('Anonymous access is disabled, running '
273 274 'authentication')
274 275
275 276 if not anonymous_perm:
276 277 log.debug('Not enough credentials to access this '
277 278 'repository as anonymous user')
278 279
279 280 username = None
280 281 # ==============================================================
281 282 # DEFAULT PERM FAILED OR ANONYMOUS ACCESS IS DISABLED SO WE
282 283 # NEED TO AUTHENTICATE AND ASK FOR AUTH USER PERMISSIONS
283 284 # ==============================================================
284 285
285 286 # try to auth based on environ, container auth methods
286 287 log.debug('Running PRE-AUTH for container based authentication')
287 pre_auth = authenticate('', '', environ,VCS_TYPE)
288 pre_auth = authenticate(
289 '', '', environ, VCS_TYPE, registry=self.registry)
288 290 if pre_auth and pre_auth.get('username'):
289 291 username = pre_auth['username']
290 292 log.debug('PRE-AUTH got %s as username', username)
291 293
292 294 # If not authenticated by the container, running basic auth
293 295 if not username:
294 296 self.authenticate.realm = get_rhodecode_realm()
295 297
296 298 try:
297 299 result = self.authenticate(environ)
298 300 except (UserCreationError, NotAllowedToCreateUserError) as e:
299 301 log.error(e)
300 302 reason = safe_str(e)
301 303 return HTTPNotAcceptable(reason)(environ, start_response)
302 304
303 305 if isinstance(result, str):
304 306 AUTH_TYPE.update(environ, 'basic')
305 307 REMOTE_USER.update(environ, result)
306 308 username = result
307 309 else:
308 310 return result.wsgi_application(environ, start_response)
309 311
310 312 # ==============================================================
311 313 # CHECK PERMISSIONS FOR THIS REQUEST USING GIVEN USERNAME
312 314 # ==============================================================
313 315 user = User.get_by_username(username)
314 316 if not self.valid_and_active_user(user):
315 317 return HTTPForbidden()(environ, start_response)
316 318 username = user.username
317 319 user.update_lastactivity()
318 320 meta.Session().commit()
319 321
320 322 # check user attributes for password change flag
321 323 user_obj = user
322 324 if user_obj and user_obj.username != User.DEFAULT_USER and \
323 325 user_obj.user_data.get('force_password_change'):
324 326 reason = 'password change required'
325 327 log.debug('User not allowed to authenticate, %s', reason)
326 328 return HTTPNotAcceptable(reason)(environ, start_response)
327 329
328 330 # check permissions for this repository
329 331 perm = self._check_permission(action, user, repo_name, ip_addr)
330 332 if not perm:
331 333 return HTTPForbidden()(environ, start_response)
332 334
333 335 # extras are injected into UI object and later available
334 336 # in hooks executed by rhodecode
335 337 check_locking = _should_check_locking(environ.get('QUERY_STRING'))
336 338 extras = vcs_operation_context(
337 339 environ, repo_name=repo_name, username=username,
338 340 action=action, scm=self.SCM,
339 341 check_locking=check_locking)
340 342
341 343 # ======================================================================
342 344 # REQUEST HANDLING
343 345 # ======================================================================
344 346 str_repo_name = safe_str(repo_name)
345 347 repo_path = os.path.join(safe_str(self.basepath), str_repo_name)
346 348 log.debug('Repository path is %s', repo_path)
347 349
348 350 fix_PATH()
349 351
350 352 log.info(
351 353 '%s action on %s repo "%s" by "%s" from %s',
352 354 action, self.SCM, str_repo_name, safe_str(username), ip_addr)
353 355 return self._generate_vcs_response(
354 356 environ, start_response, repo_path, repo_name, extras, action)
355 357
356 358 @initialize_generator
357 359 def _generate_vcs_response(
358 360 self, environ, start_response, repo_path, repo_name, extras,
359 361 action):
360 362 """
361 363 Returns a generator for the response content.
362 364
363 365 This method is implemented as a generator, so that it can trigger
364 366 the cache validation after all content sent back to the client. It
365 367 also handles the locking exceptions which will be triggered when
366 368 the first chunk is produced by the underlying WSGI application.
367 369 """
368 370 callback_daemon, extras = self._prepare_callback_daemon(extras)
369 371 config = self._create_config(extras, repo_name)
370 372 log.debug('HOOKS extras is %s', extras)
371 373 app = self._create_wsgi_app(repo_path, repo_name, config)
372 374
373 375 try:
374 376 with callback_daemon:
375 377 try:
376 378 response = app(environ, start_response)
377 379 finally:
378 380 # This statement works together with the decorator
379 381 # "initialize_generator" above. The decorator ensures that
380 382 # we hit the first yield statement before the generator is
381 383 # returned back to the WSGI server. This is needed to
382 384 # ensure that the call to "app" above triggers the
383 385 # needed callback to "start_response" before the
384 386 # generator is actually used.
385 387 yield "__init__"
386 388
387 389 for chunk in response:
388 390 yield chunk
389 391 except Exception as exc:
390 392 # TODO: johbo: Improve "translating" back the exception.
391 393 if getattr(exc, '_vcs_kind', None) == 'repo_locked':
392 394 exc = HTTPLockedRC(*exc.args)
393 395 _code = rhodecode.CONFIG.get('lock_ret_code')
394 396 log.debug('Repository LOCKED ret code %s!', (_code,))
395 397 elif getattr(exc, '_vcs_kind', None) == 'requirement':
396 398 log.debug(
397 399 'Repository requires features unknown to this Mercurial')
398 400 exc = HTTPRequirementError(*exc.args)
399 401 else:
400 402 raise
401 403
402 404 for chunk in exc(environ, start_response):
403 405 yield chunk
404 406 finally:
405 407 # invalidate cache on push
406 408 if action == 'push':
407 409 self._invalidate_cache(repo_name)
408 410
409 411 def _get_repository_name(self, environ):
410 412 """Get repository name out of the environmnent
411 413
412 414 :param environ: WSGI environment
413 415 """
414 416 raise NotImplementedError()
415 417
416 418 def _get_action(self, environ):
417 419 """Map request commands into a pull or push command.
418 420
419 421 :param environ: WSGI environment
420 422 """
421 423 raise NotImplementedError()
422 424
423 425 def _create_wsgi_app(self, repo_path, repo_name, config):
424 426 """Return the WSGI app that will finally handle the request."""
425 427 raise NotImplementedError()
426 428
427 429 def _create_config(self, extras, repo_name):
428 430 """Create a Pyro safe config representation."""
429 431 raise NotImplementedError()
430 432
431 433 def _prepare_callback_daemon(self, extras):
432 434 return prepare_callback_daemon(
433 435 extras, protocol=vcs_settings.HOOKS_PROTOCOL,
434 436 use_direct_calls=vcs_settings.HOOKS_DIRECT_CALLS)
435 437
436 438
437 439 def _should_check_locking(query_string):
438 440 # this is kind of hacky, but due to how mercurial handles client-server
439 441 # server see all operation on commit; bookmarks, phases and
440 442 # obsolescence marker in different transaction, we don't want to check
441 443 # locking on those
442 444 return query_string not in ['cmd=listkeys']
@@ -1,160 +1,161 b''
1 1 # -*- coding: utf-8 -*-
2 2
3 3 # Copyright (C) 2010-2016 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 gzip
22 22 import shutil
23 23 import logging
24 24 import tempfile
25 25 import urlparse
26 26
27 27 import rhodecode
28 28 from rhodecode.lib.middleware.appenlight import wrap_in_appenlight_if_enabled
29 29 from rhodecode.lib.middleware.simplegit import SimpleGit, GIT_PROTO_PAT
30 30 from rhodecode.lib.middleware.simplehg import SimpleHg
31 31 from rhodecode.lib.middleware.simplesvn import SimpleSvn
32 32
33 33
34 34 log = logging.getLogger(__name__)
35 35
36 36
37 37 def is_git(environ):
38 38 """
39 39 Returns True if requests should be handled by GIT wsgi middleware
40 40 """
41 41 is_git_path = GIT_PROTO_PAT.match(environ['PATH_INFO'])
42 42 log.debug(
43 43 'request path: `%s` detected as GIT PROTOCOL %s', environ['PATH_INFO'],
44 44 is_git_path is not None)
45 45
46 46 return is_git_path
47 47
48 48
49 49 def is_hg(environ):
50 50 """
51 51 Returns True if requests target is mercurial server - header
52 52 ``HTTP_ACCEPT`` of such request would start with ``application/mercurial``.
53 53 """
54 54 is_hg_path = False
55 55
56 56 http_accept = environ.get('HTTP_ACCEPT')
57 57
58 58 if http_accept and http_accept.startswith('application/mercurial'):
59 59 query = urlparse.parse_qs(environ['QUERY_STRING'])
60 60 if 'cmd' in query:
61 61 is_hg_path = True
62 62
63 63 log.debug(
64 64 'request path: `%s` detected as HG PROTOCOL %s', environ['PATH_INFO'],
65 65 is_hg_path)
66 66
67 67 return is_hg_path
68 68
69 69
70 70 def is_svn(environ):
71 71 """
72 72 Returns True if requests target is Subversion server
73 73 """
74 74 http_dav = environ.get('HTTP_DAV', '')
75 75 magic_path_segment = rhodecode.CONFIG.get(
76 76 'rhodecode_subversion_magic_path', '/!svn')
77 77 is_svn_path = (
78 78 'subversion' in http_dav or
79 79 magic_path_segment in environ['PATH_INFO'])
80 80 log.debug(
81 81 'request path: `%s` detected as SVN PROTOCOL %s', environ['PATH_INFO'],
82 82 is_svn_path)
83 83
84 84 return is_svn_path
85 85
86 86
87 87 class GunzipMiddleware(object):
88 88 """
89 89 WSGI middleware that unzips gzip-encoded requests before
90 90 passing on to the underlying application.
91 91 """
92 92
93 93 def __init__(self, application):
94 94 self.app = application
95 95
96 96 def __call__(self, environ, start_response):
97 97 accepts_encoding_header = environ.get('HTTP_CONTENT_ENCODING', b'')
98 98
99 99 if b'gzip' in accepts_encoding_header:
100 100 log.debug('gzip detected, now running gunzip wrapper')
101 101 wsgi_input = environ['wsgi.input']
102 102
103 103 if not hasattr(environ['wsgi.input'], 'seek'):
104 104 # The gzip implementation in the standard library of Python 2.x
105 105 # requires the '.seek()' and '.tell()' methods to be available
106 106 # on the input stream. Read the data into a temporary file to
107 107 # work around this limitation.
108 108
109 109 wsgi_input = tempfile.SpooledTemporaryFile(64 * 1024 * 1024)
110 110 shutil.copyfileobj(environ['wsgi.input'], wsgi_input)
111 111 wsgi_input.seek(0)
112 112
113 113 environ['wsgi.input'] = gzip.GzipFile(fileobj=wsgi_input, mode='r')
114 114 # since we "Ungzipped" the content we say now it's no longer gzip
115 115 # content encoding
116 116 del environ['HTTP_CONTENT_ENCODING']
117 117
118 118 # content length has changes ? or i'm not sure
119 119 if 'CONTENT_LENGTH' in environ:
120 120 del environ['CONTENT_LENGTH']
121 121 else:
122 122 log.debug('content not gzipped, gzipMiddleware passing '
123 123 'request further')
124 124 return self.app(environ, start_response)
125 125
126 126
127 127 class VCSMiddleware(object):
128 128
129 def __init__(self, app, config, appenlight_client):
129 def __init__(self, app, config, appenlight_client, registry):
130 130 self.application = app
131 131 self.config = config
132 132 self.appenlight_client = appenlight_client
133 self.registry = registry
133 134
134 135 def _get_handler_app(self, environ):
135 136 app = None
136 137 if is_hg(environ):
137 app = SimpleHg(self.application, self.config)
138 app = SimpleHg(self.application, self.config, self.registry)
138 139
139 140 if is_git(environ):
140 app = SimpleGit(self.application, self.config)
141 app = SimpleGit(self.application, self.config, self.registry)
141 142
142 143 proxy_svn = rhodecode.CONFIG.get(
143 144 'rhodecode_proxy_subversion_http_requests', False)
144 145 if proxy_svn and is_svn(environ):
145 app = SimpleSvn(self.application, self.config)
146 app = SimpleSvn(self.application, self.config, self.registry)
146 147
147 148 if app:
148 149 app = GunzipMiddleware(app)
149 150 app, _ = wrap_in_appenlight_if_enabled(
150 151 app, self.config, self.appenlight_client)
151 152
152 153 return app
153 154
154 155 def __call__(self, environ, start_response):
155 156 # check if we handle one of interesting protocols ?
156 157 vcs_handler = self._get_handler_app(environ)
157 158 if vcs_handler:
158 159 return vcs_handler(environ, start_response)
159 160
160 161 return self.application(environ, start_response)
General Comments 0
You need to be logged in to leave comments. Login now