base.py
825 lines
| 30.8 KiB
| text/x-python
|
PythonLexer
r5088 | # Copyright (C) 2010-2023 RhodeCode GmbH | |||
r1 | # | |||
# This program is free software: you can redistribute it and/or modify | ||||
# it under the terms of the GNU Affero General Public License, version 3 | ||||
# (only), as published by the Free Software Foundation. | ||||
# | ||||
# This program is distributed in the hope that it will be useful, | ||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||||
# GNU General Public License for more details. | ||||
# | ||||
# You should have received a copy of the GNU Affero General Public License | ||||
# along with this program. If not, see <http://www.gnu.org/licenses/>. | ||||
# | ||||
# This program is dual-licensed. If you wish to learn more about the | ||||
# RhodeCode Enterprise Edition, including its added features, Support services, | ||||
# and proprietary license terms, please see https://rhodecode.com/licenses/ | ||||
""" | ||||
Authentication modules | ||||
""" | ||||
r2736 | import socket | |||
import string | ||||
r232 | import colander | |||
r1631 | import copy | |||
r1 | import logging | |||
import time | ||||
import traceback | ||||
r107 | import warnings | |||
r1961 | import functools | |||
r1 | ||||
from pyramid.threadlocal import get_current_registry | ||||
r5057 | from rhodecode.authentication import AuthenticationPluginRegistry | |||
r1 | from rhodecode.authentication.interface import IAuthnPluginRegistry | |||
from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase | ||||
r2932 | from rhodecode.lib import rc_cache | |||
r4803 | from rhodecode.lib.statsd_client import StatsdClient | |||
r1 | from rhodecode.lib.auth import PasswordGenerator, _RhodeCodeCryptoBCrypt | |||
r5057 | from rhodecode.lib.str_utils import safe_bytes | |||
r2736 | from rhodecode.lib.utils2 import safe_int, safe_str | |||
r4803 | from rhodecode.lib.exceptions import (LdapConnectionError, LdapUsernameError, LdapPasswordError) | |||
r52 | from rhodecode.model.db import User | |||
r1 | from rhodecode.model.meta import Session | |||
from rhodecode.model.settings import SettingsModel | ||||
from rhodecode.model.user import UserModel | ||||
from rhodecode.model.user_group import UserGroupModel | ||||
log = logging.getLogger(__name__) | ||||
# auth types that authenticate() function can receive | ||||
VCS_TYPE = 'vcs' | ||||
HTTP_TYPE = 'http' | ||||
r3247 | external_auth_session_key = 'rhodecode.external_auth' | |||
r1 | ||||
r1961 | class hybrid_property(object): | |||
""" | ||||
a property decorator that works both for instance and class | ||||
""" | ||||
def __init__(self, fget, fset=None, fdel=None, expr=None): | ||||
self.fget = fget | ||||
self.fset = fset | ||||
self.fdel = fdel | ||||
self.expr = expr or fget | ||||
functools.update_wrapper(self, fget) | ||||
def __get__(self, instance, owner): | ||||
if instance is None: | ||||
return self.expr(owner) | ||||
else: | ||||
return self.fget(instance) | ||||
def __set__(self, instance, value): | ||||
self.fset(instance, value) | ||||
def __delete__(self, instance): | ||||
self.fdel(instance) | ||||
r1 | class LazyFormencode(object): | |||
def __init__(self, formencode_obj, *args, **kwargs): | ||||
self.formencode_obj = formencode_obj | ||||
self.args = args | ||||
self.kwargs = kwargs | ||||
def __call__(self, *args, **kwargs): | ||||
from inspect import isfunction | ||||
formencode_obj = self.formencode_obj | ||||
if isfunction(formencode_obj): | ||||
# case we wrap validators into functions | ||||
formencode_obj = self.formencode_obj(*args, **kwargs) | ||||
return formencode_obj(*self.args, **self.kwargs) | ||||
class RhodeCodeAuthPluginBase(object): | ||||
r3246 | # UID is used to register plugin to the registry | |||
uid = None | ||||
r1 | # cache the authentication request for N amount of seconds. Some kind | |||
# of authentication methods are very heavy and it's very efficient to cache | ||||
# the result of a call. If it's set to None (default) cache is off | ||||
AUTH_CACHE_TTL = None | ||||
AUTH_CACHE = {} | ||||
auth_func_attrs = { | ||||
"username": "unique username", | ||||
"firstname": "first name", | ||||
"lastname": "last name", | ||||
"email": "email address", | ||||
"groups": '["list", "of", "groups"]', | ||||
r2495 | "user_group_sync": | |||
'True|False defines if returned user groups should be synced', | ||||
r1 | "extern_name": "name in external source of record", | |||
"extern_type": "type of external source of record", | ||||
"admin": 'True|False defines if user should be RhodeCode super admin', | ||||
"active": | ||||
'True|False defines active state of user internally for RhodeCode', | ||||
"active_from_extern": | ||||
r3267 | "True|False|None, active state from the external auth, " | |||
r1 | "None means use definition from RhodeCode extern_type active value" | |||
r2495 | ||||
r1 | } | |||
# set on authenticate() method and via set_auth_type func. | ||||
auth_type = None | ||||
r1510 | # set on authenticate() method and via set_calling_scope_repo, this is a | |||
# calling scope repository when doing authentication most likely on VCS | ||||
# operations | ||||
acl_repo_name = None | ||||
r1 | # List of setting names to store encrypted. Plugins may override this list | |||
# to store settings encrypted. | ||||
_settings_encrypted = [] | ||||
# Mapping of python to DB settings model types. Plugins may override or | ||||
# extend this mapping. | ||||
_settings_type_map = { | ||||
r232 | colander.String: 'unicode', | |||
colander.Integer: 'int', | ||||
colander.Boolean: 'bool', | ||||
colander.List: 'list', | ||||
r1 | } | |||
r1631 | # list of keys in settings that are unsafe to be logged, should be passwords | |||
# or other crucial credentials | ||||
_settings_unsafe_keys = [] | ||||
r1 | def __init__(self, plugin_id): | |||
self._plugin_id = plugin_id | ||||
r197 | def __str__(self): | |||
return self.get_id() | ||||
r1 | def _get_setting_full_name(self, name): | |||
""" | ||||
Return the full setting name used for storing values in the database. | ||||
""" | ||||
# TODO: johbo: Using the name here is problematic. It would be good to | ||||
# introduce either new models in the database to hold Plugin and | ||||
# PluginSetting or to use the plugin id here. | ||||
r5094 | return f'auth_{self.name}_{name}' | |||
r1 | ||||
r232 | def _get_setting_type(self, name): | |||
""" | ||||
Return the type of a setting. This type is defined by the SettingsModel | ||||
and determines how the setting is stored in DB. Optionally the suffix | ||||
`.encrypted` is appended to instruct SettingsModel to store it | ||||
encrypted. | ||||
r1 | """ | |||
r232 | schema_node = self.get_settings_schema().get(name) | |||
db_type = self._settings_type_map.get( | ||||
r239 | type(schema_node.typ), 'unicode') | |||
r1 | if name in self._settings_encrypted: | |||
r5094 | db_type = f'{db_type}.encrypted' | |||
r232 | return db_type | |||
r1 | ||||
r3232 | @classmethod | |||
def docs(cls): | ||||
""" | ||||
Defines documentation url which helps with plugin setup | ||||
""" | ||||
return '' | ||||
@classmethod | ||||
def icon(cls): | ||||
""" | ||||
Defines ICON in SVG format for authentication method | ||||
""" | ||||
return '' | ||||
r1 | def is_enabled(self): | |||
""" | ||||
Returns true if this plugin is enabled. An enabled plugin can be | ||||
configured in the admin interface but it is not consulted during | ||||
authentication. | ||||
""" | ||||
auth_plugins = SettingsModel().get_auth_plugins() | ||||
return self.get_id() in auth_plugins | ||||
r2681 | def is_active(self, plugin_cached_settings=None): | |||
r1 | """ | |||
Returns true if the plugin is activated. An activated plugin is | ||||
consulted during authentication, assumed it is also enabled. | ||||
""" | ||||
r2681 | return self.get_setting_by_name( | |||
'enabled', plugin_cached_settings=plugin_cached_settings) | ||||
r1 | ||||
def get_id(self): | ||||
""" | ||||
Returns the plugin id. | ||||
""" | ||||
return self._plugin_id | ||||
r4545 | def get_display_name(self, load_from_settings=False): | |||
r1 | """ | |||
Returns a translation string for displaying purposes. | ||||
r4545 | if load_from_settings is set, plugin settings can override the display name | |||
r1 | """ | |||
raise NotImplementedError('Not implemented in base class') | ||||
def get_settings_schema(self): | ||||
""" | ||||
Returns a colander schema, representing the plugin settings. | ||||
""" | ||||
return AuthnPluginSettingsSchemaBase() | ||||
r4220 | def _propagate_settings(self, raw_settings): | |||
r2681 | settings = {} | |||
for node in self.get_settings_schema(): | ||||
settings[node.name] = self.get_setting_by_name( | ||||
node.name, plugin_cached_settings=raw_settings) | ||||
return settings | ||||
r4220 | def get_settings(self, use_cache=True): | |||
""" | ||||
Returns the plugin settings as dictionary. | ||||
""" | ||||
r4836 | raw_settings = SettingsModel().get_all_settings(cache=use_cache) | |||
r4220 | settings = self._propagate_settings(raw_settings) | |||
r4836 | return settings | |||
r4220 | ||||
r2681 | def get_setting_by_name(self, name, default=None, plugin_cached_settings=None): | |||
r1 | """ | |||
Returns a plugin setting by name. | ||||
""" | ||||
r5094 | full_name = f'rhodecode_{self._get_setting_full_name(name)}' | |||
r2681 | if plugin_cached_settings: | |||
plugin_settings = plugin_cached_settings | ||||
r2170 | else: | |||
plugin_settings = SettingsModel().get_all_settings() | ||||
r2140 | ||||
r2233 | if full_name in plugin_settings: | |||
return plugin_settings[full_name] | ||||
else: | ||||
return default | ||||
r1 | ||||
def create_or_update_setting(self, name, value): | ||||
""" | ||||
Create or update a setting for this plugin in the persistent storage. | ||||
""" | ||||
full_name = self._get_setting_full_name(name) | ||||
r232 | type_ = self._get_setting_type(name) | |||
r1 | db_setting = SettingsModel().create_or_update_setting( | |||
full_name, value, type_) | ||||
return db_setting.app_settings_value | ||||
r1631 | def log_safe_settings(self, settings): | |||
""" | ||||
returns a log safe representation of settings, without any secrets | ||||
""" | ||||
settings_copy = copy.deepcopy(settings) | ||||
for k in self._settings_unsafe_keys: | ||||
if k in settings_copy: | ||||
del settings_copy[k] | ||||
return settings_copy | ||||
r1 | @hybrid_property | |||
def name(self): | ||||
""" | ||||
Returns the name of this authentication plugin. | ||||
:returns: string | ||||
""" | ||||
raise NotImplementedError("Not implemented in base class") | ||||
Martin Bornhold
|
r1086 | def get_url_slug(self): | ||
""" | ||||
Returns a slug which should be used when constructing URLs which refer | ||||
to this plugin. By default it returns the plugin name. If the name is | ||||
not suitable for using it in an URL the plugin should override this | ||||
method. | ||||
""" | ||||
return self.name | ||||
r108 | @property | |||
r107 | def is_headers_auth(self): | |||
""" | ||||
Returns True if this authentication plugin uses HTTP headers as | ||||
authentication method. | ||||
""" | ||||
return False | ||||
r1 | @hybrid_property | |||
def is_container_auth(self): | ||||
""" | ||||
r106 | Deprecated method that indicates if this authentication plugin uses | |||
HTTP headers as authentication method. | ||||
r1 | """ | |||
r107 | warnings.warn( | |||
'Use is_headers_auth instead.', category=DeprecationWarning) | ||||
return self.is_headers_auth | ||||
r1 | ||||
@hybrid_property | ||||
def allows_creating_users(self): | ||||
""" | ||||
Defines if Plugin allows users to be created on-the-fly when | ||||
authentication is called. Controls how external plugins should behave | ||||
in terms if they are allowed to create new users, or not. Base plugins | ||||
should not be allowed to, but External ones should be ! | ||||
:return: bool | ||||
""" | ||||
return False | ||||
def set_auth_type(self, auth_type): | ||||
self.auth_type = auth_type | ||||
r1510 | def set_calling_scope_repo(self, acl_repo_name): | |||
self.acl_repo_name = acl_repo_name | ||||
r1 | def allows_authentication_from( | |||
self, user, allows_non_existing_user=True, | ||||
allowed_auth_plugins=None, allowed_auth_sources=None): | ||||
""" | ||||
Checks if this authentication module should accept a request for | ||||
the current user. | ||||
:param user: user object fetched using plugin's get_user() method. | ||||
:param allows_non_existing_user: if True, don't allow the | ||||
user to be empty, meaning not existing in our database | ||||
:param allowed_auth_plugins: if provided, users extern_type will be | ||||
checked against a list of provided extern types, which are plugin | ||||
auth_names in the end | ||||
:param allowed_auth_sources: authentication type allowed, | ||||
`http` or `vcs` default is both. | ||||
defines if plugin will accept only http authentication vcs | ||||
authentication(git/hg) or both | ||||
:returns: boolean | ||||
""" | ||||
if not user and not allows_non_existing_user: | ||||
log.debug('User is empty but plugin does not allow empty users,' | ||||
'not allowed to authenticate') | ||||
return False | ||||
expected_auth_plugins = allowed_auth_plugins or [self.name] | ||||
if user and (user.extern_type and | ||||
user.extern_type not in expected_auth_plugins): | ||||
log.debug( | ||||
'User `%s` is bound to `%s` auth type. Plugin allows only ' | ||||
'%s, skipping', user, user.extern_type, expected_auth_plugins) | ||||
return False | ||||
# by default accept both | ||||
expected_auth_from = allowed_auth_sources or [HTTP_TYPE, VCS_TYPE] | ||||
if self.auth_type not in expected_auth_from: | ||||
log.debug('Current auth source is %s but plugin only allows %s', | ||||
self.auth_type, expected_auth_from) | ||||
return False | ||||
return True | ||||
def get_user(self, username=None, **kwargs): | ||||
""" | ||||
Helper method for user fetching in plugins, by default it's using | ||||
r5057 | simple fetch by username, but this method can be customized in plugins | |||
r106 | eg. headers auth plugin to fetch user by environ params | |||
r1 | ||||
:param username: username if given to fetch from database | ||||
:param kwargs: extra arguments needed for user fetching. | ||||
""" | ||||
r5057 | ||||
r1 | user = None | |||
log.debug( | ||||
'Trying to fetch user `%s` from RhodeCode database', username) | ||||
if username: | ||||
user = User.get_by_username(username) | ||||
if not user: | ||||
log.debug('User not found, fallback to fetch user in ' | ||||
'case insensitive mode') | ||||
user = User.get_by_username(username, case_insensitive=True) | ||||
else: | ||||
log.debug('provided username:`%s` is empty skipping...', username) | ||||
if not user: | ||||
log.debug('User `%s` not found in database', username) | ||||
r1509 | else: | |||
log.debug('Got DB user:%s', user) | ||||
r1 | return user | |||
def user_activation_state(self): | ||||
""" | ||||
Defines user activation state when creating new users | ||||
:returns: boolean | ||||
""" | ||||
raise NotImplementedError("Not implemented in base class") | ||||
def auth(self, userobj, username, passwd, settings, **kwargs): | ||||
""" | ||||
Given a user object (which may be null), username, a plaintext | ||||
password, and a settings object (containing all the keys needed as | ||||
listed in settings()), authenticate this user's login attempt. | ||||
Return None on failure. On success, return a dictionary of the form: | ||||
see: RhodeCodeAuthPluginBase.auth_func_attrs | ||||
This is later validated for correctness | ||||
""" | ||||
raise NotImplementedError("not implemented in base class") | ||||
def _authenticate(self, userobj, username, passwd, settings, **kwargs): | ||||
""" | ||||
Wrapper to call self.auth() that validates call on it | ||||
:param userobj: userobj | ||||
:param username: username | ||||
:param passwd: plaintext password | ||||
:param settings: plugin settings | ||||
""" | ||||
auth = self.auth(userobj, username, passwd, settings, **kwargs) | ||||
if auth: | ||||
r2154 | auth['_plugin'] = self.name | |||
auth['_ttl_cache'] = self.get_ttl_cache(settings) | ||||
r1 | # check if hash should be migrated ? | |||
new_hash = auth.get('_hash_migrate') | ||||
if new_hash: | ||||
r5057 | # new_hash is a newly encrypted destination hash | |||
r1 | self._migrate_hash_to_bcrypt(username, passwd, new_hash) | |||
r2495 | if 'user_group_sync' not in auth: | |||
auth['user_group_sync'] = False | ||||
r1 | return self._validate_auth_return(auth) | |||
return auth | ||||
def _migrate_hash_to_bcrypt(self, username, password, new_hash): | ||||
new_hash_cypher = _RhodeCodeCryptoBCrypt() | ||||
# extra checks, so make sure new hash is correct. | ||||
r5057 | password_as_bytes = safe_bytes(password) | |||
if new_hash and new_hash_cypher.hash_check(password_as_bytes, new_hash): | ||||
r1 | cur_user = User.get_by_username(username) | |||
cur_user.password = new_hash | ||||
Session().add(cur_user) | ||||
Session().flush() | ||||
log.info('Migrated user %s hash to bcrypt', cur_user) | ||||
def _validate_auth_return(self, ret): | ||||
if not isinstance(ret, dict): | ||||
raise Exception('returned value from auth must be a dict') | ||||
for k in self.auth_func_attrs: | ||||
if k not in ret: | ||||
raise Exception('Missing %s attribute from returned data' % k) | ||||
return ret | ||||
r2154 | def get_ttl_cache(self, settings=None): | |||
plugin_settings = settings or self.get_settings() | ||||
r2954 | # we set default to 30, we make a compromise here, | |||
# performance > security, mostly due to LDAP/SVN, majority | ||||
# of users pick cache_ttl to be enabled | ||||
from rhodecode.authentication import plugin_default_auth_ttl | ||||
cache_ttl = plugin_default_auth_ttl | ||||
r2154 | ||||
r4935 | if isinstance(self.AUTH_CACHE_TTL, int): | |||
r2154 | # plugin cache set inside is more important than the settings value | |||
cache_ttl = self.AUTH_CACHE_TTL | ||||
r5140 | elif 'cache_ttl' in plugin_settings: | |||
r2154 | cache_ttl = safe_int(plugin_settings.get('cache_ttl'), 0) | |||
plugin_cache_active = bool(cache_ttl and cache_ttl > 0) | ||||
return plugin_cache_active, cache_ttl | ||||
r1 | ||||
class RhodeCodeExternalAuthPlugin(RhodeCodeAuthPluginBase): | ||||
@hybrid_property | ||||
def allows_creating_users(self): | ||||
return True | ||||
def use_fake_password(self): | ||||
""" | ||||
Return a boolean that indicates whether or not we should set the user's | ||||
password to a random value when it is authenticated by this plugin. | ||||
If your plugin provides authentication, then you will generally | ||||
want this. | ||||
:returns: boolean | ||||
""" | ||||
raise NotImplementedError("Not implemented in base class") | ||||
def _authenticate(self, userobj, username, passwd, settings, **kwargs): | ||||
# at this point _authenticate calls plugin's `auth()` function | ||||
r5094 | auth = super()._authenticate( | |||
r1 | userobj, username, passwd, settings, **kwargs) | |||
r2142 | ||||
r1 | if auth: | |||
# maybe plugin will clean the username ? | ||||
# we should use the return value | ||||
username = auth['username'] | ||||
# if external source tells us that user is not active, we should | ||||
# skip rest of the process. This can prevent from creating users in | ||||
# RhodeCode when using external authentication, but if it's | ||||
# inactive user we shouldn't create that user anyway | ||||
if auth['active_from_extern'] is False: | ||||
log.warning( | ||||
"User %s authenticated against %s, but is inactive", | ||||
username, self.__module__) | ||||
return None | ||||
cur_user = User.get_by_username(username, case_insensitive=True) | ||||
is_user_existing = cur_user is not None | ||||
if is_user_existing: | ||||
log.debug('Syncing user `%s` from ' | ||||
'`%s` plugin', username, self.name) | ||||
else: | ||||
log.debug('Creating non existing user `%s` from ' | ||||
'`%s` plugin', username, self.name) | ||||
if self.allows_creating_users: | ||||
log.debug('Plugin `%s` allows to ' | ||||
'create new users', self.name) | ||||
else: | ||||
log.debug('Plugin `%s` does not allow to ' | ||||
'create new users', self.name) | ||||
user_parameters = { | ||||
'username': username, | ||||
'email': auth["email"], | ||||
'firstname': auth["firstname"], | ||||
'lastname': auth["lastname"], | ||||
'active': auth["active"], | ||||
'admin': auth["admin"], | ||||
'extern_name': auth["extern_name"], | ||||
'extern_type': self.name, | ||||
'plugin': self, | ||||
'allow_to_create_user': self.allows_creating_users, | ||||
} | ||||
if not is_user_existing: | ||||
if self.use_fake_password(): | ||||
# Randomize the PW because we don't need it, but don't want | ||||
# them blank either | ||||
passwd = PasswordGenerator().gen_password(length=16) | ||||
user_parameters['password'] = passwd | ||||
else: | ||||
# Since the password is required by create_or_update method of | ||||
# UserModel, we need to set it explicitly. | ||||
# The create_or_update method is smart and recognises the | ||||
# password hashes as well. | ||||
user_parameters['password'] = cur_user.password | ||||
# we either create or update users, we also pass the flag | ||||
# that controls if this method can actually do that. | ||||
# raises NotAllowedToCreateUserError if it cannot, and we try to. | ||||
user = UserModel().create_or_update(**user_parameters) | ||||
Session().flush() | ||||
# enforce user is just in given groups, all of them has to be ones | ||||
# created from plugins. We store this info in _group_data JSON | ||||
# field | ||||
r2495 | ||||
if auth['user_group_sync']: | ||||
try: | ||||
groups = auth['groups'] or [] | ||||
log.debug( | ||||
'Performing user_group sync based on set `%s` ' | ||||
'returned by `%s` plugin', groups, self.name) | ||||
UserGroupModel().enforce_groups(user, groups, self.name) | ||||
except Exception: | ||||
# for any reason group syncing fails, we should | ||||
# proceed with login | ||||
log.error(traceback.format_exc()) | ||||
r1 | Session().commit() | |||
return auth | ||||
r2736 | class AuthLdapBase(object): | |||
@classmethod | ||||
r3235 | def _build_servers(cls, ldap_server_type, ldap_server, port, use_resolver=True): | |||
r2736 | def host_resolver(host, port, full_resolve=True): | |||
""" | ||||
Main work for this function is to prevent ldap connection issues, | ||||
and detect them early using a "greenified" sockets | ||||
""" | ||||
host = host.strip() | ||||
if not full_resolve: | ||||
r5094 | return f'{host}:{port}' | |||
r2736 | ||||
r4244 | log.debug('LDAP: Resolving IP for LDAP host `%s`', host) | |||
r2736 | try: | |||
ip = socket.gethostbyname(host) | ||||
r4244 | log.debug('LDAP: Got LDAP host `%s` ip %s', host, ip) | |||
r2736 | except Exception: | |||
r5094 | raise LdapConnectionError(f'Failed to resolve host: `{host}`') | |||
r2736 | ||||
log.debug('LDAP: Checking if IP %s is accessible', ip) | ||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) | ||||
try: | ||||
s.connect((ip, int(port))) | ||||
s.shutdown(socket.SHUT_RD) | ||||
r4244 | log.debug('LDAP: connection to %s successful', ip) | |||
r2736 | except Exception: | |||
raise LdapConnectionError( | ||||
r5094 | f'Failed to connect to host: `{host}:{port}`') | |||
r2736 | ||||
r5094 | return f'{host}:{port}' | |||
r2736 | ||||
if len(ldap_server) == 1: | ||||
# in case of single server use resolver to detect potential | ||||
# connection issues | ||||
full_resolve = True | ||||
else: | ||||
full_resolve = False | ||||
return ', '.join( | ||||
["{}://{}".format( | ||||
ldap_server_type, | ||||
r3235 | host_resolver(host, port, full_resolve=use_resolver and full_resolve)) | |||
r2736 | for host in ldap_server]) | |||
@classmethod | ||||
def _get_server_list(cls, servers): | ||||
r5140 | return [s.strip() for s in servers.split(',')] | |||
r2736 | ||||
@classmethod | ||||
def get_uid(cls, username, server_addresses): | ||||
uid = username | ||||
for server_addr in server_addresses: | ||||
uid = chop_at(username, "@%s" % server_addr) | ||||
return uid | ||||
r3235 | @classmethod | |||
def validate_username(cls, username): | ||||
if "," in username: | ||||
raise LdapUsernameError( | ||||
r5094 | f"invalid character `,` in username: `{username}`") | |||
r3235 | ||||
@classmethod | ||||
def validate_password(cls, username, password): | ||||
if not password: | ||||
msg = "Authenticating user %s with blank password not allowed" | ||||
log.warning(msg, username) | ||||
raise LdapPasswordError(msg) | ||||
r2736 | ||||
r1 | def loadplugin(plugin_id): | |||
""" | ||||
Loads and returns an instantiated authentication plugin. | ||||
Returns the RhodeCodeAuthPluginBase subclass on success, | ||||
r102 | or None on failure. | |||
r1 | """ | |||
# TODO: Disusing pyramids thread locals to retrieve the registry. | ||||
r440 | authn_registry = get_authn_registry() | |||
r1 | plugin = authn_registry.get_plugin(plugin_id) | |||
if plugin is None: | ||||
log.error('Authentication plugin not found: "%s"', plugin_id) | ||||
return plugin | ||||
r5057 | def get_authn_registry(registry=None) -> AuthenticationPluginRegistry: | |||
r440 | registry = registry or get_current_registry() | |||
r4220 | authn_registry = registry.queryUtility(IAuthnPluginRegistry) | |||
r440 | return authn_registry | |||
r1 | def authenticate(username, password, environ=None, auth_type=None, | |||
r1510 | skip_missing=False, registry=None, acl_repo_name=None): | |||
r1 | """ | |||
Authentication function used for access control, | ||||
It tries to authenticate based on enabled authentication modules. | ||||
r106 | :param username: username can be empty for headers auth | |||
:param password: password can be empty for headers auth | ||||
:param environ: environ headers passed for headers auth | ||||
r1 | :param auth_type: type of authentication, either `HTTP_TYPE` or `VCS_TYPE` | |||
:param skip_missing: ignores plugins that are in db but not in environment | ||||
r5057 | :param registry: pyramid registry | |||
:param acl_repo_name: name of repo for ACL checks | ||||
r1 | :returns: None if auth failed, plugin_user dict if auth is correct | |||
""" | ||||
if not auth_type or auth_type not in [HTTP_TYPE, VCS_TYPE]: | ||||
r5057 | raise ValueError(f'auth type must be on of http, vcs got "{auth_type}" instead') | |||
auth_credentials = (username and password) | ||||
headers_only = environ and not auth_credentials | ||||
r1 | ||||
Martin Bornhold
|
r591 | authn_registry = get_authn_registry(registry) | ||
r4220 | ||||
r2142 | plugins_to_check = authn_registry.get_plugins_for_authentication() | |||
r5057 | log.debug('authentication: headers=%s, username_and_passwd=%s', headers_only, bool(auth_credentials)) | |||
r2142 | log.debug('Starting ordered authentication chain using %s plugins', | |||
r2736 | [x.name for x in plugins_to_check]) | |||
r5057 | ||||
r2142 | for plugin in plugins_to_check: | |||
r1 | plugin.set_auth_type(auth_type) | |||
r1510 | plugin.set_calling_scope_repo(acl_repo_name) | |||
r1 | ||||
r106 | if headers_only and not plugin.is_headers_auth: | |||
log.debug('Auth type is for headers only and plugin `%s` is not ' | ||||
'headers plugin, skipping...', plugin.get_id()) | ||||
r1 | continue | |||
r2641 | log.debug('Trying authentication using ** %s **', plugin.get_id()) | |||
r1 | # load plugin settings from RhodeCode database | |||
plugin_settings = plugin.get_settings() | ||||
r1631 | plugin_sanitized_settings = plugin.log_safe_settings(plugin_settings) | |||
r2641 | log.debug('Plugin `%s` settings:%s', plugin.get_id(), plugin_sanitized_settings) | |||
r1 | ||||
# use plugin's method of user extraction. | ||||
user = plugin.get_user(username, environ=environ, | ||||
settings=plugin_settings) | ||||
display_user = user.username if user else username | ||||
r52 | log.debug( | |||
'Plugin %s extracted user is `%s`', plugin.get_id(), display_user) | ||||
r1 | ||||
if not plugin.allows_authentication_from(user): | ||||
log.debug('Plugin %s does not accept user `%s` for authentication', | ||||
r52 | plugin.get_id(), display_user) | |||
r1 | continue | |||
else: | ||||
log.debug('Plugin %s accepted user `%s` for authentication', | ||||
r52 | plugin.get_id(), display_user) | |||
r1 | ||||
log.info('Authenticating user `%s` using %s plugin', | ||||
r52 | display_user, plugin.get_id()) | |||
r1 | ||||
r2154 | plugin_cache_active, cache_ttl = plugin.get_ttl_cache(plugin_settings) | |||
r1 | ||||
r498 | log.debug('AUTH_CACHE_TTL for plugin `%s` active: %s (TTL: %s)', | |||
r2154 | plugin.get_id(), plugin_cache_active, cache_ttl) | |||
r1 | ||||
r4425 | user_id = user.user_id if user else 'no-user' | |||
r2847 | # don't cache for empty users | |||
plugin_cache_active = plugin_cache_active and user_id | ||||
r5106 | cache_namespace_uid = f'cache_user_auth.{rc_cache.PERMISSIONS_CACHE_VER}.{user_id}' | |||
r2845 | region = rc_cache.get_or_create_region('cache_perms', cache_namespace_uid) | |||
r1 | ||||
r2892 | @region.conditional_cache_on_arguments(namespace=cache_namespace_uid, | |||
expiration_time=cache_ttl, | ||||
condition=plugin_cache_active) | ||||
r2845 | def compute_auth( | |||
cache_name, plugin_name, username, password): | ||||
r1 | ||||
r2845 | # _authenticate is a wrapper for .auth() method of plugin. | |||
# it checks if .auth() sends proper data. | ||||
# For RhodeCodeExternalAuthPlugin it also maps users to | ||||
# Database and maps the attributes returned from .auth() | ||||
# to RhodeCode database. If this function returns data | ||||
# then auth is correct. | ||||
log.debug('Running plugin `%s` _authenticate method ' | ||||
'using username and password', plugin.get_id()) | ||||
r1 | return plugin._authenticate( | |||
user, username, password, plugin_settings, | ||||
environ=environ or {}) | ||||
r2845 | start = time.time() | |||
# for environ based auth, password can be empty, but then the validation is | ||||
# on the server that fills in the env data needed for authentication | ||||
plugin_user = compute_auth('auth', plugin.name, username, (password or '')) | ||||
r1 | ||||
auth_time = time.time() - start | ||||
r3853 | log.debug('Authentication for plugin `%s` completed in %.4fs, ' | |||
r1 | 'expiration time of fetched cache %.1fs.', | |||
r4816 | plugin.get_id(), auth_time, cache_ttl, | |||
extra={"plugin": plugin.get_id(), "time": auth_time}) | ||||
r1 | ||||
log.debug('PLUGIN USER DATA: %s', plugin_user) | ||||
r4803 | statsd = StatsdClient.statsd | |||
r1 | if plugin_user: | |||
log.debug('Plugin returned proper authentication data') | ||||
r4803 | if statsd: | |||
r4816 | elapsed_time_ms = round(1000.0 * auth_time) # use ms only | |||
r4803 | statsd.incr('rhodecode_login_success_total') | |||
r4816 | statsd.timing("rhodecode_login_timing.histogram", elapsed_time_ms, | |||
r5094 | tags=[f"plugin:{plugin.get_id()}"], | |||
r4816 | use_decimals=False | |||
) | ||||
r1 | return plugin_user | |||
r4803 | ||||
r1 | # we failed to Auth because .auth() method didn't return proper user | |||
log.debug("User `%s` failed to authenticate against %s", | ||||
r52 | display_user, plugin.get_id()) | |||
r4803 | if statsd: | |||
statsd.incr('rhodecode_login_fail_total') | ||||
r2154 | ||||
# case when we failed to authenticate against all defined plugins | ||||
r1 | return None | |||
r1454 | ||||
def chop_at(s, sub, inclusive=False): | ||||
"""Truncate string ``s`` at the first occurrence of ``sub``. | ||||
If ``inclusive`` is true, truncate just after ``sub`` rather than at it. | ||||
>>> chop_at("plutocratic brats", "rat") | ||||
'plutoc' | ||||
>>> chop_at("plutocratic brats", "rat", True) | ||||
'plutocrat' | ||||
""" | ||||
pos = s.find(sub) | ||||
if pos == -1: | ||||
return s | ||||
if inclusive: | ||||
return s[:pos+len(sub)] | ||||
return s[:pos] | ||||