diff --git a/docbook/reference/en/en-US/master.xml b/docbook/reference/en/en-US/master.xml index 37c07963f4..d87cb047a4 100755 --- a/docbook/reference/en/en-US/master.xml +++ b/docbook/reference/en/en-US/master.xml @@ -10,6 +10,7 @@ + @@ -73,6 +74,7 @@ &SocialConfig; &SocialFacebook; + &SocialGitHub; &SocialGoogle; &SocialTwitter; &SocialProviderSPI; diff --git a/docbook/reference/en/en-US/modules/social-github.xml b/docbook/reference/en/en-US/modules/social-github.xml new file mode 100644 index 0000000000..4315f6db05 --- /dev/null +++ b/docbook/reference/en/en-US/modules/social-github.xml @@ -0,0 +1,27 @@ +
+ GitHub + + To enable login with Google you first have to create an application in + GitHub Settings. Then you need to copy + the client id and secret into the Keycloak Admin Console. + + + + + Log in to GitHub Settings. Click the + Register new application button. Use any value for Application name, + Homepage URL and Application Description you want. In Authorization callback URL + enter the social callback url for your realm. Click the + Register application button. + + + + + Copy Client ID and Client secret from the + GitHub Settings into the settings + page in the Keycloak Admin Console as the Key and Secret. Then click + Save in the Keycloak Admin Console to enable login with Google. + + + +
\ No newline at end of file diff --git a/services/src/main/java/org/keycloak/services/resources/SocialResource.java b/services/src/main/java/org/keycloak/services/resources/SocialResource.java index a4efab29f7..4914c8a2bd 100755 --- a/services/src/main/java/org/keycloak/services/resources/SocialResource.java +++ b/services/src/main/java/org/keycloak/services/resources/SocialResource.java @@ -106,10 +106,10 @@ public class SocialResource { RequestDetails requestData = getRequestDetails(queryParams); SocialProvider provider = SocialLoader.load(requestData.getProviderId()); - String realmId = requestData.getClientAttribute("realmId"); + String realmName = requestData.getClientAttribute("realm"); RealmManager realmManager = new RealmManager(session); - RealmModel realm = realmManager.getRealm(realmId); + RealmModel realm = realmManager.getRealmByName(realmName); OAuthFlows oauth = Flows.oauth(realm, request, uriInfo, authManager, tokenManager); @@ -186,12 +186,12 @@ public class SocialResource { @GET @Path("{realm}/login") - public Response redirectToProviderAuth(@PathParam("realm") final String realmId, + public Response redirectToProviderAuth(@PathParam("realm") final String realmName, @QueryParam("provider_id") final String providerId, @QueryParam("client_id") final String clientId, @QueryParam("scope") final String scope, @QueryParam("state") final String state, @QueryParam("redirect_uri") String redirectUri) { RealmManager realmManager = new RealmManager(session); - RealmModel realm = realmManager.getRealm(realmId); + RealmModel realm = realmManager.getRealmByName(realmName); SocialProvider provider = SocialLoader.load(providerId); if (provider == null) { @@ -223,7 +223,7 @@ public class SocialResource { AuthRequest authRequest = provider.getAuthUrl(config); RequestDetails socialRequest = RequestDetails.create(providerId) - .putSocialAttributes(authRequest.getAttributes()).putClientAttribute("realmId", realmId) + .putSocialAttributes(authRequest.getAttributes()).putClientAttribute("realm", realmName) .putClientAttribute("clientId", clientId).putClientAttribute("scope", scope) .putClientAttribute("state", state).putClientAttribute("redirectUri", redirectUri).build(); diff --git a/social/core/src/main/java/org/keycloak/social/AbstractOAuth2Provider.java b/social/core/src/main/java/org/keycloak/social/AbstractOAuth2Provider.java new file mode 100644 index 0000000000..ae8a0a5d49 --- /dev/null +++ b/social/core/src/main/java/org/keycloak/social/AbstractOAuth2Provider.java @@ -0,0 +1,90 @@ +package org.keycloak.social; + +import org.json.JSONObject; +import org.keycloak.social.utils.SimpleHttp; + +import java.io.IOException; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * @author Stian Thorgersen + */ +public abstract class AbstractOAuth2Provider implements SocialProvider { + + private static final String AUTHORIZATION_CODE = "authorization_code"; + private static final String ACCESS_TOKEN = "access_token"; + private static final String CLIENT_ID = "client_id"; + private static final String CLIENT_SECRET = "client_secret"; + private static final String CODE = "code"; + private static final String GRANT_TYPE = "grant_type"; + private static final String REDIRECT_URI = "redirect_uri"; + private static final String RESPONSE_TYPE = "response_type"; + private static final String SCOPE = "scope"; + private static final String STATE = "state"; + + private static final String TOKEN_REGEX = "access_token=([^&]+)"; + + @Override + public abstract String getId(); + + @Override + public abstract String getName(); + + protected abstract String getScope(); + + protected abstract String getAuthUrl(); + + protected abstract String getTokenUrl(); + + protected abstract SocialUser getProfile(String accessToken) throws SocialProviderException; + + @Override + public AuthRequest getAuthUrl(SocialProviderConfig config) throws SocialProviderException { + String state = UUID.randomUUID().toString(); + + return AuthRequest.create(state, getAuthUrl()).setQueryParam(CLIENT_ID, config.getKey()) + .setQueryParam(RESPONSE_TYPE, CODE).setQueryParam(SCOPE, getScope()) + .setQueryParam(REDIRECT_URI, config.getCallbackUrl()).setQueryParam(STATE, state).setAttribute(STATE, state).build(); + } + + @Override + public SocialUser processCallback(SocialProviderConfig config, AuthCallback callback) throws SocialProviderException { + try { + String code = callback.getQueryParam(CODE); + + if (!callback.getQueryParam(STATE).equals(callback.getAttribute(STATE))) { + throw new SocialProviderException("Invalid state"); + } + + String response = SimpleHttp.doPost(getTokenUrl()).param(CODE, code).param(CLIENT_ID, config.getKey()) + .param(CLIENT_SECRET, config.getSecret()) + .param(REDIRECT_URI, config.getCallbackUrl()) + .param(GRANT_TYPE, AUTHORIZATION_CODE).asString(); + + String accessToken; + + if (response.startsWith("{")) { + accessToken = new JSONObject(response).getString(ACCESS_TOKEN); + } else { + Matcher matcher = Pattern.compile(TOKEN_REGEX).matcher(response); + if (matcher.find()) { + accessToken = matcher.group(1); + } else { + throw new SocialProviderException("Invalid response, could not find token"); + } + } + + return getProfile(accessToken); + } catch (IOException e) { + throw new SocialProviderException(e); + } + } + + @Override + public String getRequestIdParamName() { + return STATE; + } + +} diff --git a/social/facebook/pom.xml b/social/facebook/pom.xml index fdfc7bf580..78527e09bb 100755 --- a/social/facebook/pom.xml +++ b/social/facebook/pom.xml @@ -18,17 +18,6 @@ org.keycloak keycloak-social-core ${project.version} - provided - - - org.jboss.resteasy - resteasy-client - provided - - - org.codehaus.jackson - jackson-core-asl - provided diff --git a/social/facebook/src/main/java/org/keycloak/social/facebook/FacebookProvider.java b/social/facebook/src/main/java/org/keycloak/social/facebook/FacebookProvider.java index 8b911b2360..dd2d7e3853 100755 --- a/social/facebook/src/main/java/org/keycloak/social/facebook/FacebookProvider.java +++ b/social/facebook/src/main/java/org/keycloak/social/facebook/FacebookProvider.java @@ -1,149 +1,81 @@ package org.keycloak.social.facebook; -import org.jboss.resteasy.client.jaxrs.ResteasyClient; -import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder; -import org.keycloak.social.AuthCallback; +import org.json.JSONObject; +import org.keycloak.social.AbstractOAuth2Provider; import org.keycloak.social.AuthRequest; -import org.keycloak.social.SocialProvider; import org.keycloak.social.SocialProviderConfig; import org.keycloak.social.SocialProviderException; import org.keycloak.social.SocialUser; - -import javax.ws.rs.client.Entity; -import javax.ws.rs.core.Form; -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 java.net.URI; -import java.util.UUID; +import org.keycloak.social.utils.SimpleHttp; /** - * Social provider for Facebook - * - * @author Marek Posolda + * @author Stian Thorgersen */ -public class FacebookProvider implements SocialProvider { +public class FacebookProvider extends AbstractOAuth2Provider { - private static final String AUTHENTICATION_ENDPOINT_URL = "https://graph.facebook.com/oauth/authorize"; + private static final String ID = "facebook"; + private static final String NAME = "Facebook"; - private static final String ACCESS_TOKEN_ENDPOINT_URL = "https://graph.facebook.com/oauth/access_token"; - - private static final String PROFILE_ENDPOINT_URL = "https://graph.facebook.com/me"; - - private static final String DEFAULT_RESPONSE_TYPE = "code"; + private static final String AUTH_URL = "https://graph.facebook.com/oauth/authorize"; + private static final String TOKEN_URL = "https://graph.facebook.com/oauth/access_token"; + private static final String PROFILE_URL = "https://graph.facebook.com/me"; private static final String DEFAULT_SCOPE = "email"; @Override public String getId() { - return "facebook"; - } - - @Override - public AuthRequest getAuthUrl(SocialProviderConfig config) throws SocialProviderException { - String state = UUID.randomUUID().toString(); - - String redirectUri = config.getCallbackUrl(); - redirectUri = redirectUri.replace("//localhost", "//127.0.0.1"); - - return AuthRequest.create(state, AUTHENTICATION_ENDPOINT_URL).setQueryParam("client_id", config.getKey()) - .setQueryParam("response_type", DEFAULT_RESPONSE_TYPE).setQueryParam("scope", DEFAULT_SCOPE) - .setQueryParam("redirect_uri", redirectUri).setQueryParam("state", state).setAttribute("state", state).build(); - } - - @Override - public String getRequestIdParamName() { - return "state"; + return ID; } @Override public String getName() { - return "Facebook"; + return NAME; } @Override - public SocialUser processCallback(SocialProviderConfig config, AuthCallback callback) throws SocialProviderException { - String code = callback.getQueryParam(DEFAULT_RESPONSE_TYPE); + protected String getScope() { + return DEFAULT_SCOPE; + } + @Override + protected String getAuthUrl() { + return AUTH_URL; + } + + @Override + protected String getTokenUrl() { + return TOKEN_URL; + } + + @Override + protected SocialUser getProfile(String accessToken) throws SocialProviderException { try { - if (!callback.getQueryParam("state").equals(callback.getAttribute("state"))) { - throw new SocialProviderException("Invalid state"); + JSONObject profile = SimpleHttp.doGet(PROFILE_URL).header("Authorization", "Bearer " + accessToken).asJson(); + + SocialUser user = new SocialUser(profile.getString("id")); + + user.setUsername(profile.getString("username")); + if (user.getUsername() == null || user.getUsername().length() == 0) { + user.setUsername(profile.getString("id")); } - ResteasyClient client = new ResteasyClientBuilder() - .hostnameVerification(ResteasyClientBuilder.HostnameVerificationPolicy.ANY).build(); + user.setFirstName(profile.optString("first_name")); + user.setLastName(profile.optString("last_name")); + user.setEmail(profile.optString("email")); - String accessToken = loadAccessToken(code, config, client); - - FacebookUser facebookUser = loadUser(accessToken, client); - - SocialUser socialUser = new SocialUser(facebookUser.getId()); - socialUser.setUsername(facebookUser.getUsername()); - - // This could happen with Facebook testing users - if (facebookUser.getUsername() == null || facebookUser.getUsername().length() == 0) { - socialUser.setUsername(facebookUser.getId()); - } - - socialUser.setEmail(facebookUser.getEmail()); - socialUser.setLastName(facebookUser.getLastName()); - socialUser.setFirstName(facebookUser.getFirstName()); - - return socialUser; - } catch (SocialProviderException spe) { - throw spe; + return user; } catch (Exception e) { throw new SocialProviderException(e); } } - protected String loadAccessToken(String code, SocialProviderConfig config, ResteasyClient client) throws SocialProviderException { - Form form = new Form(); - form.param("grant_type", "authorization_code") - .param("code", code) - .param("client_id", config.getKey()) - .param("client_secret", config.getSecret()) - .param("redirect_uri", config.getCallbackUrl()); - - Response response = client.target(ACCESS_TOKEN_ENDPOINT_URL).request().post(Entity.form(form)); - - if (response.getStatus() != 200) { - String errorTokenResponse = response.readEntity(String.class); - throw new SocialProviderException("Access token request to Facebook failed. Status: " + response.getStatus() + ", response: " + errorTokenResponse); + @Override + public AuthRequest getAuthUrl(SocialProviderConfig config) throws SocialProviderException { + if (config.getCallbackUrl().contains("//localhost")) { + String callbackUrl = config.getCallbackUrl().replace("//localhost", "//127.0.0.1"); + config = new SocialProviderConfig(config.getKey(), config.getSecret(), callbackUrl); } - - String accessTokenResponse = response.readEntity(String.class); - return parseParameter(accessTokenResponse, "access_token"); + return super.getAuthUrl(config); } - protected FacebookUser loadUser(String accessToken, ResteasyClient client) throws SocialProviderException { - URI userDetailsUri = UriBuilder.fromUri(PROFILE_ENDPOINT_URL) - .queryParam("access_token", accessToken) - .queryParam("fields", "id,name,username,first_name,last_name,email") - .build(); - - Response response = client.target(userDetailsUri).request() - .header(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON) - .get(); - if (response.getStatus() != 200) { - String errorTokenResponse = response.readEntity(String.class); - throw new SocialProviderException("Request to Facebook for obtaining user failed. Status: " + response.getStatus() + ", response: " + errorTokenResponse); - } - - return response.readEntity(FacebookUser.class); - } - - // Parses value of given parameter from input string like "my_param=abcd&another_param=xyz" - private String parseParameter(String input, String paramName) { - int start = input.indexOf(paramName + "="); - if (start != -1) { - input = input.substring(start + paramName.length() + 1); - int end = input.indexOf("&"); - return end==-1 ? input : input.substring(0, end); - } else { - throw new IllegalArgumentException("Parameter " + paramName + " not available in response " + input); - } - - } } diff --git a/social/facebook/src/main/java/org/keycloak/social/facebook/FacebookUser.java b/social/facebook/src/main/java/org/keycloak/social/facebook/FacebookUser.java deleted file mode 100644 index 8a30fc7a0d..0000000000 --- a/social/facebook/src/main/java/org/keycloak/social/facebook/FacebookUser.java +++ /dev/null @@ -1,77 +0,0 @@ -package org.keycloak.social.facebook; - -import org.codehaus.jackson.annotate.JsonProperty; - -/** - * Wrap info about user from Facebook - * - * @author Marek Posolda - */ -public class FacebookUser { - - @JsonProperty("id") - private String id; - - @JsonProperty("first_name") - private String firstName; - - @JsonProperty("last_name") - private String lastName; - - @JsonProperty("username") - private String username; - - @JsonProperty("name") - private String name; - - @JsonProperty("email") - private String email; - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getFirstName() { - return firstName; - } - - public void setFirstName(String firstName) { - this.firstName = firstName; - } - - public String getLastName() { - return lastName; - } - - public void setLastName(String lastName) { - this.lastName = lastName; - } - - public String getUsername() { - return username; - } - - public void setUsername(String username) { - this.username = username; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getEmail() { - return email; - } - - public void setEmail(String email) { - this.email = email; - } -} diff --git a/social/github/pom.xml b/social/github/pom.xml new file mode 100755 index 0000000000..e75021dd7b --- /dev/null +++ b/social/github/pom.xml @@ -0,0 +1,23 @@ + + + keycloak-social-parent + org.keycloak + 1.0-alpha-2-SNAPSHOT + ../pom.xml + + 4.0.0 + jar + + keycloak-social-github + Keycloak Social GitHub + + + + + org.keycloak + keycloak-social-core + ${project.version} + + + diff --git a/social/github/src/main/java/org/keycloak/social/github/GitHubProvider.java b/social/github/src/main/java/org/keycloak/social/github/GitHubProvider.java new file mode 100755 index 0000000000..9ea009607b --- /dev/null +++ b/social/github/src/main/java/org/keycloak/social/github/GitHubProvider.java @@ -0,0 +1,67 @@ +package org.keycloak.social.github; + +import org.json.JSONObject; +import org.keycloak.social.AbstractOAuth2Provider; +import org.keycloak.social.AuthRequest; +import org.keycloak.social.SocialProviderConfig; +import org.keycloak.social.SocialProviderException; +import org.keycloak.social.SocialUser; +import org.keycloak.social.utils.SimpleHttp; + +/** + * @author Stian Thorgersen + */ +public class GitHubProvider extends AbstractOAuth2Provider { + + private static final String ID = "github"; + private static final String NAME = "GitHub"; + + private static final String AUTH_URL = "https://github.com/login/oauth/authorize"; + private static final String TOKEN_URL = "https://github.com/login/oauth/access_token"; + private static final String PROFILE_URL = "https://api.github.com/user"; + + private static final String DEFAULT_SCOPE = "user:email"; + + @Override + public String getId() { + return ID; + } + + @Override + public String getName() { + return NAME; + } + + @Override + protected String getScope() { + return DEFAULT_SCOPE; + } + + @Override + protected String getAuthUrl() { + return AUTH_URL; + } + + @Override + protected String getTokenUrl() { + return TOKEN_URL; + } + + @Override + protected SocialUser getProfile(String accessToken) throws SocialProviderException { + try { + JSONObject profile = SimpleHttp.doGet(PROFILE_URL).header("Authorization", "Bearer " + accessToken).asJson(); + + SocialUser user = new SocialUser(profile.get("id").toString()); + + user.setUsername(profile.getString("login")); + user.setFirstName(profile.optString("name")); + user.setEmail(profile.optString("email")); + + return user; + } catch (Exception e) { + throw new SocialProviderException(e); + } + } + +} diff --git a/social/github/src/main/resources/META-INF/services/org.keycloak.social.SocialProvider b/social/github/src/main/resources/META-INF/services/org.keycloak.social.SocialProvider new file mode 100644 index 0000000000..f9e6b0c0e7 --- /dev/null +++ b/social/github/src/main/resources/META-INF/services/org.keycloak.social.SocialProvider @@ -0,0 +1 @@ +org.keycloak.social.github.GitHubProvider diff --git a/social/google/pom.xml b/social/google/pom.xml index 12ecb81507..f5b5681b44 100755 --- a/social/google/pom.xml +++ b/social/google/pom.xml @@ -19,5 +19,4 @@ ${project.version} - diff --git a/social/google/src/main/java/org/keycloak/social/google/GoogleProvider.java b/social/google/src/main/java/org/keycloak/social/google/GoogleProvider.java index 87ea93f348..2db4a9c71e 100755 --- a/social/google/src/main/java/org/keycloak/social/google/GoogleProvider.java +++ b/social/google/src/main/java/org/keycloak/social/google/GoogleProvider.java @@ -22,67 +22,54 @@ package org.keycloak.social.google; import org.json.JSONObject; -import org.keycloak.social.AuthCallback; -import org.keycloak.social.AuthRequest; +import org.keycloak.social.AbstractOAuth2Provider; import org.keycloak.social.utils.SimpleHttp; -import org.keycloak.social.SocialProvider; -import org.keycloak.social.SocialProviderConfig; import org.keycloak.social.SocialProviderException; import org.keycloak.social.SocialUser; -import java.util.UUID; - /** * @author Stian Thorgersen */ -public class GoogleProvider implements SocialProvider { +public class GoogleProvider extends AbstractOAuth2Provider { - private static final String DEFAULT_RESPONSE_TYPE = "code"; + private static final String ID = "google"; + private static final String NAME = "Google"; - private static final String AUTH_PATH = "https://accounts.google.com/o/oauth2/auth"; - - private static final String TOKEN_PATH = "https://accounts.google.com/o/oauth2/token"; - - private static final String PROFILE_PATH = "https://www.googleapis.com/plus/v1/people/me/openIdConnect"; + private static final String AUTH_URL = "https://accounts.google.com/o/oauth2/auth"; + private static final String TOKEN_URL = "https://accounts.google.com/o/oauth2/token"; + private static final String PROFILE_URL = "https://www.googleapis.com/plus/v1/people/me/openIdConnect"; private static final String DEFAULT_SCOPE = "openid profile email"; @Override public String getId() { - return "google"; - } - - @Override - public AuthRequest getAuthUrl(SocialProviderConfig config) throws SocialProviderException { - String state = UUID.randomUUID().toString(); - - return AuthRequest.create(state, AUTH_PATH).setQueryParam("client_id", config.getKey()) - .setQueryParam("response_type", DEFAULT_RESPONSE_TYPE).setQueryParam("scope", DEFAULT_SCOPE) - .setQueryParam("redirect_uri", config.getCallbackUrl()).setQueryParam("state", state).setAttribute("state", state).build(); + return ID; } @Override public String getName() { - return "Google"; + return NAME; } @Override - public SocialUser processCallback(SocialProviderConfig config, AuthCallback callback) throws SocialProviderException { - String code = callback.getQueryParam(DEFAULT_RESPONSE_TYPE); + protected String getScope() { + return DEFAULT_SCOPE; + } + @Override + protected String getAuthUrl() { + return AUTH_URL; + } + + @Override + protected String getTokenUrl() { + return TOKEN_URL; + } + + @Override + protected SocialUser getProfile(String accessToken) throws SocialProviderException { try { - if (!callback.getQueryParam("state").equals(callback.getAttribute("state"))) { - throw new SocialProviderException("Invalid state"); - } - - JSONObject token = SimpleHttp.doPost(TOKEN_PATH).param("code", code).param("client_id", config.getKey()) - .param("client_secret", config.getSecret()) - .param("redirect_uri", config.getCallbackUrl()) - .param("grant_type", "authorization_code").asJson(); - - String accessToken = token.getString("access_token"); - - JSONObject profile = SimpleHttp.doGet(PROFILE_PATH).header("Authorization", "Bearer " + accessToken).asJson(); + JSONObject profile = SimpleHttp.doGet(PROFILE_URL).header("Authorization", "Bearer " + accessToken).asJson(); SocialUser user = new SocialUser(profile.getString("sub")); @@ -98,9 +85,4 @@ public class GoogleProvider implements SocialProvider { } } - @Override - public String getRequestIdParamName() { - return "state"; - } - } diff --git a/social/pom.xml b/social/pom.xml index 1061eda794..5d4edd7f2a 100755 --- a/social/pom.xml +++ b/social/pom.xml @@ -15,6 +15,7 @@ core + github google twitter facebook diff --git a/testsuite/integration/pom.xml b/testsuite/integration/pom.xml index e228bbe91d..acd2316d48 100755 --- a/testsuite/integration/pom.xml +++ b/testsuite/integration/pom.xml @@ -73,6 +73,11 @@ keycloak-social-core ${project.version} + + org.keycloak + keycloak-social-github + ${project.version} + org.keycloak keycloak-social-google