Create an SPI for DeviceActivityManager

closes #17134
This commit is contained in:
Douglas Palmer 2023-01-31 16:14:22 -08:00 committed by Marek Posolda
parent 9c431f3b90
commit 1d75000a0e
18 changed files with 207 additions and 84 deletions

View file

@ -66,10 +66,6 @@
<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>

View file

@ -20,15 +20,11 @@ package org.keycloak.device;
import javax.ws.rs.core.HttpHeaders;
import java.io.IOException;
import org.jboss.logging.Logger;
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>
@ -36,13 +32,6 @@ import ua_parser.Parser;
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 {
UA_PARSER = new Parser();
}
/** Returns the device information associated with the given {@code userSession}.
*
@ -72,7 +61,7 @@ public class DeviceActivityManager {
* @param session the keycloak session
*/
public static void attachDevice(UserSessionModel userSession, KeycloakSession session) {
DeviceRepresentation current = getDeviceFromUserAgent(session);
DeviceRepresentation current = session.getProvider(DeviceRepresentationProvider.class).deviceRepresentation();
if (current != null) {
try {
@ -82,72 +71,4 @@ public class DeviceActivityManager {
}
}
}
private static DeviceRepresentation getDeviceFromUserAgent(KeycloakSession session) {
KeycloakContext context = session.getContext();
if (context.getRequestHeaders() == null) {
return null;
}
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

@ -0,0 +1,14 @@
package org.keycloak.device;
import org.keycloak.models.KeycloakSession;
import org.keycloak.provider.Provider;
import org.keycloak.representations.account.DeviceRepresentation;
public interface DeviceRepresentationProvider extends Provider {
DeviceRepresentation deviceRepresentation();
@Override
default void close() {
}
}

View file

@ -0,0 +1,20 @@
package org.keycloak.device;
import org.keycloak.Config;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.provider.ProviderFactory;
public interface DeviceRepresentationProviderFactory extends ProviderFactory<DeviceRepresentationProvider> {
@Override
default void init(Config.Scope config) {
}
@Override
default void postInit(KeycloakSessionFactory factory) {
}
@Override
default void close() {
}
}

View file

@ -0,0 +1,30 @@
package org.keycloak.device;
import org.keycloak.provider.Provider;
import org.keycloak.provider.ProviderFactory;
import org.keycloak.provider.Spi;
public class DeviceRepresentationSpi implements Spi {
public static final String NAME = "deviceRepresentation";
@Override
public boolean isInternal() {
return true;
}
@Override
public String getName() {
return NAME;
}
@Override
public Class<? extends Provider> getProviderClass() {
return DeviceRepresentationProvider.class;
}
@Override
public Class<? extends ProviderFactory> getProviderFactoryClass() {
return DeviceRepresentationProviderFactory.class;
}
}

View file

@ -89,3 +89,4 @@ org.keycloak.services.clientpolicy.condition.ClientPolicyConditionSpi
org.keycloak.services.clientpolicy.executor.ClientPolicyExecutorSpi
org.keycloak.services.clientpolicy.ClientPolicyManagerSpi
org.keycloak.userprofile.UserProfileSpi
org.keycloak.device.DeviceRepresentationSpi

View file

@ -180,6 +180,10 @@
<groupId>com.webauthn4j</groupId>
<artifactId>webauthn4j-core</artifactId>
</dependency>
<dependency>
<groupId>com.github.ua-parser</groupId>
<artifactId>uap-java</artifactId>
</dependency>
</dependencies>
<build>
<plugins>

View file

@ -0,0 +1,30 @@
package org.keycloak.device;
import org.keycloak.models.KeycloakSession;
import ua_parser.Parser;
public class DeviceRepresentationProviderFactoryImpl implements DeviceRepresentationProviderFactory {
private volatile Parser parser;
public static final String PROVIDER_ID = "deviceRepresentation";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public DeviceRepresentationProvider create(KeycloakSession session) {
lazyInit(session);
return new DeviceRepresentationProviderImpl(session, parser);
}
private void lazyInit(KeycloakSession session) {
if(parser == null) {
synchronized (this) {
parser = new Parser();
}
}
}
}

View file

@ -0,0 +1,90 @@
package org.keycloak.device;
import org.jboss.logging.Logger;
import org.keycloak.models.KeycloakContext;
import org.keycloak.models.KeycloakSession;
import org.keycloak.representations.account.DeviceRepresentation;
import ua_parser.Client;
import ua_parser.Parser;
import javax.ws.rs.core.HttpHeaders;
public class DeviceRepresentationProviderImpl implements DeviceRepresentationProvider {
private static final Logger logger = Logger.getLogger(DeviceActivityManager.class);
private static final int USER_AGENT_MAX_LENGTH = 512;
private final Parser parser;
private final KeycloakSession session;
DeviceRepresentationProviderImpl(KeycloakSession session, Parser parser) {
this.session = session;
this.parser = parser;
}
@Override
public DeviceRepresentation deviceRepresentation() {
KeycloakContext context = session.getContext();
if (context.getRequestHeaders() == null) {
return null;
}
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 = 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

@ -0,0 +1 @@
org.keycloak.device.DeviceRepresentationProviderFactoryImpl

View file

@ -19,6 +19,7 @@ package org.keycloak.testsuite.model.session;
import org.infinispan.client.hotrod.RemoteCache;
import org.junit.Test;
import org.keycloak.device.DeviceRepresentationProvider;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
@ -43,6 +44,7 @@ import static org.hamcrest.Matchers.containsInAnyOrder;
@RequireProvider(UserSessionProvider.class)
@RequireProvider(value = HotRodConnectionProvider.class, only = DefaultHotRodConnectionProviderFactory.PROVIDER_ID)
@RequireProvider(DeviceRepresentationProvider.class)
public class HotRodUserSessionClientSessionRelationshipTest extends KeycloakModelTest {
private String realmId;

View file

@ -18,6 +18,7 @@ package org.keycloak.testsuite.model.session;
import org.infinispan.client.hotrod.RemoteCache;
import org.infinispan.commons.CacheException;
import org.keycloak.device.DeviceRepresentationProvider;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
@ -65,6 +66,7 @@ import static org.hamcrest.Matchers.containsInAnyOrder;
@RequireProvider(UserProvider.class)
@RequireProvider(RealmProvider.class)
@RequireProvider(UserSessionProvider.class)
@RequireProvider(DeviceRepresentationProvider.class)
public class OfflineSessionPersistenceTest extends KeycloakModelTest {
private static final int USER_COUNT = 50;

View file

@ -18,6 +18,7 @@
package org.keycloak.testsuite.model.session;
import org.junit.Test;
import org.keycloak.device.DeviceRepresentationProvider;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
@ -53,6 +54,7 @@ import static org.keycloak.utils.LockObjectsForModification.lockUserSessionsForM
@RequireProvider(UserSessionProvider.class)
@RequireProvider(DeviceRepresentationProvider.class)
public class UserSessionConcurrencyTest extends KeycloakModelTest {
private String realmId;

View file

@ -18,6 +18,7 @@
package org.keycloak.testsuite.model.session;
import org.junit.Test;
import org.keycloak.device.DeviceRepresentationProvider;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
@ -33,6 +34,7 @@ import static org.hamcrest.MatcherAssert.assertThat;
@RequireProvider(value = UserSessionProvider.class, only = MapUserSessionProviderFactory.PROVIDER_ID)
@RequireProvider(RealmProvider.class)
@RequireProvider(DeviceRepresentationProvider.class)
public class UserSessionExpirationTest extends KeycloakModelTest {
private String realmId;

View file

@ -22,6 +22,7 @@ import org.infinispan.client.hotrod.RemoteCache;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
import org.keycloak.device.DeviceRepresentationProvider;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
import org.keycloak.models.KeycloakSession;
@ -61,6 +62,7 @@ import static org.keycloak.connections.infinispan.InfinispanConnectionProvider.U
@RequireProvider(UserSessionProvider.class)
@RequireProvider(UserProvider.class)
@RequireProvider(RealmProvider.class)
@RequireProvider(DeviceRepresentationProvider.class)
public class UserSessionInitializerTest extends KeycloakModelTest {
private String realmId;

View file

@ -21,6 +21,7 @@ import org.junit.Assert;
import org.junit.Test;
import org.keycloak.OAuth2Constants;
import org.keycloak.common.util.Time;
import org.keycloak.device.DeviceRepresentationProvider;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
@ -70,6 +71,7 @@ import java.util.LinkedList;
@RequireProvider(value = UserSessionProvider.class, only = InfinispanUserSessionProviderFactory.PROVIDER_ID)
@RequireProvider(UserProvider.class)
@RequireProvider(RealmProvider.class)
@RequireProvider(DeviceRepresentationProvider.class)
public class UserSessionPersisterProviderTest extends KeycloakModelTest {
private static final int USER_SESSION_COUNT = 2000;

View file

@ -20,6 +20,7 @@ import org.hamcrest.Matchers;
import org.infinispan.client.hotrod.RemoteCache;
import org.junit.Assert;
import org.junit.Test;
import org.keycloak.device.DeviceRepresentationProvider;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
@ -69,6 +70,7 @@ import static org.keycloak.testsuite.model.session.UserSessionPersisterProviderT
@RequireProvider(UserSessionProvider.class)
@RequireProvider(UserProvider.class)
@RequireProvider(RealmProvider.class)
@RequireProvider(DeviceRepresentationProvider.class)
public class UserSessionProviderModelTest extends KeycloakModelTest {
private String realmId;

View file

@ -23,6 +23,7 @@ import org.junit.Assume;
import org.junit.Test;
import org.keycloak.common.util.Time;
import org.keycloak.connections.infinispan.InfinispanConnectionProvider;
import org.keycloak.device.DeviceRepresentationProvider;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.Constants;
@ -65,6 +66,7 @@ import org.keycloak.testsuite.model.RequireProvider;
@RequireProvider(value=UserSessionProvider.class, only={"infinispan"})
@RequireProvider(UserProvider.class)
@RequireProvider(RealmProvider.class)
@RequireProvider(DeviceRepresentationProvider.class)
public class UserSessionProviderOfflineModelTest extends KeycloakModelTest {
private String realmId;