Browse Source

Merge branch 'master' into master

Jason Rivard 5 years ago
parent
commit
1f5fa770b0
100 changed files with 3174 additions and 1351 deletions
  1. 7 8
      build/checkstyle.xml
  2. 4 0
      build/license-header-jsp.txt
  3. 11 10
      client/package.json
  4. 2 2
      client/pom.xml
  5. 1 1
      client/src/modules/helpdesk/main.ts
  6. 1 1
      client/src/modules/peoplesearch/main.ts
  7. 2 2
      data-service/pom.xml
  8. 4 0
      data-service/src/main/webapp/WEB-INF/jsp/telemetry-viewer.jsp
  9. 4 0
      data-service/src/main/webapp/index.jsp
  10. 1 1
      docker/pom.xml
  11. 1 1
      onejar/pom.xml
  12. 4 0
      onejar/src/main/resources/ROOT-redirect-webapp/WEB-INF/index.jsp
  13. 8 16
      pom.xml
  14. 1 1
      pwm-cr/pom.xml
  15. 4 0
      rest-test-service/src/main/webapp/index.jsp
  16. 25 9
      server/pom.xml
  17. 3 0
      server/src/main/java/password/pwm/AppProperty.java
  18. 1 1
      server/src/main/java/password/pwm/PwmAboutProperty.java
  19. 10 6
      server/src/main/java/password/pwm/PwmApplication.java
  20. 2 2
      server/src/main/java/password/pwm/bean/LocalSessionStateBean.java
  21. 4 4
      server/src/main/java/password/pwm/config/PwmSetting.java
  22. 3 3
      server/src/main/java/password/pwm/config/PwmSettingXml.java
  23. 6 0
      server/src/main/java/password/pwm/config/profile/LdapProfile.java
  24. 3 3
      server/src/main/java/password/pwm/config/profile/PwmPasswordRule.java
  25. 0 23
      server/src/main/java/password/pwm/error/ErrorInformation.java
  26. 125 99
      server/src/main/java/password/pwm/http/filter/RequestInitializationFilter.java
  27. 10 15
      server/src/main/java/password/pwm/http/servlet/ControlledPwmServlet.java
  28. 3 1
      server/src/main/java/password/pwm/http/servlet/admin/AppDashboardData.java
  29. 1 1
      server/src/main/java/password/pwm/http/servlet/configmanager/ConfigManagerLocalDBServlet.java
  30. 4 4
      server/src/main/java/password/pwm/http/servlet/configmanager/ConfigManagerWordlistServlet.java
  31. 0 56
      server/src/main/java/password/pwm/http/servlet/peoplesearch/PeopleSearchResourcesServlet.java
  32. 3 1
      server/src/main/java/password/pwm/http/servlet/resource/ResourceFileRequest.java
  33. 2 2
      server/src/main/java/password/pwm/http/servlet/resource/ResourceFileServlet.java
  34. 3 3
      server/src/main/java/password/pwm/http/servlet/resource/ResourceServletService.java
  35. 237 77
      server/src/main/java/password/pwm/http/tag/PasswordRequirementsTag.java
  36. 8 61
      server/src/main/java/password/pwm/ldap/LdapOperationsHelper.java
  37. 3 4
      server/src/main/java/password/pwm/ldap/search/SearchConfiguration.java
  38. 1 1
      server/src/main/java/password/pwm/svc/PwmServiceEnum.java
  39. 60 56
      server/src/main/java/password/pwm/svc/intruder/IntruderManager.java
  40. 1 1
      server/src/main/java/password/pwm/svc/report/ReportCsvUtility.java
  41. 146 130
      server/src/main/java/password/pwm/svc/report/ReportService.java
  42. 14 6
      server/src/main/java/password/pwm/svc/report/ReportSettings.java
  43. 3 2
      server/src/main/java/password/pwm/svc/report/ReportStatusInfo.java
  44. 162 58
      server/src/main/java/password/pwm/svc/wordlist/AbstractWordlist.java
  45. 155 0
      server/src/main/java/password/pwm/svc/wordlist/AbstractWordlistBucket.java
  46. 134 0
      server/src/main/java/password/pwm/svc/wordlist/LocalDBWordlistBucket.java
  47. 85 0
      server/src/main/java/password/pwm/svc/wordlist/MemoryWordlistBucket.java
  48. 164 0
      server/src/main/java/password/pwm/svc/wordlist/WordType.java
  49. 2 2
      server/src/main/java/password/pwm/svc/wordlist/Wordlist.java
  50. 10 243
      server/src/main/java/password/pwm/svc/wordlist/WordlistBucket.java
  51. 67 40
      server/src/main/java/password/pwm/svc/wordlist/WordlistConfiguration.java
  52. 154 58
      server/src/main/java/password/pwm/svc/wordlist/WordlistImporter.java
  53. 30 31
      server/src/main/java/password/pwm/svc/wordlist/WordlistInspector.java
  54. 1 2
      server/src/main/java/password/pwm/svc/wordlist/WordlistService.java
  55. 52 42
      server/src/main/java/password/pwm/svc/wordlist/WordlistSource.java
  56. 1 1
      server/src/main/java/password/pwm/svc/wordlist/WordlistSourceInfo.java
  57. 63 0
      server/src/main/java/password/pwm/svc/wordlist/WordlistStatistics.java
  58. 8 1
      server/src/main/java/password/pwm/svc/wordlist/WordlistStatus.java
  59. 1 1
      server/src/main/java/password/pwm/svc/wordlist/WordlistType.java
  60. 78 0
      server/src/main/java/password/pwm/svc/wordlist/WordlistUtil.java
  61. 3 10
      server/src/main/java/password/pwm/svc/wordlist/WordlistZipReader.java
  62. 1 85
      server/src/main/java/password/pwm/util/EventRateMeter.java
  63. 2 0
      server/src/main/java/password/pwm/util/cli/MainClass.java
  64. 1 1
      server/src/main/java/password/pwm/util/cli/commands/ExportLocalDBCommand.java
  65. 71 0
      server/src/main/java/password/pwm/util/cli/commands/ExportWordlistCommand.java
  66. 1 2
      server/src/main/java/password/pwm/util/cli/commands/ResponseStatsCommand.java
  67. 68 0
      server/src/main/java/password/pwm/util/java/AverageTracker.java
  68. 7 13
      server/src/main/java/password/pwm/util/java/JavaHelper.java
  69. 46 0
      server/src/main/java/password/pwm/util/java/LazySupplier.java
  70. 109 0
      server/src/main/java/password/pwm/util/java/MovingAverage.java
  71. 95 58
      server/src/main/java/password/pwm/util/localdb/LocalDBUtility.java
  72. 4 3
      server/src/main/java/password/pwm/util/localdb/WorkQueueProcessor.java
  73. 1 2
      server/src/main/java/password/pwm/util/localdb/XodusLocalDB.java
  74. 1 1
      server/src/main/java/password/pwm/util/operations/CrService.java
  75. 7 4
      server/src/main/java/password/pwm/util/password/PasswordRuleChecks.java
  76. 12 5
      server/src/main/java/password/pwm/util/secure/PwmHashAlgorithm.java
  77. 39 0
      server/src/main/java/password/pwm/util/secure/SecureService.java
  78. 1 1
      server/src/main/java/password/pwm/util/secure/X509Utils.java
  79. 1 1
      server/src/main/java/password/pwm/ws/server/RestServlet.java
  80. 4 1
      server/src/main/resources/password/pwm/AppProperty.properties
  81. 140 0
      server/src/test/java/password/pwm/http/filter/RequestInitializationFilterTest.java
  82. 47 0
      server/src/test/java/password/pwm/svc/wordlist/WordTypeTest.java
  83. 163 0
      server/src/test/java/password/pwm/svc/wordlist/WordlistServiceTest.java
  84. 49 0
      server/src/test/java/password/pwm/svc/wordlist/WordlistUtilTest.java
  85. 326 71
      server/src/test/java/password/pwm/util/password/PasswordRuleChecksTest.java
  86. BIN
      server/src/test/resources/password/pwm/svc/wordlist/test-wordlist.zip
  87. 20 0
      webapp/src/main/webapp/WEB-INF/jsp/README.TXT
  88. 4 0
      webapp/src/main/webapp/WEB-INF/jsp/accountinformation.jsp
  89. 4 0
      webapp/src/main/webapp/WEB-INF/jsp/activateuser-agreement.jsp
  90. 4 0
      webapp/src/main/webapp/WEB-INF/jsp/activateuser-entercode.jsp
  91. 4 0
      webapp/src/main/webapp/WEB-INF/jsp/activateuser-search.jsp
  92. 4 0
      webapp/src/main/webapp/WEB-INF/jsp/activateuser-tokenchoice.jsp
  93. 4 0
      webapp/src/main/webapp/WEB-INF/jsp/activateuser-tokensuccess.jsp
  94. 4 0
      webapp/src/main/webapp/WEB-INF/jsp/admin-activity.jsp
  95. 4 0
      webapp/src/main/webapp/WEB-INF/jsp/admin-analysis.jsp
  96. 4 0
      webapp/src/main/webapp/WEB-INF/jsp/admin-dashboard.jsp
  97. 4 0
      webapp/src/main/webapp/WEB-INF/jsp/admin-logview-window.jsp
  98. 4 0
      webapp/src/main/webapp/WEB-INF/jsp/admin-logview.jsp
  99. 4 0
      webapp/src/main/webapp/WEB-INF/jsp/admin-tokenlookup.jsp
  100. 4 0
      webapp/src/main/webapp/WEB-INF/jsp/admin-urlreference.jsp

+ 7 - 8
build/checkstyle.xml

@@ -20,8 +20,8 @@
   -->
 
 <!DOCTYPE module PUBLIC
-        "-//Puppy Crawl//DTD Check Configuration 1.2//EN"
-        "http://www.puppycrawl.com/dtds/configuration_1_2.dtd">
+        "-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
+        "https://checkstyle.org/dtds/configuration_1_3.dtd">
 
 <!--
   PWM Checkstyle definition
@@ -52,7 +52,11 @@
         <property name="headerFile" value="${checkstyle.header.file}"/>
     </module>
     -->
-
+    <module name="LineLength">
+        <property name="max" value="180" />
+        <property name="ignorePattern" value="@version|@see|@todo|TODO"/>
+        <property name="fileExtensions" value="java"/>
+    </module>
     <module name="FileTabCharacter">
         <property name="eachLine" value="true"/>
     </module>
@@ -78,11 +82,6 @@
             <property name="allowNonPrintableEscapes" value="true"/>
         </module>
 
-        <module name="LineLength">
-            <property name="max" value="180" />
-            <property name="ignorePattern" value="@version|@see|@todo|TODO"/>
-        </module>
-
         <module name="EmptyBlock">
             <property name="option" value="TEXT"/>
             <property name="tokens" value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>

+ 4 - 0
build/license-header-jsp.txt

@@ -17,3 +17,7 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>

+ 11 - 10
client/package.json

@@ -17,14 +17,15 @@
     "author": "",
     "license": "ISC",
     "dependencies": {
-        "@microfocus/ias-icons": "1.0.1",
-        "@microfocus/ng-ias": "1.0.0-alpha.2",
-        "@microfocus/ux-ias": "1.0.0-rc",
+        "@microfocus/ias-icons": "1.0.4",
+        "@microfocus/ng-ias": "1.0.1",
+        "@microfocus/ux-ias": "1.1.2",
         "@uirouter/angularjs": "1.0.15",
-        "angular": "1.6.9",
-        "angular-aria": "1.6.9",
-        "angular-sanitize": "1.6.9",
-        "angular-translate": "2.17.0",
+        "angular": "1.7.8",
+        "angular-aria": "1.7.8",
+        "angular-sanitize": "1.7.8",
+        "angular-translate": "2.18.1",
+        "core-js": "3.2.1",
         "textangular": "1.5.16"
     },
     "devDependencies": {
@@ -37,7 +38,7 @@
         "angular-mocks": "1.6.9",
         "autoprefixer": "8.1.0",
         "copy-webpack-plugin": "4.5.1",
-        "css-loader": "0.28.10",
+        "css-loader": "^3.2.0",
         "file-loader": "1.1.11",
         "html-loader": "0.5.5",
         "html-webpack-plugin": "3.0.6",
@@ -48,7 +49,7 @@
         "jshint": "^2.10.2",
         "jshint-loader": "0.8.4",
         "json-loader": "0.5.7",
-        "karma": "3.1.1",
+        "karma": "^4.3.0",
         "karma-chrome-launcher": "2.2.0",
         "karma-jasmine": "1.1.2",
         "karma-jasmine-html-reporter": "1.3.1",
@@ -75,7 +76,7 @@
         "uglifyjs-webpack-plugin": "1.2.3",
         "url-loader": "1.0.1",
         "webpack": "4.1.1",
-        "webpack-cli": "2.0.12",
+        "webpack-cli": "^3.3.8",
         "webpack-dev-server": "3.1.14",
         "webpack-merge": "4.1.2",
         "write-file-webpack-plugin": "4.2.0"

+ 2 - 2
client/pom.xml

@@ -79,9 +79,9 @@
             <plugin>
                 <groupId>com.github.eirslett</groupId>
                 <artifactId>frontend-maven-plugin</artifactId>
-                <version>1.7.6</version>
+                <version>1.8.0</version>
                 <configuration>
-                    <nodeVersion>v10.16.0</nodeVersion>
+                    <nodeVersion>v10.16.3</nodeVersion>
                     <npmVersion>6.9.0</npmVersion>
                     <installDirectory>.node</installDirectory>
                 </configuration>

+ 1 - 1
client/src/modules/helpdesk/main.ts

@@ -24,7 +24,7 @@ import 'angular-sanitize';
 import '@microfocus/ng-ias/dist/ng-ias';
 
 // Add a polyfill for Set() for IE11, since it's used in peoplesearch-base.component.ts
-import 'core-js/es6/set';
+import 'core-js/es/set';
 
 import { bootstrap, module } from 'angular';
 import helpDeskModule from './helpdesk.module';

+ 1 - 1
client/src/modules/peoplesearch/main.ts

@@ -23,7 +23,7 @@ import 'angular-translate';
 import '@microfocus/ng-ias/dist/ng-ias';
 
 // Add a polyfill for Set() for IE11, since it's used in peoplesearch-base.component.ts
-import 'core-js/es6/set';
+import 'core-js/es/set';
 
 import { bootstrap, module } from 'angular';
 import ConfigService from '../../services/peoplesearch-config.service';

+ 2 - 2
data-service/pom.xml

@@ -137,12 +137,12 @@
         <dependency>
             <groupId>com.sun.mail</groupId>
             <artifactId>jakarta.mail</artifactId>
-            <version>1.6.3</version>
+            <version>1.6.4</version>
         </dependency>
         <dependency>
             <groupId>org.apache.httpcomponents</groupId>
             <artifactId>httpclient</artifactId>
-            <version>4.5.9</version>
+            <version>4.5.10</version>
         </dependency>
         <dependency>
             <groupId>log4j</groupId>

+ 4 - 0
data-service/src/main/webapp/WEB-INF/jsp/telemetry-viewer.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 <%@ page import="password.pwm.receiver.SummaryBean" %>
 <%@ page import="password.pwm.receiver.TelemetryViewerServlet" %>
 <%@ page import="java.time.Instant" %>

+ 4 - 0
data-service/src/main/webapp/index.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 
 
 <!DOCTYPE html>

+ 1 - 1
docker/pom.xml

@@ -34,7 +34,7 @@
             <plugin>
                 <groupId>com.google.cloud.tools</groupId>
                 <artifactId>jib-maven-plugin</artifactId>
-                <version>1.4.0</version>
+                <version>1.5.1</version>
                 <executions>
                     <execution>
                         <id>make-docker-image</id>

+ 1 - 1
onejar/pom.xml

@@ -17,7 +17,7 @@
 
     <properties>
         <project.root.basedir>${project.basedir}/..</project.root.basedir>
-        <tomcat.version>9.0.22</tomcat.version>
+        <tomcat.version>9.0.24</tomcat.version>
     </properties>
 
     <build>

+ 4 - 0
onejar/src/main/resources/ROOT-redirect-webapp/WEB-INF/index.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 
 
 <%@ page session="false" contentType="text/html" %>

+ 8 - 16
pom.xml

@@ -37,7 +37,6 @@
         <project.root.basedir>${project.basedir}</project.root.basedir>
 
         <skipTests>false</skipTests>
-        <skipSpotbugs>false</skipSpotbugs>
     </properties>
 
     <modules>
@@ -58,12 +57,6 @@
                 <checkstyle.skip>true</checkstyle.skip>
             </properties>
         </profile>
-        <profile>
-            <id>skip-spotbugs</id>
-            <properties>
-                <skipSpotbugs>true</skipSpotbugs>
-            </properties>
-        </profile>
         <profile>
             <id>skip-javadoc</id>
             <properties>
@@ -77,7 +70,7 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-javadoc-plugin</artifactId>
-                <version>3.1.0</version>
+                <version>3.1.1</version>
                 <executions>
                     <execution>
                         <goals>
@@ -172,7 +165,7 @@
                     <dependency>
                         <groupId>com.puppycrawl.tools</groupId>
                         <artifactId>checkstyle</artifactId>
-                        <version>8.22</version>
+                        <version>8.24</version>
                     </dependency>
                 </dependencies>
                 <executions>
@@ -251,7 +244,7 @@
             <plugin>
                 <groupId>com.github.spotbugs</groupId>
                 <artifactId>spotbugs-maven-plugin</artifactId>
-                <version>3.1.12</version>
+                <version>3.1.12.2</version>
                 <dependencies>
                     <dependency>
                         <groupId>com.github.spotbugs</groupId>
@@ -263,12 +256,11 @@
                     <fork>false</fork>
                     <excludeFilterFile>${project.root.basedir}/build/spotbugs-exclude.xml</excludeFilterFile>
                     <includeTests>false</includeTests>
-                    <skip>${skipSpotbugs}</skip>
                     <effort>max</effort>
                 </configuration>
                 <executions>
                     <execution>
-                        <phase>test</phase>
+                        <phase>verify</phase>
                         <goals>
                             <goal>check</goal>
                         </goals>
@@ -278,7 +270,7 @@
             <plugin> <!-- checks owsp vulnerability database -->
                 <groupId>org.owasp</groupId>
                 <artifactId>dependency-check-maven</artifactId>
-                <version>5.1.1</version>
+                <version>5.2.1</version>
                 <executions>
                     <execution>
                         <goals>
@@ -295,7 +287,7 @@
         <dependency>
             <groupId>org.projectlombok</groupId>
             <artifactId>lombok</artifactId>
-            <version>1.18.8</version>
+            <version>1.18.10</version>
             <scope>provided</scope>
         </dependency>
         <dependency>
@@ -321,13 +313,13 @@
         <dependency>
             <groupId>org.assertj</groupId>
             <artifactId>assertj-core</artifactId>
-            <version>3.12.2</version>
+            <version>3.13.2</version>
             <scope>test</scope>
         </dependency>
         <dependency>
             <groupId>com.github.tomakehurst</groupId>
             <artifactId>wiremock</artifactId>
-            <version>2.24.0</version>
+            <version>2.24.1</version>
             <scope>test</scope>
         </dependency>
         <dependency>

+ 1 - 1
pwm-cr/pom.xml

@@ -45,7 +45,7 @@
         <dependency>
             <groupId>org.bouncycastle</groupId>
             <artifactId>bcpkix-jdk15on</artifactId>
-            <version>1.62</version>
+            <version>1.63</version>
         </dependency>
 
         <!-- Test dependencies -->

+ 4 - 0
rest-test-service/src/main/webapp/index.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 
 
 <!DOCTYPE html>

+ 25 - 9
server/pom.xml

@@ -202,7 +202,7 @@
         <dependency>
             <groupId>org.apache.commons</groupId>
             <artifactId>commons-text</artifactId>
-            <version>1.7</version>
+            <version>1.8</version>
         </dependency>
         <dependency>
             <groupId>org.apache.commons</groupId>
@@ -227,12 +227,12 @@
         <dependency>
             <groupId>com.sun.mail</groupId>
             <artifactId>jakarta.mail</artifactId>
-            <version>1.6.3</version>
+            <version>1.6.4</version>
         </dependency>
         <dependency>
             <groupId>org.apache.httpcomponents</groupId>
             <artifactId>httpclient</artifactId>
-            <version>4.5.9</version>
+            <version>4.5.10</version>
         </dependency>
         <dependency>
             <groupId>org.graylog2</groupId>
@@ -257,12 +257,12 @@
         <dependency>
             <groupId>org.bouncycastle</groupId>
             <artifactId>bcprov-jdk15on</artifactId>
-            <version>1.62</version>
+            <version>1.63</version>
         </dependency>
         <dependency>
             <groupId>org.bouncycastle</groupId>
             <artifactId>bcpkix-jdk15on</artifactId>
-            <version>1.62</version>
+            <version>1.63</version>
         </dependency>
         <dependency>
             <groupId>jaxen</groupId>
@@ -297,8 +297,17 @@
         <dependency>
             <groupId>org.jetbrains.xodus</groupId>
             <artifactId>xodus-environment</artifactId>
-            <version>1.3.91</version>
+            <!-- do not upgrade to 1.3.x until bug is resolved: https://youtrack.jetbrains.com/issue/XD-786 -->
+            <version>1.2.3</version>
         </dependency>
+
+        <dependency>
+            <!-- added because of older xodus depedendency -->
+            <groupId>org.jetbrains.kotlin</groupId>
+            <artifactId>kotlin-stdlib</artifactId>
+            <version>1.3.50</version>
+        </dependency>
+
         <dependency>
             <groupId>org.slf4j</groupId>
             <artifactId>slf4j-nop</artifactId>
@@ -307,17 +316,17 @@
         <dependency>
             <groupId>org.webjars</groupId>
             <artifactId>webjars-locator-core</artifactId>
-            <version>0.37</version>
+            <version>0.40</version>
         </dependency>
         <dependency>
             <groupId>com.github.ben-manes.caffeine</groupId>
             <artifactId>caffeine</artifactId>
-            <version>2.7.0</version>
+            <version>2.8.0</version>
         </dependency>
         <dependency>
             <groupId>com.nulab-inc</groupId>
             <artifactId>zxcvbn</artifactId>
-            <version>1.2.6</version>
+            <version>1.2.7</version>
         </dependency>
         <dependency>
             <groupId>com.github.ziplet</groupId>
@@ -330,6 +339,13 @@
                 </exclusion>
             </exclusions>
         </dependency>
+
+        <dependency>
+            <!-- added newer dependency of xodus-environment -->
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-compress</artifactId>
+            <version>1.19</version>
+        </dependency>
     </dependencies>
 
     <repositories>

+ 3 - 0
server/src/main/java/password/pwm/AppProperty.java

@@ -365,7 +365,10 @@ public enum AppProperty
     WORDLIST_IMPORT_DURATION_GOAL_MS                ( "wordlist.import.durationGoalMS" ),
     WORDLIST_IMPORT_MIN_TRANSACTIONS                ( "wordlist.import.minTransactions" ),
     WORDLIST_IMPORT_MAX_TRANSACTIONS                ( "wordlist.import.maxTransactions" ),
+    WORDLIST_IMPORT_MAX_CHARS_TRANSACTIONS          ( "wordlist.import.maxCharsTransactions" ),
+    WORDLIST_IMPORT_LINE_COMMENTS                   ( "wordlist.import.lineComments" ),
     WORDLIST_INSPECTOR_FREQUENCY_SECONDS            ( "wordlist.inspector.frequencySeconds" ),
+    WORDLIST_TEST_MODE                              ( "wordlist.testMode" ),
     WS_REST_CLIENT_PWRULE_HALTONERROR               ( "ws.restClient.pwRule.haltOnError" ),
     WS_REST_SERVER_SIGNING_FORM_TIMEOUT_SECONDS     ( "ws.restServer.signing.form.timeoutSeconds" ),
     WS_REST_SERVER_STATISTICS_DEFAULT_HISTORY       ( "ws.restServer.statistics.defaultHistoryDays" ),

+ 1 - 1
server/src/main/java/password/pwm/PwmAboutProperty.java

@@ -52,7 +52,7 @@ public enum PwmAboutProperty
     app_mode_manageHttps( null, pwmApplication -> Boolean.toString( pwmApplication.getPwmEnvironment().getFlags().contains( PwmEnvironment.ApplicationFlag.ManageHttps ) ) ),
     app_applicationPath( null, pwmApplication -> pwmApplication.getPwmEnvironment().getApplicationPath().getAbsolutePath() ),
     app_environmentFlags( null, pwmApplication -> StringUtil.collectionToString( pwmApplication.getPwmEnvironment().getFlags() ) ),
-    app_wordlistSize( null, pwmApplication -> Long.toString( pwmApplication.getWordlistManager().size() ) ),
+    app_wordlistSize( null, pwmApplication -> Long.toString( pwmApplication.getWordlistService().size() ) ),
     app_seedlistSize( null, pwmApplication -> Long.toString( pwmApplication.getSeedlistManager().size() ) ),
     app_sharedHistorySize( null, pwmApplication -> Long.toString( pwmApplication.getSharedHistoryManager().size() ) ),
     app_sharedHistoryOldestTime( null, pwmApplication -> format( pwmApplication.getSharedHistoryManager().getOldestEntryTime() ) ),

+ 10 - 6
server/src/main/java/password/pwm/PwmApplication.java

@@ -484,11 +484,15 @@ public class PwmApplication
             }
 
             final ByteArrayOutputStream outputContents = new ByteArrayOutputStream();
-            ExportHttpsTomcatConfigCommand.TomcatConfigWriter.writeOutputFile(
-                    pwmApplication.getConfig(),
-                    new FileInputStream( tomcatSourceFile ),
-                    outputContents
-            );
+            try ( FileInputStream fileInputStream = new FileInputStream( tomcatOutputFile ) )
+            {
+                ExportHttpsTomcatConfigCommand.TomcatConfigWriter.writeOutputFile(
+                        pwmApplication.getConfig(),
+                        fileInputStream,
+                        outputContents
+                );
+            }
+
             if ( tomcatOutputFile.exists() )
             {
                 LOGGER.trace( () -> "deleting existing tomcat configuration file " + tomcatOutputFile.getAbsolutePath() );
@@ -566,7 +570,7 @@ public class PwmApplication
         return Collections.unmodifiableList( pwmServices );
     }
 
-    public WordlistService getWordlistManager( )
+    public WordlistService getWordlistService( )
     {
         return ( WordlistService ) pwmServiceManager.getService( WordlistService.class );
     }

+ 2 - 2
server/src/main/java/password/pwm/bean/LocalSessionStateBean.java

@@ -22,7 +22,7 @@ package password.pwm.bean;
 
 import lombok.Data;
 import password.pwm.ldap.UserInfoBean;
-import password.pwm.util.EventRateMeter;
+import password.pwm.util.java.MovingAverage;
 import password.pwm.util.java.TimeDuration;
 
 import java.io.Serializable;
@@ -68,7 +68,7 @@ public class LocalSessionStateBean implements Serializable
 
     private final AtomicInteger intruderAttempts = new AtomicInteger( 0 );
     private final AtomicInteger requestCount = new AtomicInteger( 0 );
-    private final EventRateMeter.MovingAverage avgRequestDuration = new EventRateMeter.MovingAverage( TimeDuration.DAY );
+    private final MovingAverage avgRequestDuration = new MovingAverage( TimeDuration.DAY );
     private boolean oauthInProgress;
 
     private boolean sessionIdRecycleNeeded;

+ 4 - 4
server/src/main/java/password/pwm/config/PwmSetting.java

@@ -514,6 +514,10 @@ public enum PwmSetting
             "password.policy.maximumOldPasswordChars", PwmSettingSyntax.NUMERIC, PwmSettingCategory.PASSWORD_POLICY ),
     PASSWORD_POLICY_MINIMUM_LIFETIME(
             "password.policy.minimumLifetime", PwmSettingSyntax.DURATION, PwmSettingCategory.PASSWORD_POLICY ),
+    PASSWORD_POLICY_MAXIMUM_CONSECUTIVE(
+            "password.policy.maximumConsecutive", PwmSettingSyntax.NUMERIC, PwmSettingCategory.PASSWORD_POLICY ),
+    PASSWORD_POLICY_MINIMUM_STRENGTH(
+            "password.policy.minimumStrength", PwmSettingSyntax.NUMERIC, PwmSettingCategory.PASSWORD_POLICY ),
     PASSWORD_POLICY_ENABLE_WORDLIST(
             "password.policy.checkWordlist", PwmSettingSyntax.BOOLEAN, PwmSettingCategory.PASSWORD_POLICY ),
     PASSWORD_POLICY_AD_COMPLEXITY_LEVEL(
@@ -528,10 +532,6 @@ public enum PwmSetting
             "password.policy.disallowedValues", PwmSettingSyntax.STRING_ARRAY, PwmSettingCategory.PASSWORD_POLICY ),
     PASSWORD_POLICY_DISALLOWED_ATTRIBUTES(
             "password.policy.disallowedAttributes", PwmSettingSyntax.STRING_ARRAY, PwmSettingCategory.PASSWORD_POLICY ),
-    PASSWORD_POLICY_MINIMUM_STRENGTH(
-            "password.policy.minimumStrength", PwmSettingSyntax.NUMERIC, PwmSettingCategory.PASSWORD_POLICY ),
-    PASSWORD_POLICY_MAXIMUM_CONSECUTIVE(
-            "password.policy.maximumConsecutive", PwmSettingSyntax.NUMERIC, PwmSettingCategory.PASSWORD_POLICY ),
     PASSWORD_POLICY_CHANGE_MESSAGE(
             "password.policy.changeMessage", PwmSettingSyntax.LOCALIZED_TEXT_AREA, PwmSettingCategory.PASSWORD_POLICY ),
     PASSWORD_POLICY_RULE_TEXT(

+ 3 - 3
server/src/main/java/password/pwm/config/PwmSettingXml.java

@@ -32,6 +32,7 @@ import javax.xml.transform.stream.StreamSource;
 import javax.xml.validation.Schema;
 import javax.xml.validation.SchemaFactory;
 import javax.xml.validation.Validator;
+import java.io.IOException;
 import java.io.InputStream;
 import java.lang.ref.WeakReference;
 import java.time.Instant;
@@ -63,8 +64,7 @@ public class PwmSettingXml
         final XmlDocument docRefCopy = xmlDocCache.get();
         if ( docRefCopy == null )
         {
-            final InputStream inputStream = PwmSetting.class.getClassLoader().getResourceAsStream( SETTING_XML_FILENAME );
-            try
+            try ( InputStream inputStream = PwmSetting.class.getClassLoader().getResourceAsStream( SETTING_XML_FILENAME ) )
             {
                 final Instant startTime = Instant.now();
                 final XmlDocument newDoc = XmlFactory.getFactory().parseXml( inputStream );
@@ -75,7 +75,7 @@ public class PwmSettingXml
 
                 return newDoc;
             }
-            catch ( PwmUnrecoverableException e )
+            catch ( IOException | PwmUnrecoverableException e )
             {
                 throw new IllegalStateException( "error parsing " + SETTING_XML_FILENAME + ": " + e.getMessage() );
             }

+ 6 - 0
server/src/main/java/password/pwm/config/profile/LdapProfile.java

@@ -214,4 +214,10 @@ public class LdapProfile extends AbstractProfile implements Profile
             return new LdapProfile( identifier, makeValueMap( storedConfiguration, identifier, PROFILE_TYPE.getCategory() ) );
         }
     }
+
+    @Override
+    public String toString()
+    {
+        return "LDAPProfile:" + this.getIdentifier();
+    }
 }

+ 3 - 3
server/src/main/java/password/pwm/config/profile/PwmPasswordRule.java

@@ -283,21 +283,21 @@ public enum PwmPasswordRule
             false ),
 
     AllowNonAlpha(
-            ChaiPasswordRule.AllowNonAlpha,
+            null,
             PwmSetting.PASSWORD_POLICY_ALLOW_NON_ALPHA,
             ChaiPasswordRule.AllowNonAlpha.getRuleType(),
             ChaiPasswordRule.AllowNonAlpha.getDefaultValue(),
             false ),
 
     MinimumNonAlpha(
-            ChaiPasswordRule.MinimumNonAlpha,
+            null,
             PwmSetting.PASSWORD_POLICY_MINIMUM_NON_ALPHA,
             ChaiPasswordRule.RuleType.MIN,
             "0",
             false ),
 
     MaximumNonAlpha(
-            ChaiPasswordRule.MaximumNonAlpha,
+            null,
             PwmSetting.PASSWORD_POLICY_MAXIMUM_NON_ALPHA,
             ChaiPasswordRule.RuleType.MAX,
             "0",

+ 0 - 23
server/src/main/java/password/pwm/error/ErrorInformation.java

@@ -27,12 +27,7 @@ import password.pwm.http.PwmSession;
 import java.io.Serializable;
 import java.time.Instant;
 import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
 import java.util.Locale;
-import java.util.Objects;
-import java.util.Set;
-import java.util.stream.Collectors;
 
 /**
  * An ErrorInformation is a package of error data generated within PWM.  Error information includes an error code
@@ -176,23 +171,5 @@ public class ErrorInformation implements Serializable
             return this;
         }
         return new ErrorInformation( pwmError, this.getDetailedErrorMsg() );
-
-    }
-
-    public static boolean listsContainSameErrors( final List<ErrorInformation> errorInformation1, final List<ErrorInformation> errorInformation2 )
-    {
-        Objects.requireNonNull( errorInformation1 );
-        Objects.requireNonNull( errorInformation2 );
-        return extractErrorSet( errorInformation1 ).equals( extractErrorSet( errorInformation2 ) );
-    }
-
-    private static Set<PwmError> extractErrorSet( final List<ErrorInformation> errors )
-    {
-        if ( errors != null )
-        {
-            return errors.stream().map( ErrorInformation::getError ).collect( Collectors.toSet() );
-        }
-
-        return Collections.emptySet();
     }
 }

+ 125 - 99
server/src/main/java/password/pwm/http/filter/RequestInitializationFilter.java

@@ -67,6 +67,8 @@ import java.math.BigInteger;
 import java.net.InetAddress;
 import java.net.UnknownHostException;
 import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Locale;
@@ -297,17 +299,15 @@ public class RequestInitializationFilter implements Filter
     }
 
     private void checkIfSessionRecycleNeeded( final PwmRequest pwmRequest )
-            throws IOException, ServletException
     {
         if ( pwmRequest.getPwmSession().getSessionStateBean().isSessionIdRecycleNeeded() )
         {
             pwmRequest.getHttpServletRequest().changeSessionId();
             pwmRequest.getPwmSession().getSessionStateBean().setSessionIdRecycleNeeded( false );
         }
-
     }
 
-    public static void addPwmResponseHeaders(
+    private static void addPwmResponseHeaders(
             final PwmRequest pwmRequest
     )
             throws PwmUnrecoverableException
@@ -436,7 +436,7 @@ public class RequestInitializationFilter implements Filter
             return "";
         }
 
-        final String userIPAddress = readUserIPAddress( request, config );
+        final String userIPAddress = readUserNetworkAddress( request, config );
         try
         {
             return InetAddress.getByName( userIPAddress ).getCanonicalHostName();
@@ -455,51 +455,47 @@ public class RequestInitializationFilter implements Filter
      * @param request the http request object
      * @param config the application configuration
      * @return String containing the textual representation of the source IP address, or null if the request is invalid.
-     * @throws PwmUnrecoverableException if unable to read the network address
      */
-    public static String readUserIPAddress(
+    public static String readUserNetworkAddress(
             final HttpServletRequest request,
             final Configuration config
     )
-            throws PwmUnrecoverableException
     {
-        final boolean useXForwardedFor = config != null && config.readSettingAsBoolean( PwmSetting.USE_X_FORWARDED_FOR_HEADER );
-
-        String userIP = "";
+        final List<String> candidateAddresses = new ArrayList<>();
 
+        final boolean useXForwardedFor = config != null && config.readSettingAsBoolean( PwmSetting.USE_X_FORWARDED_FOR_HEADER );
         if ( useXForwardedFor )
         {
-            userIP = request.getHeader( HttpHeader.XForwardedFor.getHttpName() );
-            if ( !StringUtil.isEmpty( userIP ) )
+            final String xForwardedForValue = request.getHeader( HttpHeader.XForwardedFor.getHttpName() );
+            if ( !StringUtil.isEmpty( xForwardedForValue ) )
             {
-                final int commaIndex = userIP.indexOf( ',' );
-                if ( commaIndex > -1 )
-                {
-                    userIP = userIP.substring( 0, commaIndex );
-                }
+                Collections.addAll( candidateAddresses, xForwardedForValue.split( "," ) );
             }
+        }
 
-            if ( !StringUtil.isEmpty( userIP ) )
-            {
-                if ( !InetAddressValidator.getInstance().isValid( userIP ) )
-                {
-                    LOGGER.warn( "discarding bogus network address '" + userIP + "' in "
-                            + HttpHeader.XForwardedFor.getHttpName() + " header" );
-                    userIP = null;
-                }
-            }
+        final String sourceIP = request.getRemoteAddr();
+        if ( !StringUtil.isEmpty( sourceIP ) )
+        {
+            candidateAddresses.add( sourceIP );
         }
 
-        if ( StringUtil.isEmpty( userIP ) )
+        for ( final String candidateAddress : candidateAddresses )
         {
-            userIP = request.getRemoteAddr();
+            final String trimAddr = candidateAddress.trim();
+            if ( InetAddressValidator.getInstance().isValid( trimAddr ) )
+            {
+                return trimAddr;
+            }
+            else
+            {
+                LOGGER.warn( "discarding bogus source network address '" + trimAddr + "'" );
+            }
         }
 
-        return userIP == null ? "" : userIP;
+        return "";
     }
 
-
-    public static void handleRequestInitialization(
+    private static void handleRequestInitialization(
             final PwmRequest pwmRequest
     )
             throws PwmUnrecoverableException
@@ -517,7 +513,7 @@ public class RequestInitializationFilter implements Filter
         // mark session ip address
         if ( ssBean.getSrcAddress() == null )
         {
-            ssBean.setSrcAddress( readUserIPAddress( pwmRequest.getHttpServletRequest(), pwmRequest.getConfig() ) );
+            ssBean.setSrcAddress( readUserNetworkAddress( pwmRequest.getHttpServletRequest(), pwmRequest.getConfig() ) );
         }
 
         // mark the user's hostname in the session bean
@@ -574,18 +570,39 @@ public class RequestInitializationFilter implements Filter
         }
     }
 
-    @SuppressWarnings( "checkstyle:MethodLength" )
-    public static void handleRequestSecurityChecks(
-            final PwmRequest pwmRequest
-    )
+    private static void handleRequestSecurityChecks( final PwmRequest pwmRequest )
             throws PwmUnrecoverableException
     {
-        final LocalSessionStateBean ssBean = pwmRequest.getPwmSession().getSessionStateBean();
-
         // check the user's IP address
+        checkIfSourceAddressChanged( pwmRequest );
+
+        // check total time.
+        checkTotalSessionTime( pwmRequest );
+
+        // check headers
+        checkRequiredHeaders( pwmRequest );
+
+        // check permitted source IP address
+        checkSourceNetworkAddress( pwmRequest );
+
+        // csrf cross-site request forgery checks
+        checkCsrfHeader( pwmRequest );
+
+        // check trial
+        checkTrial( pwmRequest );
+
+        // check intruder
+        pwmRequest.getPwmApplication().getIntruderManager().convenience().checkAddressAndSession( pwmRequest.getPwmSession() );
+    }
+
+    private static void checkIfSourceAddressChanged( final PwmRequest pwmRequest )
+            throws PwmUnrecoverableException
+    {
         if ( !pwmRequest.getConfig().readSettingAsBoolean( PwmSetting.MULTI_IP_SESSION_ALLOWED ) )
         {
-            final String remoteAddress = readUserIPAddress( pwmRequest.getHttpServletRequest(), pwmRequest.getConfig() );
+            final String remoteAddress = readUserNetworkAddress( pwmRequest.getHttpServletRequest(), pwmRequest.getConfig() );
+            final LocalSessionStateBean ssBean = pwmRequest.getPwmSession().getSessionStateBean();
+
             if ( !ssBean.getSrcAddress().equals( remoteAddress ) )
             {
                 final String errorMsg = "current network address '" + remoteAddress + "' has changed from original network address '" + ssBean.getSrcAddress() + "'";
@@ -593,100 +610,109 @@ public class RequestInitializationFilter implements Filter
                 throw new PwmUnrecoverableException( errorInformation );
             }
         }
+    }
 
-        // check total time.
+    private static void checkTotalSessionTime( final PwmRequest pwmRequest )
+            throws PwmUnrecoverableException
+    {
+        final LocalSessionStateBean ssBean = pwmRequest.getPwmSession().getSessionStateBean();
+
+        if ( ssBean.getSessionCreationTime() != null )
         {
-            if ( ssBean.getSessionCreationTime() != null )
+            final long maxSessionSeconds = pwmRequest.getConfig().readSettingAsLong( PwmSetting.SESSION_MAX_SECONDS );
+            final TimeDuration sessionAge = TimeDuration.fromCurrent( ssBean.getSessionCreationTime() );
+            final int sessionSecondAge = (int) sessionAge.as( TimeDuration.Unit.SECONDS );
+            if ( sessionSecondAge > maxSessionSeconds )
             {
-                final Long maxSessionSeconds = pwmRequest.getConfig().readSettingAsLong( PwmSetting.SESSION_MAX_SECONDS );
-                final TimeDuration sessionAge = TimeDuration.fromCurrent( ssBean.getSessionCreationTime() );
-                final int sessionSecondAge = (int) sessionAge.as( TimeDuration.Unit.SECONDS );
-                if ( sessionSecondAge > maxSessionSeconds )
-                {
-                    final String errorMsg = "session age (" + sessionAge.asCompactString() + ") is longer than maximum permitted age";
-                    final ErrorInformation errorInformation = new ErrorInformation( PwmError.ERROR_SECURITY_VIOLATION, errorMsg );
-                    throw new PwmUnrecoverableException( errorInformation );
-                }
+                final String errorMsg = "session age (" + sessionAge.asCompactString() + ") is longer than maximum permitted age";
+                final ErrorInformation errorInformation = new ErrorInformation( PwmError.ERROR_SECURITY_VIOLATION, errorMsg );
+                throw new PwmUnrecoverableException( errorInformation );
             }
         }
+    }
 
-        // check headers
+    private static void checkRequiredHeaders( final PwmRequest pwmRequest )
+            throws PwmUnrecoverableException
+    {
+        final List<String> requiredHeaders = pwmRequest.getConfig().readSettingAsStringArray( PwmSetting.REQUIRED_HEADERS );
+        if ( requiredHeaders != null && !requiredHeaders.isEmpty() )
         {
-            final List<String> requiredHeaders = pwmRequest.getConfig().readSettingAsStringArray( PwmSetting.REQUIRED_HEADERS );
-            if ( requiredHeaders != null && !requiredHeaders.isEmpty() )
-            {
-                final Map<String, String> configuredValues = StringUtil.convertStringListToNameValuePair( requiredHeaders, "=" );
+            final Map<String, String> configuredValues = StringUtil.convertStringListToNameValuePair( requiredHeaders, "=" );
 
-                for ( final Map.Entry<String, String> entry : configuredValues.entrySet() )
+            for ( final Map.Entry<String, String> entry : configuredValues.entrySet() )
+            {
+                final String key = entry.getKey();
+                if ( key != null && key.length() > 0 )
                 {
-                    final String key = entry.getKey();
-                    if ( key != null && key.length() > 0 )
+                    final String requiredValue = entry.getValue();
+                    if ( requiredValue != null && requiredValue.length() > 0 )
                     {
-                        final String requiredValue = entry.getValue();
-                        if ( requiredValue != null && requiredValue.length() > 0 )
+                        final String value = pwmRequest.readHeaderValueAsString( key );
+                        if ( value == null || value.length() < 1 )
+                        {
+                            final String errorMsg = "request is missing required value for header '" + key + "'";
+                            final ErrorInformation errorInformation = new ErrorInformation( PwmError.ERROR_SECURITY_VIOLATION, errorMsg );
+                            throw new PwmUnrecoverableException( errorInformation );
+                        }
+                        else
                         {
-                            final String value = pwmRequest.readHeaderValueAsString( key );
-                            if ( value == null || value.length() < 1 )
+                            if ( !requiredValue.equals( value ) )
                             {
-                                final String errorMsg = "request is missing required value for header '" + key + "'";
+                                final String errorMsg = "request has incorrect required value for header '" + key + "'";
                                 final ErrorInformation errorInformation = new ErrorInformation( PwmError.ERROR_SECURITY_VIOLATION, errorMsg );
                                 throw new PwmUnrecoverableException( errorInformation );
                             }
-                            else
-                            {
-                                if ( !requiredValue.equals( value ) )
-                                {
-                                    final String errorMsg = "request has incorrect required value for header '" + key + "'";
-                                    final ErrorInformation errorInformation = new ErrorInformation( PwmError.ERROR_SECURITY_VIOLATION, errorMsg );
-                                    throw new PwmUnrecoverableException( errorInformation );
-                                }
-                            }
                         }
                     }
                 }
             }
         }
+    }
 
-        // check permitted source IP address
+    private static void checkSourceNetworkAddress( final PwmRequest pwmRequest )
+            throws PwmUnrecoverableException
+    {
+        final List<String> requiredHeaders = pwmRequest.getConfig().readSettingAsStringArray( PwmSetting.IP_PERMITTED_RANGE );
+        if ( requiredHeaders != null && !requiredHeaders.isEmpty() )
         {
-            final List<String> requiredHeaders = pwmRequest.getConfig().readSettingAsStringArray( PwmSetting.IP_PERMITTED_RANGE );
-            if ( requiredHeaders != null && !requiredHeaders.isEmpty() )
+            boolean match = false;
+            final String requestAddress = pwmRequest.getHttpServletRequest().getRemoteAddr();
+            for ( int i = 0; i < requiredHeaders.size() && !match; i++ )
             {
-                boolean match = false;
-                final String requestAddress = pwmRequest.getHttpServletRequest().getRemoteAddr();
-                for ( int i = 0; i < requiredHeaders.size() && !match; i++ )
+                final String ipMatchString = requiredHeaders.get( i );
+                try
                 {
-                    final String ipMatchString = requiredHeaders.get( i );
+                    final IPMatcher ipMatcher = new IPMatcher( ipMatchString );
                     try
                     {
-                        final IPMatcher ipMatcher = new IPMatcher( ipMatchString );
-                        try
-                        {
-                            if ( ipMatcher.match( requestAddress ) )
-                            {
-                                match = true;
-                            }
-                        }
-                        catch ( IPMatcher.IPMatcherException e )
+                        if ( ipMatcher.match( requestAddress ) )
                         {
-                            LOGGER.error( "error while attempting to match permitted address range '" + ipMatchString + "', error: " + e );
+                            match = true;
                         }
                     }
                     catch ( IPMatcher.IPMatcherException e )
                     {
-                        LOGGER.error( "error parsing permitted address range '" + ipMatchString + "', error: " + e );
+                        LOGGER.error( "error while attempting to match permitted address range '" + ipMatchString + "', error: " + e );
                     }
                 }
-                if ( !match )
+                catch ( IPMatcher.IPMatcherException e )
                 {
-                    final String errorMsg = "request network address '" + requestAddress + "' does not match any configured permitted source address";
-                    final ErrorInformation errorInformation = new ErrorInformation( PwmError.ERROR_SECURITY_VIOLATION, errorMsg );
-                    throw new PwmUnrecoverableException( errorInformation );
+                    LOGGER.error( "error parsing permitted address range '" + ipMatchString + "', error: " + e );
                 }
             }
+            if ( !match )
+            {
+                final String errorMsg = "request network address '" + requestAddress + "' does not match any configured permitted source address";
+                final ErrorInformation errorInformation = new ErrorInformation( PwmError.ERROR_SECURITY_VIOLATION, errorMsg );
+                throw new PwmUnrecoverableException( errorInformation );
+            }
         }
+    }
 
-        //  csrf cross-site request forgery checks
+
+    private static void checkCsrfHeader( final PwmRequest pwmRequest )
+            throws PwmUnrecoverableException
+    {
         final boolean performCsrfHeaderChecks = Boolean.parseBoolean( pwmRequest.getConfig().readAppProperty( AppProperty.SECURITY_HTTP_PERFORM_CSRF_HEADER_CHECKS ) );
         if (
                 performCsrfHeaderChecks
@@ -751,8 +777,11 @@ public class RequestInitializationFilter implements Filter
                 throw new PwmUnrecoverableException( errorInformation );
             }
         }
+    }
 
-        // check trial
+    private static void checkTrial ( final PwmRequest pwmRequest )
+            throws PwmUnrecoverableException
+    {
         if ( PwmConstants.TRIAL_MODE )
         {
             final StatisticsManager statisticsManager = pwmRequest.getPwmApplication().getStatisticsManager();
@@ -768,9 +797,6 @@ public class RequestInitializationFilter implements Filter
                 throw new PwmUnrecoverableException( new ErrorInformation( PwmError.ERROR_TRIAL_VIOLATION, "maximum usage for this server has been exceeded" ) );
             }
         }
-
-        // check intruder
-        pwmRequest.getPwmApplication().getIntruderManager().convenience().checkAddressAndSession( pwmRequest.getPwmSession() );
     }
 
     private void checkIdleTimeout( final PwmRequest pwmRequest ) throws PwmUnrecoverableException

+ 10 - 15
server/src/main/java/password/pwm/http/servlet/ControlledPwmServlet.java

@@ -48,7 +48,7 @@ public abstract class ControlledPwmServlet extends AbstractPwmServlet implements
 
     private static final PwmLogger LOGGER = PwmLogger.forClass( AbstractPwmServlet.class );
 
-    private Map<String, Method> actionMethodCache;
+    private final Map<String, Method> actionMethodCache = createMethodCache();
 
     public String servletUriRemainder( final PwmRequest pwmRequest, final String command ) throws PwmUnrecoverableException
     {
@@ -112,7 +112,7 @@ public abstract class ControlledPwmServlet extends AbstractPwmServlet implements
         }
         try
         {
-            final Method interestedMethod = discoverMethodForAction( this.getClass(), action );
+            final Method interestedMethod = actionMethodCache.get( action.toString() );
             if ( interestedMethod != null )
             {
                 interestedMethod.setAccessible( true );
@@ -211,25 +211,20 @@ public abstract class ControlledPwmServlet extends AbstractPwmServlet implements
         String action( );
     }
 
-    private Method discoverMethodForAction( final Class clazz, final ProcessAction action )
+    private Map<String, Method> createMethodCache()
     {
-        if ( actionMethodCache == null )
+        final Map<String, Method> map = new HashMap<>();
+        final Collection<Method> methods = JavaHelper.getAllMethodsForClass( this.getClass() );
+        for ( Method method : methods )
         {
-            final Map<String, Method> map = new HashMap<>();
-            final Collection<Method> methods = JavaHelper.getAllMethodsForClass( clazz );
-            for ( Method method : methods )
+            if ( method.getAnnotation( ActionHandler.class ) != null )
             {
-                if ( method.getAnnotation( ActionHandler.class ) != null )
-                {
-                    final String actionName = method.getAnnotation( ActionHandler.class ).action();
-                    map.put( actionName, method );
+                final String actionName = method.getAnnotation( ActionHandler.class ).action();
+                map.put( actionName, method );
 
-                }
             }
-            actionMethodCache = Collections.unmodifiableMap( map );
         }
-
-        return actionMethodCache.get( action.toString() );
+        return Collections.unmodifiableMap( map );
     }
 }
 

+ 3 - 1
server/src/main/java/password/pwm/http/servlet/admin/AppDashboardData.java

@@ -123,6 +123,7 @@ public class AppDashboardData implements Serializable
             final Locale locale,
             final Flag... flags
     )
+            throws PwmUnrecoverableException
     {
         final Instant startTime = Instant.now();
 
@@ -275,6 +276,7 @@ public class AppDashboardData implements Serializable
     }
 
     private static List<DisplayElement> makeLocalDbInfo( final PwmApplication pwmApplication, final Locale locale )
+            throws PwmUnrecoverableException
     {
         final List<DisplayElement> localDbInfo = new ArrayList<>();
         final String notApplicable = Display.getLocalizedMessage( locale, Display.Value_NotApplicable, pwmApplication.getConfig() );
@@ -284,7 +286,7 @@ public class AppDashboardData implements Serializable
                 "worlistSize",
                 DisplayElement.Type.number,
                 "Word List Dictionary Size",
-                numberFormat.format( pwmApplication.getWordlistManager().size() )
+                numberFormat.format( pwmApplication.getWordlistService().size() )
         ) );
 
         localDbInfo.add( new DisplayElement(

+ 1 - 1
server/src/main/java/password/pwm/http/servlet/configmanager/ConfigManagerLocalDBServlet.java

@@ -143,7 +143,7 @@ public class ConfigManagerLocalDBServlet extends AbstractPwmServlet
         {
             final int bufferSize = Integer.parseInt( pwmRequest.getConfig().readAppProperty( AppProperty.HTTP_DOWNLOAD_BUFFER_SIZE ) );
             final OutputStream bos = new BufferedOutputStream( resp.getOutputStream(), bufferSize );
-            localDBUtility.exportLocalDB( bos, LOGGER.asAppendable( PwmLogLevel.DEBUG, pwmRequest.getSessionLabel() ), true );
+            localDBUtility.exportLocalDB( bos, LOGGER.asAppendable( PwmLogLevel.DEBUG, pwmRequest.getSessionLabel() ) );
             LOGGER.debug( pwmRequest, () -> "completed localDBExport process in " + TimeDuration.fromCurrent( startTime ).asCompactString() );
         }
         catch ( Exception e )

+ 4 - 4
server/src/main/java/password/pwm/http/servlet/configmanager/ConfigManagerWordlistServlet.java

@@ -201,7 +201,7 @@ public class ConfigManagerWordlistServlet extends AbstractPwmServlet
     }
 
     void restReadWordlistData( final PwmRequest pwmRequest )
-            throws IOException
+            throws IOException, PwmUnrecoverableException
     {
         final LinkedHashMap<WordlistType, WordlistDataBean> outputData = new LinkedHashMap<>();
 
@@ -252,13 +252,13 @@ public class ConfigManagerWordlistServlet extends AbstractPwmServlet
                                 "Population Timestamp",
                                 JavaHelper.toIsoDate( wordlistStatus.getStoreDate() ) ) );
                     }
-                    if ( wordlistStatus.getRemoteInfo() != null && !StringUtil.isEmpty( wordlistStatus.getRemoteInfo().getChecksum() ) )
+                    if ( wordlistStatus.getRemoteInfo() != null && !StringUtil.isEmpty( wordlistStatus.getRemoteInfo().getHash() ) )
                     {
                         presentableValues.add( new DisplayElement(
                                 wordlistType.name() + "_sha256Hash",
                                 DisplayElement.Type.string,
-                                "CRC Checksum",
-                                wordlistStatus.getRemoteInfo().getChecksum() ) );
+                                "SHA1 Checksum",
+                                wordlistStatus.getRemoteInfo().getHash() ) );
                     }
                 }
                 if ( wordlist.getAutoImportError() != null )

+ 0 - 56
server/src/main/java/password/pwm/http/servlet/peoplesearch/PeopleSearchResourcesServlet.java

@@ -1,56 +0,0 @@
-/*
- * Password Management Servlets (PWM)
- * http://www.pwm-project.org
- *
- * Copyright (c) 2006-2009 Novell, Inc.
- * Copyright (c) 2009-2019 The PWM Project
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package password.pwm.http.servlet.peoplesearch;
-
-import org.apache.commons.lang3.StringUtils;
-import password.pwm.PwmConstants;
-
-import javax.servlet.ServletException;
-import javax.servlet.annotation.WebServlet;
-import javax.servlet.http.HttpServlet;
-import javax.servlet.http.HttpServletRequest;
-import javax.servlet.http.HttpServletResponse;
-import java.io.IOException;
-
-@WebServlet(
-        name = "PeopleSearchResourcesServlet",
-        urlPatterns = {
-                PwmConstants.URL_PREFIX_PRIVATE + "/peoplesearch/fonts/*",
-                PwmConstants.URL_PREFIX_PUBLIC + "/peoplesearch/fonts/*"
-        }
-)
-public class PeopleSearchResourcesServlet extends HttpServlet
-{
-
-    @Override
-    protected void service( final HttpServletRequest request, final HttpServletResponse response ) throws ServletException, IOException
-    {
-        response.sendRedirect( String.format(
-                // Build a relative URL with place holders:
-                "%s%s/resources/app/fonts/%s",
-
-                // Place holder values:
-                request.getContextPath(),
-                PwmConstants.URL_PREFIX_PUBLIC,
-                StringUtils.substringAfterLast( request.getRequestURI(), "/" )
-        ) );
-    }
-}

+ 3 - 1
server/src/main/java/password/pwm/http/servlet/resource/ResourceFileRequest.java

@@ -51,7 +51,9 @@ class ResourceFileRequest
     private static final PwmLogger LOGGER = PwmLogger.forClass( ResourceFileRequest.class );
 
     private static final Map<String, String> WEB_JAR_VERSION_MAP = Collections.unmodifiableMap( new HashMap<>( new WebJarAssetLocator().getWebJars() ) );
-    private static final Collection<String> WEB_JAR_ASSET_LIST = Collections.unmodifiableCollection( new ArrayList<>( new WebJarAssetLocator().getFullPathIndex().values() ) );
+
+    /** Contains a list of all resources (files) found inside the resources folder of all JARs in the WAR's classpath. **/
+    private static final Collection<String> WEB_JAR_ASSET_LIST = Collections.unmodifiableCollection( new ArrayList<>( new WebJarAssetLocator().listAssets() ) );
 
     private final HttpServletRequest httpServletRequest;
     private final Configuration configuration;

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

@@ -30,10 +30,10 @@ import password.pwm.http.HttpHeader;
 import password.pwm.http.HttpMethod;
 import password.pwm.http.PwmRequest;
 import password.pwm.http.servlet.PwmServlet;
-import password.pwm.util.EventRateMeter;
 import password.pwm.svc.stats.Statistic;
 import password.pwm.svc.stats.StatisticsManager;
 import password.pwm.util.java.JavaHelper;
+import password.pwm.util.java.MovingAverage;
 import password.pwm.util.logging.PwmLogger;
 
 import javax.servlet.ServletException;
@@ -211,7 +211,7 @@ public class ResourceFileServlet extends HttpServlet implements PwmServlet
 
             pwmRequest.debugHttpRequestToLog( debugText );
 
-            final EventRateMeter.MovingAverage cacheHitRatio = resourceService.getCacheHitRatio();
+            final MovingAverage cacheHitRatio = resourceService.getCacheHitRatio();
             StatisticsManager.incrementStat( pwmApplication, Statistic.HTTP_RESOURCE_REQUESTS );
             cacheHitRatio.update( fromCache ? 1 : 0 );
         }

+ 3 - 3
server/src/main/java/password/pwm/http/servlet/resource/ResourceServletService.java

@@ -31,9 +31,9 @@ import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.health.HealthRecord;
 import password.pwm.http.PwmRequest;
 import password.pwm.svc.PwmService;
-import password.pwm.util.EventRateMeter;
 import password.pwm.util.java.FileSystemUtility;
 import password.pwm.util.java.JavaHelper;
+import password.pwm.util.java.MovingAverage;
 import password.pwm.util.java.Percent;
 import password.pwm.util.java.TimeDuration;
 import password.pwm.util.logging.PwmLogger;
@@ -59,7 +59,7 @@ public class ResourceServletService implements PwmService
 
     private ResourceServletConfiguration resourceServletConfiguration;
     private Cache<CacheKey, CacheEntry> cache;
-    private EventRateMeter.MovingAverage cacheHitRatio = new EventRateMeter.MovingAverage( 60 * 60 * 1000 );
+    private MovingAverage cacheHitRatio = new MovingAverage( 60 * 60 * 1000 );
     private String resourceNonce;
     private STATUS status = STATUS.NEW;
 
@@ -75,7 +75,7 @@ public class ResourceServletService implements PwmService
         return cache;
     }
 
-    public EventRateMeter.MovingAverage getCacheHitRatio( )
+    public MovingAverage getCacheHitRatio( )
     {
         return cacheHitRatio;
     }

+ 237 - 77
server/src/main/java/password/pwm/http/tag/PasswordRequirementsTag.java

@@ -20,13 +20,13 @@
 
 package password.pwm.http.tag;
 
+import lombok.Value;
 import password.pwm.PwmApplication;
 import password.pwm.config.Configuration;
 import password.pwm.config.option.ADPolicyComplexity;
 import password.pwm.config.profile.NewUserProfile;
 import password.pwm.config.profile.PwmPasswordPolicy;
 import password.pwm.config.profile.PwmPasswordRule;
-import password.pwm.util.password.PasswordRuleReaderHelper;
 import password.pwm.error.PwmException;
 import password.pwm.http.PwmRequest;
 import password.pwm.http.PwmSession;
@@ -37,6 +37,7 @@ import password.pwm.util.i18n.LocaleHelper;
 import password.pwm.util.java.StringUtil;
 import password.pwm.util.logging.PwmLogger;
 import password.pwm.util.macro.MacroMachine;
+import password.pwm.util.password.PasswordRuleReaderHelper;
 
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
@@ -44,6 +45,8 @@ import javax.servlet.jsp.JspTagException;
 import javax.servlet.jsp.tagext.TagSupport;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
 import java.util.MissingResourceException;
@@ -58,31 +61,84 @@ public class PasswordRequirementsTag extends TagSupport
     private String prepend;
     private String form;
 
-    @SuppressWarnings( "checkstyle:MethodLength" )
     public static List<String> getPasswordRequirementsStrings(
-            final PwmPasswordPolicy pwordPolicy,
+            final PwmPasswordPolicy passwordPolicy,
             final Configuration config,
             final Locale locale,
             final MacroMachine macroMachine
     )
     {
-        final List<String> returnValues = new ArrayList<>();
-        final ADPolicyComplexity adPolicyLevel = pwordPolicy.getRuleHelper().getADComplexityLevel();
+        final List<String> ruleTexts = new ArrayList<>(  );
+        final PolicyValues policyValues = new PolicyValues( passwordPolicy, passwordPolicy.getRuleHelper(), locale, config, macroMachine );
+        for ( final RuleTextGenerator ruleTextGenerator : RULE_TEXT_GENERATORS )
+        {
+            ruleTexts.addAll( ruleTextGenerator.generate( policyValues ) );
+        }
 
+        return Collections.unmodifiableList( ruleTexts );
+    }
 
-        final PasswordRuleReaderHelper ruleHelper = pwordPolicy.getRuleHelper();
+    private static final List<RuleTextGenerator> RULE_TEXT_GENERATORS = Collections.unmodifiableList( Arrays.asList(
+            new CaseSensitiveRuleTextGenerator(),
+            new MinLengthRuleTextGenerator(),
+            new MaxLengthRuleTextGenerator(),
+            new MinAlphaRuleTextGenerator(),
+            new MaxAlphaRuleTextGenerator(),
+            new NumericCharsRuleTextGenerator(),
+            new SpecialCharsRuleTextGenerator(),
+            new MaximumRepeatRuleTextGenerator(),
+            new MaximumSequentialRepeatRuleTextGenerator(),
+            new MinimumLowerRuleTextGenerator(),
+            new MaximumLowerRuleTextGenerator(),
+            new MinimumUpperRuleTextGenerator(),
+            new MaximumUpperRuleTextGenerator(),
+            new MinimumUniqueRuleTextGenerator(),
+            new DisallowedValuesRuleTextGenerator(),
+            new WordlistRuleTextGenerator(),
+            new DisallowedAttributesRuleTextGenerator(),
+            new MaximumOldCharsRuleTextGenerator(),
+            new MinimumLifetimeRuleTextGenerator(),
+            new ADRuleTextGenerator(),
+            new UniqueRequiredRuleTextGenerator()
+    ) );
+
+    private interface RuleTextGenerator
+    {
+        List<String> generate( PolicyValues policyValues );
+    }
 
-        if ( ruleHelper.readBooleanValue( PwmPasswordRule.CaseSensitive ) )
-        {
-            returnValues.add( getLocalString( Message.Requirement_CaseSensitive, null, locale, config ) );
-        }
-        else
+    @Value
+    private static class PolicyValues
+    {
+        private PwmPasswordPolicy passwordPolicy;
+        private PasswordRuleReaderHelper ruleHelper;
+        private Locale locale;
+        private Configuration config;
+        private MacroMachine macroMachine;
+    }
+
+    private static class CaseSensitiveRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            returnValues.add( getLocalString( Message.Requirement_NotCaseSensitive, null, locale, config ) );
+            if ( policyValues.getRuleHelper().readBooleanValue( PwmPasswordRule.CaseSensitive ) )
+            {
+                return Collections.singletonList( getLocalString( Message.Requirement_CaseSensitive, null, policyValues ) );
+            }
+            else
+            {
+                return Collections.singletonList( getLocalString( Message.Requirement_NotCaseSensitive, null, policyValues ) );
+            }
         }
+    }
 
+    private static class MinLengthRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            int value = ruleHelper.readIntValue( PwmPasswordRule.MinimumLength );
+            int value = policyValues.getRuleHelper().readIntValue( PwmPasswordRule.MinimumLength );
+            final ADPolicyComplexity adPolicyLevel = policyValues.getRuleHelper().getADComplexityLevel();
+
             if ( adPolicyLevel == ADPolicyComplexity.AD2003 || adPolicyLevel == ADPolicyComplexity.AD2008 )
             {
                 if ( value < 6 )
@@ -92,154 +148,223 @@ public class PasswordRequirementsTag extends TagSupport
             }
             if ( value > 0 )
             {
-                returnValues.add( getLocalString( Message.Requirement_MinLength, value, locale, config ) );
+                return Collections.singletonList( getLocalString( Message.Requirement_MinLength, value, policyValues ) );
             }
+            return Collections.emptyList();
         }
+    }
 
+    private static class MaxLengthRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            final int value = ruleHelper.readIntValue( PwmPasswordRule.MaximumLength );
+            final int value = policyValues.getRuleHelper().readIntValue( PwmPasswordRule.MaximumLength );
             if ( value > 0 && value < 64 )
             {
-                returnValues.add( getLocalString( Message.Requirement_MaxLength, value, locale, config ) );
+                return Collections.singletonList( getLocalString( Message.Requirement_MaxLength, value, policyValues ) );
             }
+            return Collections.emptyList();
         }
+    }
 
+    private static class MinAlphaRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            final int value = ruleHelper.readIntValue( PwmPasswordRule.MinimumAlpha );
+            final int value = policyValues.getRuleHelper().readIntValue( PwmPasswordRule.MinimumAlpha );
             if ( value > 0 )
             {
-                returnValues.add( getLocalString( Message.Requirement_MinAlpha, value, locale, config ) );
+                return Collections.singletonList( getLocalString( Message.Requirement_MinAlpha, value, policyValues ) );
             }
+            return Collections.emptyList();
         }
+    }
 
+    private static class MaxAlphaRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            final int value = ruleHelper.readIntValue( PwmPasswordRule.MaximumAlpha );
+            final int value = policyValues.getRuleHelper().readIntValue( PwmPasswordRule.MaximumAlpha );
             if ( value > 0 )
             {
-                returnValues.add( getLocalString( Message.Requirement_MaxAlpha, value, locale, config ) );
+                return Collections.singletonList( getLocalString( Message.Requirement_MaxAlpha, value, policyValues ) );
             }
+            return Collections.emptyList();
         }
+    }
 
+    private static class NumericCharsRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
+            final PasswordRuleReaderHelper ruleHelper = policyValues.getRuleHelper();
             if ( !ruleHelper.readBooleanValue( PwmPasswordRule.AllowNumeric ) )
             {
-                returnValues.add( getLocalString( Message.Requirement_AllowNumeric, null, locale, config ) );
+                return Collections.singletonList( getLocalString( Message.Requirement_AllowNumeric, null, policyValues ) );
             }
             else
             {
+                final List<String> returnValues = new ArrayList<>(  );
                 final int minValue = ruleHelper.readIntValue( PwmPasswordRule.MinimumNumeric );
                 if ( minValue > 0 )
                 {
-                    returnValues.add( getLocalString( Message.Requirement_MinNumeric, minValue, locale, config ) );
+                    returnValues.add( getLocalString( Message.Requirement_MinNumeric, minValue, policyValues ) );
                 }
 
                 final int maxValue = ruleHelper.readIntValue( PwmPasswordRule.MaximumNumeric );
                 if ( maxValue > 0 )
                 {
-                    returnValues.add( getLocalString( Message.Requirement_MaxNumeric, maxValue, locale, config ) );
+                    returnValues.add( getLocalString( Message.Requirement_MaxNumeric, maxValue, policyValues ) );
                 }
 
                 if ( !ruleHelper.readBooleanValue( PwmPasswordRule.AllowFirstCharNumeric ) )
                 {
-                    returnValues.add( getLocalString( Message.Requirement_FirstNumeric, maxValue, locale, config ) );
+                    returnValues.add( getLocalString( Message.Requirement_FirstNumeric, maxValue, policyValues ) );
                 }
 
                 if ( !ruleHelper.readBooleanValue( PwmPasswordRule.AllowLastCharNumeric ) )
                 {
-                    returnValues.add( getLocalString( Message.Requirement_LastNumeric, maxValue, locale, config ) );
+                    returnValues.add( getLocalString( Message.Requirement_LastNumeric, maxValue, policyValues ) );
                 }
+                return returnValues;
             }
         }
+    }
 
+    private static class SpecialCharsRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
+            final PasswordRuleReaderHelper ruleHelper = policyValues.getRuleHelper();
             if ( !ruleHelper.readBooleanValue( PwmPasswordRule.AllowSpecial ) )
             {
-                returnValues.add( getLocalString( Message.Requirement_AllowSpecial, null, locale, config ) );
+                return Collections.singletonList( getLocalString( Message.Requirement_AllowSpecial, null, policyValues ) );
             }
             else
             {
+                final List<String> returnValues = new ArrayList<>(  );
                 final int minValue = ruleHelper.readIntValue( PwmPasswordRule.MinimumSpecial );
                 if ( minValue > 0 )
                 {
-                    returnValues.add( getLocalString( Message.Requirement_MinSpecial, minValue, locale, config ) );
+                    returnValues.add( getLocalString( Message.Requirement_MinSpecial, minValue, policyValues ) );
                 }
 
                 final int maxValue = ruleHelper.readIntValue( PwmPasswordRule.MaximumSpecial );
                 if ( maxValue > 0 )
                 {
-                    returnValues.add( getLocalString( Message.Requirement_MaxSpecial, maxValue, locale, config ) );
+                    returnValues.add( getLocalString( Message.Requirement_MaxSpecial, maxValue, policyValues ) );
                 }
 
                 if ( !ruleHelper.readBooleanValue( PwmPasswordRule.AllowFirstCharSpecial ) )
                 {
-                    returnValues.add( getLocalString( Message.Requirement_FirstSpecial, maxValue, locale, config ) );
+                    returnValues.add( getLocalString( Message.Requirement_FirstSpecial, maxValue, policyValues ) );
                 }
 
                 if ( !ruleHelper.readBooleanValue( PwmPasswordRule.AllowLastCharSpecial ) )
                 {
-                    returnValues.add( getLocalString( Message.Requirement_LastSpecial, maxValue, locale, config ) );
+                    returnValues.add( getLocalString( Message.Requirement_LastSpecial, maxValue, policyValues ) );
                 }
+                return returnValues;
             }
         }
+    }
 
