diff --git a/core/src/main/java/org/keycloak/representations/account/DeviceRepresentation.java b/core/src/main/java/org/keycloak/representations/account/DeviceRepresentation.java
new file mode 100644
index 0000000000..9ec8b370d4
--- /dev/null
+++ b/core/src/main/java/org/keycloak/representations/account/DeviceRepresentation.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2019 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.representations.account;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * @author Pedro Igor
+ */
+public class DeviceRepresentation {
+
+ public static final String UNKNOWN = "Unknown";
+ private static final String OTHER = "Other";
+ private static final String BROWSER_VERSION_SEPARATOR = "/";
+
+ public static DeviceRepresentation unknown() {
+ DeviceRepresentation device = new DeviceRepresentation();
+
+ device.setOs(OTHER);
+ device.setDevice(OTHER);
+
+ return device;
+ }
+
+ private String id;
+ private String ipAddress;
+ private String os;
+ private String osVersion;
+ private String browser;
+ private String device;
+ private int lastAccess;
+ private Boolean current;
+ private List sessions;
+ private boolean mobile;
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public String getIpAddress() {
+ return ipAddress;
+ }
+
+ public void setIpAddress(String ip) {
+ this.ipAddress = ip;
+ }
+
+ public String getOs() {
+ return os;
+ }
+
+ public void setOs(String os) {
+ this.os = os;
+ }
+
+ public String getOsVersion() {
+ if (osVersion == null) {
+ return UNKNOWN;
+ }
+ return osVersion;
+ }
+
+ public void setOsVersion(String osVersion) {
+ this.osVersion = osVersion;
+ }
+
+ public String getBrowser() {
+ return browser;
+ }
+
+ public void setBrowser(String browser) {
+ this.browser = browser;
+ }
+
+ public void setBrowser(String browser, String version) {
+ if (browser == null) {
+ this.browser = OTHER;
+ } else {
+ this.browser = new StringBuilder(browser).append(BROWSER_VERSION_SEPARATOR).append(version == null ? UNKNOWN : version).toString();
+ }
+ }
+
+ public String getDevice() {
+ return device;
+ }
+
+ public void setDevice(String device) {
+ this.device = device;
+ }
+
+ public int getLastAccess() {
+ return lastAccess;
+ }
+
+ public void setLastAccess(int lastAccess) {
+ this.lastAccess = lastAccess;
+ }
+
+ public Boolean getCurrent() {
+ return current;
+ }
+
+ public void setCurrent(Boolean current) {
+ this.current = current;
+ }
+
+ public void addSession(SessionRepresentation sessionRep) {
+ if (this.sessions == null) {
+ this.sessions = new ArrayList<>();
+ }
+ this.sessions.add(sessionRep);
+ }
+
+ public List getSessions() {
+ return sessions;
+ }
+
+ public void setMobile(boolean mobile) {
+ this.mobile = mobile;
+ }
+
+ public boolean isMobile() {
+ return mobile;
+ }
+}
diff --git a/core/src/main/java/org/keycloak/representations/account/SessionRepresentation.java b/core/src/main/java/org/keycloak/representations/account/SessionRepresentation.java
index 8e4d96fbd2..996c85226c 100644
--- a/core/src/main/java/org/keycloak/representations/account/SessionRepresentation.java
+++ b/core/src/main/java/org/keycloak/representations/account/SessionRepresentation.java
@@ -13,6 +13,8 @@ public class SessionRepresentation {
private int lastAccess;
private int expires;
private List clients;
+ private String browser;
+ private Boolean current;
public String getId() {
return id;
@@ -61,4 +63,20 @@ public class SessionRepresentation {
public void setClients(List clients) {
this.clients = clients;
}
+
+ public void setBrowser(String browser) {
+ this.browser = browser;
+ }
+
+ public String getBrowser() {
+ return browser;
+ }
+
+ public Boolean getCurrent() {
+ return current;
+ }
+
+ public void setCurrent(Boolean current) {
+ this.current = current;
+ }
}
diff --git a/distribution/feature-packs/server-feature-pack/pom.xml b/distribution/feature-packs/server-feature-pack/pom.xml
index 908e5a4361..f6763d93bc 100644
--- a/distribution/feature-packs/server-feature-pack/pom.xml
+++ b/distribution/feature-packs/server-feature-pack/pom.xml
@@ -52,6 +52,16 @@
+
+ com.github.ua-parser
+ uap-java
+
+
+ *
+ *
+
+
+
com.google.zxing
core
diff --git a/distribution/feature-packs/server-feature-pack/src/main/resources/modules/system/layers/keycloak/com/github/ua-parser/main/module.xml b/distribution/feature-packs/server-feature-pack/src/main/resources/modules/system/layers/keycloak/com/github/ua-parser/main/module.xml
new file mode 100644
index 0000000000..1cc14b728c
--- /dev/null
+++ b/distribution/feature-packs/server-feature-pack/src/main/resources/modules/system/layers/keycloak/com/github/ua-parser/main/module.xml
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/distribution/feature-packs/server-feature-pack/src/main/resources/modules/system/layers/keycloak/org/keycloak/keycloak-server-spi-private/main/module.xml b/distribution/feature-packs/server-feature-pack/src/main/resources/modules/system/layers/keycloak/org/keycloak/keycloak-server-spi-private/main/module.xml
index 978718bcef..fbc61bb816 100755
--- a/distribution/feature-packs/server-feature-pack/src/main/resources/modules/system/layers/keycloak/org/keycloak/keycloak-server-spi-private/main/module.xml
+++ b/distribution/feature-packs/server-feature-pack/src/main/resources/modules/system/layers/keycloak/org/keycloak/keycloak-server-spi-private/main/module.xml
@@ -39,5 +39,6 @@
+
diff --git a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java
index e2eb65b681..5b4fdd2c77 100755
--- a/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java
+++ b/model/infinispan/src/main/java/org/keycloak/models/sessions/infinispan/InfinispanUserSessionProvider.java
@@ -24,9 +24,9 @@ import org.infinispan.context.Flag;
import org.infinispan.stream.CacheCollectors;
import org.jboss.logging.Logger;
import org.keycloak.cluster.ClusterProvider;
-import org.keycloak.common.util.ObjectUtil;
import org.keycloak.common.util.Retry;
import org.keycloak.common.util.Time;
+import org.keycloak.device.DeviceActivityManager;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
@@ -36,7 +36,6 @@ import org.keycloak.models.UserLoginFailureModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.UserSessionProvider;
-import org.keycloak.models.session.UserSessionPersisterProvider;
import org.keycloak.models.sessions.infinispan.changes.Tasks;
import org.keycloak.models.sessions.infinispan.changes.sessions.CrossDCLastSessionRefreshStore;
import org.keycloak.models.sessions.infinispan.changes.sessions.PersisterLastSessionRefreshStore;
@@ -75,7 +74,6 @@ import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.function.Function;
@@ -218,7 +216,13 @@ public class InfinispanUserSessionProvider implements UserSessionProvider {
SessionUpdateTask createSessionTask = Tasks.addIfAbsentSync();
sessionTx.addTask(id, createSessionTask, entity);
- return wrap(realm, entity, false);
+ UserSessionAdapter adapter = wrap(realm, entity, false);
+
+ if (adapter != null) {
+ DeviceActivityManager.attachDevice(adapter, session);
+ }
+
+ return adapter;
}
void updateSessionEntity(UserSessionEntity entity, RealmModel realm, UserModel user, String loginUsername, String ipAddress, String authMethod, boolean rememberMe, String brokerSessionId, String brokerUserId) {
diff --git a/pom.xml b/pom.xml
index 77955302fe..d06a5ad04d 100755
--- a/pom.xml
+++ b/pom.xml
@@ -95,6 +95,7 @@
2.1.3
1.1.2
1.5.1.Final
+ 1.4.3
5.0.3.Final
25.0-jre
@@ -271,6 +272,11 @@
bcpkix-jdk15on
${bouncycastle.version}
+
+ com.github.ua-parser
+ uap-java
+ ${ua-parser.version}
+
javax.mail
javax.mail-api
diff --git a/server-spi-private/pom.xml b/server-spi-private/pom.xml
index b9de3ce6b5..a7b17da2a2 100755
--- a/server-spi-private/pom.xml
+++ b/server-spi-private/pom.xml
@@ -76,6 +76,10 @@
guava
provided
+
+ com.github.ua-parser
+ uap-java
+
junit
junit
diff --git a/server-spi-private/src/main/java/org/keycloak/device/DeviceActivityManager.java b/server-spi-private/src/main/java/org/keycloak/device/DeviceActivityManager.java
new file mode 100644
index 0000000000..02148e6c36
--- /dev/null
+++ b/server-spi-private/src/main/java/org/keycloak/device/DeviceActivityManager.java
@@ -0,0 +1,153 @@
+/*
+ * Copyright 2019 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.device;
+
+import javax.ws.rs.core.HttpHeaders;
+import java.io.IOException;
+
+import org.jboss.logging.Logger;
+import org.keycloak.common.Profile;
+import org.keycloak.common.util.Base64;
+import org.keycloak.models.KeycloakContext;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.UserSessionModel;
+import org.keycloak.representations.account.DeviceRepresentation;
+import org.keycloak.util.JsonSerialization;
+import ua_parser.Client;
+import ua_parser.Parser;
+
+/**
+ * @author Pedro Igor
+ */
+public class DeviceActivityManager {
+
+ private static final String DEVICE_NOTE = "KC_DEVICE_NOTE";
+ private static final Logger logger = Logger.getLogger(DeviceActivityManager.class);
+ private static final int USER_AGENT_MAX_LENGTH = 512;
+ private static final Parser UA_PARSER;
+
+ static {
+ try {
+ UA_PARSER = new Parser();
+ } catch (IOException cause) {
+ throw new RuntimeException("Failed to create user agent parser", cause);
+ }
+ }
+
+ /** Returns the device information associated with the given {@code userSession}.
+ *
+ *
+ * @param userSession the userSession
+ * @return the device information or null if no device is attached to the user session
+ */
+ public static DeviceRepresentation getCurrentDevice(UserSessionModel userSession) {
+ String deviceInfo = userSession.getNote(DEVICE_NOTE);
+
+ if (deviceInfo == null) {
+ return null;
+ }
+
+ try {
+ return JsonSerialization.readValue(Base64.decode(deviceInfo), DeviceRepresentation.class);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Attaches a device to the given {@code userSession} where the device information is obtained from the {@link HttpHeaders#USER_AGENT} in the current
+ * request, if available.
+ *
+ * @param userSession the user session
+ * @param session the keycloak session
+ */
+ public static void attachDevice(UserSessionModel userSession, KeycloakSession session) {
+ DeviceRepresentation current = getDeviceFromUserAgent(session);
+
+ if (current != null) {
+ try {
+ userSession.setNote(DEVICE_NOTE, Base64.encodeBytes(JsonSerialization.writeValueAsBytes(current)));
+ } catch (IOException cause) {
+ throw new RuntimeException(cause);
+ }
+ }
+ }
+
+ private static DeviceRepresentation getDeviceFromUserAgent(KeycloakSession session) {
+ KeycloakContext context = session.getContext();
+ String userAgent = context.getRequestHeaders().getHeaderString(HttpHeaders.USER_AGENT);
+
+ if (userAgent == null) {
+ return null;
+ }
+
+ if (userAgent.length() > USER_AGENT_MAX_LENGTH) {
+ logger.warn("Ignoring User-Agent header. Length is above the permitted: " + USER_AGENT_MAX_LENGTH);
+ return null;
+ }
+
+ DeviceRepresentation current;
+
+ try {
+ Client client = UA_PARSER.parse(userAgent);
+ current = new DeviceRepresentation();
+
+ current.setDevice(client.device.family);
+
+ String browserVersion = client.userAgent.major;
+
+ if (client.userAgent.minor != null) {
+ browserVersion += "." + client.userAgent.minor;
+ }
+
+ if (client.userAgent.patch != null) {
+ browserVersion += "." + client.userAgent.patch;
+ }
+
+ if (browserVersion == null) {
+ browserVersion = DeviceRepresentation.UNKNOWN;
+ }
+
+ current.setBrowser(client.userAgent.family, browserVersion);
+ current.setOs(client.os.family);
+
+ String osVersion = client.os.major;
+
+ if (client.os.minor != null) {
+ osVersion += "." + client.os.minor;
+ }
+
+ if (client.os.patch != null) {
+ osVersion += "." + client.os.patch;
+ }
+
+ if (client.os.patchMinor != null) {
+ osVersion += "." + client.os.patchMinor;
+ }
+
+ current.setOsVersion(osVersion);
+ current.setIpAddress(context.getConnection().getRemoteAddr());
+ current.setMobile(userAgent.toLowerCase().contains("mobile"));
+ } catch (Exception cause) {
+ logger.error("Failed to create device info from user agent header", cause);
+ return null;
+ }
+
+ return current;
+ }
+}
diff --git a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java
index 92994a89e0..37db2bb6d4 100755
--- a/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java
+++ b/services/src/main/java/org/keycloak/services/resources/account/AccountRestService.java
@@ -19,7 +19,6 @@ package org.keycloak.services.resources.account;
import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.common.ClientConnection;
-import org.keycloak.common.Profile;
import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventStoreProvider;
@@ -31,20 +30,16 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserConsentModel;
import org.keycloak.models.UserModel;
-import org.keycloak.models.UserSessionModel;
import org.keycloak.representations.account.ClientRepresentation;
import org.keycloak.representations.account.ConsentRepresentation;
import org.keycloak.representations.account.ConsentScopeRepresentation;
-import org.keycloak.representations.account.SessionRepresentation;
import org.keycloak.representations.account.UserRepresentation;
import org.keycloak.services.ErrorResponse;
import org.keycloak.services.managers.Auth;
-import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.Cors;
import org.keycloak.services.resources.account.resources.ResourcesService;
import org.keycloak.storage.ReadOnlyException;
-import org.keycloak.theme.Theme;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
@@ -56,19 +51,20 @@ import javax.ws.rs.PUT;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
-import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.IOException;
-import java.util.LinkedList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Properties;
import java.util.stream.Collectors;
+import org.keycloak.common.Profile;
+import org.keycloak.theme.Theme;
+
/**
* @author Stian Thorgersen
*/
@@ -224,84 +220,10 @@ public class AccountRestService {
* @return
*/
@Path("/sessions")
- @GET
- @Produces(MediaType.APPLICATION_JSON)
- @NoCache
- public Response sessions() {
+ public SessionResource sessions() {
checkAccountApiEnabled();
auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE);
-
- List reps = new LinkedList<>();
-
- List sessions = session.sessions().getUserSessions(realm, user);
- for (UserSessionModel s : sessions) {
- SessionRepresentation rep = new SessionRepresentation();
- rep.setId(s.getId());
- rep.setIpAddress(s.getIpAddress());
- rep.setStarted(s.getStarted());
- rep.setLastAccess(s.getLastSessionRefresh());
- rep.setExpires(s.getStarted() + realm.getSsoSessionMaxLifespan());
- rep.setClients(new LinkedList());
-
- for (String clientUUID : s.getAuthenticatedClientSessions().keySet()) {
- ClientModel client = realm.getClientById(clientUUID);
- ClientRepresentation clientRep = new ClientRepresentation();
- clientRep.setClientId(client.getClientId());
- clientRep.setClientName(client.getName());
- rep.getClients().add(clientRep);
- }
-
- reps.add(rep);
- }
-
- return Cors.add(request, Response.ok(reps)).auth().allowedOrigins(auth.getToken()).build();
- }
-
- /**
- * Remove sessions
- *
- * @param removeCurrent remove current session (default is false)
- * @return
- */
- @Path("/sessions")
- @DELETE
- @Produces(MediaType.APPLICATION_JSON)
- @NoCache
- public Response sessionsLogout(@QueryParam("current") boolean removeCurrent) {
- checkAccountApiEnabled();
- auth.require(AccountRoles.MANAGE_ACCOUNT);
-
- UserSessionModel userSession = auth.getSession();
-
- List userSessions = session.sessions().getUserSessions(realm, user);
- for (UserSessionModel s : userSessions) {
- if (removeCurrent || !s.getId().equals(userSession.getId())) {
- AuthenticationManager.backchannelLogout(session, s, true);
- }
- }
-
- return Cors.add(request, Response.ok()).auth().allowedOrigins(auth.getToken()).build();
- }
-
- /**
- * Remove a specific session
- *
- * @param id a specific session to remove
- * @return
- */
- @Path("/session")
- @DELETE
- @Produces(MediaType.APPLICATION_JSON)
- @NoCache
- public Response sessionLogout(@QueryParam("id") String id) {
- checkAccountApiEnabled();
- auth.require(AccountRoles.MANAGE_ACCOUNT);
-
- UserSessionModel userSession = session.sessions().getUserSession(realm, id);
- if (userSession != null && userSession.getUser().equals(user)) {
- AuthenticationManager.backchannelLogout(session, userSession, true);
- }
- return Cors.add(request, Response.ok()).auth().allowedOrigins(auth.getToken()).build();
+ return new SessionResource(session, auth, request);
}
@Path("/credentials")
diff --git a/services/src/main/java/org/keycloak/services/resources/account/SessionResource.java b/services/src/main/java/org/keycloak/services/resources/account/SessionResource.java
new file mode 100755
index 0000000000..56cee3fde9
--- /dev/null
+++ b/services/src/main/java/org/keycloak/services/resources/account/SessionResource.java
@@ -0,0 +1,208 @@
+/*
+ * Copyright 2019 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.services.resources.account;
+
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
+import javax.ws.rs.core.MediaType;
+import javax.ws.rs.core.Response;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.jboss.resteasy.annotations.cache.NoCache;
+import org.jboss.resteasy.spi.HttpRequest;
+import org.keycloak.device.DeviceActivityManager;
+import org.keycloak.models.AccountRoles;
+import org.keycloak.models.ClientModel;
+import org.keycloak.models.KeycloakSession;
+import org.keycloak.models.RealmModel;
+import org.keycloak.models.UserModel;
+import org.keycloak.models.UserSessionModel;
+import org.keycloak.representations.account.ClientRepresentation;
+import org.keycloak.representations.account.DeviceRepresentation;
+import org.keycloak.representations.account.SessionRepresentation;
+import org.keycloak.services.managers.Auth;
+import org.keycloak.services.managers.AuthenticationManager;
+import org.keycloak.services.resources.Cors;
+
+/**
+ * @author Pedro Igor
+ */
+public class SessionResource {
+
+ private final KeycloakSession session;
+ private final Auth auth;
+ private final RealmModel realm;
+ private final UserModel user;
+ private HttpRequest request;
+
+ public SessionResource(KeycloakSession session, Auth auth, HttpRequest request) {
+ this.session = session;
+ this.auth = auth;
+ this.realm = auth.getRealm();
+ this.user = auth.getUser();
+ this.request = request;
+ }
+
+ /**
+ * Get session information.
+ *
+ * @return
+ */
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ @NoCache
+ public Response toRepresentation() {
+ return Cors.add(request, Response.ok(session.sessions().getUserSessions(realm, user).stream()
+ .map(this::toRepresentation).collect(Collectors.toList()))).auth().allowedOrigins(auth.getToken()).build();
+ }
+
+ /**
+ * Get device activity information based on the active sessions.
+ *
+ * @return
+ */
+ @Path("devices")
+ @GET
+ @Produces(MediaType.APPLICATION_JSON)
+ @NoCache
+ public Response devices() {
+ Map reps = new HashMap<>();
+ List sessions = session.sessions().getUserSessions(realm, user);
+
+ for (UserSessionModel s : sessions) {
+ DeviceRepresentation device = getAttachedDevice(s);
+ DeviceRepresentation rep = reps
+ .computeIfAbsent(device.getOs() + device.getOsVersion(), key -> {
+ DeviceRepresentation representation = new DeviceRepresentation();
+
+ representation.setLastAccess(device.getLastAccess());
+ representation.setOs(device.getOs());
+ representation.setOsVersion(device.getOsVersion());
+ representation.setDevice(device.getDevice());
+ representation.setMobile(device.isMobile());
+
+ return representation;
+ });
+
+ if (isCurrentSession(s)) {
+ rep.setCurrent(true);
+ }
+
+ if (rep.getLastAccess() == 0 || rep.getLastAccess() < s.getLastSessionRefresh()) {
+ rep.setLastAccess(s.getLastSessionRefresh());
+ }
+
+ rep.addSession(createSessionRepresentation(s, device));
+ }
+
+ return Cors.add(request, Response.ok(reps.values())).auth().allowedOrigins(auth.getToken()).build();
+ }
+
+ /**
+ * Remove sessions
+ *
+ * @param removeCurrent remove current session (default is false)
+ * @return
+ */
+ @DELETE
+ @Produces(MediaType.APPLICATION_JSON)
+ @NoCache
+ public Response logout(@QueryParam("current") boolean removeCurrent) {
+ auth.require(AccountRoles.MANAGE_ACCOUNT);
+ List userSessions = session.sessions().getUserSessions(realm, user);
+
+ for (UserSessionModel s : userSessions) {
+ if (removeCurrent || !isCurrentSession(s)) {
+ AuthenticationManager.backchannelLogout(session, s, true);
+ }
+ }
+
+ return Cors.add(request, Response.noContent()).auth().allowedOrigins(auth.getToken()).build();
+ }
+
+ /**
+ * Remove a specific session
+ *
+ * @param id a specific session to remove
+ * @return
+ */
+ @Path("/{id}")
+ @DELETE
+ @Produces(MediaType.APPLICATION_JSON)
+ @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);
+ }
+ return Cors.add(request, Response.noContent()).auth().allowedOrigins(auth.getToken()).build();
+ }
+
+ private SessionRepresentation createSessionRepresentation(UserSessionModel s, DeviceRepresentation device) {
+ SessionRepresentation sessionRep = new SessionRepresentation();
+
+ sessionRep.setId(s.getId());
+ sessionRep.setIpAddress(s.getIpAddress());
+ sessionRep.setStarted(s.getStarted());
+ sessionRep.setLastAccess(s.getLastSessionRefresh());
+ sessionRep.setExpires(s.getStarted() + realm.getSsoSessionMaxLifespan());
+ sessionRep.setBrowser(device.getBrowser());
+
+ if (isCurrentSession(s)) {
+ sessionRep.setCurrent(true);
+ }
+
+ sessionRep.setClients(new LinkedList());
+
+ for (String clientUUID : s.getAuthenticatedClientSessions().keySet()) {
+ ClientModel client = realm.getClientById(clientUUID);
+ ClientRepresentation clientRep = new ClientRepresentation();
+ clientRep.setClientId(client.getClientId());
+ clientRep.setClientName(client.getName());
+ sessionRep.getClients().add(clientRep);
+ }
+ return sessionRep;
+ }
+
+ private DeviceRepresentation getAttachedDevice(UserSessionModel s) {
+ DeviceRepresentation device = DeviceActivityManager.getCurrentDevice(s);
+
+ if (device == null) {
+ device = DeviceRepresentation.unknown();
+ device.setIpAddress(s.getIpAddress());
+ }
+
+ return device;
+ }
+
+ private boolean isCurrentSession(UserSessionModel session) {
+ return session.getId().equals(auth.getSession().getId());
+ }
+
+ private SessionRepresentation toRepresentation(UserSessionModel s) {
+ return createSessionRepresentation(s, getAttachedDevice(s));
+ }
+}
diff --git a/testsuite/integration-arquillian/servers/auth-server/undertow/src/main/java/org/keycloak/testsuite/arquillian/undertow/KeycloakOnUndertow.java b/testsuite/integration-arquillian/servers/auth-server/undertow/src/main/java/org/keycloak/testsuite/arquillian/undertow/KeycloakOnUndertow.java
index 30d7491454..d9bd9798a9 100644
--- a/testsuite/integration-arquillian/servers/auth-server/undertow/src/main/java/org/keycloak/testsuite/arquillian/undertow/KeycloakOnUndertow.java
+++ b/testsuite/integration-arquillian/servers/auth-server/undertow/src/main/java/org/keycloak/testsuite/arquillian/undertow/KeycloakOnUndertow.java
@@ -50,6 +50,7 @@ import org.keycloak.services.filters.KeycloakSessionServletFilter;
import org.keycloak.services.managers.ApplianceBootstrap;
import org.keycloak.services.resources.KeycloakApplication;
import org.keycloak.testsuite.KeycloakServer;
+import org.keycloak.testsuite.TestKeycloakSessionServletFilter;
import org.keycloak.testsuite.utils.tls.TLSUtils;
import org.keycloak.testsuite.utils.undertow.UndertowDeployerHelper;
import org.keycloak.testsuite.utils.undertow.UndertowWarClassLoader;
@@ -99,7 +100,7 @@ public class KeycloakOnUndertow implements DeployableContainerPetr Mensik
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.FIELD, ElementType.PARAMETER})
+@Qualifier
+public @interface ThirdBrowser {
+}
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java
index 5a84edd154..87fcf6dfa0 100755
--- a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/AccountRestServiceTest.java
@@ -196,21 +196,6 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
TokenUtil noaccessToken = new TokenUtil("no-account-access", "password");
TokenUtil viewToken = new TokenUtil("view-account-access", "password");
- // Read sessions with no access
- assertEquals(403, SimpleHttp.doGet(getAccountUrl("sessions"), httpClient).header("Accept", "application/json").auth(noaccessToken.getToken()).asStatus());
-
- // Delete all sessions with no access
- assertEquals(403, SimpleHttp.doDelete(getAccountUrl("sessions"), httpClient).header("Accept", "application/json").auth(noaccessToken.getToken()).asStatus());
-
- // Delete all sessions with read only
- assertEquals(403, SimpleHttp.doDelete(getAccountUrl("sessions"), httpClient).header("Accept", "application/json").auth(viewToken.getToken()).asStatus());
-
- // Delete single session with no access
- assertEquals(403, SimpleHttp.doDelete(getAccountUrl("session?id=bogusId"), httpClient).header("Accept", "application/json").auth(noaccessToken.getToken()).asStatus());
-
- // Delete single session with read only
- assertEquals(403, SimpleHttp.doDelete(getAccountUrl("session?id=bogusId"), httpClient).header("Accept", "application/json").auth(viewToken.getToken()).asStatus());
-
// Read password details with no access
assertEquals(403, SimpleHttp.doGet(getAccountUrl("credentials/password"), httpClient).header("Accept", "application/json").auth(noaccessToken.getToken()).asStatus());
@@ -232,15 +217,6 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
assertEquals(200, status);
}
- @Test
- public void testGetSessions() throws IOException {
- assumeFeatureEnabled(ACCOUNT_API);
-
- List sessions = SimpleHttp.doGet(getAccountUrl("sessions"), httpClient).auth(tokenUtil.getToken()).asJson(new TypeReference>() {});
-
- assertEquals(1, sessions.size());
- }
-
@Test
public void testGetPasswordDetails() throws IOException {
assumeFeatureEnabled(ACCOUNT_API);
@@ -316,28 +292,6 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
assertEquals(1, sessions.size());
}
- @Test
- public void testDeleteSession() throws IOException {
- assumeFeatureEnabled(ACCOUNT_API);
-
- TokenUtil viewToken = new TokenUtil("view-account-access", "password");
- String sessionId = oauth.doLogin("view-account-access", "password").getSessionState();
- List sessions = SimpleHttp.doGet(getAccountUrl("sessions"), httpClient).auth(viewToken.getToken()).asJson(new TypeReference>() {});
- assertEquals(2, sessions.size());
-
- // With `ViewToken` you can only read
- int status = SimpleHttp.doDelete(getAccountUrl("session?id=" + sessionId), httpClient).acceptJson().auth(viewToken.getToken()).asStatus();
- assertEquals(403, status);
- sessions = SimpleHttp.doGet(getAccountUrl("sessions"), httpClient).auth(viewToken.getToken()).asJson(new TypeReference>() {});
- assertEquals(2, sessions.size());
-
- // Here you can delete the session
- status = SimpleHttp.doDelete(getAccountUrl("session?id=" + sessionId), httpClient).acceptJson().auth(tokenUtil.getToken()).asStatus();
- assertEquals(200, status);
- sessions = SimpleHttp.doGet(getAccountUrl("sessions"), httpClient).auth(tokenUtil.getToken()).asJson(new TypeReference>() {});
- assertEquals(1, sessions.size());
- }
-
@Test
public void listApplications() throws IOException {
assumeFeatureEnabled(ACCOUNT_API);
diff --git a/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/SessionRestServiceTest.java b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/SessionRestServiceTest.java
new file mode 100755
index 0000000000..90704904f1
--- /dev/null
+++ b/testsuite/integration-arquillian/tests/base/src/test/java/org/keycloak/testsuite/account/SessionRestServiceTest.java
@@ -0,0 +1,447 @@
+/*
+ * Copyright 2019 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.account;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertThat;
+import static org.junit.Assert.assertTrue;
+import static org.keycloak.common.Profile.Feature.ACCOUNT_API;
+import static org.keycloak.testsuite.ProfileAssume.assumeFeatureEnabled;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import org.hamcrest.Matchers;
+import org.jboss.arquillian.drone.api.annotation.Drone;
+import org.junit.Before;
+import org.junit.Test;
+import org.keycloak.OAuth2Constants;
+import org.keycloak.broker.provider.util.SimpleHttp;
+import org.keycloak.representations.account.ClientRepresentation;
+import org.keycloak.representations.account.DeviceRepresentation;
+import org.keycloak.representations.account.SessionRepresentation;
+import org.keycloak.representations.idm.RealmRepresentation;
+import org.keycloak.testsuite.util.ClientBuilder;
+import org.keycloak.testsuite.util.ContainerAssume;
+import org.keycloak.testsuite.util.OAuthClient;
+import org.keycloak.testsuite.util.SecondBrowser;
+import org.keycloak.testsuite.util.ThirdBrowser;
+import org.keycloak.testsuite.util.TokenUtil;
+import org.openqa.selenium.WebDriver;
+
+/**
+ * @author Stian Thorgersen
+ */
+public class SessionRestServiceTest extends AbstractRestServiceTest {
+
+ @Drone
+ @SecondBrowser
+ protected WebDriver secondBrowser;
+
+ @Drone
+ @ThirdBrowser
+ protected WebDriver thirdBrowser;
+
+ @Override
+ public void configureTestRealm(RealmRepresentation testRealm) {
+ super.configureTestRealm(testRealm);
+
+ testRealm.getClients().add(ClientBuilder.create()
+ .clientId("public-client-0")
+ .name("Public Client 0")
+ .baseUrl("http://client0.example.com")
+ .redirectUris(OAuthClient.APP_ROOT + "/auth")
+ .publicClient().build());
+
+ testRealm.getClients().add(ClientBuilder.create()
+ .clientId("public-client-1")
+ .name("Public Client 1")
+ .baseUrl("http://client1.example.com")
+ .redirectUris(OAuthClient.APP_ROOT + "/auth")
+ .publicClient().build());
+
+ testRealm.getClients().add(ClientBuilder.create()
+ .clientId("confidential-client-0")
+ .name("Confidential Client 0")
+ .secret("secret")
+ .serviceAccount()
+ .directAccessGrants()
+ .redirectUris(OAuthClient.APP_ROOT + "/auth").build());
+
+ testRealm.getClients().add(ClientBuilder.create()
+ .clientId("confidential-client-1")
+ .name("Confidential Client 1")
+ .secret("secret")
+ .serviceAccount()
+ .directAccessGrants()
+ .redirectUris(OAuthClient.APP_ROOT + "/auth").build());
+ }
+
+ @Test
+ public void testProfilePreviewPermissions() throws IOException {
+ assumeFeatureEnabled(ACCOUNT_API);
+
+ TokenUtil noaccessToken = new TokenUtil("no-account-access", "password");
+ TokenUtil viewToken = new TokenUtil("view-account-access", "password");
+
+ // Read sessions with no access
+ assertEquals(403, SimpleHttp.doGet(getAccountUrl("sessions"), httpClient).header("Accept", "application/json")
+ .auth(noaccessToken.getToken()).asStatus());
+
+ // Delete all sessions with no access
+ assertEquals(403, SimpleHttp.doDelete(getAccountUrl("sessions"), httpClient).header("Accept", "application/json")
+ .auth(noaccessToken.getToken()).asStatus());
+
+ // Delete all sessions with read only
+ assertEquals(403, SimpleHttp.doDelete(getAccountUrl("sessions"), httpClient).header("Accept", "application/json")
+ .auth(viewToken.getToken()).asStatus());
+
+ // Delete single session with no access
+ assertEquals(403,
+ SimpleHttp.doDelete(getAccountUrl("sessions/bogusId"), httpClient).header("Accept", "application/json")
+ .auth(noaccessToken.getToken()).asStatus());
+
+ // Delete single session with read only
+ assertEquals(403,
+ SimpleHttp.doDelete(getAccountUrl("sessions/bogusId"), httpClient).header("Accept", "application/json")
+ .auth(viewToken.getToken()).asStatus());
+ }
+
+ @Before
+ @Override
+ public void before() {
+ super.before();
+ assumeFeatureEnabled(ACCOUNT_API);
+ }
+
+ @Test
+ public void testGetSessions() throws Exception {
+ oauth.setDriver(secondBrowser);
+ codeGrant("public-client-0");
+
+ List sessions = getSessions();
+ assertEquals(2, sessions.size());
+
+ for (SessionRepresentation session : sessions) {
+ assertNotNull(session.getId());
+ assertEquals("127.0.0.1", session.getIpAddress());
+ assertTrue(session.getLastAccess() > 0);
+ assertTrue(session.getExpires() > 0);
+ assertTrue(session.getStarted() > 0);
+ assertThat(session.getClients(), Matchers.hasItem(Matchers.hasProperty("clientId",
+ Matchers.anyOf(Matchers.is("direct-grant"), Matchers.is("public-client-0")))));
+ }
+ }
+
+ @Test
+ public void testGetDevicesResponse() throws Exception {
+ oauth.setBrowserHeader("User-Agent", "Mozilla/5.0 (Windows NT 10.0) Gecko/20100101 Firefox/15.0.1");
+ OAuthClient.AccessTokenResponse tokenResponse = codeGrant("public-client-0");
+ joinSsoSession("public-client-1");
+
+ List devices = getDevicesOtherThanOther(tokenResponse.getAccessToken());
+
+ assertEquals("Should have a single device", 1, devices.size());
+
+ DeviceRepresentation device = devices.get(0);
+
+ assertTrue(device.getCurrent());
+ assertEquals("Windows", device.getOs());
+ assertEquals("10", device.getOsVersion());
+ assertEquals("Other", device.getDevice());
+
+ List sessions = device.getSessions();
+ assertEquals(1, sessions.size());
+ SessionRepresentation session = sessions.get(0);
+ assertEquals("127.0.0.1", session.getIpAddress());
+ assertTrue(device.getLastAccess() == session.getLastAccess());
+
+ List clients = session.getClients();
+ assertEquals(2, clients.size());
+ assertThat(session.getClients(), Matchers.hasItem(Matchers.hasProperty("clientId",
+ Matchers.anyOf(Matchers.is("public-client-0"), Matchers.is("public-client-1")))));
+ assertThat(session.getClients(), Matchers.hasItem(Matchers.hasProperty("clientName",
+ Matchers.anyOf(Matchers.is("Public Client 0"), Matchers.is("Public Client 1")))));
+ }
+
+ @Test
+ public void testGetDevicesSessions() throws Exception {
+ ContainerAssume.assumeAuthServerUndertow();
+ WebDriver firstBrowser = oauth.getDriver();
+
+ // first browser authenticates from Fedora
+ oauth.setBrowserHeader("User-Agent", "Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1");
+ codeGrant("public-client-0");
+ List devices = getDevicesOtherThanOther();
+ assertEquals("Should have a single device", 1, devices.size());
+ List fedoraDevices = devices.stream()
+ .filter(deviceRepresentation -> "Fedora".equals(deviceRepresentation.getOs())).collect(Collectors.toList());
+ assertEquals("Should have a single Fedora device", 1, fedoraDevices.size());
+ fedoraDevices.stream().forEach(device -> {
+ List sessions = device.getSessions();
+ assertEquals(1, sessions.size());
+ assertThat(sessions, Matchers.hasItem(Matchers.hasProperty("browser", Matchers.is("Firefox/15.0.1"))));
+ });
+
+ // second browser authenticates from Windows
+ oauth.setDriver(secondBrowser);
+ oauth.setBrowserHeader("User-Agent",
+ "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Gecko/20100101 Firefox/15.0.1");
+ codeGrant("public-client-0");
+ devices = getDevicesOtherThanOther();
+ // should have two devices
+ assertEquals("Should have two devices", 2, devices.size());
+ fedoraDevices = devices.stream()
+ .filter(deviceRepresentation -> "Fedora".equals(deviceRepresentation.getOs())).collect(Collectors.toList());
+ assertEquals(1, fedoraDevices.size());
+ List windowsDevices = devices.stream()
+ .filter(deviceRepresentation -> "Windows".equals(deviceRepresentation.getOs())).collect(Collectors.toList());
+ assertEquals(1, windowsDevices.size());
+ windowsDevices.stream().forEach(device -> {
+ List sessions = device.getSessions();
+ assertEquals(1, sessions.size());
+ assertThat(sessions, Matchers.hasItem(Matchers.hasProperty("browser", Matchers.is("Firefox/15.0.1"))));
+ });
+
+ // first browser authenticates from Windows using Edge
+ oauth.setDriver(firstBrowser);
+ oauth.setBrowserHeader("User-Agent",
+ "Mozilla/5.0 (Windows Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/39.0.2171.71 Safari/537.36 Edge/12.0");
+ codeGrant("public-client-0");
+
+ // second browser authenticates from Windows using Firefox
+ oauth.setDriver(secondBrowser);
+ oauth.setBrowserHeader("User-Agent",
+ "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Gecko/20100101 Firefox/15.0.1");
+ codeGrant("public-client-0");
+
+ // third browser authenticates from Windows using Safari
+ oauth.setDriver(thirdBrowser);
+ oauth.setBrowserHeader("User-Agent",
+ "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Version/11.0 Safari/603.1.30");
+ oauth.setBrowserHeader("X-Forwarded-For", "192.168.10.3");
+ OAuthClient.AccessTokenResponse tokenResponse = codeGrant("public-client-0");
+ devices = getDevicesOtherThanOther(tokenResponse.getAccessToken());
+ assertEquals(
+ "Should have a single device because all browsers (and sessions) are from the same platform (OS + OS version)",
+ 1, devices.size());
+ windowsDevices = devices.stream()
+ .filter(device -> "Windows".equals(device.getOs())).collect(Collectors.toList());
+ assertEquals(1, windowsDevices.size());
+ windowsDevices.stream().forEach(device -> {
+ List sessions = device.getSessions();
+ assertEquals(3, sessions.size());
+ assertEquals(1, sessions.stream().filter(
+ rep -> rep.getIpAddress().equals("127.0.0.1") && rep.getBrowser().equals("Firefox/15.0.1")
+ && rep.getCurrent() == null).count());
+ assertEquals(1, sessions.stream().filter(
+ rep -> rep.getIpAddress().equals("127.0.0.1") && rep.getBrowser().equals("Edge/12.0")
+ && rep.getCurrent() == null).count());
+ assertEquals(1, sessions.stream().filter(
+ rep -> rep.getIpAddress().equals("192.168.10.3") && rep.getBrowser().equals("Safari/11.0") && rep
+ .getCurrent()).count());
+ });
+
+ // third browser authenticates from Windows using a different Windows version
+ oauth.setDriver(thirdBrowser);
+ oauth.setBrowserHeader("User-Agent",
+ "Mozilla/5.0 (Windows 7) AppleWebKit/537.36 (KHTML, like Gecko) Version/11.0 Safari/603.1.30");
+ oauth.setBrowserHeader("X-Forwarded-For", "192.168.10.3");
+ codeGrant("public-client-0");
+ devices = getDevicesOtherThanOther();
+ windowsDevices = devices.stream()
+ .filter(device -> "Windows".equals(device.getOs())).collect(Collectors.toList());
+ assertEquals("Should have two devices for two distinct Windows versions", 2, devices.size());
+ assertEquals(2, windowsDevices.size());
+
+ oauth.setDriver(firstBrowser);
+ oauth.setBrowserHeader("User-Agent",
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 5_1_1 like Mac OS X) AppleWebKit/534.46 (KHTML, like Gecko) Version/5.1 Mobile/9B206 Safari/7534.48.3");
+ codeGrant("public-client-0");
+ oauth.setDriver(secondBrowser);
+ oauth.setBrowserHeader("User-Agent",
+ "Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1");
+ codeGrant("public-client-0");
+ devices = getDevicesOtherThanOther();
+ assertEquals("Should have 3 devices", 3, devices.size());
+ windowsDevices = devices.stream()
+ .filter(device -> "Windows".equals(device.getOs())).collect(Collectors.toList());
+ assertEquals(1, windowsDevices.size());
+ fedoraDevices = devices.stream()
+ .filter(deviceRepresentation -> "Fedora".equals(deviceRepresentation.getOs())).collect(Collectors.toList());
+ assertEquals(1, fedoraDevices.size());
+ List iphoneDevices = devices.stream()
+ .filter(device -> "iOS".equals(device.getOs()) && "iPhone".equals(device.getDevice()))
+ .collect(Collectors.toList());
+ assertEquals(1, iphoneDevices.size());
+ iphoneDevices.stream().forEach(device -> {
+ assertTrue(device.isMobile());
+ List sessions = device.getSessions();
+ assertEquals(1, sessions.size());
+ assertEquals(1, sessions.stream().filter(
+ rep -> rep.getBrowser().equals("Mobile Safari/5.1")).count());
+ });
+ }
+
+ @Test
+ public void testLogout() throws IOException {
+ TokenUtil viewToken = new TokenUtil("view-account-access", "password");
+ String sessionId = oauth.doLogin("view-account-access", "password").getSessionState();
+ List sessions = getSessions(viewToken.getToken());
+ assertEquals(2, sessions.size());
+
+ // With `ViewToken` you can only read
+ int status = SimpleHttp.doDelete(getAccountUrl("sessions/" + sessionId), httpClient).acceptJson()
+ .auth(viewToken.getToken()).asStatus();
+ assertEquals(403, status);
+ sessions = getSessions(viewToken.getToken());
+ assertEquals(2, sessions.size());
+
+ // Here you can delete the session
+ status = SimpleHttp.doDelete(getAccountUrl("sessions/" + sessionId), httpClient).acceptJson().auth(tokenUtil.getToken())
+ .asStatus();
+ assertEquals(204, status);
+ sessions = getSessions(tokenUtil.getToken());
+ assertEquals(1, sessions.size());
+ }
+
+ @Test
+ public void testLogoutAll() throws IOException {
+ codeGrant("public-client-0");
+ oauth.setDriver(secondBrowser);
+ OAuthClient.AccessTokenResponse tokenResponse = codeGrant("public-client-0");
+
+ assertEquals(3, getSessions().size());
+
+ String currentToken = tokenResponse.getAccessToken();
+ int status = SimpleHttp.doDelete(getAccountUrl("sessions"), httpClient)
+ .acceptJson()
+ .auth(currentToken).asStatus();
+ assertEquals(204, status);
+ assertEquals(1, getSessions(currentToken).size());
+
+ status = SimpleHttp.doDelete(getAccountUrl("sessions?current=true"), httpClient)
+ .acceptJson()
+ .auth(currentToken).asStatus();
+ assertEquals(204, status);
+
+ status = SimpleHttp.doGet(getAccountUrl("sessions"), httpClient)
+ .acceptJson()
+ .auth(currentToken).asStatus();
+ assertEquals(401, status);
+ }
+
+ @Test
+ public void testNullOrEmptyUserAgent() throws Exception {
+ oauth.setBrowserHeader("User-Agent", null);
+ OAuthClient.AccessTokenResponse tokenResponse = codeGrant("public-client-0");
+
+ List devices = queryDevices(tokenResponse.getAccessToken());
+
+ assertEquals("Should have a single device", 1, devices.size());
+
+ DeviceRepresentation device = devices.get(0);
+
+ assertTrue(device.getCurrent());
+ assertEquals("Other", device.getOs());
+ assertEquals("Other", device.getDevice());
+
+ List sessions = device.getSessions();
+ assertEquals(2, sessions.size());
+ SessionRepresentation session = sessions.stream().filter(rep -> rep.getCurrent() != null && rep.getCurrent()).findFirst().get();
+ assertEquals("127.0.0.1", session.getIpAddress());
+ assertEquals(device.getLastAccess(), session.getLastAccess());
+
+ assertEquals(1, session.getClients().size());
+ }
+
+ @Test
+ public void testNonBrowserSession() throws Exception {
+ // one device
+ oauth.setBrowserHeader("User-Agent", "Mozilla/5.0 (X11; Fedora; Linux x86_64; rv:15.0) Gecko/20100101 Firefox/15.0.1");
+ codeGrant("public-client-0");
+
+ // all bellow grouped from a single Other device
+ oauth.setBrowserHeader("User-Agent", null);
+ oauth.clientId("confidential-client-0");
+ oauth.doGrantAccessTokenRequest("secret", "test-user@localhost", "password");
+ oauth.clientId("confidential-client-1");
+ oauth.doGrantAccessTokenRequest("secret", "test-user@localhost", "password");
+
+ List devices = getAllDevices();
+ assertEquals(2, devices.size());
+
+ assertThat(devices,
+ Matchers.hasItems(Matchers.hasProperty("os", Matchers.anyOf(Matchers.is("Fedora"), Matchers.is("Other")))));
+
+ // three because tests use another client when booting tests
+ assertEquals(3, devices.stream().filter(deviceRepresentation -> "Other".equals(deviceRepresentation.getOs()))
+ .map(deviceRepresentation -> deviceRepresentation.getSessions().size())
+ .findFirst().get().intValue());
+ }
+
+ private List getSessions(String sessionOne) throws IOException {
+ return SimpleHttp
+ .doGet(getAccountUrl("sessions"), httpClient).auth(sessionOne)
+ .asJson(new TypeReference>() {
+ });
+ }
+
+ private List getDevicesOtherThanOther() throws IOException {
+ return getDevicesOtherThanOther(tokenUtil.getToken());
+ }
+
+ private List getAllDevices() throws IOException {
+ return queryDevices(tokenUtil.getToken());
+ }
+
+ private List getDevicesOtherThanOther(String token) throws IOException {
+ return queryDevices(token).stream().filter(rep -> !"Other".equals(rep.getOs())).collect(Collectors.toList());
+ }
+
+ private List queryDevices(String token) throws IOException {
+ return SimpleHttp
+ .doGet(getAccountUrl("sessions/devices"), httpClient).auth(token)
+ .asJson(new TypeReference>() {
+ });
+ }
+
+ private OAuthClient.AccessTokenResponse codeGrant(String clientId) {
+ oauth.clientId(clientId);
+ oauth.redirectUri(OAuthClient.APP_ROOT + "/auth");
+ oauth.openLogout();
+ oauth.doLogin("test-user@localhost", "password");
+ String code = oauth.getCurrentQuery().get(OAuth2Constants.CODE);
+ return oauth.doAccessTokenRequest(code, "password");
+ }
+
+ private void joinSsoSession(String clientId) {
+ oauth.clientId(clientId);
+ oauth.redirectUri(OAuthClient.APP_ROOT + "/auth");
+ oauth.openLoginForm();
+ }
+
+ private List getSessions() throws IOException {
+ return SimpleHttp
+ .doGet(getAccountUrl("sessions"), httpClient).auth(tokenUtil.getToken())
+ .asJson(new TypeReference>() {
+ });
+ }
+}
diff --git a/testsuite/utils/src/main/java/org/keycloak/testsuite/KeycloakServer.java b/testsuite/utils/src/main/java/org/keycloak/testsuite/KeycloakServer.java
index c78e542272..01a9f87f9e 100755
--- a/testsuite/utils/src/main/java/org/keycloak/testsuite/KeycloakServer.java
+++ b/testsuite/utils/src/main/java/org/keycloak/testsuite/KeycloakServer.java
@@ -411,7 +411,7 @@ public class KeycloakServer {
di.addServlet(restEasyDispatcher);
- FilterInfo filter = Servlets.filter("SessionFilter", KeycloakSessionServletFilter.class);
+ FilterInfo filter = Servlets.filter("SessionFilter", TestKeycloakSessionServletFilter.class);
filter.setAsyncSupported(true);
diff --git a/testsuite/utils/src/main/java/org/keycloak/testsuite/TestKeycloakSessionServletFilter.java b/testsuite/utils/src/main/java/org/keycloak/testsuite/TestKeycloakSessionServletFilter.java
new file mode 100644
index 0000000000..b64ebdc48c
--- /dev/null
+++ b/testsuite/utils/src/main/java/org/keycloak/testsuite/TestKeycloakSessionServletFilter.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2019 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;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletRequestWrapper;
+import java.io.IOException;
+
+import org.keycloak.services.filters.KeycloakSessionServletFilter;
+
+/**
+ * @author Pedro Igor
+ */
+public class TestKeycloakSessionServletFilter extends KeycloakSessionServletFilter {
+
+ @Override
+ public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
+ throws IOException, ServletException {
+ super.doFilter(new HttpServletRequestWrapper((HttpServletRequest) servletRequest) {
+ @Override
+ public String getRemoteAddr() {
+ String forwardedFor = getHeader("X-Forwarded-For");
+
+ if (forwardedFor != null) {
+ return forwardedFor;
+ }
+
+ return super.getRemoteAddr();
+ }
+ }, servletResponse, filterChain);
+ }
+}
\ No newline at end of file