From e0890235fa2a8db8177799e41d672fb5e256abed Mon Sep 17 00:00:00 2001 From: David Festal Date: Mon, 28 May 2018 11:39:44 +0200 Subject: [PATCH] Allow creating OpenShift objects under the current user account on OCP (#9577) * Support identity provider token retrieval in both JSON or URL formats. That's required because some identity providers (such a `openshift-v3`) correctly return the token information in JSON, as expected. So switching to the url-based syntax should only used when the returned json is invalid. Signed-off-by: David Festal * Introduce an `OpenShiftClientConfigFactory` to allow customizing the OpenShift config returned according to the current context (workspace ID, current user) Signed-off-by: David Festal * Openshift Infra + Multi-user => allow using OpenShift identity provider to connect to openshift with the OS oauth token of the current Che user. This introduces a new property: `che.infra.openshift.oauth_identity_provider` Signed-off-by: David Festal * Notify the user when a workspace cannot be started from the nav bar. Signed-off-by: David Festal * Add the ability to install the Openshift certificate into Keycloak Signed-off-by: David Festal * Add a yaml file to provide the openshift certificate as a secret, in case it has to be installed into the dedicated Keycloak server. Then the commands to install Che multiuser on Minishift with this certificate are: ``` oc new-project che oc process -f multi/openshift-certificate-secret.yaml -p CERTIFICATE="$(minishift ssh docker exec origin /bin/cat ./openshift.local.config/master/ca.crt)" | oc apply -f -; \ oc new-app -f multi/postgres-template.yaml; \ oc new-app -f multi/keycloak-template.yaml -p ROUTING_SUFFIX=$(minishift ip).nip.io; \ oc apply -f pvc/che-server-pvc.yaml; \ oc new-app -f che-server-template.yaml -p ROUTING_SUFFIX=$(minishift ip).nip.io -p CHE_MULTIUSER=true -p CHE_INFRA_OPENSHIFT_OAUTH__IDENTITY__PROVIDER=openshift-v3; \ oc set volume dc/che --add -m /data --name=che-data-volume --claim-name=che-data-volume ``` Of course it's still needed to register the `openshift-v3` identity provider in the Keycloak server, as well as, add the corresponding `OAuthClient` object in Minihshift. Signed-off-by: David Festal --- .../che/api/deploy/WsMasterModule.java | 7 + .../WEB-INF/classes/che/multiuser.properties | 14 ++ .../recent-workspaces.controller.ts | 12 +- deploy/openshift/templates/README.md | 21 ++ .../templates/che-server-template.yaml | 6 + .../templates/multi/keycloak-template.yaml | 6 + .../multi/openshift-certificate-secret.yaml | 29 +++ dockerfiles/keycloak/Dockerfile | 2 + .../cli/add_openshift_certificate.cli | 4 + dockerfiles/keycloak/kc_realm_user.sh | 7 + infrastructures/openshift/pom.xml | 8 + .../OpenShiftClientConfigFactory.java | 35 +++ .../openshift/OpenShiftClientFactory.java | 17 ++ .../oauth/IdentityProviderConfigFactory.java | 171 +++++++++++++ .../IdentityProviderConfigBuilderTest.java | 230 ++++++++++++++++++ .../server/KeycloakServiceClient.java | 33 ++- 16 files changed, 590 insertions(+), 12 deletions(-) create mode 100644 deploy/openshift/templates/multi/openshift-certificate-secret.yaml create mode 100644 dockerfiles/keycloak/cli/add_openshift_certificate.cli create mode 100644 infrastructures/openshift/src/main/java/org/eclipse/che/workspace/infrastructure/openshift/OpenShiftClientConfigFactory.java create mode 100644 infrastructures/openshift/src/main/java/org/eclipse/che/workspace/infrastructure/openshift/multiuser/oauth/IdentityProviderConfigFactory.java create mode 100644 infrastructures/openshift/src/test/java/org/eclipse/che/workspace/infrastructure/openshift/multiuser/oauth/IdentityProviderConfigBuilderTest.java diff --git a/assembly/assembly-wsmaster-war/src/main/java/org/eclipse/che/api/deploy/WsMasterModule.java b/assembly/assembly-wsmaster-war/src/main/java/org/eclipse/che/api/deploy/WsMasterModule.java index 785bacd00d..9d710a10f1 100644 --- a/assembly/assembly-wsmaster-war/src/main/java/org/eclipse/che/api/deploy/WsMasterModule.java +++ b/assembly/assembly-wsmaster-war/src/main/java/org/eclipse/che/api/deploy/WsMasterModule.java @@ -90,8 +90,10 @@ import org.eclipse.che.workspace.infrastructure.docker.DockerInfraModule; import org.eclipse.che.workspace.infrastructure.docker.local.LocalDockerModule; import org.eclipse.che.workspace.infrastructure.kubernetes.KubernetesInfraModule; import org.eclipse.che.workspace.infrastructure.kubernetes.KubernetesInfrastructure; +import org.eclipse.che.workspace.infrastructure.openshift.OpenShiftClientConfigFactory; import org.eclipse.che.workspace.infrastructure.openshift.OpenShiftInfraModule; import org.eclipse.che.workspace.infrastructure.openshift.OpenShiftInfrastructure; +import org.eclipse.che.workspace.infrastructure.openshift.multiuser.oauth.IdentityProviderConfigFactory; import org.eclipse.persistence.config.PersistenceUnitProperties; import org.flywaydb.core.internal.util.PlaceholderReplacer; @@ -301,6 +303,11 @@ public class WsMasterModule extends AbstractModule { bind(WorkspaceStatusCache.class) .to(org.eclipse.che.api.workspace.server.DefaultWorkspaceStatusCache.class); } + + if (OpenShiftInfrastructure.NAME.equals(infrastructure)) { + bind(OpenShiftClientConfigFactory.class).to(IdentityProviderConfigFactory.class); + } + persistenceProperties.put( PersistenceUnitProperties.EXCEPTION_HANDLER_CLASS, "org.eclipse.che.core.db.postgresql.jpa.eclipselink.PostgreSqlExceptionHandler"); diff --git a/assembly/assembly-wsmaster-war/src/main/webapp/WEB-INF/classes/che/multiuser.properties b/assembly/assembly-wsmaster-war/src/main/webapp/WEB-INF/classes/che/multiuser.properties index f2b4362fed..acd9077ffd 100644 --- a/assembly/assembly-wsmaster-war/src/main/webapp/WEB-INF/classes/che/multiuser.properties +++ b/assembly/assembly-wsmaster-war/src/main/webapp/WEB-INF/classes/che/multiuser.properties @@ -96,6 +96,20 @@ che.organization.email.org_removed_template=st-html-templates/organization_delet che.organization.email.org_renamed_subject=Che Organization renamed che.organization.email.org_renamed_template=st-html-templates/organization_renamed +##### MULTI-USER-SPECIFIC OPENSHIFT INFRASTRUCTURE CONFIGURATION ##### + +# Alias of the Openshift identity provider registered in Keycloak, +# that should be used to create workspace OpenShift resources in +# Openshift namespaces owned by the current Che user. +# +# Should be set to NULL if `che.infra.openshift.project` +# is set to a non-empty value. +# +# For more information see the following documentation: +# https://www.keycloak.org/docs/3.3/server_admin/topics/identity-broker/social/openshift.html + +che.infra.openshift.oauth_identity_provider=NULL + ##### KEYCLOACK CONFIGURATION ##### # Url to keycloak identity provider server diff --git a/dashboard/src/app/navbar/recent-workspaces/recent-workspaces.controller.ts b/dashboard/src/app/navbar/recent-workspaces/recent-workspaces.controller.ts index 397abd79ba..cf9b1db0ac 100644 --- a/dashboard/src/app/navbar/recent-workspaces/recent-workspaces.controller.ts +++ b/dashboard/src/app/navbar/recent-workspaces/recent-workspaces.controller.ts @@ -13,6 +13,8 @@ import {CheWorkspace} from '../../../components/api/workspace/che-workspace.fact import IdeSvc from '../../../app/ide/ide.service'; import {CheBranding} from '../../../components/branding/che-branding.factory'; import {WorkspacesService} from '../../workspaces/workspaces.service'; +import {CheNotification} from '../../../components/notification/che-notification.factory'; + const MAX_RECENT_WORKSPACES_ITEMS: number = 5; @@ -24,7 +26,7 @@ const MAX_RECENT_WORKSPACES_ITEMS: number = 5; */ export class NavbarRecentWorkspacesController { - static $inject = ['ideSvc', 'cheWorkspace', 'cheBranding', '$window', '$log', '$scope', '$rootScope', 'workspacesService']; + static $inject = ['ideSvc', 'cheWorkspace', 'cheBranding', '$window', '$log', '$scope', '$rootScope', 'workspacesService', 'cheNotification']; cheWorkspace: CheWorkspace; dropdownItemTempl: Array; @@ -40,6 +42,8 @@ export class NavbarRecentWorkspacesController { dropdownItems: Object; workspaceCreationLink: string; workspacesService: WorkspacesService; + cheNotification: CheNotification; + /** * Default constructor @@ -51,7 +55,8 @@ export class NavbarRecentWorkspacesController { $log: ng.ILogService, $scope: ng.IScope, $rootScope: ng.IRootScopeService, - workspacesService: WorkspacesService) { + workspacesService: WorkspacesService, + cheNotification: CheNotification) { this.ideSvc = ideSvc; this.cheWorkspace = cheWorkspace; this.$log = $log; @@ -59,6 +64,7 @@ export class NavbarRecentWorkspacesController { this.$rootScope = $rootScope; this.workspaceCreationLink = cheBranding.getWorkspace().creationLink; this.workspacesService = workspacesService; + this.cheNotification = cheNotification; // workspace updated time map by id this.workspaceUpdated = new Map(); @@ -312,6 +318,7 @@ export class NavbarRecentWorkspacesController { angular.noop(); }, (error: any) => { this.$log.error(error); + this.cheNotification.showError('Stop workspace error.', error); }); } @@ -325,6 +332,7 @@ export class NavbarRecentWorkspacesController { this.updateRecentWorkspace(workspaceId); this.cheWorkspace.startWorkspace(workspace.id, workspace.config.defaultEnv).catch((error: any) => { this.$log.error(error); + this.cheNotification.showError('Run workspace error.', error); }); } diff --git a/deploy/openshift/templates/README.md b/deploy/openshift/templates/README.md index 5f62c6c850..3f4a39f8a6 100644 --- a/deploy/openshift/templates/README.md +++ b/deploy/openshift/templates/README.md @@ -238,3 +238,24 @@ IMPORTANT! Self-signed certificates aren't acceptable. Default credentials are `admin:admin`. Go to Clients, `che-public` client and edit **Valid Redirect URIs** and **Web Origins** URLs so that they use **https** protocol. You do not need to do that if you initially deploy Che with https support. + +## Creating workspace resources in personal OpenShift accounts + +To allow creating workspace OpenShift resources in personal OpenShift accounts, you should: +- configure the Openshift identity provider in Keycloak as described in the Admin Guide +- install the Openshift console certificate in the Keycloak server (if it's self-signed) by: + - retrieve the OpenShift console certificate into the `~/openshift.crt` file with this command: + `minishift ssh docker exec origin /bin/cat ./openshift.local.config/master/ca.crt > ~/openshift.crt` + - running the following command before all other commands: + +```bash + oc process -f multi/openshift-certificate-secret.yaml -p CERTIFICATE="$(cat ~/openshift.crt)" | oc apply -f - +``` + +- add the following parameters to the `oc new-app -f che-server-template.yaml` command: + +``` +-p CHE_INFRA_OPENSHIFT_PROJECT=NULL -p CHE_INFRA_OPENSHIFT_OAUTH__IDENTITY__PROVIDER=openshift-v3 +``` + + diff --git a/deploy/openshift/templates/che-server-template.yaml b/deploy/openshift/templates/che-server-template.yaml index b781f1dfdf..7578a64250 100644 --- a/deploy/openshift/templates/che-server-template.yaml +++ b/deploy/openshift/templates/che-server-template.yaml @@ -115,6 +115,8 @@ objects: value: "INFO" - name: CHE_KEYCLOAK_AUTH__SERVER__URL value: "${CHE_KEYCLOAK_AUTH__SERVER__URL}" + - name: CHE_INFRA_OPENSHIFT_OAUTH__IDENTITY__PROVIDER + value: "${CHE_INFRA_OPENSHIFT_OAUTH__IDENTITY__PROVIDER}" - name: CHE_OAUTH_GITHUB_CLIENTID value: "${CHE_OAUTH_GITHUB_CLIENTID}" - name: CHE_OAUTH_GITHUB_CLIENTSECRET @@ -233,6 +235,10 @@ parameters: displayName: Identity provider URL description: URL of a remote identity provider. Defaults to Keycloak bundled with Che multi user value: '${PROTOCOL}://keycloak-${NAMESPACE}.${ROUTING_SUFFIX}/auth' +- name: CHE_INFRA_OPENSHIFT_OAUTH__IDENTITY__PROVIDER + displayName: Alias of the Openshift identity provider in Keycloak + description: Alias of the Openshift identity provider registered in Keycloak, that should be used to create workspace OpenShift resources in Openshift namespaces owned by the current Che user. + value: 'NULL' - name: STRATEGY displayName: Update Strategy description: Che server update strategy. Defaults to Recreate. Use Rolling only if Che deployment does not use PVC diff --git a/deploy/openshift/templates/multi/keycloak-template.yaml b/deploy/openshift/templates/multi/keycloak-template.yaml index 5940705297..b0fd6e89ef 100644 --- a/deploy/openshift/templates/multi/keycloak-template.yaml +++ b/deploy/openshift/templates/multi/keycloak-template.yaml @@ -53,6 +53,12 @@ objects: value: "${ROUTING_SUFFIX}" - name: CHE_KEYCLOAK_ADMIN_REQUIRE_UPDATE_PASSWORD value: "${CHE_KEYCLOAK_ADMIN_REQUIRE_UPDATE_PASSWORD}" + - name: OPENSHIFT_IDENTITY_PROVIDER_CERTIFICATE + valueFrom: + secretKeyRef: + key: ca.crt + name: openshift-identity-provider + optional: true image: '${IMAGE_KEYCLOAK}:${CHE_VERSION}' command: ["/scripts/kc_realm_user.sh"] imagePullPolicy: Always diff --git a/deploy/openshift/templates/multi/openshift-certificate-secret.yaml b/deploy/openshift/templates/multi/openshift-certificate-secret.yaml new file mode 100644 index 0000000000..a4f93c5ec3 --- /dev/null +++ b/deploy/openshift/templates/multi/openshift-certificate-secret.yaml @@ -0,0 +1,29 @@ +# Copyright (c) 2012-2018 Red Hat, Inc +# All rights reserved. This program and the accompanying materials +# are made available under the terms of the Eclipse Public License v1.0 +# which accompanies this distribution, and is available at +# http://www.eclipse.org/legal/epl-v10.html +# +--- +kind: Template +apiVersion: v1 +metadata: + name: openshift-identity-provider-certificate + annotations: + description: Che +objects: +- apiVersion: v1 + stringData: + ca.crt: >- + ${CERTIFICATE} + kind: Secret + metadata: + name: openshift-identity-provider + namespace: che + type: Opaque +parameters: +- name: CERTIFICATE + displayName: Openshift console certificate +labels: + app: keycloak + template: openshift-identity-provider-certificate diff --git a/dockerfiles/keycloak/Dockerfile b/dockerfiles/keycloak/Dockerfile index 13b3440aca..f7c94182c3 100644 --- a/dockerfiles/keycloak/Dockerfile +++ b/dockerfiles/keycloak/Dockerfile @@ -8,6 +8,8 @@ FROM jboss/keycloak-openshift:3.3.0.CR2-3 ADD che /opt/jboss/keycloak/themes/che ADD . /scripts/ +ADD cli /scripts/cli + USER root RUN chgrp -R 0 /scripts && \ chmod -R g+rwX /scripts diff --git a/dockerfiles/keycloak/cli/add_openshift_certificate.cli b/dockerfiles/keycloak/cli/add_openshift_certificate.cli new file mode 100644 index 0000000000..87ce6bc0cf --- /dev/null +++ b/dockerfiles/keycloak/cli/add_openshift_certificate.cli @@ -0,0 +1,4 @@ +embed-server --server-config=standalone.xml --std-out=echo +/subsystem=keycloak-server/spi=truststore/:add +/subsystem=keycloak-server/spi=truststore/provider=file/:add(properties={file => "/scripts/openshift.jks", password => "openshift", disabled => "false" },enabled=true) +stop-embedded-server \ No newline at end of file diff --git a/dockerfiles/keycloak/kc_realm_user.sh b/dockerfiles/keycloak/kc_realm_user.sh index 21d98f0fc4..04ae86a54e 100755 --- a/dockerfiles/keycloak/kc_realm_user.sh +++ b/dockerfiles/keycloak/kc_realm_user.sh @@ -27,6 +27,13 @@ if [ $KEYCLOAK_USER ] && [ $KEYCLOAK_PASSWORD ]; then /opt/jboss/keycloak/bin/add-user-keycloak.sh --user $KEYCLOAK_USER --password $KEYCLOAK_PASSWORD fi +if [ "${OPENSHIFT_IDENTITY_PROVIDER_CERTIFICATE}" != "" ]; then + echo "${OPENSHIFT_IDENTITY_PROVIDER_CERTIFICATE}" > /scripts/openshift.cer + keytool -importcert -alias HOSTDOMAIN -keystore /scripts/openshift.jks -file /scripts/openshift.cer -storepass openshift -noprompt + keytool -importkeystore -srckeystore $JAVA_HOME/jre/lib/security/cacerts -destkeystore /scripts/openshift.jks -srcstorepass changeit -deststorepass openshift + /opt/jboss/keycloak/bin/jboss-cli.sh --file=/scripts/cli/add_openshift_certificate.cli && rm -rf /opt/jboss/keycloak/standalone/configuration/standalone_xml_history +fi + echo "Starting Keycloak server..." /opt/jboss/keycloak/bin/standalone.sh -Dkeycloak.migration.action=import \ diff --git a/infrastructures/openshift/pom.xml b/infrastructures/openshift/pom.xml index 9fe4c1c70c..11c023de05 100644 --- a/infrastructures/openshift/pom.xml +++ b/infrastructures/openshift/pom.xml @@ -98,6 +98,14 @@ org.eclipse.che.infrastructure.docker docker-environment + + org.eclipse.che.multiuser + che-multiuser-keycloak-server + + + org.eclipse.che.multiuser + che-multiuser-keycloak-shared + org.slf4j slf4j-api diff --git a/infrastructures/openshift/src/main/java/org/eclipse/che/workspace/infrastructure/openshift/OpenShiftClientConfigFactory.java b/infrastructures/openshift/src/main/java/org/eclipse/che/workspace/infrastructure/openshift/OpenShiftClientConfigFactory.java new file mode 100644 index 0000000000..713ca597b4 --- /dev/null +++ b/infrastructures/openshift/src/main/java/org/eclipse/che/workspace/infrastructure/openshift/OpenShiftClientConfigFactory.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2012-2018 Red Hat, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.che.workspace.infrastructure.openshift; + +import io.fabric8.kubernetes.client.Config; +import org.eclipse.che.api.workspace.server.spi.InfrastructureException; +import org.eclipse.che.commons.annotation.Nullable; +import org.eclipse.che.workspace.infrastructure.openshift.multiuser.oauth.IdentityProviderConfigFactory; + +/** + * This class allows customizing the OpenShift {@link Config} returned by the {@link + * OpenShiftClientFactory} according to the current context (workspace ID, current user). + * + * @author David Festal + * @see IdentityProviderConfigFactory + */ +public class OpenShiftClientConfigFactory { + + /** + * Builds the Openshift {@link Config} object based on a default {@link Config} object and an + * optional workspace Id. + */ + public Config buildConfig(Config defaultConfig, @Nullable String workspaceId) + throws InfrastructureException { + return defaultConfig; + } +} diff --git a/infrastructures/openshift/src/main/java/org/eclipse/che/workspace/infrastructure/openshift/OpenShiftClientFactory.java b/infrastructures/openshift/src/main/java/org/eclipse/che/workspace/infrastructure/openshift/OpenShiftClientFactory.java index 821e0ae232..b97a53c3f6 100644 --- a/infrastructures/openshift/src/main/java/org/eclipse/che/workspace/infrastructure/openshift/OpenShiftClientFactory.java +++ b/infrastructures/openshift/src/main/java/org/eclipse/che/workspace/infrastructure/openshift/OpenShiftClientFactory.java @@ -56,8 +56,11 @@ public class OpenShiftClientFactory extends KubernetesClientFactory { private static final Logger LOG = LoggerFactory.getLogger(OpenShiftClientFactory.class); + private final OpenShiftClientConfigFactory configBuilder; + @Inject public OpenShiftClientFactory( + OpenShiftClientConfigFactory configBuilder, @Nullable @Named("che.infra.kubernetes.master_url") String masterUrl, @Nullable @Named("che.infra.kubernetes.username") String username, @Nullable @Named("che.infra.kubernetes.password") String password, @@ -79,6 +82,7 @@ public class OpenShiftClientFactory extends KubernetesClientFactory { maxConcurrentRequestsPerHost, maxIdleConnections, connectionPoolKeepAlive); + this.configBuilder = configBuilder; } protected Config buildDefaultConfig( @@ -108,6 +112,19 @@ public class OpenShiftClientFactory extends KubernetesClientFactory { return theConfig; } + /** + * Builds the Openshift {@link Config} object based on a default {@link Config} object and an + * optional workspace Id. + * + *

This method overrides the one in the Kubernetes infrastructure to introduce an additional + * extension level by delegating to an {@link OpenShiftClientConfigFactory} + */ + @Override + protected Config buildConfig(Config defaultConfig, @Nullable String workspaceId) + throws InfrastructureException { + return configBuilder.buildConfig(defaultConfig, workspaceId); + } + protected Interceptor buildKubernetesInterceptor(Config config) { final String oauthToken; if (Utils.isNotNullOrEmpty(config.getUsername()) diff --git a/infrastructures/openshift/src/main/java/org/eclipse/che/workspace/infrastructure/openshift/multiuser/oauth/IdentityProviderConfigFactory.java b/infrastructures/openshift/src/main/java/org/eclipse/che/workspace/infrastructure/openshift/multiuser/oauth/IdentityProviderConfigFactory.java new file mode 100644 index 0000000000..bf2bd2e390 --- /dev/null +++ b/infrastructures/openshift/src/main/java/org/eclipse/che/workspace/infrastructure/openshift/multiuser/oauth/IdentityProviderConfigFactory.java @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2012-2018 Red Hat, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.che.workspace.infrastructure.openshift.multiuser.oauth; + +import static org.eclipse.che.multiuser.keycloak.shared.KeycloakConstants.AUTH_SERVER_URL_SETTING; +import static org.eclipse.che.multiuser.keycloak.shared.KeycloakConstants.CLIENT_ID_SETTING; +import static org.eclipse.che.multiuser.keycloak.shared.KeycloakConstants.REALM_SETTING; + +import com.google.inject.Provider; +import io.fabric8.kubernetes.client.Config; +import io.fabric8.openshift.client.OpenShiftConfig; +import io.fabric8.openshift.client.OpenShiftConfigBuilder; +import java.util.Optional; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import org.eclipse.che.api.core.BadRequestException; +import org.eclipse.che.api.core.UnauthorizedException; +import org.eclipse.che.api.workspace.server.WorkspaceRuntimes; +import org.eclipse.che.api.workspace.server.spi.InfrastructureException; +import org.eclipse.che.api.workspace.server.spi.RuntimeContext; +import org.eclipse.che.commons.annotation.Nullable; +import org.eclipse.che.commons.env.EnvironmentContext; +import org.eclipse.che.commons.subject.Subject; +import org.eclipse.che.multiuser.keycloak.server.KeycloakServiceClient; +import org.eclipse.che.multiuser.keycloak.server.KeycloakSettings; +import org.eclipse.che.multiuser.keycloak.shared.dto.KeycloakTokenResponse; +import org.eclipse.che.workspace.infrastructure.openshift.OpenShiftClientConfigFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class retrieves the OpenShift OAuth token of the current Che user, and injects it the + * OpenShift {@link Config} object, so that workspace OpenShift resources will be created under the + * OpenShift account of the current Che user. + * + *

The OpenShift OAuth token is retrieved using the OpenShift identity provider configured in the + * Keycloak server. + * + *

If the current user is not the user that started the current workspace (for operations such as + * idling, che server shutdown, etc ...), then global OpenShift infrastructure credentials defined + * in the Che properties will be used. + * + * @author David Festal + */ +@Singleton +public class IdentityProviderConfigFactory extends OpenShiftClientConfigFactory { + + private static final Logger LOG = LoggerFactory.getLogger(IdentityProviderConfigFactory.class); + + private final String oauthIdentityProvider; + + private final KeycloakServiceClient keycloakServiceClient; + private final KeycloakSettings keycloakSettings; + private final Provider workspaceRuntimeProvider; + private final String messageToLinkAccount; + + private String rootUrl; + + @Inject + public IdentityProviderConfigFactory( + KeycloakServiceClient keycloakServiceClient, + KeycloakSettings keycloakSettings, + Provider workspaceRuntimeProvider, + @Nullable @Named("che.infra.openshift.oauth_identity_provider") String oauthIdentityProvider, + @Named("che.api") String apiEndpoint) { + super(); + this.keycloakServiceClient = keycloakServiceClient; + this.keycloakSettings = keycloakSettings; + this.workspaceRuntimeProvider = workspaceRuntimeProvider; + + this.oauthIdentityProvider = oauthIdentityProvider; + rootUrl = apiEndpoint; + if (rootUrl.endsWith("/")) { + rootUrl = rootUrl.substring(0, rootUrl.length() - 1); + } + if (rootUrl.endsWith("/api")) { + rootUrl = rootUrl.substring(0, rootUrl.length() - 4); + } + + String referrer_uri = + rootUrl.replace("http://", "http%3A%2F%2F").replace("https://", "https%3A%2F%2F") + + "%2Fdashboard%2F?redirect_fragment%3D%2Fworkspaces"; + + messageToLinkAccount = + "You should link your account with the " + + oauthIdentityProvider + + " \n" + + "identity provider by visiting the " + + "Federated Identities page of your Che account"; + } + + /** + * Builds the Openshift {@link Config} object based on a default {@link Config} object and an + * optional workspace Id. + */ + public Config buildConfig(Config defaultConfig, @Nullable String workspaceId) + throws InfrastructureException { + Subject subject = EnvironmentContext.getCurrent().getSubject(); + + String workspaceOwnerId = null; + if (workspaceId != null) { + @SuppressWarnings("rawtypes") + Optional context = + workspaceRuntimeProvider.get().getRuntimeContext(workspaceId); + workspaceOwnerId = context.map(c -> c.getIdentity().getOwnerId()).orElse(null); + } + + if (oauthIdentityProvider != null + && subject != Subject.ANONYMOUS + && (workspaceOwnerId == null || subject.getUserId().equals(workspaceOwnerId))) { + try { + KeycloakTokenResponse keycloakTokenInfos = + keycloakServiceClient.getIdentityProviderToken(oauthIdentityProvider); + if ("user:full".equals(keycloakTokenInfos.getScope())) { + return new OpenShiftConfigBuilder(OpenShiftConfig.wrap(defaultConfig)) + .withOauthToken(keycloakTokenInfos.getAccessToken()) + .build(); + } else { + throw new InfrastructureException( + "Cannot retrieve user Openshift token: '" + + oauthIdentityProvider + + "' identity provider is not granted full rights: " + + oauthIdentityProvider); + } + } catch (UnauthorizedException e) { + LOG.error("cannot retrieve User Openshift token from the identity provider", e); + + throw new InfrastructureException(messageToLinkAccount); + } catch (BadRequestException e) { + LOG.error( + "cannot retrieve User Openshift token from the '" + + oauthIdentityProvider + + "' identity provider", + e); + if (e.getMessage().endsWith("Invalid token.")) { + throw new InfrastructureException( + "Your session has expired. \nPlease " + + "" + + "login" + + " to Che again to get access to your Openshift account"); + } + throw new InfrastructureException(e.getMessage(), e); + } catch (Exception e) { + LOG.error( + "cannot retrieve User Openshift token from the '" + + oauthIdentityProvider + + "' identity provider", + e); + throw new InfrastructureException(e.getMessage(), e); + } + } + return defaultConfig; + } +} diff --git a/infrastructures/openshift/src/test/java/org/eclipse/che/workspace/infrastructure/openshift/multiuser/oauth/IdentityProviderConfigBuilderTest.java b/infrastructures/openshift/src/test/java/org/eclipse/che/workspace/infrastructure/openshift/multiuser/oauth/IdentityProviderConfigBuilderTest.java new file mode 100644 index 0000000000..62aac0537d --- /dev/null +++ b/infrastructures/openshift/src/test/java/org/eclipse/che/workspace/infrastructure/openshift/multiuser/oauth/IdentityProviderConfigBuilderTest.java @@ -0,0 +1,230 @@ +/* + * Copyright (c) 2012-2018 Red Hat, Inc. + * All rights reserved. This program and the accompanying materials + * are made available under the terms of the Eclipse Public License v1.0 + * which accompanies this distribution, and is available at + * http://www.eclipse.org/legal/epl-v10.html + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.che.workspace.infrastructure.openshift.multiuser.oauth; + +import static org.eclipse.che.multiuser.keycloak.shared.KeycloakConstants.AUTH_SERVER_URL_SETTING; +import static org.eclipse.che.multiuser.keycloak.shared.KeycloakConstants.CLIENT_ID_SETTING; +import static org.eclipse.che.multiuser.keycloak.shared.KeycloakConstants.REALM_SETTING; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; +import static org.testng.Assert.fail; + +import com.google.inject.Provider; +import io.fabric8.kubernetes.client.Config; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import org.eclipse.che.api.core.BadRequestException; +import org.eclipse.che.api.core.UnauthorizedException; +import org.eclipse.che.api.core.model.workspace.runtime.RuntimeIdentity; +import org.eclipse.che.api.core.server.dto.DtoServerImpls.ServiceErrorImpl; +import org.eclipse.che.api.workspace.server.WorkspaceRuntimes; +import org.eclipse.che.api.workspace.server.spi.InfrastructureException; +import org.eclipse.che.api.workspace.server.spi.RuntimeContext; +import org.eclipse.che.commons.env.EnvironmentContext; +import org.eclipse.che.commons.subject.Subject; +import org.eclipse.che.multiuser.keycloak.server.KeycloakServiceClient; +import org.eclipse.che.multiuser.keycloak.server.KeycloakSettings; +import org.eclipse.che.multiuser.keycloak.shared.dto.KeycloakTokenResponse; +import org.mockito.Mock; +import org.mockito.testng.MockitoTestNGListener; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Listeners; +import org.testng.annotations.Test; + +/** @author David Festal */ +@Listeners(MockitoTestNGListener.class) +public class IdentityProviderConfigBuilderTest { + private static final String PROVIDER = "openshift-v3"; + private static final String THE_USER_ID = "a_user_id"; + private static final String ANOTHER_USER_ID = "another_user_id"; + private static final String A_WORKSPACE_ID = "workspace_id"; + private static final String FULL_SCOPE = "user:full"; + private static final String ACCESS_TOKEN = "accessToken"; + private static final String AUTH_SERVER_URL = "http://keycloak.url/auth"; + private static final String REALM = "realm"; + private static final String CLIENT_ID = "clientId"; + private static final String API_ENDPOINT = "http://che-host/api"; + + private static final String SHOULD_LINK_ERROR_MESSAGE = + "You should link your account with the " + + PROVIDER + + " \n" + + "identity provider by visiting the " + + "Federated Identities page of your Che account"; + + private static final String SESSION_EXPIRED_MESSAGE = + "Your session has expired. \nPlease " + + "" + + "login" + + " to Che again to get access to your Openshift account"; + + private static final Map keycloakSettingsMap = new HashMap(); + + @Mock private KeycloakServiceClient keycloakServiceClient; + @Mock private KeycloakSettings keycloakSettings; + @Mock private Provider workspaceRuntimeProvider; + @Mock private WorkspaceRuntimes workspaceRuntimes; + @Mock private Subject subject; + @Mock private RuntimeIdentity runtimeIdentity; + + @SuppressWarnings("rawtypes") + @Mock + private RuntimeContext runtimeContext; + + @Mock private KeycloakTokenResponse tokenResponse; + + private EnvironmentContext context; + private IdentityProviderConfigFactory configBuilder; + private Config defaultConfig; + + static { + keycloakSettingsMap.put(AUTH_SERVER_URL_SETTING, AUTH_SERVER_URL); + keycloakSettingsMap.put(REALM_SETTING, REALM); + keycloakSettingsMap.put(CLIENT_ID_SETTING, CLIENT_ID); + } + + @BeforeMethod + public void setUp() throws Exception { + when(keycloakSettings.get()).thenReturn(keycloakSettingsMap); + context = spy(EnvironmentContext.getCurrent()); + EnvironmentContext.setCurrent(context); + doReturn(subject).when(context).getSubject(); + when(workspaceRuntimeProvider.get()).thenReturn(workspaceRuntimes); + when(workspaceRuntimes.getRuntimeContext(anyString())) + .thenReturn(Optional.ofNullable(runtimeContext)); + when(runtimeContext.getIdentity()).thenReturn(runtimeIdentity); + when(runtimeIdentity.getOwnerId()).thenReturn(THE_USER_ID); + when(subject.getUserId()).thenReturn(THE_USER_ID); + when(tokenResponse.getScope()).thenReturn(FULL_SCOPE); + when(tokenResponse.getAccessToken()).thenReturn(ACCESS_TOKEN); + + configBuilder = + new IdentityProviderConfigFactory( + keycloakServiceClient, + keycloakSettings, + workspaceRuntimeProvider, + PROVIDER, + API_ENDPOINT); + defaultConfig = new io.fabric8.kubernetes.client.ConfigBuilder().build(); + } + + @Test + public void testFallbackToDefaultConfigWhenProvideIsNull() throws Exception { + when(keycloakServiceClient.getIdentityProviderToken(anyString())).thenReturn(tokenResponse); + configBuilder = + new IdentityProviderConfigFactory( + keycloakServiceClient, keycloakSettings, workspaceRuntimeProvider, null, API_ENDPOINT); + assertTrue(defaultConfig == configBuilder.buildConfig(defaultConfig, A_WORKSPACE_ID)); + } + + @Test + public void testFallbackToDefaultConfigWhenSubjectIsAnonymous() throws Exception { + when(keycloakServiceClient.getIdentityProviderToken(anyString())).thenReturn(tokenResponse); + doReturn(Subject.ANONYMOUS).when(context).getSubject(); + assertTrue(defaultConfig == configBuilder.buildConfig(defaultConfig, A_WORKSPACE_ID)); + } + + @Test + public void testFallbackToDefaultConfigWhenCurrentUserIsDifferentFromWorkspaceOwner() + throws Exception { + when(keycloakServiceClient.getIdentityProviderToken(anyString())).thenReturn(tokenResponse); + when(runtimeIdentity.getOwnerId()).thenReturn(ANOTHER_USER_ID); + assertTrue(defaultConfig == configBuilder.buildConfig(defaultConfig, A_WORKSPACE_ID)); + } + + @SuppressWarnings("rawtypes") + @Test + public void testCreateUserConfigWhenNoRuntimeContext() throws Exception { + when(keycloakServiceClient.getIdentityProviderToken(anyString())).thenReturn(tokenResponse); + when(workspaceRuntimes.getRuntimeContext(anyString())) + .thenReturn(Optional.empty()); + + Config resultConfig = configBuilder.buildConfig(defaultConfig, A_WORKSPACE_ID); + assertEquals(resultConfig.getOauthToken(), ACCESS_TOKEN); + } + + @Test + public void testCreateUserConfigWhenWorkspaceIdIsNull() throws Exception { + when(keycloakServiceClient.getIdentityProviderToken(anyString())).thenReturn(tokenResponse); + Config resultConfig = configBuilder.buildConfig(defaultConfig, null); + assertEquals(resultConfig.getOauthToken(), ACCESS_TOKEN); + } + + @Test + public void testCreateUserConfig() throws Exception { + when(keycloakServiceClient.getIdentityProviderToken(anyString())).thenReturn(tokenResponse); + Config resultConfig = configBuilder.buildConfig(defaultConfig, A_WORKSPACE_ID); + assertEquals(resultConfig.getOauthToken(), ACCESS_TOKEN); + } + + @Test(expectedExceptions = {InfrastructureException.class}) + public void testThrowOnBadScope() throws Exception { + when(keycloakServiceClient.getIdentityProviderToken(anyString())).thenReturn(tokenResponse); + when(tokenResponse.getScope()).thenReturn("bad:scope"); + Config resultConfig = configBuilder.buildConfig(defaultConfig, A_WORKSPACE_ID); + assertEquals(resultConfig.getOauthToken(), ACCESS_TOKEN); + } + + public void testRethrowOnUnauthorizedException() throws Exception { + doThrow(new UnauthorizedException(ServiceErrorImpl.make().withMessage("Any other message"))) + .when(keycloakServiceClient) + .getIdentityProviderToken(anyString()); + try { + configBuilder.buildConfig(defaultConfig, A_WORKSPACE_ID); + } catch (InfrastructureException e) { + assertEquals(e.getMessage(), SHOULD_LINK_ERROR_MESSAGE, "The exception message is wrong"); + return; + } + fail( + "Should have thrown an exception with the following message: " + SHOULD_LINK_ERROR_MESSAGE); + } + + @Test(expectedExceptions = {InfrastructureException.class}) + public void testRethrowOnBadRequestException() throws Exception { + doThrow(new BadRequestException(ServiceErrorImpl.make().withMessage("Any other message"))) + .when(keycloakServiceClient) + .getIdentityProviderToken(anyString()); + configBuilder.buildConfig(defaultConfig, A_WORKSPACE_ID); + } + + public void testRethrowOnInvalidTokenBadRequestException() throws Exception { + doThrow(new BadRequestException(ServiceErrorImpl.make().withMessage("Invalid token."))) + .when(keycloakServiceClient) + .getIdentityProviderToken(anyString()); + try { + configBuilder.buildConfig(defaultConfig, A_WORKSPACE_ID); + } catch (InfrastructureException e) { + assertEquals(e.getMessage(), SESSION_EXPIRED_MESSAGE, "The exception message is wrong"); + return; + } + fail("Should have thrown an exception with the following message: " + SESSION_EXPIRED_MESSAGE); + } + + @Test(expectedExceptions = {InfrastructureException.class}) + public void testRethrowOnAnyException() throws Exception { + when(keycloakServiceClient.getIdentityProviderToken(anyString())).thenThrow(Exception.class); + configBuilder.buildConfig(defaultConfig, A_WORKSPACE_ID); + } +} diff --git a/multiuser/keycloak/che-multiuser-keycloak-server/src/main/java/org/eclipse/che/multiuser/keycloak/server/KeycloakServiceClient.java b/multiuser/keycloak/che-multiuser-keycloak-server/src/main/java/org/eclipse/che/multiuser/keycloak/server/KeycloakServiceClient.java index 15d9d5e4bf..ed6a430197 100644 --- a/multiuser/keycloak/che-multiuser-keycloak-server/src/main/java/org/eclipse/che/multiuser/keycloak/server/KeycloakServiceClient.java +++ b/multiuser/keycloak/che-multiuser-keycloak-server/src/main/java/org/eclipse/che/multiuser/keycloak/server/KeycloakServiceClient.java @@ -15,6 +15,7 @@ import static org.eclipse.che.multiuser.keycloak.shared.KeycloakConstants.REALM_ import com.google.common.io.CharStreams; import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; import io.jsonwebtoken.Jwt; import io.jsonwebtoken.impl.DefaultClaims; import java.io.IOException; @@ -79,7 +80,8 @@ public class KeycloakServiceClient { * @param redirectAfterLogin URL to return after login * @return URL to redirect client to perform account linking */ - public String getAccountLinkingURL(Jwt token, String oauthProvider, String redirectAfterLogin) { + public String getAccountLinkingURL( + @SuppressWarnings("rawtypes") Jwt token, String oauthProvider, String redirectAfterLogin) { DefaultClaims claims = (DefaultClaims) token.getBody(); final String clientId = claims.getAudience(); @@ -208,15 +210,26 @@ public class KeycloakServiceClient { } } - /** Converts key=value&foo=bar string into json */ + /** Converts key=value&foo=bar string into json if necessary */ private static String toJson(String source) { - Map queryPairs = new HashMap<>(); - Arrays.stream(source.split("&")) - .forEach( - p -> { - int delimiterIndex = p.indexOf("="); - queryPairs.put(p.substring(0, delimiterIndex), p.substring(delimiterIndex + 1)); - }); - return gson.toJson(queryPairs); + if (source == null) { + return null; + } + try { + // Check that the source is valid Json Object (can be returned as a Map) + gson.>fromJson(source, Map.class); + return source; + } catch (JsonSyntaxException notJsonException) { + // The source is not valid Json: let's see if + // it is in 'key=value&foo=bar' format + Map queryPairs = new HashMap<>(); + Arrays.stream(source.split("&")) + .forEach( + p -> { + int delimiterIndex = p.indexOf("="); + queryPairs.put(p.substring(0, delimiterIndex), p.substring(delimiterIndex + 1)); + }); + return gson.toJson(queryPairs); + } } }