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">
+