/*
* Password Management Servlets (PWM)
* http://www.pwm-project.org
*
* Copyright (c) 2006-2009 Novell, Inc.
* Copyright (c) 2009-2017 The PWM Project
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
*/
package password.pwm.svc.wordlist;
import password.pwm.AppProperty;
import password.pwm.PwmApplication;
import password.pwm.PwmApplicationMode;
import password.pwm.config.PwmSetting;
import password.pwm.config.option.DataStorageMethod;
import password.pwm.error.PwmException;
import password.pwm.health.HealthRecord;
import password.pwm.http.PwmSession;
import password.pwm.svc.PwmService;
import password.pwm.util.java.Sleeper;
import password.pwm.util.java.TimeDuration;
import password.pwm.util.java.JavaHelper;
import password.pwm.util.localdb.LocalDB;
import password.pwm.util.localdb.LocalDBException;
import password.pwm.util.logging.PwmLogger;
import password.pwm.util.secure.PwmRandom;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Timer;
import java.util.TimerTask;
public class SharedHistoryManager implements PwmService {
// ------------------------------ FIELDS ------------------------------
private static final PwmLogger LOGGER = PwmLogger.forClass(SharedHistoryManager.class);
private static final String KEY_OLDEST_ENTRY = "oldest_entry";
private static final String KEY_VERSION = "version";
private static final String KEY_SALT = "salt";
private static final int MIN_CLEANER_FREQUENCY = 1000 * 60 * 60; // 1 hour
private static final int MAX_CLEANER_FREQUENCY = 1000 * 60 * 60 * 24; // 1 day
private static final LocalDB.DB META_DB = LocalDB.DB.SHAREDHISTORY_META;
private static final LocalDB.DB WORDS_DB = LocalDB.DB.SHAREDHISTORY_WORDS;
private volatile PwmService.STATUS status = STATUS.NEW;
private volatile Timer cleanerTimer = null;
private LocalDB localDB;
private String salt;
private long oldestEntry;
private final Settings settings = new Settings();
// --------------------------- CONSTRUCTORS ---------------------------
public SharedHistoryManager() throws LocalDBException {
}
// -------------------------- OTHER METHODS --------------------------
public void close() {
status = STATUS.CLOSED;
if (cleanerTimer != null) {
cleanerTimer.cancel();
}
localDB = null;
}
public boolean containsWord(final String word) {
if (status != STATUS.OPEN) {
return false;
}
final String testWord = normalizeWord(word);
if (testWord == null) {
return false;
}
//final long startTime = System.currentTimeMillis();
boolean result = false;
try {
final String hashedWord = hashWord(testWord);
final boolean inDB = localDB.contains(WORDS_DB, hashedWord);
if (inDB) {
final long timeStamp = Long.parseLong(localDB.get(WORDS_DB, hashedWord));
final long entryAge = System.currentTimeMillis() - timeStamp;
if (entryAge < settings.maxAgeMs) {
result = true;
}
}
} catch (Exception e) {
LOGGER.warn("error checking global history list: " + e.getMessage());
}
//LOGGER.trace(pwmSession, "successfully checked word, result=" + result + ", duration=" + new TimeDuration(System.currentTimeMillis(), startTime).asCompactString());
return result;
}
public PwmService.STATUS status() {
return status;
}
public Date getOldestEntryTime() {
if (size() > 0) {
return new Date(oldestEntry);
}
return null;
}
public int size() {
if (localDB != null) {
try {
return localDB.size(WORDS_DB);
} catch (Exception e) {
LOGGER.error("error checking wordlist size: " + e.getMessage());
return 0;
}
} else {
return 0;
}
}
private boolean checkDbVersion()
throws Exception {
LOGGER.trace("checking version number stored in LocalDB");
final Object versionInDB = localDB.get(META_DB, KEY_VERSION);
final String currentVersion = "version=" + settings.version;
final boolean result = currentVersion.equals(versionInDB);
if (!result) {
LOGGER.info("existing db version does not match current db version db=(" + versionInDB + ") current=(" + currentVersion + "), clearing db");
localDB.truncate(WORDS_DB);
localDB.put(META_DB, KEY_VERSION, currentVersion);
localDB.remove(META_DB, KEY_OLDEST_ENTRY);
} else {
LOGGER.trace("existing db version matches current db version db=(" + versionInDB + ") current=(" + currentVersion + ")");
}
return result;
}
private void init(final PwmApplication pwmApplication, final long maxAgeMs) {
status = STATUS.OPENING;
final long startTime = System.currentTimeMillis();
try {
checkDbVersion();
} catch (Exception e) {
LOGGER.error("error checking db version", e);
status = STATUS.CLOSED;
return;
}
try {
final String oldestEntryStr = localDB.get(META_DB, KEY_OLDEST_ENTRY);
if (oldestEntryStr == null || oldestEntryStr.length() < 1) {
oldestEntry = 0;
LOGGER.trace("no oldestEntry timestamp stored, will rescan");
} else {
oldestEntry = Long.parseLong(oldestEntryStr);
LOGGER.trace("oldest timestamp loaded from localDB, age is " + TimeDuration.fromCurrent(oldestEntry).asCompactString());
}
} catch (LocalDBException e) {
LOGGER.error("unexpected error loading oldest-entry meta record, will remain closed: " + e.getMessage(), e);
status = STATUS.CLOSED;
return;
}
try {
final int size = localDB.size(WORDS_DB);
final StringBuilder sb = new StringBuilder();
sb.append("open with ").append(size).append(" words (");
sb.append(new TimeDuration(System.currentTimeMillis(), startTime).asCompactString()).append(")");
sb.append(", maxAgeMs=").append(new TimeDuration(maxAgeMs).asCompactString());
sb.append(", oldestEntry=").append(new TimeDuration(System.currentTimeMillis(), oldestEntry).asCompactString());
LOGGER.info(sb.toString());
} catch (LocalDBException e) {
LOGGER.error("unexpected error examining size of DB, will remain closed: " + e.getMessage(), e);
status = STATUS.CLOSED;
return;
}
status = STATUS.OPEN;
//populateFromWordlist(); //only used for debugging!!!
if (pwmApplication.getApplicationMode() == PwmApplicationMode.RUNNING || pwmApplication.getApplicationMode() == PwmApplicationMode.CONFIGURATION) {
long frequencyMs = maxAgeMs > MAX_CLEANER_FREQUENCY ? MAX_CLEANER_FREQUENCY : maxAgeMs;
frequencyMs = frequencyMs < MIN_CLEANER_FREQUENCY ? MIN_CLEANER_FREQUENCY : frequencyMs;
LOGGER.debug("scheduling cleaner task to run once every " + new TimeDuration(frequencyMs).asCompactString());
final String threadName = JavaHelper.makeThreadName(pwmApplication, this.getClass()) + " timer";
cleanerTimer = new Timer(threadName, true);
cleanerTimer.schedule(new CleanerTask(), 1000, frequencyMs);
}
}
private String normalizeWord(final String input) {
if (input == null) {
return null;
}
String word = input.trim();
if (settings.caseInsensitive) {
word = word.toLowerCase();
}
return word.length() > 0 ? word : null;
}
public synchronized void addWord(final PwmSession pwmSession, final String word) {
if (status != STATUS.OPEN) {
return;
}
final String addWord = normalizeWord(word);
if (addWord == null) {
return;
}
final long startTime = System.currentTimeMillis();
try {
final String hashedWord = hashWord(addWord);
final boolean preExisting = localDB.contains(WORDS_DB, hashedWord);
localDB.put(WORDS_DB, hashedWord, Long.toString(System.currentTimeMillis()));
{
final StringBuilder logOutput = new StringBuilder();
logOutput.append(preExisting ? "updated" : "added").append(" word");
logOutput.append(" (").append(new TimeDuration(System.currentTimeMillis(), startTime).asCompactString()).append(")");
logOutput.append(" (").append(this.size()).append(" total words)");
LOGGER.trace(logOutput.toString());
}
} catch (Exception e) {
LOGGER.warn(pwmSession, "error adding word to global history list: " + e.getMessage());
}
}
private String hashWord(final String word) throws NoSuchAlgorithmException {
final MessageDigest md = MessageDigest.getInstance(settings.hashName);
final String wordWithSalt = salt + word;
final int hashLoopCount = settings.hashIterations;
byte[] hashedAnswer = md.digest((wordWithSalt).getBytes());
for (int i = 0; i < hashLoopCount; i++) {
hashedAnswer = md.digest(hashedAnswer);
}
return JavaHelper.binaryArrayToHex(hashedAnswer);
}
// -------------------------- INNER CLASSES --------------------------
private class CleanerTask extends TimerTask {
final Sleeper sleeper = new Sleeper(10);
private CleanerTask() {
}
public void run() {
try {
reduceWordDB();
} catch (LocalDBException e) {
LOGGER.error("error during old record purge: " + e.getMessage());
}
}
private void reduceWordDB()
throws LocalDBException {
if (localDB == null || localDB.status() != LocalDB.Status.OPEN) {
return;
}
final long oldestEntryAge = System.currentTimeMillis() - oldestEntry;
if (oldestEntryAge < settings.maxAgeMs) {
LOGGER.debug("skipping wordDB reduce operation, eldestEntry="
+ TimeDuration.asCompactString(oldestEntryAge)
+ ", maxAge="
+ TimeDuration.asCompactString(settings.maxAgeMs));
return;
}
final long startTime = System.currentTimeMillis();
final int initialSize = size();
int removeCount = 0;
long localOldestEntry = System.currentTimeMillis();
LOGGER.debug("beginning wordDB reduce operation, examining " + initialSize + " words for entries older than " + TimeDuration.asCompactString(settings.maxAgeMs));
LocalDB.LocalDBIterator<String> keyIterator = null;
try {
keyIterator = localDB.iterator(WORDS_DB);
while (status == STATUS.OPEN && keyIterator.hasNext()) {
final String key = keyIterator.next();
final String value = localDB.get(WORDS_DB, key);
final long timeStamp = Long.parseLong(value);
final long entryAge = System.currentTimeMillis() - timeStamp;
if (entryAge > settings.maxAgeMs) {
localDB.remove(WORDS_DB, key);
removeCount++;
if (removeCount % 1000 == 0) {
LOGGER.trace("wordDB reduce operation in progress, removed=" + removeCount + ", total=" + (initialSize - removeCount));
}
} else {
localOldestEntry = timeStamp < localOldestEntry ? timeStamp : localOldestEntry;
}
sleeper.sleep();
}
} finally {
try {
if (keyIterator != null) {
keyIterator.close();
}
} catch (Exception e) {
LOGGER.warn("error returning LocalDB iterator: " + e.getMessage());
}
}
//update the oldest entry
if (status == STATUS.OPEN) {
oldestEntry = localOldestEntry;
localDB.put(META_DB, KEY_OLDEST_ENTRY, Long.toString(oldestEntry));
}
LOGGER.debug("completed wordDB reduce operation" + ", removed=" + removeCount
+ ", totalRemaining=" + size()
+ ", oldestEntry=" + TimeDuration.asCompactString(oldestEntry)
+ " in " + TimeDuration.fromCurrent(startTime).asCompactString());
}
}
public List<HealthRecord> healthCheck() {
return null;
}
public void init(final PwmApplication pwmApplication)
throws PwmException
{
settings.maxAgeMs = 1000 * pwmApplication.getConfig().readSettingAsLong(PwmSetting.PASSWORD_SHAREDHISTORY_MAX_AGE); // convert to MS;
settings.caseInsensitive = Boolean.parseBoolean(pwmApplication.getConfig().readAppProperty(AppProperty.SECURITY_SHAREDHISTORY_CASE_INSENSITIVE));
settings.hashName = pwmApplication.getConfig().readAppProperty(AppProperty.SECURITY_SHAREDHISTORY_HASH_NAME);
settings.hashIterations = Integer.parseInt(pwmApplication.getConfig().readAppProperty(AppProperty.SECURITY_SHAREDHISTORY_HASH_ITERATIONS));
settings.version = "2" + "_" + settings.hashName + "_" + settings.hashIterations + "_" + settings.caseInsensitive;
final int SALT_LENGTH = Integer.parseInt(pwmApplication.getConfig().readAppProperty(AppProperty.SECURITY_SHAREDHISTORY_SALT_LENGTH));
this.localDB = pwmApplication.getLocalDB();
boolean needsClearing = false;
if (localDB == null) {
LOGGER.info("LocalDB is not available, will remain closed");
status = STATUS.CLOSED;
return;
}
if (settings.maxAgeMs < 1) {
LOGGER.debug("max age=" + settings.maxAgeMs + ", will remain closed");
needsClearing = true;
}
{
this.salt = localDB.get(META_DB, KEY_SALT);
if (salt == null || salt.length() < SALT_LENGTH) {
LOGGER.warn("stored global salt value is not present, creating new salt");
this.salt = PwmRandom.getInstance().alphaNumericString(SALT_LENGTH);
localDB.put(META_DB, KEY_SALT,this.salt);
needsClearing = true;
}
}
if (needsClearing) {
LOGGER.trace("clearing wordlist");
try {
localDB.truncate(WORDS_DB);
} catch (Exception e) {
LOGGER.error("error during wordlist truncate", e);
}
}
new Thread(new Runnable() {
public void run() {
LOGGER.debug("starting up in background thread");
init(pwmApplication, settings.maxAgeMs);
}
}, JavaHelper.makeThreadName(pwmApplication, this.getClass()) + " initializer").start();
}
private static class Settings {
private String version;
private String hashName;
private int hashIterations;
private long maxAgeMs;
private boolean caseInsensitive;
}
public ServiceInfo serviceInfo()
{
if (status == STATUS.OPEN) {
return new ServiceInfo(Collections.singletonList(DataStorageMethod.LOCALDB));
} else {
return new ServiceInfo(Collections.<DataStorageMethod>emptyList());
}
}
}