diff --git a/.ci/auth_requirements b/.ci/auth_requirements new file mode 100644 index 0000000000..8e769af0db --- /dev/null +++ b/.ci/auth_requirements @@ -0,0 +1,2 @@ +python-ldap==2.4.22.0 +python-pam==1.8.2 diff --git a/.ci/basic_python_requirements b/.ci/basic_python_requirements index 7fd633583e..92655a47a1 100644 --- a/.ci/basic_python_requirements +++ b/.ci/basic_python_requirements @@ -1,3 +1,4 @@ sqlalchemy==1.0.9 alembic==0.8.2 thrift==0.9.1 +portalocker=1.0.0 diff --git a/.ci/python_requirements b/.ci/python_requirements index d954fb6f7d..7c65a5ba2b 100644 --- a/.ci/python_requirements +++ b/.ci/python_requirements @@ -3,3 +3,4 @@ alembic==0.8.2 psycopg2==2.5.4 pg8000==1.10.2 thrift==0.9.1 +portalocker==1.0.0 diff --git a/README.md b/README.md index d78f76b29b..f7f5cbcf79 100644 --- a/README.md +++ b/README.md @@ -176,3 +176,5 @@ See user guide for further configuration and check options. [Test documentation](tests/functional/package_test.md) [Database schema migration](docs/db_schema_guide.md) + +[Privileged server and authentication in client](docs/authentication.md) \ No newline at end of file diff --git a/build_package.py b/build_package.py index a91b7f9a35..82d8cbeee6 100755 --- a/build_package.py +++ b/build_package.py @@ -111,6 +111,15 @@ def generate_thrift_files(thrift_files_dir, env, silent=True): LOG.error('Failed to generate viewer server files') return ret + auth_thrift = os.path.join(thrift_files_dir, 'authentication.thrift') + auth_thrift = 'authentication.thrift' + auth_cmd = ['thrift', '-r', '-I', '.', + '--gen', 'py', auth_thrift] + ret = run_cmd(auth_cmd, thrift_files_dir, env, silent=silent) + if ret: + LOG.error('Failed to generate authentication interface files') + return ret + # ------------------------------------------------------------------- def generate_documentation(doc_root, env, silent=True): diff --git a/codechecker_lib/arg_handler.py b/codechecker_lib/arg_handler.py index 1f5516311d..69551ee21d 100644 --- a/codechecker_lib/arg_handler.py +++ b/codechecker_lib/arg_handler.py @@ -24,6 +24,7 @@ from codechecker_lib import host_check from codechecker_lib import log_parser from codechecker_lib import logger +from codechecker_lib import session_manager from codechecker_lib import util from codechecker_lib.analyzers import analyzer_types from codechecker_lib.database_handler import SQLServer @@ -118,6 +119,7 @@ def handle_server(args): context = generic_package_context.get_context() context.codechecker_workspace = workspace + session_manager.SessionManager.CodeChecker_Workspace = workspace context.db_username = args.dbusername check_env = analyzer_env.get_check_env(context.path_env_extra, @@ -437,7 +439,6 @@ def handle_plist(args): finally: pool.join() - def handle_version_info(args): """ Get and print the version information from the diff --git a/codechecker_lib/session_manager.py b/codechecker_lib/session_manager.py new file mode 100644 index 0000000000..2ca142ca3d --- /dev/null +++ b/codechecker_lib/session_manager.py @@ -0,0 +1,406 @@ +# ------------------------------------------------------------------------- +# The CodeChecker Infrastructure +# This file is distributed under the University of Illinois Open Source +# License. See LICENSE.TXT for details. +# ------------------------------------------------------------------------- +""" +Handles the allocation and destruction of privileged sessions associated +with a particular CodeChecker server. +""" + +import getpass +import hashlib +import json +import os +import portalocker +import stat +import shutil +import time +import tempfile +import uuid + +from datetime import datetime + +from codechecker_lib import logger + +unsupported_methods = [] + +try: + import ldap +except ImportError: + unsupported_methods.append("ldap") + +try: + import pam + import grp + import pwd +except ImportError: + unsupported_methods.append("pam") + +LOG = logger.get_new_logger("SESSION MANAGER") +SESSION_COOKIE_NAME = "__ccPrivilegedAccessToken" +session_lifetimes = {} + + +# ------------------------------ +# ----------- SERVER ----------- +class _Session(): + """A session for an authenticated, privileged client connection.""" + + # Create an initial salt from system environment for use with the session + # permanent persistency routine. + __initial_salt = hashlib.sha256(SESSION_COOKIE_NAME + "__" + + str(time.time()) + "__" + + os.urandom(16)).hexdigest() + + @staticmethod + def calc_persistency_hash(client_addr, auth_string): + """Calculates a more secure persistency hash for the session. This + persistency hash is intended to be used for the "session recycle" + feature to prevent NAT endpoints from accidentally getting each + other's session.""" + return hashlib.sha256(auth_string + "@" + client_addr + ":" + + _Session.__initial_salt).hexdigest() + + def __init__(self, client_addr, token, phash): + self.client = client_addr + self.token = token + self.persistent_hash = phash + self.last_access = datetime.now() + + def still_valid(self, do_revalidate=False): + """Returns if the session is still valid, and optionally revalidates + it. A session is valid in its soft-lifetime.""" + if (datetime.now() - self.last_access).total_seconds() <= \ + session_lifetimes["soft"] \ + and (datetime.now() - self.last_access).total_seconds() <= \ + session_lifetimes["hard"]: + # If the session is still valid within the "reuse enabled" (soft) + # past and the check comes from a real user access, we revalidate + # the session by extending its lifetime --- the user retains their + # data. + if do_revalidate: + self.revalidate() + + # The session is still valid if it has been used in the past + # (length of "past" is up to server host) + return True + + # If the session is older than the "soft" limit, + # the user needs to authenticate again. + return False + + def still_reusable(self): + """Returns whether the session is still reusable, ie. within its + hard lifetime: while a session is reusable, a valid authentication + from the session's user will return the user to the session.""" + return (datetime.now() - self.last_access).total_seconds() <= \ + session_lifetimes["hard"] + + def revalidate(self): + if self.still_reusable(): + # A session is only revalidated if it has yet to exceed its + # "hard" lifetime. After a session hasn't been used for this + # timeframe, it can NOT be resurrected at all --- the user needs + # to log in into a brand-new session. + self.last_access = datetime.now() + +class SessionManager: + CodeChecker_Workspace = None + + __valid_sessions = [] + __logins_since_prune = 0 + + def __init__(self): + LOG.debug('Loading session config') + + # Check whether workspace's configuration exists. + session_cfg_file = os.path.join(SessionManager.CodeChecker_Workspace, + "session_config.json") + if not os.path.exists(session_cfg_file): + LOG.warning("CodeChecker server's authentication example " + "configuration file created at " + session_cfg_file) + shutil.copyfile(os.path.join(os.environ['CC_PACKAGE_ROOT'], + "config", "session_config.json"), + session_cfg_file) + + LOG.debug(session_cfg_file) + + scfg_dict = {'authentication': {'enabled': False}} + with open(session_cfg_file, 'r') as scfg: + scfg_dict.update(json.loads(scfg.read())) + + self.__auth_config = scfg_dict["authentication"] + + # If no methods are configured as enabled, disable authentication. + if scfg_dict["authentication"].get("enabled"): + found_auth_method = False + + if "method_dictionary" in self.__auth_config and \ + self.__auth_config["method_dictionary"].get("enabled"): + found_auth_method = True + + if "method_ldap" in self.__auth_config and \ + self.__auth_config["method_ldap"].get("enabled"): + if "ldap" not in unsupported_methods: + found_auth_method = True + else: + LOG.warning("LDAP authentication was enabled but " + "prerequisites are NOT installed on the system" + "... Disabling LDAP authentication") + self.__auth_config["method_ldap"]["enabled"] = False + + if "method_pam" in self.__auth_config and \ + self.__auth_config["method_pam"].get("enabled"): + if "pam" not in unsupported_methods: + found_auth_method = True + else: + LOG.warning("PAM authentication was enabled but " + "prerequisites are NOT installed on the system" + "... Disabling PAM authentication") + self.__auth_config["method_pam"]["enabled"] = False + + # + if not found_auth_method: + LOG.warning("Authentication is enabled but no valid " + "authentication backends are configured... " + "Falling back to no authentication.") + self.__auth_config["enabled"] = False + + session_lifetimes["soft"] = self.__auth_config.get("soft_expire")\ + or 60 + session_lifetimes["hard"] = self.__auth_config.get("session_lifetime")\ + or 300 + + def isEnabled(self): + return self.__auth_config.get("enabled") + + def getRealm(self): + return { + "realm": self.__auth_config.get("realm_name"), + "error": self.__auth_config.get("realm_error") + } + + def __handle_validation(self, auth_string): + """Validate an oncoming authorization request + against some authority controller.""" + return self.__try_auth_dictionary(auth_string) \ + or self.__try_auth_pam(auth_string) \ + or self.__try_auth_ldap(auth_string) + + def __is_method_enabled(self, method): + return method not in unsupported_methods and \ + "method_" + method in self.__auth_config and \ + self.__auth_config["method_" + method].get("enabled") + + def __try_auth_dictionary(self, auth_string): + return self.__is_method_enabled("dictionary") and \ + auth_string in \ + self.__auth_config.get("method_dictionary").get("auths") + + def __try_auth_pam(self, auth_string): + if self.__is_method_enabled("pam"): + username, pw = auth_string.split(":") + auth = pam.pam() + + if auth.authenticate(username, pw): + allowed_users = self.__auth_config["method_pam"].get("users") \ + or [] + allowed_group = self.__auth_config["method_pam"].get("groups")\ + or [] + + if len(allowed_users) == 0 and len(allowed_group) == 0: + # If no filters are set, only authentication is needed. + return True + else: + if username in allowed_users: + # The user is allowed by username. + return True + + # Otherwise, check group memeberships. If any of the user's + # groups are an allowed groupl, the user is allowed + groups = [g.gr_name for g in grp.getgrall() + if username in g.gr_mem] + gid = pwd.getpwnam(username).pw_gid + groups.append(grp.getgrgid(gid).gr_name) + + return not set(groups).isdisjoint( + set(self.__auth_config["method_pam"].get("groups"))) + + return False + + def __try_auth_ldap(self, auth_string): + if self.__is_method_enabled("ldap"): + username, pw = auth_string.split(":") + for server in self.__auth_config["method_ldap"].get("authorities"): + l = ldap.initialize(server["connection_url"]) + + for query in server["queries"]: + try: + l.simple_bind_s(query.replace("$USN$", username), pw) + return True + except ldap.LDAPError as e: + toPrint = '' + if e.message: + if 'info' in e.message: + toPrint = toPrint + e.message['info'] + if 'info' in e.message and 'desc' in e.message: + toPrint = toPrint + "; " + if 'desc' in e.message: + toPrint = toPrint + e.message['desc'] + else: + toPrint = e.__repr__() + + LOG.info("LDAP authentication error against " + + server["connection_url"] + " with DN: " + + query.replace("$USN$", username) + "\n" + + "Error was: " + toPrint) + finally: + l.unbind() + + return False + + def create_or_get_session(self, client, auth_string): + """Create a new session for the given client and auth-string, if + it is valid. If an existing session is found, return that instead.""" + if not self.__auth_config["enabled"]: + return None + + self.__logins_since_prune += 1 + if self.__logins_since_prune >= \ + self.__auth_config["logins_until_cleanup"]: + self.__cleanup_sessions() + + if self.__handle_validation(auth_string): + session_already = next( + (s for s + in SessionManager.__valid_sessions if s.client == client + and s.still_reusable() + and s.persistent_hash == + _Session.calc_persistency_hash(client, auth_string)), + None) + + if session_already: + session_already.revalidate() + session = session_already + else: + # TODO: Use a more secure way for token generation? + token = uuid.UUID(bytes=os.urandom(16)).__str__().replace("-", + "") + session = _Session(client, token, + _Session.calc_persistency_hash(client, + auth_string)) + SessionManager.__valid_sessions.append(session) + + return session.token + else: + return None + + def is_valid(self, client, token, access=False): + """Validates a given token (cookie) against + the known list of privileged sessions.""" + if not self.isEnabled(): + return True + else: + return any(_sess.client == client + and _sess.token == token + and _sess.still_valid(access) + for _sess in SessionManager.__valid_sessions) + + def invalidate(self, client, token): + """Remove a user's previous session from the store.""" + for session in SessionManager.__valid_sessions[:]: + if session.client == client and session.token == token: + SessionManager.__valid_sessions.remove(session) + return True + + return False + + def __cleanup_sessions(self): + SessionManager.__valid_sessions = [s for s + in SessionManager.__valid_sessions + if s.still_reusable()] + self.__logins_since_prune = 0 + + +# ------------------------------ +# ----------- CLIENT ----------- +class SessionManager_Client: + def __init__(self): + LOG.debug('Loading session config') + + # Check whether user's configuration exists. + session_cfg_file = os.path.join(os.path.expanduser("~"), + ".codechecker_passwords.json") + if not os.path.exists(session_cfg_file): + LOG.info("CodeChecker authentication client's example " + "configuration file created at " + session_cfg_file) + shutil.copyfile(os.path.join(os.environ['CC_PACKAGE_ROOT'], + "config", "session_client.json"), + session_cfg_file) + os.chmod(session_cfg_file, stat.S_IRUSR | stat.S_IWUSR) + + LOG.debug(session_cfg_file) + with open(session_cfg_file, 'r') as scfg: + scfg_dict = json.loads(scfg.read()) + + if not scfg_dict["credentials"]: + scfg_dict["credentials"] = {} + + self.__save = scfg_dict + self.__autologin = scfg_dict.get("client_autologin") \ + if "client_autologin" in scfg_dict else True + + # Check and load token storage for user + self.token_file = os.path.join(tempfile.gettempdir(), ".codechecker_" + + getpass.getuser() + ".session.json") + LOG.debug(self.token_file) + if os.path.exists(self.token_file): + with open(self.token_file, 'r') as f: + input = json.loads(f.read()) + self.__tokens = input.get("tokens") + + mode = os.stat(self.token_file)[stat.ST_MODE] + if mode & stat.S_IRGRP \ + or mode & stat.S_IWGRP \ + or mode & stat.S_IROTH \ + or mode & stat.S_IWOTH: + LOG.warning("Credential file at '" + session_cfg_file + "' is " + "readable by users other than you! This poses a " + "risk of others getting your passwords!\n" + "Please `chmod 0600 " + session_cfg_file + "`") + else: + with open(self.token_file, 'w') as f: + json.dump({'tokens': {}}, f) + os.chmod(self.token_file, stat.S_IRUSR | stat.S_IWUSR) + + self.__tokens = {} + + def is_autologin_enabled(self): + return self.__autologin + + def getToken(self, host, port): + return self.__tokens.get(host + ":" + port) + + def getAuthString(self, host, port): + ret = self.__save["credentials"].get(host + ":" + port) + if not ret: + ret = self.__save["credentials"].get(host) + if not ret: + ret = self.__save["credentials"].get("*:" + port) + if not ret: + ret = self.__save["credentials"].get("*") + + return ret + + def saveToken(self, host, port, token, destroy=False): + if destroy: + del self.__tokens[host + ":" + port] + else: + self.__tokens[host + ":" + port] = token + + with open(self.token_file, 'w') as scfg: + portalocker.lock(scfg, portalocker.LOCK_EX) + json.dump({'tokens': self.__tokens}, scfg, + indent=2, sort_keys=True) + portalocker.unlock(scfg) diff --git a/config/config.md b/config/config.md index f7361e5d2c..7e88885660 100644 --- a/config/config.md +++ b/config/config.md @@ -8,14 +8,22 @@ checker name and a severity level. Severity levels can be found in the shared.th ### Package configuration * environment variables section Contains enviroment variable names set and used during the static analysis - * package variables section + * package variables section Default database username which will be used to initialize postgres database. * checker config section - + checkers + + checkers This section contains the default checkers set used for analysis. The order of the checkers will be kept. (To enable set to true, to disable set to false) +### Session configuration + * authentication section + Contains configuration for a **server** on how to handle authentication + * credentials section + Contains the **client** user's preconfigured authentication tokens. + * tokens section + Contains session tokens the **client** user has received through authentication. This section is **not** meant to be configured by hand. + ### gdb script Contains an automated gdb script which can be used for debug. In debug mode the failed build commands will be rerun with gdb. diff --git a/config/session_client.json b/config/session_client.json new file mode 100644 index 0000000000..8e920aeaa2 --- /dev/null +++ b/config/session_client.json @@ -0,0 +1,10 @@ +{ + "client_autologin" : true, + "credentials": { + "*" : "global:admin", + "localhost:14444" : "test:test" + }, + "tokens": { + "localhost:14444" : "dummy_token" + } +} \ No newline at end of file diff --git a/config/session_config.json b/config/session_config.json new file mode 100644 index 0000000000..3f65e368ce --- /dev/null +++ b/config/session_config.json @@ -0,0 +1,36 @@ +{ + "authentication": { + "enabled" : false, + "realm_name" : "CodeChecker Privileged server", + "realm_error" : "Access requires valid credentials.", + "soft_expire" : 60, + "session_lifetime" : 300, + "logins_until_cleanup" : 30, + "method_dictionary": { + "enabled" : false, + "auths" : [ + "global:admin", "test:test" + ] + }, + "method_ldap": { + "enabled" : false, + "authorities": [ + { + "connection_url": "ldap://ldap.example.org", + "queries": [ + "uid=$USN$,ou=admins,o=mycompany" + ] + } + ] + }, + "method_pam": { + "enabled" : false, + "users": [ + "root", "myname" + ], + "groups": [ + "adm", "cc-users" + ] + } + } +} diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000000..d713da680f --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,200 @@ +CodeChecker authentication subsytem +=================================== + +# Please be advised, that currently, login credentials travel on an unencrypted channel! + +CodeChecker also supports only allowing a privileged set of users to access the results stored on a server. + +> **NOTICE!** Some authentication subsystems require additional packages to be installed before they can be used. See below. + +## Serverside configuration + +The server's configuration is stored in the server's *workspace* folder, in `session_config.json`. +This file is created, at the first start of the server, using the package's installed `config/session_config.json` as a template. + +The `authentication` section of the config file controls how authentication is handled. + + * `enabled` + setting this to `false` disables privileged access + * `realm_name` + The name to show for web-browser viewers' pop-up login window via *HTTP Authenticate* + * `realm_error` + The error message shown in the browser when the user fails to authenticate + * `logins_until_cleanup` + After this many login attempts made towards the server, it will perform an automatic cleanup of old, expired sessions. + * `soft_expire` + (in seconds) When a user is authenticated, a session is created for them and this session identifies the user's access. + This configuration variable sets how long the session considered "valid" before the user is needed + to reauthenticate again — if this time expires, the session will be *hibernated*: the next access will be denied, + but if the user presents a valid login, they will get their session reused. + * `session_lifetime` + (in seconds) The lifetime of the session sets that after this many seconds since last session access the session is permanently invalidated. + +Every authentication method is its own JSON object in this section. Every authentication method has its +own `enabled` key which dictates whether it is used at live authentication or not. + +Users are authenticated if **any** authentication method successfully authenticates them. +Authentications are attempted in the order they are described here: *dicitonary* takes precedence, +*pam* is a secondary and *ldap* is a tertiary backend, if enabled. + +### *Dictionary* authentication + +The `authentication.method_dictionary` contains a plaintext `username:password` credentials for authentication. +If the user's login matches any of the credentials listed, the user will be authenticated. + +```json +"method_dictionary": { + "enabled" : true, + "auths" : [ + "global:admin", + "test:test" + ] +} +``` + +### External authentication methods + +External authentication methods connect to a privilege manager to authenticate users against. + +Using external authentication methods - such as *PAM* or *LDAP* - require additional packages and libraries to be installed on the system. + +~~~~~~{.sh} +# get additional system libraries +sudo apt-get install libldap2-dev libsasl2-dev libssl-dev + +# the python virtual environment must be sourced! +source ~/checker_env/bin/activate + +# install required python modules +pip install -r .ci/auth_requirements +~~~~~~ + +#### *PAM* authentication + +To access the server via PAM authentication, the user must provide valid username and password which is accepted by PAM. + +```json +"method_pam": { + "enabled" : true +} +``` + +The module can be configured to allow specific users or users belonging to specific groups only. +In the example below, `root` and `myname` can access the server, and **everyone** who belongs to the `adm` or `cc-users` group can access the server. + +```json +"method_pam": { + "enabled" : true, + "users": [ + "root", "myname" + ], + "groups": [ + "adm", "cc-users" + ] +} +``` + +#### *LDAP* authentication + +CodeChecker also supports *LDAP*-based authentication. The `authentication.method_ldap` section contains the configuration for LDAP authentication: +the server can be configured to connect to as much LDAP-servers as the administrator wants. Each LDAP server is identified by a `connection_url` and a list of `queries` +to attempt to log in the username given. + +Servers are connected to and queries are executed in the order they appear in the configuration file. +Because of this, it is not advised to list too many servers as it can elongenate the authentication process. + +The special `$USN$` token in the query is replaced to the *username* at login. + +```json +"method_ldap": { + "enabled" : true, + "authorities": [ + { + "connection_url": "ldap://ldap.example.org", + "queries": [ + "uid=$USN$,ou=admins,o=mycompany" + ] + }, + { + "connection_url" : "ldaps://secure.internal.example.org:636", + "queries": [ + "uid=$USN$,ou=owners,ou=secure,o=company" + ] + } + ] +} +``` + +---- + +## Clientside configuration + +### Web-browser client + +Authentication in the web browser is handled via standard *HTTP Authenticate* headers, the browser will prompt the user to supply their crendentials. + +For browser authentication to work, cookies must be enabled! + +### Command-line client + +The `CodeChecker cmd` client needs to be authenticated for a server before any data communication could take place. + +The client's configuration file is expected to be at `~/.codechecker_passwords.json`, which is created at the first command executed +by using the package's `config/session_client.json` as an example. + +> Please make sure, as a security precaution, that **only you** are allowed to access this file. +> Executing `chmod 0600 ~/.codechecker_passwords.json` will limit access to your user only. + +~~~~~~~~~~~~~~~~~~~~~ +usage: CodeChecker cmd login [-h] [--host HOST] -p PORT [-u USERNAME] + [-pw PASSWORD] [-d] + +optional arguments: + -h, --help show this help message and exit + --host HOST Server host. + -p PORT, --port PORT HTTP Server port. + -u USERNAME, --username USERNAME + Username to use on authentication + -pw PASSWORD, --password PASSWORD + Password for username-password authentication + (optional) + -d, --deactivate, --logout + Send a logout request for the server +~~~~~~~~~~~~~~~~~~~~~ + +The user can log in onto the server by issuing the command `CodeChecker cmd login -h host -p port -u username -pw passphrase`. +After receiving an *Authentication successful!* message, access to the analysis information is given; otherwise, *Invalid access* is shown instead of real data. + +Privileged session expire after a set amount of time. To log out manually, issue the command `CodeChecker cmd login -h host -p port --logout`. + +#### Preconfigured credentials + +To alleviate the need for supplying authentication in the command-line every time a server is connected to, users can pre-configure their credentials to be used in authentication. + +To do so, open `~/.codechecker_passwords.json`. The `credentials` section is used by the client to read pre-saved authentication data in `username:password` format. + +```json + "credentials": { + "*" : "global:passphrase", + "*:8080" : "webserver:1234", + "localhost" : "local:admin", + "localhost:6251" : "super:secret" + }, +``` + +Credentials are matched for any particular server at login in the following order: + + 1. An exact `host:port` match is tried + 2. Matching for the `host` (on any port) is tried + 3. Matching for a particular port (on any host address), in the form of `*:port`, is tried + 4. Global credentials for the installation is stored with the `*` key + +#### Automatic login + +If authentication is required by the server and the user hasn't logged in but there are saved credentials for the server, `CodeChecker cmd` will automatically try to log in. + +This behaviour can be disabled by setting `client_autologin` to `false`. + +#### Currently active tokens + +The user's currently active sessions' token are stored in the `/tmp/.codechecker_USERNAME.session.json` (where `/tmp` is the environment's *temp* folder) file. diff --git a/run_tests.sh b/run_tests.sh index f8e270be6a..2b59d9167d 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -which nosetests || (echo "[ERROR] nosetests framework is needen to run the tests" && exit 1) +which nosetests || (echo "[ERROR] nosetests framework is needed to run the tests" && exit 1) which clang || (echo "[ERROR] clang required to run functional tests" && exit 1) # setup environment variables, temporary directories diff --git a/tests/functional/package_test/__init__.py b/tests/functional/package_test/__init__.py index 7d7ae967af..91db4c370d 100644 --- a/tests/functional/package_test/__init__.py +++ b/tests/functional/package_test/__init__.py @@ -1,314 +1,357 @@ -# coding=utf-8 -# ----------------------------------------------------------------------------- -# The CodeChecker Infrastructure -# This file is distributed under the University of Illinois Open Source -# License. See LICENSE.TXT for details. -# ----------------------------------------------------------------------------- - -"""Setup for the package tests.""" -import json -import multiprocessing -import os -import shlex -import shutil -import subprocess -import sys -import time -import uuid -from subprocess import CalledProcessError - -from test_utils import get_free_port - -# sys.path modification needed so nosetests can load the test_utils package. -sys.path.append(os.path.abspath(os.environ['TEST_TESTS_DIR'])) - -# Because of the nature of the python-env loading of nosetests, we need to -# add the codechecker_gen package to the pythonpath here, so it is available -# for the actual test cases. -__PKG_ROOT = os.path.abspath(os.environ['TEST_CODECHECKER_DIR']) -__LAYOUT_FILE_PATH = os.path.join(__PKG_ROOT, 'config', 'package_layout.json') -with open(__LAYOUT_FILE_PATH) as layout_file: - __PACKAGE_LAYOUT = json.load(layout_file) -sys.path.append(os.path.join( - __PKG_ROOT, __PACKAGE_LAYOUT['static']['codechecker_gen'])) - -# Stopping event for CodeChecker server. -__STOP_SERVER = multiprocessing.Event() - - -def _wait_for_postgres_shutdown(workspace): - """ - Wait for PostgreSQL to shut down. - Check if postmaster.pid file exists if yes postgres is still running. - """ - max_wait_time = 60 - - postmaster_pid_file = os.path.join(workspace, - 'pgsql_data', - 'postmaster.pid') - - while os.path.isfile(postmaster_pid_file): - time.sleep(1) - max_wait_time -= 1 - if max_wait_time == 0: - break - - -def setup_package(): - """Setup the environment for the tests. Check the test project twice, - then start the server.""" - pkg_root = os.path.abspath(os.environ['TEST_CODECHECKER_DIR']) - - env = os.environ.copy() - env['PATH'] = os.path.join(pkg_root, 'bin') + ':' + env['PATH'] - - tmp_dir = os.path.abspath(os.environ['TEST_CODECHECKER_PACKAGE_DIR']) - workspace = os.path.join(tmp_dir, 'workspace') - if os.path.exists(workspace): - print("Removing previous workspace") - shutil.rmtree(workspace) - os.makedirs(workspace) - - test_project_path = os.path.join( - os.path.abspath(os.environ['TEST_TESTS_DIR']), - 'test_projects', - 'test_files') - - clang_version = os.environ.get('TEST_CLANG_VERSION', 'stable') - - use_postgresql = os.environ.get('TEST_USE_POSTGRESQL', '') == 'true' - - pg_db_config = {} - if use_postgresql: - pg_db_config['dbaddress'] = 'localhost' - pg_db_config['dbname'] = 'testDb' - pg_db_config['dbport'] = os.environ.get('TEST_DBPORT', get_free_port()) - if os.environ.get('TEST_DBUSERNAME', False): - pg_db_config['dbusername'] = os.environ['TEST_DBUSERNAME'] - - project_info = \ - json.load(open(os.path.realpath(env['TEST_TEST_PROJECT_CONFIG']))) - - test_config = { - 'CC_TEST_SERVER_PORT': get_free_port(), - 'CC_TEST_SERVER_HOST': 'localhost', - 'CC_TEST_VIEWER_PORT': get_free_port(), - 'CC_TEST_VIEWER_HOST': 'localhost' - } - - test_project_clean_cmd = project_info['clean_cmd'] - test_project_build_cmd = project_info['build_cmd'] - - # setup env vars for test cases - os.environ['CC_TEST_VIEWER_PORT'] = str(test_config['CC_TEST_VIEWER_PORT']) - os.environ['CC_TEST_SERVER_PORT'] = str(test_config['CC_TEST_SERVER_PORT']) - os.environ['CC_TEST_PROJECT_INFO'] = \ - json.dumps(project_info['clang_' + clang_version]) - # ------------------------------------------------------------------------- - - # generate suppress file - suppress_file = os.path.join(tmp_dir, 'suppress_file') - if os.path.isfile(suppress_file): - os.remove(suppress_file) - _generate_suppress_file(suppress_file) - - skip_list_file = os.path.join(test_project_path, 'skip_list') - - shared_test_params = { - 'suppress_file': suppress_file, - 'env': env, - 'use_postgresql': use_postgresql, - 'workspace': workspace, - 'pg_db_config': pg_db_config - } - - # First check. - print("Running first analysis") - - ret = _clean_project(test_project_path, - test_project_clean_cmd, - env) - if ret: - sys.exit(ret) - - test_project_1_name = project_info['name'] + '_' + uuid.uuid4().hex - - ret = _run_check(shared_test_params, - skip_list_file, - test_project_build_cmd, - test_project_1_name, - test_project_path) - _wait_for_postgres_shutdown(shared_test_params['workspace']) - if ret: - sys.exit(1) - - # Second check. - print("Running second analysis") - - ret = _clean_project(test_project_path, - test_project_clean_cmd, - env) - if ret: - sys.exit(ret) - - test_project_2_name = project_info['name'] + '_' + uuid.uuid4().hex - - ret = _run_check(shared_test_params, - skip_list_file, - test_project_build_cmd, - test_project_2_name, - test_project_path) - _wait_for_postgres_shutdown(shared_test_params['workspace']) - if ret: - sys.exit(1) - - # Start the CodeChecker server. - print("Starting server to get results") - _start_server(shared_test_params, test_config) - - -def teardown_package(): - """Stop the CodeChecker server.""" - __STOP_SERVER.set() - - time.sleep(10) - - -def _pg_db_config_to_cmdline_params(pg_db_config): - """Format postgres config dict to CodeChecker cmdline parameters.""" - params = [] - - for key, value in pg_db_config.items(): - params.append('--' + key) - params.append(str(value)) - - return params - - -def _clean_project(test_project_path, clean_cmd, env): - """Clean the test project.""" - command = ['bash', '-c', clean_cmd] - - try: - print(command) - subprocess.check_call(command, - cwd=test_project_path, - env=env) - return 0 - except subprocess.CalledProcessError as cerr: - print("Failed to call:\n" + ' '.join(cerr.cmd)) - return cerr.returncode - - -def _generate_suppress_file(suppress_file): - """ - Create a dummy suppress file just to check if the old and the new - suppress format can be processed. - """ - print("Generating suppress file: " + suppress_file) - - import calendar - import hashlib - import random - - hash_version = '1' - suppress_stuff = [] - for _ in range(10): - curr_time = calendar.timegm(time.gmtime()) - random_integer = random.randint(1, 9999999) - suppress_line = str(curr_time) + str(random_integer) - suppress_stuff.append( - hashlib.md5(suppress_line).hexdigest() + '#' + hash_version) - - s_file = open(suppress_file, 'w') - for k in suppress_stuff: - s_file.write(k + '||' + 'idziei éléáálk ~!@#$#%^&*() \n') - s_file.write( - k + '||' + 'test_~!@#$%^&*.cpp' + - '||' + 'idziei éléáálk ~!@#$%^&*(\n') - s_file.write( - hashlib.md5(suppress_line).hexdigest() + '||' + - 'test_~!@#$%^&*.cpp' + '||' + 'idziei éléáálk ~!@#$%^&*(\n') - - s_file.close() - - -def _generate_skip_list_file(skip_list_file): - """ - Create a dummy skip list file just to check if it can be loaded. - Skip files without any results from checking. - """ - skip_list_content = ["-*randtable.c", "-*blocksort.c", "-*huffman.c", - "-*decompress.c", "-*crctable.c"] - - s_file = open(skip_list_file, 'w') - for k in skip_list_content: - s_file.write(k + '\n') - - s_file.close() - - -def _run_check(shared_test_params, skip_list_file, test_project_build_cmd, - test_project_name, test_project_path): - """Check a test project.""" - check_cmd = ['CodeChecker', 'check', - '-w', shared_test_params['workspace'], - '--suppress', shared_test_params['suppress_file'], - '--skip', skip_list_file, - '-n', test_project_name, - '-b', "'" + test_project_build_cmd + "'", - '--analyzers', 'clangsa', - '--quiet-build'] - if shared_test_params['use_postgresql']: - check_cmd.append('--postgresql') - check_cmd += _pg_db_config_to_cmdline_params( - shared_test_params['pg_db_config']) - - try: - print(' '.join(check_cmd)) - subprocess.check_call( - shlex.split(' '.join(check_cmd)), - cwd=test_project_path, - env=shared_test_params['env']) - - print("Analyzing test project done.") - return 0 - - except CalledProcessError as cerr: - print("Failed to call:\n" + ' '.join(cerr.cmd)) - return cerr.returncode - - -def _start_server(shared_test_params, test_config): - """Start the CodeChecker server.""" - def start_server_proc(event, server_cmd, checking_env): - """Target function for starting the CodeChecker server.""" - proc = subprocess.Popen(server_cmd, env=checking_env) - - # Blocking termination until event is set. - event.wait() - - # If proc is still running, stop it. - if proc.poll() is None: - proc.terminate() - - server_cmd = ['CodeChecker', 'server', - '--check-port', str(test_config['CC_TEST_SERVER_PORT']), - '--view-port', str(test_config['CC_TEST_VIEWER_PORT']), - '-w', shared_test_params['workspace'], - '--suppress', shared_test_params['suppress_file']] - if shared_test_params['use_postgresql']: - server_cmd.append('--postgresql') - server_cmd += _pg_db_config_to_cmdline_params( - shared_test_params['pg_db_config']) - - print(' '.join(server_cmd)) - server_proc = multiprocessing.Process( - name='server', - target=start_server_proc, - args=(__STOP_SERVER, server_cmd, shared_test_params['env'])) - - server_proc.start() - - # Wait for server to start and connect to database. - time.sleep(10) +# coding=utf-8 +# ----------------------------------------------------------------------------- +# The CodeChecker Infrastructure +# This file is distributed under the University of Illinois Open Source +# License. See LICENSE.TXT for details. +# ----------------------------------------------------------------------------- + +"""Setup for the package tests.""" + +import json +import multiprocessing +import os +import shlex +import shutil +import subprocess +import sys +import time +import uuid +from subprocess import CalledProcessError + +from test_utils import get_free_port + +# sys.path modification needed so nosetests can load the test_utils package. +sys.path.append(os.path.abspath(os.environ['TEST_TESTS_DIR'])) + +# Because of the nature of the python-env loading of nosetests, we need to +# add the codechecker_gen package to the pythonpath here, so it is available +# for the actual test cases. +__PKG_ROOT = os.path.abspath(os.environ['TEST_CODECHECKER_DIR']) +__LAYOUT_FILE_PATH = os.path.join(__PKG_ROOT, 'config', 'package_layout.json') +with open(__LAYOUT_FILE_PATH) as layout_file: + __PACKAGE_LAYOUT = json.load(layout_file) +sys.path.append(os.path.join( + __PKG_ROOT, __PACKAGE_LAYOUT['static']['codechecker_gen'])) + +# Stopping event for CodeChecker server. +__STOP_SERVER = multiprocessing.Event() + + +def _wait_for_postgres_shutdown(workspace): + """ + Wait for PostgreSQL to shut down. + Check if postmaster.pid file exists if yes postgres is still running. + """ + max_wait_time = 60 + + postmaster_pid_file = os.path.join(workspace, + 'pgsql_data', + 'postmaster.pid') + + while os.path.isfile(postmaster_pid_file): + time.sleep(1) + max_wait_time -= 1 + if max_wait_time == 0: + break + + +def setup_package(): + """Setup the environment for the tests. Check the test project twice, + then start the server.""" + pkg_root = os.path.abspath(os.environ['TEST_CODECHECKER_DIR']) + + env = os.environ.copy() + env['PATH'] = os.path.join(pkg_root, 'bin') + ':' + env['PATH'] + + tmp_dir = os.path.abspath(os.environ['TEST_CODECHECKER_PACKAGE_DIR']) + workspace = os.path.join(tmp_dir, 'workspace') + if os.path.exists(workspace): + print("Removing previous workspace") + shutil.rmtree(workspace) + os.makedirs(workspace) + + test_project_path = os.path.join( + os.path.abspath(os.environ['TEST_TESTS_DIR']), + 'test_projects', + 'test_files') + + clang_version = os.environ.get('TEST_CLANG_VERSION', 'stable') + + use_postgresql = os.environ.get('TEST_USE_POSTGRESQL', '') == 'true' + + pg_db_config = {} + if use_postgresql: + pg_db_config['dbaddress'] = 'localhost' + pg_db_config['dbname'] = 'testDb' + pg_db_config['dbport'] = os.environ.get('TEST_DBPORT', get_free_port()) + if os.environ.get('TEST_DBUSERNAME', False): + pg_db_config['dbusername'] = os.environ['TEST_DBUSERNAME'] + + project_info = \ + json.load(open(os.path.realpath(env['TEST_TEST_PROJECT_CONFIG']))) + + test_config = { + 'CC_TEST_SERVER_PORT': get_free_port(), + 'CC_TEST_SERVER_HOST': 'localhost', + 'CC_TEST_VIEWER_PORT': get_free_port(), + 'CC_TEST_VIEWER_HOST': 'localhost', + 'CC_AUTH_SERVER_PORT': get_free_port(), + 'CC_AUTH_SERVER_HOST': 'localhost', + 'CC_AUTH_VIEWER_PORT': get_free_port(), + 'CC_AUTH_VIEWER_HOST': 'localhost', + } + + test_project_clean_cmd = project_info['clean_cmd'] + test_project_build_cmd = project_info['build_cmd'] + + # setup env vars for test cases + os.environ['CC_TEST_VIEWER_PORT'] = str(test_config['CC_TEST_VIEWER_PORT']) + os.environ['CC_TEST_SERVER_PORT'] = str(test_config['CC_TEST_SERVER_PORT']) + os.environ['CC_AUTH_SERVER_PORT'] = str(test_config['CC_AUTH_SERVER_PORT']) + os.environ['CC_AUTH_VIEWER_PORT'] = str(test_config['CC_AUTH_VIEWER_PORT']) + os.environ['CC_TEST_PROJECT_INFO'] = \ + json.dumps(project_info['clang_' + clang_version]) + # ------------------------------------------------------------------------- + + # generate suppress file + suppress_file = os.path.join(tmp_dir, 'suppress_file') + if os.path.isfile(suppress_file): + os.remove(suppress_file) + _generate_suppress_file(suppress_file) + + skip_list_file = os.path.join(test_project_path, 'skip_list') + + shared_test_params = { + 'suppress_file': suppress_file, + 'env': env, + 'use_postgresql': use_postgresql, + 'workspace': workspace, + 'pg_db_config': pg_db_config + } + + # First check. + print("Running first analysis") + + ret = _clean_project(test_project_path, + test_project_clean_cmd, + env) + if ret: + sys.exit(ret) + + test_project_1_name = project_info['name'] + '_' + uuid.uuid4().hex + + ret = _run_check(shared_test_params, + skip_list_file, + test_project_build_cmd, + test_project_1_name, + test_project_path) + _wait_for_postgres_shutdown(shared_test_params['workspace']) + if ret: + sys.exit(1) + + # Second check. + print("Running second analysis") + + ret = _clean_project(test_project_path, + test_project_clean_cmd, + env) + if ret: + sys.exit(ret) + + test_project_2_name = project_info['name'] + '_' + uuid.uuid4().hex + + ret = _run_check(shared_test_params, + skip_list_file, + test_project_build_cmd, + test_project_2_name, + test_project_path) + _wait_for_postgres_shutdown(shared_test_params['workspace']) + if ret: + sys.exit(1) + + # Start the CodeChecker server. + print("Starting server to get results") + _start_server(shared_test_params, test_config, False) + + # + # Create a dummy authentication-enabled configuration and an auth-enabled server. + # + # Running the tests only work if the initial value (in package + # session_config.json) is FALSE for authentication.enabled. + os.remove(os.path.join(shared_test_params['workspace'], "session_config.json")) + session_cfg_file = os.path.join(pkg_root, "config", "session_config.json") + with open(session_cfg_file, 'r+') as scfg: + __scfg_original = scfg.read() + scfg.seek(0) + scfg_dict = json.loads(__scfg_original) + + scfg_dict["authentication"]["enabled"] = True + scfg_dict["authentication"]["method_dictionary"]["enabled"] = True + scfg_dict["authentication"]["method_dictionary"]["auths"] = ["cc:test"] + + json.dump(scfg_dict, scfg, indent=2, sort_keys=True) + scfg.truncate() + + print("Starting server to test authentication") + _start_server(shared_test_params, test_config, True) + + # Need to save the original configuration back so multiple tests can work after each other + os.remove(os.path.join(shared_test_params['workspace'], "session_config.json")) + with open(session_cfg_file, 'w') as scfg: + scfg.writelines(__scfg_original) + + +def teardown_package(): + """Stop the CodeChecker server.""" + __STOP_SERVER.set() + + time.sleep(10) + + +def _pg_db_config_to_cmdline_params(pg_db_config): + """Format postgres config dict to CodeChecker cmdline parameters.""" + params = [] + + for key, value in pg_db_config.items(): + params.append('--' + key) + params.append(str(value)) + + return params + + +def _clean_project(test_project_path, clean_cmd, env): + """Clean the test project.""" + command = ['bash', '-c', clean_cmd] + + try: + print(command) + subprocess.check_call(command, + cwd=test_project_path, + env=env) + return 0 + except subprocess.CalledProcessError as cerr: + print("Failed to call:\n" + ' '.join(cerr.cmd)) + return cerr.returncode + + +def _generate_suppress_file(suppress_file): + """ + Create a dummy suppress file just to check if the old and the new + suppress format can be processed. + """ + print("Generating suppress file: " + suppress_file) + + import calendar + import hashlib + import random + + hash_version = '1' + suppress_stuff = [] + for _ in range(10): + curr_time = calendar.timegm(time.gmtime()) + random_integer = random.randint(1, 9999999) + suppress_line = str(curr_time) + str(random_integer) + suppress_stuff.append( + hashlib.md5(suppress_line).hexdigest() + '#' + hash_version) + + s_file = open(suppress_file, 'w') + for k in suppress_stuff: + s_file.write(k + '||' + 'idziei éléáálk ~!@#$#%^&*() \n') + s_file.write( + k + '||' + 'test_~!@#$%^&*.cpp' + + '||' + 'idziei éléáálk ~!@#$%^&*(\n') + s_file.write( + hashlib.md5(suppress_line).hexdigest() + '||' + + 'test_~!@#$%^&*.cpp' + '||' + 'idziei éléáálk ~!@#$%^&*(\n') + + s_file.close() + + +def _generate_skip_list_file(skip_list_file): + """ + Create a dummy skip list file just to check if it can be loaded. + Skip files without any results from checking. + """ + skip_list_content = ["-*randtable.c", "-*blocksort.c", "-*huffman.c", + "-*decompress.c", "-*crctable.c"] + + s_file = open(skip_list_file, 'w') + for k in skip_list_content: + s_file.write(k + '\n') + + s_file.close() + + +def _run_check(shared_test_params, skip_list_file, test_project_build_cmd, + test_project_name, test_project_path): + """Check a test project.""" + check_cmd = ['CodeChecker', 'check', + '-w', shared_test_params['workspace'], + '--suppress', shared_test_params['suppress_file'], + '--skip', skip_list_file, + '-n', test_project_name, + '-b', "'" + test_project_build_cmd + "'", + '--analyzers', 'clangsa', + '--quiet-build'] + if shared_test_params['use_postgresql']: + check_cmd.append('--postgresql') + check_cmd += _pg_db_config_to_cmdline_params( + shared_test_params['pg_db_config']) + + try: + print(' '.join(check_cmd)) + subprocess.check_call( + shlex.split(' '.join(check_cmd)), + cwd=test_project_path, + env=shared_test_params['env']) + + print("Analyzing test project done.") + return 0 + + except CalledProcessError as cerr: + print("Failed to call:\n" + ' '.join(cerr.cmd)) + return cerr.returncode + + +def _start_server(shared_test_params, test_config, auth=False): + """Start the CodeChecker server.""" + def start_server_proc(event, server_cmd, checking_env): + """Target function for starting the CodeChecker server.""" + proc = subprocess.Popen(server_cmd, env=checking_env) + + # Blocking termination until event is set. + event.wait() + + # If proc is still running, stop it. + if proc.poll() is None: + proc.terminate() + + server_cmd = ['CodeChecker', 'server', + '-w', shared_test_params['workspace'], + '--suppress', shared_test_params['suppress_file']] + + if auth: + server_cmd.extend(['--check-port', + str(test_config['CC_AUTH_SERVER_PORT']), + '--view-port', + str(test_config['CC_AUTH_VIEWER_PORT'])]) + else: + server_cmd.extend(['--check-port', + str(test_config['CC_TEST_SERVER_PORT']), + '--view-port', + str(test_config['CC_TEST_VIEWER_PORT'])]) + if shared_test_params['use_postgresql']: + server_cmd.append('--postgresql') + server_cmd += _pg_db_config_to_cmdline_params( + shared_test_params['pg_db_config']) + + print(' '.join(server_cmd)) + server_proc = multiprocessing.Process( + name='server', + target=start_server_proc, + args=(__STOP_SERVER, server_cmd, shared_test_params['env'])) + + server_proc.start() + + # Wait for server to start and connect to database. + time.sleep(10) diff --git a/tests/functional/package_test/test_authentication.py b/tests/functional/package_test/test_authentication.py new file mode 100644 index 0000000000..2a48f81619 --- /dev/null +++ b/tests/functional/package_test/test_authentication.py @@ -0,0 +1,94 @@ +# +# ----------------------------------------------------------------------------- +# The CodeChecker Infrastructure +# This file is distributed under the University of Illinois Open Source +# License. See LICENSE.TXT for details. +# ----------------------------------------------------------------------------- + +import json +import logging +import os +import re +import unittest + +from thrift.protocol.TProtocol import TProtocolException + +from test_utils.thrift_client_to_db import CCViewerHelper +from test_utils.thrift_client_to_db import CCAuthHelper + +class RunResults(unittest.TestCase): + def setUp(self): + self.host = 'localhost' + self.port = int(os.environ['CC_AUTH_VIEWER_PORT']) + self.uri = '/Authentication' + + def test_initial_access(self): + """Tests that initially, a non-authenticating server is accessible, + but an authenticating one is not.""" + client_unprivileged = CCViewerHelper(self.host,int( + os.environ['CC_TEST_VIEWER_PORT']), '/', True, None) + client_privileged = CCViewerHelper(self.host, self.port, '/', True, None) + + self.assertIsNotNone(client_unprivileged.getAPIVersion(), + "Unprivileged client was not accessible.") + + try: + client_privileged.getAPIVersion() + success = False + except TProtocolException as tpe: + # The server reports a HTTP 401 error which is not a valid Thrift response + # But if it does so, it passes the test! + success = True + self.assertTrue(success, "Privileged client allowed access without session.") + + def test_privileged_access(self): + """Tests that initially, a non-authenticating server is accessible, + but an authenticating one is not.""" + auth_client = CCAuthHelper(self.host, self.port, self.uri, True, None) + + handshake = auth_client.getAuthParameters() + self.assertTrue(handshake.requiresAuthentication, "Privileged server " + + "did not report that it requires authentication.") + self.assertFalse(handshake.sessionStillActive, "Empty session was " + + "reported to be still active.") + + sessionToken = auth_client.performLogin("Username:Password", + "invalid:invalid") + self.assertIsNone(sessionToken, "Invalid credentials gave us a token!") + + self.sessionToken = auth_client.performLogin("Username:Password", + "cc:test") + self.assertIsNotNone(self.sessionToken, + "Valid credentials didn't give us a token!") + + handshake = auth_client.getAuthParameters() + self.assertTrue(handshake.requiresAuthentication, "Privileged server " + + "did not report that it requires authentication.") + self.assertFalse(handshake.sessionStillActive, "Valid session was " + + "reported not to be active.") + + client = CCViewerHelper(self.host, self.port, '/', True, self.sessionToken) + + self.assertIsNotNone(client.getAPIVersion(), + "Privileged server didn't respond properly.") + + auth_client = CCAuthHelper(self.host, self.port, self.uri, True, + self.sessionToken) + result = auth_client.destroySession() + + self.assertTrue(result, "Server did not allow us to destroy session.") + + try: + client.getAPIVersion() + success = False + except TProtocolException as tpe: + # The server reports a HTTP 401 error which is not a valid Thrift response + # But if it does so, it passes the test! + success = True + self.assertTrue(success, "Privileged client allowed access after logout.") + + handshake = auth_client.getAuthParameters() + self.assertFalse(handshake.sessionStillActive, "Destroyed session was " + + "reported to be still active.") + + diff --git a/tests/test_utils/thrift_client_to_db.py b/tests/test_utils/thrift_client_to_db.py index 86e36d56e4..ac5a6996a4 100644 --- a/tests/test_utils/thrift_client_to_db.py +++ b/tests/test_utils/thrift_client_to_db.py @@ -95,8 +95,10 @@ def __init__(self, host, port, auto_handle_connection=True): class CCViewerHelper(ThriftAPIHelper): - def __init__(self, host, port, uri, auto_handle_connection=True): + def __init__(self, host, port, uri, auto_handle_connection=True, + session_token=None): # import only if necessary; some tests may not add this to PYTHONPATH + from codechecker_lib import session_manager from codeCheckerDBAccess import codeCheckerDBAccess from codeCheckerDBAccess.constants import MAX_QUERY_SIZE @@ -104,6 +106,10 @@ def __init__(self, host, port, uri, auto_handle_connection=True): transport = THttpClient.THttpClient(host, port, uri) protocol = TJSONProtocol.TJSONProtocol(transport) client = codeCheckerDBAccess.Client(protocol) + if session_token: + headers = {'Cookie': session_manager.SESSION_COOKIE_NAME + + "=" + session_token} + transport.setCustomHeaders(headers) super(CCViewerHelper, self).__init__(transport, client, auto_handle_connection) @@ -136,3 +142,25 @@ def _getAll_emu(self, func_name, *args): some_results = func2call(*args) return results + +class CCAuthHelper(ThriftAPIHelper): + + def __init__(self, host, port, uri, auto_handle_connection=True, + session_token=None): + # import only if necessary; some tests may not add this to PYTHONPATH + from codechecker_lib import session_manager + from Authentication import codeCheckerAuthentication + from Authentication.ttypes import HandshakeInformation + + transport = THttpClient.THttpClient(host, port, uri) + protocol = TJSONProtocol.TJSONProtocol(transport) + client = codeCheckerAuthentication.Client(protocol) + if session_token: + headers = {'Cookie': session_manager.SESSION_COOKIE_NAME + + "=" + session_token} + transport.setCustomHeaders(headers) + super(CCAuthHelper, self).__init__(transport, + client, auto_handle_connection) + + def __getattr__(self, attr): + return partial(self._thrift_client_call, attr) diff --git a/thrift_api/authentication.thrift b/thrift_api/authentication.thrift new file mode 100644 index 0000000000..03a2f53044 --- /dev/null +++ b/thrift_api/authentication.thrift @@ -0,0 +1,31 @@ +// ------------------------------------------------------------------------- +// The CodeChecker Infrastructure +// This file is distributed under the University of Illinois Open Source +// License. See LICENSE.TXT for details. +// ------------------------------------------------------------------------- + +include "shared.thrift" + +namespace py Authentication + +struct HandshakeInformation { + 1: bool requiresAuthentication, // true if the server has a privileged zone --- the state of having a valid access is not considered here + 2: bool sessionStillActive, // whether the session in which the HandshakeInformation is returned is a valid one +} + +service codeCheckerAuthentication { + // get basic authentication information from the server + HandshakeInformation getAuthParameters(), + + // retrieves a list of accepted authentication methods from the server + list getAcceptedAuthMethods(), + + // handles creating a session token for the user + string performLogin(1: string auth_method, + 2: string auth_string) + throws (1: shared.RequestFailed requestError), + + // performs logout action for the user (must be called from the corresponding valid session) + bool destroySession() + throws (1: shared.RequestFailed requestError) +} diff --git a/thrift_api/shared.thrift b/thrift_api/shared.thrift index 2c736d69be..9aa6458229 100644 --- a/thrift_api/shared.thrift +++ b/thrift_api/shared.thrift @@ -49,7 +49,8 @@ enum Severity{ enum ErrorCode{ DATABASE, IOERROR, - GENERAL + GENERAL, + PRIVILEGE } //----------------------------------------------------------------------------- diff --git a/thrift_api/thrift_api.md b/thrift_api/thrift_api.md index e5855b576a..41631dad3c 100644 --- a/thrift_api/thrift_api.md +++ b/thrift_api/thrift_api.md @@ -9,3 +9,7 @@ See [report_viewer_server.thrift](https://raw.githubusercontent.com/Ericsson/cod ## Report storage server API The report storage server API is used internally in the package during runtime to store the results to the database. See [report_storage_server.thrift](https://raw.githubusercontent.com/Ericsson/codechecker/master/thrift_api/report_storage_server.thrift). + +## Authentication system API +The authentication layer is used for supporting privileged-access only access. +See [authentication.thrift](https://raw.githubusercontent.com/Ericsson/codechecker/master/thrift_api/authentication.thrift) \ No newline at end of file diff --git a/viewer_clients/cmdline_client/authentication_helper.py b/viewer_clients/cmdline_client/authentication_helper.py new file mode 100644 index 0000000000..9e0b366400 --- /dev/null +++ b/viewer_clients/cmdline_client/authentication_helper.py @@ -0,0 +1,100 @@ +# ------------------------------------------------------------------------- +# The CodeChecker Infrastructure +# This file is distributed under the University of Illinois Open Source +# License. See LICENSE.TXT for details. +# ------------------------------------------------------------------------- + +import os +import sys +# import datetime +import socket + +from thrift import Thrift +from thrift.Thrift import TException, TApplicationException +from thrift.transport import THttpClient +from thrift.protocol import TJSONProtocol +from thrift.protocol.TProtocol import TProtocolException + +from codechecker_lib import session_manager + +from Authentication import codeCheckerAuthentication +import shared + + +class ThriftAuthHelper(): + def __init__(self, host, port, uri, session_token=None): + self.__host = host + self.__port = port + self.transport = THttpClient.THttpClient(self.__host, self.__port, uri) + self.protocol = TJSONProtocol.TJSONProtocol(self.transport) + self.client = codeCheckerAuthentication.Client(self.protocol) + + if session_token: + headers = {'Cookie': session_manager.SESSION_COOKIE_NAME + + "=" + session_token} + self.transport.setCustomHeaders(headers) + + # ------------------------------------------------------------ + + def ThriftClientCall(function): + # print type(function) + funcName = function.__name__ + + def wrapper(self, *args, **kwargs): + # print('['+host+':'+str(port)+'] >>>>> ['+funcName+']') + # before = datetime.datetime.now() + self.transport.open() + func = getattr(self.client, funcName) + try: + res = func(*args, **kwargs) + + except shared.ttypes.RequestFailed as reqfailure: + if reqfailure.error_code == shared.ttypes.ErrorCode.DATABASE: + print('Database error on server') + print(str(reqfailure.message)) + if reqfailure.error_code == shared.ttypes.ErrorCode.PRIVILEGE: + raise reqfailure + else: + print('Other error') + print(str(reqfailure)) + + sys.exit(1) + except TProtocolException as ex: + print("Connection failed to {0}:{1}" + .format(self.__host, self.__port)) + sys.exit(1) + except socket.error as serr: + errCause = os.strerror(serr.errno) + print(errCause) + print(str(serr)) + sys.exit(1) + + # after = datetime.datetime.now() + # timediff = after - before + # diff = timediff.microseconds/1000 + # print('['+str(diff)+'ms] <<<<< ['+host+':'+str(port)+']') + # print res + self.transport.close() + return res + + return wrapper + + # ----------------------------------------------------------------------- + @ThriftClientCall + def getAuthParameters(self): + pass + + # ----------------------------------------------------------------------- + @ThriftClientCall + def getAcceptedAuthMethods(self): + pass + + # ----------------------------------------------------------------------- + @ThriftClientCall + def performLogin(self, auth_method, auth_string): + pass + + # ----------------------------------------------------------------------- + @ThriftClientCall + def destroySession(self): + pass diff --git a/viewer_clients/cmdline_client/cmd_line_client.py b/viewer_clients/cmdline_client/cmd_line_client.py index 75eba1d528..bb78e77b0f 100644 --- a/viewer_clients/cmdline_client/cmd_line_client.py +++ b/viewer_clients/cmdline_client/cmd_line_client.py @@ -12,6 +12,9 @@ import codeCheckerDBAccess import shared from . import thrift_helper +from . import authentication_helper + +from codechecker_lib import session_manager SUPPORTED_VERSION = '5.0' @@ -34,12 +37,112 @@ def default(self, obj): d.update(obj.__dict__) return d +def handle_auth_requests(args): + session = session_manager.SessionManager_Client() + auth_client = authentication_helper.ThriftAuthHelper(args.host, + args.port, + '/Authentication', + session.getToken( + args.host, + args.port)) + + handshake = auth_client.getAuthParameters() + if not handshake.requiresAuthentication: + print("This server does not require privileged access.") + return + + if args.logout: + if args.username or args.password: + print('ERROR! Do not supply username and password ' + 'with `--logout` command.') + sys.exit(1) + + logout_done = auth_client.destroySession() + if logout_done: + session.saveToken(args.host, args.port, None, True) + print('Successfully deauthenticated from server.') + + return + + methods = auth_client.getAcceptedAuthMethods() + # Attempt username-password auth first + if 'Username:Password' in str(methods): + if not args.username or not args.password: + # Try to use a previously saved credential from configuration file + savedAuth = session.getAuthString(args.host, args.port) + + if savedAuth: + print('Logging in using preconfigured credentials.') + args.username = savedAuth.split(":")[0] + args.password = savedAuth.split(":")[1] + else: + print('Can not authenticate with username' + 'and password if it is not specified...') + sys.exit(1) + + try: + session_token = auth_client.performLogin("Username:Password", + args.username + ":" + + args.password) + session.saveToken(args.host, args.port, session_token) + print("Server reported successful authentication!") + except shared.ttypes.RequestFailed as reqfail: + print(reqfail.message) + sys.exit(1) + + +def __check_authentication(client): + """Communicate with the authentication server + to handle authentication requests.""" + result = client.getAuthParameters() + + if result.sessionStillActive: + return True + else: + return False def setupClient(host, port, uri): - """ Setup the thrift client and check API version. """ + ''' setup the thrift client and check + API version and authentication needs''' + manager = session_manager.SessionManager_Client() + session_token = manager.getToken(host, port) + + # Before actually communicating with the server, + # we need to check authentication first + auth_client = authentication_helper.ThriftAuthHelper(host, + port, + uri + + 'Authentication', + session_token) + auth_response = auth_client.getAuthParameters() + if auth_response.requiresAuthentication and \ + not auth_response.sessionStillActive: + print_err = False + + if manager.is_autologin_enabled(): + auto_auth_string = manager.getAuthString(host, port) + if auto_auth_string: + # Try to automatically log in with a saved credential + # if it exists for the server + try: + session_token = auth_client.performLogin( + "Username:Password", + auto_auth_string) + manager.saveToken(host, port, session_token) + print("Authenticated using pre-configured credentials...") + except shared.ttypes.RequestFailed: + print_err = True + else: + print_err = True - client = thrift_helper.ThriftClientHelper(host, port, uri) - # Test if client can work with thrift API getVersion. + if print_err: + print('Access denied. This server requires authentication.') + print('Please log in onto the server ' + 'using `CodeChecker cmd login`') + sys.exit(1) + + client = thrift_helper.ThriftClientHelper(host, port, uri, session_token) + # test if client can work with thrift API getVersion if not check_API_version(client): print('Backward incompatible change was in the API.') print('Please update client. Server version is not supported') @@ -420,6 +523,19 @@ def register_client_command_line(argument_parser): required=True, help='Server port.') sum_parser.set_defaults(func=handle_remove_run_results) + # Handle authentication. + auth_parser = subparsers.add_parser('login', help='Log in onto a CodeChecker server') + auth_parser.add_argument('--host', type=str, dest="host", default='localhost', + help='Server host.') + auth_parser.add_argument('-p', '--port', type=str, dest="port", default=11444, + required=True, help='HTTP Server port.') + auth_parser.add_argument('-u', '--username', type=str, dest="username", + required=False, help='Username to use on authentication') + auth_parser.add_argument('-pw', '--password', type=str, dest="password", + required=False, help="Password for username-password authentication (optional)") + auth_parser.add_argument('-d', '--deactivate', '--logout', action='store_true', + dest='logout', help='Send a logout request for the server') + auth_parser.set_defaults(func=handle_auth_requests) def main(): parser = argparse.ArgumentParser( diff --git a/viewer_clients/cmdline_client/thrift_helper.py b/viewer_clients/cmdline_client/thrift_helper.py index 48420cf937..6834632da2 100644 --- a/viewer_clients/cmdline_client/thrift_helper.py +++ b/viewer_clients/cmdline_client/thrift_helper.py @@ -8,26 +8,38 @@ import socket import sys -import shared -from codeCheckerDBAccess import codeCheckerDBAccess +from thrift import Thrift +from thrift.Thrift import TException, TApplicationException +from thrift.transport import THttpClient from thrift.protocol import TJSONProtocol from thrift.protocol.TProtocol import TProtocolException -from thrift.transport import THttpClient +from codechecker_lib import session_manager + +from codeCheckerDBAccess import codeCheckerDBAccess +import shared -class ThriftClientHelper: - def __init__(self, host, port, uri): +class ThriftClientHelper(): + + def __init__(self, host, port, uri, session_token=None): self.__host = host self.__port = port self.transport = THttpClient.THttpClient(self.__host, self.__port, uri) self.protocol = TJSONProtocol.TJSONProtocol(self.transport) self.client = codeCheckerDBAccess.Client(self.protocol) - # ------------------------------------------------------------ + if session_token: + headers = {'Cookie': session_manager.SESSION_COOKIE_NAME + + "=" + session_token} + self.transport.setCustomHeaders(headers) + +# ------------------------------------------------------------ def ThriftClientCall(function): + #print type(function) funcName = function.__name__ - def wrapper(self, *args, **kwargs): + #print('['+host+':'+str(port)+'] >>>>> ['+funcName+']') + #before = datetime.datetime.now() self.transport.open() func = getattr(self.client, funcName) try: @@ -35,7 +47,10 @@ def wrapper(self, *args, **kwargs): except shared.ttypes.RequestFailed as reqfailure: if reqfailure.error_code == shared.ttypes.ErrorCode.DATABASE: - + print('Database error on server') + print(str(reqfailure.message)) + if reqfailure.error_code == shared.ttypes.ErrorCode.PRIVILEGE: + print('Unauthorized access') print(str(reqfailure.message)) else: print('Other error') diff --git a/viewer_server/client_auth_handler.py b/viewer_server/client_auth_handler.py new file mode 100644 index 0000000000..b1ee50dae1 --- /dev/null +++ b/viewer_server/client_auth_handler.py @@ -0,0 +1,113 @@ +# ------------------------------------------------------------------------- +# The CodeChecker Infrastructure +# This file is distributed under the University of Illinois Open Source +# License. See LICENSE.TXT for details. +# ------------------------------------------------------------------------- +''' +Handle thrift requests for authentication +''' +import zlib +import os +import datetime +from collections import defaultdict +import ntpath +import codecs + + +import shared +from Authentication import constants +from Authentication.ttypes import * + +from codechecker_lib import logger +from codechecker_lib import session_manager + +LOG = logger.get_new_logger('AUTH HANDLER') + + +# ----------------------------------------------------------------------- +def timefunc(function): + ''' + timer function + ''' + + func_name = function.__name__ + + def debug_wrapper(*args, **kwargs): + ''' + wrapper for debug log + ''' + before = datetime.now() + res = function(*args, **kwargs) + after = datetime.now() + timediff = after - before + diff = timediff.microseconds/1000 + LOG.debug('['+str(diff)+'ms] ' + func_name) + return res + + def release_wrapper(*args, **kwargs): + ''' + no logging + ''' + res = function(*args, **kwargs) + return res + + if logger.get_log_level() == logger.DEBUG: + return debug_wrapper + else: + return release_wrapper + + +def conv(text): + ''' + Convert * to % got from clients for the database queries + ''' + if text is None: + return '%' + return text.replace('*', '%') + + +class ThriftAuthHandler(): + ''' + Handle Thrift authentication requests + ''' + + def __init__(self, manager, client_host, session_token=None): + self.__manager = manager + self.__client_host = client_host + self.__session_token = session_token + + @timefunc + def getAuthParameters(self): + return HandshakeInformation(self.__manager.isEnabled(), + self.__manager.is_valid( + self.__client_host, + self.__session_token, + True)) + + @timefunc + def getAcceptedAuthMethods(self): + result = [] + result.append("Username:Password") + return result + + @timefunc + def performLogin(self, auth_method, auth_string): + if auth_method == "Username:Password": + authToken = self.__manager.create_or_get_session( + self.__client_host, + auth_string) + if authToken: + return authToken + else: + raise shared.ttypes.RequestFailed( + shared.ttypes.ErrorCode.PRIVILEGE, + "Invalid credentials supplied. Refusing authentication!") + + raise shared.ttypes.RequestFailed( + shared.ttypes.ErrorCode.PRIVILEGE, + "Could not negotiate via common authentication method") + + @timefunc + def destroySession(self): + return self.__manager.invalidate(self.__client_host, + self.__session_token) diff --git a/viewer_server/client_db_access_server.py b/viewer_server/client_db_access_server.py index b961f7f3be..2adb514942 100644 --- a/viewer_server/client_db_access_server.py +++ b/viewer_server/client_db_access_server.py @@ -5,8 +5,9 @@ # ------------------------------------------------------------------------- """ Main viewer server starts a http server which handles thrift client -and browser requests. +and browser requests """ +import base64 import errno import os import posixpath @@ -14,8 +15,8 @@ import urllib from multiprocessing.pool import ThreadPool -from sqlalchemy.orm import scoped_session from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import scoped_session try: from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler @@ -24,30 +25,40 @@ from http.server import HTTPServer, BaseHTTPRequestHandler, \ SimpleHTTPRequestHandler +from thrift import Thrift +from thrift.Thrift import TException from thrift.transport import TTransport from thrift.protocol import TJSONProtocol +import shared from codeCheckerDBAccess import codeCheckerDBAccess +from codeCheckerDBAccess import constants +from codeCheckerDBAccess.ttypes import * +from Authentication import codeCheckerAuthentication +from Authentication import constants +from Authentication.ttypes import * from client_db_access_handler import ThriftRequestHandler +from client_auth_handler import ThriftAuthHandler from codechecker_lib import logger from codechecker_lib import database_handler +from codechecker_lib import session_manager LOG = logger.get_new_logger('DB ACCESS') -class RequestHander(SimpleHTTPRequestHandler): +class RequestHandler(SimpleHTTPRequestHandler): """ Handle thrift and browser requests Simply modified and extended version of SimpleHTTPRequestHandler """ def __init__(self, request, client_address, server): - self.sc_session = server.sc_session self.db_version_info = server.db_version_info + self.manager = server.manager BaseHTTPRequestHandler.__init__(self, request, @@ -58,6 +69,80 @@ def log_message(self, msg_format, *args): """ Silenting http server. """ return + def check_auth_in_request(self): + """ + Wrapper to handle authentication needs from both GET and POST requests. + """ + + if not self.manager.isEnabled(): + return True + + success = False + + # Authentication can happen in two possible ways: + # + # The user either presents a valid session cookie -- in this case, checking if + # the session for the given cookie is valid + + client_host, client_port = self.client_address + + for k in self.headers.getheaders("Cookie"): + split = k.split("; ") + for cookie in split: + values = cookie.split("=") + if len(values) == 2 and \ + values[0] == session_manager.SESSION_COOKIE_NAME: + if self.manager.is_valid(client_host, values[1], True): + # The session cookie contains valid data. + success = values[1] + + if not success: + # Session cookie was invalid (or not found...) + # Attempt to see if the browser has sent us an authentication request + authHeader = self.headers.getheader("Authorization") + if authHeader is not None and authHeader.startswith("Basic "): + LOG.info("Client from " + client_host + ":" + + str(client_port) + " attempted authorization.") + authString = base64.decodestring( + self.headers.getheader("Authorization"). + replace("Basic ", "")) + + token = self.manager.create_or_get_session(client_host, + authString) + if token: + LOG.info("Client from " + client_host + ":" + + str(client_port) + " successfully logged in") + return token + + # Else, access is still not granted. + if not success: + LOG.debug(client_host + ":" + str(client_port) + + " Invalid access, credentials not found " + + "- session refused.") + return None + + return success + + def do_GET(self): + authToken = self.check_auth_in_request() + if authToken: + self.send_response(200) + if isinstance(authToken, str): + self.send_header("Set-Cookie", + session_manager.SESSION_COOKIE_NAME + "=" + + authToken + "; Path=/") + SimpleHTTPRequestHandler.do_GET(self) + else: + self.send_response(401) + self.send_header("WWW-Authenticate", 'Basic realm="' + + self.manager.getRealm()["realm"] + '"') + self.send_header("Content-type", "text/plain") + self.send_header("Content-length", str(len( + self.manager.getRealm()["error"]))) + self.send_header('Connection', 'close') + self.end_headers() + self.wfile.write(self.manager.getRealm()["error"]) + def do_POST(self): """ Handling thrift messages. """ client_host, client_port = self.client_address @@ -75,6 +160,7 @@ def do_POST(self): output_protocol_factory = protocol_factory itrans = TTransport.TFileObjectTransport(self.rfile) + otrans = TTransport.TFileObjectTransport(self.wfile) itrans = TTransport.TBufferedTransport(itrans, int(self.headers[ 'Content-Length'])) @@ -83,6 +169,23 @@ def do_POST(self): iprot = input_protocol_factory.getProtocol(itrans) oprot = output_protocol_factory.getProtocol(otrans) + sess_token = self.check_auth_in_request() + if self.path != '/Authentication' and not sess_token: + # Bail out if the user is not authenticated... + # This response has the possibility of melting down Thrift clients, + # but the user is expected to properly authenticate first. + + LOG.debug(client_host + ":" + str(client_port) + + " Invalid access, credentials not found " + + "- session refused.") + self.send_response(401) + self.send_header("Content-type", "text/plain") + self.send_header("Content-length", str(0)) + self.end_headers() + + return + + # Authentication is handled, we may now respond to the user. try: session = self.sc_session() acc_handler = ThriftRequestHandler(session, @@ -91,7 +194,21 @@ def do_POST(self): suppress_handler, self.db_version_info) - processor = codeCheckerDBAccess.Processor(acc_handler) + if self.path == '/Authentication': + # Authentication requests must be routed to a different handler. + auth_handler = ThriftAuthHandler(self.manager, + client_host, + sess_token) + processor = codeCheckerAuthentication.Processor(auth_handler) + else: + acc_handler = ThriftRequestHandler(session, + checker_md_docs, + checker_md_docs_map, + suppress_handler, + self.db_version_info) + + processor = codeCheckerDBAccess.Processor(acc_handler) + processor.process(iprot, oprot) result = otrans.getvalue() @@ -118,7 +235,6 @@ def translate_path(self, path): """ Modified version from SimpleHTTPRequestHandler. Path is set to www_root. - """ # Abandon query parameters. path = path.split('?', 1)[0] @@ -149,7 +265,8 @@ def __init__(self, db_conn_string, pckg_data, suppress_handler, - db_version_info): + db_version_info, + manager): LOG.debug('Initializing HTTP server') @@ -159,11 +276,13 @@ def __init__(self, self.checker_md_docs_map = pckg_data['checker_md_docs_map'] self.suppress_handler = suppress_handler self.db_version_info = db_version_info - self.__engine = database_handler.SQLServer.create_engine(db_conn_string) + self.__engine = database_handler.SQLServer.create_engine( + db_conn_string) Session = scoped_session(sessionmaker()) Session.configure(bind=self.__engine) self.sc_session = Session + self.manager = manager self.__request_handlers = ThreadPool(processes=10) @@ -209,11 +328,12 @@ def start_server(package_data, port, db_conn_string, suppress_handler, server_addr = (access_server_host, port) http_server = CCSimpleHttpServer(server_addr, - RequestHander, + RequestHandler, db_conn_string, package_data, suppress_handler, - db_version_info) + db_version_info, + session_manager.SessionManager()) LOG.info('Waiting for client requests on [' + access_server_host + ':' + str(port) + ']')