/*
* JBoss, Home of Professional Open Source.
* Copyright 2012, Red Hat, Inc., and individual contributors
* as indicated by the @author tags. See the copyright.txt file in the
* distribution for a full listing of individual contributors.
*
* This is free software; you can redistribute it and/or modify it
* under the terms of the GNU Lesser General Public License as
* published by the Free Software Foundation; either version 2.1 of
* the License, or (at your option) any later version.
*
* This software is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public
* License along with this software; if not, write to the Free
* Software Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA
* 02110-1301 USA, or see the FSF site: http://www.fsf.org.
*/
package org.jboss.as.domain.management.security;
import static org.jboss.as.domain.management.logging.DomainManagementLogger.ROOT_LOGGER;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.Arrays;
import java.util.List;
import java.util.Properties;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.jboss.as.controller.services.path.PathEntry;
import org.jboss.as.controller.services.path.PathManager;
import org.jboss.as.controller.services.path.PathManager.Callback;
import org.jboss.as.controller.services.path.PathManager.Event;
import org.jboss.as.controller.services.path.PathManager.PathEventContext;
import org.jboss.as.domain.management.logging.DomainManagementLogger;
import org.jboss.msc.inject.Injector;
import org.jboss.msc.service.StartContext;
import org.jboss.msc.service.StartException;
import org.jboss.msc.service.StopContext;
import org.jboss.msc.value.InjectedValue;
/**
* The base class for services depending on loading a properties file, loads the properties on
* start up and re-loads as required where updates to the file are detected.
*
* @author <a href="mailto:darran.lofthouse@jboss.com">Darran Lofthouse</a>
*/
public class PropertiesFileLoader {
private static final char[] ESCAPE_ARRAY = new char[] { '=', '\\'};
protected static final String COMMENT_PREFIX = "#";
/**
* Pattern that matches :
*
* <ul>
* <li>{@code #username=password}</li>
* <li>{@code username=password}</li>
* </ul>
*
* The regular expression is used to obtain 2 capturing groups, {@code group(1)} is used to obtain the username,
* {@code group(2)} is used to obtain the password.
*
* Usernames can contain the following in any number and in any order: -
* <ul>
* <li>alphanumeric characters.</li>
* <li>The symbols {@code '.', '-', ',', '@'}</li>
* <li>An escaped backslash.</li>
* <li>An escaped {@code '='}</li>
* </ul>
*
* An optional preceeding '#' is greedily matched.
*
* Capturing group(1) contains a non-capturing group that specifies all of these options, the non-capturing group is
* expected to occur one or more times i.e. a username must contain at least one character. The non-capturing group also
* contains a sub non-capturing group to match against an escaped '\' or an escaped '='.
*
* After capturing group(1) match on a single '=', if an '=' is used within a username it must be escaped and if escaped
* would have been captured within the username capturing group.
*
* The final capturing group captures all characters after the delimiting '=', this capturing group does not require the
* same level of complexity as the previous capturing group has enabled matching to the first non-escaped '='.
*
* Note: Possessive quantifiers are used here as without them invalid test strings become a serious performance burden.
*/
public static final Pattern PROPERTY_PATTERN = Pattern.compile("#?+((?:[,.\\-@/a-zA-Z0-9]++|(?:\\\\[=\\\\])++)++)=(.++)");
public static final String DISABLE_SUFFIX_KEY = "!disable";
private final InjectedValue<PathManager> pathManager = new InjectedValue<PathManager>();
private final String path;
private final String relativeTo;
protected File propertiesFile;
private volatile long fileUpdated = -1;
private volatile Properties properties = null;
/*
* State maintained during persistence.
*/
private Properties toSave = null;
/*
* End of state maintained during persistence.
*/
public PropertiesFileLoader(final String path, final String relativeTo) {
this.path = path;
this.relativeTo = relativeTo;
}
public Injector<PathManager> getPathManagerInjectorInjector() {
return pathManager;
}
public void start(StartContext context) throws StartException {
String file = path;
if (relativeTo != null) {
PathManager pm = pathManager.getValue();
file = pm.resolveRelativePathEntry(file, relativeTo);
pm.registerCallback(relativeTo, new Callback() {
@Override
public void pathModelEvent(PathEventContext eventContext, String name) {
if (eventContext.isResourceServiceRestartAllowed() == false) {
eventContext.reloadRequired();
}
}
@Override
public void pathEvent(Event event, PathEntry pathEntry) {
// Service dependencies should trigger a stop and start.
}
}, Event.REMOVED, Event.UPDATED);
}
propertiesFile = new File(file);
try {
getProperties();
} catch (IOException ioe) {
throw DomainManagementLogger.ROOT_LOGGER.unableToLoadProperties(ioe);
}
}
public void stop(StopContext context) {
properties.clear();
properties = null;
propertiesFile = null;
}
public Properties getProperties() throws IOException {
loadAsRequired();
return properties;
}
protected void loadAsRequired() throws IOException {
/*
* This method does attempt to minimise the effect of race conditions, however this is not overly critical as if you
* have users attempting to authenticate at the exact point their details are added to the file there is also a chance
* of a race.
*/
boolean loadRequired = properties == null || fileUpdated != propertiesFile.lastModified();
if (loadRequired) {
synchronized (this) {
// Cache the value as there is still a chance of further modification.
long fileLastModified = propertiesFile.lastModified();
boolean loadReallyRequired = properties == null || fileUpdated != fileLastModified;
if (loadReallyRequired) {
load();
// Update this last otherwise the check outside the synchronized block could return true before the file is
// set.
fileUpdated = fileLastModified;
}
}
}
}
protected void load() throws IOException {
ROOT_LOGGER.debugf("Reloading properties file '%s'", propertiesFile.getAbsolutePath());
Properties props = new Properties();
InputStreamReader is = new InputStreamReader(new FileInputStream(propertiesFile), StandardCharsets.UTF_8);
try {
props.load(is);
} finally {
is.close();
}
verifyProperties(props);
properties = props;
}
/**
* Saves changes in properties file. It reads the property file into memory, modifies it and saves it back to the file.
*
* @throws IOException
*/
public synchronized void persistProperties() throws IOException {
beginPersistence();
// Read the properties file into memory
// Shouldn't be so bad - it's a small file
List<String> content = readFile(propertiesFile);
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(propertiesFile), StandardCharsets.UTF_8));
try {
for (String line : content) {
String trimmed = line.trim();
if (trimmed.length() == 0) {
bw.newLine();
} else {
Matcher matcher = PROPERTY_PATTERN.matcher(trimmed);
if (matcher.matches()) {
final String key = cleanKey(matcher.group(1));
if (toSave.containsKey(key) || toSave.containsKey(key + DISABLE_SUFFIX_KEY)) {
writeProperty(bw, key, matcher.group(2));
toSave.remove(key);
toSave.remove(key + DISABLE_SUFFIX_KEY);
}
} else {
write(bw, line, true);
}
}
}
endPersistence(bw);
} finally {
safeClose(bw);
}
}
protected String cleanKey(final String key) {
char[] keyChars = key.toCharArray();
char[] cleaned = new char[keyChars.length];
int keyPos = 0;
int cleanedPos = 0;
while (keyPos < keyChars.length) {
if (keyChars[keyPos++] == '\\') {
char current = keyChars[keyPos];
switch (current) {
case 't':
cleaned[cleanedPos++] = '\t';
break;
case 'r':
cleaned[cleanedPos++] = '\r';
break;
case 'n':
cleaned[cleanedPos++] = '\n';
break;
case 'f':
cleaned[cleanedPos++] = '\f';
break;
default:
cleaned[cleanedPos++] = current;
}
keyPos++;
} else {
cleaned[cleanedPos++] = keyChars[keyPos - 1];
}
}
return new String(cleaned, 0, cleanedPos);
}
protected List<String> readFile(File file) throws IOException {
return Files.readAllLines(file.toPath(), StandardCharsets.UTF_8);
}
/**
* Add the line to the content
*
* @param bufferedFileReader The file reader
* @param content The content of the file
* @param line The current read line
* @throws IOException
*/
protected void addLineContent(BufferedReader bufferedFileReader, List<String> content, String line) throws IOException {
content.add(line);
}
/**
* Method called to indicate the start of persisting the properties.
*
* @throws IOException
*/
protected void beginPersistence() throws IOException {
toSave = (Properties) properties.clone();
}
protected void write(final BufferedWriter writer, final String line, final boolean newLine) throws IOException {
writer.append(line);
if (newLine) {
writer.newLine();
}
}
/**
* Method called to indicate persisting the properties file is now complete.
*
* @throws IOException
*/
protected void endPersistence(final BufferedWriter writer) throws IOException {
// Append any additional users to the end of the file.
for (Object currentKey : toSave.keySet()) {
String key = (String) currentKey;
if (!key.contains(DISABLE_SUFFIX_KEY)) {
writeProperty(writer, key, null);
}
}
toSave = null;
}
private void writeProperty(BufferedWriter writer, String key, String currentValue) throws IOException {
String escapedKey = escapeString(key, ESCAPE_ARRAY);
final String value = getValue(key, currentValue);
final String newLine;
if (Boolean.valueOf(toSave.getProperty(key + DISABLE_SUFFIX_KEY))) {
// Commented property
newLine = "#" + escapedKey + "=" + value;
} else {
newLine = escapedKey + "=" + value;
}
write(writer, newLine, true);
}
/**
* Get the value of the property.<br/>
* If the value to save is null, return the previous value (enable/disable mode).
*
* @param key The key of the property
* @param previousValue The previous value
* @return The value of the property
*/
private String getValue(String key, String previousValue) {
final String value;
final String valueUpdated = toSave.getProperty(key);
if (valueUpdated == null) {
value = previousValue;
} else {
value = valueUpdated;
}
return value;
}
public static String escapeString(String name, char[] escapeArray) {
Arrays.sort(escapeArray);
for(int i = 0; i < name.length(); ++i) {
char ch = name.charAt(i);
if (Arrays.binarySearch(escapeArray, ch) >= 0) {
StringBuilder builder = new StringBuilder();
builder.append(name, 0, i);
builder.append('\\').append(ch);
for(int j = i + 1; j < name.length(); ++j) {
ch = name.charAt(j);
if (Arrays.binarySearch(escapeArray, ch) >= 0) {
builder.append('\\');
}
builder.append(ch);
}
return builder.toString();
}
}
return name;
}
protected void safeClose(final Closeable c) {
try {
c.close();
} catch (IOException ignored) {
}
}
/**
* Provides the base class with an opportunity to verify the contents of the properties before they are used.
*
* @param properties - The Properties instance to verify.
*/
protected void verifyProperties(Properties properties) throws IOException {
};
}