Retry the login in the SAML adapter if response is authentication_expired

Closes #28412

Signed-off-by: rmartinc <rmartinc@redhat.com>
This commit is contained in:
rmartinc 2024-04-11 10:56:35 +02:00 committed by Marek Posolda
parent e6747bfd23
commit 92bcd2645c
5 changed files with 139 additions and 12 deletions

View file

@ -40,10 +40,16 @@ public abstract class AbstractInitiateLogin implements AuthChallenge {
protected SamlDeployment deployment;
protected SamlSessionStore sessionStore;
protected boolean saveRequestUri;
public AbstractInitiateLogin(SamlDeployment deployment, SamlSessionStore sessionStore) {
this(deployment, sessionStore, true);
}
public AbstractInitiateLogin(SamlDeployment deployment, SamlSessionStore sessionStore, boolean saveRequestUri) {
this.deployment = deployment;
this.sessionStore = sessionStore;
this.saveRequestUri = saveRequestUri;
}
@Override
@ -56,7 +62,9 @@ public abstract class AbstractInitiateLogin implements AuthChallenge {
try {
SAML2AuthnRequestBuilder authnRequestBuilder = buildSaml2AuthnRequestBuilder(deployment);
BaseSAML2BindingBuilder binding = createSaml2Binding(deployment);
sessionStore.saveRequest();
if (saveRequestUri) {
sessionStore.saveRequest();
}
sendAuthnRequest(httpFacade, authnRequestBuilder, binding);
sessionStore.setCurrentAction(SamlSessionStore.CurrentAction.LOGGING_IN);

View file

@ -25,4 +25,5 @@ public class AdapterConstants {
public static final String AUTH_DATA_PARAM_NAME="org.keycloak.saml.xml.adapterConfig";
public static final String REPLICATION_CONFIG_CONTAINER_PARAM_NAME = "org.keycloak.saml.replication.container";
public static final String REPLICATION_CONFIG_SSO_CACHE_PARAM_NAME = "org.keycloak.saml.replication.cache.sso";
public static final String AUTHENTICATION_EXPIRED_MESSAGE = "authentication_expired";
}

View file

@ -32,6 +32,7 @@ import javax.xml.datatype.XMLGregorianCalendar;
import javax.xml.namespace.QName;
import org.jboss.logging.Logger;
import org.keycloak.adapters.saml.AbstractInitiateLogin;
import org.keycloak.adapters.saml.AdapterConstants;
import org.keycloak.adapters.saml.OnSessionCreated;
import org.keycloak.adapters.saml.SamlAuthenticationError;
import org.keycloak.adapters.saml.SamlDeployment;
@ -148,7 +149,7 @@ public abstract class AbstractSamlAuthenticationHandler implements SamlAuthentic
log.debug("AUTHENTICATED: was cached");
return handleRequest();
}
return initiateLogin();
return initiateLogin(true);
}
protected AuthOutcome handleRequest() {
@ -361,7 +362,10 @@ public abstract class AbstractSamlAuthenticationHandler implements SamlAuthentic
final ResponseType responseType = (ResponseType) responseHolder.getSamlObject();
AssertionType assertion = null;
if (! isSuccessfulSamlResponse(responseType) || responseType.getAssertions() == null || responseType.getAssertions().isEmpty()) {
if (isRetrayableSamlResponse(responseType)) {
// initiate the login but do not save the request cos it's /saml
return initiateLogin(false);
} else if (!isSuccessfulSamlResponse(responseType) || responseType.getAssertions() == null || responseType.getAssertions().isEmpty()) {
return failed(createAuthChallenge403(responseType));
}
try {
@ -378,7 +382,8 @@ public abstract class AbstractSamlAuthenticationHandler implements SamlAuthentic
// warning has been already emitted in DeploymentBuilder
}
if (! cvb.build().isValid()) {
return initiateLogin();
// initiate the login but do not save the request cos it's /saml
return initiateLogin(false);
}
} catch (Exception e) {
log.error("Error extracting SAML assertion: " + e.getMessage());
@ -523,6 +528,21 @@ public abstract class AbstractSamlAuthenticationHandler implements SamlAuthentic
&& Objects.equals(responseType.getStatus().getStatusCode().getValue().toString(), JBossSAMLURIConstants.STATUS_SUCCESS.get());
}
private boolean isRetrayableSamlResponse(ResponseType responseType) {
if (responseType == null || responseType.getStatus() == null) {
return false;
}
StatusType status = responseType.getStatus();
return status.getStatusCode() != null
&& AdapterConstants.AUTHENTICATION_EXPIRED_MESSAGE.equals(status.getStatusMessage())
&& status.getStatusCode().getValue() != null
&& Objects.equals(status.getStatusCode().getValue().toString(), JBossSAMLURIConstants.STATUS_RESPONDER.get())
&& status.getStatusCode().getStatusCode() != null
&& status.getStatusCode().getStatusCode().getValue() != null
&& Objects.equals(status.getStatusCode().getStatusCode().getValue().toString(), JBossSAMLURIConstants.STATUS_AUTHNFAILED.get());
}
private Element getAssertionFromResponse(final SAMLDocumentHolder responseHolder) throws ConfigurationException, ProcessingException {
Element encryptedAssertion = DocumentUtil.getElement(responseHolder.getSamlDocument(), new QName(JBossSAMLConstants.ENCRYPTED_ASSERTION.get()));
if (encryptedAssertion != null) {
@ -601,13 +621,13 @@ public abstract class AbstractSamlAuthenticationHandler implements SamlAuthentic
}
protected AuthOutcome initiateLogin() {
challenge = createChallenge();
protected AuthOutcome initiateLogin(boolean saveRequestUri) {
challenge = createChallenge(saveRequestUri);
return AuthOutcome.NOT_ATTEMPTED;
}
protected AbstractInitiateLogin createChallenge() {
return new AbstractInitiateLogin(deployment, sessionStore) {
protected AbstractInitiateLogin createChallenge(boolean saveRequestUri) {
return new AbstractInitiateLogin(deployment, sessionStore, saveRequestUri) {
@Override
protected void sendAuthnRequest(HttpFacade httpFacade, SAML2AuthnRequestBuilder authnRequestBuilder, BaseSAML2BindingBuilder binding) throws ProcessingException, ConfigurationException, IOException {
if (isAutodetectedBearerOnly(httpFacade.getRequest())) {

View file

@ -105,8 +105,8 @@ public class EcpAuthenticationHandler extends AbstractSamlAuthenticationHandler
}
@Override
protected AbstractInitiateLogin createChallenge() {
return new AbstractInitiateLogin(deployment, sessionStore) {
protected AbstractInitiateLogin createChallenge(boolean saveChallenge) {
return new AbstractInitiateLogin(deployment, sessionStore, saveChallenge) {
@Override
protected void sendAuthnRequest(HttpFacade httpFacade, SAML2AuthnRequestBuilder authnRequestBuilder, BaseSAML2BindingBuilder binding) {
try {

View file

@ -117,6 +117,7 @@ import org.keycloak.admin.client.resource.RoleScopeResource;
import org.keycloak.admin.client.resource.UserResource;
import org.keycloak.common.util.Base64;
import org.keycloak.common.util.KeyUtils;
import org.keycloak.common.util.KeycloakUriBuilder;
import org.keycloak.common.util.MultivaluedHashMap;
import org.keycloak.common.util.PemUtils;
import org.keycloak.cookie.CookieType;
@ -129,6 +130,7 @@ import org.keycloak.keys.Attributes;
import org.keycloak.keys.ImportedRsaKeyProviderFactory;
import org.keycloak.keys.KeyProvider;
import org.keycloak.models.Constants;
import org.keycloak.models.RealmModel;
import org.keycloak.protocol.saml.SamlConfigAttributes;
import org.keycloak.protocol.saml.SamlProtocol;
import org.keycloak.representations.idm.ClientRepresentation;
@ -146,6 +148,7 @@ import org.keycloak.saml.processing.core.parsers.saml.SAMLParser;
import org.keycloak.saml.processing.core.saml.v2.common.SAMLDocumentHolder;
import org.keycloak.saml.processing.core.saml.v2.util.AssertionUtil;
import org.keycloak.services.resources.RealmsResource;
import org.keycloak.sessions.RootAuthenticationSessionModel;
import org.keycloak.testsuite.adapter.page.*;
import org.keycloak.testsuite.admin.ApiUtil;
import org.keycloak.testsuite.arquillian.annotation.AppServerContainer;
@ -164,15 +167,19 @@ import org.keycloak.testsuite.updaters.ClientAttributeUpdater;
import org.keycloak.testsuite.updaters.Creator;
import org.keycloak.testsuite.updaters.UserAttributeUpdater;
import org.keycloak.testsuite.util.AdminClientUtil;
import org.keycloak.testsuite.util.BrowserTabUtil;
import org.keycloak.testsuite.util.OAuthClient;
import org.keycloak.testsuite.util.SamlClient;
import org.keycloak.testsuite.util.SamlClient.Binding;
import org.keycloak.testsuite.util.SamlClientBuilder;
import org.keycloak.testsuite.util.UIUtils;
import org.keycloak.testsuite.util.UserBuilder;
import org.keycloak.testsuite.util.WaitUtils;
import org.keycloak.testsuite.utils.io.IOUtil;
import org.openqa.selenium.By;
import org.openqa.selenium.Cookie;
import org.openqa.selenium.htmlunit.HtmlUnitDriver;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
@ -673,10 +680,14 @@ public class SAMLServletAdapterTest extends AbstractSAMLServletAdapterTest {
}
}
private static final KeyPair NEW_KEY_PAIR = KeyUtils.generateRsaKeyPair(org.keycloak.testsuite.util.KeyUtils.getLowestSupportedRsaKeySize());
private static final String NEW_KEY_PRIVATE_KEY_PEM = PemUtils.encodeKey(NEW_KEY_PAIR.getPrivate());
private static KeyPair NEW_KEY_PAIR;
private static String NEW_KEY_PRIVATE_KEY_PEM;
private PublicKey createKeys(String priority) throws Exception {
if (NEW_KEY_PAIR == null) {
NEW_KEY_PAIR = KeyUtils.generateRsaKeyPair(org.keycloak.testsuite.util.KeyUtils.getLowestSupportedRsaKeySize());
NEW_KEY_PRIVATE_KEY_PEM = PemUtils.encodeKey(NEW_KEY_PAIR.getPrivate());
}
PublicKey publicKey = NEW_KEY_PAIR.getPublic();
ComponentRepresentation rep = new ComponentRepresentation();
@ -1827,6 +1838,93 @@ public class SAMLServletAdapterTest extends AbstractSAMLServletAdapterTest {
checkLoggedOut(salesPostSigEmailServletPage, testRealmSAMLPostLoginPage);
}
@Test
public void testMultipleTabsParallelLogin() throws Exception {
try (BrowserTabUtil tabUtil = BrowserTabUtil.getInstanceAndSetEnv(driver)) {
// open an application in tab1 and go to the login page
Assert.assertEquals(1, tabUtil.getCountOfTabs());
salesPostServletPage.navigateTo();
waitForPageToLoad();
assertCurrentUrlStartsWith(testRealmSAMLPostLoginPage);
// Prepare a login in tab2
tabUtil.newTab(salesPostServletPage.buildUri().toASCIIString());
waitForPageToLoad();
assertCurrentUrlStartsWith(testRealmSAMLPostLoginPage);
Assert.assertEquals(2, tabUtil.getCountOfTabs());
testRealmSAMLPostLoginPage.form().login(bburkeUser);
waitUntilElement(By.xpath("//body")).text().contains("principal=bburke");
// Go back to tab1 and it should automatically login
tabUtil.closeTab(1);
Assert.assertEquals(1, tabUtil.getCountOfTabs());
if (driver instanceof HtmlUnitDriver) {
// go to restart URI manually as JS does not work
KeycloakUriBuilder current = KeycloakUriBuilder.fromUri(driver.getCurrentUrl(), false);
KeycloakUriBuilder restart = KeycloakUriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT + "/realms/" + DEMO + "/login-actions/restart", false)
.replaceQuery(current.getQuery(), false)
.queryParam(Constants.SKIP_LOGOUT, Boolean.TRUE.toString());
driver.navigate().to(restart.buildAsString());
}
waitUntilElement(By.xpath("//body")).text().contains("principal=bburke");
} finally {
salesPostServletPage.logout();
}
}
@Test
public void testMultipleTabsParallelLoginAfterAuthSessionExpiration() throws Exception {
try (BrowserTabUtil tabUtil = BrowserTabUtil.getInstanceAndSetEnv(driver)) {
// open an application in tab1 and go to the login page
Assert.assertEquals(1, tabUtil.getCountOfTabs());
salesPostServletPage.navigateTo();
waitForPageToLoad();
assertCurrentUrlStartsWith(testRealmSAMLPostLoginPage);
// Prepare a login in tab2
tabUtil.newTab(salesPostServletPage.buildUri().toASCIIString());
waitForPageToLoad();
assertCurrentUrlStartsWith(testRealmSAMLPostLoginPage);
Assert.assertEquals(2, tabUtil.getCountOfTabs());
// remove the authentication session in the server to simulate expiration
Cookie sessionCookie = driver.manage().getCookieNamed(CookieType.AUTH_SESSION_ID.getName());
Assert.assertNotNull(sessionCookie);
final String authSessionId = sessionCookie.getValue();
testingClient.server().run(session -> {
RealmModel realm = session.realms().getRealmByName(DEMO);
RootAuthenticationSessionModel root = session.authenticationSessions().getRootAuthenticationSession(realm, authSessionId);
session.authenticationSessions().removeRootAuthenticationSession(realm, root);
});
// finish the login that should fail
testRealmSAMLPostLoginPage.form().login(bburkeUser);
waitForPageToLoad();
assertCurrentUrlStartsWith(testRealmSAMLPostLoginPage); // we are still in login
Assert.assertEquals("Your login attempt timed out. Login will start from the beginning.",
UIUtils.getTextFromElement(driver.findElement(By.className("alert-error"))));
// login successfully in tab2 after the error
loginPage.form().login(bburkeUser);
waitUntilElement(By.xpath("//body")).text().contains("principal=bburke");
// Go back to tab1 and it should automatically log into the app with retry
tabUtil.closeTab(1);
Assert.assertEquals(1, tabUtil.getCountOfTabs());
if (driver instanceof HtmlUnitDriver) {
// go to restart URI manually as JS does not work
KeycloakUriBuilder current = KeycloakUriBuilder.fromUri(driver.getCurrentUrl(), false);
KeycloakUriBuilder restart = KeycloakUriBuilder.fromUri(OAuthClient.AUTH_SERVER_ROOT + "/realms/" + DEMO + "/login-actions/restart", false)
.replaceQuery(current.getQuery(), false)
.queryParam(Constants.SKIP_LOGOUT, Boolean.TRUE.toString());
driver.navigate().to(restart.buildAsString());
}
waitUntilElement(By.xpath("//body")).text().contains("principal=bburke");
} finally {
salesPostServletPage.logout();
}
}
private List<Cookie> impersonate(String admin, String adminPassword, String userId) throws IOException {
ResteasyClientBuilder resteasyClientBuilder = (ResteasyClientBuilder) ResteasyClientBuilder.newBuilder();
resteasyClientBuilder.connectionPoolSize(10);