mirror of
https://github.com/keycloak/keycloak.git
synced 2026-01-08 14:32:05 -03:30
Add ability to delete offline sessions via account console
Closes #15502 Signed-off-by: Robin Meese <39960884+robson90@users.noreply.github.com> Signed-off-by: Alexander Schwartz <alexander.schwartz@gmx.net> Co-authored-by: Alexander Schwartz <alexander.schwartz@gmx.net>
This commit is contained in:
parent
4349f8ee6a
commit
0d0d468f27
@ -59,6 +59,11 @@ Additionally, both `IdentityProviderModel` and `IdentityProviderRepresentation`
|
||||
configuration like `isHideOnLogin` to be null in order to not include these in Identity Provider types that do
|
||||
not need these configurations.
|
||||
|
||||
=== Account UI shows and manages offline sessions
|
||||
|
||||
The Account UI and its REST API now shows offline sessions of a user together with regular sessions in the *Device Activity* tab.
|
||||
A user can log out of those sessions like any other session, including the option *Sign out all devices*.
|
||||
|
||||
=== Realm default locale attempted for translation fallbacks
|
||||
|
||||
When localization for a realm is enabled and a translation for a message key is unavailable for the language the user selected, {project_name} now attempts to find a matching message key with the realm's default locale before defaulting to English.
|
||||
|
||||
@ -20,6 +20,8 @@ import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@ -41,6 +43,7 @@ import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.models.utils.SessionExpirationUtils;
|
||||
import org.keycloak.representations.account.ClientRepresentation;
|
||||
import org.keycloak.representations.account.DeviceRepresentation;
|
||||
import org.keycloak.representations.account.SessionRepresentation;
|
||||
@ -91,7 +94,15 @@ public class SessionResource {
|
||||
@NoCache
|
||||
public Collection<DeviceRepresentation> devices() {
|
||||
Map<String, DeviceRepresentation> reps = new HashMap<>();
|
||||
session.sessions().getUserSessionsStream(realm, user).forEach(s -> {
|
||||
|
||||
// While we avoid it, there can be both an online and an offline session with the same ID.
|
||||
// The user wouldn't know the difference between online and offline sessions, so we don't differentiate between them
|
||||
// in the UI.
|
||||
|
||||
Stream.concat(
|
||||
session.sessions().getUserSessionsStream(realm, user),
|
||||
session.sessions().getOfflineUserSessionsStream(realm, user))
|
||||
.forEach(s -> {
|
||||
DeviceRepresentation device = getAttachedDevice(s);
|
||||
DeviceRepresentation rep = reps
|
||||
.computeIfAbsent(device.getOs() + device.getOsVersion(), key -> {
|
||||
@ -131,16 +142,19 @@ public class SessionResource {
|
||||
@NoCache
|
||||
public Response logout(@QueryParam("current") boolean removeCurrent) {
|
||||
auth.require(AccountRoles.MANAGE_ACCOUNT);
|
||||
session.sessions().getUserSessionsStream(realm, user).filter(s -> removeCurrent || !isCurrentSession(s))
|
||||
.collect(Collectors.toList()) // collect to avoid concurrent modification as backchannelLogout removes the user sessions.
|
||||
.forEach(s -> {
|
||||
AuthenticationManager.backchannelLogout(session, s, true);
|
||||
event.clone()
|
||||
.event(EventType.LOGOUT)
|
||||
.user(user)
|
||||
.session(s)
|
||||
.success();
|
||||
});
|
||||
Stream.concat(
|
||||
session.sessions().getUserSessionsStream(realm, user),
|
||||
session.sessions().getOfflineUserSessionsStream(realm, user))
|
||||
.filter(s -> removeCurrent || !isCurrentSession(s))
|
||||
.collect(Collectors.toList()) // collect to avoid concurrent modification as backchannelLogout removes the user sessions.
|
||||
.forEach(s -> {
|
||||
AuthenticationManager.backchannelLogout(session, s, true);
|
||||
event.clone()
|
||||
.event(EventType.LOGOUT)
|
||||
.user(user)
|
||||
.session(s)
|
||||
.success();
|
||||
});
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@ -156,14 +170,21 @@ public class SessionResource {
|
||||
@NoCache
|
||||
public Response logout(@PathParam("id") String id) {
|
||||
auth.require(AccountRoles.MANAGE_ACCOUNT);
|
||||
UserSessionModel userSession = session.sessions().getUserSession(realm, id);
|
||||
if (userSession != null && userSession.getUser().equals(user)) {
|
||||
AuthenticationManager.backchannelLogout(session, userSession, true);
|
||||
event.event(EventType.LOGOUT)
|
||||
.user(user)
|
||||
.session(id)
|
||||
.success();
|
||||
}
|
||||
|
||||
// While we avoid it, there can be both an online and an offline session with the same ID.
|
||||
// As those have been created from the same device, it is OK to log out both of them.
|
||||
Stream.concat(
|
||||
Stream.ofNullable(session.sessions().getUserSession(realm, id)),
|
||||
Stream.ofNullable(session.sessions().getOfflineUserSession(realm, id))).
|
||||
filter(userSession -> userSession.getUser().equals(user))
|
||||
.forEach(userSession -> {
|
||||
AuthenticationManager.backchannelLogout(session, userSession, true);
|
||||
event.event(EventType.LOGOUT)
|
||||
.user(user)
|
||||
.session(id)
|
||||
.success();
|
||||
});
|
||||
|
||||
return Response.noContent().build();
|
||||
}
|
||||
|
||||
@ -174,10 +195,20 @@ public class SessionResource {
|
||||
sessionRep.setIpAddress(s.getIpAddress());
|
||||
sessionRep.setStarted(s.getStarted());
|
||||
sessionRep.setLastAccess(s.getLastSessionRefresh());
|
||||
int maxLifespan = s.isRememberMe() && realm.getSsoSessionMaxLifespanRememberMe() > 0
|
||||
? realm.getSsoSessionMaxLifespanRememberMe() : realm.getSsoSessionMaxLifespan();
|
||||
int expires = s.getStarted() + maxLifespan;
|
||||
sessionRep.setExpires(expires);
|
||||
long expires = SessionExpirationUtils.calculateUserSessionMaxLifespanTimestamp(
|
||||
s.isOffline(),
|
||||
s.isRememberMe(),
|
||||
TimeUnit.SECONDS.toMillis(s.getStarted()),
|
||||
realm);
|
||||
if (expires == -1) {
|
||||
// Offline sessions can have no expiry time. If that is the case, use the idle timestamp instead
|
||||
expires = SessionExpirationUtils.calculateUserSessionIdleTimestamp(
|
||||
s.isOffline(),
|
||||
s.isRememberMe(),
|
||||
TimeUnit.SECONDS.toMillis(s.getStarted()),
|
||||
realm);
|
||||
}
|
||||
sessionRep.setExpires((int) TimeUnit.MILLISECONDS.toSeconds(expires));
|
||||
sessionRep.setBrowser(device.getBrowser());
|
||||
|
||||
if (isCurrentSession(s)) {
|
||||
@ -211,7 +242,7 @@ public class SessionResource {
|
||||
|
||||
private boolean isCurrentSession(UserSessionModel session) {
|
||||
if (auth.getSession() == null) return false;
|
||||
return session.getId().equals(auth.getSession().getId());
|
||||
return session.getId().equals(auth.getSession().getId()) && Objects.equals(session.isOffline(), auth.getSession().isOffline());
|
||||
}
|
||||
|
||||
private SessionRepresentation toRepresentation(UserSessionModel s) {
|
||||
|
||||
@ -87,7 +87,7 @@ public abstract class AbstractRestServiceTest extends AbstractTestRealmKeycloakT
|
||||
testRealm.getUsers().add(UserBuilder.create().username("view-applications-access").addRoles("user", "offline_access").role("account", "view-applications").role("account", "manage-consent").password("password").build());
|
||||
testRealm.getUsers().add(UserBuilder.create().username("view-consent-access").role("account", "view-consent").password("password").build());
|
||||
testRealm.getUsers().add(UserBuilder.create().username("manage-consent-access").role("account", "manage-consent").role("account", "view-profile").password("password").build());
|
||||
testRealm.getUsers().add(UserBuilder.create().username("manage-account-access").role("account", "view-profile").role("account", "manage-account").password("password").build());
|
||||
testRealm.getUsers().add(UserBuilder.create().username("manage-account-access").role("account", "view-profile").role("account", "manage-account").addRoles("user", "offline_access").password("password").build());
|
||||
|
||||
org.keycloak.representations.idm.ClientRepresentation inUseApp = ClientBuilder.create().clientId("in-use-client")
|
||||
.id(KeycloakModelUtils.generateId())
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
package org.keycloak.testsuite.account;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
@ -61,6 +62,7 @@ import org.keycloak.representations.AccessToken;
|
||||
import org.keycloak.representations.account.ClientRepresentation;
|
||||
import org.keycloak.representations.account.ConsentRepresentation;
|
||||
import org.keycloak.representations.account.ConsentScopeRepresentation;
|
||||
import org.keycloak.representations.account.DeviceRepresentation;
|
||||
import org.keycloak.representations.account.SessionRepresentation;
|
||||
import org.keycloak.representations.account.UserRepresentation;
|
||||
import org.keycloak.representations.idm.AuthenticationExecutionInfoRepresentation;
|
||||
@ -1243,6 +1245,102 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
|
||||
events.assertEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testListingAllSignedInDevicesEvenOfflineSessionsThenTerminatingAllSessions() throws IOException, JWSInputException {
|
||||
String username = "manage-account-access";
|
||||
String password = "password";
|
||||
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), username);
|
||||
// first direct access grant login
|
||||
String firstToken = new TokenUtil(username, password).getToken();
|
||||
events.expect(EventType.LOGIN)
|
||||
.client("direct-grant")
|
||||
.user(user.toRepresentation().getId())
|
||||
.session(new JWSInput(firstToken).readJsonContent(AccessToken.class).getSessionId())
|
||||
.detail(Details.SCOPE, "openid profile email")
|
||||
.assertEvent();
|
||||
|
||||
// second direct access grant login
|
||||
String secondToken = new TokenUtil(username, password).getToken();
|
||||
events.expect(EventType.LOGIN)
|
||||
.client("direct-grant")
|
||||
.user(user.toRepresentation().getId())
|
||||
.session(new JWSInput(secondToken).readJsonContent(AccessToken.class).getSessionId())
|
||||
.detail(Details.SCOPE, "openid profile email")
|
||||
.assertEvent();
|
||||
|
||||
// Login with scope 'offline_access'
|
||||
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
|
||||
oauth.client("offline-client", "secret1");
|
||||
AccessTokenResponse offlineTokenResponse = oauth.doPasswordGrantRequest(username, password);
|
||||
assertNull(offlineTokenResponse.getErrorDescription());
|
||||
events.expect(EventType.LOGIN)
|
||||
.client("offline-client")
|
||||
.user(user.toRepresentation().getId())
|
||||
.session(offlineTokenResponse.getSessionState())
|
||||
.detail(Details.SCOPE, "openid email profile offline_access")
|
||||
.assertEvent();
|
||||
|
||||
// Get all logged in 'devices'
|
||||
Collection<DeviceRepresentation> devices = SimpleHttpDefault
|
||||
.doGet(getAccountUrl("sessions/devices"), httpClient)
|
||||
.header("Accept", "application/json")
|
||||
.auth(firstToken)
|
||||
.asJson(new TypeReference<Collection<DeviceRepresentation>>() {
|
||||
});
|
||||
assertFalse(devices.isEmpty());
|
||||
List<SessionRepresentation> allSessions = devices.stream().flatMap(device -> device.getSessions().stream()).toList();
|
||||
assertEquals(3, allSessions.size());
|
||||
|
||||
// User deletes all of his sessions
|
||||
int status = SimpleHttpDefault.doDelete(getAccountUrl("sessions?current=true"), httpClient)
|
||||
.acceptJson().auth(firstToken).asStatus();
|
||||
assertEquals(204, status);
|
||||
|
||||
allSessions.forEach(session -> {
|
||||
events.expectAccount(EventType.LOGOUT)
|
||||
.user(user.toRepresentation().getId())
|
||||
.session(session.getId())
|
||||
.assertEvent();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDeletionOfOfflineSessionWillFireLogoutEvent() throws IOException, JWSInputException {
|
||||
String username = "manage-account-access";
|
||||
String password = "password";
|
||||
String firstToken = new TokenUtil(username, password).getToken();
|
||||
|
||||
oauth.scope(OAuth2Constants.OFFLINE_ACCESS);
|
||||
oauth.client("offline-client", "secret1");
|
||||
AccessTokenResponse offlineTokenResponse = oauth.doPasswordGrantRequest(username, password);
|
||||
assertNull(offlineTokenResponse.getErrorDescription());
|
||||
|
||||
UserResource user = ApiUtil.findUserByUsernameId(testRealm(), username);
|
||||
Collection<DeviceRepresentation> devices = SimpleHttpDefault
|
||||
.doGet(getAccountUrl("sessions/devices"), httpClient)
|
||||
.header("Accept", "application/json")
|
||||
.auth(firstToken)
|
||||
.asJson(new TypeReference<Collection<DeviceRepresentation>>() {
|
||||
});
|
||||
assertEquals(2, devices.stream().flatMap(device -> device.getSessions().stream()).toList().size());
|
||||
|
||||
// skip direct access login and offline_access scoped login
|
||||
events.poll();
|
||||
events.poll();
|
||||
|
||||
int status = SimpleHttpDefault.doDelete(getAccountUrl(String.format("sessions/%s", offlineTokenResponse.getSessionState())), httpClient)
|
||||
.acceptJson().auth(firstToken).asStatus();
|
||||
assertEquals(204, status);
|
||||
|
||||
events.expectAccount(EventType.LOGOUT)
|
||||
.user(user.toRepresentation().getId())
|
||||
.session(offlineTokenResponse.getSessionState())
|
||||
.assertEvent();
|
||||
|
||||
events.assertEmpty();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
public void listApplications() throws Exception {
|
||||
oauth.client("in-use-client", "secret1");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user