[KEYCLOAK-7416] - Device Activity
This commit is contained in:
parent
69d6613ab6
commit
a1d8850373
19 changed files with 1135 additions and 135 deletions
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -13,6 +13,8 @@ public class SessionRepresentation {
|
|||
private int lastAccess;
|
||||
private int expires;
|
||||
private List<ClientRepresentation> clients;
|
||||
private String browser;
|
||||
private Boolean current;
|
||||
|
||||
public String getId() {
|
||||
return id;
|
||||
|
@ -61,4 +63,20 @@ public class SessionRepresentation {
|
|||
public void setClients(List<ClientRepresentation> 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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,6 +52,16 @@
|
|||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.ua-parser</groupId>
|
||||
<artifactId>uap-java</artifactId>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>*</groupId>
|
||||
<artifactId>*</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.google.zxing</groupId>
|
||||
<artifactId>core</artifactId>
|
||||
|
|
|
@ -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>
|
|
@ -39,5 +39,6 @@
|
|||
<module name="com.fasterxml.jackson.core.jackson-databind"/>
|
||||
<module name="com.fasterxml.jackson.core.jackson-core"/>
|
||||
<module name="com.google.guava"/>
|
||||
<module name="com.github.ua-parser" export="true"/>
|
||||
</dependencies>
|
||||
</module>
|
||||
|
|
|
@ -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<UserSessionEntity> 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) {
|
||||
|
|
6
pom.xml
6
pom.xml
|
@ -95,6 +95,7 @@
|
|||
<xmlsec.version>2.1.3</xmlsec.version>
|
||||
<glassfish.json.version>1.1.2</glassfish.json.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>
|
||||
<google.guava.version>25.0-jre</google.guava.version>
|
||||
|
||||
|
@ -271,6 +272,11 @@
|
|||
<artifactId>bcpkix-jdk15on</artifactId>
|
||||
<version>${bouncycastle.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.ua-parser</groupId>
|
||||
<artifactId>uap-java</artifactId>
|
||||
<version>${ua-parser.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>javax.mail</groupId>
|
||||
<artifactId>javax.mail-api</artifactId>
|
||||
|
|
|
@ -76,6 +76,10 @@
|
|||
<artifactId>guava</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.github.ua-parser</groupId>
|
||||
<artifactId>uap-java</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 <a href="mailto:sthorger@redhat.com">Stian Thorgersen</a>
|
||||
*/
|
||||
|
@ -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<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();
|
||||
return new SessionResource(session, auth, request);
|
||||
}
|
||||
|
||||
@Path("/credentials")
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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 DeployableContainer<KeycloakOnUnderto
|
|||
di.setDefaultServletConfig(new DefaultServletConfig(true));
|
||||
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.addFilterUrlMapping("SessionFilter", "/*", DispatcherType.REQUEST);
|
||||
filter.setAsyncSupported(true);
|
||||
|
|
|
@ -29,6 +29,11 @@ public class ContainerAssume {
|
|||
Assume.assumeFalse("Doesn't work on auth-server-undertow",
|
||||
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() {
|
||||
Assume.assumeTrue(
|
||||
|
|
|
@ -31,6 +31,7 @@ import org.apache.http.client.utils.URLEncodedUtils;
|
|||
import org.apache.http.impl.client.CloseableHttpClient;
|
||||
import org.apache.http.impl.client.HttpClientBuilder;
|
||||
import org.apache.http.message.BasicNameValuePair;
|
||||
import org.jboss.arquillian.drone.webdriver.htmlunit.DroneHtmlUnitDriver;
|
||||
import org.junit.Assert;
|
||||
import org.keycloak.OAuth2Constants;
|
||||
import org.keycloak.TokenVerifier;
|
||||
|
@ -1249,6 +1250,17 @@ public class OAuthClient {
|
|||
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 {
|
||||
|
||||
|
|
|
@ -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 {
|
||||
}
|
|
@ -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<SessionRepresentation> sessions = SimpleHttp.doGet(getAccountUrl("sessions"), httpClient).auth(tokenUtil.getToken()).asJson(new TypeReference<List<SessionRepresentation>>() {});
|
||||
|
||||
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<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
|
||||
public void listApplications() throws IOException {
|
||||
assumeFeatureEnabled(ACCOUNT_API);
|
||||
|
|
|
@ -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>>() {
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue