From ca0679bab296d284f01985b57d62a83dafba6a4c Mon Sep 17 00:00:00 2001 From: aliaksander-samuseu Date: Wed, 24 Jul 2024 22:32:06 +0300 Subject: [PATCH] Create Passport_and_Duo-Universal_ignore_empty.py Added a small patch to prevent attribute removals in case upstream IDP sent an empty value for some attribute Signed-off-by: aliaksander-samuseu --- ...Passport_and_Duo-Universal_ignore_empty.py | 641 ++++++++++++++++++ 1 file changed, 641 insertions(+) create mode 100644 Server/integrations/duo.passport.combine/Passport_and_Duo-Universal_ignore_empty.py diff --git a/Server/integrations/duo.passport.combine/Passport_and_Duo-Universal_ignore_empty.py b/Server/integrations/duo.passport.combine/Passport_and_Duo-Universal_ignore_empty.py new file mode 100644 index 000000000..6351351e3 --- /dev/null +++ b/Server/integrations/duo.passport.combine/Passport_and_Duo-Universal_ignore_empty.py @@ -0,0 +1,641 @@ +# oxAuth is available under the MIT License (2008). See http://opensource.org/licenses/MIT for full text. +# Copyright (c) 2019, Gluu +# +# Author: Jose Gonzalez +# Author: Yuriy Movchan +# + +# Patched by Alex: this version will ignore attributes with empty values send by upstream IDP, preventing removal of values previously assigned to users in the local database +# See the additional "if" clause in fillUser() function checking for an empty values in response + +from org.gluu.jsf2.service import FacesService +from org.gluu.jsf2.message import FacesMessages + +from org.gluu.oxauth.model.common import User, WebKeyStorage +from org.gluu.oxauth.model.configuration import AppConfiguration +from org.gluu.oxauth.model.crypto import CryptoProviderFactory +from org.gluu.oxauth.model.jwt import Jwt, JwtClaimName +from org.gluu.oxauth.model.util import Base64Util +from org.gluu.oxauth.service import AppInitializer, AuthenticationService +from org.gluu.oxauth.service.common import UserService, EncryptionService +from org.gluu.oxauth.service.net import HttpService +from org.gluu.oxauth.security import Identity +from org.gluu.oxauth.util import ServerUtil +from org.gluu.config.oxtrust import LdapOxPassportConfiguration +from org.gluu.model.custom.script.type.auth import PersonAuthenticationType +from org.gluu.persist import PersistenceEntryManager +from org.gluu.service.cdi.util import CdiUtil +from org.gluu.util import StringHelper +from java.util import ArrayList, Arrays, Collections + +from javax.faces.application import FacesMessage +from javax.faces.context import FacesContext + +import json +import sys +import datetime + +from org.gluu.util import ArrayHelper + +class PersonAuthentication(PersonAuthenticationType): + def __init__(self, currentTimeMillis): + self.currentTimeMillis = currentTimeMillis + + def init(self, customScript, configurationAttributes): + print "Passport. init called" + + self.extensionModule = self.loadExternalModule(configurationAttributes.get("extension_module")) + extensionResult = self.extensionInit(configurationAttributes) + if extensionResult != None: + return extensionResult + + print "Passport. init. Behaviour is social" + success = self.processKeyStoreProperties(configurationAttributes) + + if success: + self.providerKey = "provider" + self.customAuthzParameter = self.getCustomAuthzParameter(configurationAttributes.get("authz_req_param_provider")) + self.passportDN = self.getPassportConfigDN() + print "Passport. init. Initialization success" + else: + print "Passport. init. Initialization failed" + return success + + + def destroy(self, configurationAttributes): + print "Passport. destroy called" + return True + + + def getApiVersion(self): + return 11 + + def getAuthenticationMethodClaims(self, requestParameters): + return None + + def isValidAuthenticationMethod(self, usageType, configurationAttributes): + return True + + + def getAlternativeAuthenticationMethod(self, usageType, configurationAttributes): + return None + + + def authenticate(self, configurationAttributes, requestParameters, step): + + extensionResult = self.extensionAuthenticate(configurationAttributes, requestParameters, step) + if extensionResult != None: + return extensionResult + + print "Passport. authenticate for step %s called" % str(step) + identity = CdiUtil.bean(Identity) + + # Loading self.registeredProviders in case passport destroyed + if not hasattr(self,'registeredProviders'): + print "Passport. Fetching registered providers." + self.parseProviderConfigs() + + if step == 1: + # Get JWT token + jwt_param = ServerUtil.getFirstValue(requestParameters, "user") + + if jwt_param != None: + print "Passport. authenticate for step 1. JWT user profile token found" + + # Parse JWT and validate + jwt = Jwt.parse(jwt_param) + if not self.validSignature(jwt): + return False + + if self.jwtHasExpired(jwt): + return False + + (user_profile, jsonp) = self.getUserProfile(jwt) + if user_profile == None: + return False + + sessionAttributes = identity.getSessionId().getSessionAttributes() + self.skipProfileUpdate = StringHelper.equalsIgnoreCase(sessionAttributes.get("skipPassportProfileUpdate"), "true") + + return self.attemptAuthentication(identity, user_profile, jsonp) + + #See passportlogin.xhtml + provider = ServerUtil.getFirstValue(requestParameters, "loginForm:provider") + if StringHelper.isEmpty(provider): + + #it's username + passw auth + print "Passport. authenticate for step 1. Basic authentication detected" + logged_in = False + + credentials = identity.getCredentials() + user_name = credentials.getUsername() + user_password = credentials.getPassword() + + if StringHelper.isNotEmptyString(user_name) and StringHelper.isNotEmptyString(user_password): + authenticationService = CdiUtil.bean(AuthenticationService) + logged_in = authenticationService.authenticate(user_name, user_password) + + print "Passport. authenticate for step 1. Basic authentication returned: %s" % logged_in + return logged_in + + elif provider in self.registeredProviders: + #it's a recognized external IDP + identity.setWorkingParameter("selectedProvider", provider) + print "Passport. authenticate for step 1. Retrying step 1" + #see prepareForStep (step = 1) + return True + + if step == 2: + mail = ServerUtil.getFirstValue(requestParameters, "loginForm:email") + jsonp = identity.getWorkingParameter("passport_user_profile") + + if mail == None: + self.setMessageError(FacesMessage.SEVERITY_ERROR, "Email was missing in user profile") + elif jsonp != None: + # Completion of profile takes place + user_profile = json.loads(jsonp) + user_profile["mail"] = [ mail ] + + return self.attemptAuthentication(identity, user_profile, jsonp) + + print "Passport. authenticate for step 2. Failed: expected mail value in HTTP request and json profile in session" + return False + + + def prepareForStep(self, configurationAttributes, requestParameters, step): + + extensionResult = self.extensionPrepareForStep(configurationAttributes, requestParameters, step) + if extensionResult != None: + return extensionResult + + print "Passport. prepareForStep called %s" % str(step) + identity = CdiUtil.bean(Identity) + + if step == 1: + #re-read the strategies config (for instance to know which strategies have enabled the email account linking) + self.parseProviderConfigs() + identity.setWorkingParameter("externalProviders", json.dumps(self.registeredProviders)) + + providerParam = self.customAuthzParameter + url = None + + sessionAttributes = identity.getSessionId().getSessionAttributes() + self.skipProfileUpdate = StringHelper.equalsIgnoreCase(sessionAttributes.get("skipPassportProfileUpdate"), "true") + + #this param could have been set previously in authenticate step if current step is being retried + provider = identity.getWorkingParameter("selectedProvider") + if provider != None: + url = self.getPassportRedirectUrl(provider) + identity.setWorkingParameter("selectedProvider", None) + + elif providerParam != None: + paramValue = sessionAttributes.get(providerParam) + + if paramValue != None: + print "Passport. prepareForStep. Found value in custom param of authorization request: %s" % paramValue + provider = self.getProviderFromJson(paramValue) + + if provider == None: + print "Passport. prepareForStep. A provider value could not be extracted from custom authorization request parameter" + elif not provider in self.registeredProviders: + print "Passport. prepareForStep. Provider '%s' not part of known configured IDPs/OPs" % provider + else: + url = self.getPassportRedirectUrl(provider) + + if url == None: + print "Passport. prepareForStep. A page to manually select an identity provider will be shown" + else: + facesService = CdiUtil.bean(FacesService) + facesService.redirectToExternalURL(url) + + return True + + + def getExtraParametersForStep(self, configurationAttributes, step): + print "Passport. getExtraParametersForStep called" + if step == 1: + return Arrays.asList("selectedProvider", "externalProviders") + elif step == 2: + return Arrays.asList("passport_user_profile") + return None + + + def getCountAuthenticationSteps(self, configurationAttributes): + print "Passport. getCountAuthenticationSteps called" + identity = CdiUtil.bean(Identity) + if identity.getWorkingParameter("passport_user_profile") != None: + return 2 + return 1 + + + def getPageForStep(self, configurationAttributes, step): + print "Passport. getPageForStep called" + + extensionResult = self.extensionGetPageForStep(configurationAttributes, step) + if extensionResult != None: + return extensionResult + + if step == 1: + return "/auth/passport/passportlogin.xhtml" + return "/auth/passport/passportpostlogin.xhtml" + + + def getNextStep(self, configurationAttributes, requestParameters, step): + + if step == 1: + identity = CdiUtil.bean(Identity) + provider = identity.getWorkingParameter("selectedProvider") + if provider != None: + return 1 + + return -1 + + def getLogoutExternalUrl(self, configurationAttributes, requestParameters): + print "Get external logout URL call" + return None + + def logout(self, configurationAttributes, requestParameters): + return True + +# Extension module related functions + + def extensionInit(self, configurationAttributes): + + if self.extensionModule == None: + return None + return self.extensionModule.init(configurationAttributes) + + + def extensionAuthenticate(self, configurationAttributes, requestParameters, step): + + if self.extensionModule == None: + return None + return self.extensionModule.authenticate(configurationAttributes, requestParameters, step) + + + def extensionPrepareForStep(self, configurationAttributes, requestParameters, step): + + if self.extensionModule == None: + return None + return self.extensionModule.prepareForStep(configurationAttributes, requestParameters, step) + + + def extensionGetPageForStep(self, configurationAttributes, step): + + if self.extensionModule == None: + return None + return self.extensionModule.getPageForStep(configurationAttributes, step) + +# Initalization routines + + def loadExternalModule(self, simpleCustProperty): + + if simpleCustProperty != None: + print "Passport. loadExternalModule. Loading passport extension module..." + moduleName = simpleCustProperty.getValue2() + try: + module = __import__(moduleName) + return module + except: + print "Passport. loadExternalModule. Failed to load module %s" % moduleName + print "Exception: ", sys.exc_info()[1] + print "Passport. loadExternalModule. Flow will be driven entirely by routines of main passport script" + return None + + + def processKeyStoreProperties(self, attrs): + file = attrs.get("key_store_file") + password = attrs.get("key_store_password") + + if file != None and password != None: + file = file.getValue2() + password = password.getValue2() + + if StringHelper.isNotEmpty(file) and StringHelper.isNotEmpty(password): + self.keyStoreFile = file + self.keyStorePassword = password + return True + + print "Passport. readKeyStoreProperties. Properties key_store_file or key_store_password not found or empty" + return False + + + def getCustomAuthzParameter(self, simpleCustProperty): + + customAuthzParameter = None + if simpleCustProperty != None: + prop = simpleCustProperty.getValue2() + if StringHelper.isNotEmpty(prop): + customAuthzParameter = prop + + if customAuthzParameter == None: + print "Passport. getCustomAuthzParameter. No custom param for OIDC authz request in script properties" + print "Passport. getCustomAuthzParameter. Passport flow cannot be initiated by doing an OpenID connect authorization request" + else: + print "Passport. getCustomAuthzParameter. Custom param for OIDC authz request in script properties: %s" % customAuthzParameter + + return customAuthzParameter + +# Configuration parsing + + def getPassportConfigDN(self): + + f = open('/etc/gluu/conf/gluu.properties', 'r') + for line in f: + prop = line.split("=") + if prop[0] == "oxpassport_ConfigurationEntryDN": + prop.pop(0) + break + + f.close() + return "=".join(prop).strip() + + + def parseAllProviders(self): + + registeredProviders = {} + print "Passport. parseAllProviders. Adding providers" + entryManager = CdiUtil.bean(PersistenceEntryManager) + + config = LdapOxPassportConfiguration() + config = entryManager.find(config.getClass(), self.passportDN).getPassportConfiguration() + config = config.getProviders() if config != None else config + + if config != None and len(config) > 0: + for prvdetails in config: + if prvdetails.isEnabled(): + registeredProviders[prvdetails.getId()] = { + "emailLinkingSafe": prvdetails.isEmailLinkingSafe(), + "requestForEmail" : prvdetails.isRequestForEmail(), + "logo_img": prvdetails.getLogoImg(), + "displayName": prvdetails.getDisplayName(), + "type": prvdetails.getType() + } + + return registeredProviders + + + def parseProviderConfigs(self): + + registeredProviders = {} + try: + registeredProviders = self.parseAllProviders() + toRemove = [] + + for provider in registeredProviders: + if registeredProviders[provider]["type"] == "saml": + toRemove.append(provider) + else: + registeredProviders[provider]["saml"] = False + + for provider in toRemove: + registeredProviders.pop(provider) + + if len(registeredProviders.keys()) > 0: + print "Passport. parseProviderConfigs. Configured providers:", registeredProviders + else: + print "Passport. parseProviderConfigs. No providers registered yet" + except: + print "Passport. parseProviderConfigs. An error occurred while building the list of supported authentication providers", sys.exc_info()[1] + + self.registeredProviders = registeredProviders + +# Auxiliary routines + + def getProviderFromJson(self, providerJson): + + provider = None + try: + obj = json.loads(Base64Util.base64urldecodeToString(providerJson)) + provider = obj[self.providerKey] + except: + print "Passport. getProviderFromJson. Could not parse provided Json string. Returning None" + + return provider + + + def getPassportRedirectUrl(self, provider): + + # provider is assumed to exist in self.registeredProviders + url = None + try: + facesContext = CdiUtil.bean(FacesContext) + tokenEndpoint = "https://%s/passport/token" % facesContext.getExternalContext().getRequest().getServerName() + + httpService = CdiUtil.bean(HttpService) + httpclient = httpService.getHttpsClient() + + print "Passport. getPassportRedirectUrl. Obtaining token from passport at %s" % tokenEndpoint + resultResponse = httpService.executeGet(httpclient, tokenEndpoint, Collections.singletonMap("Accept", "text/json")) + httpResponse = resultResponse.getHttpResponse() + bytes = httpService.getResponseContent(httpResponse) + + response = httpService.convertEntityToString(bytes) + print "Passport. getPassportRedirectUrl. Response was %s" % httpResponse.getStatusLine().getStatusCode() + + tokenObj = json.loads(response) + url = "/passport/auth/%s/%s" % (provider, tokenObj["token_"]) + except: + print "Passport. getPassportRedirectUrl. Error building redirect URL: ", sys.exc_info()[1] + + return url + + + def validSignature(self, jwt): + + print "Passport. validSignature. Checking JWT token signature" + valid = False + + try: + appConfiguration = AppConfiguration() + appConfiguration.setWebKeysStorage(WebKeyStorage.KEYSTORE) + appConfiguration.setKeyStoreFile(self.keyStoreFile) + appConfiguration.setKeyStoreSecret(self.keyStorePassword) + appConfiguration.setKeyRegenerationEnabled(False) + + cryptoProvider = CryptoProviderFactory.getCryptoProvider(appConfiguration) + valid = cryptoProvider.verifySignature(jwt.getSigningInput(), jwt.getEncodedSignature(), jwt.getHeader().getKeyId(), + None, None, jwt.getHeader().getSignatureAlgorithm()) + except: + print "Exception: ", sys.exc_info()[1] + + print "Passport. validSignature. Validation result was %s" % valid + return valid + + + def jwtHasExpired(self, jwt): + # Check if jwt has expired + jwt_claims = jwt.getClaims() + try: + exp_date_timestamp = float(jwt_claims.getClaimAsString(JwtClaimName.EXPIRATION_TIME)) + exp_date = datetime.datetime.fromtimestamp(exp_date_timestamp) + hasExpired = exp_date < datetime.datetime.now() + except: + print "Exception: The JWT does not have '%s' attribute" % JwtClaimName.EXPIRATION_TIME + return False + + return hasExpired + + + def getUserProfile(self, jwt): + jwt_claims = jwt.getClaims() + user_profile_json = None + + try: + user_profile_json = CdiUtil.bean(EncryptionService).decrypt(jwt_claims.getClaimAsString("data")) + user_profile = json.loads(user_profile_json) + except: + print "Passport. getUserProfile. Problem obtaining user profile json representation" + + return (user_profile, user_profile_json) + + + def attemptAuthentication(self, identity, user_profile, user_profile_json): + + uidKey = "uid" + if not self.checkRequiredAttributes(user_profile, [uidKey, self.providerKey]): + return False + + provider = user_profile[self.providerKey] + if not provider in self.registeredProviders: + print "Passport. attemptAuthentication. Identity Provider %s not recognized" % provider + return False + + uid = user_profile[uidKey][0] + externalUid = "passport-%s:%s" % (provider, uid) + + userService = CdiUtil.bean(UserService) + userByUid = userService.getUserByAttribute("oxExternalUid", externalUid, True) + + email = None + if "mail" in user_profile: + email = user_profile["mail"] + if len(email) == 0: + email = None + else: + email = email[0] + user_profile["mail"] = [ email ] + + if email == None and self.registeredProviders[provider]["requestForEmail"]: + print "Passport. attemptAuthentication. Email was not received" + + if userByUid != None: + # This avoids asking for the email over every login attempt + email = userByUid.getAttribute("mail") + if email != None: + print "Passport. attemptAuthentication. Filling missing email value with %s" % email + user_profile["mail"] = [ email ] + + if email == None: + # Store user profile in session and abort this routine + identity.setWorkingParameter("passport_user_profile", user_profile_json) + return True + + userByMail = None if email == None else userService.getUserByAttribute("nidGSuite", email) + + # Determine if we should add entry, update existing, or deny access + doUpdate = False + doAdd = False + if userByUid != None: + print "User with externalUid '%s' already exists" % externalUid + if userByMail == None: + doUpdate = True + else: + if userByMail.getUserId() == userByUid.getUserId(): + doUpdate = True + else: + print "Users with externalUid '%s' and mail '%s' are different. Access will be denied. Impersonation attempt?" % (externalUid, email) + self.setMessageError(FacesMessage.SEVERITY_ERROR, "Email value corresponds to an already existing provisioned account") + else: + if userByMail == None: + doAdd = True + elif self.registeredProviders[provider]["emailLinkingSafe"]: + + tmpList = userByMail.getAttributeValues("oxExternalUid") + tmpList = ArrayList() if tmpList == None else ArrayList(tmpList) + tmpList.add(externalUid) + userByMail.setAttribute("oxExternalUid", tmpList, True) + + userByUid = userByMail + print "External user supplying mail %s will be linked to existing account '%s'" % (email, userByMail.getUserId()) + doUpdate = True + else: + print "An attempt to supply an email of an existing user was made. Turn on 'emailLinkingSafe' if you want to enable linking" + self.setMessageError(FacesMessage.SEVERITY_ERROR, "Email value corresponds to an already existing account. If you already have a username and password use those instead of an external authentication site to get access.") + + username = None + try: + if doUpdate: + username = userByUid.getUserId() + print "Passport. attemptAuthentication. Updating user %s" % username + self.updateUser(userByUid, user_profile, userService) + elif doAdd: + print "Passport. attemptAuthentication. Creating user %s" % externalUid + newUser = self.addUser(externalUid, user_profile, userService) + username = newUser.getUserId() + except: + print "Exception: ", sys.exc_info()[1] + print "Passport. attemptAuthentication. Authentication failed" + return False + + if username == None: + print "Passport. attemptAuthentication. Authentication attempt was rejected" + return False + else: + logged_in = CdiUtil.bean(AuthenticationService).authenticate(username) + print "Passport. attemptAuthentication. Authentication for %s returned %s" % (username, logged_in) + return logged_in + + + def setMessageError(self, severity, msg): + facesMessages = CdiUtil.bean(FacesMessages) + facesMessages.setKeepMessages() + facesMessages.clear() + facesMessages.add(severity, msg) + + + def checkRequiredAttributes(self, profile, attrs): + + for attr in attrs: + if (not attr in profile) or len(profile[attr]) == 0: + print "Passport. checkRequiredAttributes. Attribute '%s' is missing in profile" % attr + return False + return True + + + def addUser(self, externalUid, profile, userService): + + newUser = User() + #Fill user attrs + newUser.setAttribute("oxExternalUid", externalUid, True) + self.fillUser(newUser, profile) + newUser = userService.addUser(newUser, True) + return newUser + + + def updateUser(self, foundUser, profile, userService): + + # when this is false, there might still some updates taking place (e.g. not related to profile attrs released by external provider) + if (not self.skipProfileUpdate): + self.fillUser(foundUser, profile) + userService.updateUser(foundUser) + + + def fillUser(self, foundUser, profile): + + for attr in profile: + # "provider" is disregarded if part of mapping + if attr != self.providerKey: + values = profile[attr] + print "%s = %s" % (attr, values) + # Patch to prevent clearing of attribute values on the local users if IDP send an empty value in its response + if (values != None and not (ArrayHelper.isEmpty(values))): + foundUser.setAttribute(attr, values) + else: + print "Ommiting attribute named '%s' as upstream IDP sent an empty value for it" % attr + + if attr == "mail": + oxtrustMails = [] + for mail in values: + oxtrustMails.append('{"value":"%s","primary":false}' % mail) + foundUser.setAttribute("oxTrustEmail", oxtrustMails)