diff --git a/AUTHORS b/AUTHORS index 05f7ab5c5..28e89ca3e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -54,7 +54,7 @@ Written in 2016 by Qiushi Yan - yanqiushi Written in 2017 by Oleg Andrieiev - oandreyev Written in 2018 by Zhang Wei - zhangwei1979 Written in 2018 by Nirendra Singh - nirendra10695 -Written in 2018-2021 by Ayman Abi Abdallah - aabiabdallah +Written in 2018-2023 by Ayman Abi Abdallah - aabiabdallah Written in 2019 by Daniel Taylor - danieltaylor-nz Written in 2020 by Jacob Barnes - Tellan Written in 2020 by Amir Anjomshoaa - amiranjom @@ -102,7 +102,7 @@ Written in 2016 by Qiushi Yan - yanqiushi Written in 2017 by Oleg Andrieiev - oandreyev Written in 2018 by Zhang Wei - zhangwei1979 Written in 2018 by Nirendra Singh - nirendra10695 -Written in 2018-2020 by Ayman Abi Abdallah - aabiabdallah +Written in 2018-2023 by Ayman Abi Abdallah - aabiabdallah Written in 2019 by Daniel Taylor - danieltaylor-nz Written in 2020 by Jacob Barnes - Tellan Written in 2020 by Amir Anjomshoaa - amiranjom diff --git a/framework/build.gradle b/framework/build.gradle index 65137c15c..8fb85978a 100644 --- a/framework/build.gradle +++ b/framework/build.gradle @@ -40,6 +40,7 @@ dependencyUpdates.resolutionStrategy { componentSelection { rules -> rules.all { repositories { flatDir name: 'localLib', dirs: projectDir.absolutePath + '/lib' mavenCentral() + maven { url "https://build.shibboleth.net/maven/releases" } } sourceCompatibility = 11 @@ -187,6 +188,20 @@ dependencies { // Liquibase (for future reference, not used yet) // api 'org.liquibase:liquibase-core:3.4.2' // Apache 2.0 + // pac4j + implementation 'org.pac4j:pac4j-core:5.7.1' + implementation 'org.pac4j:pac4j-javaee:5.7.1' + implementation 'org.pac4j:pac4j-oauth:5.7.1' + implementation 'org.pac4j:pac4j-oidc:5.7.1' + implementation ('org.pac4j:pac4j-saml:5.7.1') { + exclude group: 'org.springframework' + exclude group: 'org.bouncycastle' + } + + // explicit dependencies to get newer versions + implementation 'org.springframework:spring-core:5.3.29' + implementation 'org.bouncycastle:bcprov-jdk18on:1.75' + // ========== test dependencies ========== // junit-platform-launcher is a dependency from spock-core, included explicitly to get more recent version as needed diff --git a/framework/entity/SecurityEntities.xml b/framework/entity/SecurityEntities.xml index e5e7f8a26..0091ff865 100644 --- a/framework/entity/SecurityEntities.xml +++ b/framework/entity/SecurityEntities.xml @@ -17,6 +17,7 @@ along with this software (see the LICENSE.md file). If not, see + @@ -545,4 +546,201 @@ along with this software (see the LICENSE.md file). If not, see + + + + + + + + Used for inbound authentication flows + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Role name in IdP server + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Specific to Keycloak. + + + Specific to Keycloak. + + + + + + A nonce is String value used to associate a Client session with an ID Token, and to mitigate replay attacks. + Required for implicit flows. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/framework/service/org/moqui/impl/AuthServices.xml b/framework/service/org/moqui/impl/AuthServices.xml new file mode 100644 index 000000000..0c1ce29ff --- /dev/null +++ b/framework/service/org/moqui/impl/AuthServices.xml @@ -0,0 +1,28 @@ + + + + + + + Performs a login operation on the IdP server. + + + + + + + + + Handles the login callback and logs the user in locally. + + + + + Performs a logout operation both locally and on the IdP server. + + + + + + + diff --git a/framework/service/org/moqui/impl/UserServices.xml b/framework/service/org/moqui/impl/UserServices.xml index 6b433bba6..7ae1bd2a0 100644 --- a/framework/service/org/moqui/impl/UserServices.xml +++ b/framework/service/org/moqui/impl/UserServices.xml @@ -30,8 +30,8 @@ along with this software (see the LICENSE.md file). If not, see - - + + @@ -51,15 +51,17 @@ along with this software (see the LICENSE.md file). If not, see - + - - Account created with username ${username} - - Because of password issues not creating account with username ${username} - - + + Account created with username ${username} + + Because of password issues not creating account with username ${username} + + + diff --git a/framework/src/main/groovy/org/moqui/impl/security/AuthenticationClientFactory.groovy b/framework/src/main/groovy/org/moqui/impl/security/AuthenticationClientFactory.groovy new file mode 100644 index 000000000..f40e6dea0 --- /dev/null +++ b/framework/src/main/groovy/org/moqui/impl/security/AuthenticationClientFactory.groovy @@ -0,0 +1,238 @@ +package org.moqui.impl.security + +import com.nimbusds.oauth2.sdk.auth.ClientAuthenticationMethod +import org.moqui.context.ExecutionContext +import org.moqui.entity.EntityList +import org.moqui.entity.EntityValue +import org.moqui.resource.ResourceReference +import org.pac4j.core.client.Client +import org.pac4j.oauth.client.* +import org.pac4j.oidc.client.* +import org.pac4j.oidc.config.AppleOidcConfiguration +import org.pac4j.oidc.config.AzureAd2OidcConfiguration +import org.pac4j.oidc.config.KeycloakOidcConfiguration +import org.pac4j.oidc.config.OidcConfiguration +import org.pac4j.saml.client.SAML2Client +import org.pac4j.saml.config.SAML2Configuration +import org.springframework.core.io.FileSystemResource + +/** + * Builds clients for desired authentication flows. + */ +final class AuthenticationClientFactory { + + /** + * Execution context used to access facades. + */ + private ExecutionContext ec + + /** + * Initializes a new {@code AuthenticationClientFactory}. + */ + AuthenticationClientFactory(ExecutionContext ec) { + this.ec = ec + } + + /** + * Builds a client specific to the specified flow. + */ + Client build(String authFlowId) { + + // find auth flow + EntityValue authFlow = ec.entity.find("moqui.security.auth.AuthFlow") + .condition("authFlowId", authFlowId) + .one() + + // build client + if ("AftOidc" == authFlow.authFlowTypeEnumId) { + return buildOidcClient(authFlowId) + } else if ("AftOauth" == authFlow.authFlowTypeEnumId) { + return buildOauthClient(authFlowId) + } else if ("AftSaml" == authFlow.authFlowTypeEnumId) { + return buildSamlClient(authFlowId) + } else { + return null + } + } + + /** + * Builds a client specific to the specified auth flow. + */ + List buildAll() { + EntityList authFlowList = ec.entity.find("moqui.security.auth.AuthFlow").list() + List clientList = [] + for (EntityValue authFlow : authFlowList) { + if ("Y" != authFlow.disabled && "Y" != authFlow.inbound) { + clientList.add(build((String) authFlow.authFlowId)) + } + } + return clientList + } + + private Client buildOidcClient(String authFlowId) { + + // find auth flow + EntityValue authFlow = ec.entity.find("moqui.security.auth.OidcFlow") + .condition("authFlowId", authFlowId) + .one() + + // init client + OidcConfiguration config + OidcClient client + if ("OctApple" == authFlow.clientTypeEnumId) { + config = new AppleOidcConfiguration() + client = new AppleClient(config) + } else if ("OctAzureAd" == authFlow.clientTypeEnumId) { + config = new AzureAd2OidcConfiguration() + client = new AzureAd2Client(config) + } else if ("OctGoogle" == authFlow.clientTypeEnumId) { + config = new OidcConfiguration() + client = new GoogleOidcClient(config) + } else if ("OctKeycloak" == authFlow.clientTypeEnumId) { + config = new KeycloakOidcConfiguration() + config.setRealm(authFlow.realm as String) + config.setBaseUri(authFlow.baseUri as String) + client = new KeycloakOidcClient(config) + } else { + config = new OidcConfiguration() + client = new OidcClient(config) + } + + // common configuration settings + config.setClientId(authFlow.clientId as String) + config.setSecret(authFlow.secret as String) + config.setDiscoveryURI(authFlow.discoveryUri as String) + config.setClientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + config.setPreferredJwsAlgorithmAsString(authFlow.preferredJwsAlgorithm.optionValue as String) + config.setUseNonce("Y" == authFlow.useNonce) + + // common client settings + client.setName(authFlowId) + + return client + } + + private Client buildOauthClient(String authFlowId) { + + // find auth flow + EntityValue authFlow = ec.entity.find("moqui.security.auth.OauthFlow") + .condition("authFlowId", authFlowId) + .one() + + // init client + if ("OctBitBucket" == authFlow.clientTypeEnumId) { + BitbucketClient client = new BitbucketClient() + client.setKey(authFlow.key as String) + client.setSecret(authFlow.secret as String) + client.setName(authFlowId) + return client + } else if ("OctDropBox" == authFlow.clientTypeEnumId) { + DropBoxClient client = new DropBoxClient() + client.setKey(authFlow.key as String) + client.setSecret(authFlow.secret as String) + client.setName(authFlowId) + return client + } else if ("OctFacebook" == authFlow.clientTypeEnumId) { + FacebookClient client = new FacebookClient() + client.setKey(authFlow.key as String) + client.setSecret(authFlow.secret as String) + client.setName(authFlowId) + return client + } else if ("OctFoursquare" == authFlow.clientTypeEnumId) { + FoursquareClient client = new FoursquareClient() + client.setKey(authFlow.key as String) + client.setSecret(authFlow.secret as String) + client.setName(authFlowId) + return client + } else if ("OctGitHub" == authFlow.clientTypeEnumId) { + GitHubClient client = new GitHubClient() + client.setKey(authFlow.key as String) + client.setSecret(authFlow.secret as String) + client.setName(authFlowId) + return client + } else if ("OctGoogle" == authFlow.clientTypeEnumId) { + Google2Client client = new Google2Client() + client.setKey(authFlow.key as String) + client.setSecret(authFlow.secret as String) + client.setName(authFlowId) + return client + } else if ("OctLinkedIn" == authFlow.clientTypeEnumId) { + LinkedIn2Client client = new LinkedIn2Client() + client.setKey(authFlow.key as String) + client.setSecret(authFlow.secret as String) + client.setName(authFlowId) + return client + } else if ("OctPaypal" == authFlow.clientTypeEnumId) { + PayPalClient client = new PayPalClient() + client.setKey(authFlow.key as String) + client.setSecret(authFlow.secret as String) + client.setName(authFlowId) + return client + } else if ("OctTwitter" == authFlow.clientTypeEnumId) { + TwitterClient client = new TwitterClient() + client.setKey(authFlow.key as String) + client.setSecret(authFlow.secret as String) + client.setName(authFlowId) + return client + } else if ("OctWordPress" == authFlow.clientTypeEnumId) { + WordPressClient client = new WordPressClient() + client.setKey(authFlow.key as String) + client.setSecret(authFlow.secret as String) + client.setName(authFlowId) + return client + } else if ("OctYahoo" == authFlow.clientTypeEnumId) { + YahooClient client = new YahooClient() + client.setKey(authFlow.key as String) + client.setSecret(authFlow.secret as String) + client.setName(authFlowId) + return client + } else if ("OctOauth10" == authFlow.clientTypeEnumId) { + OAuth10Client client = new OAuth10Client() + client.setKey(authFlow.key as String) + client.setSecret(authFlow.secret as String) + client.setName(authFlowId) + return client + } else if ("OctOauth20" == authFlow.clientTypeEnumId) { + OAuth20Client client = new OAuth20Client() + client.setKey(authFlow.key as String) + client.setSecret(authFlow.secret as String) + client.setName(authFlowId) + return client + } + + return null + } + + private Client buildSamlClient(String authFlowId) { + + // find auth flow + EntityValue authFlow = ec.entity.find("moqui.security.auth.SamlFlow") + .condition("authFlowId", authFlowId) + .one() + + // copy keystore to tmp directory + ResourceReference keystoreLocationRef = ec.resource.getLocationReference(authFlow.keystoreLocation as String) + ResourceReference keystoreTempRef = ec.resource.getLocationReference(ec.factory.runtimePath + "/tmp/" + keystoreLocationRef.getFileName()) + keystoreTempRef.putStream(keystoreLocationRef.openStream()) + + // copy metadata to tmp directory + ResourceReference metadataLocationRef = ec.resource.getLocationReference(authFlow.identityProviderMetadataLocation as String) + ResourceReference metadataTempRef = ec.resource.getLocationReference(ec.factory.runtimePath + "/tmp/" + metadataLocationRef.getFileName()) + metadataTempRef.putStream(metadataLocationRef.openStream()) + + // init client + SAML2Configuration config = new SAML2Configuration( + new FileSystemResource(new File(keystoreTempRef.getUri())), + authFlow.keystorePassword as String, + authFlow.privateKeyPassword as String, + new FileSystemResource(new File(metadataTempRef.getUri())) + ) + config.setServiceProviderEntityId(authFlow.serviceProviderEntityId as String) + config.setForceAuth("Y" == authFlow.forceAuth) + config.setPassive("Y" == authFlow.passive) + SAML2Client client = new SAML2Client(config) + client.setName(authFlowId) + + return client + } +} diff --git a/framework/src/main/groovy/org/moqui/impl/security/AuthenticationFlow.groovy b/framework/src/main/groovy/org/moqui/impl/security/AuthenticationFlow.groovy new file mode 100644 index 000000000..e01a34158 --- /dev/null +++ b/framework/src/main/groovy/org/moqui/impl/security/AuthenticationFlow.groovy @@ -0,0 +1,154 @@ +package org.moqui.impl.security + +import org.moqui.context.ExecutionContext +import org.moqui.impl.context.UserFacadeImpl +import org.pac4j.core.authorization.authorizer.DefaultAuthorizers +import org.pac4j.core.client.Client +import org.pac4j.core.config.Config +import org.pac4j.core.engine.DefaultCallbackLogic +import org.pac4j.core.engine.DefaultLogoutLogic +import org.pac4j.core.engine.DefaultSecurityLogic +import org.pac4j.core.profile.ProfileManager +import org.pac4j.core.profile.UserProfile +import org.pac4j.jee.context.JEEContext +import org.pac4j.jee.context.session.JEESessionStore +import org.pac4j.jee.http.adapter.JEEHttpActionAdapter +import org.pac4j.saml.state.SAML2StateGenerator + +class AuthenticationFlow { + + /** + * Performs a login operation. + */ + static void performLogin(ExecutionContext ec) { + + // parameters + String authFlowId = ec.context.get("authFlowId") as String + String returnTo = ec.context.get("returnTo") as String + String baseUrl = ec.web.getWebappRootUrl(true, false) + String callbackUrl = baseUrl + "/sso/callback" + + // init fields required for logic + JEEContext context = new JEEContext(ec.web.request, ec.web.response) + JEESessionStore sessionStore = JEESessionStore.INSTANCE + MoquiSecurityGrantedAccessAdapter securityGrantedAccessAdapter = new MoquiSecurityGrantedAccessAdapter(ec) + JEEHttpActionAdapter actionAdapter = JEEHttpActionAdapter.INSTANCE + + // store return URL + if (returnTo) { + ec.web.sessionAttributes.put("moquiAuthFlowReturnTo", returnTo) + sessionStore.set(context, SAML2StateGenerator.SAML_RELAY_STATE_ATTRIBUTE, returnTo) + } + + // init config + Client client = new AuthenticationClientFactory(ec).build(authFlowId) + Config config = new Config(callbackUrl, client) + + // perform logic + try { + DefaultSecurityLogic.INSTANCE.perform( + context, + sessionStore, + config, + securityGrantedAccessAdapter, + actionAdapter, + authFlowId, + DefaultAuthorizers.IS_AUTHENTICATED, + null + ) + } catch (RuntimeException e) { + ec.logger.error("An error occurred while performing login action", e) + ec.web.response.sendRedirect(baseUrl + "/Login") + } + } + + /** + * Handles the login callback. + */ + static void handleCallback(ExecutionContext ec) { + + // parameters + String baseUrl = ec.web.getWebappRootUrl(true, false) + + // init fields required for logic + JEEContext context = new JEEContext(ec.web.request, ec.web.response) + JEESessionStore sessionStore = JEESessionStore.INSTANCE + MoquiSecurityGrantedAccessAdapter securityGrantedAccessAdapter = new MoquiSecurityGrantedAccessAdapter(ec) + JEEHttpActionAdapter actionAdapter = JEEHttpActionAdapter.INSTANCE + + // init config + Config config = new Config(ec.web.getWebappRootUrl(true, false) + "/sso/callback", new AuthenticationClientFactory(ec).buildAll()) + + // retrieve return URL from "RelayState" parameter (SAML only), or from session attribute + String redirectTo = context.getRequestParameter("RelayState").orElse(ec.web.sessionAttributes.moquiAuthFlowReturnTo as String) + + // perform logic + try { + DefaultCallbackLogic.INSTANCE.perform( + context, + sessionStore, + config, + actionAdapter, + null, + false, + null + ) + + // handle incoming profiles + ProfileManager profileManager = new ProfileManager(context, sessionStore) + securityGrantedAccessAdapter.adapt(context, sessionStore, profileManager.getProfiles()) + + // login user + Optional optionalProfile = profileManager.getProfile() + if (optionalProfile.isPresent()) { + UserProfile profile = optionalProfile.get() + ((UserFacadeImpl) ec.user).internalLoginUser(profile.username) + ec.web.sessionAttributes.put("moquiAuthFlowExternalLogout", true) + ec.web.sessionAttributes.put("moquiAuthFlowReturnTo", redirectTo) + } + } catch (RuntimeException e) { + ec.logger.error("An error occurred while handling callback", e) + ec.web.response.sendRedirect(baseUrl + "/Login") + } + } + + /** + * Performs a logout operation. + */ + static void performLogout(ExecutionContext ec) { + + // parameters + String returnTo = ec.context.get("returnTo") as String + String baseUrl = ec.web.getWebappRootUrl(true, false) + String callbackUrl = returnTo ?: baseUrl + "/Login" + + // init fields required for logic + JEEContext context = new JEEContext(ec.web.request, ec.web.response) + JEESessionStore sessionStore = JEESessionStore.INSTANCE + JEEHttpActionAdapter actionAdapter = JEEHttpActionAdapter.INSTANCE + + // init config + Config config = new Config(baseUrl + "/sso/callback", new AuthenticationClientFactory(ec).buildAll()) + + // perform logic + try { + DefaultLogoutLogic.INSTANCE.perform( + context, + sessionStore, + config, + actionAdapter, + callbackUrl, + null, + false, + false, + true + ) + + // logout user + ec.user.logoutUser() + } catch (RuntimeException e) { + ec.logger.error("An error occurred while performing logout action", e) + ec.web.response.sendRedirect(returnTo ?: baseUrl + "/Login") + } + } +} diff --git a/framework/src/main/groovy/org/moqui/impl/security/MoquiSecurityGrantedAccessAdapter.groovy b/framework/src/main/groovy/org/moqui/impl/security/MoquiSecurityGrantedAccessAdapter.groovy new file mode 100644 index 000000000..355a9d539 --- /dev/null +++ b/framework/src/main/groovy/org/moqui/impl/security/MoquiSecurityGrantedAccessAdapter.groovy @@ -0,0 +1,176 @@ +package org.moqui.impl.security + +import org.moqui.context.ExecutionContext +import org.moqui.entity.EntityList +import org.moqui.entity.EntityValue +import org.pac4j.core.context.WebContext +import org.pac4j.core.context.session.SessionStore +import org.pac4j.core.engine.SecurityGrantedAccessAdapter +import org.pac4j.core.profile.UserProfile + +/** + * Handles creation of Moqui user accounts. + */ +class MoquiSecurityGrantedAccessAdapter implements SecurityGrantedAccessAdapter { + + /** + * Execution context used to access facades. + */ + private ExecutionContext ec + + /** + * Initializes a new {@code MoquiSecurityGrantedAccessAdapter}. + */ + MoquiSecurityGrantedAccessAdapter(ExecutionContext ec) { + this.ec = ec + } + + private Map mapProfileFields(EntityList fieldMaps, Map attributeMap) { + Map destinationMap = new HashMap<>() + + fieldMaps.forEach { + Object srcFieldValue = ec.resource.expression(it.dstFieldExpression as String ?: it.srcFieldName as String, null, attributeMap) + if (srcFieldValue) { + if (it.mappingServiceRegisterId) { + Map mapFieldOut = ec.service.sync().name(it.mappingServiceRegister.serviceName as String) + .parameter("srcFieldValue", srcFieldValue) + .parameter("dstFieldName", it.dstFieldName as String) + .call() + if (mapFieldOut.destinationMap) { + destinationMap.putAll(mapFieldOut.destinationMap as Map) + } + if (mapFieldOut.sourceMap) { + destinationMap.putAll(mapFieldOut.sourceMap as Map) + } + } else { + Object dstFieldValue + if (it.dstFieldTypeEnumId == null || "DftString" == it.dstFieldTypeEnumId || "DftList" == it.dstFieldTypeEnumId || "DftMap" == it.dstFieldTypeEnumId) { + dstFieldValue = srcFieldValue + } else if ("DftInteger" == it.dstFieldTypeEnumId) { + dstFieldValue = ec.l10n.parseNumber(srcFieldValue as String, null)?.intValue() + } else if ("DftLong" == it.dstFieldTypeEnumId) { + dstFieldValue = ec.l10n.parseNumber(srcFieldValue as String, null)?.longValue() + } else if ("DftFloat" == it.dstFieldTypeEnumId) { + dstFieldValue = ec.l10n.parseNumber(srcFieldValue as String, null)?.floatValue() + } else if ("DftDouble" == it.dstFieldTypeEnumId) { + dstFieldValue = ec.l10n.parseNumber(srcFieldValue as String, null)?.doubleValue() + } else if ("DftBigDecimal" == it.dstFieldTypeEnumId) { + dstFieldValue = ec.l10n.parseNumber(srcFieldValue as String, null) + } else if ("DftBigInteger" == it.dstFieldTypeEnumId) { + dstFieldValue = ec.l10n.parseNumber(srcFieldValue as String, null)?.toBigInteger() + } else if ("DftTime" == it.dstFieldTypeEnumId) { + dstFieldValue = ec.l10n.parseTime(srcFieldValue as String, null) + } else if ("DftDate" == it.dstFieldTypeEnumId) { + dstFieldValue = ec.l10n.parseDate(srcFieldValue as String, null) + } else if ("DftTimestamp" == it.dstFieldTypeEnumId) { + dstFieldValue = ec.l10n.parseTimestamp(srcFieldValue as String, null) + } + + if (dstFieldValue) { + destinationMap.put(it.dstFieldName as String, dstFieldValue) + } + } + } + } + + return destinationMap + } + + @Override + Object adapt(WebContext context, SessionStore sessionStore, Collection profiles, Object... parameters) throws Exception { + if (profiles) { + for (UserProfile profile : profiles) { + if (profile.username) { + + // map profile attributes + EntityValue authFlow = ec.entity.find("moqui.security.auth.AuthFlow") + .condition("authFlowId", profile.clientName) + .one() + Map attributeMap = mapProfileFields(authFlow.fieldMaps as EntityList, profile.attributes) + + // sync user account + String userId + EntityValue userAccount = ec.entity.find("moqui.security.UserAccount") + .condition("username", profile.username) + .useCache(false) + .one() + if (userAccount) { + userId = userAccount.userId + ec.service.sync().name("org.moqui.impl.UserServices.update#UserAccount") + .parameter("userId", userId) + .parameter("externalUserId", profile.id) + .parameter("username", profile.username) + .parameters(attributeMap) + .call() + } else { + Map createAccountOut = ec.service.sync().name("org.moqui.impl.UserServices.create#UserAccount") + .parameter("externalUserId", profile.id) + .parameter("username", profile.username) + .parameters(attributeMap) + .call() + userId = createAccountOut.userId + } + + // find user groups + EntityList userGroupMemberList = ec.entity.find("moqui.security.UserGroupMember") + .condition("userId", userId) + .conditionDate("fromDate", "thruDate", ec.user.nowTimestamp) + .list() + Set obsoleteUserGroupIdSet = new HashSet<>(userGroupMemberList*.userGroupId) + + // sync user groups + HashSet roleSet = new HashSet<>() + if (profile.roles) { + roleSet.addAll(profile.roles) + } else if (profile.attributes.containsKey("roles")) { + Object rolesObj = profile.attributes.get("roles") + if (rolesObj instanceof List) { + roleSet.addAll(rolesObj as List) + } else if (rolesObj instanceof Set) { + roleSet.addAll(rolesObj as Set) + } + } + for (String role : roleSet) { + EntityValue roleMap = ec.entity.find("moqui.security.auth.AuthFlowRoleMap") + .condition("authFlowId", profile.clientName) + .condition("roleName", role) + .one() + if (roleMap?.userGroupId && !obsoleteUserGroupIdSet.remove(roleMap.userGroupId)) { + ec.service.sync().name("create#moqui.security.UserGroupMember") + .parameter("userGroupId", roleMap.userGroupId) + .parameter("userId", userId) + .parameter("fromDate", ec.user.nowTimestamp) + .call() + } + } + for (EntityValue userGroupMember : userGroupMemberList) { + String userGroupId = userGroupMember.userGroupId + if (obsoleteUserGroupIdSet.contains(userGroupId) && userGroupId != authFlow.defaultUserGroupId) { + ec.service.sync().name("update#moqui.security.UserGroupMember") + .parameter("userGroupId", userGroupId) + .parameter("userId", userId) + .parameter("fromDate", userGroupMember.fromDate) + .parameter("thruDate", ec.user.nowTimestamp) + .call() + } + } + + // add default user group if needed + long userGroupCount = ec.entity.find("moqui.security.UserGroupMember") + .condition("userId", userId) + .conditionDate("fromDate", "thruDate", ec.user.nowTimestamp) + .count() + if (userGroupCount == 0) { + ec.service.sync().name("create#moqui.security.UserGroupMember") + .parameter("userGroupId", authFlow.defaultUserGroupId) + .parameter("userId", userId) + .parameter("fromDate", ec.user.nowTimestamp) + .call() + } + } + } + } + + return null + } +} diff --git a/framework/src/main/resources/MoquiDefaultConf.xml b/framework/src/main/resources/MoquiDefaultConf.xml index cb515655f..19afdd02b 100644 --- a/framework/src/main/resources/MoquiDefaultConf.xml +++ b/framework/src/main/resources/MoquiDefaultConf.xml @@ -401,6 +401,7 @@ + @@ -640,6 +641,7 @@ default-start-server-args="-tcpPort 9092 -ifExists -baseDir ${moqui_runtime}/db/h2"> +