Browse Source

cache improvements and pw expiry svc stub

Jason Rivard 8 years ago
parent
commit
b106661f09
34 changed files with 583 additions and 312 deletions
  1. 1 3
      import-control.xml
  2. 22 14
      pom.xml
  3. 6 3
      src/main/java/password/pwm/AppProperty.java
  4. 2 1
      src/main/java/password/pwm/PwmApplication.java
  5. 7 2
      src/main/java/password/pwm/config/PwmSetting.java
  6. 11 5
      src/main/java/password/pwm/config/profile/LdapProfile.java
  7. 3 2
      src/main/java/password/pwm/http/servlet/resource/ResourceFileServlet.java
  8. 11 12
      src/main/java/password/pwm/http/servlet/resource/ResourceServletService.java
  9. 1 1
      src/main/java/password/pwm/http/state/CryptoCookieLoginImpl.java
  10. 91 3
      src/main/java/password/pwm/ldap/LdapOperationsHelper.java
  11. 7 6
      src/main/java/password/pwm/ldap/LdapUserDataReader.java
  12. 1 10
      src/main/java/password/pwm/ldap/UserStatusReader.java
  13. 1 2
      src/main/java/password/pwm/svc/cache/CacheService.java
  14. 2 0
      src/main/java/password/pwm/svc/cache/CacheStore.java
  15. 29 29
      src/main/java/password/pwm/svc/cache/CacheStoreInfo.java
  16. 37 0
      src/main/java/password/pwm/svc/cache/CacheValueWrapper.java
  17. 29 63
      src/main/java/password/pwm/svc/cache/LocalDBCacheStore.java
  18. 19 54
      src/main/java/password/pwm/svc/cache/MemoryCacheStore.java
  19. 3 3
      src/main/java/password/pwm/svc/event/DatabaseUserHistory.java
  20. 146 0
      src/main/java/password/pwm/svc/pwnotify/PasswordExpireNotificationEngine.java
  21. 7 44
      src/main/java/password/pwm/svc/report/ReportService.java
  22. 3 3
      src/main/java/password/pwm/svc/token/DBTokenMachine.java
  23. 1 1
      src/main/java/password/pwm/util/db/DatabaseAccessorImpl.java
  24. 5 20
      src/main/java/password/pwm/util/db/DatabaseDataStore.java
  25. 2 1
      src/main/java/password/pwm/util/db/DatabaseTable.java
  26. 2 4
      src/main/java/password/pwm/util/db/JDBCDriverLoader.java
  27. 12 0
      src/main/java/password/pwm/util/java/JavaHelper.java
  28. 4 4
      src/main/java/password/pwm/util/operations/cr/DbCrOperator.java
  29. 4 4
      src/main/java/password/pwm/util/operations/otp/DbOtpOperator.java
  30. 19 14
      src/main/java/password/pwm/ws/server/rest/RestCheckPasswordServer.java
  31. 6 4
      src/main/resources/password/pwm/AppProperty.properties
  32. 6 0
      src/main/resources/password/pwm/config/PwmSetting.xml
  33. 2 0
      src/main/resources/password/pwm/i18n/PwmSetting.properties
  34. 81 0
      src/test/java/password/pwm/AppPropertyTest.java

+ 1 - 3
import-control.xml

@@ -55,9 +55,6 @@
     <!-- gson -->
     <allow pkg="com.google.gson"/>
 
-    <!-- concurrentlinkhashmap -->
-    <allow pkg="com.googlecode.concurrentlinkedhashmap"/>
-
     <!-- to be removed/scoped -->
     <allow pkg="org.apache.http"/>
     <allow pkg="org.apache.commons"/>
@@ -78,6 +75,7 @@
     <allow pkg="net.glxn"/>
     <allow pkg="org.webjars"/>
     <allow pkg="lombok"/>
+    <allow pkg="com.github.benmanes.caffeine"/>
 
 
     <!--servlet -->

+ 22 - 14
pom.xml

@@ -230,6 +230,21 @@
                             </resources>
                         </configuration>
                     </execution>
+                    <execution>
+                        <id>stage-angular</id>
+                        <phase>validate</phase>
+                        <goals>
+                            <goal>copy-resources</goal>
+                        </goals>
+                        <configuration>
+                            <outputDirectory>${project.basedir}/target/angular</outputDirectory>
+                            <resources>
+                                <resource>
+                                    <directory>src/main/angular</directory>
+                                </resource>
+                            </resources>
+                        </configuration>
+                    </execution>
                     <execution>
                         <id>copy-angular-resources</id>
                         <phase>prepare-package</phase>
@@ -240,7 +255,7 @@
                             <outputDirectory>${project.basedir}/target/${project.artifactId}-${project.version}/public/resources/app</outputDirectory>
                             <resources>
                                 <resource>
-                                    <directory>src/main/angular/dist</directory>
+                                    <directory>${project.basedir}/target/angular/dist</directory>
                                 </resource>
                             </resources>
                         </configuration>
@@ -293,13 +308,6 @@
             <plugin>
                 <artifactId>maven-clean-plugin</artifactId>
                 <version>3.0.0</version>
-                <configuration>
-                    <filesets>
-                        <fileset><directory>src/main/angular/dist</directory></fileset>
-                        <fileset><directory>src/main/angular/node_modules</directory></fileset>
-                        <fileset><directory>src/main/webapp/public/resources/app</directory></fileset>
-                    </filesets>
-                </configuration>
                 <executions>
                     <execution>
                         <id>remove-compiled-jsps</id>
@@ -435,7 +443,7 @@
                     <nodeVersion>v6.6.0</nodeVersion>
                     <npmVersion>3.10.8</npmVersion>
                     <installDirectory>target</installDirectory>
-                    <workingDirectory>${basedir}/src/main/angular</workingDirectory>
+                    <workingDirectory>${basedir}/target/angular</workingDirectory>
                 </configuration>
                 <executions>
                     <!-- install node & npm -->
@@ -623,11 +631,6 @@
             <artifactId>httpclient</artifactId>
             <version>4.5.3</version>
         </dependency>
-        <dependency>
-            <groupId>com.googlecode.concurrentlinkedhashmap</groupId>
-            <artifactId>concurrentlinkedhashmap-lru</artifactId>
-            <version>1.4.2</version>
-        </dependency>
         <dependency>
             <groupId>org.graylog2</groupId>
             <artifactId>syslog4j</artifactId>
@@ -728,6 +731,11 @@
             <artifactId>webjars-locator-core</artifactId>
             <version>0.32</version>
         </dependency>
+        <dependency>
+            <groupId>com.github.ben-manes.caffeine</groupId>
+            <artifactId>caffeine</artifactId>
+            <version>2.4.0</version>
+        </dependency>
 
 
 

+ 6 - 3
src/main/java/password/pwm/AppProperty.java

