From bb756d5de63709eba53c645adae06e9c7b6dbde0 Mon Sep 17 00:00:00 2001 From: Jesse Van Hill Date: Fri, 22 Aug 2025 10:30:20 -0500 Subject: [PATCH 1/2] Create initial example user soure profile and secret provider modules. --- README.md | 6 + secret-provider/.gitignore | 40 + secret-provider/README.md | 83 ++ secret-provider/pom.xml | 83 ++ .../secret-provider-build/doc/index.html | 11 + .../secret-provider-build/license.html | 11 + secret-provider/secret-provider-build/pom.xml | 63 ++ .../secret-provider-gateway/pom.xml | 53 ++ .../secretprovider/mongodb/GatewayHook.java | 78 ++ .../mongodb/MongoDbSecretProvider.java | 111 +++ .../MongoDbSecretProviderExtensionPoint.java | 60 ++ .../MongoDbSecretProviderResource.java | 99 +++ .../mongodb/MongoDbSecretProvider.properties | 9 + .../SlackNotificationExtensionPoint.java | 2 +- .../slack/SlackNotification.properties | 2 +- user-source-profile/.gitignore | 40 + user-source-profile/README.md | 26 + user-source-profile/pom.xml | 83 ++ .../user-source-build/doc/index.html | 11 + .../user-source-build/license.html | 11 + user-source-profile/user-source-build/pom.xml | 63 ++ .../user-source-gateway/pom.xml | 53 ++ .../usersource/mongodb/GatewayHook.java | 98 +++ .../usersource/mongodb/MongoDbUserSource.java | 801 ++++++++++++++++++ .../MongoDbUserSourceExtensionPoint.java | 78 ++ .../mongodb/MongoDbUserSourceResource.java | 158 ++++ .../mongodb/MongoDbUserSource.properties | 15 + 27 files changed, 2146 insertions(+), 2 deletions(-) create mode 100644 secret-provider/.gitignore create mode 100644 secret-provider/README.md create mode 100644 secret-provider/pom.xml create mode 100644 secret-provider/secret-provider-build/doc/index.html create mode 100644 secret-provider/secret-provider-build/license.html create mode 100644 secret-provider/secret-provider-build/pom.xml create mode 100644 secret-provider/secret-provider-gateway/pom.xml create mode 100644 secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/GatewayHook.java create mode 100644 secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProvider.java create mode 100644 secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProviderExtensionPoint.java create mode 100644 secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProviderResource.java create mode 100644 secret-provider/secret-provider-gateway/src/main/resources/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProvider.properties create mode 100644 user-source-profile/.gitignore create mode 100644 user-source-profile/README.md create mode 100644 user-source-profile/pom.xml create mode 100644 user-source-profile/user-source-build/doc/index.html create mode 100644 user-source-profile/user-source-build/license.html create mode 100644 user-source-profile/user-source-build/pom.xml create mode 100644 user-source-profile/user-source-gateway/pom.xml create mode 100644 user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/GatewayHook.java create mode 100644 user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSource.java create mode 100644 user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSourceExtensionPoint.java create mode 100644 user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSourceResource.java create mode 100644 user-source-profile/user-source-gateway/src/main/resources/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSource.properties diff --git a/README.md b/README.md index b6b092fc..46383590 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,15 @@ Adds a datasource to the report designer that can retrieve JSON data via a REST ##### [Scripting Function (RPC)](scripting-function) Adds a system.example.multiply script that can be executed from both a client and a Gateway. Also demonstrates how the client can call a method in the Gateway via RPC. +##### [Secret Provider](secret-provider) +Adds a Secret Provider that allows you to store and retrieve secrets in the Gateway. The secrets are stored in a Mongo DB backend. + ##### [Slack Alarm Notification](slack-alarm-notification) Adds a Slack Alarm Notification type that handles alarm notifications through Slack's outgoing webhooks. +##### [User Source Profile](user-source-profile) +Adds a User Source Profile that allows you to manage users and roles in the Gateway. Users and roles stored in a Mongo DB backend. + ##### [Vision Component](vision-component) Creates a Hello World component that can be dragged onto a window in the Designer. diff --git a/secret-provider/.gitignore b/secret-provider/.gitignore new file mode 100644 index 00000000..fe35c67d --- /dev/null +++ b/secret-provider/.gitignore @@ -0,0 +1,40 @@ +# A general .gitignore for Gradle or Maven built Ignition SDK projects that +# use IntelliJ or Eclipse as an IDE + +# Ignition Module files +*.modl + +# Java class files +*.class + +# generated files +bin/ +gen/ + +# Local configuration file used for proj. specific settings (sdk paths, etc) +local.properties + +# Eclipse project files +.classpath +.project + +# Intellij project files +*.iml +*.ipr +*.iws +.idea/ + +# git repos +.git/ + +# hg repos +.hg/ + +# Maven related +*/target/ +*.versionsBackup + +# Gradle related files and caches +.gradletasknamecache +.gradle/ +build/ diff --git a/secret-provider/README.md b/secret-provider/README.md new file mode 100644 index 00000000..8d1ea9dd --- /dev/null +++ b/secret-provider/README.md @@ -0,0 +1,83 @@ +# Secret Provider + +This module provides an example implementation of the `SecretProvider` interface, which allows for the user of stored +secrets by Ignition. It is backed by a MongoDB backend, and the secrets are expected to be stored in the MongoDB +database as the JSON returned from `SystemEncryptionService.entryToJson(Plaintext)`. + +In a production environment, you may want to use the MongoDB Connector module, but for simplicity, this module uses the +MongoDB Java driver directly. This allows for easy testing without the need to add another module to Ignition. + +This implementation is set up for easy testing, and the default configuration should connect to a local, unsecured +MongoDB. To start one in a Docker container, run: + +```bash +docker run -d -p 27017:27017 --rm --name insecure-mongo mongo +``` + +Should you wish to start a MongoDB instance requiring authentication, you can use the following command. Remember +to replace `admin` and `secret` with your desired username and password and configure your secret provider to use these +credentials. + +```bash +docker run -d -p 27017:27017 --rm --name secure-mongo \ + -e MONGO_INITDB_ROOT_USERNAME=admin \ + -e MONGO_INITDB_ROOT_PASSWORD=secret \ + mongo +``` + +## Adding Secrets to MongoDB + +Since there is currently no write method for SecretProvider, you will need to populate the database with secrets +manually. + +### Encrypting Secrets +First, generate a secret calling the Ignition system encryption REST API. Your API token will need to have Gateway +write permissions, and you will need to replace `password` with the secret you want to encrypt. + +```bash +curl -s -H "Content-Type: text/plain" -H "X-Ignition-API-Token: ${API_TOKEN}" \ + http://localhost:8088/data/api/v1/encryption/encrypt -d password | json_pp +``` + +Which will result in a response similar to the following: + +```json +{ + "ciphertext" : "NhkoviQxLsUZ2g", + "encrypted_key" : "fYxDhnE_nKXiWGwGBJVaEWhojeg7duY3Y3G4dF89sKdjuf5iiX2nKw", + "iv" : "TTGQPncN-rlf70Bq", + "protected" : "eyJhbGciOiJBMjU2S1ciLCJlbmMiOiJBMjU2R0NNIiwiaWF0IjoxNzU1NjM0NDg3LCJ6aXAiOiJERUYifQ", + "tag" : "sMU9-vhCyWHxLCH190eQ3A" +} +``` + +### Inserting Secrets into MongoDB + +Next, you will need to insert the encrypted secret into the MongoDB database using Mongo shell or a MongoDB client: + +```bash +mongosh mongodb://localhost:27017/secrets_db +``` + +or if you are using a secure MongoDB instance with authentication: + +```bash +mongosh mongodb://localhost:27017/secrets_db -u admin -p password --authenticationDatabase admin +``` + +Press `Enter` to connect to the database, then run the following command to insert the secret, replacing the +`secretname` with the name of your secret and the `ciphertext` with the actual ciphertext generated by the Ignition +REST API: + +```javascript +secrets_db> db.mycollection.insertOne({ + "name": "secretname", + "ciphertext": { + "ciphertext" : "NhkoviQxLsUZ2g", + "encrypted_key" : "fYxDhnE_nKXiWGwGBJVaEWhojeg7duY3Y3G4dF89sKdjuf5iiX2nKw", + "iv" : "TTGQPncN-rlf70Bq", + "protected" : "eyJhbGciOiJBMjU2S1ciLCJlbmMiOiJBMjU2R0NNIiwiaWF0IjoxNzU1NjM0NDg3LCJ6aXAiOiJERUYifQ", + "tag" : "sMU9-vhCyWHxLCH190eQ3A" + } + }) +``` diff --git a/secret-provider/pom.xml b/secret-provider/pom.xml new file mode 100644 index 00000000..aa0b834b --- /dev/null +++ b/secret-provider/pom.xml @@ -0,0 +1,83 @@ + + + 4.0.0 + + com.inductiveautomation.ignition.examples + secret-provider + pom + 1.0.0-SNAPSHOT + + + secret-provider-build + secret-provider-gateway + + + + 8.3.0-beta3 + ${ignition-platform-version} + MongoDB Secret Provider Example + Adds a secret provider backed by MongoDB to Ignition. + UTF-8 + UTF-8 + + + + + releases + https://nexus.inductiveautomation.com/repository/inductiveautomation-releases + + true + always + + + false + + + + + + + ia-releases + https://nexus.inductiveautomation.com/repository/inductiveautomation-releases + + false + + + true + always + + + + + ia-snapshots + https://nexus.inductiveautomation.com/repository/inductiveautomation-snapshots + + true + always + + + false + + + + + ia-thirdparty + https://nexus.inductiveautomation.com/repository/inductiveautomation-thirdparty + + true + always + + + false + + + + + ia-beta + https://nexus.inductiveautomation.com/repository/inductiveautomation-beta + + + + diff --git a/secret-provider/secret-provider-build/doc/index.html b/secret-provider/secret-provider-build/doc/index.html new file mode 100644 index 00000000..50ad9ff1 --- /dev/null +++ b/secret-provider/secret-provider-build/doc/index.html @@ -0,0 +1,11 @@ + + + + +Module User Manual + + +

Instructions

+

This is the root of my module's user manual.

+ + \ No newline at end of file diff --git a/secret-provider/secret-provider-build/license.html b/secret-provider/secret-provider-build/license.html new file mode 100644 index 00000000..fbb051a2 --- /dev/null +++ b/secret-provider/secret-provider-build/license.html @@ -0,0 +1,11 @@ + + + + +Module License + + +

Example License

+

This is the license for my module. You must agree to it.