+    private static class MaximumRepeatRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            final int value = pwordPolicy.getRuleHelper().readIntValue( PwmPasswordRule.MaximumRepeat );
+            final int value = policyValues.getRuleHelper().readIntValue( PwmPasswordRule.MaximumRepeat );
             if ( value > 0 )
             {
-                returnValues.add( getLocalString( Message.Requirement_MaxRepeat, value, locale, config ) );
+                return Collections.singletonList( getLocalString( Message.Requirement_MaxRepeat, value, policyValues ) );
             }
+            return Collections.emptyList();
         }
+    }
 
+    private static class MaximumSequentialRepeatRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            final int value = pwordPolicy.getRuleHelper().readIntValue( PwmPasswordRule.MaximumSequentialRepeat );
+            final int value = policyValues.getRuleHelper().readIntValue( PwmPasswordRule.MaximumSequentialRepeat );
             if ( value > 0 )
             {
-                returnValues.add( getLocalString( Message.Requirement_MaxSeqRepeat, value, locale, config ) );
+                return Collections.singletonList( getLocalString( Message.Requirement_MaxSeqRepeat, value, policyValues ) );
             }
+            return Collections.emptyList();
         }
+    }
 
+    private static class MinimumLowerRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            final int value = ruleHelper.readIntValue( PwmPasswordRule.MinimumLowerCase );
+            final int value = policyValues.getRuleHelper().readIntValue( PwmPasswordRule.MinimumLowerCase );
             if ( value > 0 )
             {
-                returnValues.add( getLocalString( Message.Requirement_MinLower, value, locale, config ) );
+                return Collections.singletonList( getLocalString( Message.Requirement_MinLower, value, policyValues ) );
             }
+            return Collections.emptyList();
         }
+    }
 
+    private static class MaximumLowerRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            final int value = ruleHelper.readIntValue( PwmPasswordRule.MaximumLowerCase );
+            final int value = policyValues.getRuleHelper().readIntValue( PwmPasswordRule.MaximumLowerCase );
             if ( value > 0 )
             {
-                returnValues.add( getLocalString( Message.Requirement_MaxLower, value, locale, config ) );
+                return Collections.singletonList( getLocalString( Message.Requirement_MaxLower, value, policyValues ) );
             }
+            return Collections.emptyList();
         }
+    }
 
+    private static class MinimumUpperRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            final int value = ruleHelper.readIntValue( PwmPasswordRule.MinimumUpperCase );
+            final int value = policyValues.getRuleHelper().readIntValue( PwmPasswordRule.MinimumUpperCase );
             if ( value > 0 )
             {
-                returnValues.add( getLocalString( Message.Requirement_MinUpper, value, locale, config ) );
+                return Collections.singletonList( getLocalString( Message.Requirement_MinUpper, value, policyValues ) );
             }
+            return Collections.emptyList();
         }
+    }
 
+    private static class MaximumUpperRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            final int value = ruleHelper.readIntValue( PwmPasswordRule.MaximumUpperCase );
+            final int value = policyValues.getRuleHelper().readIntValue( PwmPasswordRule.MaximumUpperCase );
             if ( value > 0 )
             {
-                returnValues.add( getLocalString( Message.Requirement_MaxUpper, value, locale, config ) );
+                return Collections.singletonList( getLocalString( Message.Requirement_MaxUpper, value, policyValues ) );
             }
+            return Collections.emptyList();
         }
+    }
 
+    private static class MinimumUniqueRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            final int value = ruleHelper.readIntValue( PwmPasswordRule.MinimumUnique );
+            final int value = policyValues.getRuleHelper().readIntValue( PwmPasswordRule.MinimumUnique );
             if ( value > 0 )
             {
-                returnValues.add( getLocalString( Message.Requirement_MinUnique, value, locale, config ) );
+                return Collections.singletonList( getLocalString( Message.Requirement_MinUnique, value, policyValues ) );
             }
+            return Collections.emptyList();
         }
+    }
 
+    private static class DisallowedValuesRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            final List<String> setValue = ruleHelper.getDisallowedValues();
+            final List<String> setValue = policyValues.getRuleHelper().getDisallowedValues();
             if ( !setValue.isEmpty() )
             {
                 final StringBuilder fieldValue = new StringBuilder();
@@ -247,37 +372,59 @@ public class PasswordRequirementsTag extends TagSupport
                 {
                     fieldValue.append( " " );
 
-                    final String expandedValue = macroMachine.expandMacros( loopValue );
+                    final String expandedValue = policyValues.getMacroMachine().expandMacros( loopValue );
                     fieldValue.append( StringUtil.escapeHtml( expandedValue ) );
                 }
-                returnValues.add(
-                        getLocalString( Message.Requirement_DisAllowedValues, fieldValue.toString(), locale, config ) );
+                return Collections.singletonList( getLocalString( Message.Requirement_DisAllowedValues, fieldValue.toString(), policyValues ) );
             }
+            return Collections.emptyList();
         }
+    }
 
+    private static class WordlistRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            final List<String> setValue = ruleHelper.getDisallowedAttributes();
-            if ( !setValue.isEmpty() || adPolicyLevel == ADPolicyComplexity.AD2003 )
+            if ( policyValues.getRuleHelper().readBooleanValue( PwmPasswordRule.EnableWordlist ) )
             {
-                returnValues.add( getLocalString( Message.Requirement_DisAllowedAttributes, "", locale, config ) );
+                return Collections.singletonList( getLocalString( Message.Requirement_WordList, "", policyValues ) );
             }
+            return Collections.emptyList();
         }
+    }
 
-        if ( ruleHelper.readBooleanValue( PwmPasswordRule.EnableWordlist ) )
+    private static class DisallowedAttributesRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            returnValues.add( getLocalString( Message.Requirement_WordList, "", locale, config ) );
+            final List<String> setValue = policyValues.getRuleHelper().getDisallowedAttributes();
+            final ADPolicyComplexity adPolicyLevel = policyValues.getRuleHelper().getADComplexityLevel();
+            if ( !setValue.isEmpty() || adPolicyLevel == ADPolicyComplexity.AD2003 )
+            {
+                return Collections.singletonList(  getLocalString( Message.Requirement_DisAllowedAttributes, "", policyValues ) );
+            }
+            return Collections.emptyList();
         }
+    }
 
+    private static class MaximumOldCharsRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            final int value = ruleHelper.readIntValue( PwmPasswordRule.MaximumOldChars );
+            final int value = policyValues.getRuleHelper().readIntValue( PwmPasswordRule.MaximumOldChars );
             if ( value > 0 )
             {
-                returnValues.add( getLocalString( Message.Requirement_OldChar, value, locale, config ) );
+                return Collections.singletonList( getLocalString( Message.Requirement_OldChar, value, policyValues ) );
             }
+            return Collections.emptyList();
         }
+    }
 
+    private static class MinimumLifetimeRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            final int value = ruleHelper.readIntValue( PwmPasswordRule.MinimumLifetime );
+            final int value = policyValues.getRuleHelper().readIntValue( PwmPasswordRule.MinimumLifetime );
             if ( value > 0 )
             {
                 final int secondsPerDay = 60 * 60 * 24;
@@ -287,41 +434,54 @@ public class PasswordRequirementsTag extends TagSupport
                 {
                     final int valueAsDays = value / ( 60 * 60 * 24 );
                     final Display key = valueAsDays <= 1 ? Display.Display_Day : Display.Display_Days;
-                    durationStr = valueAsDays + " " + LocaleHelper.getLocalizedMessage( locale, key, config );
+                    durationStr = valueAsDays + " " + LocaleHelper.getLocalizedMessage( policyValues.getLocale(), key, policyValues.getConfig() );
                 }
                 else
                 {
                     final int valueAsHours = value / ( 60 * 60 );
                     final Display key = valueAsHours <= 1 ? Display.Display_Hour : Display.Display_Hours;
-                    durationStr = valueAsHours + " " + LocaleHelper.getLocalizedMessage( locale, key, config );
+                    durationStr = valueAsHours + " " + LocaleHelper.getLocalizedMessage( policyValues.getLocale(), key, policyValues.getConfig() );
                 }
 
-                final String userMsg = Message.getLocalizedMessage( locale, Message.Requirement_MinimumFrequency, config,
-                        durationStr );
-                returnValues.add( userMsg );
+                final String userMsg = Message.getLocalizedMessage( policyValues.getLocale(), Message.Requirement_MinimumFrequency, policyValues.getConfig(), durationStr );
+                return Collections.singletonList( userMsg );
             }
+            return Collections.emptyList();
         }
+    }
 
-        if ( adPolicyLevel == ADPolicyComplexity.AD2003 )
-        {
-            returnValues.add( getLocalString( Message.Requirement_ADComplexity, "", locale, config ) );
-        }
-        else if ( adPolicyLevel == ADPolicyComplexity.AD2008 )
+    private static class ADRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            final int maxViolations = ruleHelper.readIntValue( PwmPasswordRule.ADComplexityMaxViolations );
-            final int minGroups = 5 - maxViolations;
-            returnValues.add( getLocalString( Message.Requirement_ADComplexity2008, String.valueOf( minGroups ), locale, config ) );
+            final ADPolicyComplexity adPolicyLevel = policyValues.getRuleHelper().getADComplexityLevel();
+            if ( adPolicyLevel == ADPolicyComplexity.AD2003 )
+            {
+                return Collections.singletonList( getLocalString( Message.Requirement_ADComplexity, "", policyValues ) );
+            }
+            else if ( adPolicyLevel == ADPolicyComplexity.AD2008 )
+            {
+                final int maxViolations = policyValues.getRuleHelper().readIntValue( PwmPasswordRule.ADComplexityMaxViolations );
+                final int minGroups = 5 - maxViolations;
+                return Collections.singletonList( getLocalString( Message.Requirement_ADComplexity2008, String.valueOf( minGroups ), policyValues ) );
+            }
+            return Collections.emptyList();
         }
+    }
 
-        if ( ruleHelper.readBooleanValue( PwmPasswordRule.UniqueRequired ) )
+    private static class UniqueRequiredRuleTextGenerator implements RuleTextGenerator
+    {
+        public List<String> generate( final PolicyValues policyValues )
         {
-            returnValues.add( getLocalString( Message.Requirement_UniqueRequired, "", locale, config ) );
+            if ( policyValues.getRuleHelper().readBooleanValue( PwmPasswordRule.UniqueRequired ) )
+            {
+                return Collections.singletonList( getLocalString( Message.Requirement_UniqueRequired, "", policyValues ) );
+            }
+            return Collections.emptyList();
         }
-
-        return returnValues;
     }
 
-    private static String getLocalString( final Message message, final int size, final Locale locale, final Configuration config )
+    private static String getLocalString( final Message message, final int size, final PolicyValues policyValues )
     {
         final Message effectiveMessage = size > 1 && message.getPluralMessage() != null
                 ? message.getPluralMessage()
@@ -329,7 +489,7 @@ public class PasswordRequirementsTag extends TagSupport
 
         try
         {
-            return Message.getLocalizedMessage( locale, effectiveMessage, config, String.valueOf( size ) );
+            return Message.getLocalizedMessage( policyValues.getLocale(), effectiveMessage, policyValues.getConfig(), String.valueOf( size ) );
         }
         catch ( MissingResourceException e )
         {
@@ -338,11 +498,11 @@ public class PasswordRequirementsTag extends TagSupport
         return "UNKNOWN MESSAGE STRING";
     }
 
-    private static String getLocalString( final Message message, final String field, final Locale locale, final Configuration config )
+    private static String getLocalString( final Message message, final String field, final PolicyValues policyValues )
     {
         try
         {
-            return Message.getLocalizedMessage( locale, message, config, field );
+            return Message.getLocalizedMessage( policyValues.getLocale(), message, policyValues.getConfig(), field );
         }
         catch ( MissingResourceException e )
         {

+ 8 - 61
server/src/main/java/password/pwm/ldap/LdapOperationsHelper.java

@@ -59,7 +59,7 @@ import password.pwm.svc.stats.Statistic;
 import password.pwm.svc.stats.StatisticsManager;
 import password.pwm.util.PasswordData;
 import password.pwm.util.i18n.LocaleHelper;
-import password.pwm.util.java.JsonUtil;
+import password.pwm.util.java.JavaHelper;
 import password.pwm.util.java.StringUtil;
 import password.pwm.util.java.TimeDuration;
 import password.pwm.util.logging.PwmLogger;
@@ -864,12 +864,18 @@ public class LdapOperationsHelper
     {
         final UserSearchEngine userSearchEngine = pwmApplication.getUserSearchEngine();
         final Queue<UserIdentity> resultSet = new LinkedList<>();
+        final long searchTimeoutMs = JavaHelper.silentParseLong(
+                pwmApplication.getConfig().readAppProperty( AppProperty.REPORTING_LDAP_SEARCH_TIMEOUT ),
+                30_000 );
 
         for ( final UserPermission userPermission : permissionList )
         {
             if ( resultSet.size() < maxResults )
             {
-                final SearchConfiguration searchConfiguration = SearchConfiguration.fromPermission( userPermission );
+                final SearchConfiguration searchConfiguration = SearchConfiguration.fromPermission( userPermission )
+                        .toBuilder()
+                        .searchTimeout( searchTimeoutMs )
+                        .build();
                 final Map<UserIdentity, Map<String, String>> searchResults = userSearchEngine.performMultiUserSearch(
                         searchConfiguration,
                         maxResults - resultSet.size(),
@@ -897,65 +903,6 @@ public class LdapOperationsHelper
         };
     }
 
-
-    public static Iterator<UserIdentity> readAllUsersFromLdap(
-            final PwmApplication pwmApplication,
-            final SessionLabel sessionLabel,
-            final String searchFilter,
-            final int maxResults
-    )
-            throws 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(),
-                sessionLabel
-
-        );
-        LOGGER.debug( sessionLabel, () -> "user search found " + searchResults.size() + " users" );
-
-        final Queue<UserIdentity> tempQueue = new LinkedList<>( searchResults.keySet() );
-
-        return new Iterator<UserIdentity>()
-        {
-            @Override
-            public boolean hasNext( )
-            {
-                return tempQueue.peek() != null;
-            }
-
-            @Override
-            public UserIdentity next( )
-            {
-                return tempQueue.poll();
-            }
-        };
-    }
-
     public static Instant readPasswordExpirationTime( final ChaiUser theUser )
     {
         try

+ 3 - 4
server/src/main/java/password/pwm/ldap/search/SearchConfiguration.java

@@ -22,7 +22,7 @@ package password.pwm.ldap.search;
 
 import com.novell.ldapchai.provider.ChaiProvider;
 import lombok.Builder;
-import lombok.Getter;
+import lombok.Value;
 import password.pwm.PwmConstants;
 import password.pwm.config.value.data.FormConfiguration;
 import password.pwm.config.value.data.UserPermission;
@@ -35,11 +35,10 @@ import java.util.Collections;
 import java.util.List;
 import java.util.Map;
 
-@Builder
-@Getter
+@Value
+@Builder( toBuilder = true )
 public class SearchConfiguration implements Serializable
 {
-
     private String filter;
     private String ldapProfile;
     private String username;

+ 1 - 1
server/src/main/java/password/pwm/svc/PwmServiceEnum.java

@@ -41,7 +41,7 @@ public enum PwmServiceEnum
     SharedHistoryManager( password.pwm.svc.wordlist.SharedHistoryManager.class ),
     AuditService( password.pwm.svc.event.AuditService.class ),
     StatisticsManager( password.pwm.svc.stats.StatisticsManager.class, Flag.StartDuringRuntimeInstance ),
-    WordlistManager( WordlistService.class ),
+    WordlistManager( WordlistService.class, Flag.StartDuringRuntimeInstance ),
     SeedlistManager( SeedlistService.class ),
     EmailQueueManager( EmailService.class ),
     SmsQueueManager( password.pwm.util.queue.SmsQueueManager.class ),

+ 60 - 56
server/src/main/java/password/pwm/svc/intruder/IntruderManager.java

@@ -104,7 +104,6 @@ public class IntruderManager implements PwmService
     }
 
     @Override
-    @SuppressWarnings( "checkstyle:MethodLength" )
     public void init( final PwmApplication pwmApplication )
             throws PwmException
     {
@@ -193,71 +192,76 @@ public class IntruderManager implements PwmService
 
         try
         {
+            initializeRecordManagers( config, recordStore );
+            status = STATUS.OPEN;
+        }
+        catch ( Exception e )
+        {
+            final ErrorInformation errorInformation = new ErrorInformation( PwmError.ERROR_SERVICE_NOT_AVAILABLE, "unexpected error starting intruder manager: " + e.getMessage() );
+            LOGGER.error( errorInformation.toDebugStr() );
+            startupError = errorInformation;
+            close();
+        }
+    }
+
+    private void initializeRecordManagers( final Configuration config, final RecordStore recordStore )
+    {
+        {
+            final IntruderSettings settings = new IntruderSettings();
+            settings.setCheckCount( ( int ) config.readSettingAsLong( PwmSetting.INTRUDER_USER_MAX_ATTEMPTS ) );
+            settings.setResetDuration( TimeDuration.of( config.readSettingAsLong( PwmSetting.INTRUDER_USER_RESET_TIME ), TimeDuration.Unit.SECONDS ) );
+            settings.setCheckDuration( TimeDuration.of( config.readSettingAsLong( PwmSetting.INTRUDER_USER_CHECK_TIME ), TimeDuration.Unit.SECONDS ) );
+            if ( settings.getCheckCount() == 0 || settings.getCheckDuration().asMillis() == 0 || settings.getResetDuration().asMillis() == 0 )
             {
-                final IntruderSettings settings = new IntruderSettings();
-                settings.setCheckCount( ( int ) config.readSettingAsLong( PwmSetting.INTRUDER_USER_MAX_ATTEMPTS ) );
-                settings.setResetDuration( TimeDuration.of( config.readSettingAsLong( PwmSetting.INTRUDER_USER_RESET_TIME ), TimeDuration.Unit.SECONDS ) );
-                settings.setCheckDuration( TimeDuration.of( config.readSettingAsLong( PwmSetting.INTRUDER_USER_CHECK_TIME ), TimeDuration.Unit.SECONDS ) );
-                if ( settings.getCheckCount() == 0 || settings.getCheckDuration().asMillis() == 0 || settings.getResetDuration().asMillis() == 0 )
-                {
-                    LOGGER.info( () -> "intruder user checking will remain disabled due to configuration settings" );
-                }
-                else
-                {
-                    recordManagers.put( RecordType.USERNAME, new RecordManagerImpl( RecordType.USERNAME, recordStore, settings ) );
-                    recordManagers.put( RecordType.USER_ID, new RecordManagerImpl( RecordType.USER_ID, recordStore, settings ) );
-                }
+                LOGGER.info( () -> "intruder user checking will remain disabled due to configuration settings" );
             }
+            else
             {
-                final IntruderSettings settings = new IntruderSettings();
-                settings.setCheckCount( ( int ) config.readSettingAsLong( PwmSetting.INTRUDER_ATTRIBUTE_MAX_ATTEMPTS ) );
-                settings.setResetDuration( TimeDuration.of( config.readSettingAsLong( PwmSetting.INTRUDER_ATTRIBUTE_RESET_TIME ), TimeDuration.Unit.MILLISECONDS ) );
-                settings.setCheckDuration( TimeDuration.of( config.readSettingAsLong( PwmSetting.INTRUDER_ATTRIBUTE_CHECK_TIME ), TimeDuration.Unit.MILLISECONDS ) );
-                if ( settings.getCheckCount() == 0 || settings.getCheckDuration().asMillis() == 0 || settings.getResetDuration().asMillis() == 0 )
-                {
-                    LOGGER.info( () -> "intruder user checking will remain disabled due to configuration settings" );
-                }
-                else
-                {
-                    recordManagers.put( RecordType.ATTRIBUTE, new RecordManagerImpl( RecordType.ATTRIBUTE, recordStore, settings ) );
-                }
+                recordManagers.put( RecordType.USERNAME, new RecordManagerImpl( RecordType.USERNAME, recordStore, settings ) );
+                recordManagers.put( RecordType.USER_ID, new RecordManagerImpl( RecordType.USER_ID, recordStore, settings ) );
             }
+        }
+        {
+            final IntruderSettings settings = new IntruderSettings();
+            settings.setCheckCount( ( int ) config.readSettingAsLong( PwmSetting.INTRUDER_ATTRIBUTE_MAX_ATTEMPTS ) );
+            settings.setResetDuration( TimeDuration.of( config.readSettingAsLong( PwmSetting.INTRUDER_ATTRIBUTE_RESET_TIME ), TimeDuration.Unit.MILLISECONDS ) );
+            settings.setCheckDuration( TimeDuration.of( config.readSettingAsLong( PwmSetting.INTRUDER_ATTRIBUTE_CHECK_TIME ), TimeDuration.Unit.MILLISECONDS ) );
+            if ( settings.getCheckCount() == 0 || settings.getCheckDuration().asMillis() == 0 || settings.getResetDuration().asMillis() == 0 )
             {
-                final IntruderSettings settings = new IntruderSettings();
-                settings.setCheckCount( ( int ) config.readSettingAsLong( PwmSetting.INTRUDER_TOKEN_DEST_MAX_ATTEMPTS ) );
-                settings.setResetDuration( TimeDuration.of( config.readSettingAsLong( PwmSetting.INTRUDER_TOKEN_DEST_RESET_TIME ), TimeDuration.Unit.SECONDS ) );
-                settings.setCheckDuration( TimeDuration.of( config.readSettingAsLong( PwmSetting.INTRUDER_TOKEN_DEST_CHECK_TIME ), TimeDuration.Unit.SECONDS ) );
-                if ( settings.getCheckCount() == 0 || settings.getCheckDuration().asMillis() == 0 || settings.getResetDuration().asMillis() == 0 )
-                {
-                    LOGGER.info( () -> "intruder user checking will remain disabled due to configuration settings" );
-                }
-                else
-                {
-                    recordManagers.put( RecordType.TOKEN_DEST, new RecordManagerImpl( RecordType.TOKEN_DEST, recordStore, settings ) );
-                }
+                LOGGER.info( () -> "intruder user checking will remain disabled due to configuration settings" );
             }
+            else
             {
-                final IntruderSettings settings = new IntruderSettings();
-                settings.setCheckCount( ( int ) config.readSettingAsLong( PwmSetting.INTRUDER_ADDRESS_MAX_ATTEMPTS ) );
-                settings.setResetDuration( TimeDuration.of( config.readSettingAsLong( PwmSetting.INTRUDER_ADDRESS_RESET_TIME ), TimeDuration.Unit.SECONDS ) );
-                settings.setCheckDuration( TimeDuration.of( config.readSettingAsLong( PwmSetting.INTRUDER_ADDRESS_CHECK_TIME ), TimeDuration.Unit.SECONDS ) );
-                if ( settings.getCheckCount() == 0 || settings.getCheckDuration().asMillis() == 0 || settings.getResetDuration().asMillis() == 0 )
-                {
-                    LOGGER.info( () -> "intruder address checking will remain disabled due to configuration settings" );
-                }
-                else
-                {
-                    recordManagers.put( RecordType.ADDRESS, new RecordManagerImpl( RecordType.ADDRESS, recordStore, settings ) );
-                }
+                recordManagers.put( RecordType.ATTRIBUTE, new RecordManagerImpl( RecordType.ATTRIBUTE, recordStore, settings ) );
             }
-            status = STATUS.OPEN;
         }
-        catch ( Exception e )
         {
-            final ErrorInformation errorInformation = new ErrorInformation( PwmError.ERROR_SERVICE_NOT_AVAILABLE, "unexpected error starting intruder manager: " + e.getMessage() );
-            LOGGER.error( errorInformation.toDebugStr() );
-            startupError = errorInformation;
-            close();
+            final IntruderSettings settings = new IntruderSettings();
+            settings.setCheckCount( ( int ) config.readSettingAsLong( PwmSetting.INTRUDER_TOKEN_DEST_MAX_ATTEMPTS ) );
+            settings.setResetDuration( TimeDuration.of( config.readSettingAsLong( PwmSetting.INTRUDER_TOKEN_DEST_RESET_TIME ), TimeDuration.Unit.SECONDS ) );
+            settings.setCheckDuration( TimeDuration.of( config.readSettingAsLong( PwmSetting.INTRUDER_TOKEN_DEST_CHECK_TIME ), TimeDuration.Unit.SECONDS ) );
+            if ( settings.getCheckCount() == 0 || settings.getCheckDuration().asMillis() == 0 || settings.getResetDuration().asMillis() == 0 )
+            {
+                LOGGER.info( () -> "intruder user checking will remain disabled due to configuration settings" );
+            }
+            else
+            {
+                recordManagers.put( RecordType.TOKEN_DEST, new RecordManagerImpl( RecordType.TOKEN_DEST, recordStore, settings ) );
+            }
+        }
+        {
+            final IntruderSettings settings = new IntruderSettings();
+            settings.setCheckCount( ( int ) config.readSettingAsLong( PwmSetting.INTRUDER_ADDRESS_MAX_ATTEMPTS ) );
+            settings.setResetDuration( TimeDuration.of( config.readSettingAsLong( PwmSetting.INTRUDER_ADDRESS_RESET_TIME ), TimeDuration.Unit.SECONDS ) );
+            settings.setCheckDuration( TimeDuration.of( config.readSettingAsLong( PwmSetting.INTRUDER_ADDRESS_CHECK_TIME ), TimeDuration.Unit.SECONDS ) );
+            if ( settings.getCheckCount() == 0 || settings.getCheckDuration().asMillis() == 0 || settings.getResetDuration().asMillis() == 0 )
+            {
+                LOGGER.info( () -> "intruder address checking will remain disabled due to configuration settings" );
+            }
+            else
+            {
+                recordManagers.put( RecordType.ADDRESS, new RecordManagerImpl( RecordType.ADDRESS, recordStore, settings ) );
+            }
         }
     }
 

+ 1 - 1
server/src/main/java/password/pwm/svc/report/ReportCsvUtility.java

@@ -187,7 +187,7 @@ public class ReportCsvUtility
         csvPrinter.printRecord( csvRow );
     }
 
-    public ReportService.RecordIterator<UserCacheRecord> iterator( )
+    public ClosableIterator<UserCacheRecord> iterator( )
     {
         return reportService.iterator();
     }

+ 146 - 130
server/src/main/java/password/pwm/svc/report/ReportService.java

@@ -20,8 +20,6 @@
 
 package password.pwm.svc.report;
 
-import com.novell.ldapchai.exception.ChaiOperationException;
-import com.novell.ldapchai.exception.ChaiUnavailableException;
 import password.pwm.PwmApplication;
 import password.pwm.PwmApplicationMode;
 import password.pwm.bean.SessionLabel;
@@ -41,6 +39,7 @@ import password.pwm.svc.PwmService;
 import password.pwm.util.EventRateMeter;
 import password.pwm.util.PwmScheduler;
 import password.pwm.util.TransactionSizeCalculator;
+import password.pwm.util.java.AverageTracker;
 import password.pwm.util.java.BlockingThreadPool;
 import password.pwm.util.java.ClosableIterator;
 import password.pwm.util.java.JavaHelper;
@@ -52,29 +51,28 @@ import password.pwm.util.localdb.LocalDBStoredQueue;
 import password.pwm.util.logging.PwmLogger;
 
 import java.math.BigDecimal;
-import java.math.BigInteger;
-import java.math.MathContext;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Iterator;
-import java.util.LinkedList;
 import java.util.List;
+import java.util.Objects;
 import java.util.Queue;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.concurrent.locks.Lock;
 import java.util.concurrent.locks.ReentrantLock;
 
+
 public class ReportService implements PwmService
 {
     private static final PwmLogger LOGGER = PwmLogger.forClass( ReportService.class );
 
-    private final AvgTracker avgTracker = new AvgTracker( 100 );
+    private final AverageTracker avgTracker = new AverageTracker( 100 );
 
     private PwmApplication pwmApplication;
     private STATUS status = STATUS.NEW;
-    private boolean cancelFlag = false;
-    private ReportStatusInfo reportStatus = ReportStatusInfo.builder().build();
+    private volatile boolean cancelFlag = false;
     private ReportSummaryData summaryData = ReportSummaryData.newSummaryData( null );
     private ExecutorService executorService;
 
@@ -83,7 +81,8 @@ public class ReportService implements PwmService
 
     private Queue<String> dnQueue;
 
-    private final EventRateMeter eventRateMeter = new EventRateMeter( TimeDuration.MINUTE );
+    private final AtomicReference<ReportStatusInfo> reportStatus = new AtomicReference<>( ReportStatusInfo.builder().build() );
+    private final EventRateMeter processRateMeter = new EventRateMeter( TimeDuration.MINUTE );
 
 
     public ReportService( )
@@ -143,8 +142,6 @@ public class ReportService implements PwmService
 
         executorService = PwmScheduler.makeBackgroundExecutor( pwmApplication, this.getClass() );
 
-        LOGGER.debug( () -> "report service started" );
-
         executorService.submit( new InitializationTask() );
 
         status = STATUS.OPEN;
@@ -153,6 +150,7 @@ public class ReportService implements PwmService
     @Override
     public void close( )
     {
+        status = STATUS.CLOSED;
         cancelFlag = true;
 
         JavaHelper.closeAndWaitExecutor( executorService, TimeDuration.SECONDS_10 );
@@ -162,16 +160,15 @@ public class ReportService implements PwmService
             userCacheService.close();
         }
 
-        status = STATUS.CLOSED;
         executorService = null;
-        saveTempData();
+        writeReportStatus();
     }
 
-    private void saveTempData( )
+    private void writeReportStatus( )
     {
         try
         {
-            pwmApplication.writeAppAttribute( PwmApplication.AppAttribute.REPORT_STATUS, reportStatus );
+            pwmApplication.writeAppAttribute( PwmApplication.AppAttribute.REPORT_STATUS, reportStatus.get() );
         }
         catch ( Exception e )
         {
@@ -198,13 +195,13 @@ public class ReportService implements PwmService
         {
             case Start:
             {
-                if ( reportStatus.getCurrentProcess() != ReportStatusInfo.ReportEngineProcess.ReadData
-                        && reportStatus.getCurrentProcess() != ReportStatusInfo.ReportEngineProcess.SearchLDAP
+                final ReportStatusInfo localReportStatus = reportStatus.get();
+                if ( localReportStatus.getCurrentProcess() != ReportStatusInfo.ReportEngineProcess.ReadData
+                        && localReportStatus.getCurrentProcess() != ReportStatusInfo.ReportEngineProcess.SearchLDAP
                 )
                 {
                     executorService.execute( new ClearTask() );
                     executorService.execute( new ReadLDAPTask() );
-                    LOGGER.trace( SessionLabel.REPORTING_SESSION_LABEL, () -> "submitted new ldap dredge task to executorService" );
                 }
             }
             break;
@@ -228,14 +225,9 @@ public class ReportService implements PwmService
         }
     }
 
-    PwmApplication getPwmApplication( )
-    {
-        return pwmApplication;
-    }
-
     public BigDecimal getEventRate( )
     {
-        return eventRateMeter.readEventRate();
+        return processRateMeter.readEventRate();
     }
 
     public long getTotalRecords( )
@@ -245,38 +237,39 @@ public class ReportService implements PwmService
 
     private void clearWorkQueue( )
     {
-        reportStatus.setCount( 0 );
-        reportStatus.setJobDuration( TimeDuration.ZERO );
+        reportStatus.updateAndGet( reportStatusInfo -> reportStatusInfo.toBuilder()
+                .count( 0 )
+                .jobDuration( TimeDuration.ZERO )
+                .build() );
+
         dnQueue.clear();
     }
 
     private void resetJobStatus( )
     {
         cancelFlag = false;
-        eventRateMeter.reset();
-
-        reportStatus.setLastError( null );
-        reportStatus.setErrors( 0 );
+        processRateMeter.reset();
 
-        reportStatus.setFinishDate( null );
+        reportStatus.updateAndGet( reportStatusInfo -> reportStatusInfo.toBuilder()
+                .lastError( null )
+                .errors( 0 )
+                .finishDate( null )
+                .reportComplete( false )
+                .build() );
 
-        pwmApplication.writeAppAttribute( PwmApplication.AppAttribute.REPORT_STATUS, null );
+        writeReportStatus();
     }
 
 
     public ReportStatusInfo getReportStatusInfo( )
     {
-        return reportStatus;
+        return reportStatus.get();
     }
 
 
-    public interface RecordIterator<K> extends ClosableIterator<UserCacheRecord>
-    {
-    }
-
-    public RecordIterator<UserCacheRecord> iterator( )
+    public ClosableIterator<UserCacheRecord> iterator( )
     {
-        return new RecordIterator<UserCacheRecord>()
+        return new ClosableIterator<UserCacheRecord>()
         {
             private UserCacheService.UserStatusCacheBeanIterator<UserCacheService.StorageKey> storageKeyIterator = userCacheService.iterator();
 
@@ -334,53 +327,14 @@ public class ReportService implements PwmService
         return dnQueue.size();
     }
 
-    public static class AvgTracker
-    {
-        private final int maxSamples;
-        private final Queue<BigInteger> samples = new LinkedList<>();
-
-        public AvgTracker( final int maxSamples )
-        {
-            this.maxSamples = maxSamples;
-        }
-
-        public void addSample( final long input )
-        {
-            samples.add( new BigInteger( Long.toString( input ) ) );
-            while ( samples.size() > maxSamples )
-            {
-                samples.remove();
-            }
-        }
-
-        public BigDecimal avg( )
-        {
-            if ( samples.isEmpty() )
-            {
-                throw new IllegalStateException( "unable to compute avg without samples" );
-            }
-
-            BigInteger total = BigInteger.ZERO;
-            for ( final BigInteger sample : samples )
-            {
-                total = total.add( sample );
-            }
-            final BigDecimal maxAsBD = new BigDecimal( Integer.toString( maxSamples ) );
-            return new BigDecimal( total ).divide( maxAsBD, MathContext.DECIMAL32 );
-        }
-
-        public long avgAsLong( )
-        {
-            return avg().longValue();
-        }
-    }
-
     private class ReadLDAPTask implements Runnable
     {
         @Override
         public void run( )
         {
-            reportStatus.setCurrentProcess( ReportStatusInfo.ReportEngineProcess.SearchLDAP );
+            reportStatus.updateAndGet( reportStatusInfo -> reportStatusInfo.toBuilder()
+                    .currentProcess( ReportStatusInfo.ReportEngineProcess.SearchLDAP )
+                    .build() );
             try
             {
                 readUserListFromLdap();
@@ -409,15 +363,15 @@ public class ReportService implements PwmService
             }
             finally
             {
-                reportStatus.setCurrentProcess( ReportStatusInfo.ReportEngineProcess.None );
+                resetCurrentProcess();
             }
         }
 
         private void readUserListFromLdap( )
-                throws ChaiUnavailableException, ChaiOperationException, PwmUnrecoverableException, PwmOperationalException
+                throws PwmUnrecoverableException, PwmOperationalException
         {
             final Instant startTime = Instant.now();
-            LOGGER.trace( () -> "beginning ldap search process" );
+            LOGGER.trace( SessionLabel.REPORTING_SESSION_LABEL, () -> "beginning ldap search process" );
 
             resetJobStatus();
             clearWorkQueue();
@@ -429,8 +383,15 @@ public class ReportService implements PwmService
                     settings.getMaxSearchSize()
             );
 
+            LOGGER.trace( SessionLabel.REPORTING_SESSION_LABEL, () -> "completed ldap search process (" + TimeDuration.compactFromCurrent( startTime ) + ")" );
+
+            writeUsersToLocalDBQueue( memQueue );
+        }
 
-            LOGGER.trace( () -> "completed ldap search process, transferring search results to work queue" );
+        private void writeUsersToLocalDBQueue( final Iterator<UserIdentity> identityQueue )
+        {
+            final Instant startTime = Instant.now();
+            LOGGER.trace( SessionLabel.REPORTING_SESSION_LABEL, () -> "transferring search results to work queue" );
 
             final TransactionSizeCalculator transactionCalculator = new TransactionSizeCalculator(
                     TransactionSizeCalculator.Settings.builder()
@@ -440,19 +401,20 @@ public class ReportService implements PwmService
                             .build()
             );
 
-            while ( status == STATUS.OPEN && !cancelFlag && memQueue.hasNext() )
+            while ( !cancelFlag && identityQueue.hasNext() )
             {
                 final Instant loopStart = Instant.now();
                 final List<String> bufferList = new ArrayList<>();
                 final int loopCount = transactionCalculator.getTransactionSize();
-                for ( int i = 0; i < loopCount && memQueue.hasNext(); i++ )
+                while ( !cancelFlag && identityQueue.hasNext() && bufferList.size() < loopCount )
                 {
-                    bufferList.add( memQueue.next().toDelimitedKey() );
+                    bufferList.add( identityQueue.next().toDelimitedKey() );
                 }
                 dnQueue.addAll( bufferList );
                 transactionCalculator.recordLastTransactionDuration( TimeDuration.fromCurrent( loopStart ) );
             }
-            LOGGER.trace( () -> "completed transfer of ldap search results to work queue in " + TimeDuration.fromCurrent( startTime ).asCompactString() );
+            LOGGER.trace( SessionLabel.REPORTING_SESSION_LABEL,
+                    () -> "completed transfer of ldap search results to work queue in " + TimeDuration.compactFromCurrent( startTime ) );
         }
     }
 
@@ -461,32 +423,38 @@ public class ReportService implements PwmService
         @Override
         public void run( )
         {
-            reportStatus.setCurrentProcess( ReportStatusInfo.ReportEngineProcess.ReadData );
+            reportStatus.updateAndGet( reportStatusInfo -> reportStatusInfo.toBuilder()
+                    .currentProcess( ReportStatusInfo.ReportEngineProcess.ReadData )
+                    .build() );
             try
             {
                 processWorkQueue();
+                if ( status == STATUS.OPEN )
+                {
+                    reportStatus.updateAndGet( reportStatusInfo -> reportStatusInfo.toBuilder()
+                            .reportComplete( true )
+                            .build() );
+                    writeReportStatus();
+                }
             }
-            catch ( Exception e )
+            catch ( PwmException e )
             {
-                if ( e instanceof PwmException )
+                if ( e.getErrorInformation().getError() == PwmError.ERROR_DIRECTORY_UNAVAILABLE )
                 {
-                    if ( ( ( PwmException ) e ).getErrorInformation().getError() == PwmError.ERROR_DIRECTORY_UNAVAILABLE )
+                    if ( executorService != null )
                     {
-                        if ( executorService != null )
-                        {
-                            LOGGER.error( SessionLabel.REPORTING_SESSION_LABEL, "directory unavailable error during background ReadData, will retry; error: " + e.getMessage() );
-                            pwmApplication.getPwmScheduler().scheduleJob( new ProcessWorkQueueTask(), executorService, TimeDuration.of( 10, TimeDuration.Unit.MINUTES ) );
-                        }
-                    }
-                    else
-                    {
-                        LOGGER.error( SessionLabel.REPORTING_SESSION_LABEL, "error during background ReadData: " + e.getMessage() );
+                        LOGGER.error( SessionLabel.REPORTING_SESSION_LABEL, "directory unavailable error during background ReadData, will retry; error: " + e.getMessage() );
+                        pwmApplication.getPwmScheduler().scheduleJob( new ProcessWorkQueueTask(), executorService, TimeDuration.of( 10, TimeDuration.Unit.MINUTES ) );
                     }
                 }
+                else
+                {
+                    LOGGER.error( SessionLabel.REPORTING_SESSION_LABEL, "error during background ReadData: " + e.getMessage() );
+                }
             }
             finally
             {
-                reportStatus.setCurrentProcess( ReportStatusInfo.ReportEngineProcess.None );
+                resetCurrentProcess();
             }
         }
 
@@ -537,8 +505,9 @@ public class ReportService implements PwmService
                         {
                             final Instant startUpdateTime = Instant.now();
                             updateCachedRecordFromLdap( userIdentity );
-                            reportStatus.setCount( reportStatus.getCount() + 1 );
-                            eventRateMeter.markEvents( 1 );
+                            reportStatus.updateAndGet( reportStatusInfo -> reportStatusInfo.toBuilder()
+                                    .count( reportStatusInfo.getCount() + 1 )
+                                    .build() );
                             final TimeDuration totalUpdateTime = TimeDuration.fromCurrent( startUpdateTime );
                             avgTracker.addSample( totalUpdateTime.asMillis() );
 
@@ -546,7 +515,9 @@ public class ReportService implements PwmService
                             {
                                 updateTimeLock.lock();
                                 final TimeDuration scaledTime = TimeDuration.of( totalUpdateTime.asMillis() / threadCount, TimeDuration.Unit.MILLISECONDS );
-                                reportStatus.setJobDuration( reportStatus.getJobDuration().add( scaledTime ) );
+                                reportStatus.updateAndGet( reportStatusInfo -> reportStatusInfo.toBuilder()
+                                        .jobDuration( reportStatusInfo.getJobDuration().add( scaledTime ) )
+                                        .build() );
                             }
                             finally
                             {
@@ -565,8 +536,10 @@ public class ReportService implements PwmService
                             final ErrorInformation errorInformation;
                             errorInformation = new ErrorInformation( PwmError.ERROR_REPORTING_ERROR, errorMsg );
                             LOGGER.error( SessionLabel.REPORTING_SESSION_LABEL, errorInformation.toDebugStr(), e );
-                            reportStatus.setLastError( errorInformation );
-                            reportStatus.setErrors( reportStatus.getErrors() + 1 );
+                            reportStatus.updateAndGet( reportStatusInfo -> reportStatusInfo.toBuilder()
+                                    .lastError( errorInformation )
+                                    .errors( reportStatusInfo.getErrors() + 1 )
+                                    .build() );
                         }
                         if ( pwmApplication.getConfig().isDevDebugMode() )
                         {
@@ -585,13 +558,20 @@ public class ReportService implements PwmService
 
                 if ( cancelFlag )
                 {
-                    reportStatus.setLastError( new ErrorInformation( PwmError.ERROR_SERVICE_NOT_AVAILABLE, "report cancelled by operator" ) );
+
+                    final ErrorInformation errorInformation = new ErrorInformation(
+                            PwmError.ERROR_SERVICE_NOT_AVAILABLE, "report cancelled by operator" );
+                    reportStatus.updateAndGet( reportStatusInfo -> reportStatusInfo.toBuilder()
+                            .lastError( errorInformation )
+                            .build() );
                 }
             }
             finally
             {
-                reportStatus.setFinishDate( Instant.now() );
-                saveTempData();
+                reportStatus.updateAndGet( reportStatusInfo -> reportStatusInfo.toBuilder()
+                        .finishDate( Instant.now() )
+                        .build() );
+                writeReportStatus();
             }
             LOGGER.debug( SessionLabel.REPORTING_SESSION_LABEL, () -> "update user cache process completed: " + JsonUtil.serialize( reportStatus ) );
         }
@@ -614,6 +594,7 @@ public class ReportService implements PwmService
 
             userCacheService.store( newUserCacheRecord );
             summaryData.update( newUserCacheRecord );
+            processRateMeter.markEvents( 1 );
 
             LOGGER.trace( SessionLabel.REPORTING_SESSION_LABEL, () -> "stored cache for " + userIdentity );
         }
@@ -632,16 +613,6 @@ public class ReportService implements PwmService
                 executorService.execute( new ReadLDAPTask() );
             }
         }
-
-        private void checkForOutdatedStoreData()
-        {
-            final Instant lastFinishDate = reportStatus.getFinishDate();
-            if ( lastFinishDate != null && TimeDuration.fromCurrent( lastFinishDate ).isLongerThan( settings.getMaxCacheAge() ) )
-            {
-                executorService.execute( new ClearTask() );
-            }
-        }
-
     }
 
     private class InitializationTask implements Runnable
@@ -652,8 +623,9 @@ public class ReportService implements PwmService
             try
             {
                 initTempData();
+                LOGGER.debug( SessionLabel.REPORTING_SESSION_LABEL, () -> "report service initialized: " + JsonUtil.serialize( reportStatus.get() ) );
             }
-            catch ( LocalDBException | PwmUnrecoverableException e )
+            catch ( PwmUnrecoverableException e )
             {
                 LOGGER.error( SessionLabel.REPORTING_SESSION_LABEL, "error during initialization: " + e.getMessage() );
                 status = STATUS.CLOSED;
@@ -668,13 +640,13 @@ public class ReportService implements PwmService
             }
         }
 
