diff --git a/confluent_client/bin/nodepower b/confluent_client/bin/nodepower index dffa241c..ccbf41bf 100755 --- a/confluent_client/bin/nodepower +++ b/confluent_client/bin/nodepower @@ -55,7 +55,7 @@ if len(args) > 1: if setstate == 'softoff': setstate = 'shutdown' -if setstate not in (None, 'on', 'off', 'shutdown', 'boot', 'reset', 'pdu_status', 'pdu_stat', 'pdu_on', 'pdu_off'): +if setstate not in (None, 'on', 'off', 'shutdown', 'boot', 'reset', 'pdu_status', 'pdu_stat', 'pdu_on', 'pdu_off', 'status', 'stat', 'state'): argparser.print_help() sys.exit(1) session = client.Command() diff --git a/confluent_server/confluent/auth.py b/confluent_server/confluent/auth.py index 9e675fbb..ce8cfd49 100644 --- a/confluent_server/confluent/auth.py +++ b/confluent_server/confluent/auth.py @@ -162,6 +162,10 @@ def authorize(name, element, tenant=False, operation='create', return False manager = configmanager.ConfigManager(tenant, username=user) userobj = manager.get_user(user) + if element and (element.startswith('/sessions/current/webauthn/registered_credentials/') or element.startswith('/sessions/current/webauthn/validate/')): + return userobj, manager, user, tenant, skipuserobj + if userobj and userobj.get('role', None) == 'Stub': + userobj = None if not userobj: for group in userutil.grouplist(user): userobj = manager.get_usergroup(group) diff --git a/confluent_server/confluent/config/configmanager.py b/confluent_server/confluent/config/configmanager.py index 5c21d89f..e88e9483 100644 --- a/confluent_server/confluent/config/configmanager.py +++ b/confluent_server/confluent/config/configmanager.py @@ -113,7 +113,7 @@ _attraliases = { 'bmcpass': 'secret.hardwaremanagementpassword', 'switchpass': 'secret.hardwaremanagementpassword', } -_validroles = ('Administrator', 'Operator', 'Monitor') +_validroles = ('Administrator', 'Operator', 'Monitor', 'Stub') membership_callback = None @@ -2447,10 +2447,10 @@ class ConfigManager(object): uid = tmpconfig[confarea].get('id', None) displayname = tmpconfig[confarea].get('displayname', None) self.create_user(user, uid=uid, displayname=displayname) - if 'cryptpass' in tmpconfig[confarea][user]: - self._cfgstore['users'][user]['cryptpass'] = \ - tmpconfig[confarea][user]['cryptpass'] - _mark_dirtykey('users', user, self.tenant) + for attrname in ('authid', 'authenticators', 'cryptpass'): + if attrname in tmpconfig[confarea][user]: + self._cfgstore['users'][user][attrname] = tmpconfig[confarea][user][attrname] + _mark_dirtykey('users', user, self.tenant) if sync: self._bg_sync_to_file() @@ -2777,8 +2777,8 @@ def dump_db_to_directory(location, password, redact=None, skipkeys=False): cfgfile.write('\n') bkupglobals = get_globals() if bkupglobals: - json.dump(bkupglobals, open(os.path.join(location, 'globals.json'), - 'w')) + with open(os.path.join(location, 'globals.json'), 'w') as globout: + json.dump(bkupglobals, globout) try: for tenant in os.listdir( os.path.join(ConfigManager._cfgdir, '/tenants/')): diff --git a/confluent_server/confluent/httpapi.py b/confluent_server/confluent/httpapi.py index d6b4af5d..ec80a095 100644 --- a/confluent_server/confluent/httpapi.py +++ b/confluent_server/confluent/httpapi.py @@ -22,9 +22,9 @@ try: except ModuleNotFoundError: import http.cookies as Cookie try: - import pywarp + import confluent.webauthn as webauthn except ImportError: - pywarp = None + webauthn = None import confluent.auth as auth import confluent.config.attributes as attribs import confluent.consoleserver as consoleserver @@ -211,6 +211,8 @@ def _should_skip_authlog(env): if '/sessions/current/async' in env['PATH_INFO']: # this is effectively invisible return True + if '/sessions/current/webauthn/registered_credentials' in env['PATH_INFO']: + return True if (env['REQUEST_METHOD'] == 'GET' and ('/sensors/' in env['PATH_INFO'] or '/health/' in env['PATH_INFO'] or @@ -267,18 +269,24 @@ def _csrf_valid(env, session): env['HTTP_CONFLUENTAUTHTOKEN'] == session['csrftoken']) -def _authorize_request(env, operation): +def _authorize_request(env, operation, reqbody): """Grant/Deny access based on data from wsgi env """ authdata = None name = '' sessionid = None + sessid = None cookie = Cookie.SimpleCookie() element = env['PATH_INFO'] if element.startswith('/sessions/current/'): - element = None - if 'HTTP_COOKIE' in env: + if (element.startswith('/sessions/current/webauthn/registered_credentials/') + or element.startswith('/sessions/current/webauthn/validate/')): + name = element.rsplit('/')[-1] + authdata = auth.authorize(name, element=element, operation=operation) + else: + element = None + if (not authdata) and 'HTTP_COOKIE' in env: cidx = (env['HTTP_COOKIE']).find('confluentsessionid=') if cidx >= 0: sessionid = env['HTTP_COOKIE'][cidx+19:cidx+51] @@ -326,18 +334,13 @@ def _authorize_request(env, operation): return {'code': 403} elif not authdata: return {'code': 401} - sessid = util.randomstring(32) - while sessid in httpsessions: - sessid = util.randomstring(32) - httpsessions[sessid] = {'name': name, 'expiry': time.time() + 90, - 'skipuserobject': authdata[4], - 'inflight': set([])} - if 'HTTP_CONFLUENTAUTHTOKEN' in env: - httpsessions[sessid]['csrftoken'] = util.randomstring(32) - cookie['confluentsessionid'] = util.stringify(sessid) - cookie['confluentsessionid']['secure'] = 1 - cookie['confluentsessionid']['httponly'] = 1 - cookie['confluentsessionid']['path'] = '/' + sessid = _establish_http_session(env, authdata, name, cookie) + if authdata and element and element.startswith('/sessions/current/webauthn/validate/'): + if webauthn: + for rsp in webauthn.handle_api_request(element, env, None, authdata[2], authdata[1], None, reqbody, None): + if rsp['verified']: + sessid = _establish_http_session(env, authdata, name, cookie) + break skiplog = _should_skip_authlog(env) if authdata: auditmsg = { @@ -356,17 +359,32 @@ def _authorize_request(env, operation): auditmsg['user'] = util.stringify(authdata[2]) if sessid is not None: authinfo['sessionid'] = sessid + if 'csrftoken' in httpsessions[sessid]: + authinfo['authtoken'] = httpsessions[sessid]['csrftoken'] + httpsessions[sessid]['cfgmgr'] = authdata[1] if not skiplog: auditlog.log(auditmsg) - if 'csrftoken' in httpsessions[sessid]: - authinfo['authtoken'] = httpsessions[sessid]['csrftoken'] - httpsessions[sessid]['cfgmgr'] = authdata[1] return authinfo elif authdata is None: return {'code': 401} else: return {'code': 403} +def _establish_http_session(env, authdata, name, cookie): + sessid = util.randomstring(32) + while sessid in httpsessions: + sessid = util.randomstring(32) + httpsessions[sessid] = {'name': name, 'expiry': time.time() + 90, + 'skipuserobject': authdata[4], + 'inflight': set([])} + if 'HTTP_CONFLUENTAUTHTOKEN' in env: + httpsessions[sessid]['csrftoken'] = util.randomstring(32) + cookie['confluentsessionid'] = util.stringify(sessid) + cookie['confluentsessionid']['secure'] = 1 + cookie['confluentsessionid']['httponly'] = 1 + cookie['confluentsessionid']['path'] = '/' + return sessid + def _pick_mimetype(env): """Detect the http indicated mime to send back. @@ -607,7 +625,7 @@ def resourcehandler_backend(env, start_response): if operation != 'retrieve' and 'restexplorerop' in querydict: operation = querydict['restexplorerop'] del querydict['restexplorerop'] - authorized = _authorize_request(env, operation) + authorized = _authorize_request(env, operation, reqbody) if 'logout' in authorized: start_response('200 Successful logout', headers) yield('{"result": "200 - Successful logout"}') @@ -636,7 +654,7 @@ def resourcehandler_backend(env, start_response): raise Exception("Unrecognized code from auth engine") headers.extend( ("Set-Cookie", m.OutputString()) - for m in authorized['cookie'].values()) + for m in authorized.get('cookie', {}).values()) cfgmgr = authorized['cfgmgr'] if (operation == 'create') and env['PATH_INFO'] == '/sessions/current/async': pagecontent = "" @@ -834,6 +852,14 @@ def resourcehandler_backend(env, start_response): tlvdata.unicode_dictvalues(sessinfo) yield json.dumps(sessinfo) return + elif url.startswith('/sessions/current/webauthn/'): + if not webauthn: + start_response('501 Not Implemented', headers) + yield '' + return + for rsp in webauthn.handle_api_request(url, env, start_response, authorized['username'], cfgmgr, headers, reqbody, authorized): + yield rsp + return resource = '.' + url[url.rindex('/'):] lquerydict = copy.deepcopy(querydict) try: diff --git a/confluent_server/confluent/plugins/hardwaremanagement/ipmi.py b/confluent_server/confluent/plugins/hardwaremanagement/ipmi.py index b5220791..99b94021 100644 --- a/confluent_server/confluent/plugins/hardwaremanagement/ipmi.py +++ b/confluent_server/confluent/plugins/hardwaremanagement/ipmi.py @@ -514,8 +514,11 @@ class IpmiHandler(object): raise exc.TargetEndpointUnreachable(ge.strerror) raise self.ipmicmd = persistent_ipmicmds[(node, tenant)] + giveup = util._monotonic_time() + 60 while not self.ipmicmd.ipmi_session.broken and not self.ipmicmd.ipmi_session.logged: self.ipmicmd.ipmi_session.wait_for_rsp(3) + if util._monotonic_time() > giveup: + self.ipmicmd.ipmi_session.broken = True bootdevices = { 'optical': 'cd' diff --git a/confluent_server/confluent/webauthn.py b/confluent_server/confluent/webauthn.py new file mode 100644 index 00000000..7e3b148f --- /dev/null +++ b/confluent_server/confluent/webauthn.py @@ -0,0 +1,128 @@ +import base64 +import confluent.tlvdata as tlvdata +import confluent.util as util +import json +import pywarp +import pywarp.backends +import pywarp.credentials + +challenges = {} + +class ConfluentBackend(pywarp.backends.CredentialStorageBackend): + def __init__(self, cfg): + self.cfg = cfg + + def get_credential_ids_by_email(self, email): + if not isinstance(email, str): + email = email.decode('utf8') + authenticators = self.cfg.get_user(email).get('authenticators', {}) + if not authenticators: + raise Exception('No authenticators found') + for cid in authenticators: + yield base64.b64decode(cid) + + def get_credential_by_email_id(self, email, id): + if not isinstance(email, str): + email = email.decode('utf8') + authenticators = self.cfg.get_user(email).get('authenticators', {}) + cid = base64.b64encode(id).decode('utf8') + pk = authenticators[cid]['cpk'] + pk = base64.b64decode(pk) + return pywarp.credentials.Credential(credential_id=id, credential_public_key=pk) + + def get_credential_by_email(self, email): + if not isinstance(email, str): + email = email.decode('utf8') + authenticators = self.cfg.get_user(email) + cid = list(authenticators)[0] + cred = authenticators[cid] + cid = base64.b64decode(cred['cid']) + cpk = base64.b64decode(cred['cpk']) + return pywarp.credentials.Credential(credential_id=cid, credential_public_key=cpk) + + def save_credential_for_user(self, email, credential): + if not isinstance(email, str): + email = email.decode('utf8') + cid = base64.b64encode(credential.id).decode('utf8') + credential = {'cid': cid, 'cpk': base64.b64encode(bytes(credential.public_key)).decode('utf8')} + authenticators = self.cfg.get_user(email).get('authenticators', {}) + authenticators[cid] = credential + self.cfg.set_user(email, {'authenticators': authenticators}) + + def save_challenge_for_user(self, email, challenge, type): + if not isinstance(email, str): + email = email.decode('utf8') + challenges[email] = challenge + + def get_challenge_for_user(self, email, type): + if not isinstance(email, str): + email = email.decode('utf8') + return challenges[email] + + +def handle_api_request(url, env, start_response, username, cfm, headers, reqbody, authorized): + if env['REQUEST_METHOD'] != 'POST': + raise Exception('Only POST supported for webauthn operations') + url = url.replace('/sessions/current/webauthn', '') + if url == '/registration_options': + rp = pywarp.RelyingPartyManager('Confluent Web UI', credential_storage_backend=ConfluentBackend(cfm), require_attestation=False) + userinfo = cfm.get_user(username) + if not userinfo: + cfm.create_user(username, role='Stub') + userinfo = cfm.get_user(username) + authid = userinfo.get('authid', None) + if not authid: + authid = util.randomstring(64) + cfm.set_user(username, {'authid': authid}) + opts = rp.get_registration_options(username) + # pywarp generates an id derived + # from username, which is a 'must not' in the spec + # we replace that with a complying approach + opts['user']['id'] = authid + if 'icon' in opts['user']: + del opts['user']['icon'] + if 'id' in opts['rp']: + del opts['rp']['id'] + start_response('200 OK', headers) + yield json.dumps(opts) + elif url.startswith('/registered_credentials/'): + username = url.rsplit('/', 1)[-1] + rp = pywarp.RelyingPartyManager('Confluent Web UI', credential_storage_backend=ConfluentBackend(cfm)) + if not isinstance(username, bytes): + username = username.encode('utf8') + opts = rp.get_authentication_options(username) + opts['challenge'] = base64.b64encode(opts['challenge']).decode('utf8') + start_response('200 OK', headers) + yield json.dumps(opts) + elif url.startswith('/validate/'): + username = url.rsplit('/', 1)[-1] + if not isinstance(username, bytes): + username = username.encode('utf8') + rp = pywarp.RelyingPartyManager('Confluent Web UI', credential_storage_backend=ConfluentBackend(cfm)) + req = json.loads(reqbody) + for x in req: + req[x] = base64.b64decode(req[x].replace('-', '+').replace('_', '/')) + req['email'] = username + rsp = rp.verify(**req) + if start_response: + start_response('200 OK', headers) + sessinfo = {'username': username} + if 'authtoken' in authorized: + sessinfo['authtoken'] = authorized['authtoken'] + if 'sessionid' in authorized: + sessinfo['sessionid'] = authorized['sessionid'] + tlvdata.unicode_dictvalues(sessinfo) + yield json.dumps(sessinfo) + else: + yield rsp + elif url == '/register_credential': + rp = pywarp.RelyingPartyManager('Confluent Web UI', credential_storage_backend=ConfluentBackend(cfm), require_attestation=False) + req = json.loads(reqbody) + for x in req: + req[x] = base64.b64decode(req[x].replace('-', '+').replace('_', '/')) + if not isinstance(username, bytes): + username = username.encode('utf8') + req['email'] = username + rsp = rp.register(**req) + start_response('200 OK', headers) + yield json.dumps(rsp) \ No newline at end of file