[KEYCLOAK-7416] - Device Activity

This commit is contained in:
Pedro Igor 2019-09-04 15:52:16 -03:00 committed by Bruno Oliveira da Silva
parent 69d6613ab6
commit a1d8850373
19 changed files with 1135 additions and 135 deletions

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
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<SessionRepresentation> 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<SessionRepresentation> getSessions() {
return sessions;
}
public void setMobile(boolean mobile) {
this.mobile = mobile;
}
public boolean isMobile() {
return mobile;
}
}

View file

@ -13,6 +13,8 @@ public class SessionRepresentation {
private int lastAccess; private int lastAccess;
private int expires; private int expires;
private List<ClientRepresentation> clients; private List<ClientRepresentation> clients;
private String browser;
private Boolean current;
public String getId() { public String getId() {
return id; return id;
@ -61,4 +63,20 @@ public class SessionRepresentation {
public void setClients(List<ClientRepresentation> clients) { public void setClients(List<ClientRepresentation> clients) {
this.clients = 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;
}
} }

View file

@ -52,6 +52,16 @@
</exclusion> </exclusion>
</exclusions> </exclusions>
</dependency> </dependency>
<dependency>
<groupId>com.github.ua-parser</groupId>
<artifactId>uap-java</artifactId>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency> <dependency>
<groupId>com.google.zxing</groupId> <groupId>com.google.zxing</groupId>
<artifactId>core</artifactId> <artifactId>core</artifactId>

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2016 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.
-->
<module xmlns="urn:jboss:module:1.3" name="com.github.ua-parser">
<resources>
<artifact name="${com.github.ua-parser:uap-java}"/>
</resources>
<dependencies>
<module name="org.yaml.snakeyaml"/>
</dependencies>
</module>

View file

@ -39,5 +39,6 @@
<module name="com.fasterxml.jackson.core.jackson-databind"/> <module name="com.fasterxml.jackson.core.jackson-databind"/>
<module name="com.fasterxml.jackson.core.jackson-core"/> <module name="com.fasterxml.jackson.core.jackson-core"/>
<module name="com.google.guava"/> <module name="com.google.guava"/>
<module name="com.github.ua-parser" export="true"/>
</dependencies> </dependencies>
</module> </module>

View file

@ -24,9 +24,9 @@ import org.infinispan.context.Flag;
import org.infinispan.stream.CacheCollectors; import org.infinispan.stream.CacheCollectors;
import org.jboss.logging.Logger; import org.jboss.logging.Logger;
import org.keycloak.cluster.ClusterProvider; import org.keycloak.cluster.ClusterProvider;
import org.keycloak.common.util.ObjectUtil;
import org.keycloak.common.util.Retry; import org.keycloak.common.util.Retry;
import org.keycloak.common.util.Time; import org.keycloak.common.util.Time;
import org.keycloak.device.DeviceActivityManager;
import org.keycloak.models.AuthenticatedClientSessionModel; import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel; import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSession;
@ -36,7 +36,6 @@ import org.keycloak.models.UserLoginFailureModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel; import org.keycloak.models.UserSessionModel;
import org.keycloak.models.UserSessionProvider; 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.Tasks;
import org.keycloak.models.sessions.infinispan.changes.sessions.CrossDCLastSessionRefreshStore; import org.keycloak.models.sessions.infinispan.changes.sessions.CrossDCLastSessionRefreshStore;
import org.keycloak.models.sessions.infinispan.changes.sessions.PersisterLastSessionRefreshStore; import org.keycloak.models.sessions.infinispan.changes.sessions.PersisterLastSessionRefreshStore;
@ -75,7 +74,6 @@ import java.util.Set;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Future; import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.function.Function; import java.util.function.Function;
@ -218,7 +216,13 @@ public class InfinispanUserSessionProvider implements UserSessionProvider {
SessionUpdateTask<UserSessionEntity> createSessionTask = Tasks.addIfAbsentSync(); SessionUpdateTask<UserSessionEntity> createSessionTask = Tasks.addIfAbsentSync();
sessionTx.addTask(id, createSessionTask, entity); 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) { void updateSessionEntity(UserSessionEntity entity, RealmModel realm, UserModel user, String loginUsername, String ipAddress, String authMethod, boolean rememberMe, String brokerSessionId, String brokerUserId) {

View file

@ -95,6 +95,7 @@
<xmlsec.version>2.1.3</xmlsec.version> <xmlsec.version>2.1.3</xmlsec.version>
<glassfish.json.version>1.1.2</glassfish.json.version> <glassfish.json.version>1.1.2</glassfish.json.version>
<wildfly.common.version>1.5.1.Final</wildfly.common.version> <wildfly.common.version>1.5.1.Final</wildfly.common.version>
<ua-parser.version>1.4.3</ua-parser.version>
<picketbox.version>5.0.3.Final</picketbox.version> <picketbox.version>5.0.3.Final</picketbox.version>
<google.guava.version>25.0-jre</google.guava.version> <google.guava.version>25.0-jre</google.guava.version>
@ -271,6 +272,11 @@
<artifactId>bcpkix-jdk15on</artifactId> <artifactId>bcpkix-jdk15on</artifactId>
<version>${bouncycastle.version}</version> <version>${bouncycastle.version}</version>
</dependency> </dependency>
<dependency>
<groupId>com.github.ua-parser</groupId>
<artifactId>uap-java</artifactId>
<version>${ua-parser.version}</version>
</dependency>
<dependency> <dependency>
<groupId>javax.mail</groupId> <groupId>javax.mail</groupId>
<artifactId>javax.mail-api</artifactId> <artifactId>javax.mail-api</artifactId>

View file

@ -76,6 +76,10 @@
<artifactId>guava</artifactId> <artifactId>guava</artifactId>
<scope>provided</scope> <scope>provided</scope>
</dependency> </dependency>
<dependency>
<groupId>com.github.ua-parser</groupId>
<artifactId>uap-java</artifactId>
</dependency>
<dependency> <dependency>
<groupId>junit</groupId> <groupId>junit</groupId>
<artifactId>junit</artifactId> <artifactId>junit</artifactId>

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
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;
}
}

View file

@ -19,7 +19,6 @@ package org.keycloak.services.resources.account;
import org.jboss.resteasy.annotations.cache.NoCache; import org.jboss.resteasy.annotations.cache.NoCache;
import org.jboss.resteasy.spi.HttpRequest; import org.jboss.resteasy.spi.HttpRequest;
import org.keycloak.common.ClientConnection; import org.keycloak.common.ClientConnection;
import org.keycloak.common.Profile;
import org.keycloak.events.Details; import org.keycloak.events.Details;
import org.keycloak.events.EventBuilder; import org.keycloak.events.EventBuilder;
import org.keycloak.events.EventStoreProvider; import org.keycloak.events.EventStoreProvider;
@ -31,20 +30,16 @@ import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel; import org.keycloak.models.RealmModel;
import org.keycloak.models.UserConsentModel; import org.keycloak.models.UserConsentModel;
import org.keycloak.models.UserModel; import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.representations.account.ClientRepresentation; import org.keycloak.representations.account.ClientRepresentation;
import org.keycloak.representations.account.ConsentRepresentation; import org.keycloak.representations.account.ConsentRepresentation;
import org.keycloak.representations.account.ConsentScopeRepresentation; import org.keycloak.representations.account.ConsentScopeRepresentation;
import org.keycloak.representations.account.SessionRepresentation;
import org.keycloak.representations.account.UserRepresentation; import org.keycloak.representations.account.UserRepresentation;
import org.keycloak.services.ErrorResponse; import org.keycloak.services.ErrorResponse;
import org.keycloak.services.managers.Auth; import org.keycloak.services.managers.Auth;
import org.keycloak.services.managers.AuthenticationManager;
import org.keycloak.services.messages.Messages; import org.keycloak.services.messages.Messages;
import org.keycloak.services.resources.Cors; import org.keycloak.services.resources.Cors;
import org.keycloak.services.resources.account.resources.ResourcesService; import org.keycloak.services.resources.account.resources.ResourcesService;
import org.keycloak.storage.ReadOnlyException; import org.keycloak.storage.ReadOnlyException;
import org.keycloak.theme.Theme;
import javax.ws.rs.Consumes; import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE; import javax.ws.rs.DELETE;
@ -56,19 +51,20 @@ import javax.ws.rs.PUT;
import javax.ws.rs.Path; import javax.ws.rs.Path;
import javax.ws.rs.PathParam; import javax.ws.rs.PathParam;
import javax.ws.rs.Produces; import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context; import javax.ws.rs.core.Context;
import javax.ws.rs.core.HttpHeaders; import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType; import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response; import javax.ws.rs.core.Response;
import java.io.IOException; import java.io.IOException;
import java.util.LinkedList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Properties; import java.util.Properties;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.keycloak.common.Profile;
import org.keycloak.theme.Theme;
/** /**
* @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a> * @author <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/ */
@ -224,84 +220,10 @@ public class AccountRestService {
* @return * @return
*/ */
@Path("/sessions") @Path("/sessions")
@GET public SessionResource sessions() {
@Produces(MediaType.APPLICATION_JSON)
@NoCache
public Response sessions() {
checkAccountApiEnabled(); checkAccountApiEnabled();
auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE); auth.requireOneOf(AccountRoles.MANAGE_ACCOUNT, AccountRoles.VIEW_PROFILE);
return new SessionResource(session, auth, request);
List<SessionRepresentation> reps = new LinkedList<>();
List<UserSessionModel> 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<UserSessionModel> 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();
} }
@Path("/credentials") @Path("/credentials")

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
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<String, DeviceRepresentation> reps = new HashMap<>();
List<UserSessionModel> 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<UserSessionModel> 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));
}
}

