From b56f0cfa717747ec5bad20f6d7bc1d23c5a8f493 Mon Sep 17 00:00:00 2001 From: Whisperity Date: Wed, 28 Sep 2016 15:56:29 +0200 Subject: [PATCH 01/13] Initial commit for authentication layer - Added a new Thrift service for handling authentication requests (not yet documented) - Added proper HTTP responses for the browser-based auth realm to work - Added wrapper for authentication requests on the command-line client - Implemented persistency for sessions (server) and valid tokens (client) --- build_package.py | 9 ++ codechecker_lib/session_manager.py | 109 +++++++++++++++ config/config.md | 12 +- config/session_config.json | 11 ++ thrift_api/authentication.thrift | 31 +++++ thrift_api/shared.thrift | 3 +- .../cmdline_client/authentication_helper.py | 102 ++++++++++++++ .../cmdline_client/cmd_line_client.py | 72 +++++++++- .../cmdline_client/thrift_helper.py | 30 +++-- viewer_server/client_auth_handler.py | 107 +++++++++++++++ viewer_server/client_db_access_server.py | 127 +++++++++++++++++- 11 files changed, 595 insertions(+), 18 deletions(-) create mode 100644 codechecker_lib/session_manager.py create mode 100644 config/session_config.json create mode 100644 thrift_api/authentication.thrift create mode 100644 viewer_clients/cmdline_client/authentication_helper.py create mode 100644 viewer_server/client_auth_handler.py diff --git a/build_package.py b/build_package.py index a91b7f9a35..770c801ea2 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/session_manager.py b/codechecker_lib/session_manager.py new file mode 100644 index 0000000000..643a725927 --- /dev/null +++ b/codechecker_lib/session_manager.py @@ -0,0 +1,109 @@ +# ------------------------------------------------------------------------- +# 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 os +import uuid + +from codechecker_lib import logger + +LOG = logger.get_new_logger("SESSION MANAGER") + +session_cookie_name = "__ccPrivilegedAccessToken" + + +# ----------- SERVER ----------- + +class sessionManager: + valid_sessions = [] + + @staticmethod + def validate(sessToken): + sessionManager.valid_sessions.append(sessToken) + + @staticmethod + def invalidate(sessToken): + if sessToken in sessionManager.valid_sessions: + sessionManager.valid_sessions.remove(sessToken) + + @staticmethod + def isValid(sessToken): + if not sessionManager().isEnabled(): + # If authentication is disabled, any kind of session token is valid. + return True + + return sessToken in sessionManager.valid_sessions + + def __init__(self): + LOG.debug('Loading session config') + + session_cfg_file = os.path.join(os.environ['CC_PACKAGE_ROOT'], "config", "session_config.json") + LOG.debug(session_cfg_file) + with open(session_cfg_file, 'r') as scfg: + scfg_dict = json.loads(scfg.read()) + + if not scfg_dict["authentication"]: + scfg_dict["authentication"] = {'enabled': False} + + self.__auth_config = scfg_dict["authentication"] + print self + + def isEnabled(self): + return self.__auth_config.get("enabled") + + + +def validate_session_token(token): + '''Validates a given token (cookie) against the known list of privileged sessions''' + return sessionManager.isValid(token) + +def validate_auth_request(authString): + '''Validate an oncoming authorization request against some authority controller''' + return authString == "cc:valid" + +def create_session(): + # TODO: More secure way for token generation? + token = uuid.UUID(bytes = os.urandom(16)).__str__() + sessionManager.validate(token) + + return token + +# ----------- CLIENT ----------- +class sessionManager_Client: + def __init__(self): + LOG.debug('Loading session config') + + session_cfg_file = os.path.join(os.environ['CC_PACKAGE_ROOT'], "config", "session_config.json") + 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"] = {} + if not scfg_dict["tokens"]: + scfg_dict["tokens"] = {} + + self.__save = scfg_dict + print self + + def getToken(self, host): + return self.__save["tokens"].get(host) + + def getAuthString(self, host): + return self.__save["credentials"].get(host) + + def saveToken(self, host, token, destroy = False): + if not destroy: + self.__save["tokens"][host] = token + else: + del self.__save["tokens"][host] + + session_cfg_file = os.path.join(os.environ['CC_PACKAGE_ROOT'], "config", "session_config.json") + with open(session_cfg_file, 'w') as scfg: + json.dump(self.__save, scfg, indent = 2, sort_keys = True) \ No newline at end of file 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_config.json b/config/session_config.json new file mode 100644 index 0000000000..5cd9d149b8 --- /dev/null +++ b/config/session_config.json @@ -0,0 +1,11 @@ +{ + "authentication": { + "enabled": false + }, + "credentials": { + "localhost:6251" : "cc:valid" + }, + "tokens": { + "localhost:6251" : "dummy_token" + } +} diff --git a/thrift_api/authentication.thrift b/thrift_api/authentication.thrift new file mode 100644 index 0000000000..0be7f28ff1 --- /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, +} + +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/viewer_clients/cmdline_client/authentication_helper.py b/viewer_clients/cmdline_client/authentication_helper.py new file mode 100644 index 0000000000..ccb44be343 --- /dev/null +++ b/viewer_clients/cmdline_client/authentication_helper.py @@ -0,0 +1,102 @@ +# ------------------------------------------------------------------------- +# 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: + print('Unauthorized access') + print(str(reqfailure.message)) + 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..e4280a46d3 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,11 +37,65 @@ 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: + 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: + 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) + + session_token = auth_client.performLogin("Username:Password", args.username + ":" + args.password) + session.saveToken(args.host + ":" + args.port, session_token) + print("Server reported successful authentication!") + + +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. """ - client = thrift_helper.ThriftClientHelper(host, port, uri) + client = thrift_helper.ThriftClientHelper(host, port, uri, None) + + # Before actually communicating with the server, we need to check authentication first + auth_client = authentication_helper.ThriftAuthHelper(host, port, uri + 'Authentication', None) + auth_response = auth_client.getAuthParameters() + if auth_response.requiresAuthentication and not auth_response.sessionStillActive: + print('Access denied.') + print('Please log in onto the server using `CodeChecker cmd login`') + sys.exit(1) + # Test if client can work with thrift API getVersion. if not check_API_version(client): print('Backward incompatible change was in the API.') @@ -420,6 +477,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..a8a91570e8 100644 --- a/viewer_clients/cmdline_client/thrift_helper.py +++ b/viewer_clients/cmdline_client/thrift_helper.py @@ -8,26 +8,37 @@ 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 +46,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..9d8aecba0c --- /dev/null +++ b/viewer_server/client_auth_handler.py @@ -0,0 +1,107 @@ +# ------------------------------------------------------------------------- +# 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, session_token = None): + self.__session_token = session_token + + @timefunc + def getAuthParameters(self): + sessions = session_manager.sessionManager() + return HandshakeInformation(sessions.isEnabled(), session_manager.validate_session_token(self.__session_token)) + + @timefunc + def getAcceptedAuthMethods(self): + result = [] + result.append("Username:Password") + return result + + @timefunc + def performLogin(self, auth_method, auth_string): + if auth_method == "Username:Password": + if session_manager.validate_auth_request(auth_string): + authToken = session_manager.create_session() + 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): + # TODO: Only let users to validate/invalidate their own tokens... :D + session_manager.sessionManager.invalidate(self.__session_token) + return True diff --git a/viewer_server/client_db_access_server.py b/viewer_server/client_db_access_server.py index b961f7f3be..02f2244480 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 @@ -24,27 +25,43 @@ 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 + +import base64 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): + cookie_name = "_ccAccessPrivileged" + cookie_str = "YEP!" + def __init__(self, request, client_address, server): self.sc_session = server.sc_session self.db_version_info = server.db_version_info @@ -58,6 +75,77 @@ 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 session_manager.sessionManager().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 + + # TODO: Logs here? + for k in self.headers.getheaders("Cookie"): + print "Begin iter." + split = k.split("; ") + for cookie in split: + print cookie + values = cookie.split("=") + if len(values) == 2 and values[0] == session_manager.session_cookie_name: + + if session_manager.validate_session_token(values[1]): + # The session cookie contains valid data. + print "Cookie is valid." + success = values[1] + else: + print "Cookie is not valid." + + 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 "): + # TODO: Log + print "Receieved HTTP Authorization header..." + authString = base64.decodestring(self.headers.getheader("Authorization").replace("Basic ", "")) + + if session_manager.validate_auth_request(authString): + # TODO: Log + print "Authorization successful, starting new session..." + return session_manager.create_session() + + # Else, access is still not granted. + if not success: + # TODO: Log + print "Credentials invalid... refusing session." + return None + + return success + + def do_GET(self): + authToken = self.check_auth_in_request() + if authToken: + self.send_response(200) + if authToken != True: + self.send_header("Set-Cookie", session_manager.session_cookie_name + "=" + authToken + "; Path=/") + SimpleHTTPRequestHandler.do_GET(self) + else: + print "Failed authentication." + errormsg = """Access requires valid credentials.""" + self.send_response(401) + self.send_header("WWW-Authenticate", 'Basic realm="test"') + self.send_header("Content-type", "text/plain") + self.send_header("Content-length", str(len(errormsg))) + self.send_header('Connection', 'close') + self.end_headers() + self.wfile.write(errormsg) + def do_POST(self): """ Handling thrift messages. """ client_host, client_port = self.client_address @@ -75,6 +163,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 +172,21 @@ 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 + print "Failed authentication!" + errormsg = """Access requires valid credentials.""" + self.send_response(401) + self.send_header("Content-type", "text/plain") + self.send_header("Content-length", str(len(errormsg))) + 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 +195,19 @@ 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(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 +234,6 @@ def translate_path(self, path): """ Modified version from SimpleHTTPRequestHandler. Path is set to www_root. - """ # Abandon query parameters. path = path.split('?', 1)[0] @@ -209,7 +324,7 @@ 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, From 7be0cf27cda322800841c24facb4d4d6a8ad847c Mon Sep 17 00:00:00 2001 From: Whisperity Date: Fri, 30 Sep 2016 12:14:49 +0200 Subject: [PATCH 02/13] Clientside preconfigured credentials and automatic login if no session is found --- README.md | 2 + codechecker_lib/session_manager.py | 27 +++++-- config/session_config.json | 10 ++- docs/authentication.md | 71 +++++++++++++++++++ thrift_api/thrift_api.md | 4 ++ .../cmdline_client/authentication_helper.py | 3 +- .../cmdline_client/cmd_line_client.py | 54 ++++++++++---- viewer_server/client_db_access_server.py | 17 ++--- 8 files changed, 151 insertions(+), 37 deletions(-) create mode 100644 docs/authentication.md 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/codechecker_lib/session_manager.py b/codechecker_lib/session_manager.py index 643a725927..99ddc1c99e 100644 --- a/codechecker_lib/session_manager.py +++ b/codechecker_lib/session_manager.py @@ -10,6 +10,7 @@ import os import uuid +import json from codechecker_lib import logger @@ -90,19 +91,31 @@ def __init__(self): scfg_dict["tokens"] = {} self.__save = scfg_dict + self.__autologin = scfg_dict["authentication"].get("client_autologin") if "client_autologin" in scfg_dict["authentication"] else True print self - def getToken(self, host): - return self.__save["tokens"].get(host) + def is_autologin_enabled(self): + return self.__autologin - def getAuthString(self, host): - return self.__save["credentials"].get(host) + def getToken(self, host, port): + return self.__save["tokens"].get(host + ":" + port) - def saveToken(self, host, token, destroy = False): + 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 not destroy: - self.__save["tokens"][host] = token + self.__save["tokens"][host + ":" + port] = token else: - del self.__save["tokens"][host] + del self.__save["tokens"][host + ":" + port] session_cfg_file = os.path.join(os.environ['CC_PACKAGE_ROOT'], "config", "session_config.json") with open(session_cfg_file, 'w') as scfg: diff --git a/config/session_config.json b/config/session_config.json index 5cd9d149b8..ec5a9cdece 100644 --- a/config/session_config.json +++ b/config/session_config.json @@ -1,11 +1,15 @@ { "authentication": { - "enabled": false + "client_autologin" : true, + "enabled" : true }, "credentials": { - "localhost:6251" : "cc:valid" + "*" : "global:admin", + "*:6251" : "super:secret", + "localhost" : "cc:bad", + "localhost:6252" : "cc:invalid" }, "tokens": { - "localhost:6251" : "dummy_token" + "localhost:6251" : "dummy_token" } } diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000000..dd9215b707 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,71 @@ +CodeChecker authentication subsytem +=================================== + +CodeChecker also supports only allowing a privileged set of users to access the results stored on a server. +Authentication configuration is stored in the `config/session_config.json` file, both for the client and the serverside. + +## Serverside configuration + +> TBD + +## 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. + +~~~~~~~~~~~~~~~~~~~~~ +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 `config/session_config.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`. \ No newline at end of file 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 index ccb44be343..7e8c09b54b 100644 --- a/viewer_clients/cmdline_client/authentication_helper.py +++ b/viewer_clients/cmdline_client/authentication_helper.py @@ -52,8 +52,7 @@ def wrapper(self, *args, **kwargs): print('Database error on server') print(str(reqfailure.message)) if reqfailure.error_code == shared.ttypes.ErrorCode.PRIVILEGE: - print('Unauthorized access') - print(str(reqfailure.message)) + raise reqfailure else: print('Other error') print(str(reqfailure)) diff --git a/viewer_clients/cmdline_client/cmd_line_client.py b/viewer_clients/cmdline_client/cmd_line_client.py index e4280a46d3..b1629b3a3b 100644 --- a/viewer_clients/cmdline_client/cmd_line_client.py +++ b/viewer_clients/cmdline_client/cmd_line_client.py @@ -39,7 +39,7 @@ def default(self, obj): 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)) + auth_client = authentication_helper.ThriftAuthHelper(args.host, args.port, '/Authentication', session.getToken(args.host, args.port)) handshake = auth_client.getAuthParameters() if not handshake.requiresAuthentication: @@ -47,9 +47,13 @@ def handle_auth_requests(args): 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) + session.saveToken(args.host, args.port, None, True) print('Successfully deauthenticated from server.') return @@ -60,18 +64,23 @@ def handle_auth_requests(args): 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) + 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) - session_token = auth_client.performLogin("Username:Password", args.username + ":" + args.password) - session.saveToken(args.host + ":" + args.port, session_token) - print("Server reported successful authentication!") + 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): @@ -84,19 +93,36 @@ def __check_authentication(client): return False def setupClient(host, port, uri): - """ Setup the thrift client and check API version. """ - - client = thrift_helper.ThriftClientHelper(host, port, uri, None) + ''' 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', None) + 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('Access denied.') - print('Please log in onto the server using `CodeChecker cmd login`') - sys.exit(1) + 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 + + if print_err: + print('Access denied. This server requires authentication.') + print('Please log in onto the server using `CodeChecker cmd login`') + sys.exit(1) - # Test if client can work with thrift API getVersion. + 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') diff --git a/viewer_server/client_db_access_server.py b/viewer_server/client_db_access_server.py index 02f2244480..76bd0a4fcd 100644 --- a/viewer_server/client_db_access_server.py +++ b/viewer_server/client_db_access_server.py @@ -90,7 +90,8 @@ def check_auth_in_request(self): # The user either presents a valid session cookie -- in this case, checking if # the session for the given cookie is valid - # TODO: Logs here? + client_host, client_port = self.client_address + for k in self.headers.getheaders("Cookie"): print "Begin iter." split = k.split("; ") @@ -101,29 +102,23 @@ def check_auth_in_request(self): if session_manager.validate_session_token(values[1]): # The session cookie contains valid data. - print "Cookie is valid." success = values[1] - else: - print "Cookie is not valid." 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 "): - # TODO: Log - print "Receieved HTTP Authorization header..." + LOG.info("Client from " + client_host + ":" + str(client_port) + " attempted authorization.") authString = base64.decodestring(self.headers.getheader("Authorization").replace("Basic ", "")) if session_manager.validate_auth_request(authString): - # TODO: Log - print "Authorization successful, starting new session..." + LOG.info("Client from " + client_host + ":" + str(client_port) + " successfully logged in") return session_manager.create_session() # Else, access is still not granted. if not success: - # TODO: Log - print "Credentials invalid... refusing session." + LOG.debug(client_host + ":" + str(client_port) + " Invalid access, credentials not found - session refused.") return None return success @@ -177,7 +172,7 @@ def do_POST(self): # 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 - print "Failed authentication!" + LOG.debug(client_host + ":" + str(client_port) + " Invalid access, credentials not found - session refused.") errormsg = """Access requires valid credentials.""" self.send_response(401) self.send_header("Content-type", "text/plain") From 24f05d9a4147c9f2aff88913a850699501657a4e Mon Sep 17 00:00:00 2001 From: Whisperity Date: Fri, 30 Sep 2016 14:06:07 +0200 Subject: [PATCH 03/13] Better authentication with less config reads and session storage --- codechecker_lib/session_manager.py | 77 ++++++++++++++++-------- config/session_config.json | 4 +- docs/authentication.md | 15 +++-- viewer_server/client_auth_handler.py | 15 +++-- viewer_server/client_db_access_server.py | 37 ++++++------ 5 files changed, 90 insertions(+), 58 deletions(-) diff --git a/codechecker_lib/session_manager.py b/codechecker_lib/session_manager.py index 99ddc1c99e..443391e62c 100644 --- a/codechecker_lib/session_manager.py +++ b/codechecker_lib/session_manager.py @@ -22,24 +22,22 @@ # ----------- SERVER ----------- class sessionManager: - valid_sessions = [] - @staticmethod - def validate(sessToken): - sessionManager.valid_sessions.append(sessToken) + class __Session(): + def __init__(self, client_addr, token): + self.client = client_addr + self.token = token - @staticmethod - def invalidate(sessToken): - if sessToken in sessionManager.valid_sessions: - sessionManager.valid_sessions.remove(sessToken) - @staticmethod - def isValid(sessToken): - if not sessionManager().isEnabled(): - # If authentication is disabled, any kind of session token is valid. + def still_valid(self): + # TODO: This. return True - return sessToken in sessionManager.valid_sessions + def revalidate(self): + # TODO: This + return self.still_valid() + + __valid_sessions = [] def __init__(self): LOG.debug('Loading session config') @@ -58,22 +56,51 @@ def __init__(self): 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"), "cookie": session_cookie_name } + + def __handle_validation(self, auth_string): + '''Validate an oncoming authorization request against some authority controller''' + # TODO: This + return True + + 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 self.__handle_validation(auth_string): + session_already = next((s for s in sessionManager.__valid_sessions if s.client == client and s.still_valid()), None) + if session_already: + session_already.revalidate() + session = session_already + else: + # TODO: More secure way for token generation? + token = uuid.UUID(bytes=os.urandom(16)).__str__() + session = sessionManager.__Session(client, token) + sessionManager.__valid_sessions.append(session) + + return session.token + else: + return None + def is_valid(self, client, token): + '''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() + for _sess in sessionManager.__valid_sessions) -def validate_session_token(token): - '''Validates a given token (cookie) against the known list of privileged sessions''' - return sessionManager.isValid(token) - -def validate_auth_request(authString): - '''Validate an oncoming authorization request against some authority controller''' - return authString == "cc:valid" + 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 -def create_session(): - # TODO: More secure way for token generation? - token = uuid.UUID(bytes = os.urandom(16)).__str__() - sessionManager.validate(token) + return False - return token # ----------- CLIENT ----------- class sessionManager_Client: diff --git a/config/session_config.json b/config/session_config.json index ec5a9cdece..252296eb75 100644 --- a/config/session_config.json +++ b/config/session_config.json @@ -1,7 +1,9 @@ { "authentication": { "client_autologin" : true, - "enabled" : true + "enabled" : true, + "realm_name" : "CodeChecker Privileged server", + "realm_error": "Access requires valid credentials." }, "credentials": { "*" : "global:admin", diff --git a/docs/authentication.md b/docs/authentication.md index dd9215b707..81b0eefbc8 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,12 +1,19 @@ CodeChecker authentication subsytem =================================== - + CodeChecker also supports only allowing a privileged set of users to access the results stored on a server. Authentication configuration is stored in the `config/session_config.json` file, both for the client and the serverside. - + ## Serverside configuration - -> TBD + +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 ## Clientside configuration diff --git a/viewer_server/client_auth_handler.py b/viewer_server/client_auth_handler.py index 9d8aecba0c..38469fa006 100644 --- a/viewer_server/client_auth_handler.py +++ b/viewer_server/client_auth_handler.py @@ -71,13 +71,14 @@ class ThriftAuthHandler(): Handle Thrift authentication requests ''' - def __init__(self, session_token = None): + 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): - sessions = session_manager.sessionManager() - return HandshakeInformation(sessions.isEnabled(), session_manager.validate_session_token(self.__session_token)) + return HandshakeInformation(self.__manager.isEnabled(), self.__manager.is_valid(self.__client_host, self.__session_token)) @timefunc def getAcceptedAuthMethods(self): @@ -88,8 +89,8 @@ def getAcceptedAuthMethods(self): @timefunc def performLogin(self, auth_method, auth_string): if auth_method == "Username:Password": - if session_manager.validate_auth_request(auth_string): - authToken = session_manager.create_session() + authToken = self.__manager.create_or_get_session(self.__client_host, auth_string) + if authToken: return authToken else: raise shared.ttypes.RequestFailed( @@ -102,6 +103,4 @@ def performLogin(self, auth_method, auth_string): @timefunc def destroySession(self): - # TODO: Only let users to validate/invalidate their own tokens... :D - session_manager.sessionManager.invalidate(self.__session_token) - return True + 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 76bd0a4fcd..f33bebbac9 100644 --- a/viewer_server/client_db_access_server.py +++ b/viewer_server/client_db_access_server.py @@ -31,8 +31,6 @@ from thrift.protocol import TJSONProtocol import shared - - from codeCheckerDBAccess import codeCheckerDBAccess from codeCheckerDBAccess import constants from codeCheckerDBAccess.ttypes import * @@ -47,8 +45,6 @@ from codechecker_lib import database_handler from codechecker_lib import session_manager -import base64 - LOG = logger.get_new_logger('DB ACCESS') @@ -58,13 +54,11 @@ class RequestHandler(SimpleHTTPRequestHandler): Simply modified and extended version of SimpleHTTPRequestHandler """ - cookie_name = "_ccAccessPrivileged" - cookie_str = "YEP!" - 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, @@ -80,7 +74,7 @@ def check_auth_in_request(self): Wrapper to handle authentication needs from both GET and POST requests. """ - if not session_manager.sessionManager().isEnabled(): + if not self.manager.isEnabled(): return True success = False @@ -98,9 +92,8 @@ def check_auth_in_request(self): for cookie in split: print cookie values = cookie.split("=") - if len(values) == 2 and values[0] == session_manager.session_cookie_name: - - if session_manager.validate_session_token(values[1]): + if len(values) == 2 and values[0] == self.manager.getRealm()["cookie"]: + if self.manager.is_valid(client_host, values[1]): # The session cookie contains valid data. success = values[1] @@ -112,9 +105,10 @@ def check_auth_in_request(self): LOG.info("Client from " + client_host + ":" + str(client_port) + " attempted authorization.") authString = base64.decodestring(self.headers.getheader("Authorization").replace("Basic ", "")) - if session_manager.validate_auth_request(authString): + 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 session_manager.create_session() + return token # Else, access is still not granted. if not success: @@ -128,18 +122,18 @@ def do_GET(self): if authToken: self.send_response(200) if authToken != True: - self.send_header("Set-Cookie", session_manager.session_cookie_name + "=" + authToken + "; Path=/") + self.send_header("Set-Cookie", self.manager.getRealm()["cookie"] + "=" + authToken + "; Path=/") SimpleHTTPRequestHandler.do_GET(self) else: print "Failed authentication." errormsg = """Access requires valid credentials.""" self.send_response(401) - self.send_header("WWW-Authenticate", 'Basic realm="test"') + 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(errormsg))) + self.send_header("Content-length", str(len(self.manager.getRealm()["error"]))) self.send_header('Connection', 'close') self.end_headers() - self.wfile.write(errormsg) + self.wfile.write(self.manager.getRealm()["error"]) def do_POST(self): """ Handling thrift messages. """ @@ -192,7 +186,7 @@ def do_POST(self): if self.path == '/Authentication': # Authentication requests must be routed to a different handler - auth_handler = ThriftAuthHandler(sess_token) + auth_handler = ThriftAuthHandler(self.manager, client_host, sess_token) processor = codeCheckerAuthentication.Processor(auth_handler) else: acc_handler = ThriftRequestHandler(session, @@ -259,7 +253,8 @@ def __init__(self, db_conn_string, pckg_data, suppress_handler, - db_version_info): + db_version_info, + manager): LOG.debug('Initializing HTTP server') @@ -274,6 +269,7 @@ def __init__(self, Session = scoped_session(sessionmaker()) Session.configure(bind=self.__engine) self.sc_session = Session + self.manager = manager self.__request_handlers = ThreadPool(processes=10) @@ -323,7 +319,8 @@ def start_server(package_data, port, db_conn_string, suppress_handler, 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) + ']') From 6b76a604e0058df799b759a7306dbfa424a6f5d3 Mon Sep 17 00:00:00 2001 From: Whisperity Date: Fri, 30 Sep 2016 14:27:50 +0200 Subject: [PATCH 04/13] Increase security by preventing session bleedover with clients from behind a NAT --- codechecker_lib/session_manager.py | 43 +++++++++++++++++++++--------- 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/codechecker_lib/session_manager.py b/codechecker_lib/session_manager.py index 443391e62c..33c3f1eeee 100644 --- a/codechecker_lib/session_manager.py +++ b/codechecker_lib/session_manager.py @@ -11,6 +11,8 @@ import os import uuid import json +import hashlib +import time from codechecker_lib import logger @@ -21,22 +23,32 @@ # ----------- SERVER ----------- -class sessionManager: +class _Session(): + # 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() - class __Session(): - def __init__(self, client_addr, token): - self.client = client_addr - self.token = token + @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 - def still_valid(self): - # TODO: This. - return True - def revalidate(self): - # TODO: This - return self.still_valid() + def still_valid(self): + # TODO: This. + return True + + def revalidate(self): + # TODO: This + return self.still_valid() + +class sessionManager: __valid_sessions = [] def __init__(self): @@ -68,14 +80,19 @@ 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 self.__handle_validation(auth_string): - session_already = next((s for s in sessionManager.__valid_sessions if s.client == client and s.still_valid()), None) + session_already = next((s for s in sessionManager.__valid_sessions if s.client == client + and s.still_valid() + and s.persistent_hash == + _Session.calc_persistency_hash(client, auth_string) + ), None) + if session_already: session_already.revalidate() session = session_already else: # TODO: More secure way for token generation? token = uuid.UUID(bytes=os.urandom(16)).__str__() - session = sessionManager.__Session(client, token) + session = _Session(client, token, _Session.calc_persistency_hash(client, auth_string)) sessionManager.__valid_sessions.append(session) return session.token From b86f406a041c481812b11423ef72d8e64af7ab1c Mon Sep 17 00:00:00 2001 From: Whisperity Date: Fri, 30 Sep 2016 15:24:23 +0200 Subject: [PATCH 05/13] Move configuration file to user's HOME folder and implement dictionary-based authentication --- codechecker_lib/session_manager.py | 30 ++++++++++++++++++++++++----- config/session_client.json | 13 +++++++++++++ config/session_config.json | 19 ++++++++---------- docs/authentication.md | 31 +++++++++++++++++++++++++++--- 4 files changed, 74 insertions(+), 19 deletions(-) create mode 100644 config/session_client.json diff --git a/codechecker_lib/session_manager.py b/codechecker_lib/session_manager.py index 33c3f1eeee..032d6aaeb1 100644 --- a/codechecker_lib/session_manager.py +++ b/codechecker_lib/session_manager.py @@ -9,6 +9,7 @@ ''' import os +import shutil import uuid import json import hashlib @@ -65,6 +66,12 @@ def __init__(self): self.__auth_config = scfg_dict["authentication"] print self + # If no methods are configured as enabled, disable authentication + if scfg_dict["authentication"].get("enabled")\ + and ("method_dictionary" in self.__auth_config and not self.__auth_config["method_dictionary"].get("enabled")): + LOG.warning("Authentication is enabled but no valid authentication backends are configured... Falling back to no authentication.") + self.__auth_config["enabled"] = False + def isEnabled(self): return self.__auth_config.get("enabled") @@ -74,7 +81,14 @@ def getRealm(self): def __handle_validation(self, auth_string): '''Validate an oncoming authorization request against some authority controller''' # TODO: This - return True + + return self.__try_auth_dictionary(auth_string) + + def __try_auth_dictionary(self, auth_string): + if "method_dictionary" in self.__auth_config and self.__auth_config["method_dictionary"].get("enabled"): + return auth_string in self.__auth_config.get("method_dictionary").get("auths") + + 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. @@ -124,7 +138,14 @@ class sessionManager_Client: def __init__(self): LOG.debug('Loading session config') - session_cfg_file = os.path.join(os.environ['CC_PACKAGE_ROOT'], "config", "session_config.json") + # Check for user's configuration to exist + if not os.path.exists(os.path.join(os.path.expanduser("~"), ".codechecker_passwords.json")): + print('CodeChecker authentication client\'s example configuration file created at ' + + os.path.join(os.path.expanduser("~"), ".codechecker_passwords.json")) + shutil.copyfile(os.path.join(os.environ['CC_PACKAGE_ROOT'], "config", "session_client.json"), + os.path.join(os.path.expanduser("~"), ".codechecker_passwords.json")) + + session_cfg_file = os.path.join(os.path.expanduser("~"), ".codechecker_passwords.json") LOG.debug(session_cfg_file) with open(session_cfg_file, 'r') as scfg: scfg_dict = json.loads(scfg.read()) @@ -135,8 +156,7 @@ def __init__(self): scfg_dict["tokens"] = {} self.__save = scfg_dict - self.__autologin = scfg_dict["authentication"].get("client_autologin") if "client_autologin" in scfg_dict["authentication"] else True - print self + self.__autologin = scfg_dict.get("client_autologin") if "client_autologin" in scfg_dict else True def is_autologin_enabled(self): return self.__autologin @@ -161,6 +181,6 @@ def saveToken(self, host, port, token, destroy = False): else: del self.__save["tokens"][host + ":" + port] - session_cfg_file = os.path.join(os.environ['CC_PACKAGE_ROOT'], "config", "session_config.json") + session_cfg_file = os.path.join(os.path.expanduser("~"), ".codechecker_passwords.json") with open(session_cfg_file, 'w') as scfg: json.dump(self.__save, scfg, indent = 2, sort_keys = True) \ No newline at end of file diff --git a/config/session_client.json b/config/session_client.json new file mode 100644 index 0000000000..9710dfef32 --- /dev/null +++ b/config/session_client.json @@ -0,0 +1,13 @@ +{ + "client_autologin" : true, + "credentials": { + "*" : "global:admin", + "*:6251" : "cc:valid", + "127.0.0.1" : "cc:bad", + "localhost" : "global:admin", + "localhost:6252" : "cc:invalid" + }, + "tokens": { + "localhost:6251" : "dummy_token" + } +} \ No newline at end of file diff --git a/config/session_config.json b/config/session_config.json index 252296eb75..8b38d02f38 100644 --- a/config/session_config.json +++ b/config/session_config.json @@ -1,17 +1,14 @@ { "authentication": { - "client_autologin" : true, "enabled" : true, "realm_name" : "CodeChecker Privileged server", - "realm_error": "Access requires valid credentials." - }, - "credentials": { - "*" : "global:admin", - "*:6251" : "super:secret", - "localhost" : "cc:bad", - "localhost:6252" : "cc:invalid" - }, - "tokens": { - "localhost:6251" : "dummy_token" + "realm_error": "Access requires valid credentials.", + + "method_dictionary": { + "enabled" : false, + "auths" : [ + "global:admin", "test:test" + ] + } } } diff --git a/docs/authentication.md b/docs/authentication.md index 81b0eefbc8..0cc3f82aa9 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -2,7 +2,8 @@ CodeChecker authentication subsytem =================================== CodeChecker also supports only allowing a privileged set of users to access the results stored on a server. -Authentication configuration is stored in the `config/session_config.json` file, both for the client and the serverside. +Authentication configuration is stored in the `config/session_config.json` file for the server and a template in `config/session_client.json` for the client. +The user's own configuration is copied by the client – at first launch – to `~/.codechecker_passwords.json`. ## Serverside configuration @@ -15,6 +16,26 @@ The `authentication` section of the config file controls how authentication is h * `realm_error` The error message shown in the browser when the user fails to authenticate +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. + +### *Dictionary* authentication + +The `authentication.method_dictionary` contains a plaintext username-password configuration for authentication. + +```json +"method_dictionary": { + "enabled" : true, + "auths" : [ + "global:admin", "test:test" + ] +} +``` + +---- + ## Clientside configuration ### Web-browser client @@ -53,7 +74,7 @@ Privileged session expire after a set amount of time. To log out manually, issue 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 `config/session_config.json`. The `credentials` section is used by the client to read pre-saved authentication data in `username:password` format. +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": { @@ -75,4 +96,8 @@ Credentials are matched for any particular server at login in the following orde 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`. \ No newline at end of file +This behaviour can be disabled by setting `client_autologin` to `false`. + +#### Currently active tokens + +The configuration file's `tokens` section contains the user's currently active sessions' tokens. This is not meant to be edited by hand, the client manages this section. \ No newline at end of file From b51c37ec860e79f752055a794b9218f3a38dbb8c Mon Sep 17 00:00:00 2001 From: Whisperity Date: Fri, 30 Sep 2016 16:33:25 +0200 Subject: [PATCH 06/13] Configurable session soft and hard timeout, and prune session after N logins to prevent memory leaks --- codechecker_lib/session_manager.py | 210 +++++++++++++----- config/session_config.json | 9 +- docs/authentication.md | 19 +- .../cmdline_client/authentication_helper.py | 5 +- .../cmdline_client/cmd_line_client.py | 45 +++- .../cmdline_client/thrift_helper.py | 3 +- viewer_server/client_auth_handler.py | 2 +- viewer_server/client_db_access_server.py | 53 +++-- 8 files changed, 256 insertions(+), 90 deletions(-) diff --git a/codechecker_lib/session_manager.py b/codechecker_lib/session_manager.py index 032d6aaeb1..40e9e731e4 100644 --- a/codechecker_lib/session_manager.py +++ b/codechecker_lib/session_manager.py @@ -14,48 +14,87 @@ import json import hashlib import time +import ldap +import ldap.sasl +from datetime import datetime from codechecker_lib import logger LOG = logger.get_new_logger("SESSION MANAGER") - -session_cookie_name = "__ccPrivilegedAccessToken" +SESSION_COOKIE_NAME = "__ccPrivilegedAccessToken" +session_lifetimes = {} +# ------------------------------ # ----------- SERVER ----------- - class _Session(): - # 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() + # 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() + '''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_valid(self): - # TODO: This. - return True + 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): - # TODO: This - return self.still_valid() + 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: __valid_sessions = [] + __logins_since_prune = 0 def __init__(self): LOG.debug('Loading session config') - session_cfg_file = os.path.join(os.environ['CC_PACKAGE_ROOT'], "config", "session_config.json") + session_cfg_file = os.path.join(os.environ['CC_PACKAGE_ROOT'], + "config", "session_config.json") LOG.debug(session_cfg_file) with open(session_cfg_file, 'r') as scfg: scfg_dict = json.loads(scfg.read()) @@ -64,63 +103,123 @@ def __init__(self): scfg_dict["authentication"] = {'enabled': False} self.__auth_config = scfg_dict["authentication"] - print self # If no methods are configured as enabled, disable authentication - if scfg_dict["authentication"].get("enabled")\ - and ("method_dictionary" in self.__auth_config and not self.__auth_config["method_dictionary"].get("enabled")): - LOG.warning("Authentication is enabled but no valid authentication backends are configured... Falling back to no authentication.") + if scfg_dict["authentication"].get("enabled") \ + and ("method_dictionary" in self.__auth_config and not + self.__auth_config["method_dictionary"].get("enabled")) \ + and ("method_ldap" in self.__auth_config and not + self.__auth_config["method_ldap"].get("enabled")): + 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"), "cookie": session_cookie_name } + 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''' - # TODO: This - - return self.__try_auth_dictionary(auth_string) + '''Validate an oncoming authorization request + against some authority controller''' + return self.__try_auth_dictionary(auth_string) \ + or self.__try_auth_ldap(auth_string) def __try_auth_dictionary(self, auth_string): - if "method_dictionary" in self.__auth_config and self.__auth_config["method_dictionary"].get("enabled"): - return auth_string in self.__auth_config.get("method_dictionary").get("auths") + if "method_dictionary" in self.__auth_config and \ + self.__auth_config["method_dictionary"].get("enabled"): + return auth_string in \ + self.__auth_config.get("method_dictionary").get("auths") return False + def __try_auth_ldap(self, auth_string): + if "method_ldap" in self.__auth_config and \ + self.__auth_config["method_ldap"].get("enabled"): + 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.''' + '''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_valid() - and s.persistent_hash == - _Session.calc_persistency_hash(client, auth_string) - ), None) + 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: More secure way for token generation? - token = uuid.UUID(bytes=os.urandom(16)).__str__() - session = _Session(client, token, _Session.calc_persistency_hash(client, auth_string)) + # 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): - '''Validates a given token (cookie) against the known list of privileged sessions''' + 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() + and _sess.token == token + and _sess.still_valid(access) for _sess in sessionManager.__valid_sessions) def invalidate(self, client, token): @@ -132,20 +231,29 @@ def invalidate(self, client, token): return False + def __cleanup_sessions(self): + for session in sessionManager.__valid_sessions[:]: + if not session.still_reusable(): + sessionManager.__valid_sessions.remove(session) + self.__logins_since_prune = 0 + +# ------------------------------ # ----------- CLIENT ----------- class sessionManager_Client: def __init__(self): LOG.debug('Loading session config') # Check for user's configuration to exist - if not os.path.exists(os.path.join(os.path.expanduser("~"), ".codechecker_passwords.json")): - print('CodeChecker authentication client\'s example configuration file created at ' + - os.path.join(os.path.expanduser("~"), ".codechecker_passwords.json")) - shutil.copyfile(os.path.join(os.environ['CC_PACKAGE_ROOT'], "config", "session_client.json"), - os.path.join(os.path.expanduser("~"), ".codechecker_passwords.json")) + session_cfg_file = os.path.join(os.path.expanduser("~"), + ".codechecker_passwords.json") + if not os.path.exists(session_cfg_file): + print("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) - session_cfg_file = os.path.join(os.path.expanduser("~"), ".codechecker_passwords.json") LOG.debug(session_cfg_file) with open(session_cfg_file, 'r') as scfg: scfg_dict = json.loads(scfg.read()) @@ -156,7 +264,8 @@ def __init__(self): scfg_dict["tokens"] = {} 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") \ + if "client_autologin" in scfg_dict else True def is_autologin_enabled(self): return self.__autologin @@ -175,12 +284,13 @@ def getAuthString(self, host, port): return ret - def saveToken(self, host, port, token, destroy = False): + def saveToken(self, host, port, token, destroy=False): if not destroy: self.__save["tokens"][host + ":" + port] = token else: del self.__save["tokens"][host + ":" + port] - session_cfg_file = os.path.join(os.path.expanduser("~"), ".codechecker_passwords.json") + session_cfg_file = os.path.join(os.path.expanduser("~"), + ".codechecker_passwords.json") with open(session_cfg_file, 'w') as scfg: - json.dump(self.__save, scfg, indent = 2, sort_keys = True) \ No newline at end of file + json.dump(self.__save, scfg, indent=2, sort_keys=True) diff --git a/config/session_config.json b/config/session_config.json index 8b38d02f38..807719c2a0 100644 --- a/config/session_config.json +++ b/config/session_config.json @@ -2,13 +2,18 @@ "authentication": { "enabled" : true, "realm_name" : "CodeChecker Privileged server", - "realm_error": "Access requires valid credentials.", - + "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 } } } diff --git a/docs/authentication.md b/docs/authentication.md index 0cc3f82aa9..d17aec2e32 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -15,6 +15,15 @@ The `authentication` section of the config file controls how authentication is h 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. @@ -23,17 +32,23 @@ Users are authenticated if **any** authentication method successfully authentica ### *Dictionary* authentication -The `authentication.method_dictionary` contains a plaintext username-password configuration for 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" + "global:admin", + "test:test" ] } ``` +### `LDAP` authentication + +> TBD + ---- ## Clientside configuration diff --git a/viewer_clients/cmdline_client/authentication_helper.py b/viewer_clients/cmdline_client/authentication_helper.py index 7e8c09b54b..9e0b366400 100644 --- a/viewer_clients/cmdline_client/authentication_helper.py +++ b/viewer_clients/cmdline_client/authentication_helper.py @@ -30,7 +30,8 @@ def __init__(self, host, port, uri, session_token=None): self.client = codeCheckerAuthentication.Client(self.protocol) if session_token: - headers = {'Cookie': session_manager.session_cookie_name + "=" + session_token} + headers = {'Cookie': session_manager.SESSION_COOKIE_NAME + + "=" + session_token} self.transport.setCustomHeaders(headers) # ------------------------------------------------------------ @@ -97,5 +98,3 @@ def performLogin(self, auth_method, auth_string): @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 b1629b3a3b..b48f45436d 100644 --- a/viewer_clients/cmdline_client/cmd_line_client.py +++ b/viewer_clients/cmdline_client/cmd_line_client.py @@ -39,7 +39,12 @@ def default(self, obj): 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)) + auth_client = authentication_helper.ThriftAuthHelper(args.host, + args.port, + '/Authentication', + session.getToken( + args.host, + args.port)) handshake = auth_client.getAuthParameters() if not handshake.requiresAuthentication: @@ -48,7 +53,8 @@ def handle_auth_requests(args): if args.logout: if args.username or args.password: - print('ERROR! Do not supply username and password with `--logout` command.') + print('ERROR! Do not supply username and password ' + 'with `--logout` command.') sys.exit(1) logout_done = auth_client.destroySession() @@ -71,11 +77,14 @@ def handle_auth_requests(args): args.username = savedAuth.split(":")[0] args.password = savedAuth.split(":")[1] else: - print('Can not authenticate with username and password if it is not specified...') + 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_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: @@ -84,7 +93,8 @@ def handle_auth_requests(args): def __check_authentication(client): - '''communicate with the authentication server to handle authentication requests''' + """Communicate with the authentication server + to handle authentication requests.""" result = client.getAuthParameters() if result.sessionStillActive: @@ -93,22 +103,32 @@ def __check_authentication(client): return False def setupClient(host, port, uri): - ''' setup the thrift client and check API version and authentication needs''' + ''' 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) + # 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: + 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 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) + 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: @@ -118,7 +138,8 @@ def setupClient(host, port, uri): if print_err: print('Access denied. This server requires authentication.') - print('Please log in onto the server using `CodeChecker cmd login`') + print('Please log in onto the server ' + 'using `CodeChecker cmd login`') sys.exit(1) client = thrift_helper.ThriftClientHelper(host, port, uri, session_token) diff --git a/viewer_clients/cmdline_client/thrift_helper.py b/viewer_clients/cmdline_client/thrift_helper.py index a8a91570e8..b8c3fbb5e2 100644 --- a/viewer_clients/cmdline_client/thrift_helper.py +++ b/viewer_clients/cmdline_client/thrift_helper.py @@ -29,7 +29,8 @@ def __init__(self, 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_manager.SESSION_COOKIE_NAME + + "=" + session_token} self.transport.setCustomHeaders(headers) # ------------------------------------------------------------ diff --git a/viewer_server/client_auth_handler.py b/viewer_server/client_auth_handler.py index 38469fa006..0263b9050e 100644 --- a/viewer_server/client_auth_handler.py +++ b/viewer_server/client_auth_handler.py @@ -78,7 +78,7 @@ def __init__(self, manager, client_host, session_token = None): @timefunc def getAuthParameters(self): - return HandshakeInformation(self.__manager.isEnabled(), self.__manager.is_valid(self.__client_host, self.__session_token)) + return HandshakeInformation(self.__manager.isEnabled(), self.__manager.is_valid(self.__client_host, self.__session_token, True)) @timefunc def getAcceptedAuthMethods(self): diff --git a/viewer_server/client_db_access_server.py b/viewer_server/client_db_access_server.py index f33bebbac9..36af9f9ddb 100644 --- a/viewer_server/client_db_access_server.py +++ b/viewer_server/client_db_access_server.py @@ -92,8 +92,9 @@ def check_auth_in_request(self): for cookie in split: print cookie values = cookie.split("=") - if len(values) == 2 and values[0] == self.manager.getRealm()["cookie"]: - if self.manager.is_valid(client_host, values[1]): + 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] @@ -102,17 +103,24 @@ def check_auth_in_request(self): # 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) + 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") + 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.") + LOG.debug(client_host + ":" + str(client_port) + + " Invalid access, credentials not found " + + "- session refused.") return None return success @@ -121,16 +129,18 @@ def do_GET(self): authToken = self.check_auth_in_request() if authToken: self.send_response(200) - if authToken != True: - self.send_header("Set-Cookie", self.manager.getRealm()["cookie"] + "=" + authToken + "; Path=/") + if authToken: + self.send_header("Set-Cookie", + session_manager.SESSION_COOKIE_NAME + "=" + + authToken + "; Path=/") SimpleHTTPRequestHandler.do_GET(self) else: - print "Failed authentication." - errormsg = """Access requires valid credentials.""" self.send_response(401) - self.send_header("WWW-Authenticate", 'Basic realm="' + self.manager.getRealm()["realm"] + '"') + 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("Content-length", str(len( + self.manager.getRealm()["error"]))) self.send_header('Connection', 'close') self.end_headers() self.wfile.write(self.manager.getRealm()["error"]) @@ -166,11 +176,13 @@ def do_POST(self): # 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.") - errormsg = """Access requires valid credentials.""" + + 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(len(errormsg))) + self.send_header("Content-length", str(0)) self.end_headers() return @@ -186,7 +198,9 @@ def do_POST(self): if self.path == '/Authentication': # Authentication requests must be routed to a different handler - auth_handler = ThriftAuthHandler(self.manager, client_host, sess_token) + auth_handler = ThriftAuthHandler(self.manager, + client_host, + sess_token) processor = codeCheckerAuthentication.Processor(auth_handler) else: acc_handler = ThriftRequestHandler(session, @@ -264,7 +278,8 @@ 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) From cbef8cfa02f4759c84ff9be1987e3b11ec50efca Mon Sep 17 00:00:00 2001 From: Whisperity Date: Fri, 30 Sep 2016 16:58:08 +0200 Subject: [PATCH 07/13] Made code pep8-compliant --- build_package.py | 2 +- codechecker_lib/session_manager.py | 4 ++-- viewer_clients/cmdline_client/cmd_line_client.py | 1 - viewer_clients/cmdline_client/thrift_helper.py | 2 +- viewer_server/client_auth_handler.py | 15 +++++++++++---- viewer_server/client_db_access_server.py | 2 +- 6 files changed, 16 insertions(+), 10 deletions(-) diff --git a/build_package.py b/build_package.py index 770c801ea2..82d8cbeee6 100755 --- a/build_package.py +++ b/build_package.py @@ -114,7 +114,7 @@ def generate_thrift_files(thrift_files_dir, env, silent=True): auth_thrift = os.path.join(thrift_files_dir, 'authentication.thrift') auth_thrift = 'authentication.thrift' auth_cmd = ['thrift', '-r', '-I', '.', - '--gen', 'py', auth_thrift] + '--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') diff --git a/codechecker_lib/session_manager.py b/codechecker_lib/session_manager.py index 40e9e731e4..69bb3fbbd2 100644 --- a/codechecker_lib/session_manager.py +++ b/codechecker_lib/session_manager.py @@ -176,8 +176,8 @@ def __try_auth_ldap(self, auth_string): 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.''' + """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 diff --git a/viewer_clients/cmdline_client/cmd_line_client.py b/viewer_clients/cmdline_client/cmd_line_client.py index b48f45436d..a06a79e84e 100644 --- a/viewer_clients/cmdline_client/cmd_line_client.py +++ b/viewer_clients/cmdline_client/cmd_line_client.py @@ -65,7 +65,6 @@ def handle_auth_requests(args): return methods = auth_client.getAcceptedAuthMethods() - # Attempt username-password auth first if 'Username:Password' in str(methods): if not args.username or not args.password: diff --git a/viewer_clients/cmdline_client/thrift_helper.py b/viewer_clients/cmdline_client/thrift_helper.py index b8c3fbb5e2..6834632da2 100644 --- a/viewer_clients/cmdline_client/thrift_helper.py +++ b/viewer_clients/cmdline_client/thrift_helper.py @@ -21,7 +21,7 @@ class ThriftClientHelper(): - def __init__(self, host, port, uri, session_token = None): + def __init__(self, host, port, uri, session_token=None): self.__host = host self.__port = port self.transport = THttpClient.THttpClient(self.__host, self.__port, uri) diff --git a/viewer_server/client_auth_handler.py b/viewer_server/client_auth_handler.py index 0263b9050e..b1ee50dae1 100644 --- a/viewer_server/client_auth_handler.py +++ b/viewer_server/client_auth_handler.py @@ -71,14 +71,18 @@ class ThriftAuthHandler(): Handle Thrift authentication requests ''' - def __init__(self, manager, client_host, session_token = None): + 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)) + return HandshakeInformation(self.__manager.isEnabled(), + self.__manager.is_valid( + self.__client_host, + self.__session_token, + True)) @timefunc def getAcceptedAuthMethods(self): @@ -89,7 +93,9 @@ def getAcceptedAuthMethods(self): @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) + authToken = self.__manager.create_or_get_session( + self.__client_host, + auth_string) if authToken: return authToken else: @@ -103,4 +109,5 @@ def performLogin(self, auth_method, auth_string): @timefunc def destroySession(self): - return self.__manager.invalidate(self.__client_host, self.__session_token) + 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 36af9f9ddb..badd181bd9 100644 --- a/viewer_server/client_db_access_server.py +++ b/viewer_server/client_db_access_server.py @@ -15,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 From 3d798130e1ae214a67aa066b5c36f59497a912e9 Mon Sep 17 00:00:00 2001 From: Whisperity Date: Mon, 3 Oct 2016 15:53:05 +0200 Subject: [PATCH 08/13] LDAP authentication and configuration --- .ci/basic_python_requirements | 1 + .ci/python_requirements | 1 + README.md | 2 +- config/session_config.json | 10 +++++++++- docs/authentication.md | 33 ++++++++++++++++++++++++++++++--- 5 files changed, 42 insertions(+), 5 deletions(-) diff --git a/.ci/basic_python_requirements b/.ci/basic_python_requirements index 7fd633583e..19ec634fb2 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 +python-ldap==2.4.22.0 diff --git a/.ci/python_requirements b/.ci/python_requirements index d954fb6f7d..faf00cdd82 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 +python-ldap==2.4.22.0 diff --git a/README.md b/README.md index f7f5cbcf79..3f39971eba 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Tested on Ubuntu LTS 14.04.2 # get ubuntu packages # clang-3.6 can be replaced by any later versions of clang -sudo apt-get install clang-3.6 doxygen build-essential thrift-compiler python-virtualenv gcc-multilib git wget +sudo apt-get install clang-3.6 doxygen build-essential thrift-compiler python-virtualenv gcc-multilib git wget libldap2-dev libsasl2-dev libssl-dev # create new python virtualenv virtualenv -p /usr/bin/python2.7 ~/checker_env diff --git a/config/session_config.json b/config/session_config.json index 807719c2a0..7b776e0f6d 100644 --- a/config/session_config.json +++ b/config/session_config.json @@ -13,7 +13,15 @@ ] }, "method_ldap": { - "enabled" : false + "enabled" : false, + "authorities": [ + { + "connection_url": "ldap://ldap.example.org", + "queries": [ + "uid=$USN$,ou=admins,o=mycompany" + ] + } + ] } } } diff --git a/docs/authentication.md b/docs/authentication.md index d17aec2e32..141a74bdb9 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -28,7 +28,7 @@ The `authentication` section of the config file controls how authentication is h 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. +Users are authenticated if **any** authentication method successfully authenticates them. If both methods are enabled, *dictionary* authentication takes precedence. ### *Dictionary* authentication @@ -45,9 +45,36 @@ If the user's login matches any of the credentials listed, the user will be auth } ``` -### `LDAP` authentication +### *LDAP* authentication -> TBD +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" + ] + } + ] +} +``` ---- From 278121563787c780d74cfaba2b1c81b8fc206ef9 Mon Sep 17 00:00:00 2001 From: Whisperity Date: Mon, 3 Oct 2016 18:37:13 +0200 Subject: [PATCH 09/13] Tests for privileged access --- config/session_client.json | 7 +- config/session_config.json | 2 +- run_tests.sh | 2 +- tests/functional/package_test/__init__.py | 667 +++++++++--------- .../package_test/test_authentication.py | 94 +++ tests/test_utils/thrift_client_to_db.py | 30 +- viewer_server/client_db_access_server.py | 2 +- 7 files changed, 481 insertions(+), 323 deletions(-) create mode 100644 tests/functional/package_test/test_authentication.py diff --git a/config/session_client.json b/config/session_client.json index 9710dfef32..8e920aeaa2 100644 --- a/config/session_client.json +++ b/config/session_client.json @@ -2,12 +2,9 @@ "client_autologin" : true, "credentials": { "*" : "global:admin", - "*:6251" : "cc:valid", - "127.0.0.1" : "cc:bad", - "localhost" : "global:admin", - "localhost:6252" : "cc:invalid" + "localhost:14444" : "test:test" }, "tokens": { - "localhost:6251" : "dummy_token" + "localhost:14444" : "dummy_token" } } \ No newline at end of file diff --git a/config/session_config.json b/config/session_config.json index 7b776e0f6d..29c3a5d0fd 100644 --- a/config/session_config.json +++ b/config/session_config.json @@ -1,6 +1,6 @@ { "authentication": { - "enabled" : true, + "enabled" : false, "realm_name" : "CodeChecker Privileged server", "realm_error" : "Access requires valid credentials.", "soft_expire" : 60, 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..84844ee89e 100644 --- a/tests/functional/package_test/__init__.py +++ b/tests/functional/package_test/__init__.py @@ -1,314 +1,353 @@ -# 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 + 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_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"] + + with open(session_cfg_file, 'w') as scfg: + json.dump(scfg_dict, scfg, indent=2, sort_keys=True) + + 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 + 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/viewer_server/client_db_access_server.py b/viewer_server/client_db_access_server.py index badd181bd9..6c9c00c174 100644 --- a/viewer_server/client_db_access_server.py +++ b/viewer_server/client_db_access_server.py @@ -129,7 +129,7 @@ def do_GET(self): authToken = self.check_auth_in_request() if authToken: self.send_response(200) - if authToken: + if isinstance(authToken, str): self.send_header("Set-Cookie", session_manager.SESSION_COOKIE_NAME + "=" + authToken + "; Path=/") From 52b5a81716d515bf075b61fe152fb8681901c3f4 Mon Sep 17 00:00:00 2001 From: Whisperity Date: Wed, 5 Oct 2016 13:28:45 +0200 Subject: [PATCH 10/13] Make authentication dependencies optional and handle missing ones with grace --- README.md | 2 +- codechecker_lib/session_manager.py | 166 ++++++++++++++-------- docs/authentication.md | 34 ++++- tests/functional/package_test/__init__.py | 22 +-- 4 files changed, 152 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 3f39971eba..f7f5cbcf79 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Tested on Ubuntu LTS 14.04.2 # get ubuntu packages # clang-3.6 can be replaced by any later versions of clang -sudo apt-get install clang-3.6 doxygen build-essential thrift-compiler python-virtualenv gcc-multilib git wget libldap2-dev libsasl2-dev libssl-dev +sudo apt-get install clang-3.6 doxygen build-essential thrift-compiler python-virtualenv gcc-multilib git wget # create new python virtualenv virtualenv -p /usr/bin/python2.7 ~/checker_env diff --git a/codechecker_lib/session_manager.py b/codechecker_lib/session_manager.py index 69bb3fbbd2..ceea65e3f9 100644 --- a/codechecker_lib/session_manager.py +++ b/codechecker_lib/session_manager.py @@ -9,17 +9,27 @@ ''' import os +import fcntl import shutil import uuid import json import hashlib import time +import getpass +import tempfile import ldap import ldap.sasl from datetime import datetime from codechecker_lib import logger +unsupported_methods = [] + +try: + import ldap +except ImportError: + unsupported_methods.append("ldap") + LOG = logger.get_new_logger("SESSION MANAGER") SESSION_COOKIE_NAME = "__ccPrivilegedAccessToken" session_lifetimes = {} @@ -28,8 +38,10 @@ # ------------------------------ # ----------- 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 + # permanent persistency routine. __initial_salt = hashlib.sha256(SESSION_COOKIE_NAME + "__" + str(time.time()) + "__" + os.urandom(16)).hexdigest() @@ -83,37 +95,61 @@ def revalidate(self): # 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 + # to log in into a brand-new session. self.last_access = datetime.now() -class sessionManager: +class SessionManager: + CodeChecker_Workspace = None + __valid_sessions = [] __logins_since_prune = 0 def __init__(self): LOG.debug('Loading session config') - session_cfg_file = os.path.join(os.environ['CC_PACKAGE_ROOT'], - "config", "session_config.json") + # 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) - with open(session_cfg_file, 'r') as scfg: - scfg_dict = json.loads(scfg.read()) - if not scfg_dict["authentication"]: - scfg_dict["authentication"] = {'enabled': False} + 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") \ - and ("method_dictionary" in self.__auth_config and not - self.__auth_config["method_dictionary"].get("enabled")) \ - and ("method_ldap" in self.__auth_config and not - self.__auth_config["method_ldap"].get("enabled")): - LOG.warning("Authentication is enabled but no valid authentication" - " backends are configured... Falling back to" - " no authentication.") - self.__auth_config["enabled"] = False + # 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 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 @@ -130,22 +166,23 @@ def getRealm(self): } def __handle_validation(self, auth_string): - '''Validate an oncoming authorization request - against some authority controller''' + """Validate an oncoming authorization request + against some authority controller.""" return self.__try_auth_dictionary(auth_string) \ or self.__try_auth_ldap(auth_string) - def __try_auth_dictionary(self, auth_string): - if "method_dictionary" in self.__auth_config and \ - self.__auth_config["method_dictionary"].get("enabled"): - return auth_string in \ - self.__auth_config.get("method_dictionary").get("auths") + 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") - return False + 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_ldap(self, auth_string): - if "method_ldap" in self.__auth_config and \ - self.__auth_config["method_ldap"].get("enabled"): + 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"]) @@ -189,7 +226,7 @@ def create_or_get_session(self, client, auth_string): if self.__handle_validation(auth_string): session_already = next( (s for s - in sessionManager.__valid_sessions if s.client == client + in SessionManager.__valid_sessions if s.client == client and s.still_reusable() and s.persistent_hash == _Session.calc_persistency_hash(client, auth_string)), @@ -205,51 +242,51 @@ def create_or_get_session(self, client, auth_string): session = _Session(client, token, _Session.calc_persistency_hash(client, auth_string)) - sessionManager.__valid_sessions.append(session) + 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''' + """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) + 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[:]: + '''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) + SessionManager.__valid_sessions.remove(session) return True return False def __cleanup_sessions(self): - for session in sessionManager.__valid_sessions[:]: - if not session.still_reusable(): - sessionManager.__valid_sessions.remove(session) + SessionManager.__valid_sessions = [s for s + in SessionManager.__valid_sessions + if s.still_reusable()] self.__logins_since_prune = 0 # ------------------------------ # ----------- CLIENT ----------- -class sessionManager_Client: +class SessionManager_Client: def __init__(self): LOG.debug('Loading session config') - # Check for user's configuration to exist + # 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): - print("CodeChecker authentication client's example configuration " - "file created at " + 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) @@ -260,37 +297,50 @@ def __init__(self): if not scfg_dict["credentials"]: scfg_dict["credentials"] = {} - if not scfg_dict["tokens"]: - scfg_dict["tokens"] = {} 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") + else: + with open(self.token_file, 'w') as f: + json.dump({'tokens': {}}, f) + + self.__tokens = {} + def is_autologin_enabled(self): return self.__autologin def getToken(self, host, port): - return self.__save["tokens"].get(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("*") + 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 not destroy: - self.__save["tokens"][host + ":" + port] = token + if destroy: + del self.__tokens[host + ":" + port] else: - del self.__save["tokens"][host + ":" + port] + self.__tokens[host + ":" + port] = token - session_cfg_file = os.path.join(os.path.expanduser("~"), - ".codechecker_passwords.json") - with open(session_cfg_file, 'w') as scfg: - json.dump(self.__save, scfg, indent=2, sort_keys=True) + with open(self.token_file, 'w') as scfg: + fcntl.lockf(scfg, fcntl.LOCK_EX) + json.dump({'tokens': self.__tokens}, scfg, + indent=2, sort_keys=True) + fcntl.lockf(scfg, fcntl.LOCK_UN) diff --git a/docs/authentication.md b/docs/authentication.md index 141a74bdb9..03c4124899 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -1,11 +1,16 @@ 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. -Authentication configuration is stored in the `config/session_config.json` file for the server and a template in `config/session_client.json` for the client. -The user's own configuration is copied by the client – at first launch – to `~/.codechecker_passwords.json`. + +> **NOTICE!** Some authentication subsystems require additional packages to be installed before they can be used. ## 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. @@ -47,6 +52,24 @@ If the user's login matches any of the credentials listed, the user will be auth ### *LDAP* authentication +#### Prerequisites + +Using the *LDAP* authentication requires some additional packages 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 basic python modules +pip install python-ldap +~~~~~~ + +#### Settings + 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. @@ -90,6 +113,9 @@ For browser authentication to work, cookies must be enabled! 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. + ~~~~~~~~~~~~~~~~~~~~~ usage: CodeChecker cmd login [-h] [--host HOST] -p PORT [-u USERNAME] [-pw PASSWORD] [-d] @@ -142,4 +168,4 @@ This behaviour can be disabled by setting `client_autologin` to `false`. #### Currently active tokens -The configuration file's `tokens` section contains the user's currently active sessions' tokens. This is not meant to be edited by hand, the client manages this section. \ No newline at end of file +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/tests/functional/package_test/__init__.py b/tests/functional/package_test/__init__.py index 84844ee89e..91db4c370d 100644 --- a/tests/functional/package_test/__init__.py +++ b/tests/functional/package_test/__init__.py @@ -174,25 +174,29 @@ def setup_package(): _start_server(shared_test_params, test_config, False) # - # Create a dummy authentication-enabled configuration and an auth-enabled server + # 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: + with open(session_cfg_file, 'r+') as scfg: __scfg_original = scfg.read() + scfg.seek(0) + scfg_dict = json.loads(__scfg_original) - 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"] - scfg_dict["authentication"]["enabled"] = True - scfg_dict["authentication"]["method_dictionary"]["enabled"] = True - scfg_dict["authentication"]["method_dictionary"]["auths"] = ["cc:test"] - - with open(session_cfg_file, 'w') as scfg: 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) From a66c809693edb65534e170df2abcb947e511ca60 Mon Sep 17 00:00:00 2001 From: Whisperity Date: Wed, 5 Oct 2016 14:17:19 +0200 Subject: [PATCH 11/13] Put authentication persistencies into more appropriate locations - User credential file is chmod 0600 at creation (if already exists and not this mode, the user is warned) - Active tokens are stored away from the credentials, in the /tmp folder - Server configuration is put into the workspace where the server is running instead of using an install-global file --- codechecker_lib/arg_handler.py | 2 +- codechecker_lib/session_manager.py | 13 +++++++++++++ docs/authentication.md | 5 ++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/codechecker_lib/arg_handler.py b/codechecker_lib/arg_handler.py index 1f5516311d..2559bbe87a 100644 --- a/codechecker_lib/arg_handler.py +++ b/codechecker_lib/arg_handler.py @@ -118,6 +118,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 +438,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 index ceea65e3f9..dc3bd2ddae 100644 --- a/codechecker_lib/session_manager.py +++ b/codechecker_lib/session_manager.py @@ -9,6 +9,7 @@ ''' import os +import stat import fcntl import shutil import uuid @@ -290,6 +291,7 @@ def __init__(self): 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: @@ -310,9 +312,20 @@ def __init__(self): 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 = {} diff --git a/docs/authentication.md b/docs/authentication.md index 03c4124899..1fb806d0a3 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -116,6 +116,9 @@ The `CodeChecker cmd` client needs to be authenticated for a server before any d 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] @@ -168,4 +171,4 @@ 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. +The configuration file's `tokens` section contains the user's currently active sessions' tokens. This is not meant to be edited by hand, the client manages this section. From 550d210608a61d23ff108248bc51fbdcf971d208 Mon Sep 17 00:00:00 2001 From: Whisperity Date: Wed, 5 Oct 2016 14:33:22 +0200 Subject: [PATCH 12/13] PAM authentication module for privileged access --- .ci/basic_python_requirements | 1 - .ci/python_requirements | 1 + codechecker_lib/session_manager.py | 52 ++++++++++++++++++++++++++++-- config/session_config.json | 9 ++++++ docs/authentication.md | 46 ++++++++++++++++++++++++-- 5 files changed, 104 insertions(+), 5 deletions(-) diff --git a/.ci/basic_python_requirements b/.ci/basic_python_requirements index 19ec634fb2..7fd633583e 100644 --- a/.ci/basic_python_requirements +++ b/.ci/basic_python_requirements @@ -1,4 +1,3 @@ sqlalchemy==1.0.9 alembic==0.8.2 thrift==0.9.1 -python-ldap==2.4.22.0 diff --git a/.ci/python_requirements b/.ci/python_requirements index faf00cdd82..b7c881ae78 100644 --- a/.ci/python_requirements +++ b/.ci/python_requirements @@ -4,3 +4,4 @@ psycopg2==2.5.4 pg8000==1.10.2 thrift==0.9.1 python-ldap==2.4.22.0 +python-pam==1.8.2 diff --git a/codechecker_lib/session_manager.py b/codechecker_lib/session_manager.py index dc3bd2ddae..4678e66a24 100644 --- a/codechecker_lib/session_manager.py +++ b/codechecker_lib/session_manager.py @@ -31,6 +31,13 @@ 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 = {} @@ -136,7 +143,6 @@ def __init__(self): 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: @@ -145,6 +151,16 @@ def __init__(self): "... 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 " @@ -170,6 +186,7 @@ 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): @@ -182,6 +199,37 @@ def __try_auth_dictionary(self, auth_string): 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(":") @@ -211,7 +259,7 @@ def __try_auth_ldap(self, auth_string): finally: l.unbind() - return False + return False def create_or_get_session(self, client, auth_string): """Create a new session for the given client and auth-string, if diff --git a/config/session_config.json b/config/session_config.json index 29c3a5d0fd..3f65e368ce 100644 --- a/config/session_config.json +++ b/config/session_config.json @@ -22,6 +22,15 @@ ] } ] + }, + "method_pam": { + "enabled" : false, + "users": [ + "root", "myname" + ], + "groups": [ + "adm", "cc-users" + ] } } } diff --git a/docs/authentication.md b/docs/authentication.md index 1fb806d0a3..9a21a4557a 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -33,7 +33,9 @@ The `authentication` section of the config file controls how authentication is h 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. If both methods are enabled, *dictionary* authentication takes precedence. +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 @@ -50,6 +52,46 @@ If the user's login matches any of the credentials listed, the user will be auth } ``` +### *PAM* authentication + +#### Prerequisites + +Using the *PAM* authentication requires some additional packages to be installed on the system. + +~~~~~~{.sh} + +# the python virtual environment must be sourced! +source ~/checker_env/bin/activate + +# install required python modules +pip install python-pam +~~~~~~ + +#### Settings + +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 #### Prerequisites @@ -64,7 +106,7 @@ sudo apt-get install libldap2-dev libsasl2-dev libssl-dev # the python virtual environment must be sourced! source ~/checker_env/bin/activate -# install required basic python modules +# install required python modules pip install python-ldap ~~~~~~ From 3d6f5b9a12c24bdefe026b6e2dec61cbd399d254 Mon Sep 17 00:00:00 2001 From: Whisperity Date: Tue, 18 Oct 2016 16:23:33 +0200 Subject: [PATCH 13/13] Moved authentication modules to a different .ci file and use portalocker --- .ci/auth_requirements | 2 ++ .ci/basic_python_requirements | 1 + .ci/python_requirements | 3 +- codechecker_lib/arg_handler.py | 1 + codechecker_lib/session_manager.py | 27 +++++++------- docs/authentication.md | 36 ++++++------------- thrift_api/authentication.thrift | 2 +- .../cmdline_client/cmd_line_client.py | 4 +-- viewer_server/client_db_access_server.py | 8 ++--- 9 files changed, 34 insertions(+), 50 deletions(-) create mode 100644 .ci/auth_requirements 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 b7c881ae78..7c65a5ba2b 100644 --- a/.ci/python_requirements +++ b/.ci/python_requirements @@ -3,5 +3,4 @@ alembic==0.8.2 psycopg2==2.5.4 pg8000==1.10.2 thrift==0.9.1 -python-ldap==2.4.22.0 -python-pam==1.8.2 +portalocker==1.0.0 diff --git a/codechecker_lib/arg_handler.py b/codechecker_lib/arg_handler.py index 2559bbe87a..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 diff --git a/codechecker_lib/session_manager.py b/codechecker_lib/session_manager.py index 4678e66a24..2ca142ca3d 100644 --- a/codechecker_lib/session_manager.py +++ b/codechecker_lib/session_manager.py @@ -3,23 +3,22 @@ # 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 fcntl import shutil -import uuid -import json -import hashlib import time -import getpass import tempfile -import ldap -import ldap.sasl +import uuid + from datetime import datetime from codechecker_lib import logger @@ -56,10 +55,10 @@ class _Session(): @staticmethod def calc_persistency_hash(client_addr, auth_string): - '''Calculates a more secure persistency hash for the session. This + """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.''' + other's session.""" return hashlib.sha256(auth_string + "@" + client_addr + ":" + _Session.__initial_salt).hexdigest() @@ -309,7 +308,7 @@ def is_valid(self, client, token, access=False): for _sess in SessionManager.__valid_sessions) def invalidate(self, client, token): - '''Remove a user's previous session from the store.''' + """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) @@ -401,7 +400,7 @@ def saveToken(self, host, port, token, destroy=False): self.__tokens[host + ":" + port] = token with open(self.token_file, 'w') as scfg: - fcntl.lockf(scfg, fcntl.LOCK_EX) + portalocker.lock(scfg, portalocker.LOCK_EX) json.dump({'tokens': self.__tokens}, scfg, indent=2, sort_keys=True) - fcntl.lockf(scfg, fcntl.LOCK_UN) + portalocker.unlock(scfg) diff --git a/docs/authentication.md b/docs/authentication.md index 9a21a4557a..d713da680f 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -5,7 +5,7 @@ CodeChecker authentication subsytem 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. +> **NOTICE!** Some authentication subsystems require additional packages to be installed before they can be used. See below. ## Serverside configuration @@ -52,22 +52,24 @@ If the user's login matches any of the credentials listed, the user will be auth } ``` -### *PAM* authentication +### External authentication methods -#### Prerequisites +External authentication methods connect to a privilege manager to authenticate users against. -Using the *PAM* authentication requires some additional packages to be installed on the system. +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 python-pam +pip install -r .ci/auth_requirements ~~~~~~ -#### Settings +#### *PAM* authentication To access the server via PAM authentication, the user must provide valid username and password which is accepted by PAM. @@ -92,25 +94,7 @@ In the example below, `root` and `myname` can access the server, and **everyone* } ``` -### *LDAP* authentication - -#### Prerequisites - -Using the *LDAP* authentication requires some additional packages 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 python-ldap -~~~~~~ - -#### Settings +#### *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` @@ -213,4 +197,4 @@ This behaviour can be disabled by setting `client_autologin` to `false`. #### Currently active tokens -The configuration file's `tokens` section contains the user's currently active sessions' tokens. This is not meant to be edited by hand, the client manages this section. +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/thrift_api/authentication.thrift b/thrift_api/authentication.thrift index 0be7f28ff1..03a2f53044 100644 --- a/thrift_api/authentication.thrift +++ b/thrift_api/authentication.thrift @@ -10,7 +10,7 @@ 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, + 2: bool sessionStillActive, // whether the session in which the HandshakeInformation is returned is a valid one } service codeCheckerAuthentication { diff --git a/viewer_clients/cmdline_client/cmd_line_client.py b/viewer_clients/cmdline_client/cmd_line_client.py index a06a79e84e..bb78e77b0f 100644 --- a/viewer_clients/cmdline_client/cmd_line_client.py +++ b/viewer_clients/cmdline_client/cmd_line_client.py @@ -38,7 +38,7 @@ def default(self, obj): return d def handle_auth_requests(args): - session = session_manager.sessionManager_Client() + session = session_manager.SessionManager_Client() auth_client = authentication_helper.ThriftAuthHelper(args.host, args.port, '/Authentication', @@ -104,7 +104,7 @@ def __check_authentication(client): def setupClient(host, port, uri): ''' setup the thrift client and check API version and authentication needs''' - manager = session_manager.sessionManager_Client() + manager = session_manager.SessionManager_Client() session_token = manager.getToken(host, port) # Before actually communicating with the server, diff --git a/viewer_server/client_db_access_server.py b/viewer_server/client_db_access_server.py index 6c9c00c174..2adb514942 100644 --- a/viewer_server/client_db_access_server.py +++ b/viewer_server/client_db_access_server.py @@ -87,10 +87,8 @@ def check_auth_in_request(self): client_host, client_port = self.client_address for k in self.headers.getheaders("Cookie"): - print "Begin iter." split = k.split("; ") for cookie in split: - print cookie values = cookie.split("=") if len(values) == 2 and \ values[0] == session_manager.SESSION_COOKIE_NAME: @@ -175,7 +173,7 @@ def do_POST(self): 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 + # but the user is expected to properly authenticate first. LOG.debug(client_host + ":" + str(client_port) + " Invalid access, credentials not found " + @@ -197,7 +195,7 @@ def do_POST(self): self.db_version_info) if self.path == '/Authentication': - # Authentication requests must be routed to a different handler + # Authentication requests must be routed to a different handler. auth_handler = ThriftAuthHandler(self.manager, client_host, sess_token) @@ -335,7 +333,7 @@ def start_server(package_data, port, db_conn_string, suppress_handler, package_data, suppress_handler, db_version_info, - session_manager.sessionManager()) + session_manager.SessionManager()) LOG.info('Waiting for client requests on [' + access_server_host + ':' + str(port) + ']')