-
         private void initTempData( )
-                throws LocalDBException, PwmUnrecoverableException
+                throws PwmUnrecoverableException
         {
             try
             {
-                reportStatus = pwmApplication.readAppAttribute( PwmApplication.AppAttribute.REPORT_STATUS, ReportStatusInfo.class );
+                final ReportStatusInfo localReportStatus = pwmApplication.readAppAttribute( PwmApplication.AppAttribute.REPORT_STATUS, ReportStatusInfo.class );
+                reportStatus.set( localReportStatus );
             }
             catch ( Exception e )
             {
@@ -682,15 +654,14 @@ public class ReportService implements PwmService
             }
 
             boolean clearFlag = false;
-            if ( reportStatus == null )
+            if ( reportStatus.get() == null )
             {
                 clearFlag = true;
                 LOGGER.debug( SessionLabel.REPORTING_SESSION_LABEL, () -> "report service did not close cleanly, will clear data." );
             }
             else
             {
-                final String currentSettingCache = settings.getSettingsHash();
-                if ( reportStatus.getSettingsHash() != null && !reportStatus.getSettingsHash().equals( currentSettingCache ) )
+                if ( !Objects.equals( reportStatus.get().getSettingsHash(), settings.getSettingsHash() ) )
                 {
                     LOGGER.error( SessionLabel.REPORTING_SESSION_LABEL, "configuration has changed, will clear cached report data" );
                     clearFlag = true;
@@ -699,9 +670,22 @@ public class ReportService implements PwmService
 
             if ( clearFlag )
             {
-                reportStatus = ReportStatusInfo.builder().settingsHash( settings.getSettingsHash() ).build();
+                initReportStatus();
                 executeCommand( ReportCommand.Clear );
             }
+
+            startNextTask();
+        }
+
+        private void startNextTask()
+        {
+            checkForOutdatedStoreData();
+
+            if ( !reportStatus.get().isReportComplete() && !dnQueue.isEmpty() )
+            {
+                LOGGER.trace( SessionLabel.REPORTING_SESSION_LABEL, () -> "resuming report data processing" );
+                executorService.execute( new ProcessWorkQueueTask() );
+            }
         }
     }
 
@@ -718,6 +702,10 @@ public class ReportService implements PwmService
             {
                 LOGGER.error( SessionLabel.REPORTING_SESSION_LABEL, "error during clear operation: " + e.getMessage() );
             }
+            finally
+            {
+                resetCurrentProcess();
+            }
         }
 
         private void doClear( ) throws LocalDBException, PwmUnrecoverableException
@@ -730,8 +718,36 @@ public class ReportService implements PwmService
                 userCacheService.clear();
             }
             summaryData = ReportSummaryData.newSummaryData( settings.getTrackDays() );
-            reportStatus = ReportStatusInfo.builder().settingsHash( settings.getSettingsHash() ).build();
+            initReportStatus();
             LOGGER.debug( SessionLabel.REPORTING_SESSION_LABEL, () -> "finished clearing report " + TimeDuration.compactFromCurrent( startTime ) );
         }
     }
+
+    private void initReportStatus()
+            throws PwmUnrecoverableException
+    {
+        final String settingsHash = settings.getSettingsHash();
+        reportStatus.set( ReportStatusInfo.builder()
+                .settingsHash( settingsHash )
+                .currentProcess( ReportStatusInfo.ReportEngineProcess.None )
+                .build() );
+        writeReportStatus();
+    }
+
+    private void resetCurrentProcess()
+    {
+        reportStatus.updateAndGet( reportStatusInfo -> reportStatusInfo.toBuilder()
+                .currentProcess( ReportStatusInfo.ReportEngineProcess.None )
+                .build() );
+        writeReportStatus();
+    }
+
+    private void checkForOutdatedStoreData()
+    {
+        final Instant lastFinishDate = reportStatus.get().getFinishDate();
+        if ( lastFinishDate != null && TimeDuration.fromCurrent( lastFinishDate ).isLongerThan( settings.getMaxCacheAge() ) )
+        {
+            executorService.execute( new ClearTask() );
+        }
+    }
 }

+ 14 - 6
server/src/main/java/password/pwm/svc/report/ReportSettings.java

@@ -74,7 +74,7 @@ class ReportSettings implements Serializable
         HIGH,
     }
 
-    public static ReportSettings readSettingsFromConfig( final Configuration config )
+    static ReportSettings readSettingsFromConfig( final Configuration config )
     {
         final ReportSettings.ReportSettingsBuilder builder = ReportSettings.builder();
         builder.maxCacheAge( TimeDuration.of( Long.parseLong( config.readAppProperty( AppProperty.REPORTING_MAX_REPORT_AGE_SECONDS ) ), TimeDuration.Unit.SECONDS ) );
@@ -82,15 +82,23 @@ class ReportSettings implements Serializable
         builder.maxSearchSize ( ( int ) config.readSettingAsLong( PwmSetting.REPORTING_MAX_QUERY_SIZE ) );
         builder.dailyJobEnabled( config.readSettingAsBoolean( PwmSetting.REPORTING_ENABLE_DAILY_JOB ) );
 
-        if ( builder.searchFilter == null || builder.searchFilter.isEmpty() )
         {
-            builder.searchFilter = null;
+            final List<UserPermission> userMatches = config.readSettingAsUserPermission( PwmSetting.REPORTING_USER_MATCH );
+            builder.searchFilter(
+                    userMatches == null || userMatches.isEmpty()
+                            ? null
+                            : userMatches
+            );
         }
 
-        builder.jobOffsetSeconds = ( int ) config.readSettingAsLong( PwmSetting.REPORTING_JOB_TIME_OFFSET );
-        if ( builder.jobOffsetSeconds > 60 * 60 * 24 )
         {
-            builder.jobOffsetSeconds = 0;
+            int reportJobOffset = ( int ) config.readSettingAsLong( PwmSetting.REPORTING_JOB_TIME_OFFSET );
+            if ( reportJobOffset > 60 * 60 * 24 )
+            {
+                reportJobOffset = 0;
+            }
+
+            builder.jobOffsetSeconds( reportJobOffset );
         }
 
         builder.trackDays( parseDayIntervalStr( config ) );

+ 3 - 2
server/src/main/java/password/pwm/svc/report/ReportStatusInfo.java

@@ -21,14 +21,14 @@
 package password.pwm.svc.report;
 
 import lombok.Builder;
-import lombok.Data;
+import lombok.Value;
 import password.pwm.error.ErrorInformation;
 import password.pwm.util.java.TimeDuration;
 
 import java.io.Serializable;
 import java.time.Instant;
 
-@Data
+@Value
 @Builder( toBuilder = true )
 public class ReportStatusInfo implements Serializable
 {
@@ -37,6 +37,7 @@ public class ReportStatusInfo implements Serializable
 
     private Instant startDate;
     private Instant finishDate;
+    private boolean reportComplete;
     private int count;
     private int errors;
     private ErrorInformation lastError;

+ 162 - 58
server/src/main/java/password/pwm/svc/wordlist/AbstractWordlist.java

@@ -35,15 +35,18 @@ import password.pwm.health.HealthTopic;
 import password.pwm.svc.PwmService;
 import password.pwm.util.PwmScheduler;
 import password.pwm.util.java.JavaHelper;
+import password.pwm.util.java.JsonUtil;
 import password.pwm.util.java.TimeDuration;
-import password.pwm.util.localdb.LocalDBException;
 import password.pwm.util.logging.PwmLogger;
 
 import java.io.InputStream;
 import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
+import java.util.Optional;
+import java.util.Set;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.function.BooleanSupplier;
@@ -51,10 +54,12 @@ import java.util.function.BooleanSupplier;
 abstract class AbstractWordlist implements Wordlist, PwmService
 {
     static final TimeDuration DEBUG_OUTPUT_FREQUENCY = TimeDuration.MINUTE;
+    private static final TimeDuration BUCKECT_CHECK_LOG_WARNING_TIMEOUT = TimeDuration.of( 100, TimeDuration.Unit.MILLISECONDS );
 
     private WordlistConfiguration wordlistConfiguration;
-    private WordlistBucket wordklistBucket;
+    private WordlistBucket wordlistBucket;
     private ExecutorService executorService;
+    private Set<WordType> wordTypesCache = null;
 
     private volatile STATUS wlStatus = STATUS.NEW;
 
@@ -64,6 +69,7 @@ abstract class AbstractWordlist implements Wordlist, PwmService
     private PwmApplication pwmApplication;
     private final AtomicBoolean inhibitBackgroundImportFlag = new AtomicBoolean( false );
     private final AtomicBoolean backgroundImportRunning = new AtomicBoolean( false );
+    private final WordlistStatistics statistics = new WordlistStatistics();
 
     private volatile Activity activity = Wordlist.Activity.Idle;
 
@@ -71,7 +77,7 @@ abstract class AbstractWordlist implements Wordlist, PwmService
     {
     }
 
-    public void init(
+    void init(
             final PwmApplication pwmApplication,
             final WordlistType type
     )
@@ -80,43 +86,134 @@ abstract class AbstractWordlist implements Wordlist, PwmService
         this.pwmApplication = pwmApplication;
         this.wordlistConfiguration = WordlistConfiguration.fromConfiguration( pwmApplication.getConfig(), type );
 
-        if ( pwmApplication.getApplicationMode() != PwmApplicationMode.RUNNING
-                || pwmApplication.getLocalDB() == null
-        )
+        if ( this.wordlistConfiguration.isTestMode() )
         {
-            wlStatus = STATUS.CLOSED;
+            startTestInstance( type );
             return;
         }
+        else
+        {
+            if ( pwmApplication.getApplicationMode() != PwmApplicationMode.RUNNING
+                    || pwmApplication.getLocalDB() == null
+            )
+            {
+                wlStatus = STATUS.CLOSED;
+                return;
+            }
+
+            if ( pwmApplication.getLocalDB() != null )
+            {
+                wlStatus = STATUS.OPEN;
+            }
+            else
+            {
+                wlStatus = STATUS.CLOSED;
+                final String errorMsg = "LocalDB is not available, will remain closed";
+                getLogger().warn( errorMsg );
+                lastError = new ErrorInformation( PwmError.ERROR_SERVICE_NOT_AVAILABLE, errorMsg );
+            }
 
-        this.wordklistBucket = new WordlistBucket( pwmApplication, wordlistConfiguration, type );
+            this.wordlistBucket = new LocalDBWordlistBucket( pwmApplication, wordlistConfiguration, type );
+        }
 
         executorService = PwmScheduler.makeBackgroundExecutor( pwmApplication, this.getClass() );
 
-        if ( pwmApplication.getLocalDB() != null )
+        if ( !pwmApplication.getPwmEnvironment().isInternalRuntimeInstance() )
         {
-            wlStatus = STATUS.OPEN;
+            pwmApplication.getPwmScheduler().scheduleFixedRateJob( new InspectorJob(), executorService, TimeDuration.SECOND, wordlistConfiguration.getInspectorFrequency() );
         }
-        else
+
+        getLogger().trace( () -> "opening with configuration: " + JsonUtil.serialize( wordlistConfiguration ) );
+    }
+
+    private void startTestInstance( final WordlistType wordlistType )
+    {
+        this.wordlistBucket = new MemoryWordlistBucket( pwmApplication, wordlistConfiguration, wordlistType );
+        final WordlistInspector wordlistInspector = new WordlistInspector( pwmApplication, AbstractWordlist.this, () -> false );
+        wordlistInspector.run();
+    }
+
+    boolean containsWord( final Set<WordType> wordTypes, final String word ) throws PwmUnrecoverableException
+    {
+        final Optional<String> testWord = WordlistUtil.normalizeWordLength( word, wordlistConfiguration );
+
+        if ( !testWord.isPresent() )
         {
-            wlStatus = STATUS.CLOSED;
-            final String errorMsg = "LocalDB is not available, will remain closed";
-            getLogger().warn( errorMsg );
-            lastError = new ErrorInformation( PwmError.ERROR_SERVICE_NOT_AVAILABLE, errorMsg );
+            return false;
         }
 
-        pwmApplication.getPwmScheduler().scheduleFixedRateJob( new InspectorJob(), executorService, TimeDuration.SECOND, wordlistConfiguration.getInspectorFrequency() );
+        boolean result = false;
+        for ( final WordType wordType : wordTypes )
+        {
+            if ( !result )
+            {
+                if ( wordType == WordType.RAW )
+                {
+                    result = checkRawWords( testWord.get() );
+                }
+                else
+                {
+                    result = checkHashWords( wordType, testWord.get() );
+                }
+            }
+        }
+
+        return result;
     }
 
-    boolean containsWord( final String word ) throws PwmUnrecoverableException
+    private boolean checkHashWords( final WordType wordType, final String word )
+            throws PwmUnrecoverableException
     {
-        try
+        final String hashWord = wordType.convertInputFromUser( pwmApplication, wordlistConfiguration, word );
+        return realBucketCheck( hashWord, wordType );
+    }
+
+    private boolean checkRawWords( final String word )
+            throws PwmUnrecoverableException
+    {
+        final String normalizedWord = WordType.RAW.convertInputFromUser( pwmApplication, wordlistConfiguration, word );
+        final Set<String> testWords = WordlistUtil.chunkWord( normalizedWord, this.wordlistConfiguration.getCheckSize() );
+
+        getStatistics().getChunksPerWordCheck().update( testWords.size() );
+        for ( final String t : testWords )
         {
-            return wordklistBucket.containsWord( word );
+
+            // stop checking once found
+            if ( realBucketCheck( t, WordType.RAW ) )
+            {
+                return true;
+            }
         }
-        catch ( LocalDBException e )
+
+        return false;
+    }
+
+    private boolean realBucketCheck( final String word, final WordType wordType )
+            throws PwmUnrecoverableException
+    {
+        getStatistics().getWordChecks().incrementAndGet();
+
+        final Instant startTime = Instant.now();
+        final boolean isContainsWord = wordlistBucket.containsWord( word );
+
+        final TimeDuration timeDuration = TimeDuration.fromCurrent( startTime );
+        getStatistics().getWordCheckTimeMS().update( timeDuration.asMillis() );
+
+        if ( timeDuration.isLongerThan( BUCKECT_CHECK_LOG_WARNING_TIMEOUT ) )
+        {
+            getLogger().debug( () -> "wordlist search time for wordlist permutations was greater then 100ms: " + timeDuration.asCompactString() );
+        }
+
+        if ( isContainsWord )
         {
-            throw PwmUnrecoverableException.newException( PwmError.ERROR_LOCALDB_UNAVAILABLE, e.getMessage() );
+            getStatistics().getWordTypeHits().get( wordType ).incrementAndGet();
         }
+        else
+        {
+            getStatistics().getMisses().incrementAndGet();
+        }
+
+        return isContainsWord;
     }
 
     String randomSeed() throws PwmUnrecoverableException
@@ -145,9 +242,9 @@ abstract class AbstractWordlist implements Wordlist, PwmService
 
         try
         {
-            return wordklistBucket.size();
+            return wordlistBucket.size();
         }
-        catch ( LocalDBException e )
+        catch ( PwmUnrecoverableException e )
         {
             getLogger().error( "error reading size: " + e.getMessage() );
         }
@@ -200,18 +297,6 @@ abstract class AbstractWordlist implements Wordlist, PwmService
         return Collections.unmodifiableList( returnList );
     }
 
-    public ServiceInfoBean serviceInfo( )
-    {
-        if ( status() == STATUS.OPEN )
-        {
-            return new ServiceInfoBean( Collections.singletonList( DataStorageMethod.LOCALDB ) );
-        }
-        else
-        {
-            return new ServiceInfoBean( Collections.emptyList() );
-        }
-    }
-
     public WordlistStatus readWordlistStatus( )
     {
         if ( wlStatus == STATUS.CLOSED )
@@ -219,20 +304,13 @@ abstract class AbstractWordlist implements Wordlist, PwmService
             return WordlistStatus.builder().build();
         }
 
-        final PwmApplication.AppAttribute appAttribute = wordlistConfiguration.getMetaDataAppAttribute();
-        final WordlistStatus storedValue = pwmApplication.readAppAttribute( appAttribute, WordlistStatus.class );
-        if ( storedValue != null )
-        {
-            return storedValue;
-        }
-        return WordlistStatus.builder().build();
+        return wordlistBucket.readWordlistStatus();
     }
 
-    void writeWordlistStatus( final WordlistStatus metadataBean )
+    void writeWordlistStatus( final WordlistStatus wordlistStatus )
     {
-        final PwmApplication.AppAttribute appAttribute = wordlistConfiguration.getMetaDataAppAttribute();
-        pwmApplication.writeAppAttribute( appAttribute, metadataBean );
-        //getLogger().trace( "updated stored state: " + JsonUtil.serialize( metadataBean ) );
+        wordTypesCache = null;
+        wordlistBucket.writeWordlistStatus( wordlistStatus );
     }
 
     @Override
@@ -245,19 +323,12 @@ abstract class AbstractWordlist implements Wordlist, PwmService
 
         cancelBackgroundAndRunImmediate( () ->
         {
-            try
-            {
-                clearImpl( Activity.Idle );
-                executorService.execute( new InspectorJob() );
-            }
-            catch ( LocalDBException e )
-            {
-                throw new PwmUnrecoverableException( e.getErrorInformation() );
-            }
+            clearImpl( Activity.Idle );
+            executorService.execute( new InspectorJob() );
         } );
     }
 
-    void clearImpl( final Activity postCleanActivity ) throws LocalDBException
+    void clearImpl( final Activity postCleanActivity ) throws PwmUnrecoverableException
     {
         final Instant startTime = Instant.now();
         getLogger().trace( () -> "clearing stored wordlist" );
@@ -278,7 +349,7 @@ abstract class AbstractWordlist implements Wordlist, PwmService
 
     WordlistBucket getWordlistBucket()
     {
-        return wordklistBucket;
+        return wordlistBucket;
     }
 
     @Override
@@ -351,7 +422,11 @@ abstract class AbstractWordlist implements Wordlist, PwmService
                     wordlistInspector.run();
                     activity = Wordlist.Activity.Idle;
                 }
-
+            }
+            catch ( Throwable t )
+            {
+                getLogger().error( "error running InspectorJob: " + t.getMessage(), t );
+                throw t;
             }
             finally
             {
@@ -360,6 +435,15 @@ abstract class AbstractWordlist implements Wordlist, PwmService
         }
     }
 
+    Set<WordType> getWordTypesCache()
+    {
+        if ( wordTypesCache == null )
+        {
+            wordTypesCache = Collections.unmodifiableSet( new HashMap<>( this.readWordlistStatus().getWordTypes() ).keySet() );
+        }
+        return wordTypesCache;
+    }
+
     @Override
     public Activity getActivity()
     {
@@ -369,5 +453,25 @@ abstract class AbstractWordlist implements Wordlist, PwmService
     void setActivity( final Activity activity )
     {
         this.activity = activity;
+        wordTypesCache = null;
     }
+
+
+    public ServiceInfoBean serviceInfo( )
+    {
+        if ( status() == STATUS.OPEN )
+        {
+            return new ServiceInfoBean( Collections.singletonList( DataStorageMethod.LOCALDB ), getStatistics().asDebugMap() );
+        }
+        else
+        {
+            return new ServiceInfoBean( Collections.emptyList() );
+        }
+    }
+
+    WordlistStatistics getStatistics()
+    {
+        return statistics;
+    }
+
 }

+ 155 - 0
server/src/main/java/password/pwm/svc/wordlist/AbstractWordlistBucket.java

@@ -0,0 +1,155 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2019 The PWM Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package password.pwm.svc.wordlist;
+
+import password.pwm.PwmApplication;
+import password.pwm.error.PwmError;
+import password.pwm.error.PwmUnrecoverableException;
+import password.pwm.util.java.AtomicLoopLongIncrementer;
+import password.pwm.util.java.JavaHelper;
+import password.pwm.util.java.StringUtil;
+
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Map;
+import java.util.TreeMap;
+
+public abstract class AbstractWordlistBucket implements WordlistBucket
+{
+    protected final PwmApplication pwmApplication;
+    protected final WordlistConfiguration wordlistConfiguration;
+    protected final WordlistType type;
+
+    public AbstractWordlistBucket( final PwmApplication pwmApplication, final WordlistConfiguration wordlistConfiguration, final WordlistType type )
+    {
+        this.pwmApplication = pwmApplication;
+        this.wordlistConfiguration = wordlistConfiguration;
+        this.type = type;
+    }
+
+    private static String seedlistLongToKey( final long longValue )
+    {
+        return Long.toString( longValue, 36 );
+    }
+
+    private Map<String, String> getWriteTxnForValue(
+            final Collection<String> words,
+            final AtomicLoopLongIncrementer valueIncrementer
+    )
+    {
+        switch ( type )
+        {
+            case SEEDLIST:
+            {
+                final Map<String, String> returnSet = new TreeMap<>();
+                for ( final String word : words )
+                {
+                    if ( !StringUtil.isEmpty( word ) )
+                    {
+                        final long nextLong = valueIncrementer.incrementAndGet();
+                        final String nextKey = seedlistLongToKey( nextLong );
+                        returnSet.put( nextKey, word );
+                    }
+                }
+                return Collections.unmodifiableMap( returnSet );
+            }
+
+            case WORDLIST:
+            {
+                final Map<String, String> returnSet = new TreeMap<>();
+                for ( final String word : words )
+                {
+                    if ( !StringUtil.isEmpty( word ) )
+                    {
+                        valueIncrementer.incrementAndGet();
+                        returnSet.put( word, "" );
+                    }
+                }
+                return returnSet;
+            }
+
+            default:
+                JavaHelper.unhandledSwitchStatement( type );
+        }
+
+        throw new IllegalStateException( "unreachable switch statement" );
+    }
+
+    @Override
+    public void addWords( final Collection<String> words, final AbstractWordlist abstractWordlist )
+            throws PwmUnrecoverableException
+    {
+        final WordlistStatus initialStatus = abstractWordlist.readWordlistStatus();
+        final AtomicLoopLongIncrementer valueIncrementer = new AtomicLoopLongIncrementer( initialStatus.getValueCount(), Long.MAX_VALUE );
+        this.putValues( getWriteTxnForValue( words, valueIncrementer ) );
+
+        if ( initialStatus.getValueCount() != valueIncrementer.get() )
+        {
+            final WordlistStatus incrementedStatus = initialStatus.toBuilder().valueCount( valueIncrementer.get() ).build();
+            abstractWordlist.writeWordlistStatus( incrementedStatus );
+        }
+    }
+
+    @Override
+    public String randomSeed() throws PwmUnrecoverableException
+    {
+        if ( type == WordlistType.WORDLIST )
+        {
+            throw new IllegalStateException( "unable to read randomSeed from WORDLIST wordlist" );
+        }
+
+        try
+        {
+            final long seedCount = size();
+            if ( seedCount > 1000 )
+            {
+                final long randomKey = pwmApplication.getSecureService().pwmRandom().nextLong( seedCount );
+                return getValue( seedlistLongToKey( randomKey ) );
+            }
+        }
+        catch ( Exception e )
+        {
+            throw PwmUnrecoverableException.newException( PwmError.ERROR_INTERNAL, "error while generating random word: " + e.getMessage() );
+        }
+
+        throw new PwmUnrecoverableException( PwmError.ERROR_INTERNAL, "seedlist word not available" );
+    }
+
+    @Override
+    public boolean containsWord( final String word ) throws PwmUnrecoverableException
+    {
+        if ( type == WordlistType.SEEDLIST )
+        {
+            throw new IllegalStateException( "unable to containWord check SEEDLIST wordlist" );
+        }
+
+        return containsKey( word );
+    }
+
+    abstract void putValues( Map<String, String> values )
+            throws PwmUnrecoverableException;
+
+    abstract boolean containsKey( String key )
+            throws PwmUnrecoverableException;
+
+    abstract String getValue( String key )
+            throws PwmUnrecoverableException;
+}

+ 134 - 0
server/src/main/java/password/pwm/svc/wordlist/LocalDBWordlistBucket.java

@@ -0,0 +1,134 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2019 The PWM Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package password.pwm.svc.wordlist;
+
+import password.pwm.PwmApplication;
+import password.pwm.error.PwmError;
+import password.pwm.error.PwmUnrecoverableException;
+import password.pwm.util.localdb.LocalDB;
+import password.pwm.util.localdb.LocalDBException;
+
+import java.util.Map;
+
+class LocalDBWordlistBucket extends AbstractWordlistBucket implements WordlistBucket
+{
+    private final LocalDB.DB db;
+    private final LocalDB localDB;
+
+    LocalDBWordlistBucket(
+            final PwmApplication pwmApplication,
+            final WordlistConfiguration wordlistConfiguration,
+            final WordlistType type
+    )
+    {
+        super( pwmApplication, wordlistConfiguration, type );
+        this.localDB = pwmApplication.getLocalDB();
+        this.db = wordlistConfiguration.getDb();
+    }
+
+    @Override
+    void putValues( final Map<String, String> values )
+            throws PwmUnrecoverableException
+    {
+        try
+        {
+            localDB.putAll( db, values );
+        }
+        catch ( LocalDBException e )
+        {
+            throw PwmUnrecoverableException.newException( PwmError.ERROR_LOCALDB_UNAVAILABLE, "error while writing words to wordlist: " + e.getMessage() );
+        }
+    }
+
+    @Override
+    String getValue( final String key )
+            throws PwmUnrecoverableException
+    {
+        try
+        {
+            return pwmApplication.getLocalDB().get( db, key );
+        }
+        catch ( Exception e )
+        {
+            throw PwmUnrecoverableException.newException( PwmError.ERROR_INTERNAL, "error while generating random word: " + e.getMessage() );
+        }
+    }
+
+    @Override
+    boolean containsKey( final String key )
+            throws PwmUnrecoverableException
+    {
+        try
+        {
+            return pwmApplication.getLocalDB().contains( db, key );
+        }
+        catch ( LocalDBException e )
+        {
+            throw PwmUnrecoverableException.newException( PwmError.ERROR_LOCALDB_UNAVAILABLE, e.getMessage() );
+        }
+    }
+
+    @Override
+    public long size() throws PwmUnrecoverableException
+    {
+        try
+        {
+            return localDB.size( db );
+        }
+        catch ( LocalDBException e )
+        {
+            throw PwmUnrecoverableException.newException( PwmError.ERROR_LOCALDB_UNAVAILABLE, e.getMessage() );
+        }
+    }
+
+
+    @Override
+    public void clear() throws PwmUnrecoverableException
+    {
+        try
+        {
+            localDB.truncate( db );
+        }
+        catch ( LocalDBException e )
+        {
+            throw PwmUnrecoverableException.newException( PwmError.ERROR_LOCALDB_UNAVAILABLE, e.getMessage() );
+        }
+    }
+
+    @Override
+    public WordlistStatus readWordlistStatus()
+    {
+        final PwmApplication.AppAttribute appAttribute = wordlistConfiguration.getMetaDataAppAttribute();
+        final WordlistStatus storedValue = pwmApplication.readAppAttribute( appAttribute, WordlistStatus.class );
+        if ( storedValue != null )
+        {
+            return storedValue;
+        }
+        return WordlistStatus.builder().build();
+    }
+
+    @Override
+    public void writeWordlistStatus( final WordlistStatus wordlistStatus )
+    {
+        final PwmApplication.AppAttribute appAttribute = wordlistConfiguration.getMetaDataAppAttribute();
+        pwmApplication.writeAppAttribute( appAttribute, wordlistStatus );
+    }
+}

+ 85 - 0
server/src/main/java/password/pwm/svc/wordlist/MemoryWordlistBucket.java

