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