diff --git a/.github/workflows/all_tests.yml b/.github/workflows/all_tests.yml index c549704..002acae 100644 --- a/.github/workflows/all_tests.yml +++ b/.github/workflows/all_tests.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - keycloak_version: [ 26.5.0 , 26.5.1, 26.5.2, 26.5.3, 26.5.4, 26.5.5, 26.5.6, 26.5.7 ] + keycloak_version: [ 26.6.0, 26.6.1 ] continue-on-error: false steps: - uses: actions/checkout@v6 diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index 47c7df5..dad51df 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -32,7 +32,7 @@ jobs: - name: Setup Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: '4.0.2' + ruby-version: '4.0.3' bundler-cache: true cache-version: 0 # Increment this number if you need to re-download cached gems working-directory: 'docs' @@ -44,7 +44,7 @@ jobs: env: JEKYLL_ENV: production - name: Upload artifact - uses: actions/upload-pages-artifact@v4 + uses: actions/upload-pages-artifact@v5 with: path: 'docs/_site/' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 979a6eb..1e65614 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: run: mv provider/target/keycloak-geoaware-shaded.jar provider/target/keycloak-geoaware.jar - name: GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: files: provider/target/keycloak-geoaware.jar fail_on_unmatched_files: true diff --git a/docker-compose.yml b/docker-compose.yml index 076be8e..229dcc7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,7 +2,7 @@ services: keycloak: container_name: keycloak hostname: keycloak - image: quay.io/keycloak/keycloak:26.5.7 + image: quay.io/keycloak/keycloak:26.6.1 depends_on: - postgres environment: @@ -60,7 +60,7 @@ services: export-keycloak-realm: container_name: keycloak-export - image: quay.io/keycloak/keycloak:26.5.7 + image: quay.io/keycloak/keycloak:26.6.1 profiles: - export-realm depends_on: @@ -76,7 +76,7 @@ services: import-keycloak-realm: container_name: keycloak-import - image: quay.io/keycloak/keycloak:26.5.7 + image: quay.io/keycloak/keycloak:26.6.1 profiles: - import-realm depends_on: diff --git a/docs/release-notes/v1.1.0.md b/docs/release-notes/v1.1.0.md new file mode 100644 index 0000000..b048752 --- /dev/null +++ b/docs/release-notes/v1.1.0.md @@ -0,0 +1,38 @@ +--- +title: Version 1.1.0 +layout: default +parent: Release Notes +--- + +# Version 1.1.0 + +## Requirements + +- Keycloak 26.6 or later + +## New Features + +### Location Condition Authenticator + +A new [conditional authenticator](https://www.keycloak.org/docs/latest/server_admin/#conditions) that gates authentication flows based on the geographic location of the client's IP address. + +Configure it in any Keycloak authentication flow under **Condition - GeoAware Location**. + +**Match by:** + +- Country ISO code (e.g. `DE`, `US`) +- Country name (e.g. `Germany`) +- Continent name (e.g. `Europe`) + +**Options:** + +- Configurable list of allowed values (multi-value) +- **Inverse decision**: revert the match result to block instead of allow, or vice versa + +When no GeoIP provider is available for the client's IP, the condition evaluates to `false` (fails closed). + +## Changes since 1.0.0 + +- Added Location Condition authenticator +- Fixed `Platform.getTmpDirectory()` removal in Keycloak 26.6: MaxMind autodownload now uses `KeycloakApplication.getTmpDirectory()` +- Extended test matrix to cover Keycloak 26.6.0 and 26.6.1 diff --git a/integration-test/src/test/java/org/b2code/loginhistory/JpaLoginHistoryProviderTest.java b/integration-test/src/test/java/org/b2code/loginhistory/JpaLoginHistoryProviderTest.java index 9c4d124..ebc81ab 100644 --- a/integration-test/src/test/java/org/b2code/loginhistory/JpaLoginHistoryProviderTest.java +++ b/integration-test/src/test/java/org/b2code/loginhistory/JpaLoginHistoryProviderTest.java @@ -1,5 +1,6 @@ package org.b2code.loginhistory; +import jakarta.ws.rs.NotFoundException; import jakarta.ws.rs.core.Response; import org.b2code.base.BaseTest; import org.b2code.config.MaxmindGeoLiteFileServerConfig; @@ -10,6 +11,11 @@ import org.keycloak.testframework.annotations.KeycloakIntegrationTest; import org.keycloak.testframework.util.ApiUtil; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.Timestamp; +import java.util.Map; import java.util.UUID; @KeycloakIntegrationTest(config = MaxmindGeoLiteFileServerConfig.class) @@ -29,20 +35,65 @@ void testLoginHistoryIsDeletedOnAccountDeletion() { } @Test - void testLoginHistoryIsDeletedOnRealmDeletion() { - login(); - - int loginRecordsBeforeDeletion = getLoginRecords().size(); - Assertions.assertEquals(1, loginRecordsBeforeDeletion, "Exactly one login history record is expected after initial login"); + void testLoginHistoryIsDeletedOnRealmDeletion() throws Exception { + // Use a throwaway realm so the framework-managed realm stays intact. + // Deleting the framework realm causes OAuthClient.close() to fail in afterEach + // (the re-created realm lacks the test OAuth client), aborting the destroy loop + // and cascading 409s to every subsequent test's beforeEach. + String tempRealmName = "temp-realm-" + UUID.randomUUID(); + RealmRepresentation tempRealmRep = new RealmRepresentation(); + tempRealmRep.setRealm(tempRealmName); + tempRealmRep.setEnabled(true); + adminClient.realms().create(tempRealmRep); + + // Create a real user in the temp realm — required because deleteByRealmId + // queries UserEntity.realmId to find which records to delete. + String tempUserId; + UserRepresentation tempUser = new UserRepresentation(); + tempUser.setUsername("temp-user-" + UUID.randomUUID()); + tempUser.setEnabled(true); + try (Response resp = adminClient.realm(tempRealmName).users().create(tempUser)) { + Assertions.assertEquals(201, resp.getStatus(), "Temp user must be created successfully"); + tempUserId = ApiUtil.getCreatedId(resp); + } - RealmRepresentation realmRep = realm.getCreatedRepresentation(); - realm.admin().remove(); + // Insert a login record directly — the plugin uses event listeners for deletion, + // so a directly-inserted record still exercises the cascade correctly. + Map dbConfig = testDatabase.serverConfig(); + try (Connection conn = DriverManager.getConnection( + dbConfig.get("db-url"), dbConfig.get("db-username"), dbConfig.get("db-password")); + PreparedStatement ps = conn.prepareStatement( + "INSERT INTO geoaware_login_record (ID, USER_ID, TIMESTAMP, IP_ADDRESS) VALUES (?, ?, ?, ?)")) { + ps.setString(1, UUID.randomUUID().toString()); + ps.setString(2, tempUserId); + ps.setTimestamp(3, new Timestamp(System.currentTimeMillis())); + ps.setString(4, "127.0.0.1"); + ps.executeUpdate(); + } - int loginRecordsAfterDeletion = getLoginRecords().size(); - Assertions.assertEquals(0, loginRecordsAfterDeletion, "Login history records must be deleted after realm deletion"); + Assertions.assertEquals(1, loginHistory.getAllByUserId(tempUserId).size(), + "Login record must exist before realm deletion"); + + adminClient.realm(tempRealmName).remove(); + + // Wait for deletion — Keycloak 26.6+ deletes asynchronously. + long deadline = System.currentTimeMillis() + 30_000; + while (System.currentTimeMillis() < deadline) { + try { + adminClient.realm(tempRealmName).toRepresentation(); + } catch (NotFoundException e) { + break; + } + try { + Thread.sleep(200); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } - // Re-create the realm to not confuse the test framework - adminClient.realms().create(realmRep); + Assertions.assertEquals(0, loginHistory.getAllByUserId(tempUserId).size(), + "Login history records must be deleted after realm deletion"); } @Test diff --git a/pom.xml b/pom.xml index 226b668..fcc9dc6 100644 --- a/pom.xml +++ b/pom.xml @@ -54,10 +54,10 @@ 21 UTF-8 - 26.5.7 + 26.6.1 5.0.2 3.4.0 - 1.18.44 + 1.18.46 1.1.1 3.1.1 1.28.0 diff --git a/provider/src/main/java/org/b2code/authentication/ip/LocationConditionalAuthenticatorFactory.java b/provider/src/main/java/org/b2code/authentication/ip/LocationConditionalAuthenticatorFactory.java new file mode 100644 index 0000000..2381e26 --- /dev/null +++ b/provider/src/main/java/org/b2code/authentication/ip/LocationConditionalAuthenticatorFactory.java @@ -0,0 +1,128 @@ +package org.b2code.authentication.ip; + +import com.google.auto.service.AutoService; +import org.b2code.PluginConstants; +import org.b2code.authentication.ip.condition.LocationCondition; +import org.keycloak.Config; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.authenticators.conditional.ConditionalAuthenticator; +import org.keycloak.authentication.authenticators.conditional.ConditionalAuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; + +import java.util.List; + +/** + * Factory for {@link LocationCondition}, registered as a Keycloak conditional authenticator. + * Provides configuration properties for location type (country, country ISO code, continent), + * match values, and an option to invert the decision. + */ +@AutoService(AuthenticatorFactory.class) +public class LocationConditionalAuthenticatorFactory implements ConditionalAuthenticatorFactory { + + public static final String PROVIDER_ID = PluginConstants.PLUGIN_NAME_LOWER_CASE + "-condition-location"; + + + public static final String COUNTRY="Country name"; + public static final String COUNTRY_ISO_CODE="Country Iso Code"; + public static final String CONTINENT="Continent name"; + + public static final String CONFIG_VALUE_TYPE="value-type"; + public static final String CONFIG_VALUES="values"; + public static final String CONFIG_REVERT="revert"; + + private static final List CONDITION_OPTIONS = List.of(COUNTRY_ISO_CODE, COUNTRY, CONTINENT); + + + @Override + public void init(Config.Scope config) { + + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + + } + + @Override + public void close() { + + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return "Condition - " + PluginConstants.PLUGIN_NAME + " Location"; + } + + @Override + public String getHelpText() { + return LocationCondition.instance().getHelpText(); + } + + + @Override + public boolean isConfigurable() { + return true; + } + + private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.DISABLED + }; + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES.clone(); + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public List getConfigProperties() { + + ProviderConfigProperty valuesType = new ProviderConfigProperty(); + valuesType.setName(CONFIG_VALUE_TYPE); + valuesType.setLabel("Values type"); + valuesType.setType(ProviderConfigProperty.LIST_TYPE); + valuesType.setRequired(true); + valuesType.setDefaultValue(COUNTRY_ISO_CODE); + valuesType.setHelpText("Select the type of value."); + valuesType.setOptions(CONDITION_OPTIONS); + + + ProviderConfigProperty values = new ProviderConfigProperty(); + values.setName(CONFIG_VALUES); + values.setLabel("Values"); + values.setType(ProviderConfigProperty.MULTIVALUED_STRING_TYPE); + values.setDefaultValue(""); + values.setHelpText("List of the match values"); + + ProviderConfigProperty revertDecision = new ProviderConfigProperty(); + revertDecision.setName(CONFIG_REVERT); + revertDecision.setLabel("Inverse decision"); + revertDecision.setType(ProviderConfigProperty.BOOLEAN_TYPE); + revertDecision.setDefaultValue(false); + revertDecision.setHelpText("Revert the condition decision"); + + return ProviderConfigurationBuilder.create() + .property(valuesType) + .property(values) + .property(revertDecision) + .build(); + } + + @Override + public ConditionalAuthenticator getSingleton() { + return LocationCondition.SINGLETON; + } +} diff --git a/provider/src/main/java/org/b2code/authentication/ip/condition/LocationCondition.java b/provider/src/main/java/org/b2code/authentication/ip/condition/LocationCondition.java new file mode 100644 index 0000000..4c24e55 --- /dev/null +++ b/provider/src/main/java/org/b2code/authentication/ip/condition/LocationCondition.java @@ -0,0 +1,102 @@ +package org.b2code.authentication.ip.condition; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.jbosslog.JBossLog; +import org.b2code.authentication.ip.LocationConditionalAuthenticatorFactory; +import org.b2code.geoip.persistence.entity.GeoIpInfo; +import org.b2code.geoip.provider.GeoIpProvider; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.authenticators.conditional.ConditionalAuthenticator; +import org.keycloak.models.Constants; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +import java.util.Map; + +/** + * Conditional authenticator that matches based on the geographic location (country or continent) + * of the client's IP address. Used in Keycloak authentication flows to gate access by location. + */ +@JBossLog +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class LocationCondition implements ConditionalAuthenticator { + + public static final String LABEL = "Location condition"; + + public static final LocationCondition SINGLETON = new LocationCondition(); + + + @Override + public boolean matchCondition(AuthenticationFlowContext context) { + if (context.getAuthenticatorConfig() == null) { + log.warn("No authenticator config set for location condition — condition not matched"); + return false; + } + + Map config = context.getAuthenticatorConfig().getConfig(); + var session = context.getSession(); + GeoIpProvider geoipProvider = session.getProvider(GeoIpProvider.class); + String ip = session.getContext().getConnection().getRemoteAddr(); + + String valueType = config.getOrDefault(LocationConditionalAuthenticatorFactory.CONFIG_VALUE_TYPE, LocationConditionalAuthenticatorFactory.COUNTRY_ISO_CODE); + String values = config.getOrDefault(LocationConditionalAuthenticatorFactory.CONFIG_VALUES, ""); + boolean reverseDecision = Boolean.parseBoolean(config.getOrDefault(LocationConditionalAuthenticatorFactory.CONFIG_REVERT, "false")); + + var value = "??"; + GeoIpInfo geoIpInfo = geoipProvider != null ? geoipProvider.getIpInfo(ip) : null; + if (geoIpInfo != null) { + value = switch (valueType) { + case LocationConditionalAuthenticatorFactory.COUNTRY_ISO_CODE -> geoIpInfo.getCountryIsoCode() != null ? geoIpInfo.getCountryIsoCode() : "??"; + case LocationConditionalAuthenticatorFactory.COUNTRY -> geoIpInfo.getCountry() != null ? geoIpInfo.getCountry() : "??"; + case LocationConditionalAuthenticatorFactory.CONTINENT -> geoIpInfo.getContinent() != null ? geoIpInfo.getContinent() : "??"; + default -> throw new IllegalStateException("Unexpected value: " + valueType); + }; + } else { + log.warnf("Checking location condition for IP: %s, can't get ip info", ip); + } + + var configuredValues = Constants.CFG_DELIMITER_PATTERN.splitAsStream(values).toList(); + var isMatch = configuredValues.contains(value); + log.debugf("Checking location condition for IP: %s, Value: %s in Values %s => %s", ip, value, values, isMatch); + + if (reverseDecision) + return !isMatch; + return isMatch; + } + + @Override + public void action(AuthenticationFlowContext context) { + // Not used + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + + } + + @Override + public void close() { + // Does nothing + } + + public String getLabel() { + return LABEL; + } + + /** Returns a human-readable description of this condition for the Keycloak admin console. */ + public String getHelpText() { + return "Check the location of the user's IP address against a list of allowed locations."; + } + + /** Returns the singleton instance of this conditional authenticator. */ + public static LocationCondition instance() { + return SINGLETON; + } +} diff --git a/provider/src/main/java/org/b2code/geoip/provider/maxmind/UpdateMaxmindDatabaseFileTask.java b/provider/src/main/java/org/b2code/geoip/provider/maxmind/UpdateMaxmindDatabaseFileTask.java index b3112e7..1157d22 100644 --- a/provider/src/main/java/org/b2code/geoip/provider/maxmind/UpdateMaxmindDatabaseFileTask.java +++ b/provider/src/main/java/org/b2code/geoip/provider/maxmind/UpdateMaxmindDatabaseFileTask.java @@ -13,10 +13,11 @@ import org.keycloak.broker.provider.util.SimpleHttp; import org.keycloak.connections.httpclient.HttpClientProvider; import org.keycloak.models.KeycloakSession; -import org.keycloak.platform.Platform; import org.keycloak.timer.ScheduledTask; import org.keycloak.utils.KeycloakSessionUtil; +import org.keycloak.services.resources.KeycloakApplication; + import java.io.File; import java.io.IOException; import java.io.InputStream; @@ -87,7 +88,7 @@ private boolean isUpdateAvailable(KeycloakSession session, DatabaseReader reader private Optional updateDatabase(KeycloakSession session, String accountId, String licenseKey) { try { - File tempDir = Platform.getPlatform().getTmpDirectory(); + File tempDir = new File(KeycloakApplication.getTmpDirectory()); if (!tempDir.exists()) { if (!tempDir.mkdirs()) { log.errorf("Temporary directory %s does not exist and could not be created.", tempDir.getAbsolutePath());