@@ -0,0 +1,85 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2019 The PWM Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package password.pwm.svc.wordlist;
+
+import password.pwm.PwmApplication;
+import password.pwm.error.PwmUnrecoverableException;
+
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+
+public class MemoryWordlistBucket extends AbstractWordlistBucket
+{
+    private final Map<String, String> map = new ConcurrentHashMap<>(  );
+    private WordlistStatus wordlistStatus;
+
+    public MemoryWordlistBucket( final PwmApplication pwmApplication, final WordlistConfiguration wordlistConfiguration, final WordlistType type )
+    {
+        super( pwmApplication, wordlistConfiguration, type );
+    }
+
+    @Override
+    void putValues( final Map<String, String> values )
+            throws PwmUnrecoverableException
+    {
+        map.putAll( values );
+    }
+
+    @Override
+    boolean containsKey( final String key )
+            throws PwmUnrecoverableException
+    {
+        return map.containsKey( key );
+    }
+
+    @Override
+    String getValue( final String key )
+            throws PwmUnrecoverableException
+    {
+        return map.get( key );
+    }
+
+    @Override
+    public long size()
+            throws PwmUnrecoverableException
+    {
+        return map.size();
+    }
+
+    @Override
+    public void clear()
+            throws PwmUnrecoverableException
+    {
+        map.clear();
+    }
+
+    @Override
+    public WordlistStatus readWordlistStatus()
+    {
+        return wordlistStatus == null ? WordlistStatus.builder().build() : wordlistStatus;
+    }
+
+    @Override
+    public void writeWordlistStatus( final WordlistStatus wordlistStatus )
+    {
+        this.wordlistStatus = wordlistStatus;
+    }
+}

+ 164 - 0
server/src/main/java/password/pwm/svc/wordlist/WordType.java

@@ -0,0 +1,164 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2019 The PWM Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package password.pwm.svc.wordlist;
+
+import password.pwm.PwmApplication;
+import password.pwm.error.PwmUnrecoverableException;
+import password.pwm.util.secure.PwmHashAlgorithm;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.regex.Pattern;
+
+public enum WordType
+{
+    RAW( null ),
+    MD5( PwmHashAlgorithm.MD5 ),
+    SHA1( PwmHashAlgorithm.SHA1 ),
+    SHA256( PwmHashAlgorithm.SHA256 ),
+    SHA512( PwmHashAlgorithm.SHA512 ),;
+
+    private static final String DELIMITER = ":";
+    private static final Pattern HEX_CHAR_PATTERN = Pattern.compile( "^[0-9a-fA-F]*$" );
+
+    private final String prefix;
+    private final String suffix;
+    private final PwmHashAlgorithm hashAlgorithm;
+
+    WordType( final PwmHashAlgorithm pwmHashAlgorithm )
+    {
+        this.hashAlgorithm = pwmHashAlgorithm;
+        prefix = ( this.name() + DELIMITER ).toLowerCase();
+        suffix = ( DELIMITER + this.name() ).toLowerCase();
+    }
+
+    public String convertInputFromWordlist(
+            final WordlistConfiguration wordlistConfiguration,
+            final String input
+    )
+    {
+        if ( this == RAW )
+        {
+            return !wordlistConfiguration.isCaseSensitive()
+                    ? input.toLowerCase()
+                    : input;
+        }
+
+        if ( input.startsWith( prefix ) )
+        {
+            final String strippedValue = input.substring( prefix.length() );
+            return makeHashedStoredValue( strippedValue );
+        }
+        else
+        {
+            final String strippedValue = input.substring( 0, input.length() - suffix.length() );
+            return makeHashedStoredValue( strippedValue );
+        }
+    }
+
+    public String convertInputFromUser(
+            final PwmApplication pwmApplication,
+            final WordlistConfiguration wordlistConfiguration,
+            final String input
+    )
+            throws PwmUnrecoverableException
+    {
+        if ( this == RAW )
+        {
+            return !wordlistConfiguration.isCaseSensitive()
+                    ? input.toLowerCase()
+                    : input;
+        }
+
+        final String hashedValue = pwmApplication.getSecureService().hash( this.hashAlgorithm, input );
+        return makeHashedStoredValue( hashedValue );
+    }
+
+    private String makeHashedStoredValue( final String hash )
+    {
+        // stored hash first to improve sorting/storage efficiency
+        return hash.toLowerCase() + DELIMITER + name();
+    }
+
+    public static WordType determineWordType( final String input )
+    {
+        Objects.requireNonNull( input );
+
+        for ( final WordType wordType : NonRawTypeSingleton.NON_RAW_TYPES )
+        {
+            if ( wordType.matchesType( input ) )
+            {
+                return wordType;
+            }
+        }
+
+        return RAW;
+    }
+
+    private boolean matchesType( final String input )
+    {
+        if ( this == RAW )
+        {
+            return true;
+        }
+
+        if ( input.length() <= prefix.length() )
+        {
+            return false;
+        }
+
+        final String lowerCaseInputPrefix = input.substring( 0, this.prefix.length() ).toLowerCase();
+        if ( lowerCaseInputPrefix.equals( prefix ) )
+        {
+            final String hashValue = input.substring( prefix.length() );
+            if ( hashValue.length() == this.hashAlgorithm.getHexValueLength() )
+            {
+                return HEX_CHAR_PATTERN.matcher( hashValue ).matches();
+            }
+        }
+
+        final String lowerCaseInputSuffix = input.substring( input.length() - this.suffix.length() ).toLowerCase();
+        if ( lowerCaseInputSuffix.equals( suffix ) )
+        {
+            final String hashValue = input.substring( 0, input.length() - suffix.length() );
+            if ( hashValue.length() == this.hashAlgorithm.getHexValueLength() )
+            {
+                return HEX_CHAR_PATTERN.matcher( hashValue ).matches();
+            }
+        }
+
+        return false;
+    }
+
+    private static class NonRawTypeSingleton
+    {
+        private static final WordType[] NON_RAW_TYPES;
+
+        static
+        {
+            final List<WordType> wordTypes = new ArrayList<>( Arrays.asList( WordType.values() ) );
+            wordTypes.remove( RAW );
+            NON_RAW_TYPES = wordTypes.toArray( new WordType[0] );
+        }
+    }
+}

+ 2 - 2
server/src/main/java/password/pwm/svc/wordlist/Wordlist.java

@@ -30,8 +30,8 @@ import java.io.InputStream;
 
 public interface Wordlist extends PwmService
 {
-
-    long size( );
+    long size( )
+            throws PwmUnrecoverableException;
 
     WordlistStatus readWordlistStatus( );
 

+ 10 - 243
server/src/main/java/password/pwm/svc/wordlist/WordlistBucket.java

@@ -20,258 +20,25 @@
 
 package password.pwm.svc.wordlist;
 
-import password.pwm.PwmApplication;
-import password.pwm.error.PwmError;
 import password.pwm.error.PwmUnrecoverableException;
-import password.pwm.util.java.JavaHelper;
-import password.pwm.util.java.StringUtil;
-import password.pwm.util.java.TimeDuration;
-import password.pwm.util.localdb.LocalDB;
-import password.pwm.util.localdb.LocalDBException;
-import password.pwm.util.logging.PwmLogger;
 
-import java.time.Instant;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.Map;
-import java.util.Set;
-import java.util.TreeMap;
-import java.util.TreeSet;
 
-class WordlistBucket
+public interface WordlistBucket
 {
-    private static final PwmLogger LOGGER = PwmLogger.forClass( WordlistBucket.class );
+    boolean containsWord( String hashWord )
+            throws PwmUnrecoverableException;
 
-    private final PwmApplication pwmApplication;
-    private final WordlistConfiguration wordlistConfiguration;
-    private final LocalDB.DB db;
-    private final WordlistType type;
+    String randomSeed() throws PwmUnrecoverableException;
 
+    void addWords( Collection<String> words, AbstractWordlist abstractWordlist )
+            throws PwmUnrecoverableException;
 
-    WordlistBucket(
-            final PwmApplication pwmApplication,
-            final WordlistConfiguration wordlistConfiguration,
-            final WordlistType type
-    )
-            throws LocalDBException
-    {
-        this.pwmApplication = pwmApplication;
-        this.wordlistConfiguration = wordlistConfiguration;
-        this.db = wordlistConfiguration.getDb();
-        this.type = type;
-    }
+    long size() throws PwmUnrecoverableException;
 
-    boolean containsWord( final String word ) throws LocalDBException
-    {
-        if ( type == WordlistType.SEEDLIST )
-        {
-            throw new IllegalStateException( "unable to containWord check SEEDLIST wordlist" );
-        }
+    void clear() throws PwmUnrecoverableException;
 
-        final String testWord = normalizeWord( word );
+    WordlistStatus readWordlistStatus();
 
-        if ( testWord == null || testWord.length() < 1 )
-        {
-            return false;
-        }
-
-        final Set<String> testWords = chunkWord( testWord, this.wordlistConfiguration.getCheckSize() );
-
-        final Instant startTime = Instant.now();
-        boolean result = false;
-
-        searchLoop:
-        for ( final String t : testWords )
-        {
-            // stop checking once found
-            if ( pwmApplication.getLocalDB().contains( db, t ) )
-            {
-                result = true;
-                break searchLoop;
-            }
-        }
-
-        final TimeDuration timeDuration = TimeDuration.fromCurrent( startTime );
-        if ( timeDuration.isLongerThan( 100 ) )
-        {
-            LOGGER.debug( () -> "wordlist search time for " + testWords.size() + " wordlist permutations was greater then 100ms: " + timeDuration.asCompactString() );
-        }
-
-        return result;
-    }
-
-    String randomSeed( ) throws PwmUnrecoverableException
-    {
-        if ( type == WordlistType.WORDLIST )
-        {
-            throw new IllegalStateException( "unable to read randomSeed from WORDLIST wordlist" );
-        }
-
-        try
-        {
-            final long seedCount = size();
-            if ( seedCount > 1000 )
-            {
-                final long randomKey = pwmApplication.getSecureService().pwmRandom().nextLong( seedCount );
-                return pwmApplication.getLocalDB().get( db, seedlistLongToKey( randomKey ) );
-            }
-        }
-        catch ( Exception e )
-        {
-            throw PwmUnrecoverableException.newException( PwmError.ERROR_INTERNAL, "error while generating random word: " + e.getMessage() );
-        }
-
-        throw new PwmUnrecoverableException( PwmError.ERROR_INTERNAL, "seedlist word not available" );
-    }
-
-    void addWords( final Collection<String> words, final AbstractWordlist abstractWordlist )
-            throws LocalDBException
-    {
-        final WordlistStatus initialStatus = abstractWordlist.readWordlistStatus();
-        final MutableLongIncrementer valueIncrementer = new MutableLongIncrementer( initialStatus.getValueCount() );
-        pwmApplication.getLocalDB().putAll( db, getWriteTxnForValue( words, valueIncrementer ) );
-        if ( initialStatus.getValueCount() != valueIncrementer.get() )
-        {
-            final WordlistStatus incrementedStatus = initialStatus.toBuilder().valueCount( valueIncrementer.get() ).build();
-            abstractWordlist.writeWordlistStatus( incrementedStatus );
-        }
-    }
-
-    long size() throws LocalDBException
-    {
-        return pwmApplication.getLocalDB().size( db );
-    }
-
-
-    void clear() throws LocalDBException
-    {
-        pwmApplication.getLocalDB().truncate( db );
-    }
-
-    private Map<String, String> getWriteTxnForValue( final Collection<String> words, final MutableLongIncrementer valueIncrementer ) throws LocalDBException
-    {
-        switch ( type )
-        {
-            case SEEDLIST:
-            {
-                final Map<String, String> returnSet = new TreeMap<>();
-                for ( final String word : words )
-                {
-                    final String normalizedWord = normalizeWord( word );
-                    if ( !StringUtil.isEmpty( normalizedWord ) )
-                    {
-                        final long nextLong = valueIncrementer.getAndIncrement();
-                        final String nextKey = seedlistLongToKey( nextLong );
-                        returnSet.put( nextKey, normalizedWord );
-                    }
-                }
-                return Collections.unmodifiableMap( returnSet );
-            }
-
-            case WORDLIST:
-            {
-                final Map<String, String> returnSet = new TreeMap<>();
-                for ( final String word : words )
-                {
-                    final String normalizedWord = normalizeWord( word );
-                    if ( !StringUtil.isEmpty( normalizedWord ) )
-                    {
-                        valueIncrementer.getAndIncrement();
-                        returnSet.put( normalizedWord, "" );
-                    }
-                }
-                return returnSet;
-            }
-
-            default:
-                JavaHelper.unhandledSwitchStatement( type );
-        }
-
-        throw new IllegalStateException( "unreachable switch statement" );
-    }
-
-    private String normalizeWord( final String input )
-    {
-        if ( input == null )
-        {
-            return null;
-        }
-
-        String word = input.trim();
-
-        if ( word.length() < wordlistConfiguration.getMinSize() )
-        {
-            return null;
-        }
-
-        if ( word.length() > wordlistConfiguration.getMaxSize() )
-        {
-            word = word.substring( 0, wordlistConfiguration.getMaxSize() );
-        }
-
-        if ( !wordlistConfiguration.isCaseSensitive() )
-        {
-            word = word.toLowerCase();
-        }
-
-        return word.length() > 0 ? word : null;
-    }
-
-    private Set<String> chunkWord( final String input, final int size )
-    {
-        if ( StringUtil.isEmpty( input ) )
-        {
-            return Collections.emptySet();
-        }
-
-        if ( size == 0 )
-        {
-            return Collections.singleton( input );
-        }
-
-        int checkSize = size == 0 || size > input.length() ? input.length() : size;
-        final TreeSet<String> testWords = new TreeSet<>();
-        while ( checkSize <= input.length() )
-        {
-            for ( int i = 0; i + checkSize <= input.length(); i++ )
-            {
-                final String loopWord = input.substring( i, i + checkSize );
-                testWords.add( loopWord );
-            }
-            checkSize++;
-        }
-
-        return testWords;
-    }
-
-    private static long seedlistKeyToLong( final String key )
-    {
-        return Long.parseLong( key, 36 );
-    }
-
-    private static String seedlistLongToKey( final long longValue )
-    {
-        return Long.toString( longValue, 36 );
-    }
-
-    public static class MutableLongIncrementer
-    {
-        private long value;
-
-        MutableLongIncrementer( final long value )
-        {
-            this.value = value;
-        }
-
-        public long getAndIncrement()
-        {
-            value++;
-            return value;
-        }
-
-        public long get()
-        {
-            return value;
-        }
-    }
+    void writeWordlistStatus( WordlistStatus wordlistStatus );
 }

+ 67 - 40
server/src/main/java/password/pwm/svc/wordlist/WordlistConfiguration.java

@@ -20,37 +20,55 @@
 
 package password.pwm.svc.wordlist;
 
+import lombok.AccessLevel;
 import lombok.Builder;
 import lombok.Getter;
+import lombok.Value;
 import password.pwm.AppProperty;
 import password.pwm.PwmApplication;
 import password.pwm.config.Configuration;
 import password.pwm.config.PwmSetting;
+import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.util.java.JavaHelper;
+import password.pwm.util.java.JsonUtil;
+import password.pwm.util.java.LazySupplier;
 import password.pwm.util.java.StringUtil;
 import password.pwm.util.java.TimeDuration;
 import password.pwm.util.localdb.LocalDB;
+import password.pwm.util.secure.PwmHashAlgorithm;
+import password.pwm.util.secure.SecureEngine;
 
 import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.function.Supplier;
 
-@Getter
-@Builder
+@Value
+@Builder( toBuilder = true )
 public class WordlistConfiguration implements Serializable
 {
+    static final int STREAM_BUFFER_SIZE = 1024_1024;
+    static final PwmHashAlgorithm HASH_ALGORITHM = PwmHashAlgorithm.SHA1;
+
     private final boolean caseSensitive;
     private final int checkSize;
     private final String autoImportUrl;
-    private final int minSize;
-    private final int maxSize;
+    private final int minWordSize;
+    private final int maxWordSize;
     private final PwmApplication.AppAttribute metaDataAppAttribute;
     private final AppProperty builtInWordlistLocationProperty;
     private final LocalDB.DB db;
     private final PwmSetting wordlistFilenameSetting;
+    private final boolean testMode;
+
+    @Builder.Default
+    private final Collection<String> commentPrefixes = new ArrayList<>();
 
     private final TimeDuration autoImportRecheckDuration;
     private final TimeDuration importDurationGoal;
     private final int importMinTransactions;
     private final int importMaxTransactions;
+    private final long importMaxChars;
 
     private final TimeDuration inspectorFrequency;
 
@@ -63,36 +81,18 @@ public class WordlistConfiguration implements Serializable
         {
             case SEEDLIST:
             {
-                return WordlistConfiguration.builder()
+                return commonBuilder( configuration, type ).toBuilder()
                         .autoImportUrl( readAutoImportUrl( configuration, PwmSetting.SEEDLIST_FILENAME ) )
-                        .minSize( Integer.parseInt( configuration.readAppProperty( AppProperty.WORDLIST_CHAR_LENGTH_MIN ) ) )
-                        .maxSize( Integer.parseInt( configuration.readAppProperty( AppProperty.WORDLIST_CHAR_LENGTH_MAX ) ) )
                         .metaDataAppAttribute( PwmApplication.AppAttribute.SEEDLIST_METADATA )
                         .builtInWordlistLocationProperty( AppProperty.SEEDLIST_BUILTIN_PATH )
                         .db( LocalDB.DB.SEEDLIST_WORDS )
                         .wordlistFilenameSetting( PwmSetting.SEEDLIST_FILENAME )
-
-                        .minSize( Integer.parseInt( configuration.readAppProperty( AppProperty.WORDLIST_CHAR_LENGTH_MIN ) ) )
-                        .maxSize( Integer.parseInt( configuration.readAppProperty( AppProperty.WORDLIST_CHAR_LENGTH_MAX ) ) )
-                        .autoImportRecheckDuration( TimeDuration.of(
-                                Long.parseLong( configuration.readAppProperty( AppProperty.WORDLIST_IMPORT_AUTO_IMPORT_RECHECK_SECONDS ) ),
-                                TimeDuration.Unit.SECONDS ) )
-                        .importDurationGoal( TimeDuration.of(
-                                Long.parseLong( configuration.readAppProperty( AppProperty.WORDLIST_IMPORT_DURATION_GOAL_MS ) ),
-                                TimeDuration.Unit.MILLISECONDS ) )
-                        .importMinTransactions( Integer.parseInt( configuration.readAppProperty( AppProperty.WORDLIST_IMPORT_MIN_TRANSACTIONS ) ) )
-                        .importMaxTransactions( Integer.parseInt( configuration.readAppProperty( AppProperty.WORDLIST_IMPORT_MAX_TRANSACTIONS ) ) )
-
-                        .inspectorFrequency( TimeDuration.of(
-                                Long.parseLong( configuration.readAppProperty( AppProperty.WORDLIST_INSPECTOR_FREQUENCY_SECONDS ) ),
-                                TimeDuration.Unit.SECONDS ) )
-
                         .build();
             }
 
             case WORDLIST:
             {
-                return WordlistConfiguration.builder()
+                return commonBuilder( configuration, type ).toBuilder()
                         .caseSensitive( configuration.readSettingAsBoolean( PwmSetting.WORDLIST_CASE_SENSITIVE )  )
                         .checkSize( (int) configuration.readSettingAsLong( PwmSetting.PASSWORD_WORDLIST_WORDSIZE ) )
                         .autoImportUrl( readAutoImportUrl( configuration, PwmSetting.WORDLIST_FILENAME ) )
@@ -100,22 +100,6 @@ public class WordlistConfiguration implements Serializable
                         .builtInWordlistLocationProperty( AppProperty.WORDLIST_BUILTIN_PATH )
                         .db( LocalDB.DB.WORDLIST_WORDS )
                         .wordlistFilenameSetting( PwmSetting.WORDLIST_FILENAME )
-
-                        .minSize( Integer.parseInt( configuration.readAppProperty( AppProperty.WORDLIST_CHAR_LENGTH_MIN ) ) )
-                        .maxSize( Integer.parseInt( configuration.readAppProperty( AppProperty.WORDLIST_CHAR_LENGTH_MAX ) ) )
-                        .autoImportRecheckDuration( TimeDuration.of(
-                                Long.parseLong( configuration.readAppProperty( AppProperty.WORDLIST_IMPORT_AUTO_IMPORT_RECHECK_SECONDS ) ),
-                                TimeDuration.Unit.SECONDS ) )
-                        .importDurationGoal( TimeDuration.of(
-                                Long.parseLong( configuration.readAppProperty( AppProperty.WORDLIST_IMPORT_DURATION_GOAL_MS ) ),
-                                TimeDuration.Unit.MILLISECONDS ) )
-                        .importMinTransactions( Integer.parseInt( configuration.readAppProperty( AppProperty.WORDLIST_IMPORT_MIN_TRANSACTIONS ) ) )
-                        .importMaxTransactions( Integer.parseInt( configuration.readAppProperty( AppProperty.WORDLIST_IMPORT_MAX_TRANSACTIONS ) ) )
-
-                        .inspectorFrequency( TimeDuration.of(
-                                Long.parseLong( configuration.readAppProperty( AppProperty.WORDLIST_INSPECTOR_FREQUENCY_SECONDS ) ),
-                                TimeDuration.Unit.SECONDS ) )
-
                         .build();
             }
 
@@ -126,6 +110,30 @@ public class WordlistConfiguration implements Serializable
         throw new IllegalStateException( "unreachable switch statement" );
     }
 
+    private static WordlistConfiguration commonBuilder(
+            final Configuration configuration,
+            final WordlistType type
+    )
+    {
+        return WordlistConfiguration.builder()
+                .commentPrefixes( StringUtil.splitAndTrim( configuration.readAppProperty( AppProperty.WORDLIST_IMPORT_LINE_COMMENTS ), ";;;" ) )
+                .testMode( Boolean.parseBoolean( configuration.readAppProperty( AppProperty.WORDLIST_TEST_MODE ) ) )
+                .minWordSize( Integer.parseInt( configuration.readAppProperty( AppProperty.WORDLIST_CHAR_LENGTH_MIN ) ) )
+                .maxWordSize( Integer.parseInt( configuration.readAppProperty( AppProperty.WORDLIST_CHAR_LENGTH_MAX ) ) )
+                .autoImportRecheckDuration( TimeDuration.of(
+                        Long.parseLong( configuration.readAppProperty( AppProperty.WORDLIST_IMPORT_AUTO_IMPORT_RECHECK_SECONDS ) ),
+                        TimeDuration.Unit.SECONDS ) )
+                .importDurationGoal( TimeDuration.of(
+                        Long.parseLong( configuration.readAppProperty( AppProperty.WORDLIST_IMPORT_DURATION_GOAL_MS ) ),
+                        TimeDuration.Unit.MILLISECONDS ) )
+                .importMinTransactions( Integer.parseInt( configuration.readAppProperty( AppProperty.WORDLIST_IMPORT_MIN_TRANSACTIONS ) ) )
+                .importMaxTransactions( Integer.parseInt( configuration.readAppProperty( AppProperty.WORDLIST_IMPORT_MAX_TRANSACTIONS ) ) )
+                .importMaxChars( JavaHelper.silentParseLong( configuration.readAppProperty( AppProperty.WORDLIST_IMPORT_MAX_CHARS_TRANSACTIONS ), 10_1024_1024 ) )
+                .inspectorFrequency( TimeDuration.of(
+                        Long.parseLong( configuration.readAppProperty( AppProperty.WORDLIST_INSPECTOR_FREQUENCY_SECONDS ) ),
+                        TimeDuration.Unit.SECONDS ) )
+                .build();
+    }
 
     private static String readAutoImportUrl(
             final Configuration configuration,
@@ -146,4 +154,23 @@ public class WordlistConfiguration implements Serializable
 
         return inputUrl;
     }
+
+    @Getter( AccessLevel.PRIVATE )
+    private final transient Supplier<String> configHash = new LazySupplier<>( () ->
+    {
+        try
+        {
+            return SecureEngine.hash( JsonUtil.serialize( WordlistConfiguration.this ), PwmHashAlgorithm.SHA1 );
+        }
+        catch ( PwmUnrecoverableException e )
+        {
+            throw new IllegalStateException( "unexpected error generating wordlist-config hash: " + e.getMessage() );
+        }
+    } );
+
+    String configHash( )
+    {
+        return configHash.get();
+    }
+
 }

+ 154 - 58
server/src/main/java/password/pwm/svc/wordlist/WordlistImporter.java

@@ -20,6 +20,7 @@
 
 package password.pwm.svc.wordlist;
 
+import lombok.Value;
 import org.apache.commons.io.IOUtils;
 import password.pwm.error.ErrorInformation;
 import password.pwm.error.PwmError;
@@ -27,16 +28,20 @@ import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.util.TransactionSizeCalculator;
 import password.pwm.util.java.ConditionalTaskExecutor;
 import password.pwm.util.java.JsonUtil;
+import password.pwm.util.java.MovingAverage;
 import password.pwm.util.java.Percent;
 import password.pwm.util.java.PwmNumberFormat;
 import password.pwm.util.java.StringUtil;
 import password.pwm.util.java.TimeDuration;
-import password.pwm.util.localdb.LocalDBException;
 import password.pwm.util.logging.PwmLogger;
 
+import java.text.DecimalFormat;
 import java.time.Instant;
+import java.util.Collection;
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.TreeMap;
 import java.util.TreeSet;
@@ -47,33 +52,41 @@ import java.util.function.BooleanSupplier;
  */
 class WordlistImporter implements Runnable
 {
-    // words tarting with this prefix are ignored.
-    private static final String COMMENT_PREFIX = "!#comment:";
-
     private final WordlistZipReader zipFileReader;
     private final WordlistSourceType sourceType;
+    private final AbstractWordlist rootWordlist;
+
     private final TransactionSizeCalculator transactionCalculator;
     private final Set<String> bufferedWords = new TreeSet<>();
     private final WordlistBucket wordlistBucket;
-    private final AbstractWordlist rootWordlist;
     private final WordlistSourceInfo wordlistSourceInfo;
     private final BooleanSupplier cancelFlag;
+    private final ImportStatistics importStatistics = new ImportStatistics();
 
+    private long charsInBuffer;
     private ErrorInformation exitError;
     private Instant startTime = Instant.now();
     private long bytesSkipped;
+    private Map<WordType, Long> seenWordTypes = new HashMap<>();
+    private boolean completed;
 
     private enum DebugKey
     {
         LinesRead,
         BytesRead,
         BytesRemaining,
-        BufferSize,
         BytesSkipped,
         BytesPerSecond,
         PercentComplete,
         ImportTime,
         EstimatedRemainingTime,
+        WordsImported,
+        ZipFile,
+        WordTypes,
+        WordsPerTxn,
+        CharsPerTxn,
+        ChunksPerWord,
+        AvgWordLength,
     }
 
     WordlistImporter(
@@ -100,7 +113,6 @@ class WordlistImporter implements Runnable
                         .maxTransactions( wordlistConfiguration.getImportMaxTransactions() )
                         .build()
         );
-
     }
 
     @Override
@@ -115,10 +127,6 @@ class WordlistImporter implements Runnable
         {
             errorMsg = "error during import: " + e.getErrorInformation().getDetailedErrorMsg();
         }
-        catch ( LocalDBException e )
-        {
-            errorMsg = "localDB error during import: " + e.getMessage();
-        }
 
         if ( errorMsg != null )
         {
@@ -135,9 +143,8 @@ class WordlistImporter implements Runnable
         }
     }
 
-    private void init( )
-            throws PwmUnrecoverableException,
-            LocalDBException
+    private void initImportProcess( )
+            throws PwmUnrecoverableException
     {
         if ( cancelFlag.getAsBoolean() )
         {
@@ -152,6 +159,7 @@ class WordlistImporter implements Runnable
         }
 
         final long previousBytesRead = rootWordlist.readWordlistStatus().getBytes();
+        seenWordTypes.putAll( rootWordlist.readWordlistStatus().getWordTypes() );
 
         if ( previousBytesRead == 0 )
         {
@@ -164,17 +172,12 @@ class WordlistImporter implements Runnable
     }
 
     private void doImport( )
-            throws LocalDBException, PwmUnrecoverableException
+            throws PwmUnrecoverableException
     {
         rootWordlist.setActivity( Wordlist.Activity.Importing );
 
         final ConditionalTaskExecutor metaUpdater = new ConditionalTaskExecutor(
-                () -> rootWordlist.writeWordlistStatus( rootWordlist.readWordlistStatus().toBuilder()
-                        .sourceType( sourceType )
-                        .storeDate( Instant.now() )
-                        .remoteInfo( wordlistSourceInfo )
-                        .bytes( zipFileReader.getByteCount() )
-                        .build() ),
+                this::writeCurrentWordlistStatus,
                 new ConditionalTaskExecutor.TimeDurationPredicate( TimeDuration.SECONDS_10 )
         );
 
@@ -187,11 +190,11 @@ class WordlistImporter implements Runnable
         {
             debugOutputter.conditionallyExecuteTask();
 
-            init();
+            initImportProcess();
 
             startTime = Instant.now();
 
-            getLogger().debug( () -> "beginning import" );
+            getLogger().debug( () -> "beginning import: " + JsonUtil.serialize( rootWordlist.readWordlistStatus() ) );
 
             String line;
             do
@@ -203,7 +206,10 @@ class WordlistImporter implements Runnable
 
                     debugOutputter.conditionallyExecuteTask();
 
-                    if ( bufferedWords.size() > transactionCalculator.getTransactionSize() )
+                    if (
+                            bufferedWords.size() > transactionCalculator.getTransactionSize()
+                                    || charsInBuffer > rootWordlist.getConfiguration().getImportMaxChars()
+                    )
                     {
                         flushBuffer();
                         metaUpdater.conditionallyExecuteTask();
@@ -228,21 +234,62 @@ class WordlistImporter implements Runnable
         }
     }
 
-    private void addLine( final String word )
+    private void addLine( final String input )
     {
-
-        if ( StringUtil.isEmpty( word ) || word.startsWith( COMMENT_PREFIX ) )
+        if ( StringUtil.isEmpty( input ) )
         {
             return;
         }
 
-        bufferedWords.add( word );
+        for ( final String commentPrefix : rootWordlist.getConfiguration().getCommentPrefixes() )
+        {
+            if ( input.startsWith( commentPrefix ) )
+            {
+                return;
+            }
+        }
+
+        final WordType wordType = WordType.determineWordType( input );
+        seenWordTypes.computeIfAbsent( wordType, wordType1 -> 0L );
+        seenWordTypes.put( wordType, seenWordTypes.get( wordType ) + 1L );
+
+        if ( wordType == WordType.RAW )
+        {
+            final Optional<String> word = WordlistUtil.normalizeWordLength( input, rootWordlist.getConfiguration() );
+            if ( word.isPresent() )
+            {
+                final String normalizedWord = wordType.convertInputFromWordlist( this.rootWordlist.getConfiguration(), word.get() );
+                final Set<String> words = WordlistUtil.chunkWord( normalizedWord, rootWordlist.getConfiguration().getCheckSize() );
+                importStatistics.getAverageWordLength().update( normalizedWord.length() );
+                importStatistics.getChunksPerWord().update( words.size() );
+                incrementCharBufferCounter( words );
+                bufferedWords.addAll( words );
+            }
+        }
+        else
+        {
+            final String normalizedWord = wordType.convertInputFromWordlist( this.rootWordlist.getConfiguration(), input );
+            incrementCharBufferCounter( Collections.singleton( normalizedWord ) );
+            bufferedWords.add( normalizedWord );
+        }
+    }
+
+    private void incrementCharBufferCounter( final Collection<String> words )
+    {
+        for ( final String word : words )
+        {
+            charsInBuffer += word.length();
+        }
+    }
+
+    private void updateStatistics( final String word )
+    {
     }
 
     private void flushBuffer( )
-            throws LocalDBException
+            throws PwmUnrecoverableException
     {
-        final long startTime = System.currentTimeMillis();
+        final Instant startTime = Instant.now();
 
         //add the elements
         wordlistBucket.addWords( bufferedWords, rootWordlist );
@@ -253,32 +300,30 @@ class WordlistImporter implements Runnable
         }
 
         //mark how long the buffer close took
-        final long commitTime = System.currentTimeMillis() - startTime;
+        final TimeDuration commitTime = TimeDuration.fromCurrent( startTime );
         transactionCalculator.recordLastTransactionDuration( commitTime );
 
+        importStatistics.getWordsPerTransaction().update( bufferedWords.size() );
+        importStatistics.getCharsPerTransaction().update( charsInBuffer );
+
         //clear the buffers.
         bufferedWords.clear();
+        charsInBuffer = 0;
     }
 
     private void populationComplete( )
-            throws LocalDBException
+            throws PwmUnrecoverableException
     {
         flushBuffer();
-        getLogger().info( () -> makeStatString() );
+        getLogger().info( this::makeStatString );
         getLogger().trace( () -> "beginning wordlist size query" );
         final long wordlistSize = wordlistBucket.size();
 
         getLogger().info( () -> "population complete, added " + wordlistSize
                 + " total words in " + TimeDuration.compactFromCurrent( startTime ) );
 
-        rootWordlist.writeWordlistStatus( rootWordlist.readWordlistStatus().toBuilder()
-                .remoteInfo( wordlistSourceInfo )
-                .storeDate( Instant.now() )
-                .sourceType( sourceType )
-                .completed( true )
-                .bytes( zipFileReader.getByteCount() )
-                .build()
-        );
+        completed = true;
+        writeCurrentWordlistStatus();
 
         getLogger().debug( () -> "final post-population status: " + JsonUtil.serialize( rootWordlist.readWordlistStatus() ) );
     }
@@ -296,24 +341,28 @@ class WordlistImporter implements Runnable
     private void skipForward( final long previousBytesRead )
             throws PwmUnrecoverableException
     {
-        final Instant startSkip = Instant.now();
-        final ConditionalTaskExecutor debugOutputter = new ConditionalTaskExecutor(
-                () -> getLogger().debug( () -> "continuing skipping forward in wordlist"
-                        + ", " + StringUtil.formatDiskSizeforDebug( zipFileReader.getByteCount() )
-                        + " of " + StringUtil.formatDiskSizeforDebug( previousBytesRead )
-                        + " (" + TimeDuration.compactFromCurrent( startSkip ) + ")" ),
-                new ConditionalTaskExecutor.TimeDurationPredicate( AbstractWordlist.DEBUG_OUTPUT_FREQUENCY )
-        );
+        final Instant startSkipTime = Instant.now();
 
-        getLogger().debug( () -> "will skip forward " + StringUtil.formatDiskSizeforDebug( previousBytesRead ) + " in stream that have been previously imported" );
-        while ( !cancelFlag.getAsBoolean() && bytesSkipped < ( previousBytesRead + 1024 ) )
+        if ( previousBytesRead > 0 )
         {
-            zipFileReader.nextLine();
-            bytesSkipped = zipFileReader.getByteCount();
-            debugOutputter.conditionallyExecuteTask();
+            final ConditionalTaskExecutor debugOutputter = ConditionalTaskExecutor.forPeriodicTask(
+                    () -> getLogger().debug( () -> "continuing skipping forward in wordlist, "
+                            + StringUtil.formatDiskSizeforDebug( zipFileReader.getByteCount() )
+                            + " of " + StringUtil.formatDiskSizeforDebug( previousBytesRead )
+                            + " (" + TimeDuration.compactFromCurrent( startSkipTime ) + ")" ),
+                    AbstractWordlist.DEBUG_OUTPUT_FREQUENCY );
+
+
+            getLogger().debug( () -> "will skip forward " + StringUtil.formatDiskSizeforDebug( previousBytesRead ) + " in wordlist that has been previously imported" );
+            while ( !cancelFlag.getAsBoolean() && bytesSkipped < ( previousBytesRead + 1024 ) )
+            {
+                zipFileReader.nextLine();
+                bytesSkipped = zipFileReader.getByteCount();
+                debugOutputter.conditionallyExecuteTask();
+            }
+            getLogger().debug( () -> "skipped forward " + StringUtil.formatDiskSizeforDebug( previousBytesRead )
+                    + " in stream (" + TimeDuration.fromCurrent( startSkipTime ).asCompactString() + ")" );
         }
-        getLogger().debug( () -> "skipped forward " + StringUtil.formatDiskSizeforDebug( previousBytesRead )
-                + " in stream (" + TimeDuration.fromCurrent( startSkip ).asCompactString() + ")" );
     }
 
     private String makeStatString()
@@ -353,7 +402,7 @@ class WordlistImporter implements Runnable
             }
             catch ( Exception e )
             {
-                getLogger().error( "error calculating " );
+                getLogger().error( "error calculating import statistics: " + e.getMessage() );
 
                 /* ignore - it's a long overflow if the estimate is off */
             }
