Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve functionality of identity provider token retrieval #8873

Merged
merged 7 commits into from
Feb 23, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions multiuser/keycloak/che-multiuser-keycloak-server/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
<artifactId>che-multiuser-keycloak-server</artifactId>
<packaging>jar</packaging>
<name>Che Multiuser :: Keycloak Server</name>
<properties>
<dto-generator-out-directory>${project.build.directory}/generated-sources/dto/</dto-generator-out-directory>
</properties>
<dependencies>
<dependency>
<groupId>com.google.code.gson</groupId>
Expand Down Expand Up @@ -107,6 +110,16 @@
<artifactId>javax.servlet-api</artifactId>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.jayway.restassured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.everrest</groupId>
<artifactId>everrest-assured</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
Expand All @@ -125,6 +138,80 @@
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<executions>
<execution>
<id>add-resource</id>
<phase>process-sources</phase>
<goals>
<goal>add-resource</goal>
</goals>
<configuration>
<resources>
<resource>
<directory>${dto-generator-out-directory}/META-INF</directory>
<targetPath>META-INF</targetPath>
</resource>
</resources>
</configuration>
</execution>
<execution>
<id>add-source</id>
<phase>process-sources</phase>
<goals>
<goal>add-source</goal>
</goals>
<configuration>
<sources>
<source>${dto-generator-out-directory}</source>
</sources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<executions>
<execution>
<id>pre-compile</id>
<phase>generate-sources</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.eclipse.che.core</groupId>
<artifactId>che-core-api-dto-maven-plugin</artifactId>
<version>${project.version}</version>
<executions>
<execution>
<id>server</id>
<phase>process-sources</phase>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<dtoPackages>
<package>org.eclipse.che.multiuser.keycloak.shared.dto</package>
</dtoPackages>
<outputDirectory>${dto-generator-out-directory}</outputDirectory>
<genClassName>org.eclipse.che.multiuser.keycloak.server.DtoServerImpls</genClassName>
<impl>server</impl>
</configuration>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.eclipse.che.multiuser</groupId>
<artifactId>che-multiuser-keycloak-shared</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
/*
* 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.multiuser.keycloak.server;

import static org.eclipse.che.multiuser.keycloak.shared.KeycloakConstants.AUTH_SERVER_URL_SETTING;
import static org.eclipse.che.multiuser.keycloak.shared.KeycloakConstants.REALM_SETTING;

import com.google.common.io.CharStreams;
import com.google.gson.Gson;
import io.jsonwebtoken.Jwt;
import io.jsonwebtoken.impl.DefaultClaims;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.regex.Pattern;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.ws.rs.HttpMethod;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriBuilder;
import org.eclipse.che.api.core.BadRequestException;
import org.eclipse.che.api.core.ForbiddenException;
import org.eclipse.che.api.core.NotFoundException;
import org.eclipse.che.api.core.ServerException;
import org.eclipse.che.api.core.UnauthorizedException;
import org.eclipse.che.commons.env.EnvironmentContext;
import org.eclipse.che.commons.lang.Pair;
import org.eclipse.che.dto.server.DtoFactory;
import org.eclipse.che.multiuser.keycloak.shared.dto.KeycloakErrorResponse;
import org.eclipse.che.multiuser.keycloak.shared.dto.KeycloakTokenResponse;

/**
* Helps to perform keycloak operations and provide correct errors handling.
*
* @author Max Shaposhnik (mshaposh@redhat.com)
*/
@Singleton
public class KeycloakServiceClient {

private KeycloakSettings keycloakSettings;

private static final Pattern assotiateUserPattern =
Pattern.compile("User (.+) is not associated with identity provider (.+)");

private static final Gson gson = new Gson();

@Inject
public KeycloakServiceClient(KeycloakSettings keycloakSettings) {
this.keycloakSettings = keycloakSettings;
}

/**
* Generates URL for account linking redirect
*
* @param token client jwt token
* @param oauthProvider provider name
* @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) {

DefaultClaims claims = (DefaultClaims) token.getBody();
final String clientId = claims.getAudience();

final String sessionState = claims.get("session_state", String.class);
MessageDigest md;
try {
md = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}

final String nonce = UUID.randomUUID().toString();
final String input = nonce + sessionState + clientId + oauthProvider;
byte[] check = md.digest(input.getBytes(StandardCharsets.UTF_8));
final String hash = Base64.getUrlEncoder().encodeToString(check);

return UriBuilder.fromUri(keycloakSettings.get().get(AUTH_SERVER_URL_SETTING))
.path("/realms/{realm}/broker/{provider}/link")
.queryParam("nonce", nonce)
.queryParam("hash", hash)
.queryParam("client_id", clientId)
.queryParam("redirect_uri", redirectAfterLogin)
.build(keycloakSettings.get().get(REALM_SETTING), oauthProvider)
.toString();
}

/**
* Gets auth token from given identity provider.
*
* @param oauthProvider provider name
* @return KeycloakTokenResponse token response
* @throws ForbiddenException when HTTP request was forbidden
* @throws BadRequestException when HTTP request considered as bad
* @throws IOException when unable to parse error response
* @throws NotFoundException when requested URL not found
* @throws ServerException when other error occurs
* @throws UnauthorizedException when no token present for user or user not linked to provider
*/
public KeycloakTokenResponse getIdentityProviderToken(String oauthProvider)
throws ForbiddenException, BadRequestException, IOException, NotFoundException,
ServerException, UnauthorizedException {
String url =
UriBuilder.fromUri(keycloakSettings.get().get(AUTH_SERVER_URL_SETTING))
.path("/realms/{realm}/broker/{provider}/token")
.build(keycloakSettings.get().get(REALM_SETTING), oauthProvider)
.toString();
try {
String response = doRequest(url, HttpMethod.GET, null);
// Successful answer is not a json, but key=value&foo=bar format pairs
return DtoFactory.getInstance()
.createDtoFromJson(toJson(response), KeycloakTokenResponse.class);
} catch (BadRequestException e) {
if (assotiateUserPattern.matcher(e.getMessage()).matches()) {
// If user has no link with identity provider yet,
// we should threat this as unauthorized and send to oAuth login page.
throw new UnauthorizedException(e.getMessage());
}
throw e;
}
}

private String doRequest(String url, String method, List<Pair<String, ?>> parameters)
throws IOException, ServerException, ForbiddenException, NotFoundException,
UnauthorizedException, BadRequestException {
final String authToken = EnvironmentContext.getCurrent().getSubject().getToken();
final boolean hasQueryParams = parameters != null && !parameters.isEmpty();
if (hasQueryParams) {
final UriBuilder ub = UriBuilder.fromUri(url);
for (Pair<String, ?> parameter : parameters) {
ub.queryParam(parameter.first, parameter.second);
}
url = ub.build().toString();
}
final HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
conn.setConnectTimeout(60000);
conn.setReadTimeout(60000);

try {
conn.setRequestMethod(method);
// drop a hint for server side that we want to receive application/json
conn.addRequestProperty(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON);
if (authToken != null) {
conn.setRequestProperty(HttpHeaders.AUTHORIZATION, "bearer " + authToken);
}
final int responseCode = conn.getResponseCode();
if ((responseCode / 100) != 2) {
InputStream in = conn.getErrorStream();
if (in == null) {
in = conn.getInputStream();
}
final String str;
try (Reader reader = new InputStreamReader(in)) {
str = CharStreams.toString(reader);
}
final String contentType = conn.getContentType();
if (contentType != null
&& (contentType.startsWith(MediaType.APPLICATION_JSON)
|| contentType.startsWith("application/vnd.api+json"))) {
final KeycloakErrorResponse serviceError =
DtoFactory.getInstance().createDtoFromJson(str, KeycloakErrorResponse.class);
if (responseCode == Response.Status.FORBIDDEN.getStatusCode()) {
throw new ForbiddenException(serviceError.getErrorMessage());
} else if (responseCode == Response.Status.NOT_FOUND.getStatusCode()) {
throw new NotFoundException(serviceError.getErrorMessage());
} else if (responseCode == Response.Status.UNAUTHORIZED.getStatusCode()) {
throw new UnauthorizedException(serviceError.getErrorMessage());
} else if (responseCode == Response.Status.INTERNAL_SERVER_ERROR.getStatusCode()) {
throw new ServerException(serviceError.getErrorMessage());
} else if (responseCode == Response.Status.BAD_REQUEST.getStatusCode()) {
throw new BadRequestException(serviceError.getErrorMessage());
}
throw new ServerException(serviceError.getErrorMessage());
}
// Can't parse content as json or content has format other we expect for error.
throw new IOException(
String.format(
"Failed access: %s, method: %s, response code: %d, message: %s",
UriBuilder.fromUri(url).replaceQuery("token").build(), method, responseCode, str));
}
try (Reader reader = new InputStreamReader(conn.getInputStream())) {
return CharStreams.toString(reader);
}
} finally {
conn.disconnect();
}
}

/** Converts key=value&foo=bar string into json */
private static String toJson(String source) {
Map<String, String> 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);
}
}
Loading