/** * OLAT - Online Learning and Training<br> * http://www.olat.org * <p> * Licensed under the Apache License, Version 2.0 (the "License"); <br> * you may not use this file except in compliance with the License.<br> * You may obtain a copy of the License at * <p> * http://www.apache.org/licenses/LICENSE-2.0 * <p> * Unless required by applicable law or agreed to in writing,<br> * software distributed under the License is distributed on an "AS IS" BASIS, <br> * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br> * See the License for the specific language governing permissions and <br> * limitations under the License. * <p> * Copyright (c) since 2004 at Multimedia- & E-Learning Services (MELS),<br> * University of Zurich, Switzerland. * <hr> * <a href="http://www.openolat.org"> * OpenOLAT - Online Learning and Training</a><br> * This file has been modified by the OpenOLAT community. Changes are licensed * under the Apache 2.0 license as the original file. * <p> */ package org.olat.core.configuration; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.file.Paths; import java.security.SecureRandom; import java.security.Security; import java.util.HashSet; import java.util.Properties; import java.util.Set; import javax.crypto.Cipher; import javax.crypto.CipherInputStream; import javax.crypto.CipherOutputStream; import javax.crypto.SecretKey; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.PBEKeySpec; import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.olat.core.id.OLATResourceable; import org.olat.core.logging.LogDelegator; import org.olat.core.util.StringHelper; import org.olat.core.util.WebappHelper; import org.olat.core.util.coordinate.CoordinatorManager; import org.olat.core.util.event.GenericEventListener; import org.olat.core.util.resource.OresHelper; /** * Description:<br> * The PersistedProperties features reading and writing of configuration * properties from and to properties files for module and application * configuration. * <p> * The idea is that the system can be configured with default values within the * module code either by a spring configuration or by using hardcoded default * values. Those default values are overridden by the values stored in the * properties files found in the user space of the system ( * <code>olatdata/system/configuration/fully.qualified.ClassName.properties</code> * ). Installing updates will not overwrite those properties files, the 'old' * configuration is available immediately to the sytem without reconfiguration. * <p> * The developer should provide a GUI for each of those values that can be * configured at runtime. It is up to the programmer if the system needs a * reboot or not. * <p> * After constructing an instance of this class the setXXXPropertyDefault() * methods must be called to set the default values in case no user * configuration is found. This is where you can set the values you get from the * spring. * <p> * After the default values have been set, using the getXXXPropertyValue() you * will get the current configuration value. The PersistedProperties class will * first look in properties from the filesystem and if nothing found, use the * setted default values. * <p> * The class does also provide setXXXProperty() methods. Setting a property will * store it always in the user space properties file * <code>olatdata/system/configuration/fully.qualified.ClassName.properties</code> * . It will not modify any spring file. The setter methods * can be stored immediately after each set or remain transient until the * explicit savePropertiesAndFireChangedEvent() method is called. * <p> * To work properly in a cluster environment, the class will fire a * PersitedPropertiesChangedEvent at the end of each save cycle. This event must * be catched by the class that is using this PersistingProperties. When * constructing the PersistingProperties, a reference to the using class must be * provided. The PersistingProperties deals with registering and unregistering * of the event listeners for the multi-node events, this is hidden to the using * class. * <p> * When finished, the destroy() method must be called. In most cases this will * be at system shutdown time. * <p> * NOTE 1: By replacing the OLAT code with a new version all configurations made * will remain since the configuration is stored in the user space and not in * the OLAT web application directory. This greatly simplifies upgrading without * reconfiguring everything. * <p> * NOTE 2: When you saved a configuration using the set and save methods in this * class, the default configuration in your spring * configuration becomes irrelevant. Changing the values in olat.local.properties or * your spring file will have no effect whatsoever. * <p> * NOTE 3: At any time, the configuration properties files at * <code>olatdata/system/configuration/</code> can be deleted. The system then * uses the default values provided in your olat_confix.xml, your spring * configuration or your hardcoded default values provided at init time. * <p> * NOTE 4: As a developer you must decide if a configuration can be reflected in * realtime or if you must reboot the system. In the first case you can savely * use the getter methods to read the configuration every time you need the * value, in the second case you should make a copy of the configuration value * at init time and operate only with the copy. * <p> * NOTE 5: In extreme conditions the get/set methods might not be cluster save. * Make sure you use a cluster wide GUI lock in the admin interfaces that makes * use of the setter methods and you are on the save side. On the other hand it * is very unlikely that something really bad happens, the properties files are * just overwritten by the next save. * <p> * <h3>Events thrown by this class:</h3> * <ul> * <li>PersistedPropertiesChangedEvent in case the configuration got changed</li> * </ul> * <p> * Initial Date: 01.10.2007 <br> * * @author Florian Gnägi, http://www.frentix.com */ public class PersistedProperties extends LogDelegator implements Initializable, Destroyable{ // base directory where all system config files are located private File configurationPropertiesFile; // the properties loaded from disk private final Properties configuredProperties = new Properties(); // the volatile default properties private Properties defaultProperties = new Properties(); // flag to indicate property set operations that have not yet been saved private boolean propertiesDirty = false; private GenericEventListener propertiesChangedEventListener; private OLATResourceable PROPERTIES_CHANGED_EVENT_CHANNEL; private CoordinatorManager coordinatorManager; private final boolean secured; private final String filename; private String userDataDirectory; static { Security.insertProviderAt(new BouncyCastleProvider(), 1); } public PersistedProperties(GenericEventListener listener) { this(CoordinatorManager.getInstance(), listener); } public PersistedProperties(CoordinatorManager coordinatorManager, GenericEventListener listener, boolean secured) { this.coordinatorManager = coordinatorManager; this.propertiesChangedEventListener = listener; this.filename = propertiesChangedEventListener.getClass().getCanonicalName() + ".properties"; this.secured = secured; } public PersistedProperties(CoordinatorManager coordinatorManager, GenericEventListener listener, String filename, boolean secured) { this.coordinatorManager = coordinatorManager; this.propertiesChangedEventListener = listener; this.filename = filename + ".properties"; this.secured = secured; } /** * [used by spring] * @param provide coordinatorManager via DI * fxdiff: needs to be public while another constructor is also public (due to spring loading) */ public PersistedProperties(CoordinatorManager coordinatorManager, GenericEventListener listener) { this.coordinatorManager = coordinatorManager; // Keep handle for dispose process this.propertiesChangedEventListener = listener; this.filename= propertiesChangedEventListener.getClass().getCanonicalName() + ".properties"; this.secured = false; } public void setUserDataDirectory(String directory) { userDataDirectory = directory; } /** * Constructor for a PersistedProperties object. The calling class must * implement the GenericEventListener and provide a reference to itself. * * @param propertiesChangedEventListener */ @Override public void init() { if(userDataDirectory == null) { userDataDirectory = WebappHelper.getUserDataRoot(); } // Load configured properties from properties file configurationPropertiesFile = Paths.get(userDataDirectory, "system", "configuration", filename).toFile(); loadPropertiesFromFile(); // Finally add listener to configuration changes done in other nodes PROPERTIES_CHANGED_EVENT_CHANNEL = OresHelper.createOLATResourceableType(propertiesChangedEventListener.getClass().getSimpleName() + ":PropertiesChangedChannel"); coordinatorManager.getCoordinator().getEventBus().registerFor(propertiesChangedEventListener, null, PROPERTIES_CHANGED_EVENT_CHANNEL); } /** * Load the persisted properties from disk. This can be useful when your * code gets a PersistedPropertiesChangedEvent and you just want to reload * the property instead of modifying the one you have already loaded. */ public void loadPropertiesFromFile() { // Might get an event after beeing disposed. Should not be the case, but you never know with multi user events accross nodes. if (propertiesChangedEventListener != null && configurationPropertiesFile.exists()) { InputStream is; try { is = new FileInputStream(configurationPropertiesFile); if(secured) { SecretKey key = generateKey("rk6R9pQy7dg3usJk"); Cipher cipher = Cipher.getInstance("AES/CTR/NOPADDING"); cipher.init(Cipher.DECRYPT_MODE, key, generateIV(cipher), random); is = new CipherInputStream(is, cipher); } configuredProperties.load(is); is.close(); } catch (FileNotFoundException e) { logError("Could not load config file from path::" + configurationPropertiesFile.getAbsolutePath(), e); } catch (IOException e) { logError("Could not load config file from path::" + configurationPropertiesFile.getAbsolutePath(), e); } catch (Exception e) { logError("Could not load config file from path::" + configurationPropertiesFile.getAbsolutePath(), e); } } } /** * [normally set by spring at startup to pass default values from olat.properties] * @param defaultProperties */ public void setDefaultProperties(Properties defaultProperties) { this.defaultProperties = defaultProperties; } /** * Call this method when the PersitedProperties is not used anymore. Will * remove the event listener for change events on this class */ @Override public final void destroy() { if (propertiesChangedEventListener != null) { coordinatorManager.getCoordinator().getEventBus().deregisterFor(propertiesChangedEventListener, PROPERTIES_CHANGED_EVENT_CHANNEL); propertiesChangedEventListener = null; } } public Set<Object> getPropertyKeys() { Set<Object> keys = new HashSet<>(); if(configuredProperties != null) { keys.addAll(configuredProperties.keySet()); } if(defaultProperties != null) { keys.addAll(defaultProperties.keySet()); } return keys; } /** * Return an int value for a certain propertyName * * @param propertyName * @return the value from the configuration or the default value or 0 */ public int getIntPropertyValue(String propertyName) { // 1) Try from configuration String stringValue = configuredProperties.getProperty(propertyName); // 2) Try from default configuration if (stringValue == null) { stringValue = defaultProperties.getProperty(propertyName); } if (StringHelper.containsNonWhitespace(stringValue)) { try { return Integer.parseInt(stringValue.trim()); } catch (Exception ex) { logWarn("Cannot parse to integer property::" + propertyName + ", value=" + stringValue, null); } } // 3) Not even a value found in the fallback, use 0 if(isLogDebugEnabled()) { logDebug("No value found for int property::" + propertyName + ", using value=0 instead", null); } return 0; } /** * Return a string value for certain propertyName-parameter. * * @param propertyName * @param allowEmptyString true: empty strings are valid values; false: emtpy * strings are discarded * @return the value from the configuration or the default value or ""/NULL * (depending on allowEmptyString flag) */ public String getStringPropertyValue(String propertyName, boolean allowEmptyString) { // 1) Try from configuration String stringValue = configuredProperties.getProperty(propertyName); // 2) Try from default configuration if (stringValue == null || (!allowEmptyString && !StringHelper.containsNonWhitespace(stringValue))) { stringValue = defaultProperties.getProperty(propertyName); } if (stringValue != null) { if (allowEmptyString || StringHelper.containsNonWhitespace(stringValue)) { return stringValue.trim(); } } // 3) Not even a value found in the fallback, return empty string stringValue = (allowEmptyString ? "" : null); if(isLogDebugEnabled()) { logDebug("No value found for string property::" + propertyName + ", using value=\"\" instead"); } return stringValue; } /** * Return a boolean value for certain propertyName * * @param propertyName * @return the value from the configuration or the default value or false */ public boolean getBooleanPropertyValue(String propertyName) { // 1) Try from configuration String stringValue = configuredProperties.getProperty(propertyName); // 2) Try from default configuration if (stringValue == null) { stringValue = defaultProperties.getProperty(propertyName); } if ((stringValue != null) && stringValue.trim().equalsIgnoreCase("TRUE")) { return true; } if ((stringValue != null) && stringValue.trim().equalsIgnoreCase("FALSE")) { return false; } // 3) Not even a value found in the fallback, return false if(isLogDebugEnabled()) { logWarn("No value found for boolean property::" + propertyName + ", using value=false instead", null); } return false; } /** * Set a string property * * @param propertyName The key * @param value The Value * @param saveConfiguration true: will save property and fire event; false: * will not save, but set a dirty flag */ public void setStringProperty(String propertyName, String value, boolean saveConfiguration) { synchronized (configuredProperties) { // make read/write save in VM String oldValue = configuredProperties.getProperty(propertyName); if (oldValue == null || !oldValue.equals(value)) { configuredProperties.setProperty(propertyName, value); propertiesDirty = true; if (saveConfiguration) savePropertiesAndFireChangedEvent(); } } } /** * Set an int property * * @param propertyName The key * @param value The Value * @param saveConfiguration true: will save property and fire event; false: * will not save, but set a dirty flag */ public void setIntProperty(String propertyName, int value, boolean saveConfiguration) { synchronized (configuredProperties) { // make read/write save in VM String oldValue = configuredProperties.getProperty(propertyName); if (oldValue == null || !oldValue.equals(Integer.toString(value))) { configuredProperties.setProperty(propertyName, Integer.toString(value)); propertiesDirty = true; if (saveConfiguration) savePropertiesAndFireChangedEvent(); } } } /** * Set a boolean property * * @param propertyName The key * @param value The Value * @param saveConfiguration true: will save property and fire event; false: * will not save, but set a dirty flag */ public void setBooleanProperty(String propertyName, boolean value, boolean saveConfiguration) { synchronized (configuredProperties) { // make read/write save in VM String oldValue = configuredProperties.getProperty(propertyName); if (oldValue == null || !oldValue.equals(Boolean.toString(value))) { configuredProperties.setProperty(propertyName, Boolean.toString(value)); propertiesDirty = true; if (saveConfiguration) savePropertiesAndFireChangedEvent(); } } } /** * Set a default value for a string property * * @param propertyName * @param value */ public void setStringPropertyDefault(String propertyName, String value) { defaultProperties.setProperty(propertyName, value); } /** * Set a default value for an integer property * * @param propertyName * @param value */ public void setIntPropertyDefault(String propertyName, int value) { defaultProperties.setProperty(propertyName, Integer.toString(value)); } /** * Set a default value for a boolean property * * @param propertyName * @param value */ public void setBooleanPropertyDefault(String propertyName, boolean value) { defaultProperties.setProperty(propertyName, Boolean.toString(value)); } public void removeProperty(String propertyName, boolean saveConfiguration) { synchronized (configuredProperties) { // make read/write save in VM configuredProperties.remove(propertyName); if (saveConfiguration) { savePropertiesAndFireChangedEvent(); } } } /** * Save the properties configuration to disk and notify other nodes about * change. This is only done when there are dirty changes, otherwhile the * method call does nothing. */ public void savePropertiesAndFireChangedEvent() { // Only save when there is something to save synchronized (configuredProperties) { // make read/write save in VM if (!propertiesDirty) return; // Set the default language OutputStream fileStream = null; try { if (!configurationPropertiesFile.exists()) { File directory = configurationPropertiesFile.getParentFile(); if (!directory.exists()) directory.mkdirs(); } fileStream = new FileOutputStream(configurationPropertiesFile); if(secured) { SecretKey key = generateKey("rk6R9pQy7dg3usJk"); Cipher cipher = Cipher.getInstance("AES/CTR/NOPADDING"); cipher.init(Cipher.ENCRYPT_MODE, key, generateIV(cipher), random); fileStream = new CipherOutputStream(fileStream, cipher); } configuredProperties.store(fileStream, null); // Flush and close before sending events to other nodes to make changes appear on other node fileStream.flush(); fileStream.close(); // Notify other cluster nodes about changed configuration PersistedPropertiesChangedEvent changedConfigEvent = new PersistedPropertiesChangedEvent(configuredProperties); coordinatorManager.getCoordinator().getEventBus().fireEventToListenersOf(changedConfigEvent, PROPERTIES_CHANGED_EVENT_CHANNEL); } catch (FileNotFoundException e) { logError("Could not write config file from path::" + configurationPropertiesFile.getAbsolutePath(), e); } catch (IOException e) { logError("Could not write config file from path::" + configurationPropertiesFile.getAbsolutePath(), e); } catch (Exception e) { logError("Could not write config file from path::" + configurationPropertiesFile.getAbsolutePath(), e); } finally { try { if (fileStream != null ) fileStream.close(); } catch (IOException e) { logError("Could not close stream after storing config to file::" + configurationPropertiesFile.getAbsolutePath(), e); } } // Reset for next save cycle propertiesDirty = false; } } /** * Clear the properties and save the empty properties to the file system. */ public void clearAndSaveProperties() { synchronized (configuredProperties) { // make read/write save in VM if (configuredProperties.size() != 0) { configuredProperties.clear(); propertiesDirty = true; savePropertiesAndFireChangedEvent(); } } } /** * Clone the persisted properties to a standard properties object. * * @return */ public Properties createPropertiesFromPersistedProperties() { Properties tmp = new Properties(); // first copy all the default values for (Object keyObject : defaultProperties.keySet()) { String key = (String) keyObject; tmp.setProperty(key, getStringPropertyValue(key, true)); } // second copy the configured values for (Object keyObject : configuredProperties.keySet()) { String key = (String) keyObject; tmp.setProperty(key, getStringPropertyValue(key, true)); } return tmp; } private static final String salt = "A long, but constant phrase that will be used each time as the salt."; private static final int iterations = 2000; private static final int keyLength = 128; private static final SecureRandom random = new SecureRandom(); private static SecretKey generateKey(String passphrase) throws Exception { PBEKeySpec keySpec = new PBEKeySpec(passphrase.toCharArray(), salt.getBytes(), iterations, keyLength); SecretKeyFactory keyFactory = SecretKeyFactory.getInstance("PBEWITHSHA256AND128BITAES-CBC-BC"); return keyFactory.generateSecret(keySpec); } private static IvParameterSpec generateIV(Cipher cipher) throws Exception { byte [] ivBytes = new byte[cipher.getBlockSize()]; random.nextBytes(ivBytes); return new IvParameterSpec(ivBytes); } }