@@ -365,7 +414,8 @@ class WordlistImporter implements Runnable
         stats.put( DebugKey.LinesRead, PwmNumberFormat.forDefaultLocale().format( zipFileReader.getLineCount() ) );
         stats.put( DebugKey.BytesRead, StringUtil.formatDiskSizeforDebug( zipFileReader.getByteCount() ) );
 
-        stats.put( DebugKey.BufferSize, PwmNumberFormat.forDefaultLocale().format( transactionCalculator.getTransactionSize() ) );
+        stats.put( DebugKey.WordsPerTxn, PwmNumberFormat.forDefaultLocale().format( (long) importStatistics.getWordsPerTransaction().getAverage() ) );
+        stats.put( DebugKey.CharsPerTxn, PwmNumberFormat.forDefaultLocale().format( (long) importStatistics.getCharsPerTransaction().getAverage() ) );
 
         if ( bytesSkipped > 0 )
         {
@@ -373,8 +423,54 @@ class WordlistImporter implements Runnable
         }
 
         stats.put( DebugKey.ImportTime, TimeDuration.fromCurrent( startTime ).asCompactString() );
+        stats.put( DebugKey.ZipFile, zipFileReader.currentZipName() );
+        stats.put( DebugKey.WordTypes, JsonUtil.serializeMap( seenWordTypes ) );
+
+        if ( importStatistics.getChunksPerWord().getAverage() > 1 )
+        {
+            final DecimalFormat decimalFormat = new DecimalFormat( "#.##" );
+            stats.put( DebugKey.ChunksPerWord, decimalFormat.format( importStatistics.getChunksPerWord().getAverage() ) );
+        }
+
+        if ( importStatistics.getAverageWordLength().getAverage() > 1 )
+        {
+            final DecimalFormat decimalFormat = new DecimalFormat( "#.##" );
+            stats.put( DebugKey.AvgWordLength, decimalFormat.format( importStatistics.getAverageWordLength().getAverage() ) );
+        }
+
+        try
+        {
+            stats.put( DebugKey.WordsImported, PwmNumberFormat.forDefaultLocale().format( wordlistBucket.size() ) );
+        }
+        catch ( PwmUnrecoverableException e )
+        {
+            getLogger().debug( () -> "error while calculating wordsImported stat during wordlist import: " + e.getMessage() );
+        }
 
         return Collections.unmodifiableMap( stats );
     }
 
+    private void writeCurrentWordlistStatus()
+    {
+        final Instant now = Instant.now();
+        rootWordlist.writeWordlistStatus( rootWordlist.readWordlistStatus().toBuilder()
+                .remoteInfo( wordlistSourceInfo )
+                .configHash( rootWordlist.getConfiguration().configHash() )
+                .storeDate( now )
+                .checkDate( now )
+                .sourceType( sourceType )
+                .completed( completed )
+                .wordTypes( new HashMap<>( seenWordTypes ) )
+                .bytes( zipFileReader.getByteCount() )
+                .build() );
+    }
+
+    @Value
+    private static class ImportStatistics
+    {
+        private final MovingAverage charsPerTransaction = new MovingAverage( TimeDuration.MINUTE );
+        private final MovingAverage wordsPerTransaction = new MovingAverage( TimeDuration.MINUTE );
+        private final MovingAverage chunksPerWord = new MovingAverage( TimeDuration.MINUTE );
+        private final MovingAverage averageWordLength = new MovingAverage( TimeDuration.MINUTE );
+    }
 }

+ 30 - 31
server/src/main/java/password/pwm/svc/wordlist/WordlistInspector.java

@@ -24,11 +24,11 @@ import password.pwm.PwmApplication;
 import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.util.java.StringUtil;
 import password.pwm.util.java.TimeDuration;
-import password.pwm.util.localdb.LocalDBException;
 import password.pwm.util.logging.PwmLogger;
 
 import java.io.IOException;
 import java.time.Instant;
+import java.util.Objects;
 import java.util.function.BooleanSupplier;
 
 class WordlistInspector implements Runnable
@@ -92,15 +92,15 @@ class WordlistInspector implements Runnable
 
         if ( autoImportUrlConfigured )
         {
-        try
-        {
-            checkAutoPopulation( existingStatus );
-        }
-        catch ( PwmUnrecoverableException e )
-        {
-            getLogger().error( "error importing auto-import wordlist: " + e.getMessage() );
-            rootWordlist.setAutoImportError( e.getErrorInformation() );
-        }
+            try
+            {
+                checkAutoPopulation( existingStatus );
+            }
+            catch ( PwmUnrecoverableException e )
+            {
+                getLogger().error( "error importing auto-import wordlist: " + e.getMessage() );
+                rootWordlist.setAutoImportError( e.getErrorInformation() );
+            }
         }
 
         existingStatus = rootWordlist.readWordlistStatus();
