diff --git a/docs/casa/index.md b/docs/casa/index.md index 6b081e52a1d..6cf4b57fc40 100644 --- a/docs/casa/index.md +++ b/docs/casa/index.md @@ -50,6 +50,7 @@ Besides a comprehensive graphical [admin console](./administration/admin-console Casa is a plugin-oriented, Java web application. Existing functionality can be extended and new functionality and APIs can be introduced through plugins. Currently, there are plugins available for the following: +- [Accounts linking](./plugins/accts-linking/index.md) - [Consent management](./plugins/consent-management.md) - [Custom branding](./plugins/custom-branding.md) - [2FA settings](./plugins/2fa-settings.md) diff --git a/docs/casa/plugins/accts-linking/accts-linking-agama.md b/docs/casa/plugins/accts-linking/accts-linking-agama.md new file mode 100644 index 00000000000..5fb93d8b343 --- /dev/null +++ b/docs/casa/plugins/accts-linking/accts-linking-agama.md @@ -0,0 +1,87 @@ +# Accounts linking project configuration + +## Overview + +The accounts linking Agama project must be configured in order to integrate third-party identity providers. The configuration of this project is supplied in a JSON file whose structure is like: + +``` +{ +"io.jans.casa.acctlinking.Launcher": { + + "providerID_1": { ... }, + "providerID_2": { ... }, + ... +} +} +``` + +Each property part of the JSON object `io.jans.casa.acctlinking.Launcher` holds the configuration of a different identity provider. Here's a how a typical configuration of a provider looks like: + +``` +{ + "displayName": "Goooogle", + "flowQname": "io.jans.inbound.GenericProvider", + "mappingClassField": "io.jans.casa.acctlinking.Mappings.GOOGLE", + "oauthParams": { + "authzEndpoint": "https://accounts.google.com/o/oauth2/v2/auth", + "tokenEndpoint": "https://oauth2.googleapis.com/token", + "userInfoEndpoint": "https://www.googleapis.com/oauth2/v3/userinfo", + "clientId": "202403151302", + "clientSecret": "m|a1l2d3i4t5a6S7h8a9k0i'r¿a", + "scopes": ["email", "profile"] + } +} + +``` + +In this case, we are populating the configuration of an OAuth-based provider called "Goooogle". + +The tables shown in [this](https://github.com/JanssenProject/jans/blob/main/docs/agama-catalog/jans/inboundID/README.md#supply-configurations) page list all possible properties to configure a provider. Particularly, two properties deserve the most detail: + +1. `flowQname`. Agama projects are made up of flows - think of small "web journeys". This property must contain the name of an existing flow capable of interfacing with the identity provider of interest. Often, there is no need to write such "interfacing" flow. The below are ready-to-use and cover most of real-world cases, specifically OpenId/OAuth providers that support the **authorization code grant** (see section 1.3 of [rfc6749](https://www.ietf.org/rfc/rfc6749)): + + - `io.jans.inbound.GenericProvider`. It implements the authorization code flow where the user's browser is taken to the external site. When authentication completes, a `code` is received at a designated redirect (callback) URL. With such `code` an access token is obtained as well as user's profile data. This flow supports _dynamic client registration_ + + - `io.jans.inbound.Apple`. It implements the authorization code flow with some nuances required in order to integrate "Apple Sign In" + + +2. `mappingClassField`. This is key for performing the attribute mapping process and the user provisioning. The remainder of this document is dedicated to these two aspects + +!!! Note + Recall `enabled` is a handy property that can be used to temporarily "deactive" a given identity provider. + +## Configuring attribute mappings + +An introduction to attribute mapping can be found [here](https://github.com/JanssenProject/jans/blob/main/docs/agama-catalog/jans/inboundID/README.md#attribute-mappings). Unless an elaborated processing of attributes is required, a basic knowledge of Java language suffices to write a useful mapping. + +To write a mapping, you can use the samples provided as a guideline (see folder `lib/io/jans/casa/acctlinking` in the Agama accounts linking project). You can add your mapping in the same file or create a new Java class for this purpose. Then save your changes, re-package (zip) the project, re-deploy, and update (re-import) the configuration if necessary. + +Specifically, for Casa accounts linking, the mapping **must** include an attribute named `ID`. While `ID` is not part of the Jans database, here it is used to supply what could be understood as the _identifier_ of the user at the external site. For instance, in a social site this may be the username or email. The example below shows how to set `ID` assuming the username was returned by the external site in an attribute named `userId`: + +``` +profile -> { + Map map = new HashMap<>(); + + map.put("ID", profile.get("userId")); + ... + return map; +} +``` + +In the above example, `profile` is a `Map` that holds the attribute-value pairs the third-party identity provider released for this user. For the interested, `profile` contents are dumped to the server logs (check `jans-auth_script.log`) so it is easy to peak into the values. Check for a message in `debug` level starting with "Profile data". + +Both the ID of identity provider and the ID of the user will end up stored in an auxiliary database attribute. This helps to identify if the incoming user is already known (has been onboarded previously). + +When the attribute mapping is applied, the `uid` attribute is set as well. This is the username the incoming user will be assigned in the local Jans database. The `uid` is automatically generated based on `ID` unless the mapping already populates the `uid` directly. + +The return value of the mapping is a `Map`. This caters for cases where resulting attributes hold booleans, dates, numbers, strings, etc. When the attribute has to hold multiple values, you can use an array or a Java `Collection` object, like a `List`. + +## User provisioning + +After attribute mapping occurs, the local database is checked to determine if the incoming user is known (based on the `ID` in the mapping and the ID of the provider in question). If no match is found, the user is onboarded: a new entry is created in the database using the information found in the resulting mapping. Otherwise, the exact behavior varies depending on the provider configuration as follows: + +- If `skipProfileUpdate` is set to `false`, the existing database entry is left untouched, otherwise +- If `cumulativeUpdate` is set to `false`, the existing attributes in the entry which are part of the mapping are overwritten +- If `cumulativeUpdate` is set to `true`, the existing attribute values in the entry are preserved and new values are added if present in the mapping + +The updates just referenced apply to the matching entry based on mapping and provider ID, however, when `emailLinkingSafe` is set to `true` and the mapping comes with a `mail` value equal to an existing e-mail in the database, the update is carried over the e-mail matching entry. diff --git a/docs/casa/plugins/accts-linking/index.md b/docs/casa/plugins/accts-linking/index.md new file mode 100644 index 00000000000..2b38548cf80 --- /dev/null +++ b/docs/casa/plugins/accts-linking/index.md @@ -0,0 +1,99 @@ +# Accounts Linking Plugin + +## Overview + +This plugin allows users to "link" their local Jans account with existing accounts at third-party identity providers like OIDC OPs and social sites, e.g. Apple, Facebook, Google, etc. + +Besides the usual onboarding of a plugin jar file in Casa, administrators must deploy a number of additional components. This will be regarded later. However let's summary the key points of the accounts linking experience: + +- When a user tries to login to Casa, the usual username/password form is presented but also a list of links that can take him to external sites (third-party identity providers) where authentication takes place +- Once authenticated, user profile data is grabbed from the external site - this is all backchannel +- A process called _attribute mapping_ is performed on profile data. This is a transformation process that turns incoming profile data into a shape compatible with a regular Jans database user entry +- If the mapped profile data matches an existing user in the Jans database, the existing entry is updated with the incoming data, otherwise, a new entry is inserted - this is called _user provisioning_. When provisioning occurs, the account has no password associated +- Finally the user is given access to Casa + +From the perspective of a user already logged into Casa, the experience is as follows: + +- In casa, a menu item is provided which takes the user to a (Casa) page that shows a list of third-party identity providers. For every provider there are options to trigger linking in case there is no account linked yet (external site authentication is launched), or to remove the linked account from the user profile +- If an account has no password assigned, removal of linked accounts is not allowed. However, a functionality for the user to assign himself a password is provided + +## Components deployment + +The pieces that allow materialization of the experience summarized above are the following: + +a) The Casa plugin jar file + +b) A custom XHTML page and jython script + +c) The Agama inbound identity project + +d) The Casa accounts linking Agama project + +Most of work is demanded on setting up project _d_, where configuration of identity providers and attribute mapping tuning takes place. + +In the following, it is assumed you have a VM-based installation of Jans Server (or Gluu Flex) available with Casa installed. In a separate machine, ensure you have SSH/SCP/SFTP access to such server and `git` installed. + +!!! Note + For the below instructions ensure to replace `` with the version of your Jans Server + +1. Download the plugin jar file `https://maven.jans.io/maven/io/jans/casa/plugins/acct-linking//acct-linking--jar-with-dependencies.jar` and copy to your server's `/opt/jans/jetty/jans-casa/plugins` + +1. Download the utility jar file `https://maven.jans.io/maven/io/jans/agama-inbound//agama-inbound-.jar` and copy to your server's `/opt/jans/jetty/jans-auth/custom/libs` + +1. In the server, create a `casa` directory inside `/opt/jans/jetty/jans-auth/custom/pages` + +1. Download the file `https://github.com/JanssenProject/jans/raw/main/jans-casa/plugins/acct-linking/extras/login.xhtml` and copy it to the previously created folder + +1. Download the file `https://github.com/JanssenProject/jans/raw/main/jans-casa/plugins/acct-linking/extras/Casa.py`. Open TUI or the admin UI (for Flex), and locate the custom script whose name is `casa`. Update the contents of the script with the contents of the file + +1. In TUI, ensure the custom script named `agama` is enabled + +1. Still in TUI, visit the Clients screen, locate the client labeled "Client for Casa". Add the following redirect URI to the list: `https:///jans-casa/pl/acct-linking/user/interlude.zul`. Replace the name of your Jans server accordingly + +1. Run the following commands to generate the archives of the Agama projects + + ``` + git clone --depth 1 --branch main --no-checkout https://github.com/JanssenProject/jans.git + cd jans + git sparse-checkout init --cone + git sparse-checkout set docs/agama-catalog/jans/inboundID/project + git sparse-checkout set jans-casa/plugins/acct-linking/extras/agama + git checkout main + cd docs/agama-catalog/jans/inboundID/project + zip -r inbound.zip * + cd jans-casa/plugins/acct-linking/extras/agama + zip -r acctlinking.zip * + ``` + +1. Transfer the zip files to a location in the server, deploy both archives using TUI (Agama menu) + +1. Finally restart the authentication server + +## Configuration + +So far all components required for the Casa inbound identity solution are loaded in the server. When logging to casa, the form presented looks like usual, and once in, the "Accounts linking" menu takes to a page which hints about missing configuration. + +The first step is figuring out the external sites to support. Keep in mind only OpenID or OAuth 2.0 based providers can be onboarded. There is not support for SAML IDPs. + +The procedures for getting configuration settings in order to integrate third party providers vary widely. Here, only basic guidelines are given: + +- If the provider is OpenId-compliant and supports dynamic client registration, obtain the OP URL and the scopes list to use when requesting user profile information. Most of times the scopes `openid`, `profile` and `email` will fit the bill + +- If the provider is OpenId-compliant and does not support dynamic client registration, obtain the OP URL and scopes as in the previous case, and also a client ID and secret + +- If the provider does not support OpenId. Obtain the following: + + - The authorization endpoint URL + - The token endpoint URL + - The endpoint URL where profile data can be retrieved + - Client credentials (ID and secret) + - Scopes to use when requesting user profile information + +The steps required to grab the above data vary among providers. Normally this is obtained through a sort of administrative developer GUI tool. If you are prompted for a "redirect URI" in such tool, provide `https:///jans-auth/fl/callback`. + +Now it's time to supply the settings grabbed. The component these configurations are injected to is the Casa accounts linking Agama project. To make the effort easier, this project is bundled with some dummy configuration properties you can use as a template. Proceed as follows: + +1. In TUI, open the Agama tab and scroll through the list of projects until the `casa-account-linking` is highlighted +1. Open the configuration management dialog (press `c`) and choose to export the sample configuration to a file on disk +1. Apply changes as needed - this is covered in a separate doc page [here](./accts-linking-agama.md). Note you can add or remove sections in the file at will, and that providers can also be disabled so they are not listed in the login page or in Casa app +1. Still in TUI, choose to import the file you have edited. Then conduct your testing diff --git a/jans-casa/app/src/main/java/io/jans/casa/core/PasswordStatusService.java b/jans-casa/app/src/main/java/io/jans/casa/core/PasswordStatusService.java index c17813c25aa..9ca7e9c5293 100644 --- a/jans-casa/app/src/main/java/io/jans/casa/core/PasswordStatusService.java +++ b/jans-casa/app/src/main/java/io/jans/casa/core/PasswordStatusService.java @@ -39,8 +39,8 @@ public boolean isPassResetAvailable() { public void reloadStatus() { - passSetAvailable = false; //Setting user's password is a disabled feature in Jans Casa IdentityPerson p = persistenceService.get(IdentityPerson.class, persistenceService.getPersonDn(asco.getUser().getId())); + passSetAvailable = !p.hasPassword(); passResetAvailable = p.hasPassword() && confSettings.isEnablePassReset(); } diff --git a/jans-casa/plugins/acct-linking/extras/Casa.py b/jans-casa/plugins/acct-linking/extras/Casa.py new file mode 100644 index 00000000000..78effa96724 --- /dev/null +++ b/jans-casa/plugins/acct-linking/extras/Casa.py @@ -0,0 +1,737 @@ + +from io.jans.agama.model import Flow +from io.jans.as.server.security import Identity +from io.jans.as.server.service import AuthenticationService +from io.jans.as.server.service import UserService +from io.jans.config import GluuConfiguration +from io.jans.as.server.service.custom import CustomScriptService +from io.jans.as.server.service.net import HttpService +from io.jans.as.server.util import ServerUtil +from io.jans.casa.model import ApplicationConfiguration +from io.jans.jsf2.service import FacesService +from io.jans.model import SimpleCustomProperty +from io.jans.model.custom.script import CustomScriptType +from io.jans.model.custom.script.type.auth import PersonAuthenticationType +from io.jans.orm import PersistenceEntryManager +from io.jans.service import EncryptionService +from io.jans.service import CacheService +from io.jans.service.cdi.util import CdiUtil +from io.jans.util import StringHelper + +from java.lang import Integer +from java.util import Collections, HashMap, HashSet, ArrayList, Arrays, Date +from java.nio.charset import Charset +from java.net import URLEncoder + +from org.apache.http.params import CoreConnectionPNames + +try: + import json +except ImportError: + import simplejson as json +import sys + +class PersonAuthentication(PersonAuthenticationType): + + def __init__(self, currentTimeMillis): + self.currentTimeMillis = currentTimeMillis + self.ACR_SG = "super_gluu" + + self.modulePrefix = "casa-external_" + + def init(self, customScript, configurationAttributes): + + print "Casa. init called" + self.authenticators = {} + self.uid_attr = self.getLocalPrimaryKey() + self.agamaFQN = "io.jans.casa.acctlinking.Launcher" + + custScriptService = CdiUtil.bean(CustomScriptService) + self.scriptsList = custScriptService.findCustomScripts(Collections.singletonList(CustomScriptType.PERSON_AUTHENTICATION), "jansConfProperty", "displayName", "jansEnabled", "jansLevel") + dynamicMethods = self.computeMethods(self.scriptsList) + + if len(dynamicMethods) > 0: + print "Casa. init. Loading scripts for dynamic modules: %s" % dynamicMethods + + for acr in dynamicMethods: + moduleName = self.modulePrefix + acr + try: + external = __import__(moduleName, globals(), locals(), ["PersonAuthentication"], -1) + module = external.PersonAuthentication(self.currentTimeMillis) + + print "Casa. init. Got dynamic module for acr %s" % acr + configAttrs = self.getConfigurationAttributes(acr, self.scriptsList) + + if acr == self.ACR_SG: + application_id = configurationAttributes.get("supergluu_app_id").getValue2() + configAttrs.put("application_id", SimpleCustomProperty("application_id", application_id)) + + if module.init(None, configAttrs): + module.configAttrs = configAttrs + self.authenticators[acr] = module + else: + print "Casa. init. Call to init in module '%s' returned False" % moduleName + except: + print "Casa. init. Failed to load module %s" % moduleName + print "Exception: ", sys.exc_info()[1] + + mobile_methods = configurationAttributes.get("mobile_methods") + self.mobile_methods = [] if mobile_methods == None else StringHelper.split(mobile_methods.getValue2(), ",") + + print "Casa. init. Initialized successfully" + return True + + + def destroy(self, configurationAttributes): + print "Casa. Destroyed called" + return True + + + def getApiVersion(self): + return 11 + + + def getAuthenticationMethodClaims(self, configurationAttributes): + return None + + + def isValidAuthenticationMethod(self, usageType, configurationAttributes): + print "Casa. isValidAuthenticationMethod called" + return True + + + def getAlternativeAuthenticationMethod(self, usageType, configurationAttributes): + return None + + + def authenticate(self, configurationAttributes, requestParameters, step): + print "Casa. authenticate for step %s" % str(step) + + userService = CdiUtil.bean(UserService) + authenticationService = CdiUtil.bean(AuthenticationService) + identity = CdiUtil.bean(Identity) + + if step == 1: + # Determine if external provider must be used + provider = ServerUtil.getFirstValue(requestParameters, "loginForm:provider") + if StringHelper.isNotEmpty(provider): + url = self.getAuthzRequestUrl(provider) + if url != None: + CdiUtil.bean(FacesService).redirectToExternalURL(url) + return url != None + + credentials = identity.getCredentials() + user_name = credentials.getUsername() + user_password = credentials.getPassword() + + if StringHelper.isNotEmptyString(user_name) and StringHelper.isNotEmptyString(user_password): + + foundUser = userService.getUserByAttribute(self.uid_attr, user_name) + #foundUser = userService.getUser(user_name) + if foundUser == None: + print "Casa. authenticate for step 1. Unknown username" + else: + platform_data = self.parsePlatformData(requestParameters) + preferred = foundUser.getAttribute("jansPreferredMethod") + mfaOff = preferred == None + logged_in = False + + if mfaOff: + logged_in = authenticationService.authenticate(user_name, user_password) + else: + acr = self.getSuitableAcr(foundUser, platform_data, preferred) + if acr != None: + module = self.authenticators[acr] + logged_in = module.authenticate(module.configAttrs, requestParameters, step) + + if logged_in: + foundUser = authenticationService.getAuthenticatedUser() + + if foundUser == None: + print "Casa. authenticate for step 1. Cannot retrieve logged user" + else: + if mfaOff: + identity.setWorkingParameter("skip2FA", True) + else: + #Determine whether to skip 2FA based on policy defined (global or user custom) + skip2FA = self.determineSkip2FA(userService, identity, foundUser, platform_data) + identity.setWorkingParameter("skip2FA", skip2FA) + identity.setWorkingParameter("ACR", acr) + + return True + + else: + print "Casa. authenticate for step 1 was not successful" + return False + + else: + user = authenticationService.getAuthenticatedUser() + if user == None: + print "Casa. authenticate for step 2. Cannot retrieve logged user" + return False + + #see casa.xhtml + alter = ServerUtil.getFirstValue(requestParameters, "alternativeMethod") + if alter != None: + #bypass the rest of this step if an alternative method was provided. Current step will be retried (see getNextStep) + self.simulateFirstStep(requestParameters, alter) + return True + + session_attributes = identity.getSessionId().getSessionAttributes() + acr = session_attributes.get("ACR") + #this working parameter is used in casa.xhtml + identity.setWorkingParameter("methods", ArrayList(self.getAvailMethodsUser(user, acr))) + + success = False + if acr in self.authenticators: + module = self.authenticators[acr] + success = module.authenticate(module.configAttrs, requestParameters, step) + + #Update the list of trusted devices if 2fa passed + if success: + print "Casa. authenticate. 2FA authentication was successful" + tdi = session_attributes.get("trustedDevicesInfo") + if tdi == None: + print "Casa. authenticate. List of user's trusted devices was not updated" + else: + user.setAttribute("jansTrustedDevices", tdi) + userService.updateUser(user) + else: + print "Casa. authenticate. 2FA authentication failed" + + return success + + return False + + + def prepareForStep(self, configurationAttributes, requestParameters, step): + print "Casa. prepareForStep %s" % str(step) + identity = CdiUtil.bean(Identity) + + if step == 1: + self.prepareUIParams(identity) + self.registeredProviders = self.parseProviderConfigs() + identity.setWorkingParameter("externalProviders", json.dumps(self.registeredProviders)) + return True + else: + session_attributes = identity.getSessionId().getSessionAttributes() + + authenticationService = CdiUtil.bean(AuthenticationService) + user = authenticationService.getAuthenticatedUser() + + if user == None: + print "Casa. prepareForStep. Cannot retrieve logged user" + return False + + acr = session_attributes.get("ACR") + print "Casa. prepareForStep. ACR = %s" % acr + identity.setWorkingParameter("methods", ArrayList(self.getAvailMethodsUser(user, acr))) + + if acr in self.authenticators: + module = self.authenticators[acr] + return module.prepareForStep(module.configAttrs, requestParameters, step) + else: + return False + + + def getExtraParametersForStep(self, configurationAttributes, step): + print "Casa. getExtraParametersForStep %s" % str(step) + list = ArrayList() + + if step > 1: + acr = CdiUtil.bean(Identity).getWorkingParameter("ACR") + + if acr in self.authenticators: + module = self.authenticators[acr] + params = module.getExtraParametersForStep(module.configAttrs, step) + if params != None: + list.addAll(params) + + list.addAll(Arrays.asList("ACR", "methods", "trustedDevicesInfo")) + else: + list.add("externalProviders") + + list.addAll(Arrays.asList("casa_contextPath", "casa_prefix", "casa_faviconUrl", "casa_extraCss", "casa_logoUrl")) + print "extras are %s" % list + return list + + + def getCountAuthenticationSteps(self, configurationAttributes): + print "Casa. getCountAuthenticationSteps called" + + if CdiUtil.bean(Identity).getWorkingParameter("skip2FA"): + return 1 + + acr = CdiUtil.bean(Identity).getWorkingParameter("ACR") + if acr in self.authenticators: + module = self.authenticators[acr] + return module.getCountAuthenticationSteps(module.configAttrs) + else: + return 2 + + print "Casa. getCountAuthenticationSteps. Could not determine the step count for acr %s" % acr + + + def getPageForStep(self, configurationAttributes, step): + print "Casa. getPageForStep called %s" % str(step) + + if step > 1: + acr = CdiUtil.bean(Identity).getWorkingParameter("ACR") + if acr in self.authenticators: + module = self.authenticators[acr] + page = module.getPageForStep(module.configAttrs, step) + else: + page=None + + return page + + return "/casa/login.xhtml" + + + def getNextStep(self, configurationAttributes, requestParameters, step): + + print "Casa. getNextStep called %s" % str(step) + if step > 1: + acr = ServerUtil.getFirstValue(requestParameters, "alternativeMethod") + if acr != None: + print "Casa. getNextStep. Use alternative method %s" % acr + CdiUtil.bean(Identity).setWorkingParameter("ACR", acr) + #retry step with different acr + return 2 + + return -1 + + + def logout(self, configurationAttributes, requestParameters): + print "Casa. logout called" + return True + +# Miscelaneous + + def getLocalPrimaryKey(self): + entryManager = CdiUtil.bean(PersistenceEntryManager) + config = GluuConfiguration() + config = entryManager.find(config.getClass(), "ou=configuration,o=jans") + #Pick (one) attribute where user id is stored (e.g. uid/mail) + # primaryKey is the primary key on the backend AD / LDAP Server + # localPrimaryKey is the primary key on Gluu. This attr value has been mapped with the primary key attr of the backend AD / LDAP when configuring cache refresh + uid_attr = config.getIdpAuthn().get(0).getConfig().findValue("localPrimaryKey").asText() + print "Casa. init. uid attribute is '%s'" % uid_attr + return uid_attr + + + def getSettings(self): + entryManager = CdiUtil.bean(PersistenceEntryManager) + config = ApplicationConfiguration() + config = entryManager.find(config.getClass(), "ou=casa,ou=configuration,o=jans") + settings = config.getSettings() + + if settings == None: + print "Casa. getSettings. Failed to parse casa settings from DB" + return settings + + + def computeMethods(self, scriptList): + + methods = [] + mapping = {} + cmConfigs = self.getSettings() + + if cmConfigs != None and cmConfigs.getAcrPluginMap() != None: + mapping = cmConfigs.getAcrPluginMap().keySet() + + for m in mapping: + for customScript in scriptList: + if customScript.getName() == m and customScript.isEnabled(): + methods.append(m) + + print "Casa. computeMethods. %s" % methods + return methods + + + def getConfigurationAttributes(self, acr, scriptsList): + + configMap = HashMap() + for customScript in scriptsList: + if customScript.getName() == acr and customScript.isEnabled(): + for prop in customScript.getConfigurationProperties(): + configMap.put(prop.getValue1(), SimpleCustomProperty(prop.getValue1(), prop.getValue2())) + + print "Casa. getConfigurationAttributes. %d configuration properties were found for %s" % (configMap.size(), acr) + return configMap + + + def getAvailMethodsUser(self, user, skip=None): + methods = HashSet() + + for method in self.authenticators: + try: + module = self.authenticators[method] + if module.hasEnrollments(module.configAttrs, user): + methods.add(method) + except: + print "Casa. getAvailMethodsUser. hasEnrollments call could not be issued for %s module" % method + print "Exception: ", sys.exc_info()[1] + + try: + if skip != None: + # skip is guaranteed to be a member of methods (if hasEnrollments routines are properly implemented). + # A call to remove strangely crashes when skip is absent + methods.remove(skip) + except: + print "Casa. getAvailMethodsUser. methods list does not contain %s" % skip + + print "Casa. getAvailMethodsUser %s" % methods.toString() + return methods + + + def prepareUIParams(self, identity): + + print "Casa. prepareUIParams. Reading UI branding params" + cacheService = CdiUtil.bean(CacheService) + casaAssets = cacheService.get("casa_assets") + + if casaAssets == None: + #This may happen when cache type is IN_MEMORY, where actual cache is merely a local variable + #(a expiring map) living inside Casa webapp, not oxAuth webapp + + sets = self.getSettings() + + custPrefix = "/custom" + logoUrl = "/images/logo.png" + faviconUrl = "/images/favicon.ico" + if (sets.getExtraCssSnippet() != None) or sets.isUseExternalBranding(): + logoUrl = custPrefix + logoUrl + faviconUrl = custPrefix + faviconUrl + + prefix = custPrefix if sets.isUseExternalBranding() else "" + + casaAssets = { + "contextPath": "/jans-casa", + "prefix" : prefix, + "faviconUrl" : faviconUrl, + "extraCss": sets.getExtraCssSnippet(), + "logoUrl": logoUrl + } + + #Setting a single variable with the whole map does not work... + identity.setWorkingParameter("casa_contextPath", casaAssets['contextPath']) + identity.setWorkingParameter("casa_prefix", casaAssets['prefix']) + identity.setWorkingParameter("casa_faviconUrl", casaAssets['contextPath'] + casaAssets['faviconUrl']) + identity.setWorkingParameter("casa_extraCss", casaAssets['extraCss']) + identity.setWorkingParameter("casa_logoUrl", casaAssets['contextPath'] + casaAssets['logoUrl']) + + + def simulateFirstStep(self, requestParameters, acr): + #To simulate 1st step, there is no need to call: + # getPageforstep (no need as user/pwd won't be shown again) + # isValidAuthenticationMethod (by restriction, it returns True) + # prepareForStep (by restriction, it returns True) + # getExtraParametersForStep (by restriction, it returns None) + print "Casa. simulateFirstStep. Calling authenticate (step 1) for %s module" % acr + if acr in self.authenticators: + module = self.authenticators[acr] + auth = module.authenticate(module.configAttrs, requestParameters, 1) + print "Casa. simulateFirstStep. returned value was %s" % auth + + +# 2FA policy enforcement + + def parsePlatformData(self, requestParameters): + try: + #Find device info passed in HTTP request params (see index.xhtml) + platform = ServerUtil.getFirstValue(requestParameters, "loginForm:platform") + deviceInf = json.loads(platform) + except: + print "Casa. parsePlatformData. Error parsing platform data" + deviceInf = None + + return deviceInf + + + def getSuitableAcr(self, user, deviceInf, preferred): + + onMobile = deviceInf != None and 'isMobile' in deviceInf and deviceInf['isMobile'] + id = user.getUserId() + strongest = -1 + acr = None + user_methods = self.getAvailMethodsUser(user) + + for s in self.scriptsList: + name = s.getName() + level = Integer.MAX_VALUE if name == preferred else s.getLevel() + if user_methods.contains(name) and level > strongest and (not onMobile or name in self.mobile_methods): + acr = name + strongest = level + + print "Casa. getSuitableAcr. On mobile = %s" % onMobile + if acr == None and onMobile: + print "Casa. getSuitableAcr. No mobile-friendly authentication method available for user %s" % id + # user_methods is not empty when this function is called, so just pick any + acr = user_methods.stream().findFirst().get() + + print "Casa. getSuitableAcr. %s was selected for user %s" % (acr, id) + return acr + + + def determineSkip2FA(self, userService, identity, foundUser, deviceInf): + + cmConfigs = self.getSettings() + + if cmConfigs == None: + print "Casa. determineSkip2FA. Failed to read policy_2fa" + return False + + cmConfigs = cmConfigs.getPluginSettings().get('strong-authn-settings') + + policy2FA = 'EVERY_LOGIN' + if cmConfigs != None and cmConfigs.get('policy_2fa') != None: + policy2FA = ','.join(cmConfigs.get('policy_2fa')) + + print "Casa. determineSkip2FA with general policy %s" % policy2FA + policy2FA += ',' + skip2FA = False + + if 'CUSTOM,' in policy2FA: + #read setting from user profile + policy = foundUser.getAttribute("jansStrongAuthPolicy") + if policy == None: + policy = 'EVERY_LOGIN,' + else: + policy = policy.upper() + ',' + print "Casa. determineSkip2FA. Using user's enforcement policy %s" % policy + + else: + #If it's not custom, then apply the global setting admin defined + policy = policy2FA + + if not 'EVERY_LOGIN,' in policy: + locationCriterion = 'LOCATION_UNKNOWN,' in policy + deviceCriterion = 'DEVICE_UNKNOWN,' in policy + + if locationCriterion or deviceCriterion: + if deviceInf == None: + print "Casa. determineSkip2FA. No user device data. Forcing 2FA to take place..." + else: + skip2FA = self.process2FAPolicy(identity, foundUser, deviceInf, locationCriterion, deviceCriterion) + + if skip2FA: + print "Casa. determineSkip2FA. Second factor is skipped" + #Update attribute if authentication will not have second step + devInf = identity.getWorkingParameter("trustedDevicesInfo") + if devInf != None: + foundUser.setAttribute("jansTrustedDevices", devInf) + userService.updateUser(foundUser) + else: + print "Casa. determineSkip2FA. Unknown %s policy: cannot skip 2FA" % policy + + return skip2FA + + + def process2FAPolicy(self, identity, foundUser, deviceInf, locationCriterion, deviceCriterion): + + skip2FA = False + #Retrieve user's devices info + devicesInfo = foundUser.getAttribute("jansTrustedDevices") + + #do geolocation + geodata = self.getGeolocation(identity) + if geodata == None: + print "Casa. process2FAPolicy: Geolocation data not obtained. 2FA skipping based on location cannot take place" + + try: + encService = CdiUtil.bean(EncryptionService) + + if devicesInfo == None: + print "Casa. process2FAPolicy: There are no trusted devices for user yet" + #Simulate empty list + devicesInfo = "[]" + else: + devicesInfo = encService.decrypt(devicesInfo) + + devicesInfo = json.loads(devicesInfo) + + partialMatch = False + idx = 0 + #Try to find a match for device only + for device in devicesInfo: + partialMatch = device['browser']['name']==deviceInf['name'] and device['os']['version']==deviceInf['os']['version'] and device['os']['family']==deviceInf['os']['family'] + if partialMatch: + break + idx+=1 + + matchFound = False + + #At least one of locationCriterion or deviceCriterion is True + if locationCriterion and not deviceCriterion: + #this check makes sense if there is city data only + if geodata!=None: + for device in devicesInfo: + #Search all registered cities that are found in trusted devices + for origin in device['origins']: + matchFound = matchFound or origin['city']==geodata['city'] + + elif partialMatch: + #In this branch deviceCriterion is True + if not locationCriterion: + matchFound = True + elif geodata!=None: + for origin in devicesInfo[idx]['origins']: + matchFound = matchFound or origin['city']==geodata['city'] + + skip2FA = matchFound + now = Date().getTime() + + #Update attribute oxTrustedDevicesInfo accordingly + if partialMatch: + #Update an existing record (update timestamp in city, or else add it) + if geodata != None: + partialMatch = False + idxCity = 0 + + for origin in devicesInfo[idx]['origins']: + partialMatch = origin['city']==geodata['city'] + if partialMatch: + break; + idxCity+=1 + + if partialMatch: + devicesInfo[idx]['origins'][idxCity]['timestamp'] = now + else: + devicesInfo[idx]['origins'].append({"city": geodata['city'], "country": geodata['country'], "timestamp": now}) + else: + #Create a new entry + browser = {"name": deviceInf['name'], "version": deviceInf['version']} + os = {"family": deviceInf['os']['family'], "version": deviceInf['os']['version']} + + if geodata == None: + origins = [] + else: + origins = [{"city": geodata['city'], "country": geodata['country'], "timestamp": now}] + + obj = {"browser": browser, "os": os, "addedOn": now, "origins": origins} + devicesInfo.append(obj) + + enc = json.dumps(devicesInfo, separators=(',',':')) + enc = encService.encrypt(enc) + identity.setWorkingParameter("trustedDevicesInfo", enc) + + except: + print "Casa. process2FAPolicy. Error!", sys.exc_info()[1] + + return skip2FA + + + def getGeolocation(self, identity): + + session_attributes = identity.getSessionId().getSessionAttributes() + if session_attributes.containsKey("remote_ip"): + remote_ip = session_attributes.get("remote_ip").split(",", 2)[0].strip() + if StringHelper.isNotEmpty(remote_ip): + + httpService = CdiUtil.bean(HttpService) + + http_client = httpService.getHttpsClient() + http_client_params = http_client.getParams() + http_client_params.setIntParameter(CoreConnectionPNames.CONNECTION_TIMEOUT, 4 * 1000) + + geolocation_service_url = "http://ip-api.com/json/%s?fields=country,city,status,message" % remote_ip + geolocation_service_headers = { "Accept" : "application/json" } + + try: + http_service_response = httpService.executeGet(http_client, geolocation_service_url, geolocation_service_headers) + http_response = http_service_response.getHttpResponse() + except: + print "Casa. Determine remote location. Exception: ", sys.exc_info()[1] + return None + + try: + if not httpService.isResponseStastusCodeOk(http_response): + print "Casa. Determine remote location. Get non 200 OK response from server:", str(http_response.getStatusLine().getStatusCode()) + httpService.consume(http_response) + return None + + response_bytes = httpService.getResponseContent(http_response) + response_string = httpService.convertEntityToString(response_bytes, Charset.forName("UTF-8")) + httpService.consume(http_response) + finally: + http_service_response.closeConnection() + + if response_string == None: + print "Casa. Determine remote location. Get empty response from location server" + return None + + response = json.loads(response_string) + + if not StringHelper.equalsIgnoreCase(response['status'], "success"): + print "Casa. Determine remote location. Get response with status: '%s'" % response['status'] + return None + + return response + + return None + + + def getLogoutExternalUrl(self, configurationAttributes, requestParameters): + print "Get external logout URL call" + return None + +# External authentication providers integration + + def getAuthzRequestUrl(self, providerId): + + url = None + if providerId in self.registeredProviders: + + print "Casa. getAuthzRequestUrl. Building an authz request URL for agama" + + params = ["response_type", "client_id", "scope", "redirect_uri", "state"] + attrs = CdiUtil.bean(Identity).getSessionId().getSessionAttributes() + + authzParams = {} + # use passport-* instead of casa + authzParams["acr_values"] = "agama" + + # set the provider to use + flowParams = URLEncoder.encode(json.dumps({ "providerId" : providerId })) + authzParams["agama_flow"] = self.agamaFQN + "-" + flowParams + + # avoids flow updating the profile of user if he has been provisioned previously + authzParams["skipProfileUpdate"] = "true" + + # copy the params in the current casa request + for param in params: + authzParams[param] = URLEncoder.encode(attrs.get(param), "UTF-8") + + url = "/jans-auth/restv1/authorize?" + for param in authzParams: + url += "&" + param + "=" + authzParams[param] + + else: + print "Casa. getAuthzRequestUrl. Provider %s not recognized" % providerId + + return url + + + def parseProviderConfigs(self): + + providers = {} + try: + entryManager = CdiUtil.bean(PersistenceEntryManager) + dn = "agFlowQName=%s,ou=flows,ou=agama,o=jans" % self.agamaFQN + conf = entryManager.find(Flow().getClass(), dn).getMetadata().getProperties() + + if conf == None: + return providers + + prop = "enabled" + for provId in conf: + pr = conf.get(provId) + if not pr.containsKey(prop) or pr.get(prop): + providers[provId] = { "displayName": pr.get("displayName"), "logoImg": pr.get("logoImg") } + + return providers + except: + print "Casa. parseProviderConfigs. An error occurred while building the list of supported authentication providers", sys.exc_info()[1] + + return providers diff --git a/jans-casa/plugins/acct-linking/extras/agama/code/io.jans.casa.acctlinking.Launcher.flow b/jans-casa/plugins/acct-linking/extras/agama/code/io.jans.casa.acctlinking.Launcher.flow new file mode 100644 index 00000000000..d6938e5e62e --- /dev/null +++ b/jans-casa/plugins/acct-linking/extras/agama/code/io.jans.casa.acctlinking.Launcher.flow @@ -0,0 +1,65 @@ +Flow io.jans.casa.acctlinking.Launcher + Basepath "" + Configs providers + Inputs providerId uidRef + +provider = providers.$providerId +//See class io.jans.inbound.Provider for reference + +When provider is null or provider.enabled is false + msg = Call java.lang.String#format "Provider '%s' not recognized. Is it enabled?" providerId + obj = { success: false, error: msg } + Finish obj + +//Launch matching flow and retrieve profile +Log "Initiating external authentication for identity provider '%'" providerId +obj = Trigger $provider.flowQname provider + +When obj.success is false + Finish obj + +field = Call io.jans.inbound.Utils#getMappingField provider.mappingClassField +idProc = Call io.jans.inbound.IdentityProcessor#new provider +profile = Call idProc applyMapping obj.data field + +field = null +//In profile, every key is associated to a list +Log "@d Mapped profile is\n" profile + +When profile.ID is null or profile.ID.empty is true + obj = { success: false, error: "Mapped profile misses value for 'ID'" } + Finish obj + +When profile.mail is null or profile.mail.empty is true + Log "Incoming user has no e-mail value" + + //Prompt for e-mail if necessary + When provider.requestForEmail is true + obj = RRF "email-prompt.ftlh" + mail = obj.email + + When mail is null + obj = { success: false, error: "Unable to complete profile data: e-mail not provided" } + Finish obj + + profile.mail = [ mail ] + +jansExtUid = Call io.jans.casa.acctlinking.UidUtils#computeExtUid providerId profile.ID[0] +uid = null + +When profile.uid is not null + uid = profile.uid[0] + +uid = Call io.jans.casa.acctlinking.UidUtils#lookupUid uidRef uid profile.ID[0] "jansExtUid" jansExtUid +profile.jansExtUid = Call io.jans.casa.acctlinking.UidUtils#attrValuesAdding uid "jansExtUid" jansExtUid + +profile.uid = [ uid ] +profile.ID = null //ID not part of DB schema - jansExtUid has what is needed + +uid | E = Call idProc process profile +When E is null + Finish uid + +Log "@e Unable to process the incoming user" E +obj = { success: false, error: E.message } +Finish obj diff --git a/jans-casa/plugins/acct-linking/extras/agama/lib/io/jans/casa/acctlinking/Mappings.java b/jans-casa/plugins/acct-linking/extras/agama/lib/io/jans/casa/acctlinking/Mappings.java new file mode 100644 index 00000000000..bc72da34944 --- /dev/null +++ b/jans-casa/plugins/acct-linking/extras/agama/lib/io/jans/casa/acctlinking/Mappings.java @@ -0,0 +1,71 @@ +package io.jans.casa.acctlinking; + +import java.util.function.UnaryOperator; +import java.util.HashMap; +import java.util.Map; + +/** + * Fields of this class can be referenced in the config properties of flow + * io.jans.casa.acctlinking.Launcher + */ +public final class Mappings { + + public static final UnaryOperator> GOOGLE = + + profile -> { + Map map = new HashMap<>(); + + map.put("ID", profile.get("sub")); + map.put("mail", profile.get("email")); + map.put("cn", profile.get("name")); + map.put("sn", profile.get("family_name")); + map.put("displayName", profile.get("given_name")); + map.put("givenName", profile.get("given_name")); + + return map; + }; + + //See https://developers.facebook.com/docs/graph-api/reference/user + public static final UnaryOperator> FACEBOOK = + + profile -> { + Map map = new HashMap<>(); + + map.put("ID", profile.get("id")); + map.put("mail", profile.get("email")); + map.put("cn", profile.get("name")); + map.put("sn", profile.get("last_name")); + map.put("displayName", profile.get("first_name")); + map.put("givenName", profile.get("first_name")); + + return map; + }; + + public static final UnaryOperator> APPLE = + + profile -> { + Map map = new HashMap<>(); + + map.put("ID", profile.get("sub")); + map.put("mail", profile.get("email")); + + return map; + }; + + //See https://docs.github.com/en/rest/users/users + public static final UnaryOperator> GITHUB = + + profile -> { + Map map = new HashMap<>(); + + map.put("ID", profile.getOrDefault("login", profile.get("id"))); + map.put("mail", profile.get("email")); + map.put("displayName", profile.get("name")); + map.put("givenName", profile.get("name")); + + return map; + }; + + private Mappings() { } + +} diff --git a/jans-casa/plugins/acct-linking/extras/agama/lib/io/jans/casa/acctlinking/UidUtils.java b/jans-casa/plugins/acct-linking/extras/agama/lib/io/jans/casa/acctlinking/UidUtils.java new file mode 100644 index 00000000000..53043e10d6f --- /dev/null +++ b/jans-casa/plugins/acct-linking/extras/agama/lib/io/jans/casa/acctlinking/UidUtils.java @@ -0,0 +1,83 @@ +package io.jans.casa.acctlinking; + +import io.jans.as.common.model.common.User; +import io.jans.as.common.service.common.UserService; +import io.jans.service.cache.CacheProvider; +import io.jans.service.cdi.util.CdiUtil; + +import java.io.IOException; +import java.util.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class UidUtils { + + private static final Logger logger = LoggerFactory.getLogger(Utils.class); + + public static String lookupUid(String uidRef, String uid, String extUid, String jansExtAttrName, + String jansExtUid) throws IOException { + + if (uidRef == null) { + boolean uidPassed = uid != null; + + if (uidPassed) { + logger.debug("Using uid passed: {}", uid); + return uid; + } + + //Find if the external account is already linked to a local one + User user = CdiUtil.bean(UserService.class).getUserByAttribute(jansExtAttrName, jansExtUid, true); + if (user == null) { + logger.info("Building a uid based on external id {}", extUid); + return extUid + "-" + randSuffix(3); + } + logger.info("Using uid of the account already linked to {}", extUid); + return user.getUserId(); + } + + logger.debug("Looking up uid ref {}", uidRef); + Object value = CdiUtil.bean(CacheProvider.class).get(uidRef); + if (value == null) throw new IOException("uid reference passed not found in Cache!"); + + return value.toString(); + + } + + public static List attrValuesAdding(String uid, String attributeName, String valueToAdd) { + + User user = CdiUtil.bean(UserService.class).getUserByAttribute("uid", uid, false); + if (user == null) return Collections.singletonList(valueToAdd); + + List values = new ArrayList<>(); + List currentValues = Optional.ofNullable(user.getAttributeValues(attributeName)) + .orElse(Collections.emptyList()); + values.addAll(currentValues); + + if (!currentValues.contains(valueToAdd)) { + values.add(valueToAdd); + } + return values; + + } + + public static String computeExtUid(String providerId, String id) { + return providerId + ":" + id; + } + + private Utils() { } + + // The idea here is to generate a random 3-char lengthed string "easy to remember" + private static String randSuffix(int randSuffixLen) { + + String s = ""; + int radix = Math.min(15, Character.MAX_RADIX); //radix 15 entails characters: 0-9 plus a-e + + for (int i = 0; i < randSuffixLen; i++) { + long rnd = Math.random() * radix; // rnd will belong to [0, radix - 1] + s += Integer.toString((int) rnd, radix); // this adds a single character to s + } + return s; + } + +} diff --git a/jans-casa/plugins/acct-linking/extras/agama/project.json b/jans-casa/plugins/acct-linking/extras/agama/project.json new file mode 100644 index 00000000000..2c5533497c6 --- /dev/null +++ b/jans-casa/plugins/acct-linking/extras/agama/project.json @@ -0,0 +1,58 @@ +{ + "projectName": "casa-account-linking", + "author": "jgomer2001", + "type": "Community", + "version": "1.0.0", + "description": "A helper project for Jans Casa accounts linking plugin", + "noDirectLaunch": [ ], + "configs": { + "io.jans.casa.acctlinking.Launcher": { + + "facebook": { + "flowQname": "io.jans.inbound.GenericProvider", + "displayName": "Facebook", + "mappingClassField": "io.jans.casa.acctlinking.Mappings.FACEBOOK", + "logoImg": "facebook.png", + "oauthParams": { + "authzEndpoint": "https://www.facebook.com/v14.0/dialog/oauth", + "tokenEndpoint": "https://graph.facebook.com/v14.0/oauth/access_token", + "userInfoEndpoint": "https://graph.facebook.com/v14.0/me", + "clientId": "", + "clientSecret": "", + "scopes": ["email", "public_profile"] + } + }, + + "github": { + "flowQname": "io.jans.inbound.GenericProvider", + "displayName": "Github", + "mappingClassField": "io.jans.casa.acctlinking.Mappings.GITHUB", + "oauthParams": { + "authzEndpoint": "https://github.com/login/oauth/authorize", + "tokenEndpoint": "https://github.com/login/oauth/access_token", + "userInfoEndpoint": "https://api.github.com/user", + "clientId": "", + "clientSecret": "", + "scopes": ["user"] + } + }, + + "google": { + "flowQname": "io.jans.inbound.GenericProvider", + "displayName": "Google", + "mappingClassField": "io.jans.casa.acctlinking.Mappings.GOOGLE", + "enabled": false, + "skipProfileUpdate": true, + "oauthParams": { + "authzEndpoint": "https://accounts.google.com/o/oauth2/v2/auth", + "tokenEndpoint": "https://oauth2.googleapis.com/token", + "userInfoEndpoint": "https://www.googleapis.com/oauth2/v3/userinfo", + "clientId": "", + "clientSecret": "", + "scopes": ["email", "profile"] + } + } + + } + } +} diff --git a/jans-casa/plugins/acct-linking/extras/agama/web/apple.png b/jans-casa/plugins/acct-linking/extras/agama/web/apple.png new file mode 100644 index 00000000000..d852c10925f Binary files /dev/null and b/jans-casa/plugins/acct-linking/extras/agama/web/apple.png differ diff --git a/jans-casa/plugins/acct-linking/extras/agama/web/email-prompt.ftlh b/jans-casa/plugins/acct-linking/extras/agama/web/email-prompt.ftlh new file mode 100644 index 00000000000..67ff370c0ea --- /dev/null +++ b/jans-casa/plugins/acct-linking/extras/agama/web/email-prompt.ftlh @@ -0,0 +1,50 @@ + + + + + + + + + + +
+
+ +
+ +
+ +
+

Please provide an e-mail address to proceed

+ +
+
+ +
+ +
+
+
+
+ +
+
+
+
+ +
+ +
+
+
+
+ + + diff --git a/jans-casa/plugins/acct-linking/extras/agama/web/facebook.png b/jans-casa/plugins/acct-linking/extras/agama/web/facebook.png new file mode 100644 index 00000000000..6f2d7a0f1eb Binary files /dev/null and b/jans-casa/plugins/acct-linking/extras/agama/web/facebook.png differ diff --git a/jans-casa/plugins/acct-linking/extras/agama/web/github.png b/jans-casa/plugins/acct-linking/extras/agama/web/github.png new file mode 100644 index 00000000000..228b09acd42 Binary files /dev/null and b/jans-casa/plugins/acct-linking/extras/agama/web/github.png differ diff --git a/jans-casa/plugins/acct-linking/extras/agama/web/google.png b/jans-casa/plugins/acct-linking/extras/agama/web/google.png new file mode 100644 index 00000000000..f92017b3bf7 Binary files /dev/null and b/jans-casa/plugins/acct-linking/extras/agama/web/google.png differ diff --git a/jans-casa/plugins/acct-linking/extras/agama/web/none.png b/jans-casa/plugins/acct-linking/extras/agama/web/none.png new file mode 100644 index 00000000000..4d84db2750f Binary files /dev/null and b/jans-casa/plugins/acct-linking/extras/agama/web/none.png differ diff --git a/jans-casa/plugins/acct-linking/extras/login.xhtml b/jans-casa/plugins/acct-linking/extras/login.xhtml new file mode 100644 index 00000000000..004766d278f --- /dev/null +++ b/jans-casa/plugins/acct-linking/extras/login.xhtml @@ -0,0 +1,147 @@ + + + + + + + #{msgs['casa.login.title']} + + + + +
+

#{msgs['casa.login.panel_title']}

+ + +
+ + +
+
+ + +
+
+ +

OR use an external service to sign in:

+
+
+ + + +
+
+ + +
+

+ Correlation Id: +

+
+ + +
+
+ +
+ +
diff --git a/jans-casa/plugins/acct-linking/pom.xml b/jans-casa/plugins/acct-linking/pom.xml new file mode 100644 index 00000000000..6c9ee66bad4 --- /dev/null +++ b/jans-casa/plugins/acct-linking/pom.xml @@ -0,0 +1,109 @@ + + + + 4.0.0 + + io.jans.casa.plugins + ${plugin.id} + 1.1.0-SNAPSHOT + jar + + + 11 + 11 + acct-linking + + + + + jans + Janssen project repository + https://maven.jans.io/maven + + + + + + + org.apache.maven.plugins + maven-assembly-plugin + 3.1.0 + + + jar-with-dependencies + + + + ${plugin.id} + ${project.version} + Janssen project + io.jans.casa.plugins.acctlinking.AccountsLinkingPlugin + + Allows the user to link their external identities (social sites and other OAuth providers) + to his local Jans account + + Available under Apache 2 license + io.jans.casa.plugins + + + + + + make-assembly + package + + single + + + + + + com.github.spotbugs + spotbugs-maven-plugin + 4.2.0 + + + + + + + + com.nimbusds + oauth2-oidc-sdk + 11.7 + provided + + + + io.jans + agama-inbound + ${project.version} + + + * + * + + + + + + io.jans + jans-core-cache + ${project.version} + provided + + + io.jans + casa-shared + ${project.version} + provided + + + io.jans + casa-config + ${project.version} + provided + + + + diff --git a/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/AccountsLinkingPlugin.java b/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/AccountsLinkingPlugin.java new file mode 100644 index 00000000000..4503d08178a --- /dev/null +++ b/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/AccountsLinkingPlugin.java @@ -0,0 +1,24 @@ +package io.jans.casa.plugins.acctlinking; + +import org.pf4j.Plugin; +import org.pf4j.PluginWrapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AccountsLinkingPlugin extends Plugin { + + private Logger logger = LoggerFactory.getLogger(getClass()); + + public AccountsLinkingPlugin(PluginWrapper wrapper) throws Exception { + super(wrapper); + } + + @Override + public void start() { + } + + @Override + public void delete() { + } + +} diff --git a/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/AccountsLinkingService.java b/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/AccountsLinkingService.java new file mode 100644 index 00000000000..d90fe3f8462 --- /dev/null +++ b/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/AccountsLinkingService.java @@ -0,0 +1,196 @@ +package io.jans.casa.plugins.acctlinking; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.nimbusds.oauth2.sdk.http.HTTPRequest; +import com.nimbusds.oauth2.sdk.http.HTTPResponse; +import com.nimbusds.oauth2.sdk.ParseException; + +import io.jans.casa.conf.OIDCClientSettings; +import io.jans.casa.core.model.IdentityPerson; +import io.jans.casa.misc.Utils; +import io.jans.casa.model.ApplicationConfiguration; +import io.jans.casa.service.IPersistenceService; +import io.jans.inbound.Provider; + +import java.io.IOException; +import java.net.*; +import java.util.*; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static java.nio.charset.StandardCharsets.UTF_8; + +public class AccountsLinkingService { + + public static final String CASA_AGAMA_FLOW = "io.jans.casa.acctlinking.Launcher"; + + public static final String READ_SCOPE = "https://jans.io/oauth/config/agama.readonly"; + + private static final String AGAMA_PRJ = "casa-account-linking"; + + private static final String CONFIGS_ENDPOINT = + "/jans-config-api/api/v1/agama-deployment/configs/" + AGAMA_PRJ; + + private Logger logger = LoggerFactory.getLogger(getClass()); + + private static AccountsLinkingService instance; + private IPersistenceService ips; + private ObjectMapper mapper; + + private OIDCClientSettings clSettings; + private String issuer; + private String basicAuthnHeader; + + public static AccountsLinkingService getInstance() { + if (instance == null) { + instance = new AccountsLinkingService(); + } + return instance; + } + + public OIDCClientSettings getCasaClient() { + return clSettings; + } + + public Map getProviders(boolean enabledOnly) throws Exception { + + HTTPRequest request = new HTTPRequest(HTTPRequest.Method.GET, + new URL(issuer + CONFIGS_ENDPOINT)); + setTimeouts(request); + request.setAuthorization(basicAuthnHeader); + request.setAuthorization("Bearer " + getAToken()); + + HTTPResponse r = request.send(); + r.ensureStatusCode(200); + + Map> madam = mapper.readValue( + r.getBody(), new TypeReference>>(){}); + + Map madman = Optional.ofNullable(madam) + .map(m -> m.get(CASA_AGAMA_FLOW)).orElse(Collections.emptyMap()); + + if (enabledOnly) { + madman = madman.entrySet().stream().filter(e -> e.getValue().isEnabled()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + return madman; + + } + + public Map getAccounts(String userId, Set knownProviders) { + + Map accts = new HashMap<>(); + for (String extUid : getPerson(userId).getJansExtUid()) { + //See method computeExtUid + int i = extUid.indexOf(":"); + + if (i > 0 && i < extUid.length() - 1) { + String pref = extUid.substring(0, i); + if (knownProviders.contains(pref)) { + accts.put(pref, extUid.substring(i + 1)); + } + } + } + return accts; + + } + + /* + //linking occurs at the Agama flow + public boolean link(String userId, String providerId, String extUid) { + + try { + IdentityPerson p = getPerson(userId); + List extUids = new ArrayList<>(p.getJansExtUid()); + extUids.add(computeExtUid(providerId, extUid)); + + p.setJansExtUid(extUids); + ips.modify(p); + return true; + } catch (Exception e) { + logger.error(e.getMessage(), e); + } + return false; + + }*/ + + public boolean delink(String userId, String providerId, String extUid) { + + try { + IdentityPerson p = getPerson(userId); + List extUids = new ArrayList<>(p.getJansExtUid()); + extUids.remove(computeExtUid(providerId, extUid)); + + p.setJansExtUid(extUids); + ips.modify(p); + return true; + } catch (Exception e) { + logger.error(e.getMessage(), e); + } + return false; + + } + + public boolean hasPassword(String id) { + return getPerson(id).hasPassword(); + } + + private IdentityPerson getPerson(String id) { + return ips.get(IdentityPerson.class, ips.getPersonDn(id)); + } + + private String computeExtUid(String providerId, String id) { + //This method HAS to match computeExtUid in class io.jans.agama.inbound.Utils + return providerId + ":" + id; + } + + private AccountsLinkingService() { + logger.info("Initializing AccountsLinkingService"); + mapper = new ObjectMapper(); + + ips = Utils.managedBean(IPersistenceService.class); + issuer = ips.getIssuerUrl(); + logger.debug("Issuer is {}", issuer); + + clSettings = ips.get(ApplicationConfiguration.class, "ou=casa,ou=configuration,o=jans") + .getSettings().getOidcSettings().getClient(); + + String authz = clSettings.getClientId() + ":" + clSettings.getClientSecret(); + authz = new String(Base64.getEncoder().encode(authz.getBytes(UTF_8)), UTF_8); + basicAuthnHeader = "Basic " + authz; + + } + + private String getAToken() throws IOException { + + StringJoiner joiner = new StringJoiner("&"); + Map.of("grant_type", "client_credentials", "scope", URLEncoder.encode(READ_SCOPE, UTF_8)) + .forEach((k, v) -> joiner.add(k + "=" + v)); + + logger.info("Calling token endpoint"); + + HTTPRequest request = new HTTPRequest( + HTTPRequest.Method.POST, new URL(issuer + "/jans-auth/restv1/token")); + setTimeouts(request); + request.setQuery(joiner.toString()); + request.setAuthorization(basicAuthnHeader); + + try { + Map jobj = request.send().getContentAsJSONObject(); + logger.info("Successful call"); + return jobj.get("access_token").toString(); + } catch (Exception e) { + throw new IOException(e.getMessage(), e); + } + + } + + private void setTimeouts(HTTPRequest request) { + request.setConnectTimeout(3500); + request.setReadTimeout(3500); + } + +} diff --git a/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/extension/AdminMenu.java b/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/extension/AdminMenu.java new file mode 100644 index 00000000000..5c0db84d743 --- /dev/null +++ b/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/extension/AdminMenu.java @@ -0,0 +1,22 @@ +package io.jans.casa.plugins.acctlinking; + +import io.jans.casa.extension.navigation.MenuType; +import io.jans.casa.extension.navigation.NavigationMenu; +import org.pf4j.Extension; + +@Extension +public class AdminMenu implements NavigationMenu { + + public String getContentsUrl() { + return "admin/menu.zul"; + } + + public MenuType menuType() { + return MenuType.ADMIN_CONSOLE; + } + + public float getPriority() { + return 0.1f; + } + +} diff --git a/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/extension/UserMenu.java b/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/extension/UserMenu.java new file mode 100644 index 00000000000..d16c16a6dd7 --- /dev/null +++ b/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/extension/UserMenu.java @@ -0,0 +1,23 @@ +package io.jans.casa.plugins.acctlinking; + +import io.jans.casa.extension.navigation.MenuType; +import io.jans.casa.extension.navigation.NavigationMenu; +import org.pf4j.Extension; + +@Extension +public class UserMenu implements NavigationMenu { + + public String getContentsUrl() { + return "user/menu.zul"; + } + + public MenuType menuType() { + return MenuType.USER; + } + + public float getPriority() { + return 0.1f; + } + +} + diff --git a/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/vm/AccountsLinkingSettingsVM.java b/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/vm/AccountsLinkingSettingsVM.java new file mode 100644 index 00000000000..eaddb34e869 --- /dev/null +++ b/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/vm/AccountsLinkingSettingsVM.java @@ -0,0 +1,42 @@ +package io.jans.casa.plugins.acctlinking.vm; + +import io.jans.casa.plugins.acctlinking.AccountsLinkingService; +import io.jans.inbound.Provider; + +import java.util.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zkoss.bind.annotation.Init; + +public class AccountsLinkingSettingsVM { + + private Logger logger = LoggerFactory.getLogger(getClass()); + + private String error; + + private Map providers = Collections.emptyMap(); + + public Map getProviders() { + return providers; + } + + public String getError() { + return error; + } + + @Init + public void init() { + + try { + logger.info("Refreshing list of identity providers"); + providers = AccountsLinkingService.getInstance().getProviders(false); + } catch (Exception e) { + providers = null; + error = e.getMessage(); + logger.error(error, e); + } + + } + +} diff --git a/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/vm/AccountsLinkingVM.java b/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/vm/AccountsLinkingVM.java new file mode 100644 index 00000000000..c311d02f5b6 --- /dev/null +++ b/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/vm/AccountsLinkingVM.java @@ -0,0 +1,143 @@ +package io.jans.casa.plugins.acctlinking.vm; + +import io.jans.inbound.Provider; +import io.jans.casa.plugins.acctlinking.AccountsLinkingService; +import io.jans.casa.service.ISessionContext; +import io.jans.casa.ui.UIUtils; + +import java.util.*; +import java.util.concurrent.TimeUnit; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zkoss.bind.BindUtils; +import org.zkoss.bind.annotation.*; +import org.zkoss.util.resource.Labels; +import org.zkoss.zk.ui.Component; +import org.zkoss.zk.ui.event.Event; +import org.zkoss.zk.ui.event.EventQueues; +import org.zkoss.zk.ui.select.annotation.WireVariable; +import org.zkoss.zul.Messagebox; + +public class AccountsLinkingVM { + + public static final String LINK_QUEUE = "social_queue"; + + public static final String EVENT_NAME = "linked"; + + public static final long ENROLL_TIME_MS = TimeUnit.MINUTES.toMillis(1); //1 min + + private Logger logger = LoggerFactory.getLogger(getClass()); + + @WireVariable + private ISessionContext sessionContext; + + private AccountsLinkingService als; + private Map providers; + private Map accounts; + + private String userId; + private String pendingProvider; + private long pendingLinkingExpiresAt; + + public Map getProviders() { + return providers; + } + + public Map getAccounts() { + return accounts; + } + + public String getPendingProvider() { + return pendingProvider; + } + + public AccountsLinkingVM() { + als = AccountsLinkingService.getInstance(); + } + + @Init + public void init() { + + try { + logger.info("Refreshing list of identity providers"); + providers = als.getProviders(true); + logger.info("{} identity providers found", providers.size()); + + userId = sessionContext.getLoggedUser().getId(); + parseLinkedAccounts(); + + if (providers.size() > 0) { + + EventQueues.lookup(LINK_QUEUE, EventQueues.SESSION, true).subscribe(event -> { + if (event.getName().equals(EVENT_NAME)) { + + String data = Optional.ofNullable(event.getData()).map(Object::toString).orElse(null); + if (data != null) { + logger.info("Received link start event for {}", data); + pendingLinkingExpiresAt = System.currentTimeMillis() + ENROLL_TIME_MS; + pendingProvider = data; + } else { + logger.info("Received linked event"); + cancel(); + parseLinkedAccounts(); + } + BindUtils.postNotifyChange(AccountsLinkingVM.this, "accounts", "pendingProvider"); + } + }); + } + + } catch (Exception e) { + logger.error(e.getMessage()); + } + + } + + @NotifyChange("pendingProvider") + public void cancel() { + pendingProvider = null; + } + + @Command + public void poll() { + + if (pendingProvider != null && pendingLinkingExpiresAt < System.currentTimeMillis()) { + logger.info("Too much time elapsed for linking to finish"); + cancel(); + //I could have used @NotifyChange("pendingProvider") in this method but postnotify will give + //better performance here. UI refresh takes place only if this IF is reached + BindUtils.postNotifyChange(AccountsLinkingVM.this, "pendingProvider"); + } + + } + + public void remove(String providerId, String extUid) { + + if (accounts.size() > 1 || als.hasPassword(userId)) { + Provider p = providers.get(providerId); + + Messagebox.show(Labels.getLabel("al.remove_hint"), null, Messagebox.YES | Messagebox.NO, Messagebox.QUESTION, + event -> { + if (Messagebox.ON_YES.equals(event.getName())) { + + if (als.delink(userId, providerId, extUid)) { + parseLinkedAccounts(); + UIUtils.showMessageUI(true, Labels.getLabel("al.removed_link", new String[]{ p.getDisplayName() })); + BindUtils.postNotifyChange(AccountsLinkingVM.this, "accounts"); + } else { + UIUtils.showMessageUI(false); + } + } + }); + } else { + Messagebox.show(Labels.getLabel("al.linking_pass_needed"), null, Messagebox.OK, Messagebox.INFORMATION); + } + + } + + private void parseLinkedAccounts() { + logger.info("Parsing linked accounts for {}", userId); + accounts = als.getAccounts(userId, providers.keySet()); + } + +} diff --git a/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/vm/SiteRedirectVM.java b/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/vm/SiteRedirectVM.java new file mode 100644 index 00000000000..ecc82f4237f --- /dev/null +++ b/jans-casa/plugins/acct-linking/src/main/java/io/jans/casa/plugins/acctlinking/vm/SiteRedirectVM.java @@ -0,0 +1,179 @@ +package io.jans.casa.plugins.acctlinking.vm; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.JsonProcessingException; + +import io.jans.casa.conf.OIDCClientSettings; +import io.jans.casa.misc.Utils; +import io.jans.casa.misc.WebUtils; +import io.jans.casa.service.IPersistenceService; +import io.jans.casa.service.ISessionContext; +import io.jans.casa.plugins.acctlinking.AccountsLinkingService; +import io.jans.inbound.oauth2.OAuthParams; +import io.jans.inbound.oauth2.CodeGrantUtil; +import io.jans.service.cache.*; +import io.jans.util.Pair; + +import java.util.*; +import java.util.stream.Collectors; +import java.net.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.zkoss.bind.annotation.Init; +import org.zkoss.bind.annotation.QueryParam; +import org.zkoss.util.resource.Labels; +import org.zkoss.zk.ui.Sessions; +import org.zkoss.zk.ui.event.Event; +import org.zkoss.zk.ui.event.EventQueues; +import org.zkoss.zk.ui.select.annotation.WireVariable; + +public class SiteRedirectVM { + + private Logger logger = LoggerFactory.getLogger(getClass()); + + private static final String STATE_ATTR = "st"; + + @WireVariable + private ISessionContext sessionContext; + + private CacheInterface cache; + private AccountsLinkingService als; + private ObjectMapper mapper; + + private String serverUrl; + private String userName; + + private String text; + private String title; + + public String getText() { + return text; + } + + public String getTitle() { + return title; + } + + public SiteRedirectVM() { + als = AccountsLinkingService.getInstance(); + mapper = new ObjectMapper(); + serverUrl = Utils.managedBean(IPersistenceService.class).getIssuerUrl(); + cache = Utils.managedBean(CacheInterface.class); + } + + @Init + public void init(@QueryParam("provider") String provider) { + + try { + logger.debug("Initializing ViewModel"); + userName = sessionContext.getLoggedUser().getUserName(); + + title = Labels.getLabel("general.error.general"); + String currentUrl = WebUtils.getServletRequest().getRequestURL().toString(); + + CodeGrantUtil cgu = new CodeGrantUtil(makeOAuthParams(als.getCasaClient(), provider, currentUrl)); + + if (Utils.isNotEmpty(provider)) { + text = Labels.getLabel("al.link_redirect_failed", new String[]{ provider }); + String url = getAuthzRequestRedirectUrl(cgu); + + EventQueues.lookup(AccountsLinkingVM.LINK_QUEUE, EventQueues.SESSION, true) + .publish(new Event(AccountsLinkingVM.EVENT_NAME, null, provider)); + WebUtils.execRedirect(url, false); + + } else { + + //Agama authn flow finished + String state = Optional.ofNullable(Sessions.getCurrent().getAttribute(STATE_ATTR)) + .map(Object::toString).orElse(null); + + if (state == null) return; + + Map mama = WebUtils.getServletRequest().getParameterMap().entrySet().stream() + .map(entry -> { + String[] val = entry.getValue(); + List values = (val == null || val.length == 0) ? + Collections.emptyList() : Arrays.asList(val); + return new AbstractMap.SimpleEntry<>(entry.getKey(), values); + }).collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + String code = cgu.parseCode(mama, state); + + logger.info("Got an authz code at callback URL"); + //If the token request succeeds, ie. does not throw, it means the code is not fake + cgu.getTokenResponse(code); + + logger.info("Notifying linking page..."); + EventQueues.lookup(AccountsLinkingVM.LINK_QUEUE, EventQueues.SESSION, true) + .publish(new Event(AccountsLinkingVM.EVENT_NAME, null, null)); + + title = Labels.getLabel("al.linking_result.success"); + text = Labels.getLabel("al.linking_result.success_close"); + } + + } catch (Exception e) { + text = e.getMessage(); + logger.error(text, e); + } + + } + + private String getAuthzRequestRedirectUrl(CodeGrantUtil cgu) throws URISyntaxException { + + logger.info("Building an agama authentication request"); + Pair pair = cgu.makeAuthzRequest(); + + Sessions.getCurrent().setAttribute(STATE_ATTR, pair.getSecond()); + return pair.getFirst(); + + } + + private OAuthParams makeOAuthParams(OIDCClientSettings cl, String provider, String redirectUri) { + + OAuthParams p = new OAuthParams(); + p.setAuthzEndpoint(serverUrl + "/jans-auth/restv1/authorize"); + p.setTokenEndpoint(serverUrl + "/jans-auth/restv1/token"); + p.setClientId(cl.getClientId()); + p.setClientSecret(cl.getClientSecret()); + p.setScopes(Collections.singletonList("openid")); + p.setRedirectUri(redirectUri); + + Map custMap = new HashMap<>(); + + if (provider != null) { + custMap.put("acr_values", "agama"); + custMap.put("agama_flow", makeAgamaFlowParam(provider)); + } + + //prompt is needed because the user could have previously linked an account and in a new + //attempt to link at a different provider, launching an authn request will not trigger the + //agama flow because there is "existing" session in the AS + custMap.put("prompt", "login"); + p.setCustParamsAuthReq(custMap); + return p; + + } + + private String makeAgamaFlowParam(String provider) { + + String key = "" + Math.random(); + int sec = Long.valueOf(AccountsLinkingVM.ENROLL_TIME_MS).intValue() / 1000; + + logger.debug("Writing uid ref to cache"); + //What is stored here will be later retrieved by the agama flow. Twice the casa enrollment time + //given. This is to avoid the not very user-friendly error "uid reference passed not found in Cache!" + cache.put(2 * sec, key, userName); + + String s = null; + try { + s = mapper.writeValueAsString(Map.of("providerId", provider, "uidRef", key)); + } catch (JsonProcessingException e) { + //this will never happen + logger.error(e.getMessage()); + } + return als.CASA_AGAMA_FLOW + "-" + s; + + } + +} diff --git a/jans-casa/plugins/acct-linking/src/main/resources/assets/admin/al-settings.zul b/jans-casa/plugins/acct-linking/src/main/resources/assets/admin/al-settings.zul new file mode 100644 index 00000000000..622b37edfc3 --- /dev/null +++ b/jans-casa/plugins/acct-linking/src/main/resources/assets/admin/al-settings.zul @@ -0,0 +1,59 @@ + + + + + + ${zkService.appName} - ${labels.al.title} + + + + + +
+
+ +
+

${labels.al.title}

+
+
+

${labels.al.adm.intro_text}

+ +

${labels.al.adm.no_providers}

+ +

${labels.al.adm.error_retrieve}

+ + + + + + + + + ${labels.al.adm.name} + ${labels.al.adm.flow} + ${labels.al.adm.mapping} + ${labels.al.adm.status} + + + + + + ${each.value.displayName} + ${each.value.flowQname} + ${each.value.mappingClassField} + + + + + +
+
+
+
+ +
diff --git a/jans-casa/plugins/acct-linking/src/main/resources/assets/admin/menu.zul b/jans-casa/plugins/acct-linking/src/main/resources/assets/admin/menu.zul new file mode 100644 index 00000000000..85511e0027a --- /dev/null +++ b/jans-casa/plugins/acct-linking/src/main/resources/assets/admin/menu.zul @@ -0,0 +1,10 @@ + + +
  • + + ${labels.al.title} + +
  • + +
    diff --git a/jans-casa/plugins/acct-linking/src/main/resources/assets/user/index.zul b/jans-casa/plugins/acct-linking/src/main/resources/assets/user/index.zul new file mode 100644 index 00000000000..6326a7f3210 --- /dev/null +++ b/jans-casa/plugins/acct-linking/src/main/resources/assets/user/index.zul @@ -0,0 +1,79 @@ + + + + + ${zkService.appName} - ${labels.al.title} + + + + + + + +
    +
    +
    +

    ${labels.al.title}

    +

    + +

    + + +
    +
    + + + +
    + +
    diff --git a/jans-casa/plugins/acct-linking/src/main/resources/assets/user/interlude.zul b/jans-casa/plugins/acct-linking/src/main/resources/assets/user/interlude.zul new file mode 100644 index 00000000000..97c57ea4906 --- /dev/null +++ b/jans-casa/plugins/acct-linking/src/main/resources/assets/user/interlude.zul @@ -0,0 +1,17 @@ + + + + +
    +
    +
    +

    ${vm.title}

    +
    +
    +

    ${vm.text}

    +
    +
    +
    + +
    diff --git a/jans-casa/plugins/acct-linking/src/main/resources/assets/user/menu.zul b/jans-casa/plugins/acct-linking/src/main/resources/assets/user/menu.zul new file mode 100644 index 00000000000..450d6cc0971 --- /dev/null +++ b/jans-casa/plugins/acct-linking/src/main/resources/assets/user/menu.zul @@ -0,0 +1,10 @@ + + +
  • + + + ${labels.al.title} + +
  • + +
    diff --git a/jans-casa/plugins/acct-linking/src/main/resources/labels/zk-label.properties b/jans-casa/plugins/acct-linking/src/main/resources/labels/zk-label.properties new file mode 100644 index 00000000000..d59d7b660ff --- /dev/null +++ b/jans-casa/plugins/acct-linking/src/main/resources/labels/zk-label.properties @@ -0,0 +1,56 @@ +# Admin section + +al.title=Accounts Linking + +al.nothing_yet=Nothing to show here yet. Contact your administrator. + +al.intro_text=Link accounts you own at external sites, for instance social networks. + +al.link_unavailable=Not linked + +al.link_status=Linked to user + +al.buttons.link=Link +al.buttons.linking=Linking ... + +al.buttons.remove=Remove + +al.buttons.cancel=Cancel + +al.remove_hint=Are you sure you want to remove this linked account? + +al.removed_link=The association with your account at {0} was removed. + +# usr.password_set.title is defined in core casa project (user.properties) +al.linking_pass_needed={ +To remove your only linked account, you have to set a password first. This will allow you to still have means +to access this application in the future. + +Click on "${usr.password_set.title}" to do so. +} + +al.link_redirect_failed=Redirecting to site "{0}" was not possible + +al.linking_result.success=Your account has been linked successfully + +al.linking_result.success_close=You can close this window now. + +## Admin labels + +al.adm.learn_config=Check the plugin docs to learn how to manage external identity providers. + +al.adm.intro_text=This is the list of external identity providers configured so far. ${al.adm.learn_config} + +al.adm.no_providers=There are no providers defined yet. ${al.adm.learn_config} + +al.adm.error_retrieve=Unable to retrieve the list of external identity providers. ${al.adm.learn_config} + +al.adm.name=Name + +al.adm.status=Enabled + +al.adm.flow=Agama flow + +al.adm.mapping=Profile attribute mapping + + diff --git a/jans-casa/pom.xml b/jans-casa/pom.xml index 70b1a361b74..0a7572b91ab 100644 --- a/jans-casa/pom.xml +++ b/jans-casa/pom.xml @@ -61,6 +61,7 @@ shared app app-fips + plugins/acct-linking plugins/client-authorizations plugins/custom-branding plugins/strong-authn-settings diff --git a/mkdocs.yml b/mkdocs.yml index 9bd7f8cf57c..37ad9d27878 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -448,6 +448,7 @@ nav: - 'Localization': 'casa/administration/localization.md' - 'Plugins': - '2FA Settings': 'casa/plugins/2fa-settings.md' + - 'Accounts Linking': 'casa/plugins/accts-linking/index.md' - 'Consent Management': 'casa/plugins/consent-management.md' - 'Custom Branding': 'casa/plugins/custom-branding.md' - 'FAQ': 'casa/administration/faq.md'