# Copyright (C) 2010-2023 RhodeCode GmbH
#
# 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
"""
import socket
import string
import colander
import copy
import logging
import time
import traceback
import warnings
import functools

from pyramid.threadlocal import get_current_registry

from rhodecode.authentication import AuthenticationPluginRegistry
from rhodecode.authentication.interface import IAuthnPluginRegistry
from rhodecode.authentication.schema import AuthnPluginSettingsSchemaBase
from rhodecode.lib import rc_cache
from rhodecode.lib.statsd_client import StatsdClient
from rhodecode.lib.auth import PasswordGenerator, _RhodeCodeCryptoBCrypt
from rhodecode.lib.str_utils import safe_bytes
from rhodecode.lib.utils2 import safe_int, safe_str
from rhodecode.lib.exceptions import (LdapConnectionError, LdapUsernameError, LdapPasswordError)
from rhodecode.model.db import User
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'

external_auth_session_key = 'rhodecode.external_auth'


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)


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):
    # UID is used to register plugin to the registry
    uid = None

    # 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"]',
        "user_group_sync":
            'True|False defines if returned user groups should be synced',
        "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":
            "True|False|None, active state from the external auth, "
            "None means use definition from RhodeCode extern_type active value"

    }
    # set on authenticate() method and via set_auth_type func.
    auth_type = None

    # 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

    # 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 = {
        colander.String: 'unicode',
        colander.Integer: 'int',
        colander.Boolean: 'bool',
        colander.List: 'list',
    }

    # list of keys in settings that are unsafe to be logged, should be passwords
    # or other crucial credentials
    _settings_unsafe_keys = []

    def __init__(self, plugin_id):
        self._plugin_id = plugin_id

    def __str__(self):
        return self.get_id()

    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.
        return f'auth_{self.name}_{name}'

    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.
        """
        schema_node = self.get_settings_schema().get(name)
        db_type = self._settings_type_map.get(
            type(schema_node.typ), 'unicode')
        if name in self._settings_encrypted:
            db_type = f'{db_type}.encrypted'
        return db_type

    @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 ''

    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

    def is_active(self, plugin_cached_settings=None):
        """
        Returns true if the plugin is activated. An activated plugin is
        consulted during authentication, assumed it is also enabled.
        """
        return self.get_setting_by_name(
            'enabled', plugin_cached_settings=plugin_cached_settings)

    def get_id(self):
        """
        Returns the plugin id.
        """
        return self._plugin_id

    def get_display_name(self, load_from_settings=False):
        """
        Returns a translation string for displaying purposes.
        if load_from_settings is set, plugin settings can override the display name
        """
        raise NotImplementedError('Not implemented in base class')

    def get_settings_schema(self):
        """
        Returns a colander schema, representing the plugin settings.
        """
        return AuthnPluginSettingsSchemaBase()

    def _propagate_settings(self, raw_settings):
        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

    def get_settings(self, use_cache=True):
        """
        Returns the plugin settings as dictionary.
        """

        raw_settings = SettingsModel().get_all_settings(cache=use_cache)
        settings = self._propagate_settings(raw_settings)

        return settings

    def get_setting_by_name(self, name, default=None, plugin_cached_settings=None):
        """
        Returns a plugin setting by name.
        """
        full_name = f'rhodecode_{self._get_setting_full_name(name)}'
        if plugin_cached_settings:
            plugin_settings = plugin_cached_settings
        else:
            plugin_settings = SettingsModel().get_all_settings()

        if full_name in plugin_settings:
            return plugin_settings[full_name]
        else:
            return default

    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)
        type_ = self._get_setting_type(name)
        db_setting = SettingsModel().create_or_update_setting(
            full_name, value, type_)
        return db_setting.app_settings_value

    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

    @hybrid_property
    def name(self):
        """
        Returns the name of this authentication plugin.

        :returns: string
        """
        raise NotImplementedError("Not implemented in base class")

    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

    @property
    def is_headers_auth(self):
        """
        Returns True if this authentication plugin uses HTTP headers as
        authentication method.
        """
        return False

    @hybrid_property
    def is_container_auth(self):
        """
        Deprecated method that indicates if this authentication plugin uses
        HTTP headers as authentication method.
        """
        warnings.warn(
            'Use is_headers_auth instead.', category=DeprecationWarning)
        return self.is_headers_auth

    @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

    def set_calling_scope_repo(self, acl_repo_name):
        self.acl_repo_name = acl_repo_name

    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
        simple fetch by username, but this method can be customized in plugins
        eg. headers auth plugin to fetch user by environ params

        :param username: username if given to fetch from database
        :param kwargs: extra arguments needed for user fetching.
        """

        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)
        else:
            log.debug('Got DB user:%s', user)
        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:
            auth['_plugin'] = self.name
            auth['_ttl_cache'] = self.get_ttl_cache(settings)
            # check if hash should be migrated ?
            new_hash = auth.get('_hash_migrate')
            if new_hash:
                # new_hash is a newly encrypted destination hash
                self._migrate_hash_to_bcrypt(username, passwd, new_hash)
            if 'user_group_sync' not in auth:
                auth['user_group_sync'] = False
            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.
        password_as_bytes = safe_bytes(password)

        if new_hash and new_hash_cypher.hash_check(password_as_bytes, new_hash):
            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

    def get_ttl_cache(self, settings=None):
        plugin_settings = settings or self.get_settings()
        # 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

        if isinstance(self.AUTH_CACHE_TTL, int):
            # plugin cache set inside is more important than the settings value
            cache_ttl = self.AUTH_CACHE_TTL
        elif plugin_settings.get('cache_ttl'):
            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


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
        auth = super()._authenticate(
            userobj, username, passwd, settings, **kwargs)

        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

            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())

            Session().commit()
        return auth


class AuthLdapBase(object):

    @classmethod
    def _build_servers(cls, ldap_server_type, ldap_server, port, use_resolver=True):

        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:
                return f'{host}:{port}'

            log.debug('LDAP: Resolving IP for LDAP host `%s`', host)
            try:
                ip = socket.gethostbyname(host)
                log.debug('LDAP: Got LDAP host `%s` ip %s', host, ip)
            except Exception:
                raise LdapConnectionError(f'Failed to resolve host: `{host}`')

            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)
                log.debug('LDAP: connection to %s successful', ip)
            except Exception:
                raise LdapConnectionError(
                    f'Failed to connect to host: `{host}:{port}`')

            return f'{host}:{port}'

        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,
                host_resolver(host, port, full_resolve=use_resolver and full_resolve))
             for host in ldap_server])

    @classmethod
    def _get_server_list(cls, servers):
        return map(string.strip, servers.split(','))

    @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

    @classmethod
    def validate_username(cls, username):
        if "," in username:
            raise LdapUsernameError(
                f"invalid character `,` in username: `{username}`")

    @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)


def loadplugin(plugin_id):
    """
    Loads and returns an instantiated authentication plugin.
    Returns the RhodeCodeAuthPluginBase subclass on success,
    or None on failure.
    """
    # TODO: Disusing pyramids thread locals to retrieve the registry.
    authn_registry = get_authn_registry()
    plugin = authn_registry.get_plugin(plugin_id)
    if plugin is None:
        log.error('Authentication plugin not found: "%s"', plugin_id)
    return plugin


def get_authn_registry(registry=None) -> AuthenticationPluginRegistry:
    registry = registry or get_current_registry()
    authn_registry = registry.queryUtility(IAuthnPluginRegistry)
    return authn_registry


def authenticate(username, password, environ=None, auth_type=None,
                 skip_missing=False, registry=None, acl_repo_name=None):
    """
    Authentication function used for access control,
    It tries to authenticate based on enabled authentication modules.

    :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
    :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
    :param registry: pyramid registry
    :param acl_repo_name: name of repo for ACL checks
    :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]:
        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

    authn_registry = get_authn_registry(registry)

    plugins_to_check = authn_registry.get_plugins_for_authentication()
    log.debug('authentication: headers=%s, username_and_passwd=%s', headers_only, bool(auth_credentials))
    log.debug('Starting ordered authentication chain using %s plugins',
              [x.name for x in plugins_to_check])

    for plugin in plugins_to_check:
        plugin.set_auth_type(auth_type)
        plugin.set_calling_scope_repo(acl_repo_name)

        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())
            continue

        log.debug('Trying authentication using ** %s **', plugin.get_id())

        # load plugin settings from RhodeCode database
        plugin_settings = plugin.get_settings()
        plugin_sanitized_settings = plugin.log_safe_settings(plugin_settings)
        log.debug('Plugin `%s` settings:%s', plugin.get_id(), plugin_sanitized_settings)

        # 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
        log.debug(
            'Plugin %s extracted user is `%s`', plugin.get_id(), display_user)

        if not plugin.allows_authentication_from(user):
            log.debug('Plugin %s does not accept user `%s` for authentication',
                      plugin.get_id(), display_user)
            continue
        else:
            log.debug('Plugin %s accepted user `%s` for authentication',
                      plugin.get_id(), display_user)

        log.info('Authenticating user `%s` using %s plugin',
                 display_user, plugin.get_id())

        plugin_cache_active, cache_ttl = plugin.get_ttl_cache(plugin_settings)

        log.debug('AUTH_CACHE_TTL for plugin `%s` active: %s (TTL: %s)',
                  plugin.get_id(), plugin_cache_active, cache_ttl)

        user_id = user.user_id if user else 'no-user'
        # don't cache for empty users
        plugin_cache_active = plugin_cache_active and user_id
        cache_namespace_uid = f'cache_user_auth.{user_id}'
        region = rc_cache.get_or_create_region('cache_perms', cache_namespace_uid)

        @region.conditional_cache_on_arguments(namespace=cache_namespace_uid,
                                               expiration_time=cache_ttl,
                                               condition=plugin_cache_active)
        def compute_auth(
                cache_name, plugin_name, username, password):

            # _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())
            return plugin._authenticate(
                user, username, password, plugin_settings,
                environ=environ or {})

        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 ''))

        auth_time = time.time() - start
        log.debug('Authentication for plugin `%s` completed in %.4fs, '
                  'expiration time of fetched cache %.1fs.',
                  plugin.get_id(), auth_time, cache_ttl,
                  extra={"plugin": plugin.get_id(), "time": auth_time})

        log.debug('PLUGIN USER DATA: %s', plugin_user)

        statsd = StatsdClient.statsd

        if plugin_user:
            log.debug('Plugin returned proper authentication data')
            if statsd:
                elapsed_time_ms = round(1000.0 * auth_time)  # use ms only
                statsd.incr('rhodecode_login_success_total')
                statsd.timing("rhodecode_login_timing.histogram", elapsed_time_ms,
                              tags=[f"plugin:{plugin.get_id()}"],
                              use_decimals=False
                )
            return plugin_user

        # we failed to Auth because .auth() method didn't return proper user
        log.debug("User `%s` failed to authenticate against %s",
                  display_user, plugin.get_id())
        if statsd:
            statsd.incr('rhodecode_login_fail_total')

    # case when we failed to authenticate against all defined plugins
    return None


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]