@@ -136,7 +136,7 @@ class WordlistInspector implements Runnable
             }
             else
             {
-                final WordlistSourceInfo builtInInfo = source.readRemoteWordlistInfo( cancelFlag );
+                final WordlistSourceInfo builtInInfo = source.readRemoteWordlistInfo( pwmApplication, cancelFlag, getLogger() );
                 if ( !builtInInfo.equals( existingStatus.getRemoteInfo() ) )
                 {
                     getLogger().debug( () -> "existing built-in store does not match imported wordlist, will re-import" );
@@ -173,6 +173,14 @@ class WordlistInspector implements Runnable
             return true;
         }
 
+        if ( !Objects.equals( wordlistStatus.getConfigHash(), rootWordlist.getConfiguration().configHash() ) )
+        {
+            getLogger().debug( () -> "stored configuration hash '" + wordlistStatus.getConfigHash()
+                    + "' does not match current configuration hash '"
+                    + rootWordlist.getConfiguration().configHash() + "', will clear" );
+            return true;
+        }
+
         switch ( wordlistStatus.getSourceType() )
         {
             case AutoImport:
@@ -231,7 +239,7 @@ class WordlistInspector implements Runnable
             final WordlistStatus wordlistStatus,
             final boolean autoImportUrlConfigured
     )
-            throws LocalDBException
+            throws PwmUnrecoverableException
     {
         if ( wordlistStatus.getSourceType() == null )
         {
@@ -264,7 +272,7 @@ class WordlistInspector implements Runnable
                 final WordlistSource testWordlistSource = WordlistSource.forAutoImport( pwmApplication, rootWordlist.getConfiguration() );
                 try
                 {
-                    testWordlistSource.readRemoteWordlistInfo( cancelFlag );
+                    testWordlistSource.readRemoteWordlistInfo( pwmApplication, cancelFlag, getLogger() );
                 }
                 catch ( PwmUnrecoverableException e )
                 {
@@ -280,10 +288,10 @@ class WordlistInspector implements Runnable
 
             case AutoImport:
             {
-                final Instant storageTime = wordlistStatus.getStoreDate();
-                final TimeDuration timeSinceCompletion = TimeDuration.fromCurrent( storageTime );
+                final Instant checkTime = wordlistStatus.getCheckDate() == null ? Instant.EPOCH : wordlistStatus.getCheckDate();
+                final TimeDuration timeSinceCheck = TimeDuration.fromCurrent( checkTime );
                 final TimeDuration recheckDuration = rootWordlist.getConfiguration().getAutoImportRecheckDuration();
-                if ( wordlistStatus.isCompleted() && timeSinceCompletion.isShorterThan( recheckDuration ) && autoImportUrlConfigured )
+                if ( wordlistStatus.isCompleted() && timeSinceCheck.isShorterThan( recheckDuration ) && autoImportUrlConfigured )
                 {
                     /*
                     getLogger().debug( "existing completed wordlist is "
@@ -308,7 +316,7 @@ class WordlistInspector implements Runnable
             throws IOException, PwmUnrecoverableException
     {
         final WordlistSource source = WordlistSource.forAutoImport( pwmApplication, rootWordlist.getConfiguration() );
-        final WordlistSourceInfo remoteInfo = source.readRemoteWordlistInfo( cancelFlag );
+        final WordlistSourceInfo remoteInfo = source.readRemoteWordlistInfo( pwmApplication, cancelFlag, getLogger() );
 
         boolean needsAutoImport = false;
         if ( remoteInfo == null )
@@ -333,13 +341,17 @@ class WordlistInspector implements Runnable
                 populateAutoImport( remoteInfo );
             }
         }
+
+        rootWordlist.writeWordlistStatus(
+                rootWordlist.readWordlistStatus().toBuilder()
+                        .checkDate( Instant.now() ).build() );
     }
 
     private void populateBuiltIn( final WordlistSourceType wordlistSourceType )
             throws IOException, PwmUnrecoverableException
     {
         final WordlistSource wordlistSource = WordlistSource.forBuiltIn( pwmApplication, rootWordlist.getConfiguration() );
-        final WordlistSourceInfo wordlistSourceInfo = wordlistSource.readRemoteWordlistInfo( cancelFlag );
+        final WordlistSourceInfo wordlistSourceInfo = wordlistSource.readRemoteWordlistInfo( pwmApplication, cancelFlag, getLogger() );
         final WordlistImporter wordlistImporter = new WordlistImporter(
                 wordlistSourceInfo,
                 wordlistSource.getZipWordlistReader(),
@@ -369,17 +381,4 @@ class WordlistInspector implements Runnable
     {
         return this.rootWordlist.getLogger();
     }
-
-
-    boolean needsRunningAgain()
-    {
-        final WordlistStatus wordlistStatus = rootWordlist.readWordlistStatus();
-
-        if ( wordlistStatus.isCompleted() )
-        {
-            return false;
-        }
-
-        return true;
-    }
 }

+ 1 - 2
server/src/main/java/password/pwm/svc/wordlist/WordlistService.java

@@ -48,9 +48,8 @@ public class WordlistService extends AbstractWordlist implements Wordlist
         return LOGGER;
     }
 
-    @Override
     public boolean containsWord( final String word ) throws PwmUnrecoverableException
     {
-        return super.containsWord( word );
+        return super.containsWord( this.getWordTypesCache(), word );
     }
 }

+ 52 - 42
server/src/main/java/password/pwm/svc/wordlist/WordlistSource.java

@@ -20,7 +20,6 @@
 
 package password.pwm.svc.wordlist;
 
-import org.apache.commons.io.IOUtils;
 import org.apache.commons.io.input.CountingInputStream;
 import org.apache.commons.io.output.NullOutputStream;
 import password.pwm.AppProperty;
@@ -37,30 +36,33 @@ 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.secure.ChecksumInputStream;
 
 import java.io.IOException;
 import java.io.InputStream;
 import java.net.URL;
+import java.security.DigestInputStream;
 import java.time.Instant;
 import java.util.function.BooleanSupplier;
 
 class WordlistSource
 {
-    private static final PwmLogger LOGGER = PwmLogger.forClass( WordlistSource.class );
 
     private final WordlistSourceType wordlistSourceType;
     private final StreamProvider streamProvider;
     private final String importUrl;
 
-    private WordlistSource( final WordlistSourceType wordlistSourceType, final String importUrl, final StreamProvider streamProvider )
+    private WordlistSource(
+            final WordlistSourceType wordlistSourceType,
+            final String importUrl,
+            final StreamProvider streamProvider
+    )
     {
         this.wordlistSourceType = wordlistSourceType;
         this.importUrl = importUrl;
         this.streamProvider = streamProvider;
     }
 
-    public WordlistSourceType getWordlistSourceType()
+    private WordlistSourceType getWordlistSourceType()
     {
         return wordlistSourceType;
     }
@@ -107,12 +109,22 @@ class WordlistSource
         return new WordlistSource( WordlistSourceType.BuiltIn, null, () ->
         {
             final ContextManager contextManager = pwmApplication.getPwmEnvironment().getContextManager();
+            final String wordlistFilename = pwmApplication.getConfig().readAppProperty( wordlistConfiguration.getBuiltInWordlistLocationProperty() );
+            final InputStream inputStream;
             if ( contextManager != null )
             {
-                final String wordlistFilename = pwmApplication.getConfig().readAppProperty( wordlistConfiguration.getBuiltInWordlistLocationProperty() );
-                return contextManager.getResourceAsStream( wordlistFilename );
+                inputStream = contextManager.getResourceAsStream( wordlistFilename );
+            }
+            else
+            {
+                inputStream = new URL( wordlistFilename ).openStream();
+            }
+
+            if ( inputStream == null )
+            {
+                throw new PwmUnrecoverableException( new ErrorInformation( PwmError.ERROR_SERVICE_NOT_AVAILABLE, "unable to locate builtin wordlist file" ) );
             }
-            throw new PwmUnrecoverableException( new ErrorInformation( PwmError.ERROR_SERVICE_NOT_AVAILABLE, "unable to locate builtin wordlist file" ) );
+            return inputStream;
         }
         );
     }
@@ -124,56 +136,45 @@ class WordlistSource
     }
 
     WordlistSourceInfo readRemoteWordlistInfo(
-            final BooleanSupplier cancelFlag
+            final PwmApplication pwmApplication,
+            final BooleanSupplier cancelFlag,
+            final PwmLogger pwmLogger
     )
             throws PwmUnrecoverableException
     {
-        final int buffersize = 128_1024;
-        InputStream inputStream = null;
+        final Instant startTime = Instant.now();
 
-        try
+        if ( cancelFlag.getAsBoolean() )
         {
-            final Instant startTime = Instant.now();
-            LOGGER.debug( () -> "reading file info for " + this.getWordlistSourceType() + " wordlist" );
-
-            inputStream = this.streamProvider.getInputStream();
+            return null;
+        }
 
-            final ChecksumInputStream checksumInputStream = new ChecksumInputStream( inputStream );
-            final CountingInputStream countingInputStream = new CountingInputStream( checksumInputStream );
+        pwmLogger.debug( () -> "begin reading file info for " + this.getWordlistSourceType() + " wordlist" );
 
+        final long bytes;
+        final String hash;
+        try (
+                InputStream inputStream = this.streamProvider.getInputStream();
+                DigestInputStream checksumInputStream = pwmApplication.getSecureService().digestInputStream( WordlistConfiguration.HASH_ALGORITHM, inputStream );
+                CountingInputStream countingInputStream = new CountingInputStream( checksumInputStream );
+        )
+        {
             final ConditionalTaskExecutor debugOutputter = new ConditionalTaskExecutor(
-                    () -> LOGGER.debug( () -> "continuing reading file info for " + getWordlistSourceType() + " wordlist"
+                    () -> pwmLogger.debug( () -> "continuing reading file info for " + getWordlistSourceType() + " wordlist"
                             + " " + StringUtil.formatDiskSizeforDebug( countingInputStream.getByteCount() )
                             + " (" + TimeDuration.compactFromCurrent( startTime ) + ")" ),
                     new ConditionalTaskExecutor.TimeDurationPredicate( AbstractWordlist.DEBUG_OUTPUT_FREQUENCY )
             );
 
-            final long bytes = JavaHelper.copyWhilePredicate(
+            bytes = JavaHelper.copyWhilePredicate(
                     countingInputStream,
                     new NullOutputStream(),
-                    buffersize, o -> !cancelFlag.getAsBoolean(),
+                    WordlistConfiguration.STREAM_BUFFER_SIZE, o -> !cancelFlag.getAsBoolean(),
                     debugOutputter );
 
-            if ( cancelFlag.getAsBoolean() )
-            {
-                return null;
-            }
-
-            final String hash = checksumInputStream.checksum();
-
-            final WordlistSourceInfo wordlistSourceInfo = new WordlistSourceInfo(
-                    hash,
-                    bytes,
-                    importUrl
-            );
-
-            LOGGER.debug( () -> "completed read of data for " + this.getWordlistSourceType() + " wordlist"
-                    + " " + StringUtil.formatDiskSizeforDebug( countingInputStream.getByteCount() )
-                    + ", " + JsonUtil.serialize( wordlistSourceInfo )
-                    + " (" + TimeDuration.compactFromCurrent( startTime ) + ")" );
-            return wordlistSourceInfo;
+            hash = JavaHelper.byteArrayToHexString( checksumInputStream.getMessageDigest().digest() );
         }
-        catch ( Exception e )
+        catch ( IOException e )
         {
             final ErrorInformation errorInformation = new ErrorInformation(
                     PwmError.ERROR_WORDLIST_IMPORT_ERROR,
@@ -181,9 +182,18 @@ class WordlistSource
             );
             throw new PwmUnrecoverableException( errorInformation );
         }
-        finally
+
+        if ( cancelFlag.getAsBoolean() )
         {
-            IOUtils.closeQuietly( inputStream );
+            return null;
         }
+
+        final WordlistSourceInfo wordlistSourceInfo = new WordlistSourceInfo( hash, bytes, importUrl );
+
+        pwmLogger.debug( () -> "completed read of data for " + this.getWordlistSourceType() + " wordlist"
+                + " " + StringUtil.formatDiskSizeforDebug( bytes )
+                + ", " + JsonUtil.serialize( wordlistSourceInfo )
+                + " (" + TimeDuration.compactFromCurrent( startTime ) + ")" );
+        return wordlistSourceInfo;
     }
 }

+ 1 - 1
server/src/main/java/password/pwm/svc/wordlist/WordlistSourceInfo.java

@@ -27,7 +27,7 @@ import java.io.Serializable;
 @Value
 public class WordlistSourceInfo implements Serializable
 {
-    private String checksum;
+    private String hash;
     private long bytes;
     private String importUrl;
 }

+ 63 - 0
server/src/main/java/password/pwm/svc/wordlist/WordlistStatistics.java

@@ -0,0 +1,63 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2019 The PWM Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package password.pwm.svc.wordlist;
+
+import lombok.Value;
+import password.pwm.util.java.AtomicLoopLongIncrementer;
+import password.pwm.util.java.MovingAverage;
+import password.pwm.util.java.TimeDuration;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.TreeMap;
+
+@Value
+class WordlistStatistics
+{
+    private MovingAverage wordCheckTimeMS = new MovingAverage( TimeDuration.of( 5, TimeDuration.Unit.MINUTES ) );
+    private MovingAverage chunksPerWordCheck = new MovingAverage( TimeDuration.of( 1, TimeDuration.Unit.DAYS ) );
+    private AtomicLoopLongIncrementer wordChecks = new AtomicLoopLongIncrementer( 0, Long.MAX_VALUE );
+    private Map<WordType, AtomicLoopLongIncrementer> wordTypeHits = new HashMap<>(  );
+    private AtomicLoopLongIncrementer misses = new AtomicLoopLongIncrementer( 0, Long.MAX_VALUE );
+
+    WordlistStatistics()
+    {
+        for ( final WordType wordType : WordType.values() )
+        {
+            wordTypeHits.put( wordType, new AtomicLoopLongIncrementer( 0, Long.MAX_VALUE ) );
+        }
+    }
+
+    Map<String, String> asDebugMap()
+    {
+        final Map<String, String> outputMap = new TreeMap<>(  );
+        outputMap.put( "AvgLocalDBWordCheckTimeMS", Double.toString( wordCheckTimeMS.getAverage() ) );
+        outputMap.put( "ChunksPerCheck", Double.toString( chunksPerWordCheck.getAverage() ) );
+        outputMap.put( "LocalDBWordChecks", Long.toString( wordChecks.get() ) );
+        outputMap.put( "Misses", Long.toString( misses.get() ) );
+        for ( final Map.Entry<WordType, AtomicLoopLongIncrementer> entry : wordTypeHits.entrySet() )
+        {
+            outputMap.put( "Hits-" + entry.getKey().name(), Long.toString( entry.getValue().get() ) );
+        }
+        return Collections.unmodifiableMap( outputMap );
+    }
+}

+ 8 - 1
server/src/main/java/password/pwm/svc/wordlist/WordlistStatus.java

@@ -25,12 +25,14 @@ import lombok.Value;
 
 import java.io.Serializable;
 import java.time.Instant;
+import java.util.HashMap;
+import java.util.Map;
 
 @Value
 @Builder( toBuilder = true )
 public class WordlistStatus implements Serializable
 {
-    public static final int CURRENT_VERSION = 5;
+    public static final int CURRENT_VERSION = 7;
 
     @Builder.Default
     private int version = CURRENT_VERSION;
@@ -38,7 +40,12 @@ public class WordlistStatus implements Serializable
     private boolean completed;
     private WordlistSourceType sourceType;
     private Instant storeDate;
+    private Instant checkDate;
     private WordlistSourceInfo remoteInfo;
     private long bytes;
     private long valueCount;
+    private String configHash;
+
+    @Builder.Default
+    private Map<WordType, Long> wordTypes = new HashMap<>();
 }

+ 1 - 1
server/src/main/java/password/pwm/svc/wordlist/WordlistType.java

@@ -32,7 +32,7 @@ public enum WordlistType
         switch ( this )
         {
             case WORDLIST:
-                return pwmApplication.getWordlistManager();
+                return pwmApplication.getWordlistService();
 
             case SEEDLIST:
                 return pwmApplication.getSeedlistManager();

+ 78 - 0
server/src/main/java/password/pwm/svc/wordlist/WordlistUtil.java

@@ -0,0 +1,78 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2019 The PWM Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package password.pwm.svc.wordlist;
+
+import password.pwm.util.java.StringUtil;
+
+import java.util.Collections;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeSet;
+
+class WordlistUtil
+{
+    static Set<String> chunkWord( final String input, final int chunkSize )
+    {
+        if ( StringUtil.isEmpty( input ) )
+        {
+            return Collections.emptySet();
+        }
+
+        if ( chunkSize == 0 || chunkSize >= input.length() )
+        {
+            return Collections.singleton( input );
+        }
+
+        final TreeSet<String> testWords = new TreeSet<>();
+        final int maxIndex = input.length() - chunkSize;
+        for ( int i = 0; i <= maxIndex; i++ )
+        {
+            final String loopWord = input.substring( i, i + chunkSize );
+            testWords.add( loopWord );
+        }
+
+        return testWords;
+    }
+
+    static Optional<String> normalizeWordLength( final String input, final WordlistConfiguration wordlistConfiguration )
+    {
+        if ( input == null )
+        {
+            return Optional.empty();
+        }
+
+        String word = input.trim();
+
+        if ( word.length() < wordlistConfiguration.getMinWordSize() )
+        {
+            return Optional.empty();
+        }
+
+        if ( word.length() > wordlistConfiguration.getMaxWordSize() )
+        {
+            word = word.substring( 0, wordlistConfiguration.getMaxWordSize() );
+        }
+
+        return word.length() > 0 ? Optional.of( word ) : Optional.empty();
+    }
+
+
+}

+ 3 - 10
server/src/main/java/password/pwm/svc/wordlist/WordlistZipReader.java

@@ -25,13 +25,13 @@ import password.pwm.PwmConstants;
 import password.pwm.error.PwmError;
 import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.util.logging.PwmLogger;
-import password.pwm.util.secure.ChecksumInputStream;
 
 import java.io.BufferedReader;
 import java.io.Closeable;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
+import java.util.Objects;
 import java.util.concurrent.atomic.AtomicLong;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipInputStream;
@@ -41,11 +41,9 @@ import java.util.zip.ZipInputStream;
  */
 class WordlistZipReader implements AutoCloseable, Closeable
 {
-
     private static final PwmLogger LOGGER = PwmLogger.forClass( WordlistZipReader.class );
 
     private final ZipInputStream zipStream;
-    private final ChecksumInputStream checksumInputStream;
     private final CountingInputStream countingInputStream;
     private final AtomicLong lineCounter = new AtomicLong( 0 );
 
@@ -54,9 +52,9 @@ class WordlistZipReader implements AutoCloseable, Closeable
 
     WordlistZipReader( final InputStream inputStream ) throws PwmUnrecoverableException
     {
-        checksumInputStream = new ChecksumInputStream( inputStream );
-        countingInputStream = new CountingInputStream( checksumInputStream );
+        Objects.requireNonNull( inputStream );
 
+        countingInputStream = new CountingInputStream( inputStream );
         zipStream = new ZipInputStream( countingInputStream );
         nextZipEntry();
         if ( zipEntry == null )
@@ -155,9 +153,4 @@ class WordlistZipReader implements AutoCloseable, Closeable
     {
         return countingInputStream.getByteCount();
     }
-
-    String getChecksum()
-    {
-        return checksumInputStream.checksum();
-    }
 }

+ 1 - 85
server/src/main/java/password/pwm/util/EventRateMeter.java

@@ -20,6 +20,7 @@
 
 package password.pwm.util;
 
+import password.pwm.util.java.MovingAverage;
 import password.pwm.util.java.TimeDuration;
 
 import java.io.Serializable;
@@ -68,89 +69,4 @@ public class EventRateMeter implements Serializable
         return new BigDecimal( this.movingAverage.getAverage() );
     }
 
-    /**
-     * <p>MovingAverage.java</p>
-     *
-     * <p>Copyright 2009-2010 Comcast Interactive Media, LLC.</p>
-     *
-     * <p>Licensed under the Apache License, Version 2.0 (the "License");
-     * you may not use this file except in compliance with the License.
-     * You may obtain a copy of the License at</p>
-     *
-     * <p>http://www.apache.org/licenses/LICENSE-2.0</p>
-     *
-     * <p>Unless required by applicable law or agreed to in writing, software
-     * distributed under the License is distributed on an "AS IS" BASIS,
-     * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-     * See the License for the specific language governing permissions and
-     * limitations under the License.</p>
-     *
-     * <p>This class implements an exponential moving average, using the
-     * algorithm described at <a href="http://en.wikipedia.org/wiki/Moving_average">http://en.wikipedia.org/wiki/Moving_average</a>. The average does not
-     * sample itself; it merely computes the new average when updated with
-     * a sample by an external mechanism.</p>
-     **/
-    public static class MovingAverage implements Serializable
-    {
-        private long windowMillis;
-        private long lastMillis;
-        private double average;
-
-        /**
-         * Construct a {@link MovingAverage}, providing the time window
-         * we want the average over. For example, providing a value of
-         * 3,600,000 provides a moving average over the last hour.
-         *
-         * @param windowMillis the length of the sliding window in
-         *                     milliseconds
-         */
-        public MovingAverage( final long windowMillis )
-        {
-            this.windowMillis = windowMillis;
-        }
-
-        public MovingAverage( final TimeDuration timeDuration )
-        {
-            this.windowMillis = timeDuration.asMillis();
-        }
-
-        /**
-         * Updates the average with the latest measurement.
-         *
-         * @param sample the latest measurement in the rolling average
-         */
-        public synchronized void update( final double sample )
-        {
-            final long now = System.currentTimeMillis();
-
-            if ( lastMillis == 0 )
-            {
-                // first sample
-                average = sample;
-                lastMillis = now;
-                return;
-            }
-            final long deltaTime = now - lastMillis;
-            final double coeff = Math.exp( -1.0 * ( ( double ) deltaTime / windowMillis ) );
-            average = ( 1.0 - coeff ) * sample + coeff * average;
-
-            lastMillis = now;
-        }
-
-        /**
-         * Returns the last computed average value.
-         *
-         * @return current average value
-         */
-        public double getAverage( )
-        {
-            update( 0 );
-            return average;
-        }
-
-        public long getLastMillis( )
-        {
-            return lastMillis;
-        }
-    }
 }

+ 2 - 0
server/src/main/java/password/pwm/util/cli/MainClass.java

@@ -50,6 +50,7 @@ import password.pwm.util.cli.commands.ExportLocalDBCommand;
 import password.pwm.util.cli.commands.ExportLogsCommand;
 import password.pwm.util.cli.commands.ExportResponsesCommand;
 import password.pwm.util.cli.commands.ExportStatsCommand;
+import password.pwm.util.cli.commands.ExportWordlistCommand;
 import password.pwm.util.cli.commands.HelpCommand;
 import password.pwm.util.cli.commands.ImportHttpsKeyStoreCommand;
 import password.pwm.util.cli.commands.ImportLocalDBCommand;
@@ -128,6 +129,7 @@ public class MainClass
         commandList.add( new HelpCommand() );
         commandList.add( new ImportPropertyConfigCommand() );
         commandList.add( new ResetInstanceIDCommand() );
+        commandList.add( new ExportWordlistCommand() );
 
         final Map<String, CliCommand> sortedMap = new TreeMap<>();
         for ( final CliCommand command : commandList )

+ 1 - 1
server/src/main/java/password/pwm/util/cli/commands/ExportLocalDBCommand.java

@@ -47,7 +47,7 @@ public class ExportLocalDBCommand extends AbstractCliCommand
         final LocalDBUtility localDBUtility = new LocalDBUtility( localDB );
         try ( FileOutputStream fileOutputStream = new FileOutputStream( outputFile ) )
         {
-            localDBUtility.exportLocalDB( fileOutputStream, System.out, true );
+            localDBUtility.exportLocalDB( fileOutputStream, System.out );
         }
         catch ( PwmOperationalException e )
         {

+ 71 - 0
server/src/main/java/password/pwm/util/cli/commands/ExportWordlistCommand.java

@@ -0,0 +1,71 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2019 The PWM Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package password.pwm.util.cli.commands;
+
+import password.pwm.error.PwmOperationalException;
+import password.pwm.util.cli.CliParameters;
+import password.pwm.util.localdb.LocalDB;
+import password.pwm.util.localdb.LocalDBUtility;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.util.Collections;
+
+public class ExportWordlistCommand extends AbstractCliCommand
+{
+    @Override
+    void doCommand( )
+            throws Exception
+    {
+        final LocalDB localDB = cliEnvironment.getLocalDB();
+
+        final File outputFile = ( File ) cliEnvironment.getOptions().get( CliParameters.REQUIRED_NEW_OUTPUT_FILE.getName() );
+        if ( outputFile.exists() )
+        {
+            out( "outputFile for ExportWordlist cannot already exist" );
+            return;
+        }
+
+        final LocalDBUtility localDBUtility = new LocalDBUtility( localDB );
+        try ( FileOutputStream fileOutputStream = new FileOutputStream( outputFile ) )
+        {
+            localDBUtility.exportWordlist( fileOutputStream, System.out );
+        }
+        catch ( PwmOperationalException e )
+        {
+            out( "error during export: " + e.getMessage() );
+        }
+    }
+
+    @Override
+    public CliParameters getCliParameters( )
+    {
+        final CliParameters cliParameters = new CliParameters();
+        cliParameters.commandName = "ExportWordlist";
+        cliParameters.description = "Export the currently loaded wordlist contents to a wordlist zip file";
+        cliParameters.options = Collections.singletonList( CliParameters.REQUIRED_NEW_OUTPUT_FILE );
+
+        cliParameters.needsLocalDB = true;
+        cliParameters.readOnly = true;
+
+        return cliParameters;
+    }
+}

+ 1 - 2
server/src/main/java/password/pwm/util/cli/commands/ResponseStatsCommand.java

@@ -21,7 +21,6 @@
 package password.pwm.util.cli.commands;
 
 import com.novell.ldapchai.cr.Challenge;
-import com.novell.ldapchai.exception.ChaiOperationException;
 import com.novell.ldapchai.exception.ChaiUnavailableException;
 import password.pwm.AppProperty;
 import password.pwm.PwmApplication;
@@ -159,7 +158,7 @@ public class ResponseStatsCommand extends AbstractCliCommand
     private static List<UserIdentity> readAllUsersFromLdap(
             final PwmApplication pwmApplication
     )
-            throws ChaiUnavailableException, ChaiOperationException, PwmUnrecoverableException, PwmOperationalException
+            throws PwmUnrecoverableException, PwmOperationalException
     {
         final List<UserIdentity> returnList = new ArrayList<>();
 

+ 68 - 0
server/src/main/java/password/pwm/util/java/AverageTracker.java

@@ -0,0 +1,68 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2019 The PWM Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package password.pwm.util.java;
+
+import java.math.BigDecimal;
+import java.math.BigInteger;
+import java.math.MathContext;
+import java.util.LinkedList;
+import java.util.Queue;
+
+public class AverageTracker
+{
+    private final int maxSamples;
+    private final Queue<BigInteger> samples = new LinkedList<>();
+
+    public AverageTracker( final int maxSamples )
+    {
+        this.maxSamples = maxSamples;
+    }
+
+    public void addSample( final long input )
+    {
+        samples.add( new BigInteger( Long.toString( input ) ) );
+        while ( samples.size() > maxSamples )
+        {
+            samples.remove();
+        }
+    }
+
+    public BigDecimal avg( )
+    {
+        if ( samples.isEmpty() )
+        {
+            throw new IllegalStateException( "unable to compute avg without samples" );
+        }
+
+        BigInteger total = BigInteger.ZERO;
+        for ( final BigInteger sample : samples )
+        {
+            total = total.add( sample );
+        }
+        final BigDecimal maxAsBD = new BigDecimal( Integer.toString( maxSamples ) );
+        return new BigDecimal( total ).divide( maxAsBD, MathContext.DECIMAL32 );
+    }
+
+    public long avgAsLong( )
+    {
+        return avg().longValue();
+    }
+}

+ 7 - 13
server/src/main/java/password/pwm/util/java/JavaHelper.java

@@ -30,7 +30,6 @@ import password.pwm.config.PwmSetting;
 import password.pwm.http.ContextManager;
 import password.pwm.util.logging.PwmLogger;
 
-import javax.annotation.CheckReturnValue;
 import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
@@ -278,7 +277,7 @@ public class JavaHelper
             final OutputStream output,
             final int bufferSize,
             final Predicate<Long> predicate,
-            final ConditionalTaskExecutor condtionalTaskExecutor
+            final ConditionalTaskExecutor conditionalTaskExecutor
     )
             throws IOException
     {
@@ -292,9 +291,9 @@ public class JavaHelper
             {
                 totalCopied += bytesCopied;
             }
-            if ( condtionalTaskExecutor != null )
+            if ( conditionalTaskExecutor != null )
             {
-                condtionalTaskExecutor.conditionallyExecuteTask();
+                conditionalTaskExecutor.conditionallyExecuteTask();
             }
             if ( !predicate.test( bytesCopied ) )
             {
@@ -332,32 +331,27 @@ public class JavaHelper
         return Instant.parse( input );
     }
 
-    @CheckReturnValue( when = javax.annotation.meta.When.NEVER )
-    public static boolean closeAndWaitExecutor( final ExecutorService executor, final TimeDuration timeDuration )
+    public static void closeAndWaitExecutor( final ExecutorService executor, final TimeDuration timeDuration )
     {
         if ( executor == null )
         {
-            return true;
+            return;
         }
 
         executor.shutdown();
         try
         {
-            return executor.awaitTermination( timeDuration.asMillis(), TimeUnit.MILLISECONDS );
+            executor.awaitTermination( timeDuration.asMillis(), TimeUnit.MILLISECONDS );
         }
         catch ( InterruptedException e )
         {
             LOGGER.warn( "unexpected error shutting down executor service " + executor.getClass().toString() + " error: " + e.getMessage() );
         }
-        return false;
     }
 
     public static Collection<Method> getAllMethodsForClass( final Class clazz )
     {
-        final LinkedHashSet<Method> methods = new LinkedHashSet<>();
-
-        // add local methods;
-        methods.addAll( Arrays.asList( clazz.getDeclaredMethods() ) );
+        final LinkedHashSet<Method> methods = new LinkedHashSet<>( Arrays.asList( clazz.getDeclaredMethods() ) );
 
         final Class superClass = clazz.getSuperclass();
         if ( superClass != null )

+ 46 - 0
server/src/main/java/password/pwm/util/java/LazySupplier.java

@@ -0,0 +1,46 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2019 The PWM Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package password.pwm.util.java;
+
+import java.util.function.Supplier;
+
+public class LazySupplier<T> implements Supplier<T>
+{
+    private boolean supplied = false;
+    private T value;
+    private final Supplier<T> realSupplier;
+
+    public LazySupplier( final Supplier<T> realSupplier )
+    {
+        this.realSupplier = realSupplier;
+    }
+
+    @Override
+    public T get()
+    {
+        if ( !supplied )
+        {
+            value = realSupplier.get();
+            supplied = true;
+        }
+        return value;
+    }
+}

+ 109 - 0
server/src/main/java/password/pwm/util/java/MovingAverage.java

@@ -0,0 +1,109 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2019 The PWM Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package password.pwm.util.java;
+
+import java.io.Serializable;
+
+/**
+ * <p>MovingAverage.java</p>
+ *
+ * <p>Copyright 2009-2010 Comcast Interactive Media, LLC.</p>
+ *
+ * <p>Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at</p>
+ *
+ * <p>http://www.apache.org/licenses/LICENSE-2.0</p>
+ *
+ * <p>Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.</p>
+ *
+ * <p>This class implements an exponential moving average, using the
+ * algorithm described at <a href="http://en.wikipedia.org/wiki/Moving_average">http://en.wikipedia.org/wiki/Moving_average</a>. The average does not
+ * sample itself; it merely computes the new average when updated with
+ * a sample by an external mechanism.</p>
+ **/
+public class MovingAverage implements Serializable
+{
+    private long windowMillis;
+    private long lastMillis;
+    private double average;
+
+    /**
+     * Construct a {@link MovingAverage}, providing the time window
+     * we want the average over. For example, providing a value of
+     * 3,600,000 provides a moving average over the last hour.
+     *
+     * @param windowMillis the length of the sliding window in
+     *                     milliseconds
+     */
+    public MovingAverage( final long windowMillis )
+    {
+        this.windowMillis = windowMillis;
+    }
+
+    public MovingAverage( final TimeDuration timeDuration )
+    {
+        this.windowMillis = timeDuration.asMillis();
+    }
+
+    /**
+     * Updates the average with the latest measurement.
+     *
+     * @param sample the latest measurement in the rolling average
+     */
+    public synchronized void update( final double sample )
+    {
+        final long now = System.currentTimeMillis();
+
+        if ( lastMillis == 0 )
+        {
+            // first sample
+            average = sample;
+            lastMillis = now;
+            return;
+        }
+        final long deltaTime = now - lastMillis;
+        final double coeff = Math.exp( -1.0 * ( ( double ) deltaTime / windowMillis ) );
+        average = ( 1.0 - coeff ) * sample + coeff * average;
+
+        lastMillis = now;
+    }
+
+    /**
+     * Returns the last computed average value.
+     *
+     * @return current average value
+     */
+    public double getAverage( )
+    {
+        update( 0 );
+        return average;
+    }
+
+    public long getLastMillis( )
+    {
+        return lastMillis;
+    }
+}

+ 95 - 58
server/src/main/java/password/pwm/util/localdb/LocalDBUtility.java

@@ -32,6 +32,8 @@ import password.pwm.util.ProgressInfo;
 import password.pwm.util.TransactionSizeCalculator;
 import password.pwm.util.java.ConditionalTaskExecutor;
 import password.pwm.util.java.JavaHelper;
+import password.pwm.util.java.Percent;
+import password.pwm.util.java.PwmNumberFormat;
 import password.pwm.util.java.StringUtil;
 import password.pwm.util.java.TimeDuration;
 import password.pwm.util.logging.PwmLogger;
@@ -45,17 +47,17 @@ import java.io.OutputStream;
 import java.io.PrintStream;
 import java.io.Reader;
 import java.math.RoundingMode;
-import java.text.DecimalFormat;
 import java.time.Instant;
 import java.util.Date;
 import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.Map;
-import java.util.Timer;
-import java.util.TimerTask;
+import java.util.Objects;
 import java.util.TreeMap;
 import java.util.zip.GZIPInputStream;
 import java.util.zip.GZIPOutputStream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
 
 public class LocalDBUtility
 {
@@ -66,7 +68,7 @@ public class LocalDBUtility
     private final LocalDB localDB;
     private int exportLineCounter;
 
-    private static final int GZIP_BUFFER_SIZE = 1024 * 512;
+    private static final int GZIP_BUFFER_SIZE = 1024 * 1024;
 
 
     public LocalDBUtility( final LocalDB localDB )
@@ -74,68 +76,48 @@ public class LocalDBUtility
         this.localDB = localDB;
     }
 
-    public void exportLocalDB( final OutputStream outputStream, final Appendable debugOutput, final boolean showLineCount )
-            throws PwmOperationalException, IOException
+    private long countBackupableRecords( final Appendable debugOutput )
+            throws LocalDBException
     {
-        if ( outputStream == null )
+        long counter = 0;
+        writeStringToOut( debugOutput, "counting records in LocalDB..." );
+        for ( final LocalDB.DB loopDB : LocalDB.DB.values() )
         {
-            throw new PwmOperationalException( PwmError.ERROR_INTERNAL, "outputFileStream for exportLocalDB cannot be null" );
-        }
-
-
-        final int totalLines;
-        if ( showLineCount )
-        {
-            writeStringToOut( debugOutput, "counting records in LocalDB..." );
-            exportLineCounter = 0;
-            for ( final LocalDB.DB loopDB : LocalDB.DB.values() )
+            if ( loopDB.isBackup() )
             {
-                if ( loopDB.isBackup() )
-                {
-                    exportLineCounter += localDB.size( loopDB );
-                }
+                counter += localDB.size( loopDB );
             }
-            totalLines = exportLineCounter;
-            writeStringToOut( debugOutput, " total lines: " + totalLines );
-        }
-        else
-        {
-            totalLines = 0;
         }
+        writeStringToOut( debugOutput, " total lines: " + counter );
+        return counter;
+    }
+
+    public void exportLocalDB( final OutputStream outputStream, final Appendable debugOutput )
+            throws PwmOperationalException
+    {
+        Objects.requireNonNull( outputStream );
         exportLineCounter = 0;
 
-        writeStringToOut( debugOutput, "export beginning" );
-        final long startTime = System.currentTimeMillis();
-        final Timer statTimer = new Timer( true );
-        statTimer.schedule( new TimerTask()
-        {
-            @Override
-            public void run( )
-            {
-                if ( showLineCount )
-                {
-                    final float percentComplete = ( float ) exportLineCounter / ( float ) totalLines;
-                    final String percentStr = DecimalFormat.getPercentInstance().format( percentComplete );
-                    writeStringToOut( debugOutput, "exported " + exportLineCounter + " records, " + percentStr + " complete" );
-                }
-                else
-                {
-                    writeStringToOut( debugOutput, "exported " + exportLineCounter + " records" );
-                }
-            }
-        }, 30 * 1000, 30 * 1000 );
+        final long totalLines = countBackupableRecords( debugOutput );
+
 
+        writeStringToOut( debugOutput, "LocalDB export beginning of " + totalLines + " records" );
+        final Instant startTime = Instant.now();
+
+        final EventRateMeter eventRateMeter = new EventRateMeter( TimeDuration.MINUTE );
+        final ConditionalTaskExecutor debugOutputter = ConditionalTaskExecutor.forPeriodicTask( () ->
+                        outputExportDebugStats( totalLines, eventRateMeter, startTime, debugOutput ),
+                TimeDuration.MINUTE );
 
         try ( CSVPrinter csvPrinter = JavaHelper.makeCsvPrinter( new GZIPOutputStream( outputStream, GZIP_BUFFER_SIZE ) ) )
         {
-            csvPrinter.printComment( PwmConstants.PWM_APP_NAME + " " + PwmConstants.SERVLET_VERSION + " LocalDB export on " + JavaHelper.toIsoDate( new Date() ) );
+            csvPrinter.printComment( PwmConstants.PWM_APP_NAME + " " + PwmConstants.SERVLET_VERSION + " LocalDB export on " + JavaHelper.toIsoDate( Instant.now() ) );
             for ( final LocalDB.DB loopDB : LocalDB.DB.values() )
             {
                 if ( loopDB.isBackup() )
                 {
                     csvPrinter.printComment( "Export of " + loopDB.toString() );
-                    final LocalDB.LocalDBIterator<String> localDBIterator = localDB.iterator( loopDB );
-                    try
+                    try ( LocalDB.LocalDBIterator<String> localDBIterator = localDB.iterator( loopDB ) )
                     {
                         while ( localDBIterator.hasNext() )
                         {
@@ -143,12 +125,10 @@ public class LocalDBUtility
                             final String value = localDB.get( loopDB, key );
                             csvPrinter.printRecord( loopDB.toString(), key, value );
                             exportLineCounter++;
+                            eventRateMeter.markEvents( 1 );
+                            debugOutputter.conditionallyExecuteTask();
                         }
                     }
-                    finally
-                    {
-                        localDBIterator.close();
-                    }
                     csvPrinter.flush();
                 }
             }
@@ -158,14 +138,71 @@ public class LocalDBUtility
         {
             writeStringToOut( debugOutput, "IO error during localDB export: " + e.getMessage() );
         }
-        finally
+
+        writeStringToOut( debugOutput, "export complete, exported " + exportLineCounter + " records in " + TimeDuration.fromCurrent( startTime ).asLongString() );
+    }
+
+    public void exportWordlist( final OutputStream outputStream, final Appendable debugOutput )
+            throws PwmOperationalException, IOException
+    {
+        Objects.requireNonNull( outputStream );
+
+        final long totalLines = localDB.size( LocalDB.DB.WORDLIST_WORDS );
+
+        exportLineCounter = 0;
+
+        writeStringToOut( debugOutput, "Wordlist ZIP export beginning of "
+                + StringUtil.formatDiskSize( totalLines ) + " records" );
+        final Instant startTime = Instant.now();
+
+        final EventRateMeter eventRateMeter = new EventRateMeter( TimeDuration.MINUTE );
+        final ConditionalTaskExecutor debugOutputter = ConditionalTaskExecutor.forPeriodicTask( () ->
+                        outputExportDebugStats( totalLines, eventRateMeter, startTime, debugOutput ),
+                TimeDuration.MINUTE );
+
+        try ( ZipOutputStream zipOutputStream = new ZipOutputStream( outputStream, PwmConstants.DEFAULT_CHARSET ) )
         {
-            statTimer.cancel();
+            zipOutputStream.putNextEntry( new ZipEntry( "wordlist.txt" ) );
+            try ( LocalDB.LocalDBIterator<String> localDBIterator = localDB.iterator( LocalDB.DB.WORDLIST_WORDS ) )
+            {
+                while ( localDBIterator.hasNext() )
+                {
+                    final String key = localDBIterator.next();
+                    zipOutputStream.write( key.getBytes( PwmConstants.DEFAULT_CHARSET ) );
+                    zipOutputStream.write( '\n' );
+                    exportLineCounter++;
+                    eventRateMeter.markEvents( 1 );
+                    debugOutputter.conditionallyExecuteTask();
+                }
+            }
+        }
+        catch ( IOException e )
+        {
+            writeStringToOut( debugOutput, "IO error during localDB export: " + e.getMessage() );
         }
 
         writeStringToOut( debugOutput, "export complete, exported " + exportLineCounter + " records in " + TimeDuration.fromCurrent( startTime ).asLongString() );
     }
 
+    private void outputExportDebugStats(
+            final long totalLines,
+            final EventRateMeter eventRateMeter,
+            final Instant startTime,
+            final Appendable debugOutput
+    )
+    {
+        final Percent percentComplete = new Percent( exportLineCounter, totalLines );
+        final String percentStr = percentComplete.pretty( 2 );
+        final long secondsRemaining = totalLines / eventRateMeter.readEventRate().longValue();
+
+        final String msg = "export stats: recordsOut=" + PwmNumberFormat.forDefaultLocale().format( exportLineCounter )
+                + ", duration=" + percentStr
+                + ", percentComplete=" + TimeDuration.fromCurrent( startTime ).asCompactString()
+                + ", recordsPerSecond=" + PwmNumberFormat.forDefaultLocale().format( eventRateMeter.readEventRate().longValue() )
+                + ", remainingTime=" + TimeDuration.of( secondsRemaining, TimeDuration.Unit.SECONDS ).asCompactString();
+        writeStringToOut( debugOutput, msg );
+    }
+
     private static void writeStringToOut( final Appendable out, final String string )
     {
         if ( out == null )
@@ -173,7 +210,7 @@ public class LocalDBUtility
             return;
         }
 
-        final String msg = JavaHelper.toIsoDate( new Date() ) + " " + string + "\n";
+        final String msg = string + "\n";
 
         try
         {
@@ -181,7 +218,7 @@ public class LocalDBUtility
         }
         catch ( IOException e )
         {
-            LOGGER.error( "error writing to output appender while performing operation: " + e.getMessage() + ", message:" + msg );
+            LOGGER.error( "error writing to output appender while performing operation: " + e.getMessage() );
         }
     }
 

+ 4 - 3
server/src/main/java/password/pwm/util/localdb/WorkQueueProcessor.java

@@ -32,12 +32,13 @@ import password.pwm.util.PwmScheduler;
 import password.pwm.util.java.AtomicLoopIntIncrementer;
 import password.pwm.util.java.JavaHelper;
 import password.pwm.util.java.JsonUtil;
+import password.pwm.util.java.MovingAverage;
 import password.pwm.util.java.StringUtil;
 import password.pwm.util.java.TimeDuration;
 import password.pwm.util.logging.PwmLogger;
 
 import java.io.Serializable;
-import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.time.Instant;
 import java.util.Collections;
 import java.util.Deque;
@@ -74,7 +75,7 @@ public final class WorkQueueProcessor<W extends Serializable>
 
     private ThreadPoolExecutor executorService;
 
-    private final EventRateMeter.MovingAverage avgLagTime = new EventRateMeter.MovingAverage( TimeDuration.HOUR );
+    private final MovingAverage avgLagTime = new MovingAverage( TimeDuration.HOUR );
     private final EventRateMeter sendRate = new EventRateMeter( TimeDuration.HOUR );
 
     private final AtomicInteger preQueueSubmit = new AtomicInteger( 0 );
@@ -583,7 +584,7 @@ public final class WorkQueueProcessor<W extends Serializable>
     {
         final Map<String, String> output = new HashMap<>();
         output.put( "avgLagTime", TimeDuration.of( ( long ) avgLagTime.getAverage(), TimeDuration.Unit.MILLISECONDS ).asCompactString() );
-        output.put( "sendRate", sendRate.readEventRate().setScale( 2, BigDecimal.ROUND_DOWN ) + "/s" );
+        output.put( "sendRate", sendRate.readEventRate().setScale( 2, RoundingMode.DOWN ) + "/s" );
         output.put( "preQueueSubmit", String.valueOf( preQueueSubmit.get() ) );
         output.put( "preQueueBypass", String.valueOf( preQueueBypass.get() ) );
         output.put( "preQueueFallback", String.valueOf( preQueueFallback.get() ) );

+ 1 - 2
server/src/main/java/password/pwm/util/localdb/XodusLocalDB.java

@@ -232,7 +232,7 @@ public class XodusLocalDB implements LocalDBProvider
     }
 
     @Override
-    public LocalDB.LocalDBIterator<String> iterator( final LocalDB.DB db ) throws LocalDBException
+    public LocalDB.LocalDBIterator<String> iterator( final LocalDB.DB db )  throws LocalDBException
     {
         return new InnerIterator( db );
     }
@@ -407,7 +407,6 @@ public class XodusLocalDB implements LocalDBProvider
         } );
     }
 
-
     @Override
     public void truncate( final LocalDB.DB db ) throws LocalDBException
     {

+ 1 - 1
server/src/main/java/password/pwm/util/operations/CrService.java

@@ -301,7 +301,7 @@ public class CrService implements PwmService
 
         {
             // check responses against wordlist
-            final WordlistService wordlistManager = pwmApplication.getWordlistManager();
+            final WordlistService wordlistManager = pwmApplication.getWordlistService();
             if ( wordlistManager.status() == PwmService.STATUS.OPEN )
             {
                 for ( final Map.Entry<Challenge, String> entry : responseMap.entrySet() )

+ 7 - 4
server/src/main/java/password/pwm/util/password/PasswordRuleChecks.java

@@ -52,6 +52,9 @@ import java.util.Map;
 import java.util.Set;
 import java.util.regex.Pattern;
 
+/**
+ * Contains validation logic for the most of the "internal" {@link PwmPasswordRule} rules.
+ */
 public class PasswordRuleChecks
 {
     private static final PwmLogger LOGGER = PwmLogger.forClass( PasswordRuleChecks.class );
@@ -101,7 +104,7 @@ public class PasswordRuleChecks
     ) );
 
 
-        public static List<ErrorInformation> extendedPolicyRuleChecker(
+    public static List<ErrorInformation> extendedPolicyRuleChecker(
             final PwmApplication pwmApplication,
             final PwmPasswordPolicy policy,
             final String password,
@@ -318,7 +321,7 @@ public class PasswordRuleChecks
                 final int maxLower = ruleHelper.readIntValue( PwmPasswordRule.MaximumLowerCase );
                 if ( maxLower > 0 && numberOfLowerChars > maxLower )
                 {
-                    errorList.add( new ErrorInformation( PwmError.PASSWORD_TOO_MANY_UPPER ) );
+                    errorList.add( new ErrorInformation( PwmError.PASSWORD_TOO_MANY_LOWER ) );
                 }
             }
             return Collections.unmodifiableList( errorList );
@@ -723,9 +726,9 @@ public class PasswordRuleChecks
             {
                 if ( pwmApplication != null )
                 {
-                    if ( pwmApplication.getWordlistManager() != null && pwmApplication.getWordlistManager().status() == PwmService.STATUS.OPEN )
+                    if ( pwmApplication.getWordlistService() != null && pwmApplication.getWordlistService().status() == PwmService.STATUS.OPEN )
                     {
-                        final boolean found = pwmApplication.getWordlistManager().containsWord( password );
+                        final boolean found = pwmApplication.getWordlistService().containsWord( password );
 
                         if ( found )
                         {

+ 12 - 5
server/src/main/java/password/pwm/util/secure/PwmHashAlgorithm.java

@@ -22,20 +22,27 @@ package password.pwm.util.secure;
 
 public enum PwmHashAlgorithm
 {
-    MD5( "MD5" ),
-    SHA1( "SHA1" ),
-    SHA256( "SHA-256" ),
-    SHA512( "SHA-512" ),;
+    MD5( "MD5", 32 ),
+    SHA1( "SHA1", 40 ),
+    SHA256( "SHA-256", 64 ),
+    SHA512( "SHA-512", 128 ),;
 
     private final String algName;
+    private final int hexValueLength;
 
-    PwmHashAlgorithm( final String algName )
+    PwmHashAlgorithm( final String algName, final int hexValueLength )
     {
         this.algName = algName;
+        this.hexValueLength = hexValueLength;
     }
 
     public String getAlgName( )
     {
         return algName;
     }
+
+    public int getHexValueLength()
+    {
+        return hexValueLength;
+    }
 }

+ 39 - 0
server/src/main/java/password/pwm/util/secure/SecureService.java

@@ -23,6 +23,7 @@ package password.pwm.util.secure;
 import password.pwm.AppProperty;
 import password.pwm.PwmApplication;
 import password.pwm.config.Configuration;
+import password.pwm.error.PwmError;
 import password.pwm.error.PwmException;
 import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.health.HealthRecord;
@@ -33,7 +34,11 @@ import password.pwm.util.logging.PwmLogger;
 
 import java.io.File;
 import java.io.IOException;
+import java.io.InputStream;
 import java.io.Serializable;
+import java.security.DigestInputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
 import java.util.List;
 
 public class SecureService implements PwmService
@@ -158,6 +163,40 @@ public class SecureService implements PwmService
         return SecureEngine.hash( input, defaultHashAlgorithm );
     }
 
+    public String hash(
+            final PwmHashAlgorithm pwmHashAlgorithm,
+            final String input
+    )
+            throws PwmUnrecoverableException
+    {
+        return SecureEngine.hash( input, pwmHashAlgorithm );
+    }
+
+    public DigestInputStream digestInputStream(
+            final InputStream inputStream
+    )
+            throws PwmUnrecoverableException
+    {
+        return digestInputStream( this.getDefaultHashAlgorithm(), inputStream );
+    }
+
+    public DigestInputStream digestInputStream(
+            final PwmHashAlgorithm pwmHashAlgorithm,
+            final InputStream inputStream
+    )
+            throws PwmUnrecoverableException
+    {
+        try
+        {
+            final MessageDigest messageDigest = MessageDigest.getInstance( pwmHashAlgorithm.getAlgName() );
+            return new DigestInputStream( inputStream, messageDigest );
+        }
+        catch ( NoSuchAlgorithmException e )
+        {
+            throw PwmUnrecoverableException.newException( PwmError.ERROR_CRYPT_ERROR, "can't create digest inputstream: " + e.getMessage() );
+        }
+    }
+
     public String hash(
             final byte[] input
     )

+ 1 - 1
server/src/main/java/password/pwm/util/secure/X509Utils.java

@@ -260,7 +260,7 @@ public abstract class X509Utils
         {
             final List<X509Certificate> asList = Arrays.asList( chain );
             certificates.addAll( JavaHelper.enumArrayContainsValue( readCertificateFlags, ReadCertificateFlag.ReadOnlyRootCA )
-                    ? identifyRootCACertificate( certificates )
+                    ? identifyRootCACertificate( asList )
                     : asList );
             wrappedTrustManager.checkServerTrusted( chain, authType );
         }

+ 1 - 1
server/src/main/java/password/pwm/ws/server/RestServlet.java

@@ -102,7 +102,7 @@ public abstract class RestServlet extends HttpServlet
                     "rest-" + REQUEST_COUNTER.next(),
                     null,
                     null,
-                    RequestInitializationFilter.readUserIPAddress( req, pwmApplication.getConfig() ),
+                    RequestInitializationFilter.readUserNetworkAddress( req, pwmApplication.getConfig() ),
                     RequestInitializationFilter.readUserHostname( req, pwmApplication.getConfig() )
             );
         }

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

@@ -340,8 +340,11 @@ wordlist.minCharLength=2
 wordlist.import.autoImportRecheckSeconds=432000
 wordlist.import.durationGoalMS=1000
 wordlist.import.minTransactions=10
-wordlist.import.maxTransactions=200000
+wordlist.import.maxTransactions=100000
+wordlist.import.maxCharsTransactions=10485760
+wordlist.import.lineComments=!#comment:
 wordlist.inspector.frequencySeconds=300
+wordlist.testMode=false
 ws.restClient.pwRule.haltOnError=true
 ws.restServer.signing.form.timeoutSeconds=120
 ws.restServer.statistics.defaultHistoryDays=7

+ 140 - 0
server/src/test/java/password/pwm/http/filter/RequestInitializationFilterTest.java

@@ -0,0 +1,140 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2019 The PWM Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package password.pwm.http.filter;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.mockito.Mockito;
+import password.pwm.config.Configuration;
+import password.pwm.config.PwmSetting;
+import password.pwm.config.stored.StoredConfigurationImpl;
+import password.pwm.config.value.BooleanValue;
+import password.pwm.error.PwmUnrecoverableException;
+import password.pwm.http.HttpHeader;
+
+import javax.servlet.http.HttpServletRequest;
+
+public class RequestInitializationFilterTest
+{
+    @Test
+    public void readUserNetworkAddressTest()
+            throws PwmUnrecoverableException
+    {
+        final Configuration conf = new Configuration( StoredConfigurationImpl.newStoredConfiguration() );
+        final HttpServletRequest mockRequest = Mockito.mock( HttpServletRequest.class );
+        Mockito.when( mockRequest.getRemoteAddr() ).thenReturn( "10.1.1.1" );
+
+        final String resultIP = RequestInitializationFilter.readUserNetworkAddress( mockRequest, conf );
+        Assert.assertEquals( "10.1.1.1", resultIP );
+    }
+
+    @Test
+    public void readUserNetworkAddressTestBogus()
+            throws PwmUnrecoverableException
+    {
+        final Configuration conf = new Configuration( StoredConfigurationImpl.newStoredConfiguration() );
+        final HttpServletRequest mockRequest = Mockito.mock( HttpServletRequest.class );
+        Mockito.when( mockRequest.getRemoteAddr() ).thenReturn( "10.1.1.1m" );
+
+        final String resultIP = RequestInitializationFilter.readUserNetworkAddress( mockRequest, conf );
+        Assert.assertEquals( "", resultIP );
+    }
+
+    @Test
+    public void readUserNetworkAddressTestXForward()
+            throws PwmUnrecoverableException
+    {
+        final Configuration conf = new Configuration( StoredConfigurationImpl.newStoredConfiguration() );
+        final HttpServletRequest mockRequest = Mockito.mock( HttpServletRequest.class );
+        Mockito.when( mockRequest.getRemoteAddr() ).thenReturn( "10.1.1.1" );
+        Mockito.when( mockRequest.getHeader( HttpHeader.XForwardedFor.getHttpName() ) ).thenReturn( "10.1.1.2" );
+
+        final String resultIP = RequestInitializationFilter.readUserNetworkAddress( mockRequest, conf );
+        Assert.assertEquals( "10.1.1.2", resultIP );
+    }
+
+    @Test
+    public void readUserNetworkAddressTestBogusXForward()
+            throws PwmUnrecoverableException
+    {
+        final Configuration conf = new Configuration( StoredConfigurationImpl.newStoredConfiguration() );
+        final HttpServletRequest mockRequest = Mockito.mock( HttpServletRequest.class );
+        Mockito.when( mockRequest.getRemoteAddr() ).thenReturn( "10.1.1.1" );
+        Mockito.when( mockRequest.getHeader( HttpHeader.XForwardedFor.getHttpName() ) ).thenReturn( "10.1.1.2a" );
+
+        final String resultIP = RequestInitializationFilter.readUserNetworkAddress( mockRequest, conf );
+        Assert.assertEquals( "10.1.1.1", resultIP );
+    }
+
+    @Test
+    public void readUserNetworkAddressTestMultipleXForward()
+            throws PwmUnrecoverableException
+    {
+        final Configuration conf = new Configuration( StoredConfigurationImpl.newStoredConfiguration() );
+        final HttpServletRequest mockRequest = Mockito.mock( HttpServletRequest.class );
+        Mockito.when( mockRequest.getRemoteAddr() ).thenReturn( "10.1.1.1" );
+        Mockito.when( mockRequest.getHeader( HttpHeader.XForwardedFor.getHttpName() ) ).thenReturn( "10.1.1.2, 10.1.1.3" );
+
+        final String resultIP = RequestInitializationFilter.readUserNetworkAddress( mockRequest, conf );
+        Assert.assertEquals( "10.1.1.2", resultIP );
+    }
+
+    @Test
+    public void readUserNetworkAddressTestMultipleBogusXForward()
+            throws PwmUnrecoverableException
+    {
+        final Configuration conf = new Configuration( StoredConfigurationImpl.newStoredConfiguration() );
+        final HttpServletRequest mockRequest = Mockito.mock( HttpServletRequest.class );
+        Mockito.when( mockRequest.getRemoteAddr() ).thenReturn( "10.1.1.1" );
+        Mockito.when( mockRequest.getHeader( HttpHeader.XForwardedFor.getHttpName() ) ).thenReturn( "10.1.1.2a, 10.1.1.3" );
+
+        final String resultIP = RequestInitializationFilter.readUserNetworkAddress( mockRequest, conf );
+        Assert.assertEquals( "10.1.1.3", resultIP );
+    }
+
+    @Test
+    public void readUserNetworkAddressTestIPv6()
+            throws PwmUnrecoverableException
+    {
+        final Configuration conf = new Configuration( StoredConfigurationImpl.newStoredConfiguration() );
+        final HttpServletRequest mockRequest = Mockito.mock( HttpServletRequest.class );
+        Mockito.when( mockRequest.getRemoteAddr() ).thenReturn( "10.1.1.1" );
+        Mockito.when( mockRequest.getHeader( HttpHeader.XForwardedFor.getHttpName() ) ).thenReturn( "10.1.1.2a, 2001:0db8:85a3:0000:0000:8a2e:0370:7334" );
+
+        final String resultIP = RequestInitializationFilter.readUserNetworkAddress( mockRequest, conf );
+        Assert.assertEquals( "2001:0db8:85a3:0000:0000:8a2e:0370:7334", resultIP );
+    }
+
+    @Test
+    public void readUserNetworkAddressTestDisabledXForwardedFor()
+            throws PwmUnrecoverableException
+    {
+        final StoredConfigurationImpl storedConfiguration = StoredConfigurationImpl.newStoredConfiguration();
+        storedConfiguration.writeSetting( PwmSetting.USE_X_FORWARDED_FOR_HEADER, new BooleanValue( false ), null );
+        final Configuration conf = new Configuration( storedConfiguration );
+        final HttpServletRequest mockRequest = Mockito.mock( HttpServletRequest.class );
+        Mockito.when( mockRequest.getRemoteAddr() ).thenReturn( "10.1.1.1" );
+        Mockito.when( mockRequest.getHeader( HttpHeader.XForwardedFor.getHttpName() ) ).thenReturn( "10.1.1.2" );
+
+        final String resultIP = RequestInitializationFilter.readUserNetworkAddress( mockRequest, conf );
+        Assert.assertEquals( "10.1.1.1", resultIP );
+    }
+}

+ 47 - 0
server/src/test/java/password/pwm/svc/wordlist/WordTypeTest.java

@@ -0,0 +1,47 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2019 The PWM Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package password.pwm.svc.wordlist;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+public class WordTypeTest
+{
+    @Test
+    public void testDetermineWordTypes()
+    {
+        Assert.assertEquals( WordType.RAW, WordType.determineWordType( "password" ) );
+
+        Assert.assertEquals( WordType.RAW, WordType.determineWordType( "sha1:password" ) );
+        Assert.assertEquals( WordType.RAW, WordType.determineWordType( "sha1:5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD" ) );
+        Assert.assertEquals( WordType.RAW, WordType.determineWordType( "sha1:5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD80" ) );
+
+        Assert.assertEquals( WordType.SHA1, WordType.determineWordType( "sha1:5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8" ) );
+        Assert.assertEquals( WordType.SHA1, WordType.determineWordType( "SHA1:5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8" ) );
+        Assert.assertEquals( WordType.SHA1, WordType.determineWordType( "sha1:5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8" ) );
+        Assert.assertEquals( WordType.SHA1, WordType.determineWordType( "SHA1:5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8" ) );
+
+        Assert.assertEquals( WordType.MD5, WordType.determineWordType( "md5:5F4DCC3B5AA765D61D8327DEB882CF99" ) );
+        Assert.assertEquals( WordType.SHA256, WordType.determineWordType( "sha256:5E884898DA28047151D0E56F8DC6292773603D0D6AABBDD62A11EF721D1542D8" ) );
+        Assert.assertEquals( WordType.SHA512, WordType.determineWordType(
+                "sha512:B109F3BBBC244EB82441917ED06D618B9008DD09B3BEFD1B5E07394C706A8BB980B1D7785E5976EC049B46DF5F1326AF5A2EA6D103FD07C95385FFAB0CACBC86" ) );
+    }
+}

+ 163 - 0
server/src/test/java/password/pwm/svc/wordlist/WordlistServiceTest.java

@@ -0,0 +1,163 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2019 The PWM Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package password.pwm.svc.wordlist;
+
+import org.junit.Assert;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.mockito.Mockito;
+import password.pwm.AppProperty;
+import password.pwm.PwmApplication;
+import password.pwm.config.Configuration;
+import password.pwm.config.PwmSetting;
+import password.pwm.config.stored.StoredConfigurationImpl;
+import password.pwm.util.localdb.TestHelper;
+
+import java.net.URL;
+
+public class WordlistServiceTest
+{
+
+    @Rule
+    public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+    @Test
+    public void testTypicalWordlist()
+            throws Exception
+    {
+        final WordlistService wordlistService = makeWordlistService( null );
+
+        Assert.assertTrue( wordlistService.containsWord( "password-test" ) );
+        Assert.assertFalse( wordlistService.containsWord( "password-false-test" ) );
+
+        Assert.assertFalse( wordlistService.containsWord( "0" ) );
+        Assert.assertFalse( wordlistService.containsWord( "01" ) );
+        Assert.assertFalse( wordlistService.containsWord( "012" ) );
+        Assert.assertFalse( wordlistService.containsWord( "0123" ) );
+        Assert.assertFalse( wordlistService.containsWord( "01234" ) );
+        Assert.assertFalse( wordlistService.containsWord( "012345" ) );
+        Assert.assertTrue( wordlistService.containsWord( "0123456" ) );
+        Assert.assertTrue( wordlistService.containsWord( "01234567" ) );
+        Assert.assertTrue( wordlistService.containsWord( "012345678" ) );
+        Assert.assertTrue( wordlistService.containsWord( "0123456789" ) );
+
+        Assert.assertTrue( wordlistService.containsWord( "abcdefghijklmnopqrstuvwxyz" ) );
+        Assert.assertTrue( wordlistService.containsWord( "AbcdefghijklmnopqrstuvwxyZ" ) );
+        Assert.assertTrue( wordlistService.containsWord( "ABCDEFGHIJKLMNOPQRSTUVWXYZ" ) );
+
+        // make
+        Assert.assertTrue( wordlistService.containsWord( "md5-Password-Test" ) );
+        Assert.assertFalse( wordlistService.containsWord( "md5-Password-Test-false" ) );
+        Assert.assertTrue( wordlistService.containsWord( "md5-Password-Test-Reverse" ) );
+        Assert.assertFalse( wordlistService.containsWord( "md5-Password-Test-Reverse-false" ) );
+        Assert.assertTrue( wordlistService.containsWord( "sha1-Password-Test" ) );
+        Assert.assertFalse( wordlistService.containsWord( "sha1-Password-Test-false" ) );
+        Assert.assertTrue( wordlistService.containsWord( "sha1-Password-Test-Reverse" ) );
+        Assert.assertFalse( wordlistService.containsWord( "sha1-Password-Test-Reverse-false" ) );
+        Assert.assertTrue( wordlistService.containsWord( "sha256-Password-Test" ) );
+        Assert.assertFalse( wordlistService.containsWord( "sha256-Password-Test-false" ) );
+        Assert.assertTrue( wordlistService.containsWord( "sha256-Password-Test-Reverse" ) );
+        Assert.assertFalse( wordlistService.containsWord( "sha256-Password-Test-Reverse-false" ) );
+        Assert.assertTrue( wordlistService.containsWord( "sha512-Password-Test" ) );
+        Assert.assertFalse( wordlistService.containsWord( "sha512-Password-Test-false" ) );
+        Assert.assertTrue( wordlistService.containsWord( "sha512-Password-Test-Reverse" ) );
+        Assert.assertFalse( wordlistService.containsWord( "sha512-Password-Test-Reverse-false" ) );
+
+        // make sure single line comment isn't imported as workd
+        Assert.assertFalse( wordlistService.containsWord( "!#comment!" ) );
+
+        // make sure raw hashes aren't imported
+        Assert.assertFalse( wordlistService.containsWord( "6D3A08CFF825AA07DCEC94801D9B7647" ) );
+        Assert.assertFalse( wordlistService.containsWord( "BB231388547E063CCFFDD0282C37184A" ) );
+        Assert.assertFalse( wordlistService.containsWord( "4B0ABDCB3430D57D0581A9D617B8ABCD3202D992" ) );
+        Assert.assertFalse( wordlistService.containsWord( "056DA0B59D7C1622B8F60726DE8E25BC771D5E89" ) );
+        Assert.assertFalse( wordlistService.containsWord( "970FE1C94E532597BB8EF9BE7F397C7C8052127B6C21F443608322B3EF01176C" ) );
+        Assert.assertFalse( wordlistService.containsWord( "A96A0E4DB996D5A4B35558BDDB54BBF389FF853E349700F6FE9F96DD4441BD48" ) );
+        Assert.assertFalse( wordlistService.containsWord(
+                "F910C640F9E720EE4E9D785101CED049B9C9A385D610E46BF8026FA9D3BC169637C0538A8361ADCB5C641079604F7C9CBAD6ED07F646D85DF83BB69E713739C4" ) );
+        Assert.assertFalse( wordlistService.containsWord(
+                "3FA6580F55AA7F5337031895239E6C2B022A9A87A7FFC72041F8E080DC9F19CFA43EE862471829E9B556A4D9AF201476E508E6A312204641F604DFBE4240907F" ) );
+        Assert.assertFalse( wordlistService.containsWord( "md5:6D3A08CFF825AA07DCEC94801D9B7647" ) );
+        Assert.assertFalse( wordlistService.containsWord( "BB231388547E063CCFFDD0282C37184A:md5" ) );
+        Assert.assertFalse( wordlistService.containsWord( "sha1:4B0ABDCB3430D57D0581A9D617B8ABCD3202D992" ) );
+        Assert.assertFalse( wordlistService.containsWord( "056DA0B59D7C1622B8F60726DE8E25BC771D5E89:sha1" ) );
+        Assert.assertFalse( wordlistService.containsWord( "sha256:970FE1C94E532597BB8EF9BE7F397C7C8052127B6C21F443608322B3EF01176C" ) );
+        Assert.assertFalse( wordlistService.containsWord( "A96A0E4DB996D5A4B35558BDDB54BBF389FF853E349700F6FE9F96DD4441BD48:sha256" ) );
+        Assert.assertFalse( wordlistService.containsWord(
+                "sha512:F910C640F9E720EE4E9D785101CED049B9C9A385D610E46BF8026FA9D3BC169637C0538A8361ADCB5C641079604F7C9CBAD6ED07F646D85DF83BB69E713739C4" ) );
+        Assert.assertFalse( wordlistService.containsWord(
+                "3FA6580F55AA7F5337031895239E6C2B022A9A87A7FFC72041F8E080DC9F19CFA43EE862471829E9B556A4D9AF201476E508E6A312204641F604DFBE4240907F:Sha512" ) );
+
+    }
+
+    @Test
+    public void testCaseSensitiveWordlist()
+            throws Exception
+    {
+        final Configuration configuration = Mockito.spy( new Configuration( StoredConfigurationImpl.newStoredConfiguration() ) );
+        Mockito.when( configuration.readSettingAsBoolean( PwmSetting.WORDLIST_CASE_SENSITIVE ) ).thenReturn( true );
+        final WordlistService wordlistService = makeWordlistService( configuration );
+
+        Assert.assertTrue( wordlistService.containsWord( "password-test" ) );
+        Assert.assertFalse( wordlistService.containsWord( "PASSWORD-TEST" ) );
+
+        Assert.assertTrue( wordlistService.containsWord( "abcdefghijklmnopqrstuvwxyz" ) );
+        Assert.assertFalse( wordlistService.containsWord( "AbcdefghijklmnopqrstuvwxyZ" ) );
+        Assert.assertTrue( wordlistService.containsWord( "ABCDEFGHIJKLMNOPQRSTUVWXYZ" ) );
+    }
+
+    @Test
+    public void testChunkedWords()
+            throws Exception
+    {
+        final Configuration configuration = Mockito.spy( new Configuration( StoredConfigurationImpl.newStoredConfiguration() ) );
+        Mockito.when( configuration.readSettingAsLong( PwmSetting.PASSWORD_WORDLIST_WORDSIZE ) ).thenReturn( 4L );
+        final WordlistService wordlistService = makeWordlistService( configuration );
+
+        Assert.assertTrue( wordlistService.containsWord( "abcdefghijklmnopqrstuvwxyz" ) );
+        Assert.assertTrue( wordlistService.containsWord( "ABCDEFGHIJKLMNOPQRSTUVWXYZ" ) );
+
+        Assert.assertFalse( wordlistService.containsWord( "A" ) );
+        Assert.assertFalse( wordlistService.containsWord( "AB" ) );
+        Assert.assertFalse( wordlistService.containsWord( "ABC" ) );
+        Assert.assertTrue( wordlistService.containsWord( "ABCD" ) );
+        Assert.assertTrue( wordlistService.containsWord( "ABCDE" ) );
+        Assert.assertTrue( wordlistService.containsWord( "ABCde" ) );
+    }
+
+    private WordlistService makeWordlistService( final Configuration inputConfiguration )
+            throws Exception
+    {
+
+        final Configuration configuration = inputConfiguration == null
+                ? Mockito.spy( new Configuration( StoredConfigurationImpl.newStoredConfiguration() ) )
+                : inputConfiguration;
+        Mockito.when( configuration.readAppProperty( AppProperty.WORDLIST_TEST_MODE ) ).thenReturn( "true" );
+        Mockito.when( configuration.readSettingAsString( PwmSetting.WORDLIST_FILENAME ) ).thenReturn( "" );
+
+        final URL url = this.getClass().getResource( "test-wordlist.zip" );
+        Mockito.when( configuration.readAppProperty( AppProperty.WORDLIST_BUILTIN_PATH ) ).thenReturn( url.toString() );
+
+        final PwmApplication pwmApplication = TestHelper.makeTestPwmApplication( temporaryFolder.newFolder(), configuration );
+        return pwmApplication.getWordlistService();
+    }
+}

+ 49 - 0
server/src/test/java/password/pwm/svc/wordlist/WordlistUtilTest.java

@@ -0,0 +1,49 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2019 The PWM Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package password.pwm.svc.wordlist;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+public class WordlistUtilTest
+{
+    @Test
+    public void testChunkWordSized()
+    {
+        final String input = "zoogam";
+        final Set<String> expectedOutput = new HashSet<>( Arrays.asList( "zoo", "oog", "gam", "oga", "gam" ) );
+        final Set<String> output = WordlistUtil.chunkWord( input, 3 );
+        Assert.assertEquals( expectedOutput, output );
+    }
+
+    @Test
+    public void testChunkWordNoSize()
+    {
+        final String input = "zoogam";
+        final Set<String> expectedOutput = new HashSet<>( Arrays.asList( "zoogam" ) );
+        final Set<String> output = WordlistUtil.chunkWord( input, 0 );
+        Assert.assertEquals( expectedOutput, output );
+    }
+}

+ 326 - 71
server/src/test/java/password/pwm/util/password/PasswordRuleChecksTest.java

@@ -28,10 +28,11 @@ import password.pwm.error.ErrorInformation;
 import password.pwm.error.PwmError;
 import password.pwm.error.PwmUnrecoverableException;
 
-import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 public class PasswordRuleChecksTest
 {
@@ -41,20 +42,16 @@ public class PasswordRuleChecksTest
     {
         final Map<String, String> policyMap = new HashMap<>();
         policyMap.put( PwmPasswordRule.MinimumLength.getKey(), "7" );
-        policyMap.put( PwmPasswordRule.AllowNumeric.getKey(), "true" );
-
-        {
-            final List<ErrorInformation> expectedErrors = new ArrayList<>();
-            expectedErrors.add( new ErrorInformation( PwmError.PASSWORD_TOO_SHORT, null ) );
 
-            Assert.assertTrue( doCompareTest( policyMap, "123", expectedErrors ) );
-            Assert.assertTrue( doCompareTest( policyMap, "1234", expectedErrors ) );
-            Assert.assertTrue( doCompareTest( policyMap, "12345", expectedErrors ) );
-            Assert.assertTrue( doCompareTest( policyMap, "123456", expectedErrors ) );
+        // violations
+        Assert.assertThat( doCheck( policyMap, "123" ), hasItems( PwmError.PASSWORD_TOO_SHORT ) );
+        Assert.assertThat( doCheck( policyMap, "1234" ), hasItems( PwmError.PASSWORD_TOO_SHORT ) );
+        Assert.assertThat( doCheck( policyMap, "12345" ), hasItems( PwmError.PASSWORD_TOO_SHORT ) );
+        Assert.assertThat( doCheck( policyMap, "123456" ),  hasItems( PwmError.PASSWORD_TOO_SHORT ) );
 
-            Assert.assertFalse( doCompareTest( policyMap, "1234567", expectedErrors ) );
-            Assert.assertFalse( doCompareTest( policyMap, "12345678", expectedErrors ) );
-        }
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "1234567" ), not( hasItems( PwmError.PASSWORD_TOO_SHORT ) ) );
+        Assert.assertThat( doCheck( policyMap, "12345678" ), not( hasItems( PwmError.PASSWORD_TOO_SHORT ) ) );
     }
 
     @Test
@@ -63,21 +60,227 @@ public class PasswordRuleChecksTest
     {
         final Map<String, String> policyMap = new HashMap<>();
         policyMap.put( PwmPasswordRule.MaximumLength.getKey(), "7" );
+
+        // violations
+        Assert.assertThat( doCheck( policyMap, "12345678" ), hasItems( PwmError.PASSWORD_TOO_LONG ) );
+        Assert.assertThat( doCheck( policyMap, "123456789" ), hasItems( PwmError.PASSWORD_TOO_LONG ) );
+
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "123" ), not( hasItems( PwmError.PASSWORD_TOO_LONG ) ) );
+        Assert.assertThat( doCheck( policyMap, "1234" ), not( hasItems( PwmError.PASSWORD_TOO_LONG ) ) );
+        Assert.assertThat( doCheck( policyMap, "12345" ), not( hasItems( PwmError.PASSWORD_TOO_LONG ) ) );
+        Assert.assertThat( doCheck( policyMap, "123456" ),  not( hasItems( PwmError.PASSWORD_TOO_LONG ) ) );
+        Assert.assertThat( doCheck( policyMap, "1234567" ),  not( hasItems( PwmError.PASSWORD_TOO_LONG ) ) );
+    }
+
+    @Test
+    public void minimumUpperCaseTest()
+            throws Exception
+    {
+        final Map<String, String> policyMap = new HashMap<>();
+        policyMap.put( PwmPasswordRule.MinimumUpperCase.getKey(), "3" );
+
+        // violations
+        Assert.assertThat( doCheck( policyMap, "A" ), hasItems( PwmError.PASSWORD_NOT_ENOUGH_UPPER ) );
+        Assert.assertThat( doCheck( policyMap, "AB" ), hasItems( PwmError.PASSWORD_NOT_ENOUGH_UPPER ) );
+        Assert.assertThat( doCheck( policyMap, "ABc" ),  hasItems( PwmError.PASSWORD_NOT_ENOUGH_UPPER ) );
+
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "ABC" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_UPPER ) ) );
+        Assert.assertThat( doCheck( policyMap, "ABCD" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_UPPER ) ) );
+        Assert.assertThat( doCheck( policyMap, "ABCDe" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_UPPER ) ) );
+        Assert.assertThat( doCheck( policyMap, "123456ABC" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_UPPER ) ) );
+    }
+
+    @Test
+    public void maximumUpperCaseTest()
+            throws Exception
+    {
+        final Map<String, String> policyMap = new HashMap<>();
+        policyMap.put( PwmPasswordRule.MaximumUpperCase.getKey(), "3" );
+
+        // violations
+        Assert.assertThat( doCheck( policyMap, "ABCD" ), hasItems( PwmError.PASSWORD_TOO_MANY_UPPER ) );
+        Assert.assertThat( doCheck( policyMap, "ABCDE" ), hasItems( PwmError.PASSWORD_TOO_MANY_UPPER ) );
+
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "A" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_UPPER ) ) );
+        Assert.assertThat( doCheck( policyMap, "AB" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_UPPER ) ) );
+        Assert.assertThat( doCheck( policyMap, "ABC" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_UPPER ) ) );
+        Assert.assertThat( doCheck( policyMap, "ABCd" ),  not( hasItems( PwmError.PASSWORD_TOO_MANY_UPPER ) ) );
+    }
+
+    @Test
+    public void minimumLowerCaseTest()
+            throws Exception
+    {
+        final Map<String, String> policyMap = new HashMap<>();
+        policyMap.put( PwmPasswordRule.MinimumLowerCase.getKey(), "3" );
+
+        // violations
+        Assert.assertThat( doCheck( policyMap, "a" ), hasItems( PwmError.PASSWORD_NOT_ENOUGH_LOWER ) );
+        Assert.assertThat( doCheck( policyMap, "ab" ), hasItems( PwmError.PASSWORD_NOT_ENOUGH_LOWER ) );
+        Assert.assertThat( doCheck( policyMap, "abC" ),  hasItems( PwmError.PASSWORD_NOT_ENOUGH_LOWER ) );
+
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "abc" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_LOWER ) ) );
+        Assert.assertThat( doCheck( policyMap, "abcd" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_LOWER ) ) );
+        Assert.assertThat( doCheck( policyMap, "abcdE" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_LOWER ) ) );
+        Assert.assertThat( doCheck( policyMap, "123456abc" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_LOWER ) ) );
+    }
+
+    @Test
+    public void maximumLowerCaseTest()
+            throws Exception
+    {
+        final Map<String, String> policyMap = new HashMap<>();
+        policyMap.put( PwmPasswordRule.MaximumLowerCase.getKey(), "3" );
+
+        // violations
+        Assert.assertThat( doCheck( policyMap, "abcd" ), hasItems( PwmError.PASSWORD_TOO_MANY_LOWER ) );
+        Assert.assertThat( doCheck( policyMap, "abcde" ), hasItems( PwmError.PASSWORD_TOO_MANY_LOWER ) );
+
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "a" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_LOWER ) ) );
+        Assert.assertThat( doCheck( policyMap, "ab" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_LOWER ) ) );
+        Assert.assertThat( doCheck( policyMap, "abc" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_LOWER ) ) );
+        Assert.assertThat( doCheck( policyMap, "abcD" ),  not( hasItems( PwmError.PASSWORD_TOO_MANY_LOWER ) ) );
+    }
+
+    @Test
+    public void minimumSpecialTest()
+            throws Exception
+    {
+        final Map<String, String> policyMap = new HashMap<>();
+        policyMap.put( PwmPasswordRule.AllowSpecial.getKey(), "true" );
+        policyMap.put( PwmPasswordRule.MinimumSpecial.getKey(), "3" );
+
+        // violations
+        Assert.assertThat( doCheck( policyMap, "!" ), hasItems( PwmError.PASSWORD_NOT_ENOUGH_SPECIAL ) );
+        Assert.assertThat( doCheck( policyMap, "!!" ), hasItems( PwmError.PASSWORD_NOT_ENOUGH_SPECIAL ) );
+        Assert.assertThat( doCheck( policyMap, "!!A" ),  hasItems( PwmError.PASSWORD_NOT_ENOUGH_SPECIAL ) );
+
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "!!!" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_SPECIAL ) ) );
+        Assert.assertThat( doCheck( policyMap, "!!!A" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_SPECIAL ) ) );
+    }
+
+    @Test
+    public void maximumSpecialTest()
+            throws Exception
+    {
+        final Map<String, String> policyMap = new HashMap<>();
+        policyMap.put( PwmPasswordRule.AllowSpecial.getKey(), "true" );
+        policyMap.put( PwmPasswordRule.MaximumSpecial.getKey(), "3" );
+
+        // violations
+        Assert.assertThat( doCheck( policyMap, "!!!!" ), hasItems( PwmError.PASSWORD_TOO_MANY_SPECIAL ) );
+        Assert.assertThat( doCheck( policyMap, "!!!!!" ), hasItems( PwmError.PASSWORD_TOO_MANY_SPECIAL ) );
+
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "!!!" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_SPECIAL ) ) );
+        Assert.assertThat( doCheck( policyMap, "!!!A" ),  not( hasItems( PwmError.PASSWORD_TOO_MANY_SPECIAL ) ) );
+    }
+
+    @Test
+    public void minimumAlphaTest()
+            throws Exception
+    {
+        final Map<String, String> policyMap = new HashMap<>();
+        policyMap.put( PwmPasswordRule.MinimumAlpha.getKey(), "3" );
+
+        // violations
+        Assert.assertThat( doCheck( policyMap, "a" ), hasItems( PwmError.PASSWORD_NOT_ENOUGH_ALPHA ) );
+        Assert.assertThat( doCheck( policyMap, "ab" ), hasItems( PwmError.PASSWORD_NOT_ENOUGH_ALPHA ) );
+        Assert.assertThat( doCheck( policyMap, "ab1" ),  hasItems( PwmError.PASSWORD_NOT_ENOUGH_ALPHA ) );
+
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "abc" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_ALPHA ) ) );
+        Assert.assertThat( doCheck( policyMap, "abcd" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_ALPHA ) ) );
+    }
+
+    @Test
+    public void maximumAlphaTest()
+            throws Exception
+    {
+        final Map<String, String> policyMap = new HashMap<>();
+        policyMap.put( PwmPasswordRule.MaximumAlpha.getKey(), "3" );
+
+        // violations
+        Assert.assertThat( doCheck( policyMap, "abcd" ), hasItems( PwmError.PASSWORD_TOO_MANY_ALPHA ) );
+        Assert.assertThat( doCheck( policyMap, "abcd1" ), hasItems( PwmError.PASSWORD_TOO_MANY_ALPHA ) );
+
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "abc" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_ALPHA ) ) );
+        Assert.assertThat( doCheck( policyMap, "abc1" ),  not( hasItems( PwmError.PASSWORD_TOO_MANY_ALPHA ) ) );
+    }
+
+    @Test
+    public void minimumNonAlphaTest()
+            throws Exception
+    {
+        final Map<String, String> policyMap = new HashMap<>();
+        policyMap.put( PwmPasswordRule.MinimumNonAlpha.getKey(), "3" );
+
+        // violations
+        Assert.assertThat( doCheck( policyMap, "!!" ), hasItems( PwmError.PASSWORD_NOT_ENOUGH_NONALPHA ) );
+        Assert.assertThat( doCheck( policyMap, "44" ), hasItems( PwmError.PASSWORD_NOT_ENOUGH_NONALPHA ) );
+        Assert.assertThat( doCheck( policyMap, "5" ),  hasItems( PwmError.PASSWORD_NOT_ENOUGH_NONALPHA ) );
+
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "!!!" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_NONALPHA ) ) );
+        Assert.assertThat( doCheck( policyMap, ",,," ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_NONALPHA ) ) );
+    }
+
+    @Test
+    public void maximumNonAlphaTest()
+            throws Exception
+    {
+        final Map<String, String> policyMap = new HashMap<>();
+        policyMap.put( PwmPasswordRule.MaximumNonAlpha.getKey(), "3" );
+
+        // violations
+        Assert.assertThat( doCheck( policyMap, "1234" ), hasItems( PwmError.PASSWORD_TOO_MANY_NONALPHA ) );
+        Assert.assertThat( doCheck( policyMap, "----" ), hasItems( PwmError.PASSWORD_TOO_MANY_NONALPHA ) );
+
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "123" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_NONALPHA ) ) );
+        Assert.assertThat( doCheck( policyMap, "---" ),  not( hasItems( PwmError.PASSWORD_TOO_MANY_NONALPHA ) ) );
+    }
+
+    @Test
+    public void minimumNumericTest()
+            throws Exception
+    {
+        final Map<String, String> policyMap = new HashMap<>();
         policyMap.put( PwmPasswordRule.AllowNumeric.getKey(), "true" );
+        policyMap.put( PwmPasswordRule.MinimumNumeric.getKey(), "3" );
 
-        {
-            final List<ErrorInformation> expectedErrors = new ArrayList<>();
-            expectedErrors.add( new ErrorInformation( PwmError.PASSWORD_TOO_LONG, null ) );
+        // violations
+        Assert.assertThat( doCheck( policyMap, "1" ), hasItems( PwmError.PASSWORD_NOT_ENOUGH_NUM ) );
+        Assert.assertThat( doCheck( policyMap, "12" ), hasItems( PwmError.PASSWORD_NOT_ENOUGH_NUM ) );
+        Assert.assertThat( doCheck( policyMap, "12a" ),  hasItems( PwmError.PASSWORD_NOT_ENOUGH_NUM ) );
 
-            Assert.assertFalse( doCompareTest( policyMap, "123", expectedErrors ) );
-            Assert.assertFalse( doCompareTest( policyMap, "1234", expectedErrors ) );
-            Assert.assertFalse( doCompareTest( policyMap, "12345", expectedErrors ) );
-            Assert.assertFalse( doCompareTest( policyMap, "123456", expectedErrors ) );
-            Assert.assertFalse( doCompareTest( policyMap, "1234567", expectedErrors ) );
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "123" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_NUM ) ) );
+        Assert.assertThat( doCheck( policyMap, "1234" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_NUM ) ) );
+    }
 