+ + \ No newline at end of file diff --git a/secret-provider/secret-provider-build/pom.xml b/secret-provider/secret-provider-build/pom.xml new file mode 100644 index 00000000..3aa43f6f --- /dev/null +++ b/secret-provider/secret-provider-build/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + com.inductiveautomation.ignition.examples + secret-provider + 1.0.0-SNAPSHOT + + + secret-provider-build + + + + com.inductiveautomation.ignition.examples + secret-provider-gateway + ${project.version} + + + + + + + com.inductiveautomation.ignitionsdk + ignition-maven-plugin + 1.2.0 + + + + package + + modl + + + + + + + + secret-provider-gateway + G + + + + com.inductiveautomation.ignition.examples.secret-provider + ${module-name} + ${module-description} + ${project.version} + ${ignition-platform-version} + license.html + + + + G + com.inductiveautomation.ignition.examples.secretprovider.mongodb.GatewayHook + + + + + + + \ No newline at end of file diff --git a/secret-provider/secret-provider-gateway/pom.xml b/secret-provider/secret-provider-gateway/pom.xml new file mode 100644 index 00000000..67a966dd --- /dev/null +++ b/secret-provider/secret-provider-gateway/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + + com.inductiveautomation.ignition.examples + secret-provider + 1.0.0-SNAPSHOT + + + secret-provider-gateway + + + + com.inductiveautomation.ignitionsdk + ignition-common + ${ignition-sdk-version} + pom + provided + + + + com.inductiveautomation.ignitionsdk + gateway-api + ${ignition-sdk-version} + pom + provided + + + + + org.mongodb + mongodb-driver-sync + 5.5.1 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.2 + + 17 + 17 + + + + + diff --git a/secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/GatewayHook.java b/secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/GatewayHook.java new file mode 100644 index 00000000..9f3a15d1 --- /dev/null +++ b/secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/GatewayHook.java @@ -0,0 +1,78 @@ +package com.inductiveautomation.ignition.examples.secretprovider.mongodb; + +import com.inductiveautomation.ignition.common.BundleUtil; +import com.inductiveautomation.ignition.common.licensing.LicenseState; +import com.inductiveautomation.ignition.gateway.config.ExtensionPoint; +import com.inductiveautomation.ignition.gateway.config.NamedResourceHandler; +import com.inductiveautomation.ignition.gateway.config.migration.ExtensionPointRecordMigrationStrategy; +import com.inductiveautomation.ignition.gateway.config.migration.IdbMigrationStrategy; +import com.inductiveautomation.ignition.gateway.config.migration.NamedRecordMigrationStrategy; +import com.inductiveautomation.ignition.gateway.config.migration.SingletonRecordMigrationStrategy; +import com.inductiveautomation.ignition.gateway.model.AbstractGatewayModuleHook; +import com.inductiveautomation.ignition.gateway.model.GatewayContext; + +import java.util.Collections; +import java.util.List; + +/** + * The GatewayHook is the main entry point for a Gateway module. It is instantiated very early in the Gateway startup + * process, before most other services are available. It is responsible for registering extension points, migration + * strategies, and other module-level functionality. + */ +public class GatewayHook extends AbstractGatewayModuleHook { + public static final String MODULE_ID = "com.inductiveautomation.ignition.examples.mongodb-secret-provider"; + + private NamedResourceHandler namedResourceHandler; + + @Override + public void setup(GatewayContext context) { + + // Register our localized properties with BundleUtil + BundleUtil.get().addBundle("MongoDbSecretProvider", getClass(), "MongoDbSecretProvider"); + + // Register our named resource handler for the MongoDbSecretProviderResource type. + namedResourceHandler = NamedResourceHandler.newBuilder(MongoDbSecretProviderResource.META) + .context(context) + .build(); + } + + @Override + public void startup(LicenseState licenseState) { + namedResourceHandler.startup(); + } + + @Override + public void shutdown() { + BundleUtil.get().removeBundle("MongoDbSecretProvider"); + namedResourceHandler.shutdown(); + } + + /** + * Here we tell the configuration management system about our "migration strategy", which adapts the legacy <=8.1 + * "PersistentRecord" storage to our new configuration management approach. You can use one of the existing builders + * here, such as {@link ExtensionPointRecordMigrationStrategy}, {@link NamedRecordMigrationStrategy}, + * or {@link SingletonRecordMigrationStrategy}, or implement your own entirely via the {@link IdbMigrationStrategy} + * interface. + */ + @Override + public List getRecordMigrationStrategies() { + // This sample wasn't available for <=8.1, so we don't need any migration strategies as there are no records + // to migrate. + // + // In fact, we could just omit this method entirely, as the default implementation returns an empty list. + // However, we include it here for demonstration purposes. + return Collections.emptyList(); + } + + /** + * In a change from the <=8.1 model, any and all extension points, no matter which extension point they + * extend, must be declared in your GatewayHook. They will be separated by the gateway and the appropriate lifecycle + * management will be handled for you. + */ + @Override + public List> getExtensionPoints() { + return List.of( + new MongoDbSecretProviderExtensionPoint() + ); + } +} diff --git a/secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProvider.java b/secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProvider.java new file mode 100644 index 00000000..2bcebd08 --- /dev/null +++ b/secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProvider.java @@ -0,0 +1,111 @@ +package com.inductiveautomation.ignition.examples.secretprovider.mongodb; + +import com.inductiveautomation.ignition.common.gson.JsonElement; +import com.inductiveautomation.ignition.common.gson.JsonParser; +import com.inductiveautomation.ignition.common.util.LoggerEx; +import com.inductiveautomation.ignition.gateway.secrets.*; +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.MongoCredential; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.MongoDatabase; +import org.apache.commons.lang3.StringUtils; +import org.bson.Document; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; + + +public class MongoDbSecretProvider implements SecretProvider { + + private static final LoggerEx LOGGER = LoggerEx.newBuilder().build(MongoDbSecretProvider.class); + + // The names of the MongoDB collections used in this example. + private static final String COLLECTION_SECRETS = "secrets"; + + // The keys used in the MongoDB documents. + private static final String KEY_NAME = "name"; + private static final String KEY_CIPHERTEXT = "ciphertext"; + + // Instance fields for this class. + private final SecretProviderContext context; + private final MongoDbSecretProviderResource settings; + private final MongoClient mongoClient; + private final MongoDatabase database; + + MongoDbSecretProvider(SecretProviderContext context, MongoDbSecretProviderResource settings) { + this.context = context; + this.settings = settings; + + // Create a builder for the MongoDB client settings using the provided connection string. + MongoClientSettings.Builder builder = MongoClientSettings.builder() + .applyConnectionString(new ConnectionString(settings.connectionString())); + + // Enable authentication if username and password are provided. + if (StringUtils.isNotBlank(settings.username()) && settings.password() != null) { + try (Plaintext plaintext = Secret.create(context.getGatewayContext(), settings.password()).getPlaintext()) { + builder.credential( + MongoCredential.createCredential( + settings.username(), + settings.authenticationDb(), + plaintext.getAsString(StandardCharsets.UTF_8).toCharArray()) + ); + } catch (Exception e) { + throw new RuntimeException("Failed to create MongoDB credential", e); + } + } + + // We don't use a try-with-resources block because we want the client to remain open for the lifetime + // of this secret provider. If we were to use this in production, we probably would want to re-evaluate + // this decision. This code could possibly lead to resource leaks if not managed properly or issues with + // connectivity if the MongoDB instance is restarted or becomes unavailable. + mongoClient = MongoClients.create(builder.build()); + database = mongoClient.getDatabase(settings.databaseName()); + } + + @Override + public List list() throws SecretProviderException { + MongoCollection collection = database.getCollection(COLLECTION_SECRETS); + + try { + return collection.find() + .map(doc -> doc.getString(KEY_NAME)) + .into(new java.util.ArrayList<>()); + } catch (Exception e) { + LOGGER.error("Failed to list secrets from MongoDB", e); + throw new SecretProviderException("Failed to list secrets", e); + } + } + + @Override + public Plaintext read(String s) throws SecretProviderException { + Objects.requireNonNull(s, "Secret name cannot be null"); + MongoCollection collection = database.getCollection(COLLECTION_SECRETS); + + // Search for the secret by name. + Document doc = null; + try { + doc = collection.find(new Document(KEY_NAME, s)).first(); + } catch (Exception e) { + LOGGER.error("Failed to read secret '" + s + "' from MongoDB", e); + throw new SecretProviderException("Failed to read secret", e); + } + + // If the secret was not found, throw an exception. + if (doc == null) { + throw new SecretNotFoundException("Secret '" + s + "' does not exist"); + } + + // Decrypt the ciphertext using the system encryption service. + try { + JsonElement element = JsonParser.parseString(doc.get(KEY_CIPHERTEXT, Document.class).toJson()); + return context.getGatewayContext().getSystemEncryptionService().decryptFromJson(element); + } catch (Exception e) { + LOGGER.error("Failed to decrypt secret '" + s + "'", e); + throw new SecretProviderException("Failed to decrypt secret", e); + } + } +} diff --git a/secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProviderExtensionPoint.java b/secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProviderExtensionPoint.java new file mode 100644 index 00000000..f3ee8087 --- /dev/null +++ b/secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProviderExtensionPoint.java @@ -0,0 +1,60 @@ +package com.inductiveautomation.ignition.examples.secretprovider.mongodb; + +import com.inductiveautomation.ignition.gateway.config.AbstractExtensionPoint; +import com.inductiveautomation.ignition.gateway.config.ExtensionPointConfig; +import com.inductiveautomation.ignition.gateway.config.ValidationErrors; +import com.inductiveautomation.ignition.gateway.dataroutes.openapi.SchemaUtil; +import com.inductiveautomation.ignition.gateway.secrets.*; +import com.inductiveautomation.ignition.gateway.web.nav.ExtensionPointResourceForm; +import com.inductiveautomation.ignition.gateway.web.nav.WebUiComponent; + +import java.util.Optional; + +public class MongoDbSecretProviderExtensionPoint + extends AbstractExtensionPoint + implements SecretProviderType { + + public static final String EXTENSION_POINT_TYPE = "MONGODB"; + + public MongoDbSecretProviderExtensionPoint() { + super(EXTENSION_POINT_TYPE, + "MongoDbSecretProvider.SecretProviderType.Name", + "MongoDbSecretProvider.SecretProviderType.Desc"); + } + + public SecretProvider createProvider(SecretProviderContext context) throws SecretProviderTypeException { + ExtensionPointConfig config = context.getResource().config(); + MongoDbSecretProviderResource settings = getSettings(config) + .orElseThrow(() -> new IllegalStateException("Secret provider configuration missing for: " + + context.getResource().name())); + return new MongoDbSecretProvider(context, settings); + } + + @Override + public Optional defaultSettings() { + return Optional.of(MongoDbSecretProviderResource.DEFAULT); + } + + @Override + public Optional getWebUiComponent(ComponentType type) { + return Optional.of( + new ExtensionPointResourceForm( + SecretProviderConfig.RESOURCE_TYPE, + "Secret Provider", + EXTENSION_POINT_TYPE, + SchemaUtil.fromType(SecretProviderConfig.class), + SchemaUtil.fromType(MongoDbSecretProviderResource.class) + ) + ); + } + + @Override + protected void validate(MongoDbSecretProviderResource settings, ValidationErrors.Builder errors) { + /* + Optionally, add validation to an incoming configuration object + These error messages will be conveyed back to the standard web UI automatically + */ + // errors.requireNotNull("someField", settings.auditProfileName()); + super.validate(settings, errors); + } +} diff --git a/secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProviderResource.java b/secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProviderResource.java new file mode 100644 index 00000000..b5459167 --- /dev/null +++ b/secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProviderResource.java @@ -0,0 +1,99 @@ +package com.inductiveautomation.ignition.examples.secretprovider.mongodb; + +import com.inductiveautomation.ignition.common.resourcecollection.ResourceType; +import com.inductiveautomation.ignition.gateway.config.ResourceTypeMeta; +import com.inductiveautomation.ignition.gateway.dataroutes.openapi.annotations.*; +import com.inductiveautomation.ignition.gateway.secrets.SecretConfig; +import com.inductiveautomation.ignition.gateway.web.nav.FormFieldType; +import org.apache.commons.lang3.StringUtils; + +/** + * Configuration for a MongoDB secret provider. This resource will be persisted to disk as part of the secret + * provider config. + */ +public record MongoDbSecretProviderResource( + @FormCategory("CUSTOM SETTINGS") + @Label("Connection String") + @FormField(FormFieldType.TEXT) + @DefaultValue("mongodb://localhost:27017") + @Required +// @DescriptionKey("MongoDbSecretProviderResource.connectionString.Desc") + @Description("The connection string to use to connect to the MongoDB instance.") + String connectionString, + + @FormCategory("CUSTOM SETTINGS") + @Label("Database Name") + @FormField(FormFieldType.TEXT) + @DefaultValue("secrets_db") + @Required +// @DescriptionKey("MongoDbSecretProviderResource.databaseName.Desc") + @Description("The MongoDB database name to use to store the secret provider documents.") + String databaseName, + + @FormCategory("CUSTOM SETTINGS") + @Label("Username") + @FormField(FormFieldType.TEXT) +// @DescriptionKey("MongoDbSecretProviderResource.username.Desc") + @Description("The username to use to connect to the MongoDB instance.") + String username, + + @FormCategory("CUSTOM SETTINGS") + @Label("Password") + @FormField(FormFieldType.SECRET) +// @DescriptionKey("MongoDbSecretProviderResource.password.Desc") + @Description("The password to use to connect to the MongoDB instance.") + SecretConfig password, + + @FormCategory("CUSTOM SETTINGS") + @Label("Authentication Database") + @FormField(FormFieldType.TEXT) + @DefaultValue("admin") +// @DescriptionKey("MongoDbSecretProviderResource.authenticationDb.Desc") + @Description(""" + The name of the database to use for authentication. This is typically the "admin" database in MongoDB. + """) + String authenticationDb +) { + public static final ResourceType RESOURCE_TYPE = new ResourceType(GatewayHook.MODULE_ID, "mongodb-user-source"); + + public static final MongoDbSecretProviderResource DEFAULT = new MongoDbSecretProviderResource( + "mongodb://localhost:27017", + "secrets_db", + null, + null, + "admin" + ); + + public static final ResourceTypeMeta META = ResourceTypeMeta.newBuilder(MongoDbSecretProviderResource.class) + .resourceType(RESOURCE_TYPE) + .categoryName("MongoDB Secret Provider") + .defaultConfig(DEFAULT) + .buildValidator((resource, validator) -> { + // Custom validation logic for the resource. This gets called anytime the resource system creates + // an instance of this resource, such as when a secret provider is created, updated, or loaded. + }) + .build(); + + /** + * Canonical constructor that fills in default values for any null or blank parameters. + * + * @param connectionString The MongoDB connection string. + * @param databaseName The name of the database containing secret provider documents. + * @param username The username for authenticating to MongoDB. + * @param password The password for authenticating to MongoDB. + * @param authenticationDb The database to authenticate against. + */ + public MongoDbSecretProviderResource { + if (StringUtils.isBlank(connectionString)) { + connectionString = DEFAULT.connectionString(); + } + + if (StringUtils.isBlank(databaseName)) { + databaseName = DEFAULT.databaseName(); + } + + if (StringUtils.isBlank(authenticationDb)) { + authenticationDb = DEFAULT.authenticationDb(); + } + } +} diff --git a/secret-provider/secret-provider-gateway/src/main/resources/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProvider.properties b/secret-provider/secret-provider-gateway/src/main/resources/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProvider.properties new file mode 100644 index 00000000..b536682e --- /dev/null +++ b/secret-provider/secret-provider-gateway/src/main/resources/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProvider.properties @@ -0,0 +1,9 @@ +SecretProviderType.Name=MongoDB Secret Provider (Example) +SecretProviderType.Desc=Example secret provider that uses MongoDB as the backend data store. + + +connectionString.Desc=The connection string to use to connect to the MongoDB instance. +databaseName.Desc=The MongoDB database name to use to store the secret provider documents. +username.Desc=The username to use to connect to the MongoDB instance. +password.Desc=The password to use to connect to the MongoDB instance. +authenticationDb.Desc=The name of the database to use for authentication. This is typically the "admin" database in MongoDB. diff --git a/slack-alarm-notification/slack-notification-gateway/src/main/java/io/ia/ignition/sdk/examples/slack/profile/SlackNotificationExtensionPoint.java b/slack-alarm-notification/slack-notification-gateway/src/main/java/io/ia/ignition/sdk/examples/slack/profile/SlackNotificationExtensionPoint.java index 3de620a0..d7c8fff5 100644 --- a/slack-alarm-notification/slack-notification-gateway/src/main/java/io/ia/ignition/sdk/examples/slack/profile/SlackNotificationExtensionPoint.java +++ b/slack-alarm-notification/slack-notification-gateway/src/main/java/io/ia/ignition/sdk/examples/slack/profile/SlackNotificationExtensionPoint.java @@ -32,7 +32,7 @@ public class SlackNotificationExtensionPoint public SlackNotificationExtensionPoint() { super(TYPE_ID, - "SlackNotification.SlackNotificationProfileType.Name", + "SlackNotification.SlackNotificationProfileType.DisplayName", "SlackNotification.SlackNotificationProfileType.Description", SlackNotificationProfileResource.class); diff --git a/slack-alarm-notification/slack-notification-gateway/src/main/resources/io/ia/ignition/sdk/examples/slack/SlackNotification.properties b/slack-alarm-notification/slack-notification-gateway/src/main/resources/io/ia/ignition/sdk/examples/slack/SlackNotification.properties index 164a4c16..61061358 100644 --- a/slack-alarm-notification/slack-notification-gateway/src/main/resources/io/ia/ignition/sdk/examples/slack/SlackNotification.properties +++ b/slack-alarm-notification/slack-notification-gateway/src/main/resources/io/ia/ignition/sdk/examples/slack/SlackNotification.properties @@ -1,4 +1,4 @@ -SlackNotificationProfileType.Name=Slack Notification +SlackNotificationProfileType.DisplayName=Slack Notification SlackNotificationProfileType.Description=Send alarm notifications via Slack. Properties.Message.DisplayName=Message diff --git a/user-source-profile/.gitignore b/user-source-profile/.gitignore new file mode 100644 index 00000000..fe35c67d --- /dev/null +++ b/user-source-profile/.gitignore @@ -0,0 +1,40 @@ +# A general .gitignore for Gradle or Maven built Ignition SDK projects that +# use IntelliJ or Eclipse as an IDE + +# Ignition Module files +*.modl + +# Java class files +*.class + +# generated files +bin/ +gen/ + +# Local configuration file used for proj. specific settings (sdk paths, etc) +local.properties + +# Eclipse project files +.classpath +.project + +# Intellij project files +*.iml +*.ipr +*.iws +.idea/ + +# git repos +.git/ + +# hg repos +.hg/ + +# Maven related +*/target/ +*.versionsBackup + +# Gradle related files and caches +.gradletasknamecache +.gradle/ +build/ diff --git a/user-source-profile/README.md b/user-source-profile/README.md new file mode 100644 index 00000000..0b23b4e7 --- /dev/null +++ b/user-source-profile/README.md @@ -0,0 +1,26 @@ +# UserSourceProfile + +This module provides an implementation of the `UserSourceProvider` interface, which allows for the management of user +profiles in a system. It includes methods for creating, updating, and deleting user profiles, as well as retrieving user +information. It is backed by a MongoDB backend. + +In a production environment, you may want to use the MongoDB Connector module, but for simplicity, this module uses the +MongoDB Java driver directly. This allows for easy testing without the need to add another module to Ignition. + +This implementation is set up for easy testing, and the default configuration should connect to a local, unsecured +MongoDB. To start one in a Docker container, run: + +```bash +docker run -d -p 27017:27017 --rm --name insecure-mongo mongo +``` + +Should you wish to start a MongoDB instance requiring authentication, you can use the following command. Remember +to replace `admin` and `secret` with your desired username and password and configure your user source to use these +credentials. + +```bash +docker run -d -p 27017:27017 --rm --name secure-mongo \ + -e MONGO_INITDB_ROOT_USERNAME=admin \ + -e MONGO_INITDB_ROOT_PASSWORD=secret \ + mongo +``` diff --git a/user-source-profile/pom.xml b/user-source-profile/pom.xml new file mode 100644 index 00000000..1e3a1505 --- /dev/null +++ b/user-source-profile/pom.xml @@ -0,0 +1,83 @@ + + + 4.0.0 + + com.inductiveautomation.ignition.examples + user-source-profile + pom + 1.0.0-SNAPSHOT + + + user-source-build + user-source-gateway + + + + 8.3.0-beta3 + ${ignition-platform-version} + MongoDB User Source Example + Adds an example user source backed by MongoDB to Ignition. + UTF-8 + UTF-8 + + + + + releases + https://nexus.inductiveautomation.com/repository/inductiveautomation-releases + + true + always + + + false + + + + + + + ia-releases + https://nexus.inductiveautomation.com/repository/inductiveautomation-releases + + false + + + true + always + + + + + ia-snapshots + https://nexus.inductiveautomation.com/repository/inductiveautomation-snapshots + + true + always + + + false + + + + + ia-thirdparty + https://nexus.inductiveautomation.com/repository/inductiveautomation-thirdparty + + true + always + + + false + + + + + ia-beta + https://nexus.inductiveautomation.com/repository/inductiveautomation-beta + + + + diff --git a/user-source-profile/user-source-build/doc/index.html b/user-source-profile/user-source-build/doc/index.html new file mode 100644 index 00000000..50ad9ff1 --- /dev/null +++ b/user-source-profile/user-source-build/doc/index.html @@ -0,0 +1,11 @@ + + + + +Module User Manual + + +

Instructions

+

This is the root of my module's user manual.

+ + \ No newline at end of file diff --git a/user-source-profile/user-source-build/license.html b/user-source-profile/user-source-build/license.html new file mode 100644 index 00000000..fbb051a2 --- /dev/null +++ b/user-source-profile/user-source-build/license.html @@ -0,0 +1,11 @@ + + + + +Module License + + +

Example License

+

This is the license for my module. You must agree to it.

+ + \ No newline at end of file diff --git a/user-source-profile/user-source-build/pom.xml b/user-source-profile/user-source-build/pom.xml new file mode 100644 index 00000000..d9dbeea6 --- /dev/null +++ b/user-source-profile/user-source-build/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + com.inductiveautomation.ignition.examples + user-source-profile + 1.0.0-SNAPSHOT + + + user-source-build + + + + com.inductiveautomation.ignition.examples + user-source-gateway + ${project.version} + + + + + + + com.inductiveautomation.ignitionsdk + ignition-maven-plugin + 1.2.0 + + + + package + + modl + + + + + + + + user-source-gateway + G + + + + com.inductiveautomation.ignition.examples.user-source + ${module-name} + ${module-description} + ${project.version} + ${ignition-platform-version} + license.html + + + + G + com.inductiveautomation.ignition.examples.usersource.mongodb.GatewayHook + + + + + + + \ No newline at end of file diff --git a/user-source-profile/user-source-gateway/pom.xml b/user-source-profile/user-source-gateway/pom.xml new file mode 100644 index 00000000..e6c2c81e --- /dev/null +++ b/user-source-profile/user-source-gateway/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + + com.inductiveautomation.ignition.examples + user-source-profile + 1.0.0-SNAPSHOT + + + user-source-gateway + + + + com.inductiveautomation.ignitionsdk + ignition-common + ${ignition-sdk-version} + pom + provided + + + + com.inductiveautomation.ignitionsdk + gateway-api + ${ignition-sdk-version} + pom + provided + + + + + org.mongodb + mongodb-driver-sync + 5.5.1 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.2 + + 17 + 17 + + + + + diff --git a/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/GatewayHook.java b/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/GatewayHook.java new file mode 100644 index 00000000..995a397b --- /dev/null +++ b/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/GatewayHook.java @@ -0,0 +1,98 @@ +package com.inductiveautomation.ignition.examples.usersource.mongodb; + +import com.inductiveautomation.ignition.common.BundleUtil; +import com.inductiveautomation.ignition.common.licensing.LicenseState; +import com.inductiveautomation.ignition.gateway.config.ExtensionPoint; +import com.inductiveautomation.ignition.gateway.config.NamedResourceHandler; +import com.inductiveautomation.ignition.gateway.config.migration.ExtensionPointRecordMigrationStrategy; +import com.inductiveautomation.ignition.gateway.config.migration.IdbMigrationStrategy; +import com.inductiveautomation.ignition.gateway.config.migration.NamedRecordMigrationStrategy; +import com.inductiveautomation.ignition.gateway.config.migration.SingletonRecordMigrationStrategy; +import com.inductiveautomation.ignition.gateway.model.AbstractGatewayModuleHook; +import com.inductiveautomation.ignition.gateway.model.GatewayContext; + +import java.util.Collections; +import java.util.List; + +/** + * The GatewayHook is the main entry point for a Gateway module. It is instantiated very early in the Gateway startup + * process, before most other services are available. It is responsible for registering extension points, migration + * strategies, and other module-level functionality. + */ +public class GatewayHook extends AbstractGatewayModuleHook { + public static final String MODULE_ID = "com.inductiveautomation.ignition.examples.mongodb-user-source"; + + private NamedResourceHandler namedResourceHandler; + private GatewayContext context; + + @Override + public void setup(GatewayContext context) { + this.context = context; + + // Register our localized properties with BundleUtil + BundleUtil.get().addBundle("MongoDbUserSource", getClass(), "MongoDbUserSource"); + + // Register our named resource handler for the MongoDbUserSourceResource type. + namedResourceHandler = NamedResourceHandler.newBuilder(MongoDbUserSourceResource.META) + .context(context) + .build(); + } + + @Override + public void startup(LicenseState licenseState) { + // Start up the named resource handler to make our resource type available in the system. + namedResourceHandler.startup(); + + // Register user properties that can be used to extend the properties of a user in the system. + context.getUserSourceManager().registerUserProperties( + MongoDbUserSource.FAVORITE_COLOR, + MongoDbUserSource.FAVORITE_NUMBER, + MongoDbUserSource.LIKES_APPLES + ); + } + + @Override + public void shutdown() { + // Unregister the user properties we registered at startup. + context.getUserSourceManager().unregisterUserProperties( + MongoDbUserSource.FAVORITE_COLOR, + MongoDbUserSource.FAVORITE_NUMBER, + MongoDbUserSource.LIKES_APPLES + ); + + // Unregister our resource handler and remove our localized properties from BundleUtil + BundleUtil.get().removeBundle("MongoDbUserSource"); + + // Shut down the named resource handler + namedResourceHandler.shutdown(); + } + + /** + * Here we tell the configuration management system about our "migration strategy", which adapts the legacy <=8.1 + * "PersistentRecord" storage to our new configuration management approach. You can use one of the existing builders + * here, such as {@link ExtensionPointRecordMigrationStrategy}, {@link NamedRecordMigrationStrategy}, + * or {@link SingletonRecordMigrationStrategy}, or implement your own entirely via the {@link IdbMigrationStrategy} + * interface. + */ + @Override + public List getRecordMigrationStrategies() { + // This sample wasn't available for <=8.1, so we don't need any migration strategies as there are no records + // to migrate. + // + // In fact, we could just omit this method entirely, as the default implementation returns an empty list. + // However, we include it here for demonstration purposes. + return Collections.emptyList(); + } + + /** + * In a change from the <=8.1 model, any and all extension points, no matter which extension point they + * extend, must be declared in your GatewayHook. They will be separated by the gateway and the appropriate lifecycle + * management will be handled for you. + */ + @Override + public List> getExtensionPoints() { + return List.of( + new MongoDbUserSourceExtensionPoint() + ); + } +} diff --git a/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSource.java b/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSource.java new file mode 100644 index 00000000..7dbb75ce --- /dev/null +++ b/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSource.java @@ -0,0 +1,801 @@ +package com.inductiveautomation.ignition.examples.usersource.mongodb; + +import com.inductiveautomation.ignition.common.config.BasicConfigurationProperty; +import com.inductiveautomation.ignition.common.config.ConfigurationProperty; +import com.inductiveautomation.ignition.common.gui.UICallback; +import com.inductiveautomation.ignition.common.role.BasicRole; +import com.inductiveautomation.ignition.common.role.Role; +import com.inductiveautomation.ignition.common.user.*; +import com.inductiveautomation.ignition.common.user.schedule.ScheduleAdjustment; +import com.inductiveautomation.ignition.common.util.LogUtil; +import com.inductiveautomation.ignition.common.util.LoggerEx; +import com.inductiveautomation.ignition.gateway.authentication.impl.PasswordExpirationBypass; +import com.inductiveautomation.ignition.gateway.secrets.Plaintext; +import com.inductiveautomation.ignition.gateway.secrets.Secret; +import com.inductiveautomation.ignition.gateway.user.AbstractUserSourceProfile; +import com.inductiveautomation.ignition.gateway.user.PasswordExpiredException; +import com.inductiveautomation.ignition.gateway.user.UserSourceManager; +import com.inductiveautomation.ignition.gateway.user.UserSourceProfileKernel; +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import com.mongodb.MongoCredential; +import com.mongodb.client.*; +import com.mongodb.client.model.Filters; +import com.mongodb.client.model.Updates; +import com.mongodb.client.result.DeleteResult; +import org.apache.commons.lang3.StringUtils; +import org.apache.log4j.Level; +import org.bson.Document; +import org.bson.conversions.Bson; +import org.joda.time.DateTime; +import org.joda.time.Days; + +import javax.annotation.Nonnull; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; + +/** + * An example implementation of a User Source Profile that uses MongoDB as the backend data store. + *

+ * This class demonstrates how to implement user authentication, role management, and user management + * using a MongoDB database. It includes methods for adding, altering, and removing users and roles, + * as well as authenticating users via username/password or badge. + *

+ * Note: This is a simplified example for demonstration purposes and not intended for use in a production environment. + *

+ * Some notable omissions in this example include: + *

    + *
  • Password hashing (passwords are stored in plain text for simplicity)
  • + *
  • Password complexity (min length, char classes, etc.)
  • + *
  • Input validation and sanitization
  • + *
  • Comprehensive error handling (particularly in network connectivity) and logging
  • + *
  • Configuration options for MongoDB connection (e.g., SSL)
  • + *
+ */ +public class MongoDbUserSource extends AbstractUserSourceProfile { + + private static final LoggerEx LOGGER = LoggerEx.newBuilder().build(MongoDbUserSource.class); + + // Custom configuration properties for this user source profile. + public static final ConfigurationProperty FAVORITE_COLOR = + new BasicConfigurationProperty<>("favoriteColor", "MongoDbUserSource.favoriteColor.Desc", + "", String.class, "blue"); + public static final ConfigurationProperty FAVORITE_NUMBER = + new BasicConfigurationProperty<>("favoriteNumber", "MongoDbUserSource.favoriteNumber.Desc", + "", Integer.class, 42); + public static final ConfigurationProperty LIKES_APPLES = + new BasicConfigurationProperty<>("likesApples", "MongoDbUserSource.likesApples.Desc", + "", Boolean.class, true); + + // The names of the MongoDB collections used in this example. + private static final String COLLECTION_ROLES = "roles"; + private static final String COLLECTION_USERS = "users"; + + // The keys used in the MongoDB documents. + private static final String KEY_ID = "id"; + private static final String KEY_NAME = "name"; + private static final String KEY_PASSWORD = "password"; + private static final String KEY_PASSWORD_DATE = "passwordDate"; + private static final String KEY_PASSWORD_HISTORY = "passwordHistory"; + private static final String KEY_LASTNAME = "lastName"; + private static final String KEY_FIRSTNAME = "firstName"; + private static final String KEY_SCHEDULE = "schedule"; + private static final String KEY_NOTES = "notes"; + private static final String KEY_BADGE = "badge"; + private static final String KEY_LANGUAGE = "language"; + private static final String KEY_ROLES = "roles"; + private static final String KEY_SA = "scheduleAdjustments"; + private static final String KEY_AVAILABLE = "available"; + private static final String KEY_START = "start"; + private static final String KEY_END = "end"; + private static final String KEY_NOTE = "note"; + private static final String KEY_CI = "contactInfo"; + private static final String KEY_TYPE = "type"; + private static final String KEY_VALUE = "value"; + private static final String KEY_FAVORITE_COLOR = "favoriteColor"; + private static final String KEY_FAVORITE_NUMBER = "favoriteNumber"; + private static final String KEY_LIKES_APPLES = "likesApples"; + + // Instance fields for this class. + private MongoClientSettings mongoClientSettings; + private MongoClient mongoClient; + private MongoDatabase database; + private MongoDbUserSourceResource settings; + + /** + * Constructor for the {@link MongoDbUserSource}. + * + * @param kernel the UserSourceProfileKernel that provides the context for this profile. + */ + MongoDbUserSource(UserSourceProfileKernel kernel) { + super(kernel); + } + + /** + * Sets the settings for this user source profile. + * + * @param settings the {@link MongoDbUserSourceResource} containing configuration settings. + */ + public void setSettings(MongoDbUserSourceResource settings) { + this.settings = settings; + } + + @Override + public void startup(UserSourceManager manager) { + super.startup(manager); + + // Create a builder for the MongoDB client settings using the provided connection string. + MongoClientSettings.Builder builder = MongoClientSettings.builder() + .applyConnectionString(new ConnectionString(settings.connectionString())); + + // Enable authentication if username and password are provided. + if (StringUtils.isNotBlank(settings.username()) && settings.password() != null) { + try (Plaintext plaintext = Secret.create(manager.getGatewayContext(), settings.password()).getPlaintext()) { + builder.credential( + MongoCredential.createCredential( + settings.username(), + settings.authenticationDb(), + plaintext.getAsString(StandardCharsets.UTF_8).toCharArray()) + ); + } catch (Exception e) { + throw new RuntimeException("Failed to create MongoDB credential", e); + } + } + + this.mongoClientSettings = builder.build(); + } + + @Override + public void shutdown() { + super.shutdown(); + + if (mongoClient != null) { + mongoClient.close(); + } + } + + /** + * Retrieves the MongoDB database instance for this user source profile. + * + * @return the {@link MongoDatabase} instance. + */ + private MongoDatabase getDatabase() { + if (database == null) { + if (mongoClient == null) { + // We don't use a try-with-resources block because we want the client to remain open for the lifetime + // of this user source profile. + mongoClient = MongoClients.create(mongoClientSettings); + } + + // Get the database from the MongoDB client using the configured database name. + database = mongoClient.getDatabase(settings.databaseName()); + } + return database; + } + + @Override + public AuthenticatedUser authenticate(AuthChallenge challenge) throws Exception { + return authenticate(challenge, false); + } + + /** + * Authenticates a user based on the provided authentication challenge. + * + * @param challenge the authentication challenge containing credentials. + * @param bypassExpiration whether to bypass password expiration checks. + * @return an AuthenticatedUser if authentication is successful, null otherwise. + * @throws Exception if an error occurs during authentication. + */ + private AuthenticatedUser authenticate(AuthChallenge challenge, boolean bypassExpiration) throws Exception { + // Handle any of the authentication challenges that this user source supports. + if (challenge instanceof SimpleAuthChallenge usernameAndPassword) { + return authenticateUsernamePassword(usernameAndPassword, bypassExpiration); + } else if (challenge instanceof BadgeAuthChallenge badgeChallenge) { + return authenticateBadge(badgeChallenge, bypassExpiration); + } else if (challenge instanceof PasswordExpirationBypass wrapped) { + return authenticate(wrapped.actual(), true); + } else { + throw new Exception("Authentication using username and password or badge is required."); + } + } + + /** + * Authenticates a user using username and password. + * + * @param challenge the authentication challenge containing username and password. + * @param bypassExpiration whether to bypass password expiration checks. + * @return an AuthenticatedUser if authentication is successful, null otherwise. + * @throws Exception if an error occurs during authentication. + */ + private AuthenticatedUser authenticateUsernamePassword(SimpleAuthChallenge challenge, + boolean bypassExpiration) throws Exception { + + // Check if the user is locked out. + String uname = challenge.username(); + if (isLockedOut(uname)) { + LogUtil.logOncePerMinute( + LOGGER, + Level.INFO, + Level.DEBUG, + String.format("User '%s' is locked out", uname) + ); + return null; + } + + try { + // Validate the user exists + MongoCollection collection = getDatabase().getCollection(COLLECTION_USERS); + Document document = collection.find(Filters.eq(KEY_NAME, uname)).first(); + if (document != null) { + // Found the user, now validate the password + if (isPasswordInvalid(document, challenge.password(), bypassExpiration)) { + return null; + } + + return new BasicAuthenticatedUser(toUser(document), new Date()); + } else { + return null; + } + } catch (PasswordExpiredException ex) { + throw ex; + } catch (Exception ex) { + throw new Exception("Unexpected exception during internal authenticator authentication.", ex); + } + } + + /** + * Checks if the provided password is valid for the user document. + * + * @param document the user document from the database. + * @param pwd the password to validate. + * @param bypassExpiration whether to bypass password expiration checks. + * @return true if the password is invalid, false otherwise. + * @throws PasswordExpiredException if the password is expired and bypassExpiration is false. + */ + private boolean isPasswordInvalid(Document document, String pwd, boolean bypassExpiration) + throws PasswordExpiredException { + String uname = document.getString(KEY_NAME); + if (!StringUtils.equals(pwd, document.getString(KEY_PASSWORD))) { + if (notifyFailedAttempt(uname)) { + LogUtil.logOncePerMinute( + LOGGER, + Level.INFO, + Level.DEBUG, + String.format("User '%s' is now locked out", uname) + ); + } + return true; + } + + // Password is valid, now check if the password is expired + if (settings.passwordMaxAge() > 0 && !bypassExpiration) { + long pwdTimestamp = document.getLong(KEY_PASSWORD_DATE); + if (pwdTimestamp > 0) { + DateTime passwordCreatedOn = new DateTime(pwdTimestamp); + DateTime now = DateTime.now(); + int days = Days.daysBetween(passwordCreatedOn.toLocalDate(), now.toLocalDate()).getDays(); + if (days > settings.passwordMaxAge()) { + throw new PasswordExpiredException(getName(), uname); + } + } + } + + return false; + } + + /** + * Authenticates a user using a badge. + * + * @param challenge the badge authentication challenge. + * @param bypassExpiration whether to bypass password expiration checks. + * @return an AuthenticatedUser if authentication is successful, null otherwise. + * @throws Exception if an error occurs during authentication. + */ + private AuthenticatedUser authenticateBadge(BadgeAuthChallenge challenge, + boolean bypassExpiration) throws Exception { + String badge = challenge.badge(); + try { + // Validate the user exists + MongoCollection collection = getDatabase().getCollection(COLLECTION_USERS); + Bson filter = Filters.eq(KEY_BADGE, badge); + + // Find all users with the specified badge + FindIterable findIterable = collection.find(Filters.eq(KEY_BADGE, badge)); + List matches = new ArrayList<>(); + for (Document doc : findIterable) { + matches.add(doc); + } + + // We should only have one user with a given badge; otherwise, the badge is ambiguous. + if (matches.isEmpty()) { + return null; + } else if (matches.size() == 1) { + Document document = matches.get(0); + + // Found the user, now validate user is not locked out. + String uname = document.getString(KEY_NAME); + if (isLockedOut(uname)) { + LogUtil.logOncePerMinute( + LOGGER, + Level.INFO, + Level.DEBUG, + String.format("User '%s' is locked out", uname) + ); + return null; + } + + // Validate the secret if provided + if (challenge.hasSecret() && isPasswordInvalid(document, challenge.secret(), bypassExpiration)) { + return null; + } + + return new BasicAuthenticatedUser(toUser(document), new Date()); + } else { + String badgeUsers = matches.stream() + .map(doc -> doc.getString(KEY_NAME)) + .collect(Collectors.joining(", ", "'", "'")); + LogUtil.logOncePerMinute(LOGGER, Level.WARN, Level.DEBUG, String.format( + "User with badge '%s' is ambiguous - could be one of: %s", + badge, + badgeUsers) + ); + return null; + } + } catch (PasswordExpiredException ex) { + throw ex; + } catch (Exception ex) { + throw new Exception("Unexpected exception during internal authenticator authentication.", ex); + } + } + + @Override + public void addRole(Role role, UICallback ui) throws Exception { + if (!role.getProfileName().equals(getProfileName())) { + ui.warn("User source does not match role. Unexpected results may occur."); + } + + // Validate the role doesn't already exist + MongoCollection collection = getDatabase().getCollection(COLLECTION_ROLES); + if (collection.find(Filters.eq(KEY_NAME, role.getName())).first() != null) { + throw new IllegalArgumentException("Role with name '" + role.getName() + "' already exists."); + } + + // Create a new role document from the role. + Document document = new Document() + .append(KEY_ID, UUID.randomUUID().toString()) + .append(KEY_NAME, role.getName()) + .append(KEY_NOTES, role.getNotes()); + collection.insertOne(document); + } + + @Override + public void alterRole(Role role, UICallback ui) throws Exception { + MongoCollection collection = getDatabase().getCollection(COLLECTION_ROLES); + + // Validate the role already exists + Bson filter = Filters.eq(KEY_ID, role.getId()); + Document document = collection.find(filter).first(); + if (document == null) { + throw new IllegalArgumentException("Cannot alter role: role with ID '" + role.getId() + "' not found."); + } + + // Does a role with the specified name exist already if we are changing names? + Bson filter2 = Filters.and(Filters.ne(KEY_ID, role.getId()), Filters.eq(KEY_NAME, role.getName())); + if (collection.find(filter2).first() != null) { + throw new IllegalArgumentException("Cannot alter role: role with name '" + + role.getName() + "' already exists."); + } + + // Update the role document with the new values. + Bson update = Updates.combine( + Updates.set(KEY_NAME, role.getName()), + Updates.set(KEY_NOTES, role.getNotes()) + ); + + // Perform the update operation. + collection.updateOne(filter, update); + } + + @Override + public void removeRole(Role role, UICallback ui) throws Exception { + // Remove the role from all users that have this role + Bson update = Updates.pull(KEY_ROLES, role.getId()); + getDatabase().getCollection(COLLECTION_USERS).updateMany(Filters.empty(), update); + + // Now remove the role itself. + DeleteResult result = getDatabase().getCollection(COLLECTION_ROLES).deleteOne(Filters.eq(KEY_ID, role.getId())); + if (result.getDeletedCount() == 0) { + throw new IllegalArgumentException("Cannot remove role: role with ID '" + role.getId() + "' not found."); + } + } + + @Nonnull + @Override + public Collection getRoles() throws Exception { + Collection roles = new ArrayList<>(); + try (MongoCursor cursor = getDatabase().getCollection(COLLECTION_ROLES).find().iterator()) { + while (cursor.hasNext()) { + roles.add(toRole(cursor.next())); + } + } + return roles; + } + + @Override + public void addUser(User user, UICallback ui) throws Exception { + if (!user.getProfileName().equals(getProfileName())) { + ui.warn("User source does not match user. Unexpected results may occur."); + } + + // Validate the user doesn't already exist + MongoCollection collection = getDatabase().getCollection(COLLECTION_USERS); + if (collection.find(Filters.eq(KEY_NAME, user.get(User.Username))).first() != null) { + throw new IllegalArgumentException("User with username '" + user.get(User.Username) + "' already exists."); + } + + // Create a new user document from the user. + // + // WARNING: For this example, we are storing the password in plain text (not recommended for production use) + Document document = new Document() + .append(KEY_ID, UUID.randomUUID().toString()) + .append(KEY_NAME, user.get(User.Username)) + .append(KEY_PASSWORD, user.get(User.Password)) + .append(KEY_PASSWORD_DATE, System.currentTimeMillis()) + .append(KEY_FIRSTNAME, user.get(User.FirstName)) + .append(KEY_LASTNAME, user.get(User.LastName)) + .append(KEY_BADGE, user.get(User.Badge)) + .append(KEY_NOTES, user.get(User.Notes)) + .append(KEY_LANGUAGE, user.get(User.Language)) + .append(KEY_SCHEDULE, user.get(User.Schedule)); + + // If password history is enabled and a password is provided, add the initial password to the history. + String password = user.get(User.Password); + if (settings.passwordHistory() > 0 && StringUtils.isNotBlank(password)) { + document.append(KEY_PASSWORD_HISTORY, List.of(password)); + } + + // Roles, Schedule Adjustments, Contact Info, etc. + document.append(KEY_ROLES, getRoleIds(user.getRoles())); + document.append(KEY_CI, getContactInfoDocuments(user)); + document.append(KEY_SA, getScheduleAdjustmentDocuments(user)); + + // Custom properties + document.append(KEY_FAVORITE_COLOR, user.getOrElse(FAVORITE_COLOR, "blue")); + document.append(KEY_FAVORITE_NUMBER, user.getOrElse(FAVORITE_NUMBER, 42)); + document.append(KEY_LIKES_APPLES, user.getOrElse(LIKES_APPLES, true)); + + collection.insertOne(document); + } + + /** + * Converts a list of MongoDB Documents into a list of ContactInfo objects. + * + * @param documents the list of MongoDB Documents representing the user's contact information. + * @return a list of ContactInfo objects. + */ + private List getContactInfo(List documents) { + List contactInfoList = new ArrayList<>(); + for (Document doc : documents) { + ContactInfo contactInfo = new ContactInfo(doc.getString(KEY_TYPE), doc.getString(KEY_VALUE)); + contactInfoList.add(contactInfo); + } + return contactInfoList; + } + + /** + * Converts a list of MongoDB Documents into a list of ScheduleAdjustment objects. + * + * @param documents the list of MongoDB Documents representing the user's schedule adjustments. + * @return a list of ScheduleAdjustment objects. + */ + private List getScheduleAdjustments(List documents) { + List adjustments = new ArrayList<>(); + for (Document doc : documents) { + boolean available = doc.getBoolean(KEY_AVAILABLE); + Date start = null; + if (doc.getLong(KEY_START) != null) { + start = new Date(doc.getLong(KEY_START)); + } + Date end = null; + if (doc.getLong(KEY_END) != null) { + end = new Date(doc.getLong(KEY_END)); + } + String note = null; + if (doc.getString(KEY_NOTE) != null) { + note = doc.getString(KEY_NOTE); + } + adjustments.add(new ScheduleAdjustment(start, end, available, note)); + } + return adjustments; + } + + /** + * Converts the user's schedule adjustments into a list of MongoDB Documents. + * + * @param user the user whose schedule adjustments are to be converted. + * @return a list of Documents representing the user's schedule adjustments. + */ + private List getScheduleAdjustmentDocuments(User user) { + List documents = new ArrayList<>(); + for (ScheduleAdjustment sa : user.getScheduleAdjustments()) { + Document document = new Document(); + document.append(KEY_AVAILABLE, sa.isAvailable()); + if (sa.getStart() != null) { + document.append(KEY_START, sa.getStart().getTime()); + } + if (sa.getEnd() != null) { + document.append(KEY_END, sa.getEnd().getTime()); + } + if (sa.getNote() != null) { + document.append(KEY_NOTE, sa.getNote()); + } + document.append(KEY_NOTE, sa.getNote()); + documents.add(document); + } + return documents; + } + + /** + * Converts the user's contact information into a list of MongoDB Documents. + * + * @param user the user whose contact information is to be converted. + * @return a list of Documents representing the user's contact information. + */ + private List getContactInfoDocuments(User user) { + List documents = new ArrayList<>(); + for (ContactInfo ci : user.getContactInfo()) { + Document document = new Document(); + document.append(KEY_TYPE, ci.getContactType()); + document.append(KEY_VALUE, ci.getValue()); + documents.add(document); + } + return documents; + } + + /** + * Retrieves the role IDs for the given role names. + * + * @param roleNames the collection of role names to look up. + * @return a set of role IDs corresponding to the provided role names, or null if no roles are found. + */ + private Collection getRoleIds(Collection roleNames) { + List roleIds = new ArrayList<>(); + + if (roleNames != null && !roleNames.isEmpty()) { + MongoCollection collection = getDatabase().getCollection(COLLECTION_ROLES); + try (MongoCursor cursor = collection.find(Filters.in(KEY_NAME, roleNames)).iterator()) { + while (cursor.hasNext()) { + String roleId = cursor.next().getString(KEY_ID); + if (!roleIds.contains(roleId)) { + roleIds.add(roleId); + } + } + } + } + return roleIds.isEmpty() ? null : roleIds; + } + + /** + * Retrieves the role names for the given role IDs. + * + * @param roleIds the collection of role IDs to look up. + * @return a list of role names corresponding to the provided role IDs, or null if no roles are found. + */ + private List getRoleNames(Collection roleIds) { + List roleNames = new ArrayList<>(); + + if (roleIds != null && !roleIds.isEmpty()) { + MongoCollection collection = getDatabase().getCollection(COLLECTION_ROLES); + try (MongoCursor cursor = collection.find(Filters.in(KEY_ID, roleIds)).iterator()) { + while (cursor.hasNext()) { + String roleName = cursor.next().getString(KEY_NAME); + if (!roleNames.contains(roleName)) { + roleNames.add(roleName); + } + } + } + } + return roleNames.isEmpty() ? null : roleNames; + } + + @Override + public void alterUser(User user, UICallback ui) throws Exception { + MongoCollection collection = getDatabase().getCollection(COLLECTION_USERS); + + // Validate the user already exists + Bson filter = Filters.eq(KEY_ID, user.getId()); + Document document = collection.find(filter).first(); + if (document == null) { + throw new IllegalArgumentException("Cannot alter user: user with ID '" + user.getId() + "' not found."); + } + + // Does a user with the specified username exist already if we are changing names? + Bson filter2 = Filters.and(Filters.ne(KEY_ID, user.getId()), Filters.eq(KEY_NAME, user.get(User.Username))); + if (collection.find(filter2).first() != null) { + throw new IllegalArgumentException("Cannot alter user: user with username '" + + user.get(User.Username) + "' already exists."); + } + + // Update the user document with the new values. + Bson update = Updates.combine( + Updates.set(KEY_NAME, user.get(User.Username)), + Updates.set(KEY_FIRSTNAME, user.get(User.FirstName)), + Updates.set(KEY_LASTNAME, user.get(User.LastName)), + Updates.set(KEY_BADGE, user.get(User.Badge)), + Updates.set(KEY_NOTES, user.get(User.Notes)), + Updates.set(KEY_LANGUAGE, user.get(User.Language)), + Updates.set(KEY_SCHEDULE, user.get(User.Schedule)) + ); + + // If the password is set and has not been used yet, update it and the password date. + if (StringUtils.isNotBlank(user.get(User.Password))) { + String newPassword = user.get(User.Password); + List passwordHistory = checkPasswordHistory(document, user.get(User.Password)); + + // Update the password and password date. + update = Updates.combine(update, + Updates.set(KEY_PASSWORD, newPassword), + Updates.set(KEY_PASSWORD_DATE, System.currentTimeMillis()) + ); + + // If password history is enabled, update it as well. + if (passwordHistory != null) { + update = Updates.combine(update, Updates.set(KEY_PASSWORD_HISTORY, passwordHistory)); + } + } + + // Set roles, contact info, and schedule adjustments + update = Updates.combine(update, Updates.set(KEY_ROLES, getRoleIds(user.getRoles()))); + update = Updates.combine(update, Updates.set(KEY_CI, getContactInfoDocuments(user))); + update = Updates.combine(update, Updates.set(KEY_SA, getScheduleAdjustmentDocuments(user))); + + // Set our custom properties + update = Updates.combine(update, Updates.set(KEY_FAVORITE_COLOR, user.getOrElse(FAVORITE_COLOR, "blue"))); + update = Updates.combine(update, Updates.set(KEY_FAVORITE_NUMBER, user.getOrElse(FAVORITE_NUMBER, 42))); + update = Updates.combine(update, Updates.set(KEY_LIKES_APPLES, user.getOrElse(LIKES_APPLES, true))); + + // Perform the update operation. + collection.updateOne(filter, update); + } + + @Override + public void removeUser(User user, UICallback ui) throws Exception { + DeleteResult result = getDatabase().getCollection(COLLECTION_USERS).deleteOne(Filters.eq(KEY_ID, user.getId())); + if (result.getDeletedCount() == 0) { + throw new IllegalArgumentException("Cannot remove user: user with ID '" + user.getId() + "' not found."); + } + } + + @Override + public void alterPassword(User user, String oldPassword, String newPassword) throws Exception { + MongoCollection collection = getDatabase().getCollection(COLLECTION_USERS); + + // Validate the user already exists + Bson filter = Filters.eq(KEY_ID, user.getId()); + Document document = collection.find(filter).first(); + if (document == null) { + throw new IllegalArgumentException("User with ID '" + user.getId() + "' does not exist."); + } + + // Verify the old password matches. + if (!StringUtils.equals(oldPassword, document.getString(KEY_PASSWORD))) { + throw new IllegalArgumentException("The old password is incorrect."); + } + + // If password history is enabled, verify the new password is not in the history. + List passwordHistory = checkPasswordHistory(document, newPassword); + + // Update the password and password date. + Bson update = Updates.combine( + Updates.set(KEY_PASSWORD, newPassword), + Updates.set(KEY_PASSWORD_DATE, System.currentTimeMillis()) + ); + if (passwordHistory != null) { + update = Updates.combine(update, Updates.set(KEY_PASSWORD_HISTORY, passwordHistory)); + } + + collection.updateOne(filter, update); + } + + @Nonnull + @Override + public Collection getUsers() throws Exception { + Collection users = new ArrayList<>(); + try (MongoCursor cursor = getDatabase().getCollection(COLLECTION_USERS).find().iterator()) { + while (cursor.hasNext()) { + users.add(toUser(cursor.next())); + } + } + return users; + } + + @Nonnull + @Override + public Optional getUser(String userName) throws Exception { + return Optional.ofNullable( + toUser(getDatabase().getCollection(COLLECTION_USERS).find(Filters.eq(KEY_NAME, userName)).first()) + ); + } + + @Override + public Set getEditFlags() { + return EnumSet.allOf(UserSourceEditCapability.class); + } + + /** + * Checks if the new password has been used in the user's password history. + * + * @param document the user document from the database. + * @param newPassword the new password to check. + * @return the new list of previous passwords if password history is enabled, null otherwise. + * @throws IllegalArgumentException if the new password is found in the user's password history. + */ + private List checkPasswordHistory(Document document, String newPassword) throws IllegalArgumentException { + List history = null; + if (settings.passwordHistory() > 0) { + history = document.getList(KEY_PASSWORD_HISTORY, String.class); + if (history.contains(newPassword)) { + throw new IllegalArgumentException("The new password cannot be the same as any of the last " + + settings.passwordHistory() + " passwords."); + } + + while (history.size() >= settings.passwordHistory()) { + history.remove(0); + } + history.add(newPassword); + } + return history; + } + + /** + * Converts a MongoDB Document to a {@link BasicRole}. + * + * @param document the MongoDB Document representing the role. + * @return a {@link BasicRole} object or null if the document is null. + */ + private BasicRole toRole(Document document) { + if (document == null) { + return null; + } + + BasicRole basicRole = new BasicRole(getProfileName(), document.getString(KEY_ID)); + basicRole.setName(document.getString(KEY_NAME)); + basicRole.setNotes(document.getString(KEY_NOTES)); + return basicRole; + } + + /** + * Converts a MongoDB Document to a {@link BasicUser}. + * + * @param document the MongoDB Document representing the user. + * @return a {@link BasicUser} object or null if the document is null. + */ + private BasicUser toUser(Document document) { + if (document == null) { + return null; + } + + String id = document.getString(KEY_ID); + BasicUser basicUser = new BasicUser(getProfileName(), id, null, null); + basicUser.set(User.Username, document.getString(KEY_NAME)); + basicUser.set(User.FirstName, document.getString(KEY_FIRSTNAME)); + basicUser.set(User.LastName, document.getString(KEY_LASTNAME)); + basicUser.set(User.Badge, document.getString(KEY_BADGE)); + basicUser.set(User.Notes, document.getString(KEY_NOTES)); + basicUser.set(User.Language, document.getString(KEY_LANGUAGE)); + basicUser.set(User.Schedule, document.getString(KEY_SCHEDULE)); + + // Set roles, contact info, and schedule adjustments + basicUser.setRoles(getRoleNames(document.getList(KEY_ROLES, String.class))); + basicUser.setContactInfo(getContactInfo(document.getList(KEY_CI, Document.class))); + basicUser.setScheduleAdjustments(getScheduleAdjustments(document.getList(KEY_SA, Document.class))); + + // Set our custom properties + basicUser.set(FAVORITE_COLOR, document.getString(KEY_FAVORITE_COLOR)); + basicUser.set(FAVORITE_NUMBER, document.getInteger(KEY_FAVORITE_NUMBER)); + basicUser.set(LIKES_APPLES, document.getBoolean(KEY_LIKES_APPLES)); + + return basicUser; + } +} diff --git a/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSourceExtensionPoint.java b/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSourceExtensionPoint.java new file mode 100644 index 00000000..2436a861 --- /dev/null +++ b/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSourceExtensionPoint.java @@ -0,0 +1,78 @@ +package com.inductiveautomation.ignition.examples.usersource.mongodb; + +import com.inductiveautomation.ignition.gateway.config.DecodedResource; +import com.inductiveautomation.ignition.gateway.config.ExtensionPointConfig; +import com.inductiveautomation.ignition.gateway.config.ValidationErrors; +import com.inductiveautomation.ignition.gateway.dataroutes.openapi.SchemaUtil; +import com.inductiveautomation.ignition.gateway.model.GatewayContext; +import com.inductiveautomation.ignition.gateway.user.UserSourceExtensionPoint; +import com.inductiveautomation.ignition.gateway.user.UserSourceProfile; +import com.inductiveautomation.ignition.gateway.user.UserSourceProfileConfig; +import com.inductiveautomation.ignition.gateway.user.UserSourceProfileKernel; +import com.inductiveautomation.ignition.gateway.web.nav.ExtensionPointResourceForm; +import com.inductiveautomation.ignition.gateway.web.nav.WebUiComponent; + +import java.util.Optional; + +/** + * The {@link MongoDbUserSourceExtensionPoint} is responsible for creating instances of the MongoDbUserSource + * when a user source profile of this type is configured in the Gateway. + */ +public class MongoDbUserSourceExtensionPoint extends UserSourceExtensionPoint { + public static final String EXTENSION_POINT_TYPE = "MONGODB"; + + public MongoDbUserSourceExtensionPoint() { + super(EXTENSION_POINT_TYPE, + "MongoDbUserSource.UserSourceType.Name", + "MongoDbUserSource.UserSourceType.Desc", + MongoDbUserSourceResource.class); + } + + @Override + public UserSourceProfile createNewProfile( + GatewayContext context, + DecodedResource> resource) throws Exception { + + String profileName = resource.name(); + + // Retrieve the settings for the user source profile from the resource configuration. + MongoDbUserSourceResource settings = getSettings(resource.config()) + .orElseThrow( + () -> new IllegalStateException("User source configuration missing for profile: " + profileName) + ); + + // Create a new UserSourceProfileKernel using the profile name and settings. + UserSourceProfileKernel kernel = createKernel(profileName, resource.config().profile(), context); + MongoDbUserSource profile = new MongoDbUserSource(kernel); + profile.setSettings(settings); + return profile; + } + + @Override + public Optional defaultSettings() { + return Optional.of(MongoDbUserSourceResource.DEFAULT); + } + + @Override + public Optional getWebUiComponent(ComponentType type) { + return Optional.of( + new ExtensionPointResourceForm( + UserSourceProfileConfig.RESOURCE_TYPE, + "User Source Profile", + EXTENSION_POINT_TYPE, + SchemaUtil.fromType(UserSourceProfileConfig.class), + SchemaUtil.fromType(MongoDbUserSourceResource.class) + ) + ); + } + + @Override + protected void validate(MongoDbUserSourceResource settings, ValidationErrors.Builder errors) { + /* + Optionally, add validation to an incoming configuration object + These error messages will be conveyed back to the standard web UI automatically + */ + // errors.requireNotNull("someField", settings.auditProfileName()); + super.validate(settings, errors); + } +} diff --git a/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSourceResource.java b/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSourceResource.java new file mode 100644 index 00000000..fb722bd4 --- /dev/null +++ b/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSourceResource.java @@ -0,0 +1,158 @@ +package com.inductiveautomation.ignition.examples.usersource.mongodb; + +import com.inductiveautomation.ignition.common.resourcecollection.ResourceType; +import com.inductiveautomation.ignition.gateway.config.ResourceTypeMeta; +import com.inductiveautomation.ignition.gateway.dataroutes.openapi.annotations.*; +import com.inductiveautomation.ignition.gateway.secrets.SecretConfig; +import com.inductiveautomation.ignition.gateway.web.nav.FormFieldType; +import org.apache.commons.lang3.StringUtils; + +/** + * Configuration for a MongoDB user source profile. This resource will be persisted to disk as part of the user + * source profile config. + */ +public record MongoDbUserSourceResource( + @FormCategory("CUSTOM SETTINGS") + @Label("Connection String") + @FormField(FormFieldType.TEXT) + @DefaultValue("mongodb://localhost:27017") + @Required +// @DescriptionKey("MongoDbUserSourceResource.connectionString.Desc") + @Description("The connection string to use to connect to the MongoDB instance.") + String connectionString, + + @FormCategory("CUSTOM SETTINGS") + @Label("Database Name") + @FormField(FormFieldType.TEXT) + @DefaultValue("user_db") + @Required +// @DescriptionKey("MongoDbUserSourceResource.databaseName.Desc") + @Description("The MongoDB database name to use to store the user source documents.") + String databaseName, + + @FormCategory("CUSTOM SETTINGS") + @Label("Username") + @FormField(FormFieldType.TEXT) +// @DescriptionKey("MongoDbUserSourceResource.username.Desc") + @Description("The username to use to connect to the MongoDB instance.") + String username, + + @FormCategory("CUSTOM SETTINGS") + @Label("Password") + @FormField(FormFieldType.SECRET) +// @DescriptionKey("MongoDbUserSourceResource.password.Desc") + @Description("The password to use to connect to the MongoDB instance.") + SecretConfig password, + + @FormCategory("CUSTOM SETTINGS") + @Label("Authentication Database") + @FormField(FormFieldType.TEXT) + @DefaultValue("admin") +// @DescriptionKey("MongoDbUserSourceResource.authenticationDb.Desc") + @Description(""" + The name of the database to use for authentication. This is typically the "admin" database in MongoDB. + """) + String authenticationDb, + + @FormCategory("CUSTOM SETTINGS") + @Label("Maximum Password Age") + @FormField(FormFieldType.NUMBER) + @DefaultValue("90") + @Minimum("0") + @Maximum(value = "360", exclusive = true) + @Required + @NonSecret +// @DescriptionKey("MongoDbUserSourceResource.passwordMaxAge.Desc") + @Description(""" + This is a setting that defines the maximum age of a password in days. If set to 0, the password will \ + never expire. If set to a positive number, users will be required to change their password after the \ + specified number of days. + """) + Integer passwordMaxAge, + + @FormCategory("CUSTOM SETTINGS") + @Label("Password History") + @FormField(FormFieldType.NUMBER) + @DefaultValue("5") + @Minimum("0") + @Required + @NonSecret +// @DescriptionKey("MongoDbUserSourceResource.passwordHistory.Desc") + @Description(""" + This is a setting that defines the number of previous passwords to remember for a user. Set to 0 to \ + disable. When changing a password, the new password will be checked against this history to ensure \ + that the user is not reusing an old password. + """) + Integer passwordHistory +) { + public static final ResourceType RESOURCE_TYPE = new ResourceType(GatewayHook.MODULE_ID, "mongodb-user-source"); + + public static final MongoDbUserSourceResource DEFAULT = new MongoDbUserSourceResource( + "mongodb://localhost:27017", + "user_db", + null, + null, + "admin", + 90, + 5 + ); + + public static final ResourceTypeMeta META = ResourceTypeMeta.newBuilder(MongoDbUserSourceResource.class) + .resourceType(RESOURCE_TYPE) + .categoryName("MongoDB User Source") + .defaultConfig(DEFAULT) + .buildValidator((resource, validator) -> { + // Custom validation logic for the resource. This gets called anytime the resource system creates + // an instance of this resource, such as when a user source profile is created, updated, or loaded. + validator.checkField( + resource.passwordMaxAge() >= 0 && resource.passwordMaxAge() < 360, + "passwordMaxAge", + "passwordMaxAge must be in the range [0, 1000)" + ); + validator.checkField( + resource.passwordHistory() < 0, + "passwordHistory", + "passwordHistory must be greater than or equal to 0" + ); + if (StringUtils.isNotBlank(resource.username()) && resource.password != null + && StringUtils.isBlank(resource.authenticationDb)) { + validator.addFieldMessage( + "authenticationDb", + "authenticationDb must be set when username and password are provided"); + } + }) + .build(); + + /** + * Canonical constructor that fills in default values for any null or blank parameters. + * + * @param connectionString The MongoDB connection string. + * @param databaseName The name of the database containing user information. + * @param username The username for authenticating to MongoDB. + * @param password The password for authenticating to MongoDB. + * @param authenticationDb The database to authenticate against. + * @param passwordMaxAge Maximum password age in days. + * @param passwordHistory Number of previous passwords to remember. + */ + public MongoDbUserSourceResource { + if (StringUtils.isBlank(connectionString)) { + connectionString = DEFAULT.connectionString(); + } + + if (StringUtils.isBlank(databaseName)) { + databaseName = DEFAULT.databaseName(); + } + + if (StringUtils.isBlank(authenticationDb)) { + authenticationDb = DEFAULT.authenticationDb(); + } + + if (passwordMaxAge == null) { + passwordMaxAge = DEFAULT.passwordMaxAge(); + } + + if (passwordHistory == null) { + passwordHistory = DEFAULT.passwordHistory(); + } + } +} diff --git a/user-source-profile/user-source-gateway/src/main/resources/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSource.properties b/user-source-profile/user-source-gateway/src/main/resources/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSource.properties new file mode 100644 index 00000000..805d6368 --- /dev/null +++ b/user-source-profile/user-source-gateway/src/main/resources/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSource.properties @@ -0,0 +1,15 @@ +UserSourceType.Name=MongoDB User Source (Example) +UserSourceType.Desc=Example user source that uses MongoDB as the backend data store. + + +connectionString.Desc=The connection string to use to connect to the MongoDB instance. +databaseName.Desc=The MongoDB database name to use to store the user source documents. +username.Desc=The username to use to connect to the MongoDB instance. +password.Desc=The password to use to connect to the MongoDB instance. +authenticationDb.Desc=The name of the database to use for authentication. This is typically the "admin" database in MongoDB. +passwordMaxAge.Desc=This is a setting that defines the maximum age of a password in days. If set to 0, the password will never expire. If set to a positive number, users will be required to change their password after the specified number of days. +passwordHistory.Desc=This is a setting that defines the number of previous passwords to remember for a user. Set to 0 to disable. When changing a password, the new password will be checked against this history to ensure that the user is not reusing an old password. + +favoriteColor.Desc=Favorite Color +favoriteNumber.Desc=Favorite Number +likesApples.Desc=Likes Apples? \ No newline at end of file From efd40551fd293dd98f961689a71c86ad38629bf2 Mon Sep 17 00:00:00 2001 From: Jesse Van Hill Date: Thu, 25 Sep 2025 17:31:00 -0500 Subject: [PATCH 2/2] IGN-14437: Add reference properties for SecretConfig config properties. --- .../MongoDbSecretProviderExtensionPoint.java | 31 ++++++++++++++++ user-source-profile/README.md | 2 +- .../usersource/mongodb/MongoDbUserSource.java | 13 +++---- .../MongoDbUserSourceExtensionPoint.java | 35 +++++++++++++++++++ 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProviderExtensionPoint.java b/secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProviderExtensionPoint.java index f3ee8087..9e04da54 100644 --- a/secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProviderExtensionPoint.java +++ b/secret-provider/secret-provider-gateway/src/main/java/com/inductiveautomation/ignition/examples/secretprovider/mongodb/MongoDbSecretProviderExtensionPoint.java @@ -20,6 +20,37 @@ public MongoDbSecretProviderExtensionPoint() { super(EXTENSION_POINT_TYPE, "MongoDbSecretProvider.SecretProviderType.Name", "MongoDbSecretProvider.SecretProviderType.Desc"); + + // The password in our configuration might be a referenced secret that points to a secret provider, so we need + // to add a reference property for it. We need to register our reference property and consume updates / renames + // of the SecretProvider to keep our configuration in sync. + addReferenceProperty("password", builder -> builder + .targetType(SecretProviderConfig.RESOURCE_TYPE) + .value(resource -> { + // Return the SecretProvider name if the password is a referenced secret. + SecretConfig secretConfig = resource.password(); + if (secretConfig != null && secretConfig.isReferenced()) { + return secretConfig.getAsReferenced().getProviderName(); + } + return null; + }) + .caseSensitive(true) + .onUpdate((resource, newName) -> { + // Return a new resource with the updated SecretProvider name if the password is a + // referenced secret. + SecretConfig secretConfig = resource.password(); + if (secretConfig != null && secretConfig.isReferenced()) { + return new MongoDbSecretProviderResource( + resource.connectionString(), + resource.databaseName(), + resource.username(), + SecretConfig.referenced(newName, secretConfig.getAsReferenced().getSecretName()), + resource.authenticationDb() + ); + } + return resource; // Should never get here, but return the original resource if we do. + }) + ); } public SecretProvider createProvider(SecretProviderContext context) throws SecretProviderTypeException { diff --git a/user-source-profile/README.md b/user-source-profile/README.md index 898fa1f0..07ab3acf 100644 --- a/user-source-profile/README.md +++ b/user-source-profile/README.md @@ -1,4 +1,4 @@ -# UserSourceProfile +# User Source Profile This module provides an implementation of the `UserSourceProvider` interface, which allows for the management of user profiles in a system. It includes methods for creating, updating, and deleting user profiles, as well as retrieving user diff --git a/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSource.java b/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSource.java index 499bc3e6..4fb4dab9 100644 --- a/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSource.java +++ b/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSource.java @@ -27,12 +27,11 @@ import org.apache.log4j.Level; import org.bson.Document; import org.bson.conversions.Bson; -import org.joda.time.DateTime; -import org.joda.time.Days; import javax.annotation.Nonnull; import java.nio.charset.StandardCharsets; import java.util.*; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** @@ -264,13 +263,9 @@ private boolean isPasswordInvalid(Document document, String pwd, boolean bypassE // Password is valid, now check if the password is expired if (settings.passwordMaxAge() > 0 && !bypassExpiration) { long pwdTimestamp = document.getLong(KEY_PASSWORD_DATE); - if (pwdTimestamp > 0) { - DateTime passwordCreatedOn = new DateTime(pwdTimestamp); - DateTime now = DateTime.now(); - int days = Days.daysBetween(passwordCreatedOn.toLocalDate(), now.toLocalDate()).getDays(); - if (days > settings.passwordMaxAge()) { - throw new PasswordExpiredException(getName(), uname); - } + long pwdExpiration = pwdTimestamp + TimeUnit.DAYS.toMillis(settings.passwordMaxAge()); + if (pwdTimestamp > 0 && System.currentTimeMillis() >= pwdExpiration) { + throw new PasswordExpiredException(getName(), uname); } } diff --git a/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSourceExtensionPoint.java b/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSourceExtensionPoint.java index f6fb1c34..542206ef 100644 --- a/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSourceExtensionPoint.java +++ b/user-source-profile/user-source-gateway/src/main/java/com/inductiveautomation/ignition/examples/usersource/mongodb/MongoDbUserSourceExtensionPoint.java @@ -5,6 +5,8 @@ import com.inductiveautomation.ignition.gateway.config.ValidationErrors; import com.inductiveautomation.ignition.gateway.dataroutes.openapi.SchemaUtil; import com.inductiveautomation.ignition.gateway.model.GatewayContext; +import com.inductiveautomation.ignition.gateway.secrets.SecretConfig; +import com.inductiveautomation.ignition.gateway.secrets.SecretProviderConfig; import com.inductiveautomation.ignition.gateway.user.UserSourceExtensionPoint; import com.inductiveautomation.ignition.gateway.user.UserSourceProfile; import com.inductiveautomation.ignition.gateway.user.UserSourceProfileConfig; @@ -26,6 +28,39 @@ public MongoDbUserSourceExtensionPoint() { "MongoDbUserSource.UserSourceType.Name", "MongoDbUserSource.UserSourceType.Desc", MongoDbUserSourceResource.class); + + // The password in our configuration might be a referenced secret that points to a secret provider, so we need + // to add a reference property for it. We need to register our reference property and consume updates / renames + // of the SecretProvider to keep our configuration in sync. + addReferenceProperty("password", builder -> builder + .targetType(SecretProviderConfig.RESOURCE_TYPE) + .value(resource -> { + // Return the SecretProvider name if the password is a referenced secret. + SecretConfig secretConfig = resource.password(); + if (secretConfig != null && secretConfig.isReferenced()) { + return secretConfig.getAsReferenced().getProviderName(); + } + return null; + }) + .caseSensitive(true) + .onUpdate((resource, newName) -> { + // Return a new resource with the updated SecretProvider name if the password is a + // referenced secret. + SecretConfig secretConfig = resource.password(); + if (secretConfig != null && secretConfig.isReferenced()) { + return new MongoDbUserSourceResource( + resource.connectionString(), + resource.databaseName(), + resource.username(), + SecretConfig.referenced(newName, secretConfig.getAsReferenced().getSecretName()), + resource.authenticationDb(), + resource.passwordMaxAge(), + resource.passwordHistory() + ); + } + return resource; // Should never get here, but return the original resource if we do. + }) + ); } @Override