View file

@ -50,6 +50,7 @@ import org.keycloak.services.filters.KeycloakSessionServletFilter;
import org.keycloak.services.managers.ApplianceBootstrap; import org.keycloak.services.managers.ApplianceBootstrap;
import org.keycloak.services.resources.KeycloakApplication; import org.keycloak.services.resources.KeycloakApplication;
import org.keycloak.testsuite.KeycloakServer; import org.keycloak.testsuite.KeycloakServer;
import org.keycloak.testsuite.TestKeycloakSessionServletFilter;
import org.keycloak.testsuite.utils.tls.TLSUtils; import org.keycloak.testsuite.utils.tls.TLSUtils;
import org.keycloak.testsuite.utils.undertow.UndertowDeployerHelper; import org.keycloak.testsuite.utils.undertow.UndertowDeployerHelper;
import org.keycloak.testsuite.utils.undertow.UndertowWarClassLoader; import org.keycloak.testsuite.utils.undertow.UndertowWarClassLoader;
@ -99,7 +100,7 @@ public class KeycloakOnUndertow implements DeployableContainer<KeycloakOnUnderto
di.setDefaultServletConfig(new DefaultServletConfig(true)); di.setDefaultServletConfig(new DefaultServletConfig(true));
di.addWelcomePage("theme/keycloak/welcome/resources/index.html"); di.addWelcomePage("theme/keycloak/welcome/resources/index.html");
FilterInfo filter = Servlets.filter("SessionFilter", KeycloakSessionServletFilter.class); FilterInfo filter = Servlets.filter("SessionFilter", TestKeycloakSessionServletFilter.class);
di.addFilter(filter); di.addFilter(filter);
di.addFilterUrlMapping("SessionFilter", "/*", DispatcherType.REQUEST); di.addFilterUrlMapping("SessionFilter", "/*", DispatcherType.REQUEST);
filter.setAsyncSupported(true); filter.setAsyncSupported(true);