-            Assert.assertTrue( doCompareTest( policyMap, "12345678", expectedErrors ) );
-            Assert.assertTrue( doCompareTest( policyMap, "123456789", expectedErrors ) );
-        }
+    @Test
+    public void maximumNumericTest()
+            throws Exception
+    {
+        final Map<String, String> policyMap = new HashMap<>();
+        policyMap.put( PwmPasswordRule.AllowNumeric.getKey(), "true" );
+        policyMap.put( PwmPasswordRule.MaximumNumeric.getKey(), "3" );
+
+        // violations
+        Assert.assertThat( doCheck( policyMap, "1234" ), hasItems( PwmError.PASSWORD_TOO_MANY_NUMERIC ) );
+        Assert.assertThat( doCheck( policyMap, "12345" ), hasItems( PwmError.PASSWORD_TOO_MANY_NUMERIC ) );
+
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "12" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_NUMERIC ) ) );
+        Assert.assertThat( doCheck( policyMap, "123" ),  not( hasItems( PwmError.PASSWORD_TOO_MANY_NUMERIC ) ) );
     }
 
     @Test
@@ -86,19 +289,49 @@ public class PasswordRuleChecksTest
     {
         final Map<String, String> policyMap = new HashMap<>();
         policyMap.put( PwmPasswordRule.MinimumUnique.getKey(), "4" );
-        policyMap.put( PwmPasswordRule.AllowNumeric.getKey(), "true" );
 
-        {
-            final List<ErrorInformation> expectedErrors = new ArrayList<>();
-            expectedErrors.add( new ErrorInformation( PwmError.PASSWORD_NOT_ENOUGH_UNIQUE, null ) );
+        // violations
+        Assert.assertThat( doCheck( policyMap, "aaa" ), hasItems( PwmError.PASSWORD_NOT_ENOUGH_UNIQUE ) );
+        Assert.assertThat( doCheck( policyMap, "aaa2" ), hasItems( PwmError.PASSWORD_NOT_ENOUGH_UNIQUE ) );
+        Assert.assertThat( doCheck( policyMap, "aaa23" ), hasItems( PwmError.PASSWORD_NOT_ENOUGH_UNIQUE ) );
 
-            Assert.assertTrue( doCompareTest( policyMap, "aaa", expectedErrors ) );
-            Assert.assertTrue( doCompareTest( policyMap, "aaa2", expectedErrors ) );
-            Assert.assertTrue( doCompareTest( policyMap, "aaa23", expectedErrors ) );
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "aaa234" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_UNIQUE ) ) );
+        Assert.assertThat( doCheck( policyMap, "aaa2345" ), not( hasItems( PwmError.PASSWORD_NOT_ENOUGH_UNIQUE ) ) );
+    }
 
-            Assert.assertFalse( doCompareTest( policyMap, "aaa234", expectedErrors ) );
-            Assert.assertFalse( doCompareTest( policyMap, "aaa2345", expectedErrors ) );
-        }
+    @Test
+    public void maximumSequentialRepeatTest()
+            throws Exception
+    {
+        final Map<String, String> policyMap = new HashMap<>();
+        policyMap.put( PwmPasswordRule.MaximumSequentialRepeat.getKey(), "4" );
+
+        // violations
+        Assert.assertThat( doCheck( policyMap, "aaaaa" ), hasItems( PwmError.PASSWORD_TOO_MANY_REPEAT ) );
+        Assert.assertThat( doCheck( policyMap, "aaaaaa" ), hasItems( PwmError.PASSWORD_TOO_MANY_REPEAT ) );
+
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "aaa" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_REPEAT ) ) );
+        Assert.assertThat( doCheck( policyMap, "aaaa" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_REPEAT ) ) );
+        Assert.assertThat( doCheck( policyMap, "aaa23" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_REPEAT ) ) );
+    }
+
+    @Test
+    public void maximumRepeatTest()
+            throws Exception
+    {
+        final Map<String, String> policyMap = new HashMap<>();
+        policyMap.put( PwmPasswordRule.MaximumRepeat.getKey(), "4" );
+
+        // violations
+        Assert.assertThat( doCheck( policyMap, "aa2aaa" ), hasItems( PwmError.PASSWORD_TOO_MANY_REPEAT ) );
+        Assert.assertThat( doCheck( policyMap, "aa2aaaa" ), hasItems( PwmError.PASSWORD_TOO_MANY_REPEAT ) );
+
+        // not violations
+        Assert.assertThat( doCheck( policyMap, "aa2a" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_REPEAT ) ) );
+        Assert.assertThat( doCheck( policyMap, "aa2aa" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_REPEAT ) ) );
+        Assert.assertThat( doCheck( policyMap, "aa2a23" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_REPEAT ) ) );
     }
 
     @Test
@@ -109,25 +342,19 @@ public class PasswordRuleChecksTest
             final Map<String, String> policyMap = new HashMap<>();
             policyMap.put( PwmPasswordRule.AllowNumeric.getKey(), "true" );
 
-            {
-                final List<ErrorInformation> expectedErrors = new ArrayList<>();
-                expectedErrors.add( new ErrorInformation( PwmError.PASSWORD_TOO_MANY_NUMERIC, null ) );
-
-                Assert.assertFalse( doCompareTest( policyMap, "aaa", expectedErrors ) );
-                Assert.assertFalse( doCompareTest( policyMap, "aaa2", expectedErrors ) );
-            }
+            // not violations
+            Assert.assertThat( doCheck( policyMap, "aaa" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_NUMERIC ) ) );
+            Assert.assertThat( doCheck( policyMap, "aaa2" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_NUMERIC ) ) );
         }
         {
             final Map<String, String> policyMap = new HashMap<>();
             policyMap.put( PwmPasswordRule.AllowNumeric.getKey(), "false" );
 
-            {
-                final List<ErrorInformation> expectedErrors = new ArrayList<>();
-                expectedErrors.add( new ErrorInformation( PwmError.PASSWORD_TOO_MANY_NUMERIC, null ) );
+            // violations
+            Assert.assertThat( doCheck( policyMap, "aaa2" ), hasItems( PwmError.PASSWORD_TOO_MANY_NUMERIC ) );
 
-                Assert.assertFalse( doCompareTest( policyMap, "aaa", expectedErrors ) );
-                Assert.assertTrue( doCompareTest( policyMap, "aaa2", expectedErrors ) );
-            }
+            // not violations
+            Assert.assertThat( doCheck( policyMap, "aaa" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_NUMERIC ) ) );
         }
     }
 
@@ -139,47 +366,75 @@ public class PasswordRuleChecksTest
             final Map<String, String> policyMap = new HashMap<>();
             policyMap.put( PwmPasswordRule.AllowSpecial.getKey(), "true" );
 
-            {
-                final List<ErrorInformation> expectedErrors = new ArrayList<>();
-                expectedErrors.add( new ErrorInformation( PwmError.PASSWORD_TOO_MANY_SPECIAL, null ) );
+            // not violations
+            Assert.assertThat( doCheck( policyMap, "aaa" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_SPECIAL ) ) );
+            Assert.assertThat( doCheck( policyMap, "aaa^" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_SPECIAL ) ) );
+            Assert.assertThat( doCheck( policyMap, "123" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_SPECIAL ) ) );
+        }
+        {
+            final Map<String, String> policyMap = new HashMap<>();
+            policyMap.put( PwmPasswordRule.AllowSpecial.getKey(), "false" );
 
-                Assert.assertFalse( doCompareTest( policyMap, "aaa", expectedErrors ) );
-                Assert.assertFalse( doCompareTest( policyMap, "aaa^", expectedErrors ) );
-            }
+            // violations
+            Assert.assertThat( doCheck( policyMap, "aaa^" ), hasItems( PwmError.PASSWORD_TOO_MANY_SPECIAL ) );
+            Assert.assertThat( doCheck( policyMap, "^" ), hasItems( PwmError.PASSWORD_TOO_MANY_SPECIAL ) );
+
+            // not violations
+            Assert.assertThat( doCheck( policyMap, "aaa" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_SPECIAL ) ) );
+            Assert.assertThat( doCheck( policyMap, "123" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_SPECIAL ) ) );
         }
+    }
+
+    @Test
+    public void allowNonAlpha()
+            throws Exception
+    {
         {
             final Map<String, String> policyMap = new HashMap<>();
-            policyMap.put( PwmPasswordRule.AllowNumeric.getKey(), "false" );
+            policyMap.put( PwmPasswordRule.AllowNonAlpha.getKey(), "true" );
 
-            {
-                final List<ErrorInformation> expectedErrors = new ArrayList<>();
-                expectedErrors.add( new ErrorInformation( PwmError.PASSWORD_TOO_MANY_SPECIAL, null ) );
+            // violations
+            Assert.assertThat( doCheck( policyMap, "aaa" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_NONALPHA ) ) );
 
-                Assert.assertFalse( doCompareTest( policyMap, "aaa", expectedErrors ) );
-                Assert.assertTrue( doCompareTest( policyMap, "aaa^", expectedErrors ) );
-            }
+            // not violations
+            Assert.assertThat( doCheck( policyMap, "^" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_NONALPHA ) ) );
+            Assert.assertThat( doCheck( policyMap, "aaa^" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_NONALPHA ) ) );
+            Assert.assertThat( doCheck( policyMap, "123" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_NONALPHA ) ) );
+        }
+        {
+            final Map<String, String> policyMap = new HashMap<>();
+            policyMap.put( PwmPasswordRule.AllowNonAlpha.getKey(), "false" );
+
+            // violations
+            Assert.assertThat( doCheck( policyMap, "^" ), hasItems( PwmError.PASSWORD_TOO_MANY_NONALPHA ) );
+            Assert.assertThat( doCheck( policyMap, "aaa^" ), hasItems( PwmError.PASSWORD_TOO_MANY_NONALPHA ) );
+            Assert.assertThat( doCheck( policyMap, "123" ), hasItems( PwmError.PASSWORD_TOO_MANY_NONALPHA ) );
+
+            // not violations
+            Assert.assertThat( doCheck( policyMap, "aaa" ), not( hasItems( PwmError.PASSWORD_TOO_MANY_NONALPHA ) ) );
         }
     }
 
-    private static List<ErrorInformation> doTest( final Map<String, String> policy, final String password )
+    private Set<PwmError> doCheck(
+            final Map<String, String> policy,
+            final String password
+    )
             throws PwmUnrecoverableException
     {
         final Map<String, String> policyMap = new HashMap<>( PwmPasswordPolicy.defaultPolicy().getPolicyMap() );
         policyMap.putAll( policy );
         final PwmPasswordPolicy pwmPasswordPolicy = PwmPasswordPolicy.createPwmPasswordPolicy( policyMap );
-        return PasswordRuleChecks.extendedPolicyRuleChecker( null, pwmPasswordPolicy, password, null, null );
+        final List<ErrorInformation> errorResults = PasswordRuleChecks.extendedPolicyRuleChecker( null, pwmPasswordPolicy, password, null, null );
+        return errorResults.stream().map( ErrorInformation::getError ).collect( Collectors.toSet() );
     }
 
-    private static boolean doCompareTest(
-            final Map<String, String> policyMap,
-            final String password,
-            final List<ErrorInformation> expectedErrors
-    )
-            throws PwmUnrecoverableException
+    private static <T> org.hamcrest.Matcher<T> not( final org.hamcrest.Matcher<T> matcher )
     {
-        return ErrorInformation.listsContainSameErrors(
-                doTest( policyMap, password ),
-                expectedErrors );
+        return org.hamcrest.CoreMatchers.not( matcher );
     }
 
+    private static <T> org.hamcrest.Matcher<java.lang.Iterable<T>> hasItems( final T... items )
+    {
+        return org.hamcrest.core.IsCollectionContaining.<T>hasItems( items );
+    }
 }

BIN
server/src/test/resources/password/pwm/svc/wordlist/test-wordlist.zip


+ 20 - 0
webapp/src/main/webapp/WEB-INF/jsp/README.TXT

@@ -0,0 +1,20 @@
+## Important information about JSP modifications and customizations.
+
+Modifying the JSP (and other) files is strongly discouraged.  As new versions of this software are released you will
+need to re-modify the updated JSP files with any changes.  Updating this software is important to address
+security vulnerabilities and take advantage of improved security defenses and new features.  Modifying JSPs is tempting
+and works well in the short term, but almost always causes long term problems and inhibits important upgrades in the
+future.
+
+Instead of modifying the JSP files, using custom javascript is a more sustainable approach.  You can add custom
+javascript code via the configuration in 'Settings -> User Interface -> Look & Feel -> Embedded Javascript'.  See
+the help for that setting to learn more.
+
+Additionally, you can use 'Settings -> User Interface -> Look & Feel -> Custom Resource Bundle' to include file resources
+such as images, javascript and css'.  See the help for that setting to learn more.
+
+Using the two settings above it is possible to accomplish nearly anything that modifying the JSPs would do.
+Additionally your changes will be part of the configuration and be preserved over upgrades.  Thus, a non-developer
+administrator will have a good chance of being able to upgrade the software with the modifications intact.  While there
+is not a guarantee of consistent application javascript APIs in this software, the environment is generally stable and
+will often (but not always) work without modification from version to version.

+ 4 - 0
webapp/src/main/webapp/WEB-INF/jsp/accountinformation.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 
 
 <%@ page import="password.pwm.http.JspUtility" %>

+ 4 - 0
webapp/src/main/webapp/WEB-INF/jsp/activateuser-agreement.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 
 
 <%@ page import="password.pwm.http.bean.ActivateUserBean" %>

+ 4 - 0
webapp/src/main/webapp/WEB-INF/jsp/activateuser-entercode.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 
 
 <!DOCTYPE html>

+ 4 - 0
webapp/src/main/webapp/WEB-INF/jsp/activateuser-search.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 
 
 <%@ page import="password.pwm.http.tag.conditional.PwmIfTest" %>

+ 4 - 0
webapp/src/main/webapp/WEB-INF/jsp/activateuser-tokenchoice.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 
 
 <%@ page import="password.pwm.bean.TokenDestinationItem" %>

+ 4 - 0
webapp/src/main/webapp/WEB-INF/jsp/activateuser-tokensuccess.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 
 
 <%@ page import="password.pwm.bean.TokenDestinationItem" %>

+ 4 - 0
webapp/src/main/webapp/WEB-INF/jsp/admin-activity.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 
 
 <%@ page import="password.pwm.error.PwmException" %>

+ 4 - 0
webapp/src/main/webapp/WEB-INF/jsp/admin-analysis.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 
 
 <%@ page import="password.pwm.error.PwmException" %>

+ 4 - 0
webapp/src/main/webapp/WEB-INF/jsp/admin-dashboard.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 
 
 <%@ page import="password.pwm.config.option.DataStorageMethod" %>

+ 4 - 0
webapp/src/main/webapp/WEB-INF/jsp/admin-logview-window.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 
 
 <%@ page import="password.pwm.http.JspUtility" %>

+ 4 - 0
webapp/src/main/webapp/WEB-INF/jsp/admin-logview.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 
 
 <%@ page import="password.pwm.i18n.Admin" %>

+ 4 - 0
webapp/src/main/webapp/WEB-INF/jsp/admin-tokenlookup.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 
 
 <%@ page import="password.pwm.error.PwmError" %>

+ 4 - 0
webapp/src/main/webapp/WEB-INF/jsp/admin-urlreference.jsp

@@ -17,6 +17,10 @@
  ~ See the License for the specific language governing permissions and
  ~ limitations under the License.
 --%>
+<%--
+       THIS FILE IS NOT INTENDED FOR END USER MODIFICATION.
+       See the README.TXT file in WEB-INF/jsp before making changes.
+--%>
 
 
 <!DOCTYPE html>

Some files were not shown because too many files changed in this diff