@@ -159,6 +159,11 @@ public enum     AppProperty {
     HELPDESK_TOKEN_VALUE                            ("helpdesk.token.value"),
     HELPDESK_VERIFICATION_INVALID_DELAY_MS          ("helpdesk.verification.invalid.delayMs"),
     HELPDESK_VERIFICATION_TIMEOUT_SECONDS           ("helpdesk.verification.timeoutSeconds"),
+    LDAP_RESOLVE_CANONICAL_DN                       ("ldap.resolveCanonicalDN"),
+    LDAP_CACHE_CANONICAL_ENABLE                     ("ldap.cache.canonical.enable"),
+    LDAP_CACHE_CANONICAL_SECONDS                    ("ldap.cache.canonical.seconds"),
+    LDAP_CACHE_USER_GUID_ENABLE                     ("ldap.cache.userGuid.enable"),
+    LDAP_CACHE_USER_GUID_SECONDS                    ("ldap.cache.userGuid.seconds"),
     LDAP_CHAI_SETTINGS                              ("ldap.chaiSettings"),
     LDAP_EXTENSIONS_NMAS_ENABLE                     ("ldap.extensions.nmas.enable"),
     LDAP_CONNECTION_TIMEOUT                         ("ldap.connection.timeoutMS"),
@@ -250,8 +255,6 @@ public enum     AppProperty {
     SECURITY_SHAREDHISTORY_CASE_INSENSITIVE         ("security.sharedHistory.caseInsensitive"),
     SECURITY_SHAREDHISTORY_SALT_LENGTH              ("security.sharedHistory.saltLength"),
     SECURITY_CERTIFICATES_VALIDATE_TIMESTAMPS       ("security.certs.validateTimestamps"),
-    SECURITY_LDAP_RESOLVE_CANONICAL_DN              ("security.ldap.resolveCanonicalDN"),
-    SECURITY_LDAP_CANONICAL_CACHE_SECONDS           ("security.ldap.canonicalCacheSeconds"),
     SECURITY_CONFIG_MIN_SECURITY_KEY_LENGTH         ("security.config.minSecurityKeyLength"),
     SECURITY_DEFAULT_EPHEMERAL_BLOCK_ALG            ("security.defaultEphemeralBlockAlg"),
     SECURITY_DEFAULT_EPHEMERAL_HASH_ALG             ("security.defaultEphemeralHashAlg"),
@@ -306,6 +309,6 @@ public enum     AppProperty {
     }
 
     private static String readAppPropertiesBundle(final String key) {
-        return  ResourceBundle.getBundle(AppProperty.class.getName()).getString(key);
+        return ResourceBundle.getBundle(AppProperty.class.getName()).getString(key);
     }
 }

+ 2 - 1
src/main/java/password/pwm/PwmApplication.java

@@ -60,6 +60,7 @@ import password.pwm.svc.wordlist.WordlistManager;
 import password.pwm.util.PasswordData;
 import password.pwm.util.VersionChecker;
 import password.pwm.util.cli.commands.ExportHttpsTomcatConfigCommand;
+import password.pwm.util.db.DatabaseAccessor;
 import password.pwm.util.db.DatabaseAccessorImpl;
 import password.pwm.util.java.FileSystemUtility;
 import password.pwm.util.java.JavaHelper;
@@ -530,7 +531,7 @@ public class PwmApplication {
         return pwmEnvironment.getApplicationMode();
     }
 
-    public synchronized DatabaseAccessorImpl getDatabaseAccessor()
+    public synchronized DatabaseAccessor getDatabaseAccessor()
     {
         return (DatabaseAccessorImpl)pwmServiceManager.getService(DatabaseAccessorImpl.class);
     }

+ 7 - 2
src/main/java/password/pwm/config/PwmSetting.java

@@ -286,6 +286,11 @@ public enum PwmSetting {
             "email.smtp.userpassword", PwmSettingSyntax.PASSWORD, PwmSettingCategory.EMAIL_SETTINGS),
     EMAIL_MAX_QUEUE_AGE(
             "email.queueMaxAge", PwmSettingSyntax.DURATION, PwmSettingCategory.EMAIL_SETTINGS),
+    EMAIL_ADVANCED_SETTINGS(
+            "email.smtp.advancedSettings", PwmSettingSyntax.STRING_ARRAY, PwmSettingCategory.EMAIL_SETTINGS),
+
+
+    // email template
     EMAIL_CHANGEPASSWORD(
             "email.changePassword", PwmSettingSyntax.EMAIL, PwmSettingCategory.EMAIL_TEMPLATES),
     EMAIL_CHANGEPASSWORD_HELPDESK(
@@ -322,9 +327,9 @@ public enum PwmSetting {
             "email.helpdesk.unlock", PwmSettingSyntax.EMAIL, PwmSettingCategory.EMAIL_TEMPLATES),
     EMAIL_UNLOCK(
             "email.unlock", PwmSettingSyntax.EMAIL, PwmSettingCategory.EMAIL_TEMPLATES),
+    EMAIL_PW_EXPIRATION_NOTICE(
+            "email.pwExpirationNotice", PwmSettingSyntax.EMAIL, PwmSettingCategory.EMAIL_TEMPLATES),
 
-    EMAIL_ADVANCED_SETTINGS(
-            "email.smtp.advancedSettings", PwmSettingSyntax.STRING_ARRAY, PwmSettingCategory.EMAIL_SETTINGS),
 
     // sms settings
     SMS_MAX_QUEUE_AGE(

+ 11 - 5
src/main/java/password/pwm/config/profile/LdapProfile.java

@@ -125,15 +125,17 @@ public class LdapProfile extends AbstractProfile implements Profile {
             throws PwmUnrecoverableException
     {
         {
-            final boolean doCanonicalDnResolve = Boolean.parseBoolean(pwmApplication.getConfig().readAppProperty(AppProperty.SECURITY_LDAP_RESOLVE_CANONICAL_DN));
+            final boolean doCanonicalDnResolve = Boolean.parseBoolean(pwmApplication.getConfig().readAppProperty(AppProperty.LDAP_RESOLVE_CANONICAL_DN));
             if (!doCanonicalDnResolve) {
                 return dnValue;
             }
         }
 
+        final boolean enableCanonicalCache = Boolean.parseBoolean(pwmApplication.getConfig().readAppProperty(AppProperty.LDAP_CACHE_CANONICAL_ENABLE));
+
         String canonicalValue = null;
         final CacheKey cacheKey = CacheKey.makeCacheKey(LdapPermissionTester.class, null, "canonicalDN-" + this.getIdentifier() + "-" + dnValue);
-        {
+        if (enableCanonicalCache) {
             final String cachedDN = pwmApplication.getCacheService().get(cacheKey);
             if (cachedDN != null) {
                 canonicalValue = cachedDN;
@@ -145,9 +147,13 @@ public class LdapProfile extends AbstractProfile implements Profile {
                 final ChaiProvider chaiProvider = this.getProxyChaiProvider(pwmApplication);
                 final ChaiEntry chaiEntry = ChaiFactory.createChaiEntry(dnValue, chaiProvider);
                 canonicalValue = chaiEntry.readCanonicalDN();
-                final long cacheSeconds = Long.parseLong(pwmApplication.getConfig().readAppProperty(AppProperty.SECURITY_LDAP_CANONICAL_CACHE_SECONDS));
-                final CachePolicy cachePolicy = CachePolicy.makePolicyWithExpiration(new TimeDuration(cacheSeconds, TimeUnit.SECONDS));
-                pwmApplication.getCacheService().put(cacheKey, cachePolicy, canonicalValue);
+
+                if (enableCanonicalCache) {
+                    final long cacheSeconds = Long.parseLong(pwmApplication.getConfig().readAppProperty(AppProperty.LDAP_CACHE_CANONICAL_SECONDS));
+                    final CachePolicy cachePolicy = CachePolicy.makePolicyWithExpiration(new TimeDuration(cacheSeconds, TimeUnit.SECONDS));
+                    pwmApplication.getCacheService().put(cacheKey, cachePolicy, canonicalValue);
+                }
+
                 LOGGER.trace("read and cached canonical ldap DN value for input '" + dnValue + "' as '" + canonicalValue + "'");
             } catch (ChaiUnavailableException | ChaiOperationException e) {
                 LOGGER.error("error while reading canonicalDN for dn value '" + dnValue + "', error: " + e.getMessage());

+ 3 - 2
src/main/java/password/pwm/http/servlet/resource/ResourceFileServlet.java

@@ -22,6 +22,7 @@
 
 package password.pwm.http.servlet.resource;
 
+import com.github.benmanes.caffeine.cache.Cache;
 import org.apache.commons.io.IOUtils;
 import org.webjars.WebJarAssetLocator;
 import password.pwm.PwmApplication;
@@ -264,7 +265,7 @@ public class ResourceFileServlet extends HttpServlet implements PwmServlet {
             final HttpServletResponse response,
             final FileResource file,
             final boolean acceptsGzip,
-            final Map<CacheKey, CacheEntry> responseCache
+            final Cache<CacheKey, CacheEntry> responseCache
     )
             throws UncacheableResourceException, IOException
     {
@@ -275,7 +276,7 @@ public class ResourceFileServlet extends HttpServlet implements PwmServlet {
 
         boolean fromCache = false;
         final CacheKey cacheKey = new CacheKey(file, acceptsGzip);
-        CacheEntry cacheEntry = responseCache.get(cacheKey);
+        CacheEntry cacheEntry = responseCache.getIfPresent(cacheKey);
         if (cacheEntry == null) {
             final Map<String, String> headers = new HashMap<>();
             final ByteArrayOutputStream tempOutputStream = new ByteArrayOutputStream();

+ 11 - 12
src/main/java/password/pwm/http/servlet/resource/ResourceServletService.java

@@ -22,7 +22,8 @@
 
 package password.pwm.http.servlet.resource;
 
-import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
 import org.apache.commons.io.output.NullOutputStream;
 import password.pwm.AppProperty;
 import password.pwm.PwmApplication;
@@ -60,7 +61,7 @@ public class ResourceServletService implements PwmService {
 
 
     private ResourceServletConfiguration resourceServletConfiguration;
-    private Map<CacheKey, CacheEntry> cacheMap;
+    private Cache<CacheKey, CacheEntry> cache;
     private EventRateMeter.MovingAverage cacheHitRatio = new EventRateMeter.MovingAverage(60 * 60 * 1000);
     private String resourceNonce;
     private STATUS status = STATUS.NEW;
@@ -71,8 +72,8 @@ public class ResourceServletService implements PwmService {
         return resourceNonce;
     }
 
-    public Map<CacheKey, CacheEntry> getCacheMap() {
-        return cacheMap;
+    public Cache<CacheKey, CacheEntry> getCacheMap() {
+        return cache;
     }
 
     public EventRateMeter.MovingAverage getCacheHitRatio() {
@@ -81,12 +82,10 @@ public class ResourceServletService implements PwmService {
 
 
     public long bytesInCache() {
-        final Map<CacheKey, CacheEntry> responseCache = getCacheMap();
-        final Map<CacheKey, CacheEntry> cacheCopy = new HashMap<>();
-        cacheCopy.putAll(responseCache);
+        final Map<CacheKey, CacheEntry> cacheCopy = new HashMap<>(cache.asMap());
         long cacheByteCount = 0;
         for (final CacheKey cacheKey : cacheCopy.keySet()) {
-            final CacheEntry cacheEntry = responseCache.get(cacheKey);
+            final CacheEntry cacheEntry = cacheCopy.get(cacheKey);
             if (cacheEntry != null && cacheEntry.getEntity() != null) {
                 cacheByteCount += cacheEntry.getEntity().length;
             }
@@ -95,8 +94,8 @@ public class ResourceServletService implements PwmService {
     }
 
     public int itemsInCache() {
-        final Map<CacheKey, CacheEntry> responseCache = getCacheMap();
-        return responseCache.size();
+        final Cache<CacheKey, CacheEntry> responseCache = getCacheMap();
+        return (int)responseCache.estimatedSize();
     }
 
     public Percent cacheHitRatio() {
@@ -117,8 +116,8 @@ public class ResourceServletService implements PwmService {
         try {
             this.resourceServletConfiguration = ResourceServletConfiguration.createResourceServletConfiguration(pwmApplication);
 
-            cacheMap = new ConcurrentLinkedHashMap.Builder<CacheKey, CacheEntry>()
-                    .maximumWeightedCapacity(resourceServletConfiguration.getMaxCacheItems())
+            cache = Caffeine.newBuilder()
+                    .maximumSize(resourceServletConfiguration.getMaxCacheItems())
                     .build();
 
             status = STATUS.OPEN;

+ 1 - 1
src/main/java/password/pwm/http/state/CryptoCookieLoginImpl.java

@@ -101,7 +101,7 @@ class CryptoCookieLoginImpl implements SessionLoginProvider {
                 try {
                     checkIfRemoteLoginCookieIsValid(pwmRequest, remoteLoginCookie);
                 } catch (PwmOperationalException e) {
-                    LOGGER.warn(pwmRequest, e.getErrorInformation().toDebugStr());
+                    LOGGER.debug(pwmRequest, e.getErrorInformation().toDebugStr());
                     clearLoginSession(pwmRequest);
                     return;
                 }

+ 91 - 3
src/main/java/password/pwm/ldap/LdapOperationsHelper.java

@@ -36,6 +36,7 @@ import com.novell.ldapchai.provider.ChaiSetting;
 import com.novell.ldapchai.util.SearchHelper;
 import password.pwm.AppProperty;
 import password.pwm.PwmApplication;
+import password.pwm.PwmConstants;
 import password.pwm.bean.SessionLabel;
 import password.pwm.bean.UserIdentity;
 import password.pwm.config.Configuration;
@@ -49,16 +50,22 @@ import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.http.PwmSession;
 import password.pwm.ldap.search.SearchConfiguration;
 import password.pwm.ldap.search.UserSearchEngine;
+import password.pwm.svc.cache.CacheKey;
+import password.pwm.svc.cache.CachePolicy;
 import password.pwm.svc.stats.Statistic;
 import password.pwm.svc.stats.StatisticsManager;
 import password.pwm.util.PasswordData;
+import password.pwm.util.java.JsonUtil;
 import password.pwm.util.java.StringUtil;
+import password.pwm.util.java.TimeDuration;
 import password.pwm.util.logging.PwmLogger;
 import password.pwm.util.macro.MacroMachine;
 import password.pwm.util.secure.X509Utils;
 
 import javax.net.ssl.X509TrustManager;
 import java.security.cert.X509Certificate;
+import java.time.Instant;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collections;
 import java.util.Date;
@@ -67,6 +74,7 @@ import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import java.util.concurrent.TimeUnit;
 
 public class LdapOperationsHelper {
     private static final PwmLogger LOGGER = PwmLogger.forClass(LdapOperationsHelper.class);
@@ -158,9 +166,25 @@ public class LdapOperationsHelper {
     )
             throws ChaiUnavailableException, PwmUnrecoverableException
     {
-        final String existingValue = GUIDHelper.readExistingGuidValue(pwmApplication, sessionLabel, userIdentity, throwExceptionOnError);
-        final LdapProfile ldapProfile = pwmApplication.getConfig().getLdapProfiles().get(
-                userIdentity.getLdapProfileID());
+
+        final boolean enableCache = Boolean.parseBoolean(pwmApplication.getConfig().readAppProperty(AppProperty.LDAP_CACHE_USER_GUID_ENABLE));
+        final CacheKey cacheKey = CacheKey.makeCacheKey(LdapOperationsHelper.class, null, "guidValue-" + userIdentity.toDelimitedKey());
+
+        if (enableCache) {
+            final String cachedValue = pwmApplication.getCacheService().get(cacheKey);
+            if (cachedValue != null) {
+                return cachedValue;
+            }
+        }
+
+        final String existingValue = GUIDHelper.readExistingGuidValue(
+                pwmApplication,
+                sessionLabel,
+                userIdentity,
+                throwExceptionOnError
+        );
+
+        final LdapProfile ldapProfile = pwmApplication.getConfig().getLdapProfiles().get(userIdentity.getLdapProfileID());
         final String guidAttributeName = ldapProfile.readSettingAsString(PwmSetting.LDAP_GUID_ATTRIBUTE);
         if (existingValue == null || existingValue.length() < 1) {
             if (!"DN".equalsIgnoreCase(guidAttributeName) && !"VENDORGUID".equalsIgnoreCase(guidAttributeName)) {
@@ -172,6 +196,13 @@ public class LdapOperationsHelper {
             final String errorMsg = "unable to resolve GUID value for user " + userIdentity.toString();
             GUIDHelper.processError(errorMsg,throwExceptionOnError);
         }
+
+        if (enableCache) {
+            final long cacheSeconds = Long.parseLong(pwmApplication.getConfig().readAppProperty(AppProperty.LDAP_CACHE_USER_GUID_SECONDS));
+            final CachePolicy cachePolicy = CachePolicy.makePolicyWithExpiration(new TimeDuration(cacheSeconds, TimeUnit.SECONDS));
+            pwmApplication.getCacheService().put(cacheKey, cachePolicy, existingValue);
+        }
+
         return existingValue;
     }
 
@@ -599,4 +630,61 @@ public class LdapOperationsHelper {
         }
         return Collections.emptyMap();
     }
+
+    public static List<UserIdentity> readAllUsersFromLdap(
+            final PwmApplication pwmApplication,
+            final SessionLabel sessionLabel,
+            final String searchFilter,
+            final int maxResults
+    )
+            throws ChaiUnavailableException, ChaiOperationException, PwmUnrecoverableException, PwmOperationalException
+    {
+        final UserSearchEngine userSearchEngine = pwmApplication.getUserSearchEngine();
+
+        final SearchConfiguration searchConfiguration;
+        {
+            final SearchConfiguration.SearchConfigurationBuilder builder = SearchConfiguration.builder();
+
+            builder.enableValueEscaping(false);
+            builder.searchTimeout(Long.parseLong(pwmApplication.getConfig().readAppProperty(AppProperty.REPORTING_LDAP_SEARCH_TIMEOUT)));
+
+            if (searchFilter == null) {
+                builder.username("*");
+            } else {
+                builder.filter(searchFilter);
+            }
+
+            searchConfiguration = builder.build();
+        }
+
+        LOGGER.debug(sessionLabel,"beginning user search using parameters: " + (JsonUtil.serialize(searchConfiguration)));
+
+        final Map<UserIdentity,Map<String,String>> searchResults = userSearchEngine.performMultiUserSearch(
+                searchConfiguration,
+                maxResults,
+                Collections.emptyList(),
+                PwmConstants.REPORTING_SESSION_LABEL
+
+        );
+        LOGGER.debug(sessionLabel,"user search found " + searchResults.size() + " users for reporting");
+        return new ArrayList<>(searchResults.keySet());
+    }
+
+    public static Instant readPasswordExpirationTime(final ChaiUser theUser) {
+        try {
+            Date ldapPasswordExpirationTime = theUser.readPasswordExpirationDate();
+            if (ldapPasswordExpirationTime != null && ldapPasswordExpirationTime.getTime() < 0) {
+                // If ldapPasswordExpirationTime is less than 0, this may indicate an extremely late date, past the epoch.
+                ldapPasswordExpirationTime = null;
+            }
+            return ldapPasswordExpirationTime == null
+                    ? null
+                    : ldapPasswordExpirationTime.toInstant();
+        } catch (Exception e) {
+            LOGGER.warn("error reading password expiration time: " + e.getMessage());
+        }
+
+        return null;
+    }
+
 }

+ 7 - 6
src/main/java/password/pwm/ldap/LdapUserDataReader.java

@@ -22,7 +22,8 @@
 
 package password.pwm.ldap;
 
-import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
 import com.novell.ldapchai.ChaiFactory;
 import com.novell.ldapchai.ChaiUser;
 import com.novell.ldapchai.exception.ChaiOperationException;
@@ -50,8 +51,8 @@ public class LdapUserDataReader implements Serializable, UserDataReader {
 
     private static final Boolean NULL_CACHE_VALUE = Boolean.FALSE;
 
-    private final Map<String,Object> cacheMap = new ConcurrentLinkedHashMap.Builder<String, Object>()
-            .maximumWeightedCapacity(100)  // safety limit
+    private final Cache<String,Object> cacheMap = Caffeine.newBuilder()
+            .maximumSize(100)  // safety limit
             .build();
     private final ChaiUser user;
     private final UserIdentity userIdentity;
@@ -162,12 +163,12 @@ public class LdapUserDataReader implements Serializable, UserDataReader {
         }
 
         if (ignoreCache) {
-            cacheMap.keySet().removeAll(attributes);
+            cacheMap.invalidateAll();
         }
 
         // figure out uncached attributes.
         final List<String> uncachedAttributes = new ArrayList<>(attributes);
-        uncachedAttributes.removeAll(cacheMap.keySet());
+        uncachedAttributes.removeAll(cacheMap.asMap().keySet());
 
         // read uncached attributes into cache
         if (!uncachedAttributes.isEmpty()) {
@@ -186,7 +187,7 @@ public class LdapUserDataReader implements Serializable, UserDataReader {
         // build result data from cache
         final Map<String,List<String>> returnMap = new HashMap<>();
         for (final String attribute : attributes) {
-            final Object cachedValue = cacheMap.get(attribute);
+            final Object cachedValue = cacheMap.getIfPresent(attribute);
             if (cachedValue != null && !NULL_CACHE_VALUE.equals(cachedValue)) {
                 returnMap.put(attribute,(List<String>)cachedValue);
             }

+ 1 - 10
src/main/java/password/pwm/ldap/UserStatusReader.java

@@ -362,16 +362,7 @@ public class UserStatusReader {
         }
 
         // read password expiration time
-        try {
-            Date ldapPasswordExpirationTime = theUser.readPasswordExpirationDate();
-            if (ldapPasswordExpirationTime != null && ldapPasswordExpirationTime.getTime() < 0) {
-                // If ldapPasswordExpirationTime is less than 0, this may indicate an extremely late date, past the epoch.
-                ldapPasswordExpirationTime = null;
-            }
-            uiBean.setPasswordExpirationTime(ldapPasswordExpirationTime == null ? null : ldapPasswordExpirationTime.toInstant());
-        } catch (Exception e) {
-            LOGGER.warn(sessionLabel, "error reading password expiration time: " + e.getMessage());
-        }
+        uiBean.setPasswordExpirationTime(LdapOperationsHelper.readPasswordExpirationTime(theUser));
 
         // read password state
         uiBean.setPasswordState(readPasswordStatus(theUser, uiBean.getPasswordPolicy(), uiBean, currentPassword));

+ 1 - 2
src/main/java/password/pwm/svc/cache/CacheService.java

@@ -25,7 +25,6 @@ package password.pwm.svc.cache;
 import password.pwm.AppProperty;
 import password.pwm.PwmApplication;
 import password.pwm.PwmApplicationMode;
-import password.pwm.config.option.DataStorageMethod;
 import password.pwm.error.PwmException;
 import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.health.HealthRecord;
@@ -99,7 +98,7 @@ public class CacheService implements PwmService {
 
     @Override
     public ServiceInfo serviceInfo() {
-        return new ServiceInfo(Collections.<DataStorageMethod>emptyList());
+        return new ServiceInfo(Collections.emptyList());
     }
 
     public void put(final CacheKey cacheKey, final CachePolicy cachePolicy, final String payload)

+ 2 - 0
src/main/java/password/pwm/svc/cache/CacheStore.java

@@ -32,4 +32,6 @@ public interface CacheStore {
     String read(CacheKey cacheKey) throws PwmUnrecoverableException;
     
     CacheStoreInfo getCacheStoreInfo();
+
+    int itemCount();
 }

+ 29 - 29
src/main/java/password/pwm/svc/cache/CacheStoreInfo.java

@@ -23,51 +23,51 @@
 package password.pwm.svc.cache;
 
 import java.io.Serializable;
+import java.util.concurrent.atomic.AtomicLong;
 
 public class CacheStoreInfo implements Serializable {
-    private int storeCount;
-    private int readCount;
-    private int hitCount;
-    private int missCount;
-    private int itemCount;
+    private final AtomicLong storeCount = new AtomicLong();
+    private final AtomicLong readCount = new AtomicLong();
+    private final AtomicLong hitCount = new AtomicLong();
+    private final AtomicLong missCount = new AtomicLong();
 
-    public int getStoreCount() {
-        return storeCount;
+    public void incrementStoreCount()
+    {
+        storeCount.incrementAndGet();
     }
 
-    public void setStoreCount(final int storeCount) {
-        this.storeCount = storeCount;
+    public void incrementReadCount()
+    {
+        readCount.incrementAndGet();
     }
 
-    public int getReadCount() {
-        return readCount;
+    public void incrementHitCount()
+    {
+        hitCount.incrementAndGet();
     }
 
-    public void setReadCount(final int readCount) {
-        this.readCount = readCount;
+    public void incrementMissCount()
+    {
+        missCount.incrementAndGet();
     }
 
-    public int getHitCount() {
-        return hitCount;
+    public long getStoreCount()
+    {
+        return storeCount.get();
     }
 
-    public void setHitCount(final int hitCount) {
-        this.hitCount = hitCount;
+    public long getReadCount()
+    {
+        return readCount.get();
     }
 
-    public int getMissCount() {
-        return missCount;
+    public long getHitCount()
+    {
+        return hitCount.get();
     }
 
-    public void setMissCount(final int missCount) {
-        this.missCount = missCount;
-    }
-
-    public int getItemCount() {
-        return itemCount;
-    }
-
-    public void setItemCount(final int itemCount) {
-        this.itemCount = itemCount;
+    public long getMissCount()
+    {
+        return missCount.get();
     }
 }

+ 37 - 0
src/main/java/password/pwm/svc/cache/CacheValueWrapper.java

@@ -0,0 +1,37 @@
+/*
+ * 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.cache;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+import java.io.Serializable;
+import java.time.Instant;
+
+@Getter
+@AllArgsConstructor
+class CacheValueWrapper implements Serializable {
+    private final CacheKey cacheKey;
+    private final Instant expirationDate;
+    private final String payload;
+}

+ 29 - 63
src/main/java/password/pwm/svc/cache/LocalDBCacheStore.java

@@ -30,12 +30,12 @@ import password.pwm.util.localdb.LocalDB;
 import password.pwm.util.localdb.LocalDBException;
 import password.pwm.util.logging.PwmLogger;
 
-import java.io.Serializable;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.Timer;
 import java.util.TimerTask;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicInteger;
 
 public class LocalDBCacheStore implements CacheStore {
     private static final PwmLogger LOGGER = PwmLogger.forClass(LocalDBCacheStore.class);
@@ -45,13 +45,10 @@ public class LocalDBCacheStore implements CacheStore {
     private static final int TICKS_BETWEEN_PURGE_CYCLES = 1000;
 
     private final LocalDB localDB;
-    private final Timer timer;
-    private int ticks = 0;
+    private final ExecutorService timer;
+    private final AtomicInteger ticks = new AtomicInteger(0);
 
-    private int readCount;
-    private int storeCount;
-    private int hitCount;
-    private int missCount;
+    private final CacheStoreInfo cacheStoreInfo = new CacheStoreInfo();
 
     LocalDBCacheStore(final PwmApplication pwmApplication) {
         this.localDB = pwmApplication.getLocalDB();
@@ -60,23 +57,23 @@ public class LocalDBCacheStore implements CacheStore {
         } catch (LocalDBException e) {
             LOGGER.error("error while clearing LocalDB CACHE DB during init: " + e.getMessage());
         }
-        timer = new Timer(JavaHelper.makeThreadName(pwmApplication,LocalDBCacheStore.class),true);
+        timer = JavaHelper.makeSingleThreadExecutorService(pwmApplication, LocalDBCacheStore.class);
     }
 
     @Override
     public void store(final CacheKey cacheKey, final Instant expirationDate, final String data)
             throws PwmUnrecoverableException
     {
-        ticks++;
-        storeCount++;
+        ticks.incrementAndGet();
+        cacheStoreInfo.incrementStoreCount();
         try {
-            localDB.put(DB,cacheKey.getHash(),JsonUtil.serialize(new ValueWrapper(cacheKey, expirationDate, data)));
+            localDB.put(DB,cacheKey.getHash(),JsonUtil.serialize(new CacheValueWrapper(cacheKey, expirationDate, data)));
         } catch (LocalDBException e) {
             LOGGER.error("error while writing cache: " + e.getMessage());
         }
-        if (ticks > TICKS_BETWEEN_PURGE_CYCLES) {
-            ticks = 0;
-            timer.schedule(new PurgerTask(),1);
+        if (ticks.get() > TICKS_BETWEEN_PURGE_CYCLES) {
+            ticks.set(0);
+            timer.execute(new PurgerTask());
         }
     }
 
@@ -84,7 +81,7 @@ public class LocalDBCacheStore implements CacheStore {
     public String read(final CacheKey cacheKey)
             throws PwmUnrecoverableException 
     {
-        readCount++;
+        cacheStoreInfo.incrementReadCount();
         final String hashKey = cacheKey.getHash();
         final String storedValue; 
         try {
@@ -95,10 +92,10 @@ public class LocalDBCacheStore implements CacheStore {
         }
         if (storedValue != null) {
             try {
-                final ValueWrapper valueWrapper = JsonUtil.deserialize(storedValue, ValueWrapper.class);
+                final CacheValueWrapper valueWrapper = JsonUtil.deserialize(storedValue, CacheValueWrapper.class);
                 if (cacheKey.equals(valueWrapper.getCacheKey())) {
                     if (valueWrapper.getExpirationDate().isAfter(Instant.now())) {
-                        hitCount++;
+                        cacheStoreInfo.getHitCount();
                         return valueWrapper.getPayload();
                     }
                 }
@@ -111,57 +108,15 @@ public class LocalDBCacheStore implements CacheStore {
                 LOGGER.error("error while purging record from cache: " + e.getMessage());
             }
         }
-        missCount++;
+        cacheStoreInfo.incrementMissCount();
         return null;
     }
 
     @Override
     public CacheStoreInfo getCacheStoreInfo() {
-        final CacheStoreInfo cacheStoreInfo = new CacheStoreInfo();
-        cacheStoreInfo.setReadCount(readCount);
-        cacheStoreInfo.setStoreCount(storeCount);
-        cacheStoreInfo.setHitCount(hitCount);
-        cacheStoreInfo.setMissCount(missCount);
-        try {
-            cacheStoreInfo.setItemCount(localDB.size(DB));
-        } catch (LocalDBException e) {
-            LOGGER.error("error generating cacheStoreInfo: " + e.getMessage());
-        }
         return cacheStoreInfo;
     }
 
-    private static class ValueWrapper implements Serializable {
-        final CacheKey cacheKey;
-        final Instant expirationDate;
-        final String payload;
-
-        private ValueWrapper(
-                final CacheKey cacheKey,
-                final Instant expirationDate,
-                final String payload
-        )
-        {
-            this.cacheKey = cacheKey;
-            this.expirationDate = expirationDate;
-            this.payload = payload;
-        }
-
-        public CacheKey getCacheKey()
-        {
-            return cacheKey;
-        }
-
-        public Instant getExpirationDate() {
-            return expirationDate;
-        }
-
-        public String getPayload()
-        {
-            return payload;
-        }
-    }
-    
-   
     private boolean purgeExpiredRecords() throws LocalDBException {
         final List<String> removalKeys = new ArrayList<>();
         final LocalDB.LocalDBIterator<String> localDBIterator = localDB.iterator(DB);
@@ -175,8 +130,8 @@ public class LocalDBCacheStore implements CacheStore {
                     if (key != null) {
                         final String strValue = localDB.get(DB, key);
                         if (strValue != null) {
-                            final ValueWrapper valueWrapper = JsonUtil.deserialize(strValue, ValueWrapper.class);
-                            if (valueWrapper.expirationDate.isBefore(Instant.now())) {
+                            final CacheValueWrapper valueWrapper = JsonUtil.deserialize(strValue, CacheValueWrapper.class);
+                            if (valueWrapper.getExpirationDate().isBefore(Instant.now())) {
                                 keep = true;
                             }
                         }
@@ -214,4 +169,15 @@ public class LocalDBCacheStore implements CacheStore {
             }
         }
     }
+
+    @Override
+    public int itemCount()
+    {
+        try {
+            return localDB.size(DB);
+        } catch (LocalDBException e) {
+            LOGGER.error("unexpected error reading size from localDB: " + e.getMessage(), e);
+        }
+        return 0;
+    }
 }

+ 19 - 54
src/main/java/password/pwm/svc/cache/MemoryCacheStore.java

@@ -22,91 +22,56 @@
 
 package password.pwm.svc.cache;
 
-import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
 import password.pwm.error.PwmUnrecoverableException;
 
-import java.io.Serializable;
 import java.time.Instant;
-import java.util.Map;
 
 class MemoryCacheStore implements CacheStore {
-    private final Map<String,ValueWrapper> memoryStore;
-    private int readCount;
-    private int storeCount;
-    private int hitCount;
-    private int missCount;
+    private final Cache<String,CacheValueWrapper> memoryStore;
+    private final CacheStoreInfo cacheStoreInfo = new CacheStoreInfo();
 
     MemoryCacheStore(final int maxItems) {
-        memoryStore = new ConcurrentLinkedHashMap.Builder<String, ValueWrapper>()
-            .maximumWeightedCapacity(maxItems)
-            .build();
+        memoryStore = Caffeine.newBuilder()
+                .maximumSize(maxItems)
+                .build();
     }
 
     @Override
     public void store(final CacheKey cacheKey, final Instant expirationDate, final String data)
             throws PwmUnrecoverableException {
-        storeCount++;
-        memoryStore.put(cacheKey.getHash(), new ValueWrapper(cacheKey, expirationDate, data));
+        cacheStoreInfo.getStoreCount();
+        memoryStore.put(cacheKey.getHash(), new CacheValueWrapper(cacheKey, expirationDate, data));
     }
 
     @Override
     public String read(final CacheKey cacheKey)
             throws PwmUnrecoverableException 
     {
-        readCount++;
-        final ValueWrapper valueWrapper = memoryStore.get(cacheKey.getHash());
+        cacheStoreInfo.getReadCount();
+        final CacheValueWrapper valueWrapper = memoryStore.getIfPresent(cacheKey.getHash());
         if (valueWrapper != null) {
             if (cacheKey.equals(valueWrapper.getCacheKey())) {
                 if (valueWrapper.getExpirationDate().isAfter(Instant.now())) {
-                    hitCount++;
-                    return valueWrapper.payload;
+                    cacheStoreInfo.incrementHitCount();
+                    return valueWrapper.getPayload();
                 }
             }
         }
-        missCount++;
+        memoryStore.invalidate(cacheKey.getHash());
+        cacheStoreInfo.incrementMissCount();
         return null;
     }
 
     @Override
     public CacheStoreInfo getCacheStoreInfo() {
-        final CacheStoreInfo cacheStoreInfo = new CacheStoreInfo();
-        cacheStoreInfo.setReadCount(readCount);
-        cacheStoreInfo.setStoreCount(storeCount);
-        cacheStoreInfo.setHitCount(hitCount);
-        cacheStoreInfo.setMissCount(missCount);
-        cacheStoreInfo.setItemCount(memoryStore.size());
         return cacheStoreInfo;
     }
 
-    private static class ValueWrapper implements Serializable {
-        final CacheKey cacheKey;
-        final Instant expirationDate;
-        final String payload;
-
-        private ValueWrapper(
-                final CacheKey cacheKey,
-                final Instant expirationDate,
-                final String payload
-        )
-        {
-            this.cacheKey = cacheKey;
-            this.expirationDate = expirationDate;
-            this.payload = payload;
-        }
-
-        public CacheKey getCacheKey()
-        {
-            return cacheKey;
-        }
-
-        public Instant getExpirationDate() {
-            return expirationDate;
-        }
-
-        public String getPayload()
-        {
-            return payload;
-        }
+    @Override
+    public int itemCount()
+    {
+        return (int)memoryStore.estimatedSize();
     }
-
 }

+ 3 - 3
src/main/java/password/pwm/svc/event/DatabaseUserHistory.java

@@ -30,10 +30,10 @@ import password.pwm.error.ErrorInformation;
 import password.pwm.error.PwmError;
 import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.ldap.LdapOperationsHelper;
-import password.pwm.util.java.JsonUtil;
-import password.pwm.util.db.DatabaseAccessorImpl;
+import password.pwm.util.db.DatabaseAccessor;
 import password.pwm.util.db.DatabaseException;
 import password.pwm.util.db.DatabaseTable;
+import password.pwm.util.java.JsonUtil;
 import password.pwm.util.logging.PwmLogger;
 
 import java.io.Serializable;
@@ -46,7 +46,7 @@ class DatabaseUserHistory implements UserHistoryStore {
     private static final DatabaseTable TABLE = DatabaseTable.USER_AUDIT;
 
     final PwmApplication pwmApplication;
-    final DatabaseAccessorImpl databaseAccessor;
+    final DatabaseAccessor databaseAccessor;
 
     DatabaseUserHistory(final PwmApplication pwmApplication) {
         this.pwmApplication = pwmApplication;

+ 146 - 0
src/main/java/password/pwm/svc/pwnotify/PasswordExpireNotificationEngine.java

@@ -0,0 +1,146 @@
+/*
+ * 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.pwnotify;
+
+import com.novell.ldapchai.ChaiUser;
+import com.novell.ldapchai.exception.ChaiOperationException;
+import com.novell.ldapchai.exception.ChaiUnavailableException;
+import lombok.Getter;
+import password.pwm.PwmApplication;
+import password.pwm.bean.SessionLabel;
+import password.pwm.bean.UserIdentity;
+import password.pwm.error.ErrorInformation;
+import password.pwm.error.PwmError;
+import password.pwm.error.PwmOperationalException;
+import password.pwm.error.PwmUnrecoverableException;
+import password.pwm.ldap.LdapOperationsHelper;
+import password.pwm.util.db.DatabaseException;
+import password.pwm.util.db.DatabaseTable;
+import password.pwm.util.java.JsonUtil;
+import password.pwm.util.java.StringUtil;
+
+import java.io.Serializable;
+import java.time.Instant;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Queue;
+
+public class PasswordExpireNotificationEngine {
+
+    private final Settings settings;
+    private final PwmApplication pwmApplication;
+
+    public PasswordExpireNotificationEngine(final PwmApplication pwmApplication)
+    {
+        this.pwmApplication = pwmApplication;
+        this.settings = new Settings();
+    }
+
+    public void executeJob()
+            throws ChaiUnavailableException, ChaiOperationException, PwmOperationalException, PwmUnrecoverableException
+    {
+        final Queue<UserIdentity> workQueue;
+        {
+            final List<UserIdentity> users = LdapOperationsHelper.readAllUsersFromLdap(
+                    pwmApplication,
+                    null,
+                    null,
+                    1_000_000
+            );
+            workQueue = new LinkedList<>(users);
+        }
+
+        while (!workQueue.isEmpty()) {
+            final UserIdentity userIdentity = workQueue.poll();
+            final StoredState storedState = new DbStorage(pwmApplication).getStoredState(userIdentity, null);
+        }
+    }
+
+    static void processUserIdentity(
+            final PwmApplication pwmApplication,
+            final UserIdentity userIdentity
+            )
+            throws PwmUnrecoverableException
+    {
+        final ChaiUser theUser = pwmApplication.getProxiedChaiUser(userIdentity);
+        final Instant passwordExpirationTime = LdapOperationsHelper.readPasswordExpirationTime(theUser);
+        if (passwordExpirationTime == null || passwordExpirationTime.isBefore(Instant.now())) {
+            return;
+        }
+    }
+
+    @Getter
+    static class Settings implements Serializable {
+        private List<Integer> dayIntervals;
+    }
+
+    @Getter
+    static class StoredState implements Serializable {
+        private Instant lastSendTimestamp;
+    }
+
+    interface PwExpireStorageEngine {
+
+        StoredState getStoredState(
+                UserIdentity userIdentity,
+                SessionLabel sessionLabel
+        )
+                throws PwmUnrecoverableException;
+    }
+
+    static class DbStorage implements PwExpireStorageEngine {
+        private final PwmApplication pwmApplication;
+
+        DbStorage(final PwmApplication pwmApplication)
+        {
+            this.pwmApplication = pwmApplication;
+        }
+
+        @Override
+        public StoredState getStoredState(
+                final UserIdentity userIdentity,
+                final SessionLabel sessionLabel
+        )
+                throws PwmUnrecoverableException
+        {
+            final String guid;
+            try {
+                guid = LdapOperationsHelper.readLdapGuidValue(pwmApplication, sessionLabel, userIdentity, true);
+            } catch (ChaiUnavailableException e) {
+                throw new PwmUnrecoverableException(PwmUnrecoverableException.fromChaiException(e).getErrorInformation());
+            }
+            if (StringUtil.isEmpty(guid)) {
+                throw new PwmUnrecoverableException(PwmError.ERROR_MISSING_GUID);
+            }
+
+            final String rawDbValue;
+            try {
+                rawDbValue = pwmApplication.getDatabaseAccessor().get(DatabaseTable.PW_NOTIFY, guid);
+            } catch (DatabaseException e) {
+                throw new PwmUnrecoverableException(new ErrorInformation(PwmError.ERROR_DB_UNAVAILABLE,e.getMessage()));
+            }
+
+            return JsonUtil.deserialize(rawDbValue, StoredState.class);
+        }
+    }
+}

+ 7 - 44
src/main/java/password/pwm/svc/report/ReportService.java

@@ -25,7 +25,6 @@ package password.pwm.svc.report;
 import com.novell.ldapchai.exception.ChaiOperationException;
 import com.novell.ldapchai.exception.ChaiUnavailableException;
 import com.novell.ldapchai.provider.ChaiProvider;
-import password.pwm.AppProperty;
 import password.pwm.PwmApplication;
 import password.pwm.PwmApplicationMode;
 import password.pwm.PwmConstants;
@@ -39,8 +38,7 @@ import password.pwm.error.PwmException;
 import password.pwm.error.PwmOperationalException;
 import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.health.HealthRecord;
-import password.pwm.ldap.search.SearchConfiguration;
-import password.pwm.ldap.search.UserSearchEngine;
+import password.pwm.ldap.LdapOperationsHelper;
 import password.pwm.ldap.UserStatusReader;
 import password.pwm.svc.PwmService;
 import password.pwm.svc.stats.EventRateMeter;
@@ -63,7 +61,6 @@ import java.util.ArrayList;
 import java.util.Collections;
 import java.util.LinkedList;
 import java.util.List;
-import java.util.Map;
 import java.util.Queue;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
@@ -393,7 +390,12 @@ public class ReportService implements PwmService {
 
             final Queue<UserIdentity> memQueue;
             {
-                final List<UserIdentity> userIdentities = readAllUsersFromLdap(pwmApplication, settings.getSearchFilter(), settings.getMaxSearchSize());
+                final List<UserIdentity> userIdentities = LdapOperationsHelper.readAllUsersFromLdap(
+                        pwmApplication,
+                        PwmConstants.REPORTING_SESSION_LABEL,
+                        settings.getSearchFilter(),
+                        settings.getMaxSearchSize()
+                );
                 Collections.shuffle(userIdentities);
                 memQueue = new LinkedList<>(userIdentities);
             }
@@ -420,45 +422,6 @@ public class ReportService implements PwmService {
             }
             LOGGER.trace("completed transfer of ldap search results to work queue in " + TimeDuration.fromCurrent(startTime).asCompactString());
         }
-
-        private List<UserIdentity> readAllUsersFromLdap(
-                final PwmApplication pwmApplication,
-                final String searchFilter,
-                final int maxResults
-        )
-                throws ChaiUnavailableException, ChaiOperationException, PwmUnrecoverableException, PwmOperationalException
-        {
-            final UserSearchEngine userSearchEngine = pwmApplication.getUserSearchEngine();
-
-            final SearchConfiguration searchConfiguration;
-            {
-                final SearchConfiguration.SearchConfigurationBuilder builder = SearchConfiguration.builder();
-
-                builder.enableValueEscaping(false);
-                builder.searchTimeout(Long.parseLong(pwmApplication.getConfig().readAppProperty(AppProperty.REPORTING_LDAP_SEARCH_TIMEOUT)));
-
-                if (searchFilter == null) {
-                    builder.username("*");
-                } else {
-                    builder.filter(searchFilter);
-                }
-
-                searchConfiguration = builder.build();
-            }
-
-
-            LOGGER.debug(PwmConstants.REPORTING_SESSION_LABEL,"beginning UserReportService user search using parameters: " + (JsonUtil.serialize(searchConfiguration)));
-
-            final Map<UserIdentity,Map<String,String>> searchResults = userSearchEngine.performMultiUserSearch(
-                    searchConfiguration,
-                    maxResults,
-                    Collections.emptyList(),
-                    PwmConstants.REPORTING_SESSION_LABEL
-
-            );
-            LOGGER.debug(PwmConstants.REPORTING_SESSION_LABEL,"user search found " + searchResults.size() + " users for reporting");
-            return new ArrayList<>(searchResults.keySet());
-        }
     }
 
     private class ProcessWorkQueueTask implements Runnable {

+ 3 - 3
src/main/java/password/pwm/svc/token/DBTokenMachine.java

@@ -25,16 +25,16 @@ package password.pwm.svc.token;
 import password.pwm.bean.SessionLabel;
 import password.pwm.error.PwmOperationalException;
 import password.pwm.error.PwmUnrecoverableException;
-import password.pwm.util.db.DatabaseAccessorImpl;
+import password.pwm.util.db.DatabaseAccessor;
 import password.pwm.util.db.DatabaseTable;
 
 import java.util.Iterator;
 
 class DBTokenMachine implements TokenMachine {
-    private DatabaseAccessorImpl databaseAccessor;
+    private DatabaseAccessor databaseAccessor;
     private TokenService tokenService;
 
-    DBTokenMachine(final TokenService tokenService, final DatabaseAccessorImpl databaseAccessor) {
+    DBTokenMachine(final TokenService tokenService, final DatabaseAccessor databaseAccessor) {
         this.tokenService = tokenService;
         this.databaseAccessor = databaseAccessor;
     }

+ 1 - 1
src/main/java/password/pwm/util/db/DatabaseAccessorImpl.java

@@ -723,7 +723,7 @@ public class DatabaseAccessorImpl implements PwmService, DatabaseAccessor {
                 returnObj.put(PwmAboutProperty.database_databaseProductVersion, databaseMetaData.getDatabaseProductVersion());
                 return Collections.unmodifiableMap(returnObj);
             } catch (SQLException e) {
-                LOGGER.error("error rading jdbc meta data: " + e.getMessage());
+                LOGGER.error("error reading jdbc meta data: " + e.getMessage());
             }
         }
         return Collections.emptyMap();

+ 5 - 20
src/main/java/password/pwm/util/db/DatabaseDataStore.java

@@ -23,21 +23,19 @@
 package password.pwm.util.db;
 
 import password.pwm.error.PwmDataStoreException;
-import password.pwm.svc.PwmService;
-import password.pwm.util.java.ClosableIterator;
 import password.pwm.util.DataStore;
+import password.pwm.util.java.ClosableIterator;
 
 public class DatabaseDataStore implements DataStore {
-    private final DatabaseAccessorImpl databaseAccessor;
+    private final DatabaseAccessor databaseAccessor;
     private final DatabaseTable table;
 
-    public DatabaseDataStore(final DatabaseAccessorImpl databaseAccessor, final DatabaseTable table) {
+    public DatabaseDataStore(final DatabaseAccessor databaseAccessor, final DatabaseTable table) {
         this.databaseAccessor = databaseAccessor;
         this.table = table;
     }
 
     public void close() throws PwmDataStoreException {
-        databaseAccessor.close();
     }
 
     public boolean contains(final String key) throws PwmDataStoreException {
@@ -53,24 +51,11 @@ public class DatabaseDataStore implements DataStore {
     }
 
     public Status status() {
-        final PwmService.STATUS dbStatus = databaseAccessor.status();
-        if (dbStatus == null) {
+        if (databaseAccessor == null) {
             return null;
         }
-        switch (dbStatus) {
-            case OPEN:
-                return Status.OPEN;
-
-            case CLOSED:
-                return Status.CLOSED;
 
-            case NEW:
-            case OPENING:
-                return Status.NEW;
-
-            default:
-                throw new IllegalStateException("unknown databaseAccessor state");
-        }
+        return Status.OPEN;
     }
 
     public boolean put(final String key, final String value) throws PwmDataStoreException {

+ 2 - 1
src/main/java/password/pwm/util/db/DatabaseTable.java

@@ -28,5 +28,6 @@ public enum DatabaseTable {
     USER_AUDIT,
     INTRUDER,
     TOKENS,
-    OTP
+    OTP,
+    PW_NOTIFY,
 }

+ 2 - 4
src/main/java/password/pwm/util/db/JDBCDriverLoader.java

@@ -22,7 +22,6 @@
 
 package password.pwm.util.db;
 
-import com.googlecode.concurrentlinkedhashmap.ConcurrentLinkedHashMap;
 import org.xeustechnologies.jcl.JarClassLoader;
 import org.xeustechnologies.jcl.JclObjectFactory;
 import password.pwm.PwmApplication;
@@ -45,6 +44,7 @@ import java.sql.Driver;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
 
 public class JDBCDriverLoader {
 
@@ -229,9 +229,7 @@ public class JDBCDriverLoader {
         private final PwmLogger LOGGER = PwmLogger.forClass(AppPathDriverLoader.class, true);
 
         // static ccache of classloader to prevent classloader memory leak
-        private static Map<String,ClassLoader> driverCache = new ConcurrentLinkedHashMap.Builder<String,ClassLoader>().
-                maximumWeightedCapacity(100).
-                build();
+        private static Map<String,ClassLoader> driverCache = new ConcurrentHashMap<>();
 
         @Override
         public Driver loadDriver(final PwmApplication pwmApplication, final DBConfiguration dbConfiguration)

+ 12 - 0
src/main/java/password/pwm/util/java/JavaHelper.java

@@ -326,4 +326,16 @@ public class JavaHelper {
     {
         return new CSVPrinter(new OutputStreamWriter(outputStream,PwmConstants.DEFAULT_CHARSET), PwmConstants.DEFAULT_CSV_FORMAT);
     }
+
+    public static ExecutorService makeSingleThreadExecutorService(
+            final PwmApplication pwmApplication,
+            final Class clazz
+    )
+    {
+        return Executors.newSingleThreadScheduledExecutor(
+                makePwmThreadFactory(
+                        JavaHelper.makeThreadName(pwmApplication,clazz) + "-",
+                        true
+                ));
+    }
 }

+ 4 - 4
src/main/java/password/pwm/util/operations/cr/DbCrOperator.java

@@ -36,7 +36,7 @@ import password.pwm.error.ErrorInformation;
 import password.pwm.error.PwmError;
 import password.pwm.error.PwmOperationalException;
 import password.pwm.error.PwmUnrecoverableException;
-import password.pwm.util.db.DatabaseAccessorImpl;
+import password.pwm.util.db.DatabaseAccessor;
 import password.pwm.util.db.DatabaseException;
 import password.pwm.util.db.DatabaseTable;
 import password.pwm.util.logging.PwmLogger;
@@ -69,7 +69,7 @@ public class DbCrOperator implements CrOperator {
         }
 
         try {
-            final DatabaseAccessorImpl databaseAccessor = pwmApplication.getDatabaseAccessor();
+            final DatabaseAccessor databaseAccessor = pwmApplication.getDatabaseAccessor();
             final String responseStringBlob = databaseAccessor.get(DatabaseTable.PWM_RESPONSES, userGUID);
             if (responseStringBlob != null && responseStringBlob.length() > 0) {
                 final ResponseSet userResponseSet = ChaiResponseSet.parseChaiResponseSetXML(responseStringBlob, theUser);
@@ -109,7 +109,7 @@ public class DbCrOperator implements CrOperator {
         }
 
         try {
-            final DatabaseAccessorImpl databaseAccessor = pwmApplication.getDatabaseAccessor();
+            final DatabaseAccessor databaseAccessor = pwmApplication.getDatabaseAccessor();
             databaseAccessor.remove(DatabaseTable.PWM_RESPONSES, userGUID);
             LOGGER.info("cleared responses for user " + theUser.getEntryDN() + " in remote database");
         } catch (DatabaseException e) {
@@ -140,7 +140,7 @@ public class DbCrOperator implements CrOperator {
                     responseInfoBean.getCsIdentifier()
             );
 
-            final DatabaseAccessorImpl databaseAccessor = pwmApplication.getDatabaseAccessor();
+            final DatabaseAccessor databaseAccessor = pwmApplication.getDatabaseAccessor();
             databaseAccessor.put(DatabaseTable.PWM_RESPONSES, userGUID, responseSet.stringValue());
             LOGGER.info("saved responses for " + theUser.getEntryDN() + " in remote database (key=" + userGUID + ")");
         } catch (ChaiException e) {

+ 4 - 4
src/main/java/password/pwm/util/operations/otp/DbOtpOperator.java

@@ -36,7 +36,7 @@ import password.pwm.error.PwmError;
 import password.pwm.error.PwmOperationalException;
 import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.http.PwmSession;
-import password.pwm.util.db.DatabaseAccessorImpl;
+import password.pwm.util.db.DatabaseAccessor;
 import password.pwm.util.db.DatabaseException;
 import password.pwm.util.db.DatabaseTable;
 import password.pwm.util.localdb.LocalDBException;
@@ -63,7 +63,7 @@ public class DbOtpOperator extends AbstractOtpOperator {
 
         OTPUserRecord otpConfig = null;
         try {
-            final DatabaseAccessorImpl databaseAccessor = pwmApplication.getDatabaseAccessor();
+            final DatabaseAccessor databaseAccessor = pwmApplication.getDatabaseAccessor();
             String value = databaseAccessor.get(DatabaseTable.OTP, userGUID);
             if (value != null && value.length() > 0) {
                 if (getPwmApplication().getConfig().readSettingAsBoolean(PwmSetting.OTP_SECRET_ENCRYPT)) {
@@ -109,7 +109,7 @@ public class DbOtpOperator extends AbstractOtpOperator {
                 LOGGER.debug("Encrypting OTP secret for storage");
                 value = encryptAttributeValue(value);
             }
-            final DatabaseAccessorImpl databaseAccessor = pwmApplication.getDatabaseAccessor();
+            final DatabaseAccessor databaseAccessor = pwmApplication.getDatabaseAccessor();
             databaseAccessor.put(DatabaseTable.OTP, userGUID, value);
             LOGGER.info("saved OTP secret for " + theUser + " in remote database (key=" + userGUID + ")");
         } catch (PwmOperationalException ex) {
@@ -135,7 +135,7 @@ public class DbOtpOperator extends AbstractOtpOperator {
         LOGGER.trace("attempting to clear OTP secret for " + theUser + " in remote database (key=" + userGUID + ")");
         
         try {
-            final DatabaseAccessorImpl databaseAccessor = pwmApplication.getDatabaseAccessor();
+            final DatabaseAccessor databaseAccessor = pwmApplication.getDatabaseAccessor();
             databaseAccessor.remove(DatabaseTable.OTP, userGUID);
             LOGGER.info("cleared OTP secret for " + theUser + " in remote database (key=" + userGUID + ")");
         } catch (DatabaseException ex) {

+ 19 - 14
src/main/java/password/pwm/ws/server/rest/RestCheckPasswordServer.java

@@ -25,6 +25,8 @@ package password.pwm.ws.server.rest;
 
 import com.novell.ldapchai.ChaiUser;
 import com.novell.ldapchai.exception.ChaiUnavailableException;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
 import password.pwm.PwmApplication;
 import password.pwm.bean.LoginInfoBean;
 import password.pwm.bean.UserIdentity;
@@ -65,11 +67,13 @@ import java.time.Instant;
 public class RestCheckPasswordServer extends AbstractRestServer {
     private static final PwmLogger LOGGER = PwmLogger.forClass(RestCheckPasswordServer.class);
 
+    @Getter
+    @AllArgsConstructor
     public static class JsonInput implements Serializable
     {
-        public String password1;
-        public String password2;
-        public String username;
+        public final String password1;
+        public final String password2;
+        public final String username;
     }
 
     public static class JsonData implements Serializable
@@ -112,11 +116,7 @@ public class RestCheckPasswordServer extends AbstractRestServer {
     )
             throws PwmUnrecoverableException
     {
-        final JsonInput jsonInput = new JsonInput();
-        jsonInput.password1 = password1;
-        jsonInput.password2 = password2;
-        jsonInput.username = username;
-
+        final JsonInput jsonInput = new JsonInput(password1, password2, username);
         return doOperation(jsonInput);
     }
 
@@ -200,21 +200,26 @@ public class RestCheckPasswordServer extends AbstractRestServer {
         }
     }
 
-    private static class PasswordCheckRequest {
-        final UserIdentity userDN;
+    public static class PasswordCheckRequest {
+        final UserIdentity userIdentity;
         final PasswordData password1;
         final PasswordData password2;
         final UserInfoBean userInfoBean;
 
-        private PasswordCheckRequest(final UserIdentity userDN, final PasswordData password1, final PasswordData password2, final UserInfoBean userInfoBean) {
-            this.userDN= userDN;
+        public PasswordCheckRequest(
+                final UserIdentity userDN,
+                final PasswordData password1,
+                final PasswordData password2,
+                final UserInfoBean userInfoBean
+        ) {
+            this.userIdentity = userDN;
             this.password1 = password1;
             this.password2 = password2;
             this.userInfoBean = userInfoBean;
         }
 
         public UserIdentity getUserIdentity() {
-            return userDN;
+            return userIdentity;
         }
 
         public PasswordData getPassword1() {
@@ -231,7 +236,7 @@ public class RestCheckPasswordServer extends AbstractRestServer {
     }
 
 
-    public JsonData doPasswordRuleCheck(
+    public static JsonData doPasswordRuleCheck(
             final PwmApplication pwmApplication,
             final PwmSession pwmSession,
             final PasswordCheckRequest checkRequest

+ 6 - 4
src/main/resources/password/pwm/AppProperty.properties

@@ -35,7 +35,7 @@ backup.path=backup
 backup.config.count=20
 backup.localdb.count=10
 cache.enable=true
-cache.memory.maxItems=100
+cache.memory.maxItems=1000
 cache.pwRuleCheckLifetimeMS=30000
 cache.uniqueFormValueLifetimeMS=30000
 client.ajax.activityMaxEpsRate=100
@@ -138,12 +138,16 @@ intruder.minimumDelayPenaltyMS=300
 intruder.maximumDelayPenaltyMS=3000
 intruder.delayPerCountMS=200
 intruder.delayMaxJitterMS=2000
+ldap.resolveCanonicalDN=true
+ldap.cache.canonical.enable=true
+ldap.cache.canonical.seconds=60
+ldap.cache.userGuid.enable=true
+ldap.cache.userGuid.seconds=3600
 ldap.chaiSettings=
 ldap.extensions.nmas.enable=true
 ldap.connection.timeoutMS=30000
 ldap.profile.retryDelayMS=30000
 ldap.promiscuousEnable=false
-ldap.search.timeoutMS=30000
 ldap.password.replicaCheck.initialDelayMS=1000
 ldap.password.replicaCheck.cycleDelayMS=7000
 ldap.password.change.self.enable=true
@@ -233,8 +237,6 @@ security.sharedHistory.hashName=SHA-512
 security.sharedHistory.caseInsensitive=true
 security.sharedHistory.saltLength=64
 security.certs.validateTimestamps=false
-security.ldap.resolveCanonicalDN=true
-security.ldap.canonicalCacheSeconds=600
 security.defaultEphemeralBlockAlg=AES128_GCM
 security.defaultEphemeralHashAlg=SHA512
 security.config.minSecurityKeyLength=32

+ 6 - 0
src/main/resources/password/pwm/config/PwmSetting.xml

@@ -816,6 +816,12 @@
             <value>{"to":"@User:Email@","from":"Unlock Account Notice \u003c@DefaultEmailFromAddress@\u003e","subject":"Account Unlock Notice","bodyPlain":"Your account has been unlocked.","bodyHtml":""}</value>
         </default>
     </setting>
+    <setting hidden="false" key="email.pwExpirationNotice" level="1">
+        <flag>MacroSupport</flag>
+        <default>
+            <value>{"to":"@User:Email@","from":"Password Expiration Notice \u003c@DefaultEmailFromAddress@\u003e","subject":"Password Expiration Notice","bodyPlain":"Your password is about to expire.  Your password will expire in @User:DaysUntilPwExpire@ days.","bodyHtml":""}</value>
+        </default>
+    </setting>
     <setting hidden="false" key="email.smtp.advancedSettings" level="2">
         <regex>^[a-zA-Z0-9.]+=.+$</regex>
         <default/>

+ 2 - 0
src/main/resources/password/pwm/i18n/PwmSetting.properties

@@ -311,6 +311,7 @@ Setting_Description_email.helpdesk.unlock=Define this template to send an email
 Setting_Description_email.intruderNotice=Define this template to send an email when a userDN intruder lockout occurs.
 Setting_Description_email.newUser=Define this template to send an email to newly created users.
 Setting_Description_email.newUser.token=Define this template to send an email during the new user verification process.  You can use %TOKEN% to insert the token value into the email.
+Setting_Description_email.pwExpirationNotice=Email sent to users to notify the user of an impending password notification. 
 Setting_Description_email.queueMaxAge=Specify the maximum age (in seconds) an email can wait in the send queue.  If an email is in the send queue longer than this time, @PwmAppName@ discards it.  Emails only persist in the send queue if there is an IO or network error to the SMTP server while sending the email.
 Setting_Description_email.sendpassword=Define this template to send an email during forgotten password reset process if you enabled the send password functionality.
 Setting_Description_email.sendUsername=Define this template to send an email for the forgotten user name process.
@@ -781,6 +782,7 @@ Setting_Label_email.helpdesk.unlock=Help Desk Unlock Account Email
 Setting_Label_email.intruderNotice=Intruder Notice Email
 Setting_Label_email.newUser=New User Email
 Setting_Label_email.newUser.token=New User Verification Email
+Setting_Label_email.pwExpirationNotice=Password Expiration Notification Email
 Setting_Label_email.queueMaxAge=Maximum Email Queue Age
 Setting_Label_email.sendpassword=Send Password Email
 Setting_Label_email.sendUsername=Send User Name Email

+ 81 - 0
src/test/java/password/pwm/AppPropertyTest.java

@@ -0,0 +1,81 @@
+/*
+ * 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;
+
+import junit.framework.TestCase;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.ResourceBundle;
+import java.util.Set;
+
+public class AppPropertyTest extends TestCase {
+    @Test
+    public void testValues()
+            throws Exception
+    {
+        for (final AppProperty appProperty : AppProperty.values()) {
+            final String value = appProperty.getDefaultValue();
+            Assert.assertNotNull("AppProperty " + appProperty + " does not have a value", value);
+        }
+    }
+
+    @Test
+    public void testKeys() {
+        for (final AppProperty appProperty : AppProperty.values()) {
+            final String key = appProperty.getKey();
+            Assert.assertNotNull("AppProperty " + appProperty + " does not have a key", key);
+        }
+    }
+
+    @Test
+    public void testKeyValues() {
+        final ResourceBundle resourceBundle = ResourceBundle.getBundle(AppProperty.class.getName());
+        final Set<String> allResourceBundleKeys = new HashSet<>();
+        final Set<String> allEnumKeys = new HashSet<>();
+
+        for (final Enumeration enumeration = resourceBundle.getKeys(); enumeration.hasMoreElements(); ) {
+            allResourceBundleKeys.add((String)enumeration.nextElement());
+        }
+
+        for (final AppProperty appProperty : AppProperty.values()) {
+            allEnumKeys.add(appProperty.getKey());
+        }
+
+        final Set<String> bundleKeysMissingEnum = new HashSet<>(allResourceBundleKeys);
+        bundleKeysMissingEnum.removeAll(allEnumKeys);
+        if (!bundleKeysMissingEnum.isEmpty()) {
+            Assert.fail("AppProperty resource bundle contains key " + bundleKeysMissingEnum.iterator().next()
+                    + " does not have a corresponding Enum value");
+        }
+
+        final Set<String> enumKeysMissingResource = new HashSet<>(allEnumKeys);
+        enumKeysMissingResource.removeAll(allResourceBundleKeys);
+        if (!enumKeysMissingResource.isEmpty()) {
+            Assert.fail("AppProperty enum contains key " + bundleKeysMissingEnum.iterator().next()
+                    + " does not have a corresponding resource bundle value");
+        }
+    }
+}