/*
* Copyright 2017 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.cluster;
import org.keycloak.admin.client.resource.RealmResource;
import org.keycloak.admin.client.resource.UsersResource;
import org.keycloak.representations.idm.*;
import org.keycloak.testsuite.Retry;
import org.keycloak.testsuite.adapter.page.EmployeeServletDistributable;
import org.keycloak.testsuite.adapter.page.SAMLServlet;
import org.keycloak.testsuite.auth.page.AuthRealm;
import org.keycloak.testsuite.auth.page.login.*;
import org.keycloak.testsuite.page.AbstractPage;
import org.keycloak.testsuite.util.WaitUtils;
import io.undertow.Undertow;
import io.undertow.server.handlers.ResponseCodeHandler;
import io.undertow.server.handlers.proxy.LoadBalancingProxyClient;
import io.undertow.server.handlers.proxy.ProxyHandler;
import java.io.File;
import java.io.IOException;
import java.net.URI;
import java.net.URL;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.function.Consumer;
import org.apache.commons.io.FileUtils;
import org.apache.commons.lang3.math.NumberUtils;
import org.jboss.arquillian.container.test.api.*;
import org.jboss.arquillian.graphene.page.Page;
import org.jboss.arquillian.test.api.ArquillianResource;
import org.junit.*;
import org.keycloak.testsuite.adapter.AbstractServletsAdapterTest;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.AppServerContainer;
import org.openqa.selenium.TimeoutException;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.support.PageFactory;
import org.openqa.selenium.support.ui.WebDriverWait;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.assertThat;
import static org.keycloak.testsuite.AbstractAuthTest.createUserRepresentation;
import static org.keycloak.testsuite.admin.Users.setPasswordFor;
import static org.keycloak.testsuite.arquillian.AppServerTestEnricher.getNearestSuperclassWithAnnotation;
import static org.keycloak.testsuite.auth.page.AuthRealm.DEMO;
import static org.keycloak.testsuite.util.IOUtil.loadRealm;
import static org.keycloak.testsuite.util.URLAssert.assertCurrentUrlStartsWith;
/**
*
* @author hmlnarik
*/
public abstract class AbstractSAMLAdapterClusterTest extends AbstractServletsAdapterTest {
protected static final String NODE_1_NAME = "ha-node-1";
protected static final String NODE_2_NAME = "ha-node-2";
protected final String NODE_1_SERVER_NAME = getAppServerId() + "-" + NODE_1_NAME;
protected final String NODE_2_SERVER_NAME = getAppServerId() + "-" + NODE_2_NAME;
protected static final int PORT_OFFSET_NODE_REVPROXY = NumberUtils.toInt(System.getProperty("app.server.reverse-proxy.port.offset"), -1);
protected static final int HTTP_PORT_NODE_REVPROXY = 8080 + PORT_OFFSET_NODE_REVPROXY;
protected static final int PORT_OFFSET_NODE_1 = NumberUtils.toInt(System.getProperty("app.server.1.port.offset"), -1);
protected static final int HTTP_PORT_NODE_1 = 8080 + PORT_OFFSET_NODE_1;
protected static final int PORT_OFFSET_NODE_2 = NumberUtils.toInt(System.getProperty("app.server.2.port.offset"), -1);
protected static final int HTTP_PORT_NODE_2 = 8080 + PORT_OFFSET_NODE_2;
protected static final URI NODE_1_URI = URI.create("http://localhost:" + HTTP_PORT_NODE_1);
protected static final URI NODE_2_URI = URI.create("http://localhost:" + HTTP_PORT_NODE_2);
@BeforeClass
public static void checkPropertiesSet() {
Assume.assumeThat(PORT_OFFSET_NODE_1, not(is(-1)));
Assume.assumeThat(PORT_OFFSET_NODE_2, not(is(-1)));
Assume.assumeThat(PORT_OFFSET_NODE_REVPROXY, not(is(-1)));
}
protected static void prepareServerDirectory(String targetSubdirectory) throws IOException {
Path path = Paths.get(System.getProperty("app.server.home"), targetSubdirectory);
File targetSubdirFile = path.toFile();
FileUtils.deleteDirectory(targetSubdirFile);
FileUtils.forceMkdir(targetSubdirFile);
FileUtils.copyDirectoryToDirectory(Paths.get(System.getProperty("app.server.home"), "standalone", "deployments").toFile(), targetSubdirFile);
}
protected LoadBalancingProxyClient loadBalancerToNodes;
protected Undertow reverseProxyToNodes;
@ArquillianResource
protected ContainerController controller;
@ArquillianResource
protected Deployer deployer;
@Page
LoginActions loginActionsPage;
@Override
public void addTestRealms(List<RealmRepresentation> testRealms) {
testRealms.add(loadRealm("/adapter-test/keycloak-saml/testsaml-behind-lb.json"));
}
@Before
public void prepareReverseProxy() throws Exception {
loadBalancerToNodes = new LoadBalancingProxyClient().addHost(NODE_1_URI, NODE_1_NAME).setConnectionsPerThread(10);
reverseProxyToNodes = Undertow.builder().addHttpListener(HTTP_PORT_NODE_REVPROXY, "localhost").setIoThreads(2).setHandler(new ProxyHandler(loadBalancerToNodes, 5000, ResponseCodeHandler.HANDLE_404)).build();
reverseProxyToNodes.start();
}
@After
public void stopReverseProxy() {
reverseProxyToNodes.stop();
}
@Before
public void startServer() throws Exception {
prepareServerDirectory("standalone-" + NODE_1_NAME);
controller.start(NODE_1_SERVER_NAME);
prepareWorkerNode(Integer.valueOf(System.getProperty("app.server.1.management.port")));
prepareServerDirectory("standalone-" + NODE_2_NAME);
controller.start(NODE_2_SERVER_NAME);
prepareWorkerNode(Integer.valueOf(System.getProperty("app.server.2.management.port")));
deployer.deploy(EmployeeServletDistributable.DEPLOYMENT_NAME);
deployer.deploy(EmployeeServletDistributable.DEPLOYMENT_NAME + "_2");
}
protected abstract void prepareWorkerNode(Integer managementPort) throws Exception;
@After
public void stopServer() {
controller.stop(NODE_1_SERVER_NAME);
controller.stop(NODE_2_SERVER_NAME);
}
@Override
public void setDefaultPageUriParameters() {
super.setDefaultPageUriParameters();
testRealmSAMLPostLoginPage.setAuthRealm(DEMO);
loginPage.setAuthRealm(DEMO);
loginActionsPage.setAuthRealm(DEMO);
}
protected void testLogoutViaSessionIndex(URL employeeUrl, Consumer<EmployeeServletDistributable> logoutFunction) {
EmployeeServletDistributable page = PageFactory.initElements(driver, EmployeeServletDistributable.class);
page.setUrl(employeeUrl);
page.getUriBuilder().port(HTTP_PORT_NODE_REVPROXY);
UserRepresentation bburkeUser = createUserRepresentation("bburke", "bburke@redhat.com", "Bill", "Burke", true);
setPasswordFor(bburkeUser, CredentialRepresentation.PASSWORD);
assertSuccessfulLogin(page, bburkeUser, testRealmSAMLPostLoginPage, "principal=bburke");
updateProxy(NODE_2_NAME, NODE_2_URI, NODE_1_URI);
logoutFunction.accept(page);
delayedCheckLoggedOut(page, loginActionsPage);
updateProxy(NODE_1_NAME, NODE_1_URI, NODE_2_URI);
delayedCheckLoggedOut(page, loginActionsPage);
}
@Test
public void testBackchannelLogout(@ArquillianResource
@OperateOnDeployment(value = EmployeeServletDistributable.DEPLOYMENT_NAME) URL employeeUrl) throws Exception {
testLogoutViaSessionIndex(employeeUrl, (EmployeeServletDistributable page) -> {
RealmResource demoRealm = adminClient.realm(DEMO);
String bburkeId = ApiUtil.findUserByUsername(demoRealm, "bburke").getId();
demoRealm.users().get(bburkeId).logout();
log.infov("Logged out via admin console");
});
}
@Test
public void testFrontchannelLogout(@ArquillianResource
@OperateOnDeployment(value = EmployeeServletDistributable.DEPLOYMENT_NAME) URL employeeUrl) throws Exception {
testLogoutViaSessionIndex(employeeUrl, (EmployeeServletDistributable page) -> {
page.logout();
log.infov("Logged out via application");
});
}
protected void updateProxy(String hostToPointToName, URI hostToPointToUri, URI hostToRemove) {
loadBalancerToNodes.removeHost(hostToRemove);
loadBalancerToNodes.addHost(hostToPointToUri, hostToPointToName);
log.infov("Reverse proxy will direct requests to {0}", hostToPointToUri);
}
protected void assertSuccessfulLogin(SAMLServlet page, UserRepresentation user, Login loginPage, String expectedString) {
page.navigateTo();
assertCurrentUrlStartsWith(loginPage);
loginPage.form().login(user);
WebDriverWait wait = new WebDriverWait(driver, WaitUtils.PAGELOAD_TIMEOUT_MILLIS / 1000);
wait.until((WebDriver d) -> d.getPageSource().contains(expectedString));
}
protected void delayedCheckLoggedOut(AbstractPage page, AuthRealm loginPage) {
Retry.execute(() -> {
try {
checkLoggedOut(page, loginPage);
} catch (AssertionError | TimeoutException ex) {
driver.navigate().refresh();
log.debug("[Retriable] Timed out waiting for login page");
throw new RuntimeException(ex);
}
}, 10, 100);
}
protected void checkLoggedOut(AbstractPage page, AuthRealm loginPage) {
page.navigateTo();
WaitUtils.waitForPageToLoad(driver);
assertCurrentUrlStartsWith(loginPage);
}
private String getAppServerId() {
Class<?> annotatedClass = getNearestSuperclassWithAnnotation(this.getClass(), AppServerContainer.class);
return (annotatedClass == null ? "<cannot-find-@AppServerContainer>"
: annotatedClass.getAnnotation(AppServerContainer.class).value());
}
}