View file

@ -29,6 +29,11 @@ public class ContainerAssume {
Assume.assumeFalse("Doesn't work on auth-server-undertow", Assume.assumeFalse("Doesn't work on auth-server-undertow",
AuthServerTestEnricher.AUTH_SERVER_CONTAINER.equals(AuthServerTestEnricher.AUTH_SERVER_CONTAINER_DEFAULT)); AuthServerTestEnricher.AUTH_SERVER_CONTAINER.equals(AuthServerTestEnricher.AUTH_SERVER_CONTAINER_DEFAULT));
} }
public static void assumeAuthServerUndertow() {
Assume.assumeTrue("Only works on auth-server-undertow",
AuthServerTestEnricher.AUTH_SERVER_CONTAINER.equals(AuthServerTestEnricher.AUTH_SERVER_CONTAINER_DEFAULT));
}
public static void assumeClusteredContainer() { public static void assumeClusteredContainer() {
Assume.assumeTrue( Assume.assumeTrue(

View file

@ -31,6 +31,7 @@ import org.apache.http.client.utils.URLEncodedUtils;
import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicNameValuePair; import org.apache.http.message.BasicNameValuePair;
import org.jboss.arquillian.drone.webdriver.htmlunit.DroneHtmlUnitDriver;
import org.junit.Assert; import org.junit.Assert;
import org.keycloak.OAuth2Constants; import org.keycloak.OAuth2Constants;
import org.keycloak.TokenVerifier; import org.keycloak.TokenVerifier;
@ -1249,6 +1250,17 @@ public class OAuthClient {
publicKeys.clear(); publicKeys.clear();
} }
public void setBrowserHeader(String name, String value) {
if (driver instanceof DroneHtmlUnitDriver) {
DroneHtmlUnitDriver droneDriver = (DroneHtmlUnitDriver) this.driver;
droneDriver.getWebClient().removeRequestHeader(name);
droneDriver.getWebClient().addRequestHeader(name, value);
}
}
public WebDriver getDriver() {
return driver;
}
private interface StateParamProvider { private interface StateParamProvider {

View file

@ -0,0 +1,34 @@
/*
* 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.util;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.jboss.arquillian.drone.api.annotation.Qualifier;
/**
*
* @author <a href="mailto:pmensik@redhat.com">Petr Mensik</a>
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Qualifier
public @interface ThirdBrowser {
}

View file

@ -196,21 +196,6 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
TokenUtil noaccessToken = new TokenUtil("no-account-access", "password"); TokenUtil noaccessToken = new TokenUtil("no-account-access", "password");
TokenUtil viewToken = new TokenUtil("view-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 // Read password details with no access
assertEquals(403, SimpleHttp.doGet(getAccountUrl("credentials/password"), httpClient).header("Accept", "application/json").auth(noaccessToken.getToken()).asStatus()); 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); assertEquals(200, status);
} }
@Test
public void testGetSessions() throws IOException {
assumeFeatureEnabled(ACCOUNT_API);
List<SessionRepresentation> sessions = SimpleHttp.doGet(getAccountUrl("sessions"), httpClient).auth(tokenUtil.getToken()).asJson(new TypeReference<List<SessionRepresentation>>() {});
assertEquals(1, sessions.size());
}
@Test @Test
public void testGetPasswordDetails() throws IOException { public void testGetPasswordDetails() throws IOException {
assumeFeatureEnabled(ACCOUNT_API); assumeFeatureEnabled(ACCOUNT_API);
@ -316,28 +292,6 @@ public class AccountRestServiceTest extends AbstractRestServiceTest {
assertEquals(1, sessions.size()); 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<SessionRepresentation> sessions = SimpleHttp.doGet(getAccountUrl("sessions"), httpClient).auth(viewToken.getToken()).asJson(new TypeReference<List<SessionRepresentation>>() {});
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<List<SessionRepresentation>>() {});
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<List<SessionRepresentation>>() {});
assertEquals(1, sessions.size());
}
@Test @Test
public void listApplications() throws IOException { public void listApplications() throws IOException {
assumeFeatureEnabled(ACCOUNT_API); assumeFeatureEnabled(ACCOUNT_API);

View file

@ -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 <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
*/
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<SessionRepresentation> 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<DeviceRepresentation> 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<SessionRepresentation> 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<ClientRepresentation> 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<DeviceRepresentation> devices = getDevicesOtherThanOther();
assertEquals("Should have a single device", 1, devices.size());
List<DeviceRepresentation> 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<SessionRepresentation> 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<DeviceRepresentation> windowsDevices = devices.stream()
.filter(deviceRepresentation -> "Windows".equals(deviceRepresentation.getOs())).collect(Collectors.toList());
assertEquals(1, windowsDevices.size());
windowsDevices.stream().forEach(device -> {
List<SessionRepresentation> 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<SessionRepresentation> 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<DeviceRepresentation> 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<SessionRepresentation> 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<SessionRepresentation> 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<DeviceRepresentation> 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<SessionRepresentation> 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<DeviceRepresentation> 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<SessionRepresentation> getSessions(String sessionOne) throws IOException {
return SimpleHttp
.doGet(getAccountUrl("sessions"), httpClient).auth(sessionOne)
.asJson(new TypeReference<List<SessionRepresentation>>() {
});
}
private List<DeviceRepresentation> getDevicesOtherThanOther() throws IOException {
return getDevicesOtherThanOther(tokenUtil.getToken());
}
private List<DeviceRepresentation> getAllDevices() throws IOException {
return queryDevices(tokenUtil.getToken());
}
private List<DeviceRepresentation> getDevicesOtherThanOther(String token) throws IOException {
return queryDevices(token).stream().filter(rep -> !"Other".equals(rep.getOs())).collect(Collectors.toList());
}
private List<DeviceRepresentation> queryDevices(String token) throws IOException {
return SimpleHttp
.doGet(getAccountUrl("sessions/devices"), httpClient).auth(token)
.asJson(new TypeReference<List<DeviceRepresentation>>() {
});
}
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<SessionRepresentation> getSessions() throws IOException {
return SimpleHttp
.doGet(getAccountUrl("sessions"), httpClient).auth(tokenUtil.getToken())
.asJson(new TypeReference<List<SessionRepresentation>>() {
});
}
}

View file

@ -411,7 +411,7 @@ public class KeycloakServer {
di.addServlet(restEasyDispatcher); di.addServlet(restEasyDispatcher);
FilterInfo filter = Servlets.filter("SessionFilter", KeycloakSessionServletFilter.class); FilterInfo filter = Servlets.filter("SessionFilter", TestKeycloakSessionServletFilter.class);
filter.setAsyncSupported(true); filter.setAsyncSupported(true);

View file

@ -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 <a href="mailto:psilva@redhat.com">Pedro Igor</a>
*/
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);
}
}