From 7f572fd1645d4d99a47b52d70f01b6aaf2eb71c0 Mon Sep 17 00:00:00 2001 From: Jarrod Johnson Date: Wed, 9 Oct 2013 16:17:37 -0400 Subject: [PATCH] Implement session cookie for optional use by clients to guard against attempts to slow session Implement http and console session reaping in httpapi layer --- confluent/auth.py | 54 ++++++++++++++++------- confluent/httpapi.py | 102 +++++++++++++++++++++++++++++++------------ 2 files changed, 112 insertions(+), 44 deletions(-) diff --git a/confluent/auth.py b/confluent/auth.py index 029683c2..816816cf 100644 --- a/confluent/auth.py +++ b/confluent/auth.py @@ -1,6 +1,7 @@ # authentication and authorization routines for confluent -# authentication scheme caches password values to help HTTP Basic auth -# the PBKDF2 transform is skipped if a user has been idle for sufficient time +# authentication scheme caches passphrase values to help HTTP Basic auth +# the PBKDF2 transform is skipped unless a user has been idle for sufficient +# time import confluent.config as config import eventlet @@ -35,15 +36,16 @@ def _get_usertenant(name, tenant=False): administrator account a tenant gets. Otherwise, just assume a user in the default tenant """ - if isinstance(tenant,bool): + if not isinstance(tenant,bool): + # if not boolean, it must be explicit tenant user = name - tenant = None - elif '/' in name: + elif '/' in name: # tenant scoped name tenant, user = name.split('/', 1) elif config.is_tenant(name): + # the account is the implicit tenant owner account user = name tenant = name - else: + else: # assume it is a non-tenant user account user = name tenant = None yield user @@ -73,7 +75,7 @@ def authorize(name, element, tenant=False, access='rw'): return None -def set_user_password(name, passphrase, tenant=None): +def set_user_passphrase(name, passphrase, tenant=None): """Set user passphrase :param name: The unique shortname of the user @@ -92,7 +94,27 @@ def set_user_password(name, passphrase, tenant=None): cfm.set_user(name, { 'cryptpass': (salt, crypted) }) -def check_user_passphrase(name, passphrase, tenant=None): +def check_user_passphrase(name, passphrase, element=None, tenant=False): + """Check a a login name and passphrase for authenticity and authorization + + The function combines authentication and authorization into one function. + It is highly recommended for a session layer to provide some secure means + of protecting a session once this function works once and calling + authorize() in order to provide best performance regardless of + circumstance. The function makes effort to provide good performance + in repeated invocation, but that facility will slow down to deter + detected passphrase guessing activity when such activity is detected. + + :param name: The login name provided by client + :param passhprase: The passphrase provided by client + :param element: Optional specification of a particular destination + :param tenant: Optional explicit indication of tenant (defaults to + embedded in name) + """ + # If there is any sign of guessing on a user, all valid and + # invalid attempts are equally slowed to no more than 20 per second + # for that particular user. + # similarly, guessing usernames is throttled to 20/sec user, tenant = _get_usertenant(name, tenant) while (user,tenant) in _passchecking: # Want to serialize passphrase checking activity @@ -102,18 +124,18 @@ def check_user_passphrase(name, passphrase, tenant=None): eventlet.sleep(0.5) if (user,tenant) in _passcache: if passphrase == _passcache[(user,tenant)]: - return True + return authorize(user, element, tenant) else: # In case of someone trying to guess, # while someone is legitimately logged in # invalidate cache and force the slower check del _passcache[(user, tenant)] - return False - eventlet.sleep(0.1) # limit throughput of remote guessing + return None cfm = config.ConfigManager(tenant) ucfg = cfm.get_user(user) if ucfg is None or 'cryptpass' not in ucfg: - return False + eventlet.sleep(0.05) #stall even on test for existance of a username + return None _passchecking[(user, tenant)] = True # TODO(jbjohnso): WORKERPOOL # PBKDF2 is, by design, cpu intensive @@ -122,8 +144,10 @@ def check_user_passphrase(name, passphrase, tenant=None): crypted = kdf.PBKDF2(passphrase, salt, 32, 10000, lambda p, s: hash.HMAC.new(p, s, hash.SHA256).digest()) del _passchecking[(user, tenant)] + eventlet.sleep(0.05) #either way, we want to stall so that client can't + # determine failure because there is a delay, valid response will + # delay as well if crypt == crypted: _passcache[(user, tenant)] = passphrase - return True - return False - + return authorize(user, element, tenant) + return None diff --git a/confluent/httpapi.py b/confluent/httpapi.py index e1a2fd91..68b3472b 100644 --- a/confluent/httpapi.py +++ b/confluent/httpapi.py @@ -4,6 +4,7 @@ # It additionally manages httprequest console sessions as supported by # shillinabox javascript import base64 +import Cookie import confluent.console as console import confluent.auth as auth import confluent.util as util @@ -11,16 +12,34 @@ import eventlet import json import os import string +import time import urlparse import eventlet.wsgi #scgi = eventlet.import_patched('flup.server.scgi') consolesessions = {} +httpsessions = {} -def _get_query_dict(qstring, reqbody, reqtype): +def _sessioncleaner(): + while (1): + currtime = time.time() + for session in httpsessions.keys(): + if httpsessions[session]['expiry'] < currtime: + del httpsessions[session] + for session in consolesessions.keys(): + if consolesessions[session]['expiry'] < currtime: + del consolesessions[session] + eventlet.sleep(10) + + +def _get_query_dict(env, reqbody, reqtype): qdict = {} + try: + qstring = env['QUERY_STRING'] + except KeyError: + qstring = None if qstring: for qpair in qstring.split('&'): qkey, qvalue = qpair.split('=') @@ -37,17 +56,37 @@ def _authorize_request(env): """Grant/Deny access based on data from wsgi env """ - if 'REMOTE_USER' in env: # HTTP Basic auth passed - user = env['REMOTE_USER'] - #TODO: actually pass in the element - authdata = auth.authorize(user, element=None) - if authdata is None: - return {'code': 401} - else: - return {'code': 200, - 'cfgmgr': authdata[1], - 'userdata': authdata[0]} - + authdata = False + cookie = Cookie.SimpleCookie() + if 'HTTP_COOKIE' in env: + #attempt to use the cookie. If it matches + cc = Cookie.SimpleCookie() + cc.load(env['HTTP_COOKIE']) + if 'confluentsessionid' in cc: + sessionid = cc['confluentsessionid'].value + if sessionid in httpsessions: + httpsessions[sessionid]['expiry'] = time.time() + 90 + name = httpsessions[sessionid]['name'] + authdata = auth.authorize(name, element=None) + if authdata is False and 'HTTP_AUTHORIZATION' in env: + name, passphrase = base64.b64decode( + env['HTTP_AUTHORIZATION'].replace('Basic ','')).split(':',1) + authdata = auth.check_user_passphrase(name, passphrase, element=None) + sessid = util.randomstring(32) + while sessid in httpsessions: + sessid = util.randomstring(32) + httpsessions[sessid] = {'name': name, 'expiry': time.time() + 90} + cookie['confluentsessionid']=sessid + cookie['confluentsessionid']['secure'] = 1 + cookie['confluentsessionid']['httponly'] = 1 + cookie['confluentsessionid']['path'] = '/' + if authdata: + return {'code': 200, + 'cookie': cookie, + 'cfgmgr': authdata[1], + 'userdata': authdata[0]} + else: + return {'code': 401} # TODO(jbjohnso): actually evaluate the request for authorization # In theory, the x509 or http auth stuff will get translated and then # passed on to the core authorization function in an appropriate form @@ -77,10 +116,11 @@ def _pick_mimetype(env): def _assign_consessionid(consolesession): - sessid = util.randomstring(20) + sessid = util.randomstring(32) while sessid in consolesessions.keys(): - sessid = util.randomstring(20) - consolesessions[sessid] = consolesession + sessid = util.randomstring(32) + consolesessions[sessid] = {'session': consolesession, + 'expiry': time.time() + 60} return sessid def resourcehandler(env, start_response): @@ -96,17 +136,20 @@ def resourcehandler(env, start_response): if authorized['code'] == 401: start_response('401 Authentication Required', [('Content-type', 'text/plain'), - ('WWW-Authenticate', 'Basic realm="confluent"')]) + ('WWW-Authenticate', 'Basic realm="confluent"')]) return 'authentication required' if authorized['code'] == 403: start_response('403 Forbidden', [('Content-type', 'text/plain'), - ('WWW-Authenticate', 'Basic realm="confluent"')]) + ('WWW-Authenticate', 'Basic realm="confluent"')]) return 'authorization failed' if authorized['code'] != 200: raise Exception("Unrecognized code from auth engine") + headers = [('Content-Type', 'application/json; charset=utf-8')] + headers.extend(("Set-Cookie", m.OutputString()) + for m in authorized['cookie'].values()) cfgmgr = authorized['cfgmgr'] - querydict = _get_query_dict(env['QUERY_STRING'], reqbody, reqtype) + querydict = _get_query_dict(env, reqbody, reqtype) if '/console/session' in env['PATH_INFO']: #hard bake JSON into this path, do not support other incarnations prefix, _, _ = env['PATH_INFO'].partition('/console/session') @@ -116,11 +159,10 @@ def resourcehandler(env, start_response): consession = console.ConsoleSession(node=nodename, configmanager=cfgmgr) if not consession: - start_response("500 Internal Server Error", []) + start_response("500 Internal Server Error", headers) return sessid = _assign_consessionid(consession) - start_response('200 OK', [('Content-Type', - 'application/json; charset=utf-8')]) + start_response('200 OK', headers) return ['{"session":"%s","data":""}' % sessid] elif 'keys' in querydict.keys(): # client wishes to push some keys into the remote console @@ -128,24 +170,24 @@ def resourcehandler(env, start_response): for idx in xrange(0, len(querydict['keys']), 2): input += chr(int(querydict['keys'][idx:idx+2],16)) sessid = querydict['session'] - consolesessions[sessid].write(input) - start_response('200 OK', [('Content-Type', - 'application/json; charset=utf-8')]) + consolesessions[sessid]['expiry'] = time.time() + 90 + consolesessions[sessid]['session'].write(input) + start_response('200 OK', headers) return [] # client has requests to send or receive, not both... else: #no keys, but a session, means it's hooking to receive data sessid = querydict['session'] - outdata = consolesessions[sessid].get_next_output(timeout=45) + consolesessions[sessid]['expiry'] = time.time() + 90 + outdata = consolesessions[sessid]['session'].get_next_output(timeout=45) try: rsp = json.dumps({'session': querydict['session'], 'data': outdata}) except UnicodeDecodeError: rsp = json.dumps({'session': querydict['session'], 'data': outdata}, encoding='cp437') except UnicodeDecodeError: rsp = json.dumps({'session': querydict['session'], 'data': 'DECODEERROR'}) - start_response('200 OK', [('Content-Type', - 'application/json; charset=utf-8')]) + start_response('200 OK', headers) return [rsp] - start_response('404 Not Found', []) - return ["Unrecognized directive (404)"] + start_response('404 Not Found', headers) + return ["404 Unrecognized resource"] def serve(): @@ -166,6 +208,8 @@ class HttpApi(object): def start(self): self.server = eventlet.spawn(serve) +_cleaner = eventlet.spawn(_sessioncleaner) +