From 9a5224aebfb90de8e0625e2d2e17683f3d25407e Mon Sep 17 00:00:00 2001 From: Whisperity Date: Mon, 20 Nov 2017 11:45:31 +0100 Subject: [PATCH 1/2] Split session_manager into a server- and a clientside module --- .../libclient/authentication_helper.py | 8 +- libcodechecker/libclient/client.py | 22 +- .../libclient/credential_manager.py | 92 +++++++ libcodechecker/libclient/product_helper.py | 6 +- libcodechecker/libclient/thrift_helper.py | 5 +- libcodechecker/libhandlers/server.py | 6 +- libcodechecker/server/api/authentication.py | 2 +- libcodechecker/server/server.py | 21 +- .../{ => server}/session_manager.py | 227 +++++------------- libcodechecker/util.py | 23 ++ libcodechecker/version.py | 23 +- tests/libtest/thrift_client_to_db.py | 20 +- 12 files changed, 239 insertions(+), 216 deletions(-) create mode 100644 libcodechecker/libclient/credential_manager.py rename libcodechecker/{ => server}/session_manager.py (65%) diff --git a/libcodechecker/libclient/authentication_helper.py b/libcodechecker/libclient/authentication_helper.py index 2ea495117a..6be4f2be3b 100644 --- a/libcodechecker/libclient/authentication_helper.py +++ b/libcodechecker/libclient/authentication_helper.py @@ -5,9 +5,9 @@ # ------------------------------------------------------------------------- import os -import sys # import datetime import socket +import sys from thrift.transport import THttpClient from thrift.protocol import TJSONProtocol @@ -17,9 +17,10 @@ import shared from Authentication_v6 import codeCheckerAuthentication -from libcodechecker import session_manager from libcodechecker import util +from credential_manager import SESSION_COOKIE_NAME + class ThriftAuthHelper(): def __init__(self, protocol, host, port, uri, @@ -32,8 +33,7 @@ def __init__(self, protocol, host, port, uri, self.client = codeCheckerAuthentication.Client(self.protocol) if session_token: - headers = {'Cookie': session_manager.SESSION_COOKIE_NAME + - "=" + session_token} + headers = {'Cookie': SESSION_COOKIE_NAME + '=' + session_token} self.transport.setCustomHeaders(headers) # ------------------------------------------------------------ diff --git a/libcodechecker/libclient/client.py b/libcodechecker/libclient/client.py index fc70b469dd..1e559ee8e4 100644 --- a/libcodechecker/libclient/client.py +++ b/libcodechecker/libclient/client.py @@ -13,14 +13,14 @@ import shared from Authentication_v6 import ttypes as AuthTypes -from libcodechecker import session_manager from libcodechecker.logger import get_logger from libcodechecker.util import split_product_url from libcodechecker.version import CLIENT_API from . import authentication_helper -from . import thrift_helper from . import product_helper +from . import thrift_helper +from credential_manager import UserCredentials LOG = get_logger('system') @@ -46,8 +46,8 @@ def setup_auth_client(protocol, host, port, session_token=None): """ if not session_token: - manager = session_manager.SessionManager_Client() - session_token = manager.getToken(host, port) + manager = UserCredentials() + session_token = manager.get_token(host, port) session_token_new = perform_auth_for_handler(protocol, manager, host, port, @@ -79,8 +79,8 @@ def setup_auth_client_from_url(product_url, session_token=None): def handle_auth(protocol, host, port, username, login=False): - session = session_manager.SessionManager_Client() - auth_token = session.getToken(host, port) + session = UserCredentials() + auth_token = session.get_token(host, port) auth_client = authentication_helper.ThriftAuthHelper(protocol, host, port, '/v' + @@ -92,7 +92,7 @@ def handle_auth(protocol, host, port, username, login=False): if not login: logout_done = auth_client.destroySession() if logout_done: - session.saveToken(host, port, None, True) + session.save_token(host, port, None, True) LOG.info("Successfully logged out.") return @@ -116,7 +116,7 @@ def handle_auth(protocol, host, port, username, login=False): if 'Username:Password' in str(methods): # Try to use a previously saved credential from configuration file. - saved_auth = session.getAuthString(host, port) + saved_auth = session.get_auth_string(host, port) if saved_auth: LOG.info("Logging in using preconfigured credentials...") @@ -134,7 +134,7 @@ def handle_auth(protocol, host, port, username, login=False): username + ":" + pwd) - session.saveToken(host, port, session_token) + session.save_token(host, port, session_token) LOG.info("Server reported successful authentication.") except shared.ttypes.RequestFailed as reqfail: LOG.error("Authentication failed! Please check your credentials.") @@ -170,7 +170,7 @@ def perform_auth_for_handler(protocol, manager, host, port, print_err = False if manager.is_autologin_enabled(): - auto_auth_string = manager.getAuthString(host, port) + auto_auth_string = manager.get_auth_string(host, port) if auto_auth_string: # Try to automatically log in with a saved credential # if it exists for the server. @@ -178,7 +178,7 @@ def perform_auth_for_handler(protocol, manager, host, port, session_token = auth_client.performLogin( "Username:Password", auto_auth_string) - manager.saveToken(host, port, session_token) + manager.save_token(host, port, session_token) LOG.info("Authenticated using pre-configured " "credentials.") return session_token diff --git a/libcodechecker/libclient/credential_manager.py b/libcodechecker/libclient/credential_manager.py new file mode 100644 index 0000000000..ad7595e662 --- /dev/null +++ b/libcodechecker/libclient/credential_manager.py @@ -0,0 +1,92 @@ +# ------------------------------------------------------------------------- +# The CodeChecker Infrastructure +# This file is distributed under the University of Illinois Open Source +# License. See LICENSE.TXT for details. +# ------------------------------------------------------------------------- +""" +Handles the management of stored user credentials and currently known session +tokens. +""" + +import json +import os +import stat + +import portalocker + +from libcodechecker.logger import get_logger +from libcodechecker.util import check_file_owner_rw +from libcodechecker.util import load_json_or_empty +from libcodechecker.version import SESSION_COOKIE_NAME as _SCN + +LOG = get_logger('system') +SESSION_COOKIE_NAME = _SCN + + +class UserCredentials: + + def __init__(self): + LOG.debug("Loading clientside session config.") + + # Check whether user's configuration exists. + user_home = os.path.expanduser("~") + session_cfg_file = os.path.join(user_home, + ".codechecker.passwords.json") + LOG.debug(session_cfg_file) + + scfg_dict = load_json_or_empty(session_cfg_file, {}, + "user authentication") + check_file_owner_rw(session_cfg_file) + + if not scfg_dict.get('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(user_home, ".codechecker.session.json") + LOG.debug(self.token_file) + + if os.path.exists(self.token_file): + token_dict = load_json_or_empty(self.token_file, {}, + "user authentication") + check_file_owner_rw(self.token_file) + + self.__tokens = token_dict.get('tokens') + 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 get_token(self, host, port): + return self.__tokens.get("{0}:{1}".format(host, port)) + + def get_auth_string(self, host, port): + ret = self.__save['credentials'].get('{0}:{1}'.format(host, port)) + if not ret: + ret = self.__save['credentials'].get(host) + if not ret: + ret = self.__save['credentials'].get('*:{0}'.format(port)) + if not ret: + ret = self.__save['credentials'].get('*') + + return ret + + def save_token(self, host, port, token, destroy=False): + if destroy: + del self.__tokens['{0}:{1}'.format(host, port)] + else: + self.__tokens['{0}:{1}'.format(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/libcodechecker/libclient/product_helper.py b/libcodechecker/libclient/product_helper.py index ef284e22d3..f4025eab5a 100644 --- a/libcodechecker/libclient/product_helper.py +++ b/libcodechecker/libclient/product_helper.py @@ -16,9 +16,10 @@ import shared from ProductManagement_v6 import codeCheckerProductService -from libcodechecker import session_manager from libcodechecker import util +from credential_manager import SESSION_COOKIE_NAME + class ThriftProductHelper(object): def __init__(self, protocol, host, port, uri, session_token=None): @@ -30,8 +31,7 @@ def __init__(self, protocol, host, port, uri, session_token=None): self.client = codeCheckerProductService.Client(self.protocol) if session_token: - headers = {'Cookie': session_manager.SESSION_COOKIE_NAME + - "=" + session_token} + headers = {'Cookie': SESSION_COOKIE_NAME + '=' + session_token} self.transport.setCustomHeaders(headers) # ------------------------------------------------------------ diff --git a/libcodechecker/libclient/thrift_helper.py b/libcodechecker/libclient/thrift_helper.py index 59fdd0c4bc..ce7583b878 100644 --- a/libcodechecker/libclient/thrift_helper.py +++ b/libcodechecker/libclient/thrift_helper.py @@ -16,10 +16,10 @@ import shared from codeCheckerDBAccess_v6 import codeCheckerDBAccess -from libcodechecker import session_manager from libcodechecker import util from libcodechecker.logger import get_logger +from credential_manager import SESSION_COOKIE_NAME LOG = get_logger('system') @@ -35,8 +35,7 @@ def __init__(self, protocol, host, port, uri, session_token=None): self.client = codeCheckerDBAccess.Client(self.protocol) if session_token: - headers = {'Cookie': session_manager.SESSION_COOKIE_NAME + - "=" + session_token} + headers = {'Cookie': SESSION_COOKIE_NAME + '=' + session_token} self.transport.setCustomHeaders(headers) def ThriftClientCall(function): diff --git a/libcodechecker/libhandlers/server.py b/libcodechecker/libhandlers/server.py index a6233748dd..45163a0fa9 100644 --- a/libcodechecker/libhandlers/server.py +++ b/libcodechecker/libhandlers/server.py @@ -26,11 +26,11 @@ from libcodechecker import host_check from libcodechecker import logger from libcodechecker import output_formatters -from libcodechecker import session_manager from libcodechecker import util from libcodechecker.analyze import analyzer_env -from libcodechecker.server import server from libcodechecker.server import instance_manager +from libcodechecker.server import server +from libcodechecker.server import session_manager from libcodechecker.server.database import database from libcodechecker.server.database import database_status from libcodechecker.server.database.config_db_model \ @@ -861,7 +861,7 @@ def server_init_start(args): def main(args): """ Setup a logger server based on the configuration and - manage the Codechecker server. + manage the CodeChecker server. """ with logger.LOG_CFG_SERVER(args.verbose): server_init_start(args) diff --git a/libcodechecker/server/api/authentication.py b/libcodechecker/server/api/authentication.py index 9c8985c669..1f374fffa2 100644 --- a/libcodechecker/server/api/authentication.py +++ b/libcodechecker/server/api/authentication.py @@ -48,7 +48,7 @@ def getAuthParameters(self): token = None if self.__auth_session: token = self.__auth_session.token - return HandshakeInformation(self.__manager.isEnabled(), + return HandshakeInformation(self.__manager.is_enabled(), self.__manager.is_valid( token, True)) diff --git a/libcodechecker/server/server.py b/libcodechecker/server/server.py index f8865c826b..4ad2c5823d 100644 --- a/libcodechecker/server/server.py +++ b/libcodechecker/server/server.py @@ -20,6 +20,7 @@ import socket import ssl import sys +import stat import urllib try: @@ -40,13 +41,13 @@ from codeCheckerDBAccess_v6 import codeCheckerDBAccess as ReportAPI_v6 from ProductManagement_v6 import codeCheckerProductService as ProductAPI_v6 -from libcodechecker import session_manager from libcodechecker.logger import get_logger from libcodechecker.util import get_tmp_dir_hash from . import instance_manager from . import permissions from . import routing +from . import session_manager from api.authentication import ThriftAuthHandler as AuthHandler_v6 from api.bad_api_version import ThriftAPIMismatchHandler as BadAPIHandler from api.product_server import ThriftProductHandler as ProductHandler_v6 @@ -84,7 +85,7 @@ def __check_auth_in_request(self): present. """ - if not self.server.manager.isEnabled(): + if not self.server.manager.is_enabled(): return None success = None @@ -153,18 +154,18 @@ def do_GET(self): LOG.info("{0}:{1} -- [{2}] GET {3}" .format(self.client_address[0], str(self.client_address[1]), - auth_session.user if auth_session else "Anonymous", + auth_session.user if auth_session else 'Anonymous', self.path)) - if self.server.manager.isEnabled() and not auth_session: - realm = self.server.manager.getRealm()["realm"] - error_body = self.server.manager.getRealm()["error"] + if self.server.manager.is_enabled() and not auth_session: + realm = self.server.manager.get_realm()['realm'] + error_body = self.server.manager.get_realm()['error'] self.send_response(401) # 401 Unauthorised - self.send_header("WWW-Authenticate", + self.send_header('WWW-Authenticate', 'Basic realm="{0}"'.format(realm)) - self.send_header("Content-type", "text/plain") - self.send_header("Content-length", str(len(error_body))) + self.send_header('Content-type', 'text/plain') + self.send_header('Content-length', str(len(error_body))) self.send_header('Connection', 'close') self.end_headers() self.wfile.write(error_body) @@ -344,7 +345,7 @@ def do_POST(self): iprot = input_protocol_factory.getProtocol(itrans) oprot = output_protocol_factory.getProtocol(otrans) - if self.server.manager.isEnabled() and \ + if self.server.manager.is_enabled() and \ not self.path.endswith('/Authentication') and \ not auth_session: # Bail out if the user is not authenticated... diff --git a/libcodechecker/session_manager.py b/libcodechecker/server/session_manager.py similarity index 65% rename from libcodechecker/session_manager.py rename to libcodechecker/server/session_manager.py index 0ba614bab9..d7e1160b96 100644 --- a/libcodechecker/session_manager.py +++ b/libcodechecker/server/session_manager.py @@ -4,38 +4,34 @@ # License. See LICENSE.TXT for details. # ------------------------------------------------------------------------- """ -Handles the allocation and destruction of privileged sessions associated -with a particular CodeChecker server. +Handles the management of authentication sessions on the server's side. """ from datetime import datetime import hashlib -import json import os import shutil -import stat import time import uuid -import portalocker - from libcodechecker.logger import get_logger -from libcodechecker import util +from libcodechecker.util import check_file_owner_rw +from libcodechecker.util import load_json_or_empty +from libcodechecker.version import SESSION_COOKIE_NAME unsupported_methods = [] try: from libcodechecker.libauth import cc_ldap except ImportError: - unsupported_methods.append("ldap") + unsupported_methods.append('ldap') try: from libcodechecker.libauth import cc_pam except ImportError: - unsupported_methods.append("pam") + unsupported_methods.append('pam') LOG = get_logger("server") -SESSION_COOKIE_NAME = "__ccPrivilegedAccessToken" session_lifetimes = {} @@ -44,8 +40,8 @@ class _Session(object): # 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()) + "__" + + __initial_salt = hashlib.sha256(SESSION_COOKIE_NAME + '__' + + str(time.time()) + '__' + os.urandom(16)).hexdigest() @staticmethod @@ -54,7 +50,7 @@ def calc_persistency_hash(auth_string): 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 + "@" + + return hashlib.sha256(auth_string + '@' + _Session.__initial_salt).hexdigest() def __init__(self, token, phash, username, groups, is_root=False): @@ -77,7 +73,7 @@ def still_valid(self, do_revalidate=False): it. A session is valid in its soft-lifetime.""" if (datetime.now() - self.last_access).total_seconds() <= \ - session_lifetimes["soft"] and self.still_reusable(): + session_lifetimes['soft'] and self.still_reusable(): # 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 @@ -98,7 +94,7 @@ def still_reusable(self): 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"] + session_lifetimes['hard'] def revalidate(self): if self.still_reusable(): @@ -109,41 +105,6 @@ def revalidate(self): self.last_access = datetime.now() -def check_file_owner_rw(file_to_check): - """ - Check the file permissions. - Return: - True if only the owner can read or write the file. - False if other users or groups can read or write the file. - """ - - mode = os.stat(file_to_check)[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("'{0}' is readable by users other than you!" - " This poses a risk of leaking sensitive" - " information, such as passwords, session tokens, etc.!\n" - "Please 'chmod 0600 {0}' so only you can access the file." - .format(file_to_check)) - return False - return True - - -def load_server_cfg(server_cfg_file): - """ - Tries to load the session config file which should be a - valid json file, if loading fails returns an empty dict. - """ - - if os.path.exists(server_cfg_file): - check_file_owner_rw(server_cfg_file) - return util.load_json_or_empty(server_cfg_file, {}) - - return {} - - class SessionManager: CodeChecker_Workspace = None @@ -179,14 +140,17 @@ def __init__(self, root_sha, force_auth=False): LOG.debug(server_cfg_file) - # Create the default settings and then load the file from the disk. - scfg_dict = {'authentication': {'enabled': False}} - scfg_dict.update(load_server_cfg(server_cfg_file)) + scfg_dict = load_json_or_empty(server_cfg_file, {}, + 'server configuration') + if scfg_dict != {}: + check_file_owner_rw(server_cfg_file) + else: + scfg_dict = {'authentication': {'enabled': False}} - self.__max_run_count = scfg_dict["max_run_count"] \ - if "max_run_count" in scfg_dict else None + self.__max_run_count = scfg_dict['max_run_count'] \ + if 'max_run_count' in scfg_dict else None - self.__auth_config = scfg_dict["authentication"] + self.__auth_config = scfg_dict['authentication'] if force_auth: LOG.debug("Authentication was force-enabled.") @@ -196,32 +160,32 @@ def __init__(self, root_sha, force_auth=False): self.__auth_config['method_root'] = root_sha # If no methods are configured as enabled, disable authentication. - if scfg_dict["authentication"].get("enabled"): + 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"): + 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: + 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 + 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: + 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 + self.__auth_config['method_pam']['enabled'] = False # if not found_auth_method: @@ -234,20 +198,20 @@ def __init__(self, root_sha, force_auth=False): LOG.warning("Authentication is enabled but no valid " "authentication backends are configured... " "Falling back to no authentication.") - self.__auth_config["enabled"] = False + 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 + 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 is_enabled(self): + return self.__auth_config.get('enabled') - def getRealm(self): + def get_realm(self): return { - "realm": self.__auth_config.get("realm_name"), - "error": self.__auth_config.get("realm_error") + "realm": self.__auth_config.get('realm_name'), + "error": self.__auth_config.get('realm_error') } def __handle_validation(self, auth_string): @@ -276,16 +240,16 @@ def __handle_validation(self, 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") + 'method_' + method in self.__auth_config and \ + self.__auth_config['method_' + method].get('enabled') def __try_auth_root(self, auth_string): """ Try to authenticate the user against the root username:password's hash. """ - if "method_root" in self.__auth_config and \ + if 'method_root' in self.__auth_config and \ hashlib.sha256(auth_string).hexdigest() == \ - self.__auth_config["method_root"]: + self.__auth_config['method_root']: return { 'username': SessionManager.get_user_name(auth_string), 'groups': [], @@ -301,12 +265,12 @@ def __try_auth_dictionary(self, auth_string): Returns a validation object if successful, which contains the users' groups. """ - method_config = self.__auth_config.get("method_dictionary") + method_config = self.__auth_config.get('method_dictionary') if not method_config: return False - valid = self.__is_method_enabled("dictionary") and \ - auth_string in method_config.get("auths") + valid = self.__is_method_enabled('dictionary') and \ + auth_string in method_config.get('auths') if not valid: return False @@ -324,9 +288,9 @@ def __try_auth_pam(self, auth_string): """ Try to authenticate user based on the PAM configuration. """ - if self.__is_method_enabled("pam"): - username, password = auth_string.split(":") - if cc_pam.auth_user(self.__auth_config["method_pam"], + if self.__is_method_enabled('pam'): + username, password = auth_string.split(':') + if cc_pam.auth_user(self.__auth_config['method_pam'], username, password): # PAM does not hold a group membership list we can reliably # query. @@ -338,11 +302,11 @@ def __try_auth_ldap(self, auth_string): """ Try to authenticate user to all the configured authorities. """ - if self.__is_method_enabled("ldap"): - username, password = auth_string.split(":") + if self.__is_method_enabled('ldap'): + username, password = auth_string.split(':') - ldap_authorities = self.__auth_config["method_ldap"] \ - .get("authorities") + ldap_authorities = self.__auth_config['method_ldap'] \ + .get('authorities') for ldap_conf in ldap_authorities: if cc_ldap.auth_user(ldap_conf, username, password): groups = cc_ldap.get_groups(ldap_conf, username, password) @@ -352,7 +316,7 @@ def __try_auth_ldap(self, auth_string): @staticmethod def get_user_name(auth_string): - return auth_string.split(":")[0] + return auth_string.split(':')[0] def create_or_get_session(self, auth_string): """Create a new session for the given auth-string, if it is valid. If @@ -360,12 +324,12 @@ def create_or_get_session(self, auth_string): Currently only username:password format auth_string is supported. """ - if not self.__auth_config["enabled"]: + 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.__auth_config['logins_until_cleanup']: self.__cleanup_sessions() validation = self.__try_auth_root(auth_string) @@ -387,11 +351,11 @@ def create_or_get_session(self, auth_string): session = session_already else: # TODO: Use a more secure way for token generation? - token = uuid.UUID(bytes=os.urandom(16)).__str__().replace("-", - "") + token = uuid.UUID(bytes=os.urandom(16)).__str__().replace('-', + '') user_name = validation['username'] - groups = validation.get("groups", []) + groups = validation.get('groups', []) is_root = validation.get('root', False) session = _Session(token, @@ -406,7 +370,7 @@ def create_or_get_session(self, auth_string): def is_valid(self, token, access=False): """Validates a given token (cookie) against the known list of privileged sessions.""" - if not self.isEnabled(): + if not self.is_enabled(): return True else: return any(_sess.token == token and _sess.still_valid(access) @@ -423,7 +387,7 @@ def get_session(self, token, access=False): """Gets the privileged session object based based on the token. """ - if not self.isEnabled(): + if not self.is_enabled(): return None for _sess in SessionManager.__valid_sessions: if _sess.token == token and _sess.still_valid(access): @@ -444,68 +408,3 @@ def __cleanup_sessions(self): in SessionManager.__valid_sessions if s.still_reusable()] self.__logins_since_prune = 0 - - -class SessionManager_Client: - def __init__(self): - LOG.debug('Loading session config') - - # Check whether user's configuration exists. - user_home = os.path.expanduser("~") - session_cfg_file = os.path.join(user_home, - ".codechecker.passwords.json") - LOG.debug(session_cfg_file) - - scfg_dict = load_server_cfg(session_cfg_file) - - if not scfg_dict.get("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(user_home, ".codechecker.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") - check_file_owner_rw(self.token_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("{0}:{1}".format(host, port)) - - def getAuthString(self, host, port): - ret = self.__save["credentials"].get("{0}:{1}".format(host, port)) - if not ret: - ret = self.__save["credentials"].get(host) - if not ret: - ret = self.__save["credentials"].get("*:{0}".format(port)) - if not ret: - ret = self.__save["credentials"].get("*") - - return ret - - def saveToken(self, host, port, token, destroy=False): - if destroy: - del self.__tokens["{0}:{1}".format(host, port)] - else: - self.__tokens["{0}:{1}".format(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/libcodechecker/util.py b/libcodechecker/util.py index 116201e9fb..385939bcbe 100644 --- a/libcodechecker/util.py +++ b/libcodechecker/util.py @@ -14,6 +14,7 @@ import re import shutil import socket +import stat import subprocess import tempfile @@ -436,6 +437,28 @@ def get_new_line_col_without_whitespace(line_content, old_col): old_col - line_strip_len +def check_file_owner_rw(file_to_check): + """ + Check the file permissions. + Return: + True if only the owner can read or write the file. + False if other users or groups can read or write the file. + """ + + mode = os.stat(file_to_check)[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("'{0}' is readable by users other than you! " + "This poses a risk of leaking sensitive " + "information, such as passwords, session tokens, etc.!\n" + "Please 'chmod 0600 {0}' so only you can access the file." + .format(file_to_check)) + return False + return True + + def load_json_or_empty(path, default=None, kind=None): """ Load the contents of the given file as a JSON and return it's value, diff --git a/libcodechecker/version.py b/libcodechecker/version.py index 4b95d4fc6f..356f214e3e 100644 --- a/libcodechecker/version.py +++ b/libcodechecker/version.py @@ -4,19 +4,28 @@ # License. See LICENSE.TXT for details. # ------------------------------------------------------------------------- """ -This module stores a global constant between CodeChecker server and client, -which dictates what API version the client should use, and what the server -accepts. +This module stores constants that are shared between the CodeChecker server +and client, related to API and other version-specific information. """ -# This dict object stores for each MAJOR version (key) the largest MINOR -# version (value) supported by the build. +""" +The name of the cookie which contains the user's authentication session's +token. +""" +SESSION_COOKIE_NAME = "__ccPrivilegedAccessToken" + +""" +The newest supported minor version (value) for each supported major version +(key) in this particular build. +""" SUPPORTED_VERSIONS = { 6: 6 } -# This value is automatically generated to represent the highest version -# available in the current build. +""" +Used by the client to automatically identify the latest major and minor +version. +""" CLIENT_API = '{0}.{1}'.format( max(SUPPORTED_VERSIONS.keys()), SUPPORTED_VERSIONS[max(SUPPORTED_VERSIONS.keys())]) diff --git a/tests/libtest/thrift_client_to_db.py b/tests/libtest/thrift_client_to_db.py index b8f062f5df..2b3b86aa46 100644 --- a/tests/libtest/thrift_client_to_db.py +++ b/tests/libtest/thrift_client_to_db.py @@ -5,7 +5,6 @@ # ----------------------------------------------------------------------------- from functools import partial -from libcodechecker import util import os import re import socket @@ -17,6 +16,7 @@ import shared +from libcodechecker import util from libcodechecker.version import CLIENT_API as VERSION @@ -110,9 +110,10 @@ class CCViewerHelper(ThriftAPIHelper): def __init__(self, protocol, host, port, product, endpoint, auto_handle_connection=True, session_token=None): # Import only if necessary; some tests may not add this to PYTHONPATH. - from libcodechecker import session_manager from codeCheckerDBAccess_v6 import codeCheckerDBAccess from codeCheckerDBAccess_v6.constants import MAX_QUERY_SIZE + from libcodechecker.libclient.credential_manager \ + import SESSION_COOKIE_NAME self.max_query_size = MAX_QUERY_SIZE url = util.create_product_url(protocol, host, port, @@ -123,8 +124,7 @@ def __init__(self, protocol, host, port, product, endpoint, protocol = TJSONProtocol.TJSONProtocol(transport) client = codeCheckerDBAccess.Client(protocol) if session_token: - headers = {'Cookie': session_manager.SESSION_COOKIE_NAME + - "=" + session_token} + headers = {'Cookie': SESSION_COOKIE_NAME + '=' + session_token} transport.setCustomHeaders(headers) super(CCViewerHelper, self).__init__(transport, client, auto_handle_connection) @@ -167,16 +167,16 @@ class CCAuthHelper(ThriftAPIHelper): def __init__(self, proto, host, port, uri, auto_handle_connection=True, session_token=None): # Import only if necessary; some tests may not add this to PYTHONPATH. - from libcodechecker import session_manager from Authentication_v6 import codeCheckerAuthentication + from libcodechecker.libclient.credential_manager \ + import SESSION_COOKIE_NAME url = util.create_product_url(proto, host, port, '/v' + VERSION + uri) transport = THttpClient.THttpClient(url) protocol = TJSONProtocol.TJSONProtocol(transport) client = codeCheckerAuthentication.Client(protocol) if session_token: - headers = {'Cookie': session_manager.SESSION_COOKIE_NAME + - "=" + session_token} + headers = {'Cookie': SESSION_COOKIE_NAME + '=' + session_token} transport.setCustomHeaders(headers) super(CCAuthHelper, self).__init__(transport, client, auto_handle_connection) @@ -191,8 +191,9 @@ def __init__(self, proto, host, port, product, uri, auto_handle_connection=True, session_token=None): # Import only if necessary; some tests may not add this to PYTHONPATH. - from libcodechecker import session_manager from ProductManagement_v6 import codeCheckerProductService + from libcodechecker.libclient.credential_manager \ + import SESSION_COOKIE_NAME full_uri = '/v' + VERSION + uri if product: full_uri = '/' + product + full_uri @@ -202,8 +203,7 @@ def __init__(self, proto, host, port, product, protocol = TJSONProtocol.TJSONProtocol(transport) client = codeCheckerProductService.Client(protocol) if session_token: - headers = {'Cookie': session_manager.SESSION_COOKIE_NAME + - "=" + session_token} + headers = {'Cookie': SESSION_COOKIE_NAME + '=' + session_token} transport.setCustomHeaders(headers) super(CCProductHelper, self).__init__(transport, client, auto_handle_connection) From aedcc670c2cdf578a73e391105f0f0f104f0729c Mon Sep 17 00:00:00 2001 From: Whisperity Date: Thu, 23 Nov 2017 13:14:59 +0100 Subject: [PATCH 2/2] Store the users' authenticated sessions in the database too --- config_db_migrate/__init__.py | 0 ...447_share_sessions_through_the_database.py | 30 ++ config_db_migrate/versions/__init__.py | 0 .../libclient/credential_manager.py | 6 +- libcodechecker/libhandlers/server.py | 3 - libcodechecker/server/api/authentication.py | 2 +- .../server/database/config_db_model.py | 16 + libcodechecker/server/server.py | 42 ++- libcodechecker/server/session_manager.py | 355 ++++++++++++------ libcodechecker/version.py | 18 +- 10 files changed, 332 insertions(+), 140 deletions(-) create mode 100644 config_db_migrate/__init__.py create mode 100644 config_db_migrate/versions/150800b30447_share_sessions_through_the_database.py create mode 100644 config_db_migrate/versions/__init__.py diff --git a/config_db_migrate/__init__.py b/config_db_migrate/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/config_db_migrate/versions/150800b30447_share_sessions_through_the_database.py b/config_db_migrate/versions/150800b30447_share_sessions_through_the_database.py new file mode 100644 index 0000000000..f363ece0af --- /dev/null +++ b/config_db_migrate/versions/150800b30447_share_sessions_through_the_database.py @@ -0,0 +1,30 @@ +"""Share sessions through the database + +Revision ID: 150800b30447 +Revises: 8268fc7ca7f4 +Create Date: 2017-11-23 15:26:45.594141 + +""" + +# revision identifiers, used by Alembic. +revision = '150800b30447' +down_revision = '8268fc7ca7f4' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def upgrade(): + op.create_table('sessions', + sa.Column('auth_string', sa.CHAR(64), nullable=False), + sa.Column('token', sa.CHAR(32), nullable=False), + sa.Column('last_access', sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint('auth_string', + name=op.f('pk_sessions')) + ) + + +def downgrade(): + op.drop_table('sessions') diff --git a/config_db_migrate/versions/__init__.py b/config_db_migrate/versions/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/libcodechecker/libclient/credential_manager.py b/libcodechecker/libclient/credential_manager.py index ad7595e662..b9b0912b12 100644 --- a/libcodechecker/libclient/credential_manager.py +++ b/libcodechecker/libclient/credential_manager.py @@ -36,14 +36,14 @@ def __init__(self): scfg_dict = load_json_or_empty(session_cfg_file, {}, "user authentication") - check_file_owner_rw(session_cfg_file) + if os.path.exists(session_cfg_file): + check_file_owner_rw(session_cfg_file) if not scfg_dict.get('credentials'): scfg_dict['credentials'] = {} self.__save = scfg_dict - self.__autologin = scfg_dict.get('client_autologin') \ - if 'client_autologin' in scfg_dict else True + self.__autologin = scfg_dict.get('client_autologin', True) # Check and load token storage for user. self.token_file = os.path.join(user_home, ".codechecker.session.json") diff --git a/libcodechecker/libhandlers/server.py b/libcodechecker/libhandlers/server.py index 45163a0fa9..c520fb6433 100644 --- a/libcodechecker/libhandlers/server.py +++ b/libcodechecker/libhandlers/server.py @@ -30,7 +30,6 @@ from libcodechecker.analyze import analyzer_env from libcodechecker.server import instance_manager from libcodechecker.server import server -from libcodechecker.server import session_manager from libcodechecker.server.database import database from libcodechecker.server.database import database_status from libcodechecker.server.database.config_db_model \ @@ -679,8 +678,6 @@ def server_init_start(args): context = generic_package_context.get_context() context.codechecker_workspace = args.config_directory - session_manager.SessionManager.CodeChecker_Workspace = \ - args.config_directory context.db_username = args.dbusername check_env = analyzer_env.get_check_env(context.path_env_extra, diff --git a/libcodechecker/server/api/authentication.py b/libcodechecker/server/api/authentication.py index 1f374fffa2..a24623f9b8 100644 --- a/libcodechecker/server/api/authentication.py +++ b/libcodechecker/server/api/authentication.py @@ -48,7 +48,7 @@ def getAuthParameters(self): token = None if self.__auth_session: token = self.__auth_session.token - return HandshakeInformation(self.__manager.is_enabled(), + return HandshakeInformation(self.__manager.is_enabled, self.__manager.is_valid( token, True)) diff --git a/libcodechecker/server/database/config_db_model.py b/libcodechecker/server/database/config_db_model.py index e3883eefd5..6855e37e0e 100644 --- a/libcodechecker/server/database/config_db_model.py +++ b/libcodechecker/server/database/config_db_model.py @@ -9,6 +9,7 @@ from __future__ import print_function from __future__ import unicode_literals +from datetime import datetime import sys from sqlalchemy import * @@ -107,6 +108,21 @@ def __init__(self, permission, product_id, name, is_group=False): self.is_group = is_group +class Session(Base): + __tablename__ = 'sessions' + + # Auth-String is a SHA-256 hash, while token is a Python UUID which is + # 32 characters (both in hex expanded format). + auth_string = Column(CHAR(64), nullable=False, primary_key=True) + token = Column(CHAR(32), nullable=False) + last_access = Column(DateTime, nullable=False) + + def __init__(self, auth_string, token): + self.auth_string = auth_string + self.token = token + self.last_access = datetime.now() + + IDENTIFIER = { 'identifier': "ConfigDatabase", 'orm_meta': CC_META, diff --git a/libcodechecker/server/server.py b/libcodechecker/server/server.py index 4ad2c5823d..ff84da7837 100644 --- a/libcodechecker/server/server.py +++ b/libcodechecker/server/server.py @@ -16,7 +16,7 @@ import os import posixpath from random import sample -import stat +import shutil import socket import ssl import sys @@ -85,7 +85,7 @@ def __check_auth_in_request(self): present. """ - if not self.server.manager.is_enabled(): + if not self.server.manager.is_enabled: return None success = None @@ -157,7 +157,7 @@ def do_GET(self): auth_session.user if auth_session else 'Anonymous', self.path)) - if self.server.manager.is_enabled() and not auth_session: + if self.server.manager.is_enabled and not auth_session: realm = self.server.manager.get_realm()['realm'] error_body = self.server.manager.get_realm()['error'] @@ -345,7 +345,7 @@ def do_POST(self): iprot = input_protocol_factory.getProtocol(itrans) oprot = output_protocol_factory.getProtocol(otrans) - if self.server.manager.is_enabled() and \ + if self.server.manager.is_enabled and \ not self.path.endswith('/Authentication') and \ not auth_session: # Bail out if the user is not authenticated... @@ -688,6 +688,7 @@ def __init__(self, LOG.debug("Creating database engine for CONFIG DATABASE...") self.__engine = product_db_sql_server.create_engine() self.config_session = sessionmaker(bind=self.__engine) + self.manager.set_database_connection(self.config_session) # Load the initial list of products and set up the server. cfg_sess = self.config_session() @@ -856,7 +857,7 @@ def __make_root_file(root_file): return sha -def start_server(config_directory, package_data, port, db_conn_string, +def start_server(config_directory, package_data, port, config_sql_server, suppress_handler, listen_address, force_auth, skip_db_cleanup, context, check_env): """ @@ -885,16 +886,41 @@ def start_server(config_directory, package_data, port, db_conn_string, .format(root_file)) root_sha = __make_root_file(root_file) + # Check whether configuration file exists, create an example if not. + server_cfg_file = os.path.join(config_directory, 'server_config.json') + if not os.path.exists(server_cfg_file): + # For backward compatibility reason if the session_config.json file + # exists we rename it to server_config.json. + session_cfg_file = os.path.join(config_directory, + 'session_config.json') + example_cfg_file = os.path.join(os.environ['CC_PACKAGE_ROOT'], + 'config', 'server_config.json') + if os.path.exists(session_cfg_file): + LOG.info("Renaming '{0}' to '{1}'. Please check the example " + "configuration file ('{2}') or the user guide for more " + "information.".format(session_cfg_file, + server_cfg_file, + example_cfg_file)) + os.rename(session_cfg_file, server_cfg_file) + else: + LOG.info("CodeChecker server's example configuration file " + "created at '{0}'".format(server_cfg_file)) + shutil.copyfile(example_cfg_file, server_cfg_file) + try: - manager = session_manager.SessionManager(root_sha, force_auth) + manager = session_manager.SessionManager( + server_cfg_file, + config_sql_server.get_connection_string(), + root_sha, + force_auth) except IOError, ValueError: - LOG.error("The server's authentication config file is invalid!") + LOG.error("The server's configuration file is invalid!") sys.exit(1) http_server = CCSimpleHttpServer(server_addr, RequestHandler, config_directory, - db_conn_string, + config_sql_server, skip_db_cleanup, package_data, suppress_handler, diff --git a/libcodechecker/server/session_manager.py b/libcodechecker/server/session_manager.py index d7e1160b96..457ec8e4c5 100644 --- a/libcodechecker/server/session_manager.py +++ b/libcodechecker/server/session_manager.py @@ -10,57 +10,55 @@ from datetime import datetime import hashlib import os -import shutil -import time import uuid +from sqlalchemy import and_ + from libcodechecker.logger import get_logger from libcodechecker.util import check_file_owner_rw from libcodechecker.util import load_json_or_empty -from libcodechecker.version import SESSION_COOKIE_NAME +from libcodechecker.version import SESSION_COOKIE_NAME as _SCN + +from database.config_db_model import Session as SessionRecord -unsupported_methods = [] +UNSUPPORTED_METHODS = [] try: from libcodechecker.libauth import cc_ldap except ImportError: - unsupported_methods.append('ldap') + UNSUPPORTED_METHODS.append('ldap') try: from libcodechecker.libauth import cc_pam except ImportError: - unsupported_methods.append('pam') + UNSUPPORTED_METHODS.append('pam') + LOG = get_logger("server") -session_lifetimes = {} +SESSION_COOKIE_NAME = _SCN class _Session(object): """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(auth_string): + def calc_persistency_hash(auth_string, salt=None): """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 + '@' + - _Session.__initial_salt).hexdigest() + salt if salt else 'CodeChecker').hexdigest() - def __init__(self, token, phash, username, groups, is_root=False): + def __init__(self, token, phash, username, groups, + is_root=False, database=None): self.last_access = datetime.now() self.token = token self.persistent_hash = phash self.user = username self.groups = groups - self.__root = is_root + self.__database = database @property def is_root(self): @@ -68,18 +66,20 @@ def is_root(self): superuser (root) credentials.""" return self.__root - 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.""" + def still_valid(self, soft_lifetime, hard_lifetime, 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 self.still_reusable(): + soft_lifetime and self.still_reusable(hard_lifetime): # 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() + self.revalidate(soft_lifetime, hard_lifetime) # The session is still valid if it has been used in the past # (length of "past" is up to server host). @@ -89,63 +89,76 @@ def still_valid(self, do_revalidate=False): # the user needs to authenticate again. return False - def still_reusable(self): + def still_reusable(self, hard_lifetime): """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'] + hard_lifetime - def revalidate(self): - if self.still_reusable(): + def revalidate(self, soft_lifetime, hard_lifetime): + if self.still_reusable(hard_lifetime): # 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() + if self.__database and not self.still_reusable(soft_lifetime): + # Update the timestamp in the database for the session's last + # access. We only do this if the soft-lifetime has expired so + # that not EVERY API requests' EVERY session check creates a + # database write. + try: + transaction = self.__database() + record = transaction.query(SessionRecord). \ + get(self.persistent_hash) + + if record: + record.last_access = self.last_access + transaction.commit() + except Exception as e: + LOG.error("Couldn't update usage timestamp of {0}" + .format(self.token)) + LOG.error(str(e)) + finally: + transaction.close() + class SessionManager: - CodeChecker_Workspace = None - - __valid_sessions = [] - __logins_since_prune = 0 - - def __init__(self, root_sha, force_auth=False): - LOG.debug('Loading session config') - - # Check whether workspace's configuration exists. - server_cfg_file = os.path.join(SessionManager.CodeChecker_Workspace, - "server_config.json") - - if not os.path.exists(server_cfg_file): - # For backward compatibility reason if the session_config.json file - # exists we rename it to server_config.json. - session_cfg_file = os.path.join( - SessionManager.CodeChecker_Workspace, - "session_config.json") - example_cfg_file = os.path.join(os.environ['CC_PACKAGE_ROOT'], - "config", "server_config.json") - if os.path.exists(session_cfg_file): - LOG.info("Renaming {0} to {1}. Please check the example " - "configuration file ({2}) or the user guide for " - "more information.".format(session_cfg_file, - server_cfg_file, - example_cfg_file)) - os.rename(session_cfg_file, server_cfg_file) - else: - LOG.info("CodeChecker server's example configuration file " - "created at " + server_cfg_file) - shutil.copyfile(example_cfg_file, server_cfg_file) - - LOG.debug(server_cfg_file) - - scfg_dict = load_json_or_empty(server_cfg_file, {}, + """ + Provides the functionality required to handle user authentication on a + CodeChecker server. + """ + + def __init__(self, configuration_file, session_salt, + root_sha, force_auth=False): + """ + Initialise a new Session Manager on the server. + + :param configuration_file: The configuration file to read + authentication backends from. + :param session_salt: An initial salt that will be used in hashing + the session to the database. + :param root_sha: The SHA-256 hash of the root user's authentication. + :param force_auth: If True, the manager will be enabled even if the + configuration file disables authentication. + """ + self.__database_connection = None + self.__logins_since_prune = 0 + self.__sessions = [] + self.__session_salt = hashlib.sha1(session_salt).hexdigest() + + LOG.debug(configuration_file) + scfg_dict = load_json_or_empty(configuration_file, {}, 'server configuration') if scfg_dict != {}: - check_file_owner_rw(server_cfg_file) + check_file_owner_rw(configuration_file) else: - scfg_dict = {'authentication': {'enabled': False}} + # If the configuration dict is empty, it means a JSON couldn't + # have been parsed from it. + raise ValueError("Server configuration file was invalid, or " + "empty.") self.__max_run_count = scfg_dict['max_run_count'] \ if 'max_run_count' in scfg_dict else None @@ -169,7 +182,7 @@ def __init__(self, root_sha, force_auth=False): if 'method_ldap' in self.__auth_config and \ self.__auth_config['method_ldap'].get('enabled'): - if 'ldap' not in unsupported_methods: + if 'ldap' not in UNSUPPORTED_METHODS: found_auth_method = True else: LOG.warning("LDAP authentication was enabled but " @@ -179,7 +192,7 @@ def __init__(self, root_sha, force_auth=False): if 'method_pam' in self.__auth_config and \ self.__auth_config['method_pam'].get('enabled'): - if 'pam' not in unsupported_methods: + if 'pam' not in UNSUPPORTED_METHODS: found_auth_method = True else: LOG.warning("PAM authentication was enabled but " @@ -187,7 +200,6 @@ def __init__(self, root_sha, force_auth=False): "... Disabling PAM authentication.") self.__auth_config['method_pam']['enabled'] = False - # if not found_auth_method: if force_auth: LOG.warning("Authentication was manually enabled, but no " @@ -200,11 +212,7 @@ def __init__(self, root_sha, force_auth=False): "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 - + @property def is_enabled(self): return self.__auth_config.get('enabled') @@ -214,6 +222,15 @@ def get_realm(self): "error": self.__auth_config.get('realm_error') } + def set_database_connection(self, connection): + """ + Set the instance's database connection to use in fetching + database-stored sessions to the given connection. + + Use None as connection's value to unset the database. + """ + self.__database_connection = connection + def __handle_validation(self, auth_string): """ Validate an oncoming authorization request @@ -239,7 +256,7 @@ def __handle_validation(self, auth_string): return False def __is_method_enabled(self, method): - return method not in unsupported_methods and \ + return method not in UNSUPPORTED_METHODS and \ 'method_' + method in self.__auth_config and \ self.__auth_config['method_' + method].get('enabled') @@ -318,6 +335,51 @@ def __try_auth_ldap(self, auth_string): def get_user_name(auth_string): return auth_string.split(':')[0] + def _fetch_session_or_token(self, persistency_hash): + """ + Contact the session store to try fetching a valid session or token + for the given session object hash. This first uses the instance's + in-memory storage, and if nothing is found, contacts the database + (if connected). + + Returns a _Session object if found locally. + Returns a string token if it was found in the database. + None if a session wasn't found. + """ + + # Try the local store first. + sessions = (s for s in self.__sessions + if self.__still_reusable(s) and + s.persistent_hash == persistency_hash) + session = next(sessions, None) + + if not session and self.__database_connection: + try: + # Try the database, if it is connected. + transaction = self.__database_connection() + db_record = transaction.query(SessionRecord) \ + .get(persistency_hash) + + if db_record: + if (datetime.now() - db_record.last_access). \ + total_seconds() <= \ + self.__auth_config['session_lifetime']: + # If a token was found in the database and the session + # for it can still be resurrected, we reuse this token. + return db_record.token + else: + # The token has expired, remove it from the database. + transaction.delete(db_record) + transaction.commit() + except Exception as e: + LOG.error("Couldn't check login in the database: ") + LOG.error(str(e)) + finally: + if transaction: + transaction.close() + + return session + def create_or_get_session(self, auth_string): """Create a new session for the given auth-string, if it is valid. If an existing session is found, return that instead. @@ -337,44 +399,69 @@ def create_or_get_session(self, auth_string): validation = self.__handle_validation(auth_string) if validation: - # If the session is still valid and credentials - # are resent return old token. - session_already = next( - (s for s - in SessionManager.__valid_sessions if s.still_reusable() and - s.persistent_hash == - _Session.calc_persistency_hash(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('-', - '') + sess_hash = _Session.calc_persistency_hash(self.__session_salt, + auth_string) + local_session, db_token = None, None + + # If the session is still valid and credentials are resent, + # return old token. This is fetched either locally or from the db. + session_or_token = self._fetch_session_or_token(sess_hash) + if session_or_token: + if isinstance(session_or_token, _Session): + # We were able to fetch a session from the local in-memory + # storage. + local_session = session_or_token + self.__still_valid(local_session, do_revalidate=True) + elif isinstance(session_or_token, basestring): + # The database gave us a token, which we will reuse in + # creating a local cache entry for the session. + db_token = session_or_token + + if not local_session: + # If there isn't a Session locally, create it. + token = db_token if db_token else \ + uuid.UUID(bytes=os.urandom(16)).__str__().replace('-', '') user_name = validation['username'] groups = validation.get('groups', []) is_root = validation.get('root', False) - session = _Session(token, - _Session.calc_persistency_hash(auth_string), - user_name, groups, is_root) - SessionManager.__valid_sessions.append(session) - - return session - - return None + local_session = _Session(token, sess_hash, + user_name, groups, is_root, + self.__database_connection) + self.__sessions.append(local_session) + + if self.__database_connection: + if not db_token: + # If db_token is None, the session was created + # brand new. + try: + transaction = self.__database_connection() + record = SessionRecord(sess_hash, token) + transaction.add(record) + transaction.commit() + except Exception as e: + LOG.error("Couldn't store login into database: ") + LOG.error(str(e)) + finally: + if transaction: + transaction.close() + else: + # The local session was created from a token + # already present in the database, thus we can't + # add a new one. + self.__still_valid(local_session, do_revalidate=True) + + return local_session def is_valid(self, token, access=False): """Validates a given token (cookie) against the known list of privileged sessions.""" - if not self.is_enabled(): + if not self.is_enabled: return True - else: - return any(_sess.token == token and _sess.still_valid(access) - for _sess in SessionManager.__valid_sessions) + + return any(sess.token == token and self.__still_valid(sess, access) + for sess in self.__sessions) def get_max_run_count(self): """ @@ -387,24 +474,66 @@ def get_session(self, token, access=False): """Gets the privileged session object based based on the token. """ - if not self.is_enabled(): + if not self.is_enabled: return None - for _sess in SessionManager.__valid_sessions: - if _sess.token == token and _sess.still_valid(access): - return _sess - return None + + for sess in self.__sessions: + if sess.token == token and self.__still_valid(sess, access): + return sess def invalidate(self, token): - """Remove a user's previous session from the store.""" - for session in SessionManager.__valid_sessions[:]: - if session.token == token: - SessionManager.__valid_sessions.remove(session) - return True + """ + Remove a user's previous session from the store. + """ + + try: + transaction = self.__database_connection() \ + if self.__database_connection else None + + for session in self.__sessions[:]: + if session.token == token: + self.__sessions.remove(session) + + if transaction: + transaction.query(SessionRecord). \ + filter(and_(SessionRecord.auth_string == + session.persistent_hash, + SessionRecord.token == token)). \ + delete() + transaction.commit() + + return True + except Exception as e: + LOG.error("Couldn't invalidate session for token {0}" + .format(token)) + LOG.error(str(e)) + finally: + if transaction: + transaction.close() return False def __cleanup_sessions(self): - SessionManager.__valid_sessions = [s for s - in SessionManager.__valid_sessions - if s.still_reusable()] + tokens = [s.token for s in self.__sessions + if not self.__still_reusable(s)] self.__logins_since_prune = 0 + + for token in tokens: + self.invalidate(token) + + def __still_reusable(self, session): + """ + Helper function for checking if the session could be + resurrected, even if the soft grace period has expired. + """ + return session.still_reusable(self.__auth_config['session_lifetime']) + + def __still_valid(self, session, do_revalidate=False): + """ + Helper function for checking the validity of a session and + optionally resurrecting it (if possible). This function uses the + current instance's grace periods. + """ + return session.still_valid(self.__auth_config['soft_expire'], + self.__auth_config['session_lifetime'], + do_revalidate) diff --git a/libcodechecker/version.py b/libcodechecker/version.py index 356f214e3e..68c7d02b01 100644 --- a/libcodechecker/version.py +++ b/libcodechecker/version.py @@ -8,24 +8,18 @@ and client, related to API and other version-specific information. """ -""" -The name of the cookie which contains the user's authentication session's -token. -""" +# The name of the cookie which contains the user's authentication session's +# token. SESSION_COOKIE_NAME = "__ccPrivilegedAccessToken" -""" -The newest supported minor version (value) for each supported major version -(key) in this particular build. -""" +# The newest supported minor version (value) for each supported major version +# (key) in this particular build. SUPPORTED_VERSIONS = { 6: 6 } -""" -Used by the client to automatically identify the latest major and minor -version. -""" +# Used by the client to automatically identify the latest major and minor +# version. CLIENT_API = '{0}.{1}'.format( max(SUPPORTED_VERSIONS.keys()), SUPPORTED_VERSIONS[max(SUPPORTED_VERSIONS.keys())])