Refresh the login page when root auth session changes

Closes #32658

Signed-off-by: Giuseppe Graziano <g.graziano94@gmail.com>
This commit is contained in:
Giuseppe Graziano 2024-10-04 12:39:56 +02:00 committed by Alexander Schwartz
parent 25e4995eb7
commit 612e2caae1
8 changed files with 119 additions and 5 deletions

View File

@ -19,6 +19,11 @@ public final class CookieType {
.defaultMaxAge(CookieMaxAge.SESSION)
.build();
public static final CookieType AUTH_SESSION_ID_HASH = CookieType.create("KC_AUTH_SESSION_HASH")
.scope(CookieScope.INTERNAL_JS)
.defaultMaxAge(60)
.build();
public static final CookieType AUTH_SESSION_ID = CookieType.create("AUTH_SESSION_ID")
.scope(CookieScope.FEDERATION)
.defaultMaxAge(CookieMaxAge.SESSION)

View File

@ -19,16 +19,24 @@
package org.keycloak.forms.login.freemarker.model;
import org.keycloak.crypto.JavaAlgorithm;
import org.keycloak.jose.jws.crypto.HashUtils;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
/**
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
*/
public class AuthenticationSessionBean {
private final String authSessionId;
private final String authSessionIdHash;
private final String tabId;
public AuthenticationSessionBean(String authSessionId, String tabId) {
this.authSessionId = authSessionId;
this.authSessionIdHash = Base64.getEncoder().withoutPadding().encodeToString(HashUtils.hash(JavaAlgorithm.SHA256, authSessionId.getBytes(StandardCharsets.UTF_8)));
this.tabId = tabId;
}
@ -36,6 +44,10 @@ public class AuthenticationSessionBean {
return authSessionId;
}
public String getAuthSessionIdHash() {
return authSessionIdHash;
}
public String getTabId() {
return tabId;
}

View File

@ -17,13 +17,17 @@
package org.keycloak.services.managers;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.Objects;
import org.jboss.logging.Logger;
import org.keycloak.common.util.Time;
import org.keycloak.cookie.CookieProvider;
import org.keycloak.cookie.CookieType;
import org.keycloak.crypto.JavaAlgorithm;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.jose.jws.crypto.HashUtils;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
@ -36,6 +40,7 @@ import org.keycloak.sessions.RootAuthenticationSessionModel;
import org.keycloak.sessions.StickySessionEncoderProvider;
import static org.keycloak.services.managers.AuthenticationManager.authenticateIdentityCookie;
import static org.keycloak.services.managers.AuthenticationManager.setKcActionStatus;
/**
@ -64,6 +69,7 @@ public class AuthenticationSessionManager {
if (browserCookie) {
setAuthSessionCookie(rootAuthSession.getId());
setAuthSessionIdHashCookie(rootAuthSession.getId());
}
return rootAuthSession;
@ -126,6 +132,17 @@ public class AuthenticationSessionManager {
log.debugf("Set AUTH_SESSION_ID cookie with value %s", encodedAuthSessionId);
}
/**
* @param authSessionId decoded authSessionId (without route info attached)
*/
public void setAuthSessionIdHashCookie(String authSessionId) {
String authSessionIdHash = Base64.getEncoder().withoutPadding().encodeToString(HashUtils.hash(JavaAlgorithm.SHA256, authSessionId.getBytes(StandardCharsets.UTF_8)));
session.getProvider(CookieProvider.class).set(CookieType.AUTH_SESSION_ID_HASH, authSessionIdHash);
log.debugf("Set KC_AUTH_SESSION_HASH cookie with value %s", authSessionIdHash);
}
/**
*

View File

@ -70,6 +70,7 @@ public class DefaultCookieProviderTest extends AbstractKeycloakTest {
Response response = testingInsecure.server("master").runWithResponse(session -> {
CookieProvider cookies = session.getProvider(CookieProvider.class);
cookies.set(CookieType.AUTH_SESSION_ID, "my-auth-session-id");
cookies.set(CookieType.AUTH_SESSION_ID_HASH, "my-kc-auth-session");
cookies.set(CookieType.AUTH_RESTART, "my-auth-restart");
cookies.set(CookieType.AUTH_DETACHED, "my-auth-detached", 222);
cookies.set(CookieType.IDENTITY, "my-identity", 333);
@ -78,8 +79,9 @@ public class DefaultCookieProviderTest extends AbstractKeycloakTest {
cookies.set(CookieType.SESSION, "my-session", 444);
cookies.set(CookieType.WELCOME_CSRF, "my-welcome-csrf");
});
Assert.assertEquals(8, response.getCookies().size());
Assert.assertEquals(9, response.getCookies().size());
assertCookie(response, "AUTH_SESSION_ID", "my-auth-session-id", "/auth/realms/master/", -1, false, true, "Lax", true);
assertCookie(response, "KC_AUTH_SESSION_HASH", "my-kc-auth-session", "/auth/realms/master/", 60, false, false, "Strict", true);
assertCookie(response, "KC_RESTART", "my-auth-restart", "/auth/realms/master/", -1, false, true, "Lax", false);
assertCookie(response, "KC_STATE_CHECKER", "my-auth-detached", "/auth/realms/master/", 222, false, true, "Strict", false);
assertCookie(response, "KEYCLOAK_IDENTITY", "my-identity", "/auth/realms/master/", 333, false, true, "Lax", true);
@ -183,6 +185,7 @@ public class DefaultCookieProviderTest extends AbstractKeycloakTest {
response = testingClient.server("master").runWithResponse(session -> {
CookieProvider cookies = session.getProvider(CookieProvider.class);
cookies.set(CookieType.AUTH_SESSION_ID, "my-auth-session-id");
cookies.set(CookieType.AUTH_SESSION_ID_HASH, "my-kc-auth-session");
cookies.set(CookieType.AUTH_RESTART, "my-auth-restart");
cookies.set(CookieType.AUTH_DETACHED, "my-auth-detached", 222);
cookies.set(CookieType.IDENTITY, "my-identity", 333);
@ -193,8 +196,9 @@ public class DefaultCookieProviderTest extends AbstractKeycloakTest {
});
}
Assert.assertEquals(8, response.getCookies().size());
Assert.assertEquals(9, response.getCookies().size());
assertCookie(response, "AUTH_SESSION_ID", "my-auth-session-id", "/auth/realms/master/", -1, false, true, "Lax", true);
assertCookie(response, "KC_AUTH_SESSION_HASH", "my-kc-auth-session", "/auth/realms/master/", 60, false, false, "Strict", true);
assertCookie(response, "KC_RESTART", "my-auth-restart", "/auth/realms/master/", -1, false, true, "Lax", false);
assertCookie(response, "KC_STATE_CHECKER", "my-auth-detached", "/auth/realms/master/", 222, false, true, "Strict", false);
assertCookie(response, "KEYCLOAK_IDENTITY", "my-identity", "/auth/realms/master/", 333, false, true, "Lax", true);

View File

@ -246,22 +246,22 @@ public class MultipleTabsLoginTest extends AbstractTestRealmKeycloakTest {
oauth.openLoginForm();
loginPage.assertCurrent();
getLogger().info("URL in tab1: " + driver.getCurrentUrl());
// Open new tab 2
tabUtil.newTab(oauth.getLoginFormUrl());
assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(2));
loginPage.assertCurrent();
getLogger().info("URL in tab2: " + driver.getCurrentUrl());
// Wait until authentication session expires
setTimeOffset(7200000);
//triggers the postponed function in authChecker.js to check if the auth session cookie has changed
WaitUtils.pause(2000);
// Try to login in tab2. After fill login form, the login will be restarted (due KC_RESTART cookie). User can continue login
loginPage.login("login-test", "password");
loginPage.assertCurrent();
Assert.assertEquals(loginPage.getError(), "Your login attempt timed out. Login will start from the beginning.");
events.clear();
loginSuccessAndDoRequiredActions();
// Go back to tab1. Usually should be automatically authenticated here (previously it showed "You are already logged-in")
@ -338,6 +338,9 @@ public class MultipleTabsLoginTest extends AbstractTestRealmKeycloakTest {
// Wait until authentication session expires
setTimeOffset(7200000);
//triggers the postponed function in authChecker.js to check if the auth session cookie has changed
WaitUtils.pause(2000);
// Try to login in tab2. After fill login form, the login will be restarted (due KC_RESTART cookie). User can continue login
loginPage.login("login-test", "password");
loginPage.assertCurrent();
@ -374,6 +377,9 @@ public class MultipleTabsLoginTest extends AbstractTestRealmKeycloakTest {
// Wait until authentication session expires
setTimeOffset(7200000);
//triggers the postponed function in authChecker.js to check if the auth session cookie has changed
WaitUtils.pause(2000);
// Try to login in tab2. After fill login form, the login will be restarted (due KC_RESTART cookie). User can continue login
loginPage.login("login-test", "password");
loginPage.assertCurrent();
@ -692,6 +698,44 @@ public class MultipleTabsLoginTest extends AbstractTestRealmKeycloakTest {
}
}
@Test
public void testLoginPageRefresh() {
try (BrowserTabUtil tabUtil = BrowserTabUtil.getInstanceAndSetEnv(driver)) {
assertThat(tabUtil.getCountOfTabs(), Matchers.is(1));
oauth.openLoginForm();
loginPage.assertCurrent();
getLogger().info("URL in tab1: " + driver.getCurrentUrl());
//delete cookie to be recreated in tab 2
driver.manage().deleteCookieNamed("AUTH_SESSION_ID");
// Open new tab 2
tabUtil.newTab(oauth.getLoginFormUrl());
assertThat(tabUtil.getCountOfTabs(), Matchers.equalTo(2));
loginPage.assertCurrent();
getLogger().info("URL in tab2: " + driver.getCurrentUrl());
tabUtil.switchToTab(0);
loginPage.assertCurrent();
//wait for the refresh in the first tab
WaitUtils.pause(2000);
if (driver instanceof HtmlUnitDriver) {
// authChecker.js javascript does not work with HtmlUnitDriver. So need to "refresh" the current browser tab by running the last action in order to simulate "already_logged_in"
// error and being redirected to client
driver.navigate().refresh();
}
loginSuccessAndDoRequiredActions();
tabUtil.switchToTab(1);
waitForAppPage(() -> loginPage.login("login-test", "password"));
}
}
private void waitForAppPage(Runnable htmlUnitAction) {
if (driver instanceof HtmlUnitDriver) {
// authChecker.js javascript does not work with HtmlUnitDriver. So need to "refresh" the current browser tab by running the last action in order to simulate "already_logged_in"

View File

@ -1,4 +1,5 @@
const CHECK_INTERVAL_MILLISECS = 2000;
const AUTH_SESSION_TIMEOUT_MILLISECS = 1000;
const initialSession = getSession();
let timeout;
@ -32,6 +33,19 @@ export function checkCookiesAndSetTimer(loginRestartUrl) {
}
}
export function checkAuthSession(pageAuthSessionHash) {
setTimeout(() => {
const cookieAuthSessionHash = getKcAuthSessionHash();
if (cookieAuthSessionHash !== pageAuthSessionHash) {
location.reload();
}
}, AUTH_SESSION_TIMEOUT_MILLISECS);
}
function getKcAuthSessionHash() {
return getCookieByName("KC_AUTH_SESSION_HASH");
}
function getSession() {
return getCookieByName("KEYCLOAK_SESSION");
}

View File

@ -50,6 +50,15 @@
"${url.ssoLoginInOtherTabsUrl?no_esc}"
);
</script>
<#if authenticationSession??>
<script type="module">
import { checkAuthSession } from "${url.resourcesPath}/js/authChecker.js";
checkAuthSession(
"${authenticationSession.authSessionIdHash}"
);
</script>
</#if>
</head>
<body class="${properties.kcBodyClass!}">

View File

@ -92,6 +92,15 @@
"${url.ssoLoginInOtherTabsUrl?no_esc}"
);
</script>
<#if authenticationSession??>
<script type="module">
import { checkAuthSession } from "${url.resourcesPath}/js/authChecker.js";
checkAuthSession(
"${authenticationSession.authSessionIdHash}"
);
</script>
</#if>
<script>
// Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1404468
const isFirefox;