From 82d2d4dd55ee2c339bb6ee5fdc6e4777dccc0c4c Mon Sep 17 00:00:00 2001 From: Tomas Kopecek Date: Mon, 5 Sep 2022 16:59:35 +0200 Subject: [PATCH] use header-based auth --- docs/source/hub_conf.rst | 11 +++++++ hub/hub.conf | 2 ++ hub/kojixmlrpc.py | 32 +++++++++------------ koji/__init__.py | 62 +++++++++++++++++++++------------------- koji/auth.py | 51 ++++++++++++++++++++++++++------- 5 files changed, 101 insertions(+), 57 deletions(-) diff --git a/docs/source/hub_conf.rst b/docs/source/hub_conf.rst index 158d739f..fbe503c4 100644 --- a/docs/source/hub_conf.rst +++ b/docs/source/hub_conf.rst @@ -153,6 +153,17 @@ The following options control aspects of authentication when using ``mod_auth_gs option. The default value of False is recommended. + DisableURLSessions + Type: boolean + + Default: ``False`` + + If set to ``False``, it enables older clients to log in via session parameters + encoded in URL. New behaviour uses header-based parameteres. This default + will be changed in future to ``True`` effectively disabling older clients. It is + encouraged to set it to ``True`` as soon as possible when no older clients are + using the hub. (Added in 1.30) + Enabling gssapi auth also requires settings in the httpd config. SSL client certificate auth configuration diff --git a/hub/hub.conf b/hub/hub.conf index 783cdd77..90fd9a64 100644 --- a/hub/hub.conf +++ b/hub/hub.conf @@ -35,6 +35,8 @@ KojiDir = /mnt/koji # AllowedKrbRealms = * ## TODO: this option should be removed in future release # DisableGSSAPIProxyDNFallback = False +## TODO: this option should be turned True in future release +# DisableURLSessions = False ## end Kerberos auth configuration diff --git a/hub/kojixmlrpc.py b/hub/kojixmlrpc.py index f11bb4ee..5596d053 100644 --- a/hub/kojixmlrpc.py +++ b/hub/kojixmlrpc.py @@ -19,7 +19,7 @@ # Mike McLean import datetime -import http.cookies +import email import inspect import logging import os @@ -449,6 +449,8 @@ def load_config(environ): ['AllowedKrbRealms', 'string', '*'], # TODO: this option should be removed in future release ['DisableGSSAPIProxyDNFallback', 'boolean', False], + # TODO: this option should be turned True in future release + ['DisableURLSessions', 'boolean', False], ['DNUsernameComponent', 'string', 'CN'], ['ProxyDNs', 'string', ''], @@ -808,24 +810,18 @@ def application(environ, start_response): except RequestTimeout as e: return error_reply(start_response, '408 Request Timeout', str(e) + '\n') response = response.encode() - headers = [ - ('Content-Length', str(len(response))), - ('Content-Type', "text/xml"), - ] - cookies = http.cookies.SimpleCookie(environ.get('HTTP_SET_COOKIE')) + headers = email.message.EmailMessage() + headers['Content-Length'] = str(len(response)) + headers['Content-Type'] = "text/xml" if hasattr(context, 'session') and context.session.logged_in: - cookies['session-id'] = context.session.id - cookies['session-key'] = context.session.key - cookies['callnum'] = context.session.callnum - cookies['logged_in'] = "1" - else: - # good for not mistaking for the older hub with small overhead - cookies['logged_in'] = "0" - for c in cookies.values(): - c.path = '/' - c.secure = True - headers.append(('Set-Cookie', c.OutputString())) - start_response('200 OK', headers) + headers.add_header( + 'X-Session-Data', + '1', # logged in + session_id=str(context.session.id), + session_key=str(context.session.key), + callnum=str(context.session.callnum), + ) + start_response('200 OK', headers.items()) if h.traceback: # rollback context.cnx.rollback() diff --git a/koji/__init__.py b/koji/__init__.py index 62ff9725..5df90437 100644 --- a/koji/__init__.py +++ b/koji/__init__.py @@ -26,6 +26,7 @@ from __future__ import absolute_import, division import base64 import datetime +import email import errno import hashlib import json @@ -57,7 +58,6 @@ except ImportError: # pragma: no cover from fnmatch import fnmatch import dateutil.parser -import http.cookies import requests import six import six.moves.configparser @@ -2701,12 +2701,19 @@ class ClientSession(object): if name == 'rawUpload': return self._prepUpload(*args, **kwargs) args = encode_args(*args, **kwargs) + headers = email.message.EmailMessage() if self.logged_in: - for c in self.rsession.cookies: - if c.name == 'callnum': - c.value = str(self.callnum) + sinfo = self.sinfo.copy() + sinfo['callnum'] = self.callnum self.callnum += 1 - handler = self.baseurl + if sinfo.get('header-auth'): + for k, v in sinfo.items(): + sinfo[k] = str(v) + handler = self.baseurl + headers.add_header('X-Session-Data', '1', **sinfo) + else: + # old server + handler = "%s?%s" % (self.baseurl, six.moves.urllib.parse.urlencode(sinfo)) elif name == 'sslLogin': handler = self.baseurl + '/ssllogin' else: @@ -2717,12 +2724,9 @@ class ClientSession(object): # encoded as UTF-8. For python3 it means "return a str with an appropriate # xml declaration for encoding as UTF-8". request = request.encode('utf-8') - headers = [ - # connection class handles Host - ('User-Agent', 'koji/1'), - ('Content-Type', 'text/xml'), - ('Content-Length', str(len(request))), - ] + headers['User-Agent'] = 'koji/1' + headers['Content-Type'] = 'text/xml' + headers['Content-Length'] = str(len(request)) return handler, headers, request def _sanitize_url(self, url): @@ -2801,13 +2805,6 @@ class ClientSession(object): warnings.simplefilter("ignore") r = self.rsession.post(handler, **callopts) r.raise_for_status() - if self.logged_in and len(r.cookies.items()) == 0: - # we have session, sent the cookies, but server is old - # and didn't sent them back, use old url-encoded style - sinfo = self.sinfo.copy() - handler = "%s?%s" % (handler, six.moves.urllib.parse.urlencode(sinfo)) - r = self.rsession.post(handler, **callopts) - r.raise_for_status() try: ret = self._read_xmlrpc_response(r) finally: @@ -3039,24 +3036,31 @@ class ClientSession(object): """prep a rawUpload call""" if not self.logged_in: raise ActionNotAllowed("you must be logged in to upload") - args = self.sinfo.copy() - args['callnum'] = self.callnum - args['filename'] = name - args['filepath'] = path - args['fileverify'] = verify - args['offset'] = str(offset) + sinfo = self.sinfo.copy() + sinfo['callnum'] = self.callnum + args = { + 'filename': name, + 'filepath': path, + 'fileverify': verify, + 'offset': str(offset), + } if overwrite: args['overwrite'] = "1" if volume is not None: args['volume'] = volume size = len(chunk) self.callnum += 1 + headers = email.message.EmailMessage() + if sinfo.get('header-auth'): + for k, v in sinfo.items(): + sinfo[k] = str(v) + headers.add_header('X-Session-Data', '1', **sinfo) + else: + args.update(sinfo) handler = "%s?%s" % (self.baseurl, six.moves.urllib.parse.urlencode(args)) - headers = [ - ('User-Agent', 'koji/1'), - ("Content-Type", "application/octet-stream"), - ("Content-length", str(size)), - ] + headers['User-Agent'] = 'koji/1' + headers["Content-Type"] = "application/octet-stream" + headers["Content-length"] = str(size) request = chunk if six.PY3 and isinstance(chunk, str): request = chunk.encode('utf-8') diff --git a/koji/auth.py b/koji/auth.py index b7a1984f..db8275f5 100644 --- a/koji/auth.py +++ b/koji/auth.py @@ -79,20 +79,47 @@ class Session(object): self._groups = None self._host_id = '' environ = getattr(context, 'environ', {}) - # prefer new cookie-based sessions - if 'HTTP_COOKIE' in environ: - cookies = http.cookies.SimpleCookie(environ['HTTP_COOKIE']) + # prefer new header-based sessions + if 'HTTP_X_SESSION_DATA' in environ: + header = environ['HTTP_X_SESSION_DATA'] + params = header.split(';') + id, key, callnum = None, None, None + for p in params[1:]: + k, v = [x.strip() for x in p.split('=')] + v = v.strip('"') + if k == 'session-id': + id = int(v) + elif k == 'session-key': + key = v + elif k == 'callnum': + callnum = v + elif k == 'header-auth': + pass + else: + raise koji.AuthError("Unexpected key in X-Session-Data: %s" % k) + if id is None: + raise koji.AuthError('session-id not specified in session args') + elif key is None: + raise koji.AuthError('session-key not specified in session args') + elif not context.opts['DisableURLSessions'] and args is not None: + # old deprecated method with session values in query string + # Option will be turned off by default in future release and removed later + args = environ.get('QUERY_STRING', '') + if not args: + self.message = 'nor session header nor session args' + return + args = urllib.parse.parse_qs(args, strict_parsing=True) try: - id = int(cookies['session-id'].value) - key = str(cookies['session-key'].value) + id = int(args['session-id'][0]) + key = args['session-key'][0] except KeyError as field: raise koji.AuthError('%s not specified in session args' % field) try: - callnum = int(cookies['callnum'].value) - except KeyError: + callnum = args['callnum'][0] + except Exception: callnum = None else: - self.message = 'no session cookies' + self.message = 'no X-Session-Data header' return hostip = self.get_remote_ip(override=hostip) # lookup the session @@ -498,14 +525,18 @@ class Session(object): insert.execute() context.cnx.commit() - # update it here, so it can be propagated to the cookies in kojixmlrpc.py + # update it here, so it can be propagated to the headers in kojixmlrpc.py context.session.id = session_id context.session.key = key context.session.logged_in = True context.session.callnum = 0 # return session info - return {'session-id': session_id, 'session-key': key} + return { + 'session-id': session_id, + 'session-key': key, + 'header-auth': True, # signalize to client to use new session handling in 1.30 + } def subsession(self): "Create a subsession"