KEYCLOAK-4501

This commit is contained in:
Bill Burke
2017-02-27 18:46:00 -05:00
parent ca64b90cbf
commit b4f625e1ce
18 changed files with 913 additions and 33 deletions

View File

@@ -0,0 +1,89 @@
/*
* Copyright 2016 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.common.util;
import java.util.Map;
/**
* Helper class to do a browser redirect via a POST.
*
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class HttpPostRedirect {
/**
* Generate an HTML page that does a browser redirect via a POST. The HTML document uses Javascript to automatically
* submit a FORM post when loaded.
*
* This is similar to what the SAML Post Binding does.
*
* Here's an example
*
* <pre>
* {@code
* <HTML>
* <HEAD>
* <TITLE>title</TITLE>
* </HEAD>
* <BODY Onload="document.forms[0].submit()">
* <FORM METHOD="POST" ACTION="actionUrl">
* <INPUT TYPE="HIDDEN" NAME="param" VALUE="value"/>
* <NOSCRIPT>
* <P>JavaScript is disabled. We strongly recommend to enable it. Click the button below to continue.</P>
* <INPUT TYPE="SUBMIT" VALUE="CONTINUE"/>
* </NOSCRIPT>
* </FORM>
* </BODY>
* </HTML>
* }
* </pre>
*
* @param title may be null. Just the title of the HTML document
* @param actionUrl URL to redirect to
* @param params must be encoded so that they can be placed in an HTML form hidden INPUT field value
* @return
*/
public String buildHtml(String title, String actionUrl, Map<String, String> params) {
StringBuilder builder = new StringBuilder();
builder.append("<HTML>")
.append("<HEAD>");
if (title != null) {
builder.append("<TITLE>SAML HTTP Post Binding</TITLE>");
}
builder.append("</HEAD>")
.append("<BODY Onload=\"document.forms[0].submit()\">")
.append("<FORM METHOD=\"POST\" ACTION=\"").append(actionUrl).append("\">");
for (Map.Entry<String, String> param : params.entrySet()) {
builder.append("<INPUT TYPE=\"HIDDEN\" NAME=\"").append(param.getKey()).append("\"").append(" VALUE=\"").append(param.getValue()).append("\"/>");
}
builder.append("<NOSCRIPT>")
.append("<P>JavaScript is disabled. We strongly recommend to enable it. Click the button below to continue.</P>")
.append("<INPUT TYPE=\"SUBMIT\" VALUE=\"CONTINUE\" />")
.append("</NOSCRIPT>")
.append("</FORM></BODY></HTML>");
return builder.toString();
}
}

View File

@@ -75,4 +75,7 @@ public interface Errors {
String PASSWORD_CONFIRM_ERROR = "password_confirm_error";
String PASSWORD_MISSING = "password_missing";
String PASSWORD_REJECTED = "password_rejected";
String NOT_LOGGED_IN = "not_logged_in";
String UNKNOWN_IDENTITY_PROVIDER = "unknown_identity_provider";
String ILLEGAL_ORIGIN = "illegal_origin";
}

View File

@@ -113,7 +113,10 @@ public enum EventType {
CLIENT_UPDATE(true),
CLIENT_UPDATE_ERROR(true),
CLIENT_DELETE(true),
CLIENT_DELETE_ERROR(true);
CLIENT_DELETE_ERROR(true),
CLIENT_INITIATED_ACCOUNT_LINKING(true),
CLIENT_INITIATED_ACCOUNT_LINKING_ERROR(true);
private boolean saveByDefault;

View File

@@ -216,8 +216,10 @@ public abstract class AbstractOAuth2IdentityProvider<C extends OAuth2IdentityPro
if (error != null) {
//logger.error("Failed " + getConfig().getAlias() + " broker login: " + error);
if (error.equals(ACCESS_DENIED)) {
logger.error(ACCESS_DENIED + " for broker login " + getConfig().getProviderId());
return callback.cancelled(state);
} else {
logger.error(error + " for broker login " + getConfig().getProviderId());
return callback.error(state, Messages.IDENTITY_PROVIDER_UNEXPECTED_ERROR);
}
}

View File

@@ -699,7 +699,7 @@ public class AuthenticationManager {
}
protected static AuthResult verifyIdentityToken(KeycloakSession session, RealmModel realm, UriInfo uriInfo, ClientConnection connection, boolean checkActive, boolean checkTokenType,
public static AuthResult verifyIdentityToken(KeycloakSession session, RealmModel realm, UriInfo uriInfo, ClientConnection connection, boolean checkActive, boolean checkTokenType,
boolean isCookie, String tokenString, HttpHeaders headers) {
try {
TokenVerifier verifier = TokenVerifier.create(tokenString).realmUrl(Urls.realmIssuer(uriInfo.getBaseUri(), realm.getName())).checkActive(checkActive).checkTokenType(checkTokenType);

View File

@@ -17,6 +17,7 @@
package org.keycloak.services.resources;
import org.jboss.logging.Logger;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.HttpRequest;
import org.jboss.resteasy.spi.ResteasyProviderFactory;
@@ -34,8 +35,10 @@ import org.keycloak.broker.provider.IdentityProviderMapper;
import org.keycloak.broker.saml.SAMLEndpoint;
import org.keycloak.broker.social.SocialIdentityProvider;
import org.keycloak.common.ClientConnection;
import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.common.util.Time;
import org.keycloak.common.util.UriUtils;
import org.keycloak.events.Details;
import org.keycloak.events.Errors;
import org.keycloak.events.EventBuilder;
@@ -55,17 +58,20 @@ import org.keycloak.models.RoleModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.utils.FormMessage;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.protocol.oidc.TokenManager;
import org.keycloak.protocol.oidc.utils.RedirectUtils;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.protocol.saml.SamlService;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.representations.AccessToken;
import org.keycloak.saml.common.constants.GeneralConstants;
import org.keycloak.services.ErrorPage;
import org.keycloak.services.ErrorPageException;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.ServicesLogger;
import org.keycloak.services.Urls;
import org.keycloak.services.managers.AppAuthManager;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.managers.AuthenticationManager.AuthResult;
import org.keycloak.services.managers.BruteForceProtector;
import org.keycloak.services.managers.ClientSessionCode;
@@ -88,6 +94,9 @@ import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriInfo;
import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -95,6 +104,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import static org.keycloak.models.AccountRoles.MANAGE_ACCOUNT;
import static org.keycloak.models.ClientSessionModel.Action.AUTHENTICATE;
@@ -140,6 +150,162 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
this.event = new EventBuilder(realmModel, session, clientConnection).event(EventType.IDENTITY_PROVIDER_LOGIN);
}
private void checkRealm() {
if (!realmModel.isEnabled()) {
event.error(Errors.REALM_DISABLED);
throw new ErrorPageException(session, Messages.REALM_NOT_ENABLED);
}
}
private ClientModel checkClient(String clientId) {
if (clientId == null) {
event.error(Errors.INVALID_REQUEST);
throw new ErrorPageException(session, Messages.MISSING_PARAMETER, OIDCLoginProtocol.CLIENT_ID_PARAM);
}
event.client(clientId);
ClientModel client = realmModel.getClientByClientId(clientId);
if (client == null) {
event.error(Errors.CLIENT_NOT_FOUND);
throw new ErrorPageException(session, Messages.INVALID_REQUEST);
}
if (!client.isEnabled()) {
event.error(Errors.CLIENT_DISABLED);
throw new ErrorPageException(session, Messages.INVALID_REQUEST);
}
return client;
}
/**
* Closes off CORS preflight requests for account linking
*
* @param providerId
* @return
*/
@OPTIONS
@Path("/{provider_id}/link")
public Response clientIntiatedAccountLinkingPreflight(@PathParam("provider_id") String providerId) {
return Response.status(403).build(); // don't allow preflight
}
@GET
@NoCache
@Path("/{provider_id}/link")
public Response clientInitiatedAccountLinking(@PathParam("provider_id") String providerId,
@QueryParam("redirect_uri") String redirectUri,
@QueryParam("client_id") String clientId,
@QueryParam("nonce") String nonce,
@QueryParam("hash") String hash
) {
this.event.event(EventType.CLIENT_INITIATED_ACCOUNT_LINKING);
checkRealm();
ClientModel client = checkClient(clientId);
AuthenticationManager authenticationManager = new AuthenticationManager();
redirectUri = RedirectUtils.verifyRedirectUri(uriInfo, redirectUri, realmModel, client);
if (redirectUri == null) {
event.error(Errors.INVALID_REDIRECT_URI);
throw new ErrorPageException(session, Messages.INVALID_REQUEST);
}
if (nonce == null || hash == null) {
event.error(Errors.INVALID_REDIRECT_URI);
throw new ErrorPageException(session, Messages.INVALID_REQUEST);
}
// only allow origins from client. Not sure we need this as I don't believe cookies can be
// sent if CORS preflight requests can't execute.
String origin = headers.getRequestHeaders().getFirst("Origin");
if (origin != null) {
String redirectOrigin = UriUtils.getOrigin(redirectUri);
if (!redirectOrigin.equals(origin)) {
event.error(Errors.ILLEGAL_ORIGIN);
throw new ErrorPageException(session, Messages.INVALID_REQUEST);
}
}
AuthResult cookieResult = authenticationManager.authenticateIdentityCookie(session, realmModel, true);
if (cookieResult == null) {
event.error(Errors.NOT_LOGGED_IN);
UriBuilder builder = UriBuilder.fromUri(redirectUri)
.queryParam("error", Errors.NOT_LOGGED_IN)
.queryParam("nonce", nonce);
return Response.status(302).location(builder.build()).build();
}
ClientSessionModel clientSession = null;
for (ClientSessionModel cs : cookieResult.getSession().getClientSessions()) {
if (cs.getClient().getClientId().equals(clientId)) {
byte[] decoded = Base64Url.decode(hash);
MessageDigest md = null;
try {
md = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new ErrorPageException(session, Messages.UNEXPECTED_ERROR_HANDLING_REQUEST);
}
String input = nonce + cookieResult.getSession().getId() + cs.getId() + providerId;
byte[] check = md.digest(input.getBytes(StandardCharsets.UTF_8));
if (MessageDigest.isEqual(decoded, check)) {
clientSession = cs;
}
break;
}
}
if (clientSession == null) {
event.error(Errors.INVALID_TOKEN);
throw new ErrorPageException(session, Messages.INVALID_REQUEST);
}
IdentityProviderModel identityProviderModel = realmModel.getIdentityProviderByAlias(providerId);
if (identityProviderModel == null) {
event.error(Errors.UNKNOWN_IDENTITY_PROVIDER);
UriBuilder builder = UriBuilder.fromUri(redirectUri)
.queryParam("error", Errors.UNKNOWN_IDENTITY_PROVIDER)
.queryParam("nonce", nonce);
return Response.status(302).location(builder.build()).build();
}
ClientSessionCode clientSessionCode = new ClientSessionCode(session, realmModel, clientSession);
clientSessionCode.setAction(ClientSessionModel.Action.AUTHENTICATE.name());
clientSessionCode.getCode();
clientSession.setRedirectUri(redirectUri);
clientSession.setNote(OIDCLoginProtocol.STATE_PARAM, UUID.randomUUID().toString());
event.success();
try {
IdentityProvider identityProvider = getIdentityProvider(session, realmModel, providerId);
Response response = identityProvider.performLogin(createAuthenticationRequest(providerId, clientSessionCode));
if (response != null) {
if (isDebugEnabled()) {
logger.debugf("Identity provider [%s] is going to send a request [%s].", identityProvider, response);
}
return response;
}
} catch (IdentityBrokerException e) {
return redirectToErrorPage(Messages.COULD_NOT_SEND_AUTHENTICATION_REQUEST, e, providerId);
} catch (Exception e) {
return redirectToErrorPage(Messages.UNEXPECTED_ERROR_HANDLING_REQUEST, e, providerId);
}
return redirectToErrorPage(Messages.COULD_NOT_PROCEED_WITH_AUTHENTICATION_REQUEST);
}
@POST
@Path("/{provider_id}/login")
public Response performPostLogin(@PathParam("provider_id") String providerId, @QueryParam("code") String code) {
@@ -147,6 +313,7 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
}
@GET
@NoCache
@Path("/{provider_id}/login")
public Response performLogin(@PathParam("provider_id") String providerId, @QueryParam("code") String code) {
this.event.detail(Details.IDENTITY_PROVIDER, providerId);
@@ -198,11 +365,18 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
}
@GET
@NoCache
@Path("{provider_id}/token")
public Response retrieveToken(@PathParam("provider_id") String providerId) {
return getToken(providerId, false);
}
private boolean canReadBrokerToken(AccessToken token) {
Map<String, AccessToken.Access> resourceAccess = token.getResourceAccess();
AccessToken.Access brokerRoles = resourceAccess == null ? null : resourceAccess.get(Constants.BROKER_SERVICE_CLIENT_ID);
return brokerRoles != null && brokerRoles.isUserInRole(Constants.READ_TOKEN_ROLE);
}
private Response getToken(String providerId, boolean forceRetrieval) {
this.event.event(EventType.IDENTITY_PROVIDER_RETRIEVE_TOKEN);
@@ -226,9 +400,7 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
return corsResponse(forbidden("Realm has not migrated to support the broker token exchange service"), clientModel);
}
Map<String, AccessToken.Access> resourceAccess = token.getResourceAccess();
AccessToken.Access brokerRoles = resourceAccess == null ? null : resourceAccess.get(Constants.BROKER_SERVICE_CLIENT_ID);
if (brokerRoles == null || !brokerRoles.isUserInRole(Constants.READ_TOKEN_ROLE)) {
if (!canReadBrokerToken(token)) {
return corsResponse(forbidden("Client [" + clientModel.getClientId() + "] not authorized to retrieve tokens from identity provider [" + providerId + "]."), clientModel);
}
@@ -366,6 +538,7 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
// Callback from LoginActionsService after first login with broker was done and Keycloak account is successfully linked/created
@GET
@NoCache
@Path("/after-first-broker-login")
public Response afterFirstBrokerLogin(@QueryParam("code") String code) {
ParsedCodeContext parsedCode = parseClientSessionCode(code);
@@ -487,6 +660,7 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
// Callback from LoginActionsService after postBrokerLogin flow is finished
@GET
@NoCache
@Path("/after-post-broker-login")
public Response afterPostBrokerLoginFlow(@QueryParam("code") String code) {
ParsedCodeContext parsedCode = parseClientSessionCode(code);
@@ -613,7 +787,19 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
this.event.event(EventType.FEDERATED_IDENTITY_LINK);
if (federatedUser != null) {
return redirectToAccountErrorPage(clientSession, Messages.IDENTITY_PROVIDER_ALREADY_LINKED, context.getIdpConfig().getAlias());
// refresh the token
if (context.getIdpConfig().isStoreToken()) {
federatedIdentityModel = this.session.users().getFederatedIdentity(federatedUser, context.getIdpConfig().getAlias(), this.realmModel);
if (!ObjectUtil.isEqualOrBothNull(context.getToken(), federatedIdentityModel.getToken())) {
this.session.users().updateFederatedIdentity(this.realmModel, federatedUser, federatedIdentityModel);
if (isDebugEnabled()) {
logger.debugf("Identity [%s] update with response from identity provider [%s].", federatedUser, context.getIdpConfig().getAlias());
}
}
}
return Response.status(302).location(UriBuilder.fromUri(clientSession.getRedirectUri()).build()).build();
}
UserModel authenticatedUser = clientSession.getUserSession().getUser();
@@ -645,15 +831,7 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
FederatedIdentityModel federatedIdentityModel = this.session.users().getFederatedIdentity(federatedUser, context.getIdpConfig().getAlias(), this.realmModel);
// Skip DB write if tokens are null or equal
if (context.getIdpConfig().isStoreToken() && !ObjectUtil.isEqualOrBothNull(context.getToken(), federatedIdentityModel.getToken())) {
federatedIdentityModel.setToken(context.getToken());
this.session.users().updateFederatedIdentity(this.realmModel, federatedUser, federatedIdentityModel);
if (isDebugEnabled()) {
logger.debugf("Identity [%s] update with response from identity provider [%s].", federatedUser, context.getIdpConfig().getAlias());
}
}
updateToken(context, federatedUser, federatedIdentityModel);
context.getIdp().updateBrokeredUser(session, realmModel, federatedUser, context);
Set<IdentityProviderMapperModel> mappers = realmModel.getIdentityProviderMappersByAlias(context.getIdpConfig().getAlias());
if (mappers != null) {
@@ -666,6 +844,18 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
}
private void updateToken(BrokeredIdentityContext context, UserModel federatedUser, FederatedIdentityModel federatedIdentityModel) {
if (context.getIdpConfig().isStoreToken() && !ObjectUtil.isEqualOrBothNull(context.getToken(), federatedIdentityModel.getToken())) {
federatedIdentityModel.setToken(context.getToken());
this.session.users().updateFederatedIdentity(this.realmModel, federatedUser, federatedIdentityModel);
if (isDebugEnabled()) {
logger.debugf("Identity [%s] update with response from identity provider [%s].", federatedUser, context.getIdpConfig().getAlias());
}
}
}
private ParsedCodeContext parseClientSessionCode(String code) {
ClientSessionCode clientCode = ClientSessionCode.parse(code, this.session, this.realmModel);

View File

@@ -0,0 +1,97 @@
/*
* Copyright 2016 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.adapter.servlet;
import org.keycloak.KeycloakSecurityContext;
import org.keycloak.common.util.Base64Url;
import org.keycloak.common.util.KeycloakUriBuilder;
import org.keycloak.representations.AccessToken;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.UUID;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class ClientInitiatedAccountLinkServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse resp) throws ServletException, IOException {
if (request.getRequestURI().endsWith("/link") && request.getParameter("response") == null) {
String provider = request.getParameter("provider");
String realm = request.getParameter("realm");
KeycloakSecurityContext session = (KeycloakSecurityContext) request.getAttribute(KeycloakSecurityContext.class.getName());
AccessToken token = session.getToken();
String clientSessionId = token.getClientSession();
String nonce = UUID.randomUUID().toString();
MessageDigest md = null;
try {
md = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
String input = nonce + token.getSessionState() + clientSessionId + provider;
byte[] check = md.digest(input.getBytes(StandardCharsets.UTF_8));
String hash = Base64Url.encode(check);
request.getSession().setAttribute("hash", hash);
String redirectUri = KeycloakUriBuilder.fromUri(request.getRequestURL().toString())
.replaceQuery(null)
.queryParam("response", "true").build().toString();
String accountLinkUrl = KeycloakUriBuilder.fromUri(ServletTestUtils.getAuthServerUrlBase())
.path("/auth/realms/{realm}/broker/{provider}/link")
.queryParam("nonce", nonce)
.queryParam("hash", hash)
.queryParam("client_id", token.getIssuedFor())
.queryParam("redirect_uri", redirectUri).build(realm, provider).toString();
resp.setStatus(302);
resp.setHeader("Location", accountLinkUrl);
} else if (request.getRequestURI().endsWith("/link") && request.getParameter("response") != null) {
String hash = request.getSession().getAttribute("hash").toString();
String hashParam = request.getParameter("hash");
resp.setStatus(200);
resp.setContentType("text/html");
PrintWriter pw = resp.getWriter();
pw.printf("<html><head><title>%s</title></head><body>", "Client Linking");
String error = request.getParameter("error");
if (error != null) {
pw.println("Link error: " + error);
} else {
pw.println("Account Linked");
}
pw.print("</body></html>");
pw.flush();
} else {
resp.setStatus(200);
resp.setContentType("text/html");
PrintWriter pw = resp.getWriter();
pw.printf("<html><head><title>%s</title></head><body>", "Client Linking");
pw.println("Unknown request: " + request.getRequestURL().toString());
pw.print("</body></html>");
pw.flush();
}
}
}

View File

@@ -51,6 +51,6 @@ public class ServletTestUtils {
return System.getProperty("auth.server.ssl.base.url", "https://localhost:8543");
}
return System.getProperty("auth.server.base.url");
return System.getProperty("auth.server.base.url", "http://localhost:8180");
}
}

View File

@@ -144,7 +144,12 @@ public class LoginPage extends AbstractPage {
public boolean isCurrent() {
return driver.getTitle().equals("Log in to test") || driver.getTitle().equals("Anmeldung bei test");
String realm = "test";
return isCurrent(realm);
}
public boolean isCurrent(String realm) {
return driver.getTitle().equals("Log in to " + realm) || driver.getTitle().equals("Anmeldung bei " + realm);
}
public void clickRegister() {

View File

@@ -0,0 +1,245 @@
/*
* Copyright 2016 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.broker;
import org.apache.http.client.utils.URIBuilder;
import org.jboss.arquillian.container.test.api.Deployment;
import org.jboss.arquillian.container.test.api.OperateOnDeployment;
import org.jboss.arquillian.graphene.page.Page;
import org.jboss.arquillian.test.api.ArquillianResource;
import org.jboss.shrinkwrap.api.spec.WebArchive;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.common.util.Base64Url;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.representations.idm.ClientRepresentation;
import org.keycloak.representations.idm.FederatedIdentityRepresentation;
import org.keycloak.representations.idm.RealmRepresentation;
import org.keycloak.representations.idm.RoleRepresentation;
import org.keycloak.representations.idm.UserRepresentation;
import org.keycloak.testsuite.AbstractKeycloakTest;
import org.keycloak.testsuite.adapter.page.AppServerContextRoot;
import org.keycloak.testsuite.adapter.servlet.ClientInitiatedAccountLinkServlet;
import org.keycloak.testsuite.arquillian.AuthServerTestEnricher;
import org.keycloak.testsuite.arquillian.annotation.AppServerContainer;
import org.keycloak.testsuite.federation.PassThroughFederatedUserStorageProvider;
import org.keycloak.testsuite.page.AbstractPageWithInjectedUrl;
import org.keycloak.testsuite.pages.AccountFederatedIdentityPage;
import org.keycloak.testsuite.pages.LoginPage;
import org.keycloak.testsuite.pages.UpdateAccountInformationPage;
import org.keycloak.testsuite.util.AdapterServletDeployment;
import javax.ws.rs.core.UriBuilder;
import java.net.URL;
import java.util.LinkedList;
import java.util.List;
import java.util.UUID;
import static org.junit.Assert.assertTrue;
import static org.keycloak.testsuite.admin.ApiUtil.createUserAndResetPasswordWithAdminClient;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
@AppServerContainer("auth-server-undertow")
public class ClientInitiatedAccountLinkTest extends AbstractKeycloakTest {
public static final String CHILD_IDP = "child";
public static final String PARENT_IDP = "parent-idp";
public static final String PARENT_USERNAME = "parent";
@Page
protected UpdateAccountInformationPage profilePage;
@Page
protected LoginPage loginPage;
@Page
protected AppServerContextRoot appServerContextRootPage;
public boolean isRelative() {
return testContext.isRelativeAdapterTest();
}
public static class ClientApp extends AbstractPageWithInjectedUrl {
public static final String DEPLOYMENT_NAME = "client-linking";
@ArquillianResource
@OperateOnDeployment(DEPLOYMENT_NAME)
private URL url;
@Override
public URL getInjectedUrl() {
return url;
}
}
@Page
protected ClientApp appPage;
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
RealmRepresentation realm = new RealmRepresentation();
realm.setRealm(CHILD_IDP);
realm.setEnabled(true);
ClientRepresentation servlet = new ClientRepresentation();
servlet.setClientId("client-linking");
servlet.setProtocol(OIDCLoginProtocol.LOGIN_PROTOCOL);
String uri = "/client-linking";
if (!isRelative()) {
uri = appServerContextRootPage.toString() + uri;
}
servlet.setAdminUrl(uri);
servlet.setBaseUrl(uri);
servlet.setRedirectUris(new LinkedList<>());
servlet.getRedirectUris().add(uri + "/*");
servlet.setSecret("password");
servlet.setFullScopeAllowed(true);
realm.setClients(new LinkedList<>());
realm.getClients().add(servlet);
testRealms.add(realm);
realm = new RealmRepresentation();
realm.setRealm(PARENT_IDP);
realm.setEnabled(true);
testRealms.add(realm);
}
@Deployment(name = "client-linking")
public static WebArchive customerPortal() {
return AdapterServletDeployment.oidcDeployment("client-linking", "/account-link-test", ClientInitiatedAccountLinkServlet.class);
}
@Before
public void addIdpUser() {
RealmResource realm = adminClient.realms().realm(PARENT_IDP);
UserRepresentation user = new UserRepresentation();
user.setUsername(PARENT_USERNAME);
user.setEnabled(true);
String userId = createUserAndResetPasswordWithAdminClient(realm, user, "password");
}
private String childUserId = null;
@Before
public void addChildUser() {
RealmResource realm = adminClient.realms().realm(CHILD_IDP);
UserRepresentation user = new UserRepresentation();
user.setUsername("child");
user.setEnabled(true);
childUserId = createUserAndResetPasswordWithAdminClient(realm, user, "password");
// have to add a role as stupid undertow auth manager doesn't like "*"
realm.roles().create(new RoleRepresentation("user", null, false));
RoleRepresentation role = realm.roles().get("user").toRepresentation();
List<RoleRepresentation> roles = new LinkedList<>();
roles.add(role);
realm.users().get(childUserId).roles().realmLevel().add(roles);
}
@Before
public void createBroker() {
createParentChild();
}
public void createParentChild() {
BrokerTestTools.createKcOidcBroker(adminClient, CHILD_IDP, PARENT_IDP, suiteContext);
}
@Test
public void testErrorConditions() {
RealmResource realm = adminClient.realms().realm(CHILD_IDP);
List<FederatedIdentityRepresentation> links = realm.users().get(childUserId).getFederatedIdentity();
Assert.assertTrue(links.isEmpty());
UriBuilder redirectUri = UriBuilder.fromUri(appPage.getInjectedUrl().toString())
.path("link")
.queryParam("response", "true");
UriBuilder directLinking = UriBuilder.fromUri(AuthServerTestEnricher.getAuthServerContextRoot() + "/auth")
.path("realms/child/broker/{provider}/link")
.queryParam("client_id", "client-linking")
.queryParam("redirect_uri", redirectUri.build())
.queryParam("hash", Base64Url.encode("crap".getBytes()))
.queryParam("nonce", UUID.randomUUID().toString());
String linkUrl = directLinking
.build(PARENT_IDP).toString();
// test not logged in
driver.navigate().to(linkUrl);
Assert.assertTrue(driver.getCurrentUrl().contains("error=not_logged_in"));
// now log in
driver.navigate().to( appPage.getInjectedUrl() + "/hello");
loginPage.login("child", "password");
Assert.assertTrue(driver.getCurrentUrl().startsWith(appPage.getInjectedUrl() + "/hello"));
// now test CSRF with bad hash.
driver.navigate().to(linkUrl);
Assert.assertTrue(driver.getPageSource().contains("We're sorry..."));
}
@Test
public void testAccountLink() {
RealmResource realm = adminClient.realms().realm(CHILD_IDP);
List<FederatedIdentityRepresentation> links = realm.users().get(childUserId).getFederatedIdentity();
Assert.assertTrue(links.isEmpty());
UriBuilder linkBuilder = UriBuilder.fromUri(appPage.getInjectedUrl().toString())
.path("link");
String linkUrl = linkBuilder.clone()
.queryParam("realm", CHILD_IDP)
.queryParam("provider", PARENT_IDP).build().toString();
driver.navigate().to(linkUrl);
Assert.assertTrue(loginPage.isCurrent(CHILD_IDP));
loginPage.login("child", "password");
Assert.assertTrue(loginPage.isCurrent(PARENT_IDP));
loginPage.login(PARENT_USERNAME, "password");
System.out.println("After linking: " + driver.getCurrentUrl());
System.out.println(driver.getPageSource());
Assert.assertTrue(driver.getCurrentUrl().startsWith(linkBuilder.toTemplate()));
Assert.assertTrue(driver.getPageSource().contains("Account Linked"));
links = realm.users().get(childUserId).getFederatedIdentity();
Assert.assertFalse(links.isEmpty());
}
}

View File

@@ -0,0 +1,93 @@
/*
* Copyright 2016 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.keycloak.testsuite.util;
import org.apache.commons.io.IOUtils;
import org.jboss.shrinkwrap.api.Archive;
import org.jboss.shrinkwrap.api.ShrinkWrap;
import org.jboss.shrinkwrap.api.asset.StringAsset;
import org.jboss.shrinkwrap.api.spec.WebArchive;
import java.io.IOException;
import java.net.URL;
/**
* Expects a structure like adapter-test directory
*
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public class AdapterServletDeployment {
public static final String JBOSS_DEPLOYMENT_STRUCTURE_XML = "jboss-deployment-structure.xml";
public static final String TOMCAT_CONTEXT_XML = "context.xml";
// hardcoded for now
public static final URL tomcatContext = AdapterServletDeployment.class
.getResource("/adapter-test/" + TOMCAT_CONTEXT_XML);
public static WebArchive oidcDeployment(String name, String configRoot, Class... servletClasses) {
return oidcDeployment(name, configRoot, "keycloak.json");
}
public static WebArchive oidcDeployment(String name, String configRoot, String adapterConfigFilename, Class... servletClasses) {
String configPath = configRoot + "/" + name;
String webInfPath = configPath + "/WEB-INF/";
URL keycloakJSON = AdapterServletDeployment.class.getResource(webInfPath + adapterConfigFilename);
URL webXML = AdapterServletDeployment.class.getResource(webInfPath + "web.xml");
WebArchive deployment = ShrinkWrap.create(WebArchive.class, name + ".war")
.addClasses(servletClasses)
.addAsWebInfResource(webXML, "web.xml");
URL keystore = AdapterServletDeployment.class.getResource(webInfPath + "keystore.jks");
if (keystore != null) {
deployment.addAsWebInfResource(keystore, "classes/keystore.jks");
}
if (keycloakJSON != null) {
deployment.addAsWebInfResource(keycloakJSON, "keycloak.json");
}
URL jbossDeploymentStructure = AdapterServletDeployment.class.getResource(webInfPath + JBOSS_DEPLOYMENT_STRUCTURE_XML);
if (jbossDeploymentStructure == null) {
jbossDeploymentStructure = AdapterServletDeployment.class.getResource(configRoot + "/" + JBOSS_DEPLOYMENT_STRUCTURE_XML);
}
if (jbossDeploymentStructure != null) deployment.addAsWebInfResource(jbossDeploymentStructure, JBOSS_DEPLOYMENT_STRUCTURE_XML);
addContextXml(deployment, name);
return deployment;
}
public static void addContextXml(Archive archive, String contextPath) {
// hardcoded for now
try {
String contextXmlContent = IOUtils.toString(tomcatContext.openStream())
.replace("%CONTEXT_PATH%", contextPath);
archive.add(new StringAsset(contextXmlContent), "/META-INF/context.xml");
} catch (IOException ex) {
throw new RuntimeException(ex);
}
}
}

View File

@@ -0,0 +1,38 @@
{
"id": "child",
"realm": "child",
"enabled": true,
"accessTokenLifespan": 600,
"accessCodeLifespan": 10,
"accessCodeLifespanUserAction": 6000,
"sslRequired": "external",
"registrationAllowed": false,
"privateKey": "MIICXAIBAAKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQABAoGAfmO8gVhyBxdqlxmIuglbz8bcjQbhXJLR2EoS8ngTXmN1bo2L90M0mUKSdc7qF10LgETBzqL8jYlQIbt+e6TH8fcEpKCjUlyq0Mf/vVbfZSNaVycY13nTzo27iPyWQHK5NLuJzn1xvxxrUeXI6A2WFpGEBLbHjwpx5WQG9A+2scECQQDvdn9NE75HPTVPxBqsEd2z10TKkl9CZxu10Qby3iQQmWLEJ9LNmy3acvKrE3gMiYNWb6xHPKiIqOR1as7L24aTAkEAtyvQOlCvr5kAjVqrEKXalj0Tzewjweuxc0pskvArTI2Oo070h65GpoIKLc9jf+UA69cRtquwP93aZKtW06U8dQJAF2Y44ks/mK5+eyDqik3koCI08qaC8HYq2wVl7G2QkJ6sbAaILtcvD92ToOvyGyeE0flvmDZxMYlvaZnaQ0lcSQJBAKZU6umJi3/xeEbkJqMfeLclD27XGEFoPeNrmdx0q10Azp4NfJAY+Z8KRyQCR2BEG+oNitBOZ+YXF9KCpH3cdmECQHEigJhYg+ykOvr1aiZUMFT72HU0jnmQe2FVekuG+LJUt2Tm7GtMjTFoGpf0JwrVuZN39fOYAlo+nTixgeW7X8Y=",
"publicKey": "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB",
"requiredCredentials": [ "password" ],
"users" : [
{
"username" : "bburke@redhat.com",
"enabled": true,
"email" : "bburke@redhat.com",
"firstName": "Bill",
"lastName": "Burke",
"credentials" : [
{ "type" : "password",
"value" : "password" }
]
}
],
"clients": [
{
"clientId": "client-linking",
"enabled": true,
"adminUrl": "/client-linking",
"baseUrl": "/client-linking",
"redirectUris": [
"/client-linking/*"
],
"secret": "password"
}
]
}

View File

@@ -0,0 +1,20 @@
<!--
~ Copyright 2016 Red Hat, Inc. and/or its affiliates
~ and other contributors as indicated by the @author tags.
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<Context path="/customer-portal">
<Valve className="org.keycloak.adapters.tomcat.KeycloakAuthenticatorValve"/>
</Context>

View File

@@ -0,0 +1,46 @@
<?xml version="1.0"?>
<!--
~ Copyright 2016 Red Hat, Inc. and/or its affiliates
~ and other contributors as indicated by the @author tags.
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<!DOCTYPE Configure PUBLIC "-//Mort Bay Consulting//DTD Configure//EN" "http://www.eclipse.org/jetty/configure_9_0.dtd">
<Configure class="org.eclipse.jetty.webapp.WebAppContext">
<Get name="securityHandler">
<Set name="authenticator">
<New class="org.keycloak.adapters.jetty.KeycloakJettyAuthenticator">
<!--
<Set name="adapterConfig">
<New class="org.keycloak.representations.adapters.config.AdapterConfig">
<Set name="realm">tomcat</Set>
<Set name="resource">customer-portal</Set>
<Set name="authServerUrl">http://localhost:8180/auth</Set>
<Set name="sslRequired">external</Set>
<Set name="credentials">
<Map>
<Entry>
<Item>secret</Item>
<Item>password</Item>
</Entry>
</Map>
</Set>
<Set name="realmKey">MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCrVrCuTtArbgaZzL1hvh0xtL5mc7o0NqPVnYXkLvgcwiC3BjLGw1tGEGoJaXDuSaRllobm53JBhjx33UNv+5z/UMG4kytBWxheNVKnL6GgqlNabMaFfPLPCF8kAgKnsi79NMo+n6KnSY8YeUmec/p2vjO2NjsSAVcWEQMVhJ31LwIDAQAB</Set>
</New>
</Set>
-->
</New>
</Set>
</Get>
</Configure>

View File

@@ -0,0 +1,9 @@
{
"realm" : "child",
"resource" : "client-linking",
"auth-server-url" : "http://localhost:8180/auth",
"ssl-required" : "external",
"credentials" : {
"secret": "password"
}
}

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2016 Red Hat, Inc. and/or its affiliates
~ and other contributors as indicated by the @author tags.
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<web-app xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
version="3.0">
<module-name>client-linking</module-name>
<servlet>
<servlet-name>Servlet</servlet-name>
<servlet-class>org.keycloak.testsuite.adapter.servlet.ClientInitiatedAccountLinkServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>Servlet</servlet-name>
<url-pattern>/*</url-pattern>
</servlet-mapping>
<security-constraint>
<web-resource-collection>
<web-resource-name>Users</web-resource-name>
<url-pattern>/*</url-pattern>
</web-resource-collection>
<auth-constraint>
<role-name>user</role-name>
</auth-constraint>
</security-constraint>
<login-config>
<auth-method>KEYCLOAK</auth-method>
<realm-name>child</realm-name>
</login-config>
<security-role>
<role-name>user</role-name>
</security-role>
</web-app>

View File

@@ -104,21 +104,6 @@ public class AdapterTest {
Thread.sleep(1000000000);
}
public static class MySuper {
}
public static class Base extends MySuper {
public Class superClass() {
return super.getClass();
}
}
@Test
public void testBase() {
System.out.println(new Base().superClass().getName());
}
@Test
public void testLoginSSOAndLogout() throws Exception {
testStrategy.testLoginSSOMax();

View File

@@ -57,6 +57,7 @@
<module>tomcat8</module>
<module>jetty</module>
<module>integration-arquillian</module>
<module>stress</module>
</modules>
</project>