浏览代码

misc refactoring

jrivard@gmail.com 6 年之前
父节点
当前提交
1f881af29b
共有 58 个文件被更改,包括 1422 次插入1491 次删除
  1. 41 0
      build/checkstyle-jsp.xml
  2. 2 38
      data-service/src/main/java/password/pwm/receiver/TelemetryRestReceiver.java
  3. 1 1
      docker/pom.xml
  4. 46 16
      pom.xml
  5. 1 0
      server/src/main/java/password/pwm/AppProperty.java
  6. 65 11
      server/src/main/java/password/pwm/PwmApplication.java
  7. 11 3
      server/src/main/java/password/pwm/bean/EmailItemBean.java
  8. 2 5
      server/src/main/java/password/pwm/bean/FormNonce.java
  9. 4 141
      server/src/main/java/password/pwm/bean/LoginInfoBean.java
  10. 2 2
      server/src/main/java/password/pwm/bean/PasswordStatus.java
  11. 2 2
      server/src/main/java/password/pwm/bean/SessionLabel.java
  12. 2 2
      server/src/main/java/password/pwm/bean/SmsItemBean.java
  13. 2 2
      server/src/main/java/password/pwm/bean/TelemetryPublishBean.java
  14. 8 13
      server/src/main/java/password/pwm/config/PwmSetting.java
  15. 8 0
      server/src/main/java/password/pwm/config/PwmSettingCategory.java
  16. 1 1
      server/src/main/java/password/pwm/config/PwmSettingXml.java
  17. 68 80
      server/src/main/java/password/pwm/health/HealthMonitor.java
  18. 1 0
      server/src/main/java/password/pwm/http/HttpHeader.java
  19. 2 41
      server/src/main/java/password/pwm/http/PwmHttpRequestWrapper.java
  20. 3 6
      server/src/main/java/password/pwm/http/PwmRequest.java
  21. 14 11
      server/src/main/java/password/pwm/http/filter/SessionFilter.java
  22. 1 2
      server/src/main/java/password/pwm/http/servlet/ClientApiServlet.java
  23. 1 1
      server/src/main/java/password/pwm/http/servlet/DeleteAccountServlet.java
  24. 1 6
      server/src/main/java/password/pwm/http/servlet/admin/ReportStatusBean.java
  25. 1 2
      server/src/main/java/password/pwm/http/servlet/configmanager/DebugItemGenerator.java
  26. 4 1
      server/src/main/java/password/pwm/http/servlet/forgottenpw/ForgottenPasswordServlet.java
  27. 2 0
      server/src/main/java/password/pwm/http/servlet/newuser/NewUserServlet.java
  28. 76 0
      server/src/main/java/password/pwm/http/servlet/resource/ConfigSettingFileResource.java
  29. 403 0
      server/src/main/java/password/pwm/http/servlet/resource/ResourceFileRequest.java
  30. 100 489
      server/src/main/java/password/pwm/http/servlet/resource/ResourceFileServlet.java
  31. 5 1
      server/src/main/java/password/pwm/http/servlet/resource/ResourceServletService.java
  32. 1 1
      server/src/main/java/password/pwm/http/tag/conditional/PwmIfTest.java
  33. 2 0
      server/src/main/java/password/pwm/ldap/UserInfo.java
  34. 1 2
      server/src/main/java/password/pwm/ldap/UserInfoBean.java
  35. 18 1
      server/src/main/java/password/pwm/ldap/UserInfoReader.java
  36. 6 1
      server/src/main/java/password/pwm/svc/event/AuditService.java
  37. 0 1
      server/src/main/java/password/pwm/svc/node/NodeService.java
  38. 27 93
      server/src/main/java/password/pwm/svc/report/ReportService.java
  39. 5 2
      server/src/main/java/password/pwm/svc/report/ReportSettings.java
  40. 9 72
      server/src/main/java/password/pwm/svc/report/ReportStatusInfo.java
  41. 96 227
      server/src/main/java/password/pwm/svc/report/ReportSummaryData.java
  42. 56 51
      server/src/main/java/password/pwm/svc/report/UserCacheRecord.java
  43. 6 23
      server/src/main/java/password/pwm/svc/report/UserCacheService.java
  44. 14 19
      server/src/main/java/password/pwm/svc/stats/StatisticsManager.java
  45. 11 9
      server/src/main/java/password/pwm/svc/token/TokenService.java
  46. 41 9
      server/src/main/java/password/pwm/util/DailySummaryJob.java
  47. 74 0
      server/src/main/java/password/pwm/util/ServletUtility.java
  48. 31 28
      server/src/main/java/password/pwm/util/java/TimeDuration.java
  49. 6 10
      server/src/main/java/password/pwm/util/operations/PasswordUtility.java
  50. 3 4
      server/src/main/java/password/pwm/ws/client/rest/form/FormDataRequestBean.java
  51. 2 3
      server/src/main/java/password/pwm/ws/client/rest/form/FormDataResponseBean.java
  52. 5 22
      server/src/main/java/password/pwm/ws/server/rest/RestHealthServer.java
  53. 1 0
      server/src/main/resources/password/pwm/AppProperty.properties
  54. 0 30
      server/src/main/resources/password/pwm/config/PwmSetting.xml
  55. 0 2
      server/src/main/resources/password/pwm/i18n/PwmSetting.properties
  56. 124 0
      server/src/test/java/password/pwm/config/PwmSettingXmlTest.java
  57. 0 2
      webapp/src/main/webapp/WEB-INF/jsp/configguide-cr_policy.jsp
  58. 3 2
      webapp/src/main/webapp/public/resources/js/main.js

+ 41 - 0
build/checkstyle-jsp.xml

@@ -0,0 +1,41 @@
+<?xml version="1.0"?>
+<!--
+  ~ Password Management Servlets (PWM)
+  ~ http://www.pwm-project.org
+  ~
+  ~ Copyright (c) 2006-2009 Novell, Inc.
+  ~ Copyright (c) 2009-2018 The PWM Project
+  ~
+  ~ This program is free software; you can redistribute it and/or modify
+  ~ it under the terms of the GNU General Public License as published by
+  ~ the Free Software Foundation; either version 2 of the License, or
+  ~ (at your option) any later version.
+  ~
+  ~ This program is distributed in the hope that it will be useful,
+  ~ but WITHOUT ANY WARRANTY; without even the implied warranty of
+  ~ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+  ~ GNU General Public License for more details.
+  ~
+  ~ You should have received a copy of the GNU General Public License
+  ~ along with this program; if not, write to the Free Software
+  ~ Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+  -->
+
+<!DOCTYPE module PUBLIC
+        "-//Puppy Crawl//DTD Check Configuration 1.2//EN"
+        "http://www.puppycrawl.com/dtds/configuration_1_2.dtd">
+
+<!--
+  PWM Checkstyle definition
+-->
+
+<module name="Checker">
+    <property name="fileExtensions" value="java, xml, jsp"/>
+
+    <module name="TreeWalker" >
+        <module name="Regexp">
+            <property name="format" value="WarnJavaScriptNotEnabledMessage"/>
+            <property name="illegalPattern" value="true"/>
+        </module>
+    </module>
+</module>

+ 2 - 38
data-service/src/main/java/password/pwm/receiver/TelemetryRestReceiver.java

@@ -22,13 +22,12 @@
 
 package password.pwm.receiver;
 
-import org.apache.commons.io.IOUtils;
-import password.pwm.PwmConstants;
 import password.pwm.bean.TelemetryPublishBean;
 import password.pwm.error.ErrorInformation;
 import password.pwm.error.PwmError;
 import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.i18n.Message;
+import password.pwm.util.ServletUtility;
 import password.pwm.util.java.JsonUtil;
 import password.pwm.ws.server.RestResultBean;
 
@@ -38,9 +37,6 @@ import javax.servlet.http.HttpServlet;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.io.StringWriter;
 
 @WebServlet(
         name = "TelemetryRestReceiver",
@@ -58,7 +54,7 @@ public class TelemetryRestReceiver extends HttpServlet
         try
         {
             resp.setHeader( "Content", "application/json" );
-            final String input = readRequestBodyAsString( req, 1024 * 1024 );
+            final String input = ServletUtility.readRequestBodyAsString( req, 1024 * 1024 );
             final TelemetryPublishBean telemetryPublishBean = JsonUtil.deserialize( input, TelemetryPublishBean.class );
             final Storage stoage = ContextManager.getContextManager( this.getServletContext() ).getApp().getStorage();
             stoage.store( telemetryPublishBean );
@@ -74,36 +70,4 @@ public class TelemetryRestReceiver extends HttpServlet
             resp.getWriter().print( restResultBean.toJson() );
         }
     }
-
-    private static String readRequestBodyAsString( final HttpServletRequest req, final int maxChars )
-            throws IOException, PwmUnrecoverableException
-    {
-        final StringWriter stringWriter = new StringWriter();
-        final Reader readerStream = new InputStreamReader(
-                req.getInputStream(),
-                PwmConstants.DEFAULT_CHARSET
-        );
-
-        try
-        {
-            IOUtils.copy( readerStream, stringWriter );
-        }
-        catch ( Exception e )
-        {
-            final String errorMsg = "error reading request body stream: " + e.getMessage();
-            throw new PwmUnrecoverableException( new ErrorInformation( PwmError.ERROR_INTERNAL, errorMsg ) );
-        }
-        finally
-        {
-            IOUtils.closeQuietly( readerStream );
-        }
-
-        final String stringValue = stringWriter.toString();
-        if ( stringValue.length() > maxChars )
-        {
-            final String msg = "input request body is to big, size=" + stringValue.length() + ", max=" + maxChars;
-            throw new PwmUnrecoverableException( new ErrorInformation( PwmError.ERROR_INTERNAL, msg ) );
-        }
-        return stringValue;
-    }
 }

+ 1 - 1
docker/pom.xml

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

+ 46 - 16
pom.xml

@@ -77,7 +77,7 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-javadoc-plugin</artifactId>
-                <version>3.0.1</version>
+                <version>3.1.0</version>
                 <executions>
                     <execution>
                         <goals>
@@ -99,6 +99,7 @@
                                     <Implementation-Version-Display>v${project.version} b${build.number} r${build.revision}</Implementation-Version-Display>
                                 </manifestEntries>
                             </archive>
+                            <source>8</source>
                         </configuration>
                     </execution>
                 </executions>
@@ -180,7 +181,7 @@
                         <phase>validate</phase>
                         <configuration>
                             <propertyExpansion>basedir=${project.root.basedir}</propertyExpansion>
-                            <configLocation>${project.root.basedir}/build/checkstyle.xml</configLocation>
+                            <configLocation>build/checkstyle.xml</configLocation>
                             <encoding>UTF-8</encoding>
                             <consoleOutput>true</consoleOutput>
                             <includeTestResources>true</includeTestResources>
@@ -215,6 +216,27 @@
                             <goal>check</goal>
                         </goals>
                     </execution>
+                    <execution>
+                        <id>checkstyle-jsp</id>
+                        <phase>validate</phase>
+                        <configuration>
+                            <propertyExpansion>basedir=${project.root.basedir}</propertyExpansion>
+                            <configLocation>${project.root.basedir}/build/checkstyle-jsp.xml</configLocation>
+                            <encoding>UTF-8</encoding>
+                            <consoleOutput>true</consoleOutput>
+                            <includeTestResources>true</includeTestResources>
+                            <failsOnError>true</failsOnError>
+                            <includes>**/*.jsp</includes>
+                            <sourceDirectories>
+                                <directory>src</directory>
+                                <directory>src/test</directory>
+                                <directory>src/main/webapp</directory>
+                            </sourceDirectories>
+                        </configuration>
+                        <goals>
+                            <goal>check</goal>
+                        </goals>
+                    </execution>
                 </executions>
             </plugin>
             <plugin>
@@ -243,25 +265,33 @@
                     </execution>
                 </executions>
             </plugin>
-        </plugins>
-    </build>
-
-    <reporting>
-        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-source-plugin</artifactId>
+                <version>3.0.1</version>
+                <executions>
+                    <execution>
+                        <phase>package</phase>
+                        <configuration>
+                            <goal>aggregate</goal>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
             <plugin> <!-- checks owsp vulnerability database -->
                 <groupId>org.owasp</groupId>
                 <artifactId>dependency-check-maven</artifactId>
-                <version>4.0.1</version>
-                <reportSets>
-                    <reportSet>
-                        <reports>
-                            <report>aggregate</report>
-                        </reports>
-                    </reportSet>
-                </reportSets>
+                <version>5.0.0-M2</version>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>check</goal>
+                        </goals>
+                    </execution>
+                </executions>
             </plugin>
         </plugins>
-    </reporting>
+    </build>
 
     <!-- common dependencies -->
     <dependencies>

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

@@ -300,6 +300,7 @@ public enum AppProperty
     RECAPTCHA_VALIDATE_URL                          ( "recaptcha.validateUrl" ),
     REPORTING_LDAP_SEARCH_TIMEOUT                   ( "reporting.ldap.searchTimeoutMs" ),
     REPORTING_LDAP_SEARCH_THREADS                   ( "reporting.ldap.searchThreads" ),
+    REPORTING_MAX_REPORT_AGE_SECONDS                ( "reporting.maxReportAgeSeconds" ),
     SECURITY_STRIP_INLINE_JAVASCRIPT                ( "security.html.stripInlineJavascript" ),
     SECURITY_HTTP_FORCE_REQUEST_SEQUENCING          ( "security.http.forceRequestSequencing" ),
     SECURITY_HTTP_STRIP_HEADER_REGEX                ( "security.http.stripHeaderRegex" ),

+ 65 - 11
server/src/main/java/password/pwm/PwmApplication.java

@@ -25,6 +25,7 @@ package password.pwm;
 import com.novell.ldapchai.ChaiUser;
 import com.novell.ldapchai.exception.ChaiUnavailableException;
 import com.novell.ldapchai.provider.ChaiProvider;
+import org.jetbrains.annotations.NotNull;
 import password.pwm.bean.SessionLabel;
 import password.pwm.bean.SmsItemBean;
 import password.pwm.bean.UserIdentity;
@@ -43,7 +44,6 @@ import password.pwm.ldap.search.UserSearchEngine;
 import password.pwm.svc.PwmService;
 import password.pwm.svc.PwmServiceManager;
 import password.pwm.svc.cache.CacheService;
-import password.pwm.svc.node.NodeService;
 import password.pwm.svc.email.EmailService;
 import password.pwm.svc.event.AuditEvent;
 import password.pwm.svc.event.AuditRecordFactory;
@@ -51,6 +51,7 @@ import password.pwm.svc.event.AuditService;
 import password.pwm.svc.event.SystemAuditRecord;
 import password.pwm.svc.intruder.IntruderManager;
 import password.pwm.svc.intruder.RecordType;
+import password.pwm.svc.node.NodeService;
 import password.pwm.svc.pwnotify.PwNotifyService;
 import password.pwm.svc.report.ReportService;
 import password.pwm.svc.sessiontrack.SessionTrackService;
@@ -62,6 +63,7 @@ import password.pwm.svc.token.TokenService;
 import password.pwm.svc.wordlist.SeedlistService;
 import password.pwm.svc.wordlist.SharedHistoryManager;
 import password.pwm.svc.wordlist.WordlistService;
+import password.pwm.util.DailySummaryJob;
 import password.pwm.util.MBeanUtility;
 import password.pwm.util.PasswordData;
 import password.pwm.util.cli.commands.ExportHttpsTomcatConfigCommand;
@@ -99,9 +101,10 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Objects;
 import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
 import java.util.concurrent.ScheduledExecutorService;
-import java.util.concurrent.ScheduledFuture;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
 
@@ -298,8 +301,11 @@ public class PwmApplication
             StatisticsManager.incrementStat( this, Statistic.PWM_STARTUPS );
             LOGGER.debug( () -> "buildTime=" + PwmConstants.BUILD_TIME + ", javaLocale=" + Locale.getDefault() + ", DefaultLocale=" + PwmConstants.DEFAULT_LOCALE );
 
-            applicationExecutorService.execute( () -> postInitTasks() );
+            final ExecutorService executorService = JavaHelper.makeSingleThreadExecutorService( this, PwmApplication.class );
+            scheduleFutureJob( this::postInitTasks, executorService, TimeDuration.ZERO );
         }
+
+
     }
 
     private void postInitTasks( )
@@ -417,6 +423,11 @@ public class PwmApplication
             LOGGER.debug( () -> "error initializing UserAgentUtils: " + e.getMessage() );
         }
 
+        {
+            final ExecutorService executorService = JavaHelper.makeSingleThreadExecutorService( this, PwmApplication.class );
+            this.scheduleFixedRateJob( new DailySummaryJob( this ), executorService, TimeDuration.fromCurrent( JavaHelper.nextZuluZeroTime() ), TimeDuration.DAY );
+        }
+
         LOGGER.trace( () -> "completed post init tasks in " + TimeDuration.fromCurrent( startTime ).asCompactString() );
     }
 
@@ -1024,18 +1035,25 @@ public class PwmApplication
         return inprogressRequests;
     }
 
-    public ScheduledFuture scheduleFutureJob(
+
+    public Future scheduleFutureJob(
             final Runnable runnable,
             final ExecutorService executor,
             final TimeDuration delay
     )
     {
+        Objects.requireNonNull( runnable );
+        Objects.requireNonNull( executor );
+        Objects.requireNonNull( delay );
+
         if ( applicationExecutorService.isShutdown() )
         {
             return null;
         }
 
-        return applicationExecutorService.schedule(  new WrappedRunner( runnable, executor ), delay.asMillis(), TimeUnit.MILLISECONDS );
+        final WrappedRunner wrappedRunner = new WrappedRunner( runnable, executor );
+        applicationExecutorService.schedule( wrappedRunner, delay.asMillis(), TimeUnit.MILLISECONDS );
+        return wrappedRunner.getFuture();
     }
 
     public void scheduleFixedRateJob(
@@ -1068,6 +1086,8 @@ public class PwmApplication
     {
         private final Runnable runnable;
         private final ExecutorService executor;
+        private volatile Future innerFuture;
+        private volatile boolean hasFailed;
 
         WrappedRunner( final Runnable runnable, final ExecutorService executor )
         {
@@ -1075,28 +1095,62 @@ public class PwmApplication
             this.executor = executor;
         }
 
-        @Override
-        public void run()
+        Future getFuture()
         {
-            if ( executor.isShutdown() )
+            return new Future()
             {
-                return;
-            }
+                @Override
+                public boolean cancel( final boolean mayInterruptIfRunning )
+                {
+                    return false;
+                }
 
+                @Override
+                public boolean isCancelled()
+                {
+                    return hasFailed;
+                }
+
+                @Override
+                public boolean isDone()
+                {
+                    return hasFailed || innerFuture != null && innerFuture.isDone();
+                }
+
+                @Override
+                public Object get()
+                {
+                    return null;
+                }
+
+                @Override
+                public Object get( final long timeout, @NotNull final TimeUnit unit )
+                {
+                    return null;
+                }
+            };
+        }
+
+
+        @Override
+        public void run()
+        {
             try
             {
                 if ( !executor.isShutdown() )
                 {
-                    executor.execute( runnable );
+                    innerFuture = executor.submit( runnable );
                 }
                 else
                 {
+                    hasFailed = true;
                     LOGGER.trace( () -> "skipping scheduled job " + runnable + " on shutdown executor + " + executor );
                 }
             }
             catch ( Throwable t )
             {
                 LOGGER.error( "unexpected error running scheduled job: " + t.getMessage(), t );
+                hasFailed = true;
             }
         }
     }

+ 11 - 3
server/src/main/java/password/pwm/bean/EmailItemBean.java

@@ -24,13 +24,13 @@ package password.pwm.bean;
 
 import lombok.AllArgsConstructor;
 import lombok.Builder;
-import lombok.Getter;
+import lombok.Value;
 
 import java.io.Serializable;
 
-@Getter
+@Value
 @AllArgsConstructor
-@Builder
+@Builder( toBuilder = true )
 public class EmailItemBean implements Serializable
 {
     private final String to;
@@ -39,6 +39,14 @@ public class EmailItemBean implements Serializable
     private final String bodyPlain;
     private final String bodyHtml;
 
+    public EmailItemBean applyBodyReplacement( final CharSequence target, final CharSequence replacement )
+    {
+        return this.toBuilder()
+                .bodyPlain( this.getBodyPlain().replace( target, replacement ) )
+                .bodyHtml( this.getBodyHtml().replace( target, replacement ) )
+                .build();
+    }
+
     public String toDebugString( )
     {
         return "from: " + from + ", to: " + to + ", subject: " + subject;

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

@@ -23,17 +23,14 @@
 package password.pwm.bean;
 
 import com.google.gson.annotations.SerializedName;
-import lombok.AllArgsConstructor;
-import lombok.Getter;
+import lombok.Value;
 
 import java.io.Serializable;
 import java.time.Instant;
 
-@Getter
-@AllArgsConstructor
+@Value
 public class FormNonce implements Serializable
 {
-
     @SerializedName( "g" )
     private final String sessionGUID;
 

+ 4 - 141
server/src/main/java/password/pwm/bean/LoginInfoBean.java

@@ -23,8 +23,7 @@
 package password.pwm.bean;
 
 import com.google.gson.annotations.SerializedName;
-import lombok.Getter;
-import lombok.Setter;
+import lombok.Data;
 import password.pwm.PwmConstants;
 import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.ldap.auth.AuthenticationType;
@@ -46,8 +45,7 @@ import java.util.Set;
  *
  * <p>Short serialized names are used to shrink the effective size of the login cookie.</p>
  */
-@Getter
-@Setter
+@Data
 public class LoginInfoBean implements Serializable
 {
 
@@ -67,10 +65,10 @@ public class LoginInfoBean implements Serializable
     private UserIdentity userIdentity;
 
     @SerializedName( "a" )
-    private boolean auth;
+    private boolean authenticated;
 
     @SerializedName( "p" )
-    private PasswordData pw;
+    private PasswordData userCurrentPassword;
 
     @SerializedName( "t" )
     private AuthenticationType type = AuthenticationType.UNAUTHENTICATED;
@@ -105,141 +103,6 @@ public class LoginInfoBean implements Serializable
     @SerializedName( "lf" )
     private Set<LoginFlag> loginFlags = new HashSet<>();
 
-    public Instant getAuthTime( )
-    {
-        return authTime;
-    }
-
-    public void setAuthTime( final Instant authTime )
-    {
-        this.authTime = authTime;
-    }
-
-    public AuthenticationType getType( )
-    {
-        return type;
-    }
-
-    public void setType( final AuthenticationType type )
-    {
-        this.type = type;
-    }
-
-    public PasswordData getUserCurrentPassword( )
-    {
-        return pw;
-    }
-
-    public void setUserCurrentPassword( final PasswordData userCurrentPassword )
-    {
-        this.pw = userCurrentPassword;
-    }
-
-    public BasicAuthInfo getBasicAuth( )
-    {
-        return basicAuth;
-    }
-
-    public void setBasicAuth( final BasicAuthInfo basicAuth )
-    {
-        this.basicAuth = basicAuth;
-    }
-
-    public Instant getOauthExp( )
-    {
-        return oauthExp;
-    }
-
-    public void setOauthExp( final Instant oauthExp )
-    {
-        this.oauthExp = oauthExp;
-    }
-
-    public String getOauthRefToken( )
-    {
-        return oauthRefToken;
-    }
-
-    public void setOauthRefToken( final String oauthRefToken )
-    {
-        this.oauthRefToken = oauthRefToken;
-    }
-
-    public List<AuthenticationType> getAuthFlags( )
-    {
-        return authFlags;
-    }
-
-    public PwmAuthenticationSource getAuthSource( )
-    {
-        return authSource;
-    }
-
-    public void setAuthSource( final PwmAuthenticationSource authSource )
-    {
-        this.authSource = authSource;
-    }
-
-    public String getGuid( )
-    {
-        return guid;
-    }
-
-    public void setGuid( final String guid )
-    {
-        this.guid = guid;
-    }
-
-    public int getReqCounter( )
-    {
-        return reqCounter;
-    }
-
-    public void setReqCounter( final int reqCounter )
-    {
-        this.reqCounter = reqCounter;
-    }
-
-    public UserIdentity getUserIdentity( )
-    {
-        return userIdentity;
-    }
-
-    public void setUserIdentity( final UserIdentity userIdentity )
-    {
-        this.userIdentity = userIdentity;
-    }
-
-    public boolean isAuthenticated( )
-    {
-        return auth;
-    }
-
-    public void setAuthenticated( final boolean authenticated )
-    {
-        this.auth = authenticated;
-    }
-
-    public PasswordData getPw( )
-    {
-        return pw;
-    }
-
-    public void setPw( final PasswordData pw )
-    {
-        this.pw = pw;
-    }
-
-    public Instant getReqTime( )
-    {
-        return reqTime;
-    }
-
-    public void setReqTime( final Instant reqTime )
-    {
-        this.reqTime = reqTime;
-    }
-
     public boolean isLoginFlag( final LoginFlag loginStateFlag )
     {
         return loginFlags.contains( loginStateFlag );

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

@@ -23,12 +23,12 @@
 package password.pwm.bean;
 
 import lombok.Builder;
-import lombok.Getter;
+import lombok.Value;
 import password.pwm.util.java.JsonUtil;
 
 import java.io.Serializable;
 
-@Getter
+@Value
 @Builder
 public class PasswordStatus implements Serializable
 {

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

@@ -23,11 +23,11 @@
 package password.pwm.bean;
 
 import lombok.AllArgsConstructor;
-import lombok.Getter;
+import lombok.Value;
 
 import java.io.Serializable;
 
-@Getter
+@Value
 @AllArgsConstructor
 public class SessionLabel implements Serializable
 {

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

@@ -24,12 +24,12 @@ package password.pwm.bean;
 
 
 import lombok.AllArgsConstructor;
-import lombok.Getter;
+import lombok.Value;
 import password.pwm.util.java.JsonUtil;
 
 import java.io.Serializable;
 
-@Getter
+@Value
 @AllArgsConstructor
 public class SmsItemBean implements Serializable
 {

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

@@ -23,14 +23,14 @@
 package password.pwm.bean;
 
 import lombok.Builder;
-import lombok.Getter;
+import lombok.Value;
 
 import java.io.Serializable;
 import java.time.Instant;
 import java.util.List;
 import java.util.Map;
 
-@Getter
+@Value
 @Builder
 public class TelemetryPublishBean implements Serializable
 {

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

@@ -33,6 +33,7 @@ import password.pwm.util.logging.PwmLogger;
 import password.pwm.util.macro.MacroMachine;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
@@ -1129,16 +1130,14 @@ public enum PwmSetting
 
 
     // reporting
-    REPORTING_ENABLE(
+    REPORTING_ENABLE_DAILY_JOB(
             "reporting.enable", PwmSettingSyntax.BOOLEAN, PwmSettingCategory.REPORTING ),
+    REPORTING_JOB_TIME_OFFSET(
+            "reporting.job.timeOffset", PwmSettingSyntax.DURATION, PwmSettingCategory.REPORTING ),
     REPORTING_USER_MATCH(
             "reporting.ldap.userMatch", PwmSettingSyntax.USER_PERMISSION, PwmSettingCategory.REPORTING ),
-    REPORTING_MAX_CACHE_AGE(
-            "reporting.maxCacheAge", PwmSettingSyntax.DURATION, PwmSettingCategory.REPORTING ),
     REPORTING_MAX_QUERY_SIZE(
             "reporting.ldap.maxQuerySize", PwmSettingSyntax.NUMERIC, PwmSettingCategory.REPORTING ),
-    REPORTING_JOB_TIME_OFFSET(
-            "reporting.job.timeOffset", PwmSettingSyntax.DURATION, PwmSettingCategory.REPORTING ),
     REPORTING_JOB_INTENSITY(
             "reporting.job.intensity", PwmSettingSyntax.SELECT, PwmSettingCategory.REPORTING ),
     REPORTING_SUMMARY_DAY_VALUES(
@@ -1591,14 +1590,10 @@ public enum PwmSetting
 
     public static PwmSetting forKey( final String key )
     {
-        for ( final PwmSetting loopSetting : values() )
-        {
-            if ( loopSetting.getKey().equals( key ) )
-            {
-                return loopSetting;
-            }
-        }
-        return null;
+        return Arrays.stream( values() )
+                .filter( loopValue -> loopValue.getKey().equals( key ) )
+                .findFirst()
+                .orElse( null );
     }
 
     public String toMenuLocationDebug(

+ 8 - 0
server/src/main/java/password/pwm/config/PwmSettingCategory.java

@@ -27,6 +27,7 @@ import password.pwm.util.i18n.LocaleHelper;
 import password.pwm.util.java.XmlElement;
 
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Iterator;
@@ -452,4 +453,11 @@ public enum PwmSettingCategory
         return Collections.unmodifiableCollection( returnValues );
     }
 
+    public static PwmSettingCategory forKey( final String key )
+    {
+        return Arrays.stream( values() )
+                .filter( loopValue -> loopValue.getKey().equals( key ) )
+                .findFirst()
+                .orElse( null );
+    }
 }

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

@@ -43,7 +43,7 @@ import java.util.concurrent.atomic.AtomicInteger;
 
 public class PwmSettingXml
 {
-    private static final String SETTING_XML_FILENAME = ( PwmSetting.class.getPackage().getName()
+    public static final String SETTING_XML_FILENAME = ( PwmSetting.class.getPackage().getName()
             + "." + PwmSetting.class.getSimpleName() ).replace( ".", "/" ) + ".xml";
 
     public static final String XML_ELEMENT_LDAP_PERMISSION = "ldapPermission";

+ 68 - 80
server/src/main/java/password/pwm/health/HealthMonitor.java

@@ -22,6 +22,7 @@
 
 package password.pwm.health;
 
+import lombok.Value;
 import password.pwm.AppProperty;
 import password.pwm.PwmApplication;
 import password.pwm.error.PwmException;
@@ -35,22 +36,19 @@ import java.time.Instant;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.TreeSet;
+import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.ExecutorService;
-import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicInteger;
 
 public class HealthMonitor implements PwmService
 {
     private static final PwmLogger LOGGER = PwmLogger.forClass( HealthMonitor.class );
 
-    private PwmApplication pwmApplication;
-
-    private final Set<HealthRecord> healthRecords = new TreeSet<>();
-
     private static final List<HealthChecker> HEALTH_CHECKERS;
 
     static
@@ -68,12 +66,11 @@ public class HealthMonitor implements PwmService
     private ExecutorService executorService;
     private HealthMonitorSettings settings;
 
-    private volatile Instant lastHealthCheckTime = Instant.ofEpochMilli( 0 );
-    private volatile Instant lastRequestedUpdateTime = Instant.ofEpochMilli( 0 );
-
-    private Map<HealthMonitorFlag, Serializable> healthProperties = new HashMap<>();
+    private Map<HealthMonitorFlag, Serializable> healthProperties = new ConcurrentHashMap<>();
 
     private STATUS status = STATUS.NEW;
+    private PwmApplication pwmApplication;
+    private volatile HealthData healthData = emptyHealthData();
 
     enum HealthMonitorFlag
     {
@@ -81,18 +78,6 @@ public class HealthMonitor implements PwmService
         AdPasswordPolicyApiCheck,
     }
 
-    public enum CheckTimeliness
-    {
-        /* Execute update immediately and wait for results */
-        Immediate,
-
-        /* Take current data unless its ancient */
-        CurrentButNotAncient,
-
-        /* Take current data even if its ancient and never block */
-        NeverBlock,
-    }
-
     public HealthMonitor( )
     {
     }
@@ -103,16 +88,17 @@ public class HealthMonitor implements PwmService
         {
             return null;
         }
-        return lastHealthCheckTime;
+        final HealthData healthData = this.healthData;
+        return healthData != null ? healthData.getTimeStamp() : Instant.ofEpochMilli( 0 );
     }
 
-    public HealthStatus getMostSevereHealthStatus( final CheckTimeliness timeliness )
+    public HealthStatus getMostSevereHealthStatus( )
     {
         if ( status != STATUS.OPEN )
         {
             return HealthStatus.GOOD;
         }
-        return getMostSevereHealthStatus( getHealthRecords( timeliness ) );
+        return getMostSevereHealthStatus( getHealthRecords( ) );
     }
 
     public static HealthStatus getMostSevereHealthStatus( final Collection<HealthRecord> healthRecords )
@@ -149,42 +135,38 @@ public class HealthMonitor implements PwmService
             return;
         }
 
-
         executorService = JavaHelper.makeBackgroundExecutor( pwmApplication, this.getClass() );
-        pwmApplication.scheduleFixedRateJob( new ScheduledUpdater(), executorService, TimeDuration.SECONDS_10, settings.getNominalCheckInterval() );
 
         status = STATUS.OPEN;
     }
 
-    public Set<HealthRecord> getHealthRecords( final CheckTimeliness timeliness )
+    public Set<HealthRecord> getHealthRecords( )
     {
         if ( status != STATUS.OPEN )
         {
             return Collections.emptySet();
         }
 
-        lastRequestedUpdateTime = Instant.now();
+        if ( healthData.recordsAreOutdated() )
+        {
+            final Instant startTime = Instant.now();
+            LOGGER.trace( () ->  "begin force immediate check" );
+            final Future future = pwmApplication.scheduleFutureJob( new ImmediateJob(), executorService, TimeDuration.ZERO );
+            JavaHelper.pause( settings.getMaximumForceCheckWait().asMillis(), 500, o -> future.isDone() );
+            LOGGER.trace( () ->  "exit force immediate check, done=" + future.isDone() + ", " + TimeDuration.compactFromCurrent( startTime ) );
+        }
+
+        pwmApplication.scheduleFutureJob( new UpdateJob(), executorService, settings.getNominalCheckInterval() );
 
         {
-            final boolean recordsAreStale = TimeDuration.fromCurrent( lastHealthCheckTime ).isLongerThan( settings.getMaximumRecordAge() );
-            if ( timeliness == CheckTimeliness.Immediate || ( timeliness == CheckTimeliness.CurrentButNotAncient && recordsAreStale ) )
+            final HealthData localHealthData = this.healthData;
+            if ( localHealthData.recordsAreOutdated() )
             {
-                final ScheduledFuture updateTask = pwmApplication.scheduleFutureJob( new ImmediateUpdater(), executorService, TimeDuration.ZERO );
-                final Instant beginWaitTime = Instant.now();
-                while ( !updateTask.isDone() && TimeDuration.fromCurrent( beginWaitTime ).isShorterThan( settings.getMaximumForceCheckWait() ) )
-                {
-                    JavaHelper.pause( 500 );
-                }
+                return Collections.singleton( HealthRecord.forMessage( HealthMessage.NoData ) );
             }
-        }
 
-        final boolean recordsAreStale = TimeDuration.fromCurrent( lastHealthCheckTime ).isLongerThan( settings.getMaximumRecordAge() );
-        if ( recordsAreStale )
-        {
-            return Collections.singleton( HealthRecord.forMessage( HealthMessage.NoData ) );
+            return localHealthData.getHealthRecords();
         }
-
-        return Collections.unmodifiableSet( healthRecords );
     }
 
     public void close( )
@@ -193,30 +175,32 @@ public class HealthMonitor implements PwmService
         {
             executorService.shutdown();
         }
-        healthRecords.clear();
+        healthData = emptyHealthData();
         status = STATUS.CLOSED;
     }
 
+    private HealthData emptyHealthData()
+    {
+        return new HealthData( Collections.emptySet(), Instant.ofEpochMilli( 0 ) );
+    }
+
     public List<HealthRecord> healthCheck( )
     {
         return Collections.emptyList();
     }
 
+    private AtomicInteger healthCheckCount = new AtomicInteger( 0 );
+
     private void doHealthChecks( )
     {
+        final int counter = healthCheckCount.getAndIncrement();
         if ( status != STATUS.OPEN )
         {
             return;
         }
 
-        final TimeDuration timeSinceLastUpdate = TimeDuration.fromCurrent( lastHealthCheckTime );
-        if ( timeSinceLastUpdate.isShorterThan( settings.getMinimumCheckInterval().asMillis(), TimeDuration.Unit.MILLISECONDS ) )
-        {
-            return;
-        }
-
         final Instant startTime = Instant.now();
-        LOGGER.trace( () -> "beginning background health check process" );
+        LOGGER.trace( () -> "beginning health check execution (" + counter + ")" );
         final List<HealthRecord> tempResults = new ArrayList<>();
         for ( final HealthChecker loopChecker : HEALTH_CHECKERS )
         {
@@ -254,10 +238,9 @@ public class HealthMonitor implements PwmService
                 }
             }
         }
-        healthRecords.clear();
-        healthRecords.addAll( tempResults );
-        lastHealthCheckTime = Instant.now();
-        LOGGER.trace( () -> "health check process completed (" + TimeDuration.compactFromCurrent( startTime ) + ")" );
+
+        healthData = new HealthData( Collections.unmodifiableSet( new TreeSet<>( tempResults ) ), Instant.now() );
+        LOGGER.trace( () -> "completed health check execution (" + counter + ") in " + TimeDuration.compactFromCurrent( startTime ) );
     }
 
     public ServiceInfoBean serviceInfo( )
@@ -265,50 +248,55 @@ public class HealthMonitor implements PwmService
         return new ServiceInfoBean( Collections.emptyList() );
     }
 
-    public Map<HealthMonitorFlag, Serializable> getHealthProperties( )
+    Map<HealthMonitorFlag, Serializable> getHealthProperties( )
     {
         return healthProperties;
     }
 
-    private class ScheduledUpdater implements Runnable
+    private class UpdateJob implements Runnable
     {
         @Override
         public void run( )
         {
-            final TimeDuration timeSinceLastRequest = TimeDuration.fromCurrent( lastRequestedUpdateTime );
-            if ( timeSinceLastRequest.isShorterThan( settings.getNominalCheckInterval().asMillis() + 1000, TimeDuration.Unit.MILLISECONDS ) )
+            if ( healthData.recordsAreStale() )
             {
-                try
-                {
-                    doHealthChecks();
-                }
-                catch ( Throwable e )
-                {
-                    LOGGER.error( "error during health check execution: " + e.getMessage(), e );
-
-                }
+                new ImmediateJob().run();
             }
         }
     }
 
-    private class ImmediateUpdater implements Runnable
+    private class ImmediateJob implements Runnable
     {
         @Override
         public void run( )
         {
-            final TimeDuration timeSinceLastUpdate = TimeDuration.fromCurrent( lastHealthCheckTime );
-            if ( timeSinceLastUpdate.isLongerThan( settings.getMinimumCheckInterval().asMillis(), TimeDuration.Unit.MILLISECONDS ) )
+            try
             {
-                try
-                {
-                    doHealthChecks();
-                }
-                catch ( Throwable e )
-                {
-                    LOGGER.error( "error during health check execution: " + e.getMessage(), e );
-                }
+                final Instant startTime = Instant.now();
+                doHealthChecks();
+                LOGGER.trace( () -> "completed health check dredge " + TimeDuration.compactFromCurrent( startTime ) );
+            }
+            catch ( Throwable e )
+            {
+                LOGGER.error( "error during health check execution: " + e.getMessage(), e );
             }
         }
     }
 
+    @Value
+    private class HealthData
+    {
+        private Set<HealthRecord> healthRecords;
+        private Instant timeStamp;
+
+        private boolean recordsAreStale()
+        {
+            return TimeDuration.fromCurrent( this.getTimeStamp() ).isLongerThan( settings.getNominalCheckInterval() );
+        }
+
+        private boolean recordsAreOutdated()
+        {
+            return TimeDuration.fromCurrent( this.getTimeStamp() ).isLongerThan( settings.getMaximumRecordAge() );
+        }
+    }
 }

+ 1 - 0
server/src/main/java/password/pwm/http/HttpHeader.java

@@ -48,6 +48,7 @@ public enum HttpHeader
     Origin( "Origin" ),
     Referer( "Referer" ),
     Server( "Server" ),
+    SetCookie( "Set-Cookie" ),
     UserAgent( "User-Agent" ),
     WWW_Authenticate( "WWW-Authenticate" ),
     XContentTypeOptions( "X-Content-Type-Options" ),

+ 2 - 41
server/src/main/java/password/pwm/http/PwmHttpRequestWrapper.java

@@ -22,14 +22,12 @@
 
 package password.pwm.http;
 
-import org.apache.commons.io.IOUtils;
 import password.pwm.AppProperty;
 import password.pwm.PwmConstants;
 import password.pwm.config.Configuration;
-import password.pwm.error.ErrorInformation;
-import password.pwm.error.PwmError;
 import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.util.PasswordData;
+import password.pwm.util.ServletUtility;
 import password.pwm.util.Validator;
 import password.pwm.util.java.JavaHelper;
 import password.pwm.util.java.JsonUtil;
@@ -39,9 +37,6 @@ import password.pwm.util.logging.PwmLogger;
 import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServletRequest;
 import java.io.IOException;
-import java.io.InputStreamReader;
-import java.io.Reader;
-import java.io.StringWriter;
 import java.io.UnsupportedEncodingException;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -117,41 +112,7 @@ public class PwmHttpRequestWrapper
     public String readRequestBodyAsString( final int maxChars )
             throws IOException, PwmUnrecoverableException
     {
-        return readRequestBodyAsString( this.getHttpServletRequest(), maxChars );
-    }
-
-    public static String readRequestBodyAsString( final HttpServletRequest httpServletRequest, final int maxChars )
-            throws IOException, PwmUnrecoverableException
-    {
-        final StringWriter stringWriter = new StringWriter();
-        final Reader readerStream = new InputStreamReader(
-                httpServletRequest.getInputStream(),
-                PwmConstants.DEFAULT_CHARSET
-        );
-
-        try
-        {
-            IOUtils.copy( readerStream, stringWriter );
-        }
-        catch ( Exception e )
-        {
-            final String errorMsg = "error reading request body stream: " + e.getMessage();
-            throw new PwmUnrecoverableException( new ErrorInformation( PwmError.ERROR_INTERNAL, errorMsg ) );
-        }
-        finally
-        {
-            IOUtils.closeQuietly( readerStream );
-        }
-
-        final String stringValue = stringWriter.toString();
-        if ( stringValue.length() > maxChars )
-        {
-            throw new PwmUnrecoverableException( new ErrorInformation(
-                    PwmError.ERROR_INTERNAL,
-                    "input request body is to big, size=" + stringValue.length() + ", max=" + maxChars )
-            );
-        }
-        return stringValue;
+        return ServletUtility.readRequestBodyAsString( this.getHttpServletRequest(), maxChars );
     }
 
     public Map<String, String> readBodyAsJsonStringMap( final Flag... flags )

+ 3 - 6
server/src/main/java/password/pwm/http/PwmRequest.java

@@ -73,11 +73,11 @@ public class PwmRequest extends PwmHttpRequestWrapper
 
     private static final PwmLogger LOGGER = PwmLogger.forClass( PwmRequest.class );
 
-
     private final PwmResponse pwmResponse;
+    private final PwmURL pwmURL;
+
     private transient PwmApplication pwmApplication;
     private transient PwmSession pwmSession;
-    private PwmURL pwmURL;
 
     private final Set<PwmRequestFlag> flags = new HashSet<>();
 
@@ -110,6 +110,7 @@ public class PwmRequest extends PwmHttpRequestWrapper
         this.pwmResponse = new PwmResponse( httpServletResponse, this, pwmApplication.getConfig() );
         this.pwmSession = pwmSession;
         this.pwmApplication = pwmApplication;
+        this.pwmURL = new PwmURL( this.getHttpServletRequest() );
     }
 
     public PwmApplication getPwmApplication( )
@@ -371,10 +372,6 @@ public class PwmRequest extends PwmHttpRequestWrapper
 
     public PwmURL getURL( )
     {
-        if ( pwmURL == null )
-        {
-            pwmURL = new PwmURL( this.getHttpServletRequest() );
-        }
         return pwmURL;
     }
 

+ 14 - 11
server/src/main/java/password/pwm/http/filter/SessionFilter.java

@@ -123,7 +123,7 @@ public class SessionFilter extends AbstractPwmFilter
                     && e.getCause() instanceof NoClassDefFoundError
                     && e.getCause().getMessage() != null
                     && e.getCause().getMessage().contains( "JaxbAnnotationIntrospector" )
-                    )
+            )
             {
                 // this is a jersey 1.18 bug that occurs once per execution
                 LOGGER.debug( pwmRequest, () -> "ignoring JaxbAnnotationIntrospector NoClassDefFoundError: " + e.getMessage() );
@@ -323,8 +323,15 @@ public class SessionFilter extends AbstractPwmFilter
 
             LOGGER.trace( pwmRequest, () -> "session has not been validated, redirecting with verification key to " + returnURL );
 
-            // better chance of detecting un-sticky sessions this way
-            pwmResponse.setHeader( HttpHeader.Connection, "close" );
+            {
+                final String httpVersion = pwmRequest.getHttpServletRequest().getProtocol();
+                if ( "HTTP/1.0".equals( httpVersion ) || "HTTP/1.1".equals( httpVersion ) )
+                {
+                    // better chance of detecting un-sticky sessions this way
+                    pwmResponse.setHeader( HttpHeader.Connection, "close" );
+                }
+            }
+
             if ( mode == SessionVerificationMode.VERIFY_AND_CACHE )
             {
                 req.setAttribute( "Location", returnURL );
@@ -361,8 +368,7 @@ public class SessionFilter extends AbstractPwmFilter
     {
         final HttpServletRequest req = pwmRequest.getHttpServletRequest();
 
-        final StringBuilder sb = new StringBuilder();
-        sb.append( req.getRequestURL() );
+        String redirectURL = req.getRequestURI();
 
         final String verificationParamName = pwmRequest.getConfig().readAppProperty( AppProperty.HTTP_PARAM_SESSION_VERIFICATION );
 
@@ -380,9 +386,7 @@ public class SessionFilter extends AbstractPwmFilter
                     for ( final Iterator<String> valueIter = paramValues.iterator(); valueIter.hasNext(); )
                     {
                         final String value = valueIter.next();
-                        sb.append( sb.toString().contains( "?" ) ? "&" : "?" );
-                        sb.append( StringUtil.urlEncode( paramName ) ).append( "=" );
-                        sb.append( StringUtil.urlEncode( value ) );
+                        redirectURL = PwmURL.appendAndEncodeUrlParameters( redirectURL, paramName, value );
                     }
                 }
             }
@@ -394,11 +398,10 @@ public class SessionFilter extends AbstractPwmFilter
 
         if ( validationKey != null )
         {
-            sb.append( sb.toString().contains( "?" ) ? "&" : "?" );
-            sb.append( verificationParamName ).append( "=" ).append( validationKey );
+            redirectURL = PwmURL.appendAndEncodeUrlParameters( redirectURL, verificationParamName, validationKey );
         }
 
-        return sb.toString();
+        return redirectURL;
     }
 
 

+ 1 - 2
server/src/main/java/password/pwm/http/servlet/ClientApiServlet.java

@@ -221,8 +221,7 @@ public class ClientApiServlet extends ControlledPwmServlet
         {
             final HealthData jsonOutput = RestHealthServer.processGetHealthCheckData(
                     pwmRequest.getPwmApplication(),
-                    pwmRequest.getLocale(),
-                    false );
+                    pwmRequest.getLocale() );
             final RestResultBean restResultBean = RestResultBean.withData( jsonOutput );
             pwmRequest.outputJsonResult( restResultBean );
         }

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

@@ -238,7 +238,7 @@ public class DeleteAccountServlet extends ControlledPwmServlet
         {
             final MacroMachine macroMachine = pwmRequest.getPwmSession().getSessionManager().getMacroMachine( pwmApplication );
             final String macroedUrl = macroMachine.expandMacros( nextUrl );
-            LOGGER.debug( pwmRequest, () -> "settinging forward url to post-delete next url: " + macroedUrl );
+            LOGGER.debug( pwmRequest, () -> "setting forward url to post-delete next url: " + macroedUrl );
             pwmRequest.getPwmSession().getSessionStateBean().setForwardURL( macroedUrl );
         }
 

+ 1 - 6
server/src/main/java/password/pwm/http/servlet/admin/ReportStatusBean.java

@@ -63,7 +63,7 @@ public class ReportStatusBean implements Serializable
             case RollOver:
             {
                 presentableMap.add( new DisplayElement( "usersProcessed", DisplayElement.Type.string, "Users Processed",
-                        numberFormat.format( reportService.getSummaryData().getTotalUsers() )
+                        numberFormat.format( reportService.getSummaryData().getTotalUsers().intValue() )
                                 + " of " + numberFormat.format( reportService.getTotalRecords() ) ) );
                 availableCommands.add( ReportService.ReportCommand.Stop );
             }
@@ -132,11 +132,6 @@ public class ReportStatusBean implements Serializable
             }
             final long totalRecords = reportService.getTotalRecords();
             presentableMap.add( new DisplayElement( "recordsInCache", DisplayElement.Type.string, "Records in Cache", numberFormat.format( totalRecords ) ) );
-            if ( totalRecords > 0 )
-            {
-                presentableMap.add( new DisplayElement( "meanRecordCacheTime", DisplayElement.Type.timestamp, "Mean Record Cache Time",
-                        JavaHelper.toIsoDate( reportService.getSummaryData().getMeanCacheTime() ) ) );
-            }
         }
 
         return ReportStatusBean.builder()

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

@@ -33,7 +33,6 @@ import password.pwm.bean.UserIdentity;
 import password.pwm.config.Configuration;
 import password.pwm.config.stored.StoredConfigurationImpl;
 import password.pwm.error.PwmUnrecoverableException;
-import password.pwm.health.HealthMonitor;
 import password.pwm.health.HealthRecord;
 import password.pwm.http.ContextManager;
 import password.pwm.http.PwmRequest;
@@ -349,7 +348,7 @@ public class DebugItemGenerator
         @Override
         public void outputItem( final PwmApplication pwmApplication, final PwmRequest pwmRequest, final OutputStream outputStream ) throws Exception
         {
-            final Set<HealthRecord> records = pwmApplication.getHealthMonitor().getHealthRecords( HealthMonitor.CheckTimeliness.CurrentButNotAncient );
+            final Set<HealthRecord> records = pwmApplication.getHealthMonitor().getHealthRecords();
             final String recordJson = JsonUtil.serializeCollection( records, JsonUtil.Flag.PrettyPrint );
             outputStream.write( recordJson.getBytes( PwmConstants.DEFAULT_CHARSET ) );
         }

+ 4 - 1
server/src/main/java/password/pwm/http/servlet/forgottenpw/ForgottenPasswordServlet.java

@@ -1134,7 +1134,10 @@ public class ForgottenPasswordServlet extends ControlledPwmServlet
             {
                 if ( userInfo.isPasswordLocked() )
                 {
-                    pwmRequest.setAttribute( PwmRequestAttribute.ForgottenPasswordInhibitPasswordReset, Boolean.TRUE );
+                    final boolean inhibitReset = minLifetimeOption != RecoveryMinLifetimeOption.ALLOW
+                            && userInfo.isWithinPasswordMinimumLifetime();
+
+                    pwmRequest.setAttribute( PwmRequestAttribute.ForgottenPasswordInhibitPasswordReset, inhibitReset );
                     pwmRequest.forwardToJsp( JspUrl.RECOVER_PASSWORD_ACTION_CHOICE );
                     return;
                 }

+ 2 - 0
server/src/main/java/password/pwm/http/servlet/newuser/NewUserServlet.java

@@ -195,6 +195,8 @@ public class NewUserServlet extends ControlledPwmServlet
     protected void nextStep( final PwmRequest pwmRequest )
             throws IOException, ServletException, PwmUnrecoverableException, ChaiUnavailableException
     {
+        TimeDuration.of( 8, TimeDuration.Unit.SECONDS ).pause();
+
         final NewUserBean newUserBean = getNewUserBean( pwmRequest );
         final PwmApplication pwmApplication = pwmRequest.getPwmApplication();
         final PwmSession pwmSession = pwmRequest.getPwmSession();

+ 76 - 0
server/src/main/java/password/pwm/http/servlet/resource/ConfigSettingFileResource.java

@@ -0,0 +1,76 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2018 The PWM Project
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+ */
+
+package password.pwm.http.servlet.resource;
+
+import password.pwm.PwmConstants;
+import password.pwm.config.Configuration;
+import password.pwm.config.PwmSetting;
+import password.pwm.util.java.StringUtil;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+public class ConfigSettingFileResource implements FileResource
+{
+    private final String bodyText;
+    private final String requestURI;
+
+    public ConfigSettingFileResource( final PwmSetting pwmSetting, final Configuration configuration, final String requestURI )
+    {
+        this.bodyText = configuration.readSettingAsString( pwmSetting );
+        this.requestURI = requestURI;
+    }
+
+
+    @Override
+    public InputStream getInputStream()
+            throws IOException
+    {
+        return new ByteArrayInputStream( bodyText.getBytes( PwmConstants.DEFAULT_CHARSET ) );
+    }
+
+    @Override
+    public long length()
+    {
+        return bodyText.length();
+    }
+
+    @Override
+    public long lastModified()
+    {
+        return 0;
+    }
+
+    @Override
+    public boolean exists()
+    {
+        return !StringUtil.isEmpty( bodyText );
+    }
+
+    @Override
+    public String getName()
+    {
+        return requestURI;
+    }
+}

+ 403 - 0
server/src/main/java/password/pwm/http/servlet/resource/ResourceFileRequest.java

@@ -0,0 +1,403 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2018 The PWM Project
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+ */
+
+package password.pwm.http.servlet.resource;
+
+import org.webjars.WebJarAssetLocator;
+import password.pwm.config.Configuration;
+import password.pwm.config.PwmSetting;
+import password.pwm.error.ErrorInformation;
+import password.pwm.error.PwmError;
+import password.pwm.error.PwmUnrecoverableException;
+import password.pwm.http.HttpHeader;
+import password.pwm.http.PwmHttpRequestWrapper;
+import password.pwm.util.java.StringUtil;
+import password.pwm.util.logging.PwmLogger;
+
+import javax.servlet.ServletContext;
+import javax.servlet.http.HttpServletRequest;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+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() ) );
+
+    private final HttpServletRequest httpServletRequest;
+    private final Configuration configuration;
+    private final ResourceServletConfiguration resourceServletConfiguration;
+
+    private FileResource fileResource;
+
+    ResourceFileRequest(
+            final Configuration configuration,
+            final ResourceServletConfiguration resourceServletConfiguration,
+            final HttpServletRequest httpServletRequest
+    )
+    {
+        this.configuration = configuration;
+        this.resourceServletConfiguration = resourceServletConfiguration;
+        this.httpServletRequest = httpServletRequest;
+    }
+
+    HttpServletRequest getHttpServletRequest()
+    {
+        return httpServletRequest;
+    }
+
+    ResourceServletConfiguration getResourceServletConfiguration()
+    {
+        return resourceServletConfiguration;
+    }
+
+    String getRequestURI()
+    {
+        return stripNonceFromURI( figureRequestPathMinusContext() );
+    }
+
+    String getReturnContentType()
+            throws PwmUnrecoverableException
+    {
+        final boolean acceptsCompression = allowsCompression();
+        final String rawContentType = getRawMimeType();
+        return acceptsCompression ? rawContentType : rawContentType + ";charset=UTF-8";
+    }
+
+    FileResource getRequestedFileResource()
+            throws PwmUnrecoverableException
+    {
+        if ( fileResource == null )
+        {
+            final String resourcePathUri = this.getRequestURI();
+            final ServletContext servletContext = this.getHttpServletRequest().getServletContext();
+            fileResource = resolveRequestedResource( configuration, servletContext, resourcePathUri, resourceServletConfiguration );
+        }
+        return fileResource;
+    }
+
+    private String getRawMimeType()
+            throws PwmUnrecoverableException
+    {
+        final String filename = getRequestedFileResource().getName();
+        final String contentType = this.httpServletRequest.getServletContext().getMimeType( filename );
+        if ( contentType == null )
+        {
+            if ( filename.endsWith( ".woff2" ) )
+            {
+                return "font/woff2";
+            }
+        }
+
+        // If content type is unknown, then set the default value.
+        // For all content types, see: http://www.w3schools.com/media/media_mimeref.asp
+        // To add new content types, add new mime-mapping entry in web.xml.
+        return contentType == null ? "application/octet-stream" : contentType;
+    }
+
+    boolean allowsCompression()
+            throws PwmUnrecoverableException
+    {
+        // If content type is text, then determine whether GZIP content encoding is supported by
+        // the browser and expand content type with the one and right character encoding.
+        if ( resourceServletConfiguration.isEnableGzip() )
+        {
+            final String contentType = getRawMimeType();
+            if ( contentType.startsWith( "text" ) || contentType.contains( "javascript" ) )
+            {
+                final PwmHttpRequestWrapper pwmHttpRequestWrapper = new PwmHttpRequestWrapper( httpServletRequest, configuration );
+                final String acceptEncoding = pwmHttpRequestWrapper.readHeaderValueAsString( HttpHeader.AcceptEncoding );
+                return acceptEncoding != null && accepts( acceptEncoding, "gzip" );
+            }
+        }
+        return false;
+    }
+
+    private String stripNonceFromURI(
+            final String uriString
+    )
+    {
+        if ( !resourceServletConfiguration.isEnablePathNonce() )
+        {
+            return uriString;
+        }
+
+        final Matcher theMatcher = resourceServletConfiguration.getNoncePattern().matcher( uriString );
+
+        if ( theMatcher.find() )
+        {
+            return theMatcher.replaceFirst( "" );
+        }
+
+        return uriString;
+    }
+
+    private String figureRequestPathMinusContext()
+    {
+        final String requestURI = httpServletRequest.getRequestURI();
+        return requestURI.substring( httpServletRequest.getContextPath().length() );
+    }
+
+    static FileResource resolveRequestedResource(
+            final Configuration configuration,
+            final ServletContext servletContext,
+            final String resourcePathUri,
+            final ResourceServletConfiguration resourceServletConfiguration
+    )
+            throws PwmUnrecoverableException
+    {
+
+        // URL-decode the file name (might contain spaces and on) and prepare file object.
+        String filename = StringUtil.urlDecode( resourcePathUri );
+
+        // parse out the session key...
+        if ( filename.contains( ";" ) )
+        {
+            filename = filename.substring( 0, filename.indexOf( ";" ) );
+        }
+
+
+        if ( !filename.startsWith( ResourceFileServlet.RESOURCE_PATH ) )
+        {
+            LOGGER.warn( "illegal url request to " + filename );
+            throw new PwmUnrecoverableException( new ErrorInformation( PwmError.ERROR_SERVICE_NOT_AVAILABLE, "illegal url request" ) );
+        }
+
+        {
+            final String embedThemeUrl = ResourceFileServlet.RESOURCE_PATH
+                    + ResourceFileServlet.THEME_CSS_PATH.replace( ResourceFileServlet.TOKEN_THEME, ResourceFileServlet.EMBED_THEME );
+            final String embedThemeMobileUrl = ResourceFileServlet.RESOURCE_PATH
+                    + ResourceFileServlet.THEME_CSS_MOBILE_PATH.replace( ResourceFileServlet.TOKEN_THEME, ResourceFileServlet.EMBED_THEME );
+
+            if ( filename.equalsIgnoreCase( embedThemeUrl ) )
+            {
+                return new ConfigSettingFileResource( PwmSetting.DISPLAY_CSS_EMBED, configuration, filename );
+            }
+            else if ( filename.equalsIgnoreCase( embedThemeMobileUrl ) )
+            {
+                return new ConfigSettingFileResource( PwmSetting.DISPLAY_CSS_MOBILE_EMBED, configuration, filename );
+            }
+        }
+
+
+        {
+            final FileResource resource = handleWebjarURIs( servletContext, resourcePathUri );
+            if ( resource != null )
+            {
+                return resource;
+            }
+        }
+
+        {
+            // check files system zip files.
+            final Map<String, ZipFile> zipResources = resourceServletConfiguration.getZipResources();
+            for ( final Map.Entry<String, ZipFile> entry : zipResources.entrySet() )
+            {
+                final String path = entry.getKey();
+                if ( filename.startsWith( path ) )
+                {
+                    final String zipSubPath = filename.substring( path.length() + 1, filename.length() );
+                    final ZipFile zipFile = entry.getValue();
+                    final ZipEntry zipEntry = zipFile.getEntry( zipSubPath );
+                    if ( zipEntry != null )
+                    {
+                        return new ZipFileResource( zipFile, zipEntry );
+                    }
+                }
+                if ( filename.startsWith( zipResources.get( path ).getName() ) )
+                {
+                    LOGGER.warn( "illegal url request to " + filename + " zip resource" );
+                    throw new PwmUnrecoverableException( new ErrorInformation( PwmError.ERROR_SERVICE_NOT_AVAILABLE, "illegal url request" ) );
+                }
+            }
+        }
+
+        // convert to file.
+        final String filePath = servletContext.getRealPath( filename );
+        final File file = new File( filePath );
+
+        // figure top-most path allowed by request
+        final String parentDirectoryPath = servletContext.getRealPath( ResourceFileServlet.RESOURCE_PATH );
+        final File parentDirectory = new File( parentDirectoryPath );
+
+        FileResource fileSystemResource = null;
+        {
+            //verify the requested page is a child of the servlet resource path.
+            int recursions = 0;
+            File recurseFile = file.getParentFile();
+            while ( recurseFile != null && recursions < 100 )
+            {
+                if ( parentDirectory.equals( recurseFile ) )
+                {
+                    fileSystemResource = new RealFileResource( file );
+                    break;
+                }
+                recurseFile = recurseFile.getParentFile();
+                recursions++;
+            }
+        }
+
+        if ( fileSystemResource == null )
+        {
+            LOGGER.warn( "attempt to access file outside of servlet path " + file.getAbsolutePath() );
+            throw new PwmUnrecoverableException( new ErrorInformation( PwmError.ERROR_SERVICE_NOT_AVAILABLE, "illegal file path request" ) );
+        }
+
+        if ( !fileSystemResource.exists() )
+        {
+            // check custom (configuration defined) zip file bundles
+            final Map<String, FileResource> customResources = resourceServletConfiguration.getCustomFileBundle();
+            for ( final Map.Entry<String, FileResource> entry : customResources.entrySet() )
+            {
+                final String customFileName = entry.getKey();
+                final String testName = ResourceFileServlet.RESOURCE_PATH + "/" + customFileName;
+                if ( testName.equals( resourcePathUri ) )
+                {
+                    return entry.getValue();
+                }
+            }
+        }
+
+        return fileSystemResource;
+    }
+
+    private static FileResource handleWebjarURIs(
+            final ServletContext servletContext,
+            final String resourcePathUri
+    )
+            throws PwmUnrecoverableException
+    {
+        if ( resourcePathUri.startsWith( ResourceFileServlet.WEBJAR_BASE_URL_PATH ) )
+        {
+            // This allows us to override a webjar file, if needed.  Mostly helpful during development.
+            final File file = new File( servletContext.getRealPath( resourcePathUri ) );
+            if ( file.exists() )
+            {
+                return new RealFileResource( file );
+            }
+
+            final String remainingPath = resourcePathUri.substring( ResourceFileServlet.WEBJAR_BASE_URL_PATH.length(), resourcePathUri.length() );
+
+            final String webJarName;
+            final String webJarPath;
+            {
+                final int slashIndex = remainingPath.indexOf( "/" );
+                if ( slashIndex < 0 )
+                {
+                    return null;
+                }
+                webJarName = remainingPath.substring( 0, slashIndex );
+                webJarPath = remainingPath.substring( slashIndex + 1, remainingPath.length() );
+            }
+
+            final String versionString = WEB_JAR_VERSION_MAP.get( webJarName );
+            if ( versionString == null )
+            {
+                return null;
+            }
+
+            final String fullPath = ResourceFileServlet.WEBJAR_BASE_FILE_PATH + "/" + webJarName + "/" + versionString + "/" + webJarPath;
+            if ( WEB_JAR_ASSET_LIST.contains( fullPath ) )
+            {
+                final ClassLoader classLoader = servletContext.getClassLoader();
+                final InputStream inputStream = classLoader.getResourceAsStream( fullPath );
+
+                if ( inputStream != null )
+                {
+                    return new InputStreamFileResource( inputStream, fullPath );
+                }
+            }
+        }
+
+        return null;
+    }
+
+    private static class InputStreamFileResource implements FileResource
+    {
+        private final InputStream inputStream;
+        private final String fullPath;
+
+        InputStreamFileResource( final InputStream inputStream, final String fullPath )
+        {
+            this.inputStream = inputStream;
+            this.fullPath = fullPath;
+        }
+
+        @Override
+        public InputStream getInputStream( ) throws IOException
+        {
+            return inputStream;
+        }
+
+        @Override
+        public long length( )
+        {
+            return 0;
+        }
+
+        @Override
+        public long lastModified( )
+        {
+            return 0;
+        }
+
+        @Override
+        public boolean exists( )
+        {
+            return true;
+        }
+
+        @Override
+        public String getName( )
+        {
+            return fullPath;
+        }
+    }
+
+    /**
+     * Returns true if the given accept header accepts the given value.
+     *
+     * @param acceptHeader The accept header.
+     * @param toAccept     The value to be accepted.
+     * @return True if the given accept header accepts the given value.
+     */
+    private static boolean accepts( final String acceptHeader, final String toAccept )
+    {
+        final String[] acceptValues = acceptHeader.split( "\\s*(,|;)\\s*" );
+        Arrays.sort( acceptValues );
+        return Arrays.binarySearch( acceptValues, toAccept ) > -1
+                || Arrays.binarySearch( acceptValues, toAccept.replaceAll( "/.*$", "/*" ) ) > -1
+                || Arrays.binarySearch( acceptValues, "*/*" ) > -1;
+    }
+}

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

@@ -23,11 +23,8 @@
 package password.pwm.http.servlet.resource;
 
 import com.github.benmanes.caffeine.cache.Cache;
-import org.apache.commons.io.IOUtils;
-import org.webjars.WebJarAssetLocator;
 import password.pwm.PwmApplication;
 import password.pwm.PwmConstants;
-import password.pwm.config.PwmSetting;
 import password.pwm.error.ErrorInformation;
 import password.pwm.error.PwmError;
 import password.pwm.error.PwmUnrecoverableException;
@@ -38,10 +35,9 @@ import password.pwm.http.servlet.PwmServlet;
 import password.pwm.svc.stats.EventRateMeter;
 import password.pwm.svc.stats.Statistic;
 import password.pwm.svc.stats.StatisticsManager;
-import password.pwm.util.java.StringUtil;
+import password.pwm.util.java.JavaHelper;
 import password.pwm.util.logging.PwmLogger;
 
-import javax.servlet.ServletContext;
 import javax.servlet.ServletException;
 import javax.servlet.annotation.WebServlet;
 import javax.servlet.http.HttpServlet;
@@ -51,21 +47,12 @@ import java.io.BufferedInputStream;
 import java.io.BufferedOutputStream;
 import java.io.ByteArrayInputStream;
 import java.io.ByteArrayOutputStream;
-import java.io.Closeable;
-import java.io.File;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
-import java.util.regex.Matcher;
 import java.util.zip.GZIPOutputStream;
-import java.util.zip.ZipEntry;
-import java.util.zip.ZipFile;
 
 @WebServlet(
         name = "ResourceFileServlet",
@@ -78,6 +65,9 @@ public class ResourceFileServlet extends HttpServlet implements PwmServlet
     private static final PwmLogger LOGGER = PwmLogger.forClass( ResourceFileServlet.class );
 
     public static final String RESOURCE_PATH = "/public/resources";
+    static final String WEBJAR_BASE_URL_PATH = RESOURCE_PATH + "/webjars/";
+    static final String WEBJAR_BASE_FILE_PATH = "META-INF/resources/webjars";
+
     public static final String THEME_CSS_PATH = "/themes/%THEME%/style.css";
     public static final String THEME_CSS_MOBILE_PATH = "/themes/%THEME%/mobileStyle.css";
     public static final String THEME_CSS_CONFIG_PATH = "/themes/%THEME%/configStyle.css";
@@ -85,13 +75,6 @@ public class ResourceFileServlet extends HttpServlet implements PwmServlet
     public static final String TOKEN_THEME = "%THEME%";
     public static final String EMBED_THEME = "embed";
 
-    private static final String WEBJAR_BASE_FILE_PATH = "META-INF/resources/webjars";
-    private static final String WEBJAR_BASE_URL_PATH = RESOURCE_PATH + "/webjars/";
-
-    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() ) );
-
-
     @Override
     protected void doGet( final HttpServletRequest req, final HttpServletResponse resp )
             throws ServletException, IOException
@@ -134,11 +117,9 @@ public class ResourceFileServlet extends HttpServlet implements PwmServlet
             throws IOException, PwmUnrecoverableException
     {
 
-        final FileResource file = resolveRequestedFile(
-                req.getServletContext(),
-                figureRequestPathMinusContext( req ),
-                ResourceServletConfiguration.defaultConfiguration()
-        );
+        final ResourceFileRequest resourceFileRequest = new ResourceFileRequest( null, ResourceServletConfiguration.defaultConfiguration(), req );
+
+        final FileResource file = resourceFileRequest.getRequestedFileResource();
 
         if ( file == null || !file.exists() )
         {
@@ -149,9 +130,8 @@ public class ResourceFileServlet extends HttpServlet implements PwmServlet
         handleUncachedResponse( resp, file, false );
     }
 
-    @SuppressWarnings( "checkstyle:MethodLength" )
     protected void processAction( final PwmRequest pwmRequest )
-            throws ServletException, IOException, PwmUnrecoverableException
+            throws IOException, PwmUnrecoverableException
     {
         if ( pwmRequest.getMethod() != HttpMethod.GET )
         {
@@ -164,25 +144,13 @@ public class ResourceFileServlet extends HttpServlet implements PwmServlet
         final ResourceServletService resourceService = pwmApplication.getResourceServletService();
         final ResourceServletConfiguration resourceConfiguration = resourceService.getResourceServletConfiguration();
 
-
-        final String requestURI = stripNonceFromURI( resourceConfiguration, figureRequestPathMinusContext( pwmRequest.getHttpServletRequest() ) );
-
-        try
-        {
-            if ( handleEmbeddedURIs( pwmApplication, requestURI, pwmRequest.getPwmResponse().getHttpServletResponse(), resourceConfiguration ) )
-            {
-                return;
-            }
-        }
-        catch ( Exception e )
-        {
-            LOGGER.error( pwmRequest, "unexpected error detecting/handling special request uri: " + e.getMessage() );
-        }
+        final ResourceFileRequest resourceFileRequest = new ResourceFileRequest( pwmApplication.getConfig(), resourceConfiguration, pwmRequest.getHttpServletRequest() );
+        final String requestURI = resourceFileRequest.getRequestURI();
 
         final FileResource file;
         try
         {
-            file = resolveRequestedFile( this.getServletContext(), requestURI, resourceConfiguration );
+            file = resourceFileRequest.getRequestedFileResource();
         }
         catch ( PwmUnrecoverableException e )
         {
@@ -209,47 +177,14 @@ public class ResourceFileServlet extends HttpServlet implements PwmServlet
         }
 
         // Get content type by file name and set default GZIP support and content disposition.
-        String contentType = getMimeType( file.getName() );
-        boolean acceptsGzip = false;
-
-        // If content type is unknown, then set the default value.
-        // For all content types, see: http://www.w3schools.com/media/media_mimeref.asp
-        // To add new content types, add new mime-mapping entry in web.xml.
-        if ( contentType == null )
-        {
-            contentType = "application/octet-stream";
-        }
-
-        // If content type is text, then determine whether GZIP content encoding is supported by
-        // the browser and expand content type with the one and right character encoding.
-        if ( resourceConfiguration.isEnableGzip() )
-        {
-            if ( contentType.startsWith( "text" ) || contentType.contains( "javascript" ) )
-            {
-                final String acceptEncoding = pwmRequest.readHeaderValueAsString( HttpHeader.AcceptEncoding );
-                acceptsGzip = acceptEncoding != null && accepts( acceptEncoding, "gzip" );
-                contentType += ";charset=UTF-8";
-            }
-        }
+        final String contentType = resourceFileRequest.getReturnContentType();
+        final boolean acceptsGzip = resourceFileRequest.allowsCompression();
 
         final HttpServletResponse response = pwmRequest.getPwmResponse().getHttpServletResponse();
-        final String eTagValue = resourceConfiguration.getNonceValue();
 
+        if ( respondWithNotModified( pwmRequest, resourceConfiguration ) )
         {
-            // reply back with etag.
-            final String ifNoneMatchValue = pwmRequest.readHeaderValueAsString( HttpHeader.If_None_Match );
-            if ( ifNoneMatchValue != null && ifNoneMatchValue.equals( eTagValue ) )
-            {
-                response.reset();
-                response.setStatus( HttpServletResponse.SC_NOT_MODIFIED );
-                try
-                {
-                    pwmRequest.debugHttpRequestToLog( "returning HTTP 304 status" );
-                }
-                catch ( PwmUnrecoverableException e2 )
-                { /* noop */ }
-                return;
-            }
+            return;
         }
 
         // Initialize response.
@@ -260,54 +195,22 @@ public class ResourceFileServlet extends HttpServlet implements PwmServlet
         try
         {
             boolean fromCache = false;
-            StringBuilder debugText = new StringBuilder();
+            String debugText;
             try
             {
-                fromCache = handleCacheableResponse( resourceConfiguration, response, file, acceptsGzip, resourceService.getCacheMap() );
-                if ( fromCache || acceptsGzip )
-                {
-                    debugText.append( "(" );
-                    if ( fromCache )
-                    {
-                        debugText.append( "cached" );
-                    }
-                    if ( fromCache && acceptsGzip )
-                    {
-                        debugText.append( ", " );
-                    }
-                    if ( acceptsGzip )
-                    {
-                        debugText.append( "gzip" );
-                    }
-                    debugText.append( ")" );
-                }
-                else
-                {
-                    debugText = new StringBuilder( "(not cached)" );
-                }
-                StatisticsManager.incrementStat( pwmApplication, Statistic.HTTP_RESOURCE_REQUESTS );
+                fromCache = handleCacheableResponse( resourceFileRequest, response, resourceService.getCacheMap() );
+                debugText = makeDebugText( fromCache, acceptsGzip, false );
             }
             catch ( UncacheableResourceException e )
             {
                 handleUncachedResponse( response, file, acceptsGzip );
-                debugText = new StringBuilder();
-                debugText.append( "(uncacheable" );
-                if ( acceptsGzip )
-                {
-                    debugText.append( ", gzip" );
-                }
-                debugText.append( ")" );
-            }
-            try
-            {
-                pwmRequest.debugHttpRequestToLog( debugText.toString() );
-            }
-            catch ( PwmUnrecoverableException e )
-            {
-                /* noop */
+                debugText = makeDebugText( fromCache, acceptsGzip, true );
             }
 
+            pwmRequest.debugHttpRequestToLog( debugText );
+
             final EventRateMeter.MovingAverage cacheHitRatio = resourceService.getCacheHitRatio();
+            StatisticsManager.incrementStat( pwmApplication, Statistic.HTTP_RESOURCE_REQUESTS );
             cacheHitRatio.update( fromCache ? 1 : 0 );
         }
         catch ( Exception e )
@@ -316,49 +219,82 @@ public class ResourceFileServlet extends HttpServlet implements PwmServlet
         }
     }
 
+    private String makeDebugText( final boolean fromCache, final boolean acceptsGzip, final boolean uncacheable )
+    {
+        if ( uncacheable )
+        {
+            final StringBuilder debugText = new StringBuilder();
+            debugText.append( "(uncacheable" );
+            if ( acceptsGzip )
+            {
+                debugText.append( ", gzip" );
+            }
+            debugText.append( ")" );
+            return debugText.toString();
+        }
+
+        if ( fromCache || acceptsGzip )
+        {
+            final StringBuilder debugText = new StringBuilder();
+            debugText.append( "(" );
+            if ( fromCache )
+            {
+                debugText.append( "cached" );
+            }
+            if ( fromCache && acceptsGzip )
+            {
+                debugText.append( ", " );
+            }
+            if ( acceptsGzip )
+            {
+                debugText.append( "gzip" );
+            }
+            debugText.append( ")" );
+            return debugText.toString();
+        }
+        else
+        {
+            return "(not cached)";
+        }
+    }
+
     private boolean handleCacheableResponse(
-            final ResourceServletConfiguration resourceServletConfiguration,
+            final ResourceFileRequest resourceFileRequest,
             final HttpServletResponse response,
-            final FileResource file,
-            final boolean acceptsGzip,
             final Cache<CacheKey, CacheEntry> responseCache
     )
-            throws UncacheableResourceException, IOException
+            throws UncacheableResourceException, IOException, PwmUnrecoverableException
     {
+        final FileResource file = resourceFileRequest.getRequestedFileResource();
 
-        if ( file.length() > resourceServletConfiguration.getMaxCacheBytes() )
+        if ( file.length() > resourceFileRequest.getResourceServletConfiguration().getMaxCacheBytes() )
         {
             throw new UncacheableResourceException( "file to large to cache" );
         }
 
         boolean fromCache = false;
-        final CacheKey cacheKey = new CacheKey( file, acceptsGzip );
+        final CacheKey cacheKey = new CacheKey( file, resourceFileRequest.allowsCompression() );
         CacheEntry cacheEntry = responseCache.getIfPresent( cacheKey );
         if ( cacheEntry == null )
         {
             final Map<String, String> headers = new HashMap<>();
             final ByteArrayOutputStream tempOutputStream = new ByteArrayOutputStream();
-            final InputStream input = file.getInputStream();
 
-            try
+            try ( InputStream input = resourceFileRequest.getRequestedFileResource().getInputStream() )
             {
-                if ( acceptsGzip )
+                if ( resourceFileRequest.allowsCompression() )
                 {
-                    final GZIPOutputStream gzipOutputStream = new GZIPOutputStream( tempOutputStream );
-                    headers.put( HttpHeader.ContentEncoding.getHttpName(), "gzip" );
-                    copy( input, gzipOutputStream );
-                    close( gzipOutputStream );
+                    try ( GZIPOutputStream gzipOutputStream = new GZIPOutputStream( tempOutputStream ) )
+                    {
+                        headers.put( HttpHeader.ContentEncoding.getHttpName(), "gzip" );
+                        JavaHelper.copy( input, gzipOutputStream );
+                    }
                 }
                 else
                 {
-                    copy( input, tempOutputStream );
+                    JavaHelper.copy( input, tempOutputStream );
                 }
             }
-            finally
-            {
-                close( input );
-                close( tempOutputStream );
-            }
 
             final byte[] entity = tempOutputStream.toByteArray();
             headers.put( HttpHeader.ContentLength.getHttpName(), String.valueOf( entity.length ) );
@@ -375,14 +311,9 @@ public class ResourceFileServlet extends HttpServlet implements PwmServlet
             response.setHeader( key, cacheEntry.getHeaderStrings().get( key ) );
         }
 
-        final OutputStream responseOutputStream = response.getOutputStream();
-        try
-        {
-            copy( new ByteArrayInputStream( cacheEntry.getEntity() ), responseOutputStream );
-        }
-        finally
+        try ( OutputStream responseOutputStream = response.getOutputStream() )
         {
-            close( responseOutputStream );
+            JavaHelper.copy( new ByteArrayInputStream( cacheEntry.getEntity() ), responseOutputStream );
         }
 
         return fromCache;
@@ -392,23 +323,18 @@ public class ResourceFileServlet extends HttpServlet implements PwmServlet
             final HttpServletResponse response,
             final FileResource file,
             final boolean acceptsGzip
-    ) throws IOException
+    )
+            throws IOException
     {
-        // Prepare streams.
-        OutputStream output = null;
-        InputStream input = null;
-
-        try
+        try (
+                OutputStream output = new BufferedOutputStream( response.getOutputStream() );
+                InputStream input = new BufferedInputStream( file.getInputStream() );
+        )
         {
-            // Open streams.
-            input = new BufferedInputStream( file.getInputStream() );
-            output = new BufferedOutputStream( response.getOutputStream() );
-
             if ( acceptsGzip )
             {
-                // The browser accepts GZIP, so GZIP the content.
                 response.setHeader( HttpHeader.ContentEncoding.getHttpName(), "gzip" );
-                output = new GZIPOutputStream( output );
+                JavaHelper.copy( input, new GZIPOutputStream( output ) );
             }
             else
             {
@@ -418,337 +344,12 @@ public class ResourceFileServlet extends HttpServlet implements PwmServlet
                 {
                     response.setHeader( HttpHeader.ContentLength.getHttpName(), String.valueOf( file.length() ) );
                 }
+                JavaHelper.copy( input, output );
             }
-
-            // Copy full range.
-            copy( input, output );
-        }
-        finally
-        {
-            // Gently close streams.
-            close( output );
-            close( input );
         }
 
     }
 
-    /**
-     * Returns true if the given accept header accepts the given value.
-     *
-     * @param acceptHeader The accept header.
-     * @param toAccept     The value to be accepted.
-     * @return True if the given accept header accepts the given value.
-     */
-    private static boolean accepts( final String acceptHeader, final String toAccept )
-    {
-        final String[] acceptValues = acceptHeader.split( "\\s*(,|;)\\s*" );
-        Arrays.sort( acceptValues );
-        return Arrays.binarySearch( acceptValues, toAccept ) > -1
-                || Arrays.binarySearch( acceptValues, toAccept.replaceAll( "/.*$", "/*" ) ) > -1
-                || Arrays.binarySearch( acceptValues, "*/*" ) > -1;
-    }
-
-    /**
-     * Copy the given byte range of the given input to the given output.
-     *
-     * @param input  The input to copy the given range to the given output for.
-     * @param output The output to copy the given range from the given input for.
-     * @throws IOException If something fails at I/O level.
-     */
-    private static void copy( final InputStream input, final OutputStream output )
-            throws IOException
-    {
-        IOUtils.copy( input, output );
-    }
-
-    /**
-     * Close the given resource.
-     *
-     * @param resource The resource to be closed.
-     */
-    private static void close( final Closeable resource )
-    {
-        IOUtils.closeQuietly( resource );
-    }
-
-    static FileResource resolveRequestedFile(
-            final ServletContext servletContext,
-            final String resourcePathUri,
-            final ResourceServletConfiguration resourceServletConfiguration
-    )
-            throws PwmUnrecoverableException
-    {
-        // URL-decode the file name (might contain spaces and on) and prepare file object.
-        String filename = StringUtil.urlDecode( resourcePathUri );
-
-        // parse out the session key...
-        if ( filename.contains( ";" ) )
-        {
-            filename = filename.substring( 0, filename.indexOf( ";" ) );
-        }
-
-
-        if ( !filename.startsWith( RESOURCE_PATH ) )
-        {
-            LOGGER.warn( "illegal url request to " + filename );
-            throw new PwmUnrecoverableException( new ErrorInformation( PwmError.ERROR_SERVICE_NOT_AVAILABLE, "illegal url request" ) );
-        }
-
-        {
-            final FileResource resource = handleWebjarURIs( servletContext, resourcePathUri );
-            if ( resource != null )
-            {
-                return resource;
-            }
-        }
-
-        {
-            // check files system zip files.
-            final Map<String, ZipFile> zipResources = resourceServletConfiguration.getZipResources();
-            for ( final Map.Entry<String, ZipFile> entry : zipResources.entrySet() )
-            {
-                final String path = entry.getKey();
-                if ( filename.startsWith( path ) )
-                {
-                    final String zipSubPath = filename.substring( path.length() + 1, filename.length() );
-                    final ZipFile zipFile = entry.getValue();
-                    final ZipEntry zipEntry = zipFile.getEntry( zipSubPath );
-                    if ( zipEntry != null )
-                    {
-                        return new ZipFileResource( zipFile, zipEntry );
-                    }
-                }
-                if ( filename.startsWith( zipResources.get( path ).getName() ) )
-                {
-                    LOGGER.warn( "illegal url request to " + filename + " zip resource" );
-                    throw new PwmUnrecoverableException( new ErrorInformation( PwmError.ERROR_SERVICE_NOT_AVAILABLE, "illegal url request" ) );
-                }
-            }
-        }
-
-        // convert to file.
-        final String filePath = servletContext.getRealPath( filename );
-        final File file = new File( filePath );
-
-        // figure top-most path allowed by request
-        final String parentDirectoryPath = servletContext.getRealPath( RESOURCE_PATH );
-        final File parentDirectory = new File( parentDirectoryPath );
-
-        FileResource fileSystemResource = null;
-        {
-            //verify the requested page is a child of the servlet resource path.
-            int recursions = 0;
-            File recurseFile = file.getParentFile();
-            while ( recurseFile != null && recursions < 100 )
-            {
-                if ( parentDirectory.equals( recurseFile ) )
-                {
-                    fileSystemResource = new RealFileResource( file );
-                    break;
-                }
-                recurseFile = recurseFile.getParentFile();
-                recursions++;
-            }
-        }
-
-        if ( fileSystemResource == null )
-        {
-            LOGGER.warn( "attempt to access file outside of servlet path " + file.getAbsolutePath() );
-            throw new PwmUnrecoverableException( new ErrorInformation( PwmError.ERROR_SERVICE_NOT_AVAILABLE, "illegal file path request" ) );
-        }
-
-        if ( !fileSystemResource.exists() )
-        {
-            // check custom (configuration defined) zip file bundles
-            final Map<String, FileResource> customResources = resourceServletConfiguration.getCustomFileBundle();
-            for ( final Map.Entry<String, FileResource> entry : customResources.entrySet() )
-            {
-                final String customFileName = entry.getKey();
-                final String testName = RESOURCE_PATH + "/" + customFileName;
-                if ( testName.equals( resourcePathUri ) )
-                {
-                    return entry.getValue();
-                }
-            }
-        }
-
-        return fileSystemResource;
-    }
-
-    private boolean handleEmbeddedURIs(
-            final PwmApplication pwmApplication,
-            final String requestURI,
-            final HttpServletResponse response,
-            final ResourceServletConfiguration resourceServletConfiguration
-    )
-            throws PwmUnrecoverableException, IOException, ServletException
-    {
-        if ( requestURI != null )
-        {
-            final String embedThemeUrl = RESOURCE_PATH + THEME_CSS_PATH.replace( TOKEN_THEME, EMBED_THEME );
-            final String embedThemeMobileUrl = RESOURCE_PATH + THEME_CSS_MOBILE_PATH.replace( TOKEN_THEME, EMBED_THEME );
-            if ( requestURI.equalsIgnoreCase( embedThemeUrl ) )
-            {
-                writeConfigSettingToBody( pwmApplication, PwmSetting.DISPLAY_CSS_EMBED, response, resourceServletConfiguration );
-                return true;
-            }
-            else if ( requestURI.equalsIgnoreCase( embedThemeMobileUrl ) )
-            {
-                writeConfigSettingToBody( pwmApplication, PwmSetting.DISPLAY_CSS_MOBILE_EMBED, response, resourceServletConfiguration );
-                return true;
-            }
-        }
-        return false;
-    }
-
-    private void writeConfigSettingToBody(
-            final PwmApplication pwmApplication,
-            final PwmSetting pwmSetting,
-            final HttpServletResponse response,
-            final ResourceServletConfiguration resourceServletConfiguration
-    )
-            throws PwmUnrecoverableException, IOException
-    {
-        final String bodyText = pwmApplication.getConfig().readSettingAsString( pwmSetting );
-        try
-        {
-            response.setContentType( "text/css" );
-            addExpirationHeaders( resourceServletConfiguration, response );
-            if ( bodyText != null && bodyText.length() > 0 )
-            {
-                response.setIntHeader( "Content-Length", bodyText.length() );
-                copy( new ByteArrayInputStream( bodyText.getBytes( PwmConstants.DEFAULT_CHARSET ) ), response.getOutputStream() );
-            }
-            else
-            {
-                response.setIntHeader( "Content-Length", 0 );
-            }
-        }
-        finally
-        {
-            close( response.getOutputStream() );
-        }
-    }
-
-    private String stripNonceFromURI(
-            final ResourceServletConfiguration resourceServletConfiguration,
-            final String uriString
-    )
-    {
-        if ( !resourceServletConfiguration.isEnablePathNonce() )
-        {
-            return uriString;
-        }
-
-        final Matcher theMatcher = resourceServletConfiguration.getNoncePattern().matcher( uriString );
-
-        if ( theMatcher.find() )
-        {
-            return theMatcher.replaceFirst( "" );
-        }
-
-        return uriString;
-    }
-
-    private String figureRequestPathMinusContext( final HttpServletRequest httpServletRequest )
-    {
-        final String requestURI = httpServletRequest.getRequestURI();
-        return requestURI.substring( httpServletRequest.getContextPath().length(), requestURI.length() );
-    }
-
-    private static FileResource handleWebjarURIs(
-            final ServletContext servletContext,
-            final String resourcePathUri
-    )
-            throws PwmUnrecoverableException
-    {
-        if ( resourcePathUri.startsWith( WEBJAR_BASE_URL_PATH ) )
-        {
-            // This allows us to override a webjar file, if needed.  Mostly helpful during development.
-            final File file = new File( servletContext.getRealPath( resourcePathUri ) );
-            if ( file.exists() )
-            {
-                return new RealFileResource( file );
-            }
-
-            final String remainingPath = resourcePathUri.substring( WEBJAR_BASE_URL_PATH.length(), resourcePathUri.length() );
-
-            final String webJarName;
-            final String webJarPath;
-            {
-                final int slashIndex = remainingPath.indexOf( "/" );
-                if ( slashIndex < 0 )
-                {
-                    return null;
-                }
-                webJarName = remainingPath.substring( 0, slashIndex );
-                webJarPath = remainingPath.substring( slashIndex + 1, remainingPath.length() );
-            }
-
-            final String versionString = WEB_JAR_VERSION_MAP.get( webJarName );
-            if ( versionString == null )
-            {
-                return null;
-            }
-
-            final String fullPath = WEBJAR_BASE_FILE_PATH + "/" + webJarName + "/" + versionString + "/" + webJarPath;
-            if ( WEB_JAR_ASSET_LIST.contains( fullPath ) )
-            {
-                final ClassLoader classLoader = servletContext.getClassLoader();
-                final InputStream inputStream = classLoader.getResourceAsStream( fullPath );
-
-                if ( inputStream != null )
-                {
-                    return new InputStreamFileResource( inputStream, fullPath );
-                }
-            }
-        }
-
-        return null;
-    }
-
-    private static class InputStreamFileResource implements FileResource
-    {
-        private final InputStream inputStream;
-        private final String fullPath;
-
-        InputStreamFileResource( final InputStream inputStream, final String fullPath )
-        {
-            this.inputStream = inputStream;
-            this.fullPath = fullPath;
-        }
-
-        @Override
-        public InputStream getInputStream( ) throws IOException
-        {
-            return inputStream;
-        }
-
-        @Override
-        public long length( )
-        {
-            return 0;
-        }
-
-        @Override
-        public long lastModified( )
-        {
-            return 0;
-        }
-
-        @Override
-        public boolean exists( )
-        {
-            return true;
-        }
-
-        @Override
-        public String getName( )
-        {
-            return fullPath;
-        }
-    }
-
     private void addExpirationHeaders( final ResourceServletConfiguration resourceServletConfiguration, final HttpServletResponse httpResponse )
     {
         httpResponse.setDateHeader( "Expires", System.currentTimeMillis() + ( resourceServletConfiguration.getCacheExpireSeconds() * 1000 ) );
@@ -756,16 +357,26 @@ public class ResourceFileServlet extends HttpServlet implements PwmServlet
         httpResponse.setHeader( "Vary", "Accept-Encoding" );
     }
 
-    private String getMimeType( final String filename )
+    private boolean respondWithNotModified( final PwmRequest pwmRequest, final ResourceServletConfiguration resourceConfiguration )
     {
-        final String contentType = getServletContext().getMimeType( filename );
-        if ( contentType == null )
+        final String eTagValue = resourceConfiguration.getNonceValue();
+        final HttpServletResponse response = pwmRequest.getPwmResponse().getHttpServletResponse();
+
+        final String ifNoneMatchValue = pwmRequest.readHeaderValueAsString( HttpHeader.If_None_Match );
+        if ( ifNoneMatchValue != null && ifNoneMatchValue.equals( eTagValue ) )
         {
-            if ( filename.endsWith( ".woff2" ) )
+            // reply back with etag.
+            response.reset();
+            response.setStatus( HttpServletResponse.SC_NOT_MODIFIED );
+            try
             {
-                return "font/woff2";
+                pwmRequest.debugHttpRequestToLog( "returning HTTP 304 status" );
             }
+            catch ( PwmUnrecoverableException e2 )
+            { /* noop */ }
+            return true;
         }
-        return contentType;
+
+        return false;
     }
 }

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

@@ -271,7 +271,11 @@ public class ResourceServletService implements PwmService
         for ( final String testUrl : testUrls )
         {
             final String themePathUrl = ResourceFileServlet.RESOURCE_PATH + testUrl.replace( ResourceFileServlet.TOKEN_THEME, themeName );
-            final FileResource resolvedFile = ResourceFileServlet.resolveRequestedFile( servletContext, themePathUrl, getResourceServletConfiguration() );
+            final FileResource resolvedFile = ResourceFileRequest.resolveRequestedResource(
+                    pwmRequest.getConfig(),
+                    servletContext,
+                    themePathUrl,
+                    getResourceServletConfiguration() );
             if ( resolvedFile != null && resolvedFile.exists() )
             {
                 LOGGER.debug( pwmRequest, () -> "check for theme validity of '" + themeName + "' returned true" );

+ 1 - 1
server/src/main/java/password/pwm/http/tag/conditional/PwmIfTest.java

@@ -364,7 +364,7 @@ public enum PwmIfTest
                 final HealthMonitor healthMonitor = pwmRequest.getPwmApplication().getHealthMonitor();
                 if ( healthMonitor != null && healthMonitor.status() == PwmService.STATUS.OPEN )
                 {
-                    if ( healthMonitor.getMostSevereHealthStatus( HealthMonitor.CheckTimeliness.NeverBlock ) == HealthStatus.WARN )
+                    if ( healthMonitor.getMostSevereHealthStatus() == HealthStatus.WARN )
                     {
                         return true;
                     }

+ 2 - 0
server/src/main/java/password/pwm/ldap/UserInfo.java

@@ -109,4 +109,6 @@ public interface UserInfo
     List<String> readMultiStringAttribute( String attribute ) throws PwmUnrecoverableException;
 
     Map<String, String> readStringAttributes( Collection<String> attributes ) throws PwmUnrecoverableException;
+
+    Instant getPasswordExpirationNoticeSendTime( ) throws PwmUnrecoverableException;
 }

+ 1 - 2
server/src/main/java/password/pwm/ldap/UserInfoBean.java

@@ -46,7 +46,6 @@ import java.util.Map;
 @Builder
 public class UserInfoBean implements UserInfo
 {
-
     private final UserIdentity userIdentity;
     private final String username;
     private final String userEmailAddress;
@@ -87,6 +86,7 @@ public class UserInfoBean implements UserInfo
     private final Instant passwordLastModifiedTime;
     private final Instant lastLdapLoginTime;
     private final Instant accountExpirationTime;
+    private final Instant passwordExpirationNoticeSendTime;
 
     private final boolean accountEnabled;
     private final boolean accountExpired;
@@ -148,6 +148,5 @@ public class UserInfoBean implements UserInfo
         }
         return Collections.unmodifiableMap( returnObj );
     }
-
 }
 

+ 18 - 1
server/src/main/java/password/pwm/ldap/UserInfoReader.java

@@ -52,10 +52,11 @@ import password.pwm.error.PwmDataValidationException;
 import password.pwm.error.PwmError;
 import password.pwm.error.PwmUnrecoverableException;
 import password.pwm.svc.PwmService;
-import password.pwm.util.i18n.LocaleHelper;
+import password.pwm.svc.pwnotify.PwNotifyUserStatus;
 import password.pwm.util.PasswordData;
 import password.pwm.util.PwmPasswordRuleValidator;
 import password.pwm.util.form.FormUtility;
+import password.pwm.util.i18n.LocaleHelper;
 import password.pwm.util.java.CachingProxyWrapper;
 import password.pwm.util.java.JavaHelper;
 import password.pwm.util.java.TimeDuration;
@@ -74,6 +75,7 @@ import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 
 public class UserInfoReader implements UserInfo
@@ -868,4 +870,19 @@ public class UserInfoReader implements UserInfo
     {
         return locale == null ? null : LocaleHelper.getBrowserLocaleString( locale );
     }
+
+    @Override
+    public Instant getPasswordExpirationNoticeSendTime()
+            throws PwmUnrecoverableException
+    {
+        if ( pwmApplication.getPwNotifyService().status() == PwmService.STATUS.OPEN )
+        {
+            final Optional<PwNotifyUserStatus> optionalState = pwmApplication.getPwNotifyService().readUserNotificationState( userIdentity, sessionLabel );
+            if ( optionalState.isPresent() )
+            {
+                return optionalState.get().getExpireTime();
+            }
+        }
+        return null;
+    }
 }

+ 6 - 1
server/src/main/java/password/pwm/svc/event/AuditService.java

@@ -309,7 +309,12 @@ public class AuditService implements PwmService
             body = StringUtil.mapToString( mapRecord, "=", "\n" );
         }
 
-        final EmailItemBean emailItem = new EmailItemBean( toAddress, fromAddress, subject, body, null );
+        final EmailItemBean emailItem = EmailItemBean.builder()
+                .to( toAddress )
+                .from( fromAddress )
+                .subject( subject )
+                .bodyPlain( body )
+                .build();
         pwmApplication.getEmailQueue().submitEmail( emailItem, null, macroMachine );
     }
 

+ 0 - 1
server/src/main/java/password/pwm/svc/node/NodeService.java

@@ -44,7 +44,6 @@ import java.util.Map;
 
 public class NodeService implements PwmService
 {
-
     private static final PwmLogger LOGGER = PwmLogger.forClass( NodeService.class );
 
     private PwmApplication pwmApplication;

+ 27 - 93
server/src/main/java/password/pwm/svc/report/ReportService.java

@@ -75,7 +75,7 @@ public class ReportService implements PwmService
     private PwmApplication pwmApplication;
     private STATUS status = STATUS.NEW;
     private boolean cancelFlag = false;
-    private ReportStatusInfo reportStatus = new ReportStatusInfo( "" );
+    private ReportStatusInfo reportStatus = ReportStatusInfo.builder().build();
     private ReportSummaryData summaryData = ReportSummaryData.newSummaryData( null );
     private ExecutorService executorService;
 
@@ -201,7 +201,7 @@ public class ReportService implements PwmService
             {
                 if ( reportStatus.getCurrentProcess() != ReportStatusInfo.ReportEngineProcess.ReadData
                         && reportStatus.getCurrentProcess() != ReportStatusInfo.ReportEngineProcess.SearchLDAP
-                        )
+                )
                 {
                     executorService.execute( new ClearTask() );
                     executorService.execute( new ReadLDAPTask() );
@@ -292,31 +292,13 @@ public class ReportService implements PwmService
             {
                 try
                 {
-                    UserCacheRecord returnBean = null;
-                    while ( returnBean == null && this.storageKeyIterator.hasNext() )
+                    while ( this.storageKeyIterator.hasNext() )
                     {
                         final UserCacheService.StorageKey key = this.storageKeyIterator.next();
-                        returnBean = userCacheService.readStorageKey( key );
+                        final UserCacheRecord returnBean = userCacheService.readStorageKey( key );
                         if ( returnBean != null )
                         {
-                            if ( returnBean.getCacheTimestamp() == null )
-                            {
-                                final UserCacheRecord finalBean = returnBean;
-                                LOGGER.debug( SessionLabel.REPORTING_SESSION_LABEL, () -> "purging record due to missing cache timestamp: "
-                                        + JsonUtil.serialize( finalBean ) );
-                                userCacheService.removeStorageKey( key );
-                            }
-                            else if ( TimeDuration.fromCurrent( returnBean.getCacheTimestamp() ).isLongerThan( settings.getMaxCacheAge() ) )
-                            {
-                                final UserCacheRecord finalBean = returnBean;
-                                LOGGER.debug( SessionLabel.REPORTING_SESSION_LABEL, () -> "purging record due to old age timestamp: "
-                                        + JsonUtil.serialize( finalBean ) );
-                                userCacheService.removeStorageKey( key );
-                            }
-                            else
-                            {
-                                return returnBean;
-                            }
+                            return returnBean;
                         }
 
                     }
@@ -407,6 +389,7 @@ public class ReportService implements PwmService
             }
             catch ( Exception e )
             {
+                boolean errorProcessed = false;
                 if ( e instanceof PwmException )
                 {
                     if ( ( ( PwmException ) e ).getErrorInformation().getError() == PwmError.ERROR_DIRECTORY_UNAVAILABLE )
@@ -415,12 +398,14 @@ public class ReportService implements PwmService
                         {
                             LOGGER.error( SessionLabel.REPORTING_SESSION_LABEL, "directory unavailable error during background SearchLDAP, will retry; error: " + e.getMessage() );
                             pwmApplication.scheduleFutureJob( new ReadLDAPTask(), executorService, TimeDuration.of( 10, TimeDuration.Unit.MINUTES ) );
+                            errorProcessed = true;
                         }
                     }
-                    else
-                    {
-                        LOGGER.error( SessionLabel.REPORTING_SESSION_LABEL, "error during background ReadData: " + e.getMessage() );
-                    }
+                }
+
+                if ( !errorProcessed )
+                {
+                    LOGGER.error( SessionLabel.REPORTING_SESSION_LABEL, "error during background ReadData: " + e.getMessage() );
                 }
             }
             finally
@@ -614,20 +599,13 @@ public class ReportService implements PwmService
 
 
         private void updateCachedRecordFromLdap( final UserIdentity userIdentity )
-                throws ChaiUnavailableException, PwmUnrecoverableException, LocalDBException
+                throws PwmUnrecoverableException, LocalDBException
         {
             if ( status != STATUS.OPEN )
             {
                 return;
             }
 
-            final UserCacheService.StorageKey storageKey = UserCacheService.StorageKey.fromUserIdentity( pwmApplication, userIdentity );
-            final UserCacheRecord userCacheRecord = userCacheService.readStorageKey( storageKey );
-
-            if ( userCacheRecord != null )
-            {
-                summaryData.remove( userCacheRecord );
-            }
             final UserInfo userInfo = UserInfoFactory.newUserInfoUsingProxyForOfflineUser(
                     pwmApplication,
                     SessionLabel.REPORTING_SESSION_LABEL,
@@ -642,71 +620,29 @@ public class ReportService implements PwmService
         }
     }
 
-    private class RolloverTask implements Runnable
+    private class DailyJobExecuteTask implements Runnable
     {
         @Override
         public void run( )
         {
-            reportStatus.setCurrentProcess( ReportStatusInfo.ReportEngineProcess.RollOver );
-            try
-            {
-                summaryData = ReportSummaryData.newSummaryData( settings.getTrackDays() );
-                updateRestingCacheData();
-            }
-            finally
+            checkForOutdatedStoreData();
+
+            if ( settings.isDailyJobEnabled() )
             {
-                reportStatus.setCurrentProcess( ReportStatusInfo.ReportEngineProcess.None );
+                executorService.execute( new ClearTask() );
+                executorService.execute( new ReadLDAPTask() );
             }
         }
 
-        private void updateRestingCacheData( )
+        private void checkForOutdatedStoreData()
         {
-            final Instant startTime = Instant.now();
-            int examinedRecords = 0;
-
-            try ( ClosableIterator<UserCacheRecord> iterator = iterator() )
+            final Instant lastFinishDate = reportStatus.getFinishDate();
+            if ( lastFinishDate != null && TimeDuration.fromCurrent( lastFinishDate ).isLongerThan( settings.getMaxCacheAge() ) )
             {
-                final long totalRecords = userCacheService.size();
-                LOGGER.debug( SessionLabel.REPORTING_SESSION_LABEL, () -> "beginning cache review process of " + totalRecords + " records" );
-                Instant lastLogOutputTime = Instant.now();
-
-                while ( !cancelFlag && iterator.hasNext() && status == STATUS.OPEN )
-                {
-                    // (purge routine is embedded in next();
-                    final UserCacheRecord record = iterator.next();
-
-                    if ( summaryData != null && record != null )
-                    {
-                        summaryData.update( record );
-                    }
-
-                    examinedRecords++;
-
-                    if ( TimeDuration.fromCurrent( lastLogOutputTime ).isLongerThan( 30, TimeDuration.Unit.SECONDS ) )
-                    {
-                        final int finalExamined = examinedRecords;
-                        LOGGER.trace( SessionLabel.REPORTING_SESSION_LABEL,
-                                () -> "cache review process in progress, examined " + finalExamined
-                                        + " in " + TimeDuration.compactFromCurrent( startTime ) );
-                        lastLogOutputTime = Instant.now();
-                    }
-                }
-                final int finalExamined = examinedRecords;
-                LOGGER.info( SessionLabel.REPORTING_SESSION_LABEL,
-                        () -> "completed cache review process of " + finalExamined
-                                + " cached report records in " + TimeDuration.compactFromCurrent( startTime ) );
+                executorService.execute( new ClearTask() );
             }
         }
-    }
 
-    private class DailyJobExecuteTask implements Runnable
-    {
-        @Override
-        public void run( )
-        {
-            executorService.execute( new ClearTask() );
-            executorService.execute( new ReadLDAPTask() );
-        }
     }
 
     private class InitializationTask implements Runnable
@@ -725,17 +661,15 @@ public class ReportService implements PwmService
                 return;
             }
 
-            final boolean reportingEnabled = pwmApplication.getConfig().readSettingAsBoolean( PwmSetting.REPORTING_ENABLE );
+            final boolean reportingEnabled = pwmApplication.getConfig().readSettingAsBoolean( PwmSetting.REPORTING_ENABLE_DAILY_JOB );
             if ( reportingEnabled )
             {
                 final Instant nextZuluZeroTime = JavaHelper.nextZuluZeroTime();
                 final long secondsUntilNextDredge = settings.getJobOffsetSeconds() + TimeDuration.fromCurrent( nextZuluZeroTime ).as( TimeDuration.Unit.SECONDS );
                 final TimeDuration initialDelay = TimeDuration.of( secondsUntilNextDredge, TimeDuration.Unit.SECONDS );
-                pwmApplication.scheduleFixedRateJob( new ProcessWorkQueueTask(), executorService, initialDelay, TimeDuration.DAY );
+                pwmApplication.scheduleFixedRateJob( new DailyJobExecuteTask(), executorService, initialDelay, TimeDuration.DAY );
                 LOGGER.debug( () -> "scheduled daily execution, next task will be at " + nextZuluZeroTime.toString() );
             }
-            executorService.submit( new RolloverTask() );
-            executorService.submit( new ProcessWorkQueueTask() );
         }
 
 
@@ -769,7 +703,7 @@ public class ReportService implements PwmService
 
             if ( clearFlag )
             {
-                reportStatus = new ReportStatusInfo( settings.getSettingsHash() );
+                reportStatus = ReportStatusInfo.builder().settingsHash( settings.getSettingsHash() ).build();
                 executeCommand( ReportCommand.Clear );
             }
         }
@@ -800,7 +734,7 @@ public class ReportService implements PwmService
                 userCacheService.clear();
             }
             summaryData = ReportSummaryData.newSummaryData( settings.getTrackDays() );
-            reportStatus = new ReportStatusInfo( settings.getSettingsHash() );
+            reportStatus = ReportStatusInfo.builder().settingsHash( settings.getSettingsHash() ).build();
             LOGGER.debug( SessionLabel.REPORTING_SESSION_LABEL, () -> "finished clearing report " + TimeDuration.compactFromCurrent( startTime ) );
         }
     }

+ 5 - 2
server/src/main/java/password/pwm/svc/report/ReportSettings.java

@@ -46,8 +46,10 @@ class ReportSettings implements Serializable
 {
     private static final PwmLogger LOGGER = PwmLogger.forClass( ReportSettings.class );
 
+    private boolean dailyJobEnabled;
+
     @Builder.Default
-    private TimeDuration maxCacheAge = TimeDuration.of( TimeDuration.DAY.asMillis() * 90, TimeDuration.Unit.MILLISECONDS );
+    private TimeDuration maxCacheAge = TimeDuration.of( 10, TimeDuration.Unit.DAYS );
 
     @Builder.Default
     private List<UserPermission> searchFilter = Collections.emptyList();
@@ -77,9 +79,10 @@ class ReportSettings implements Serializable
     public static ReportSettings readSettingsFromConfig( final Configuration config )
     {
         final ReportSettings.ReportSettingsBuilder builder = ReportSettings.builder();
-        builder.maxCacheAge( TimeDuration.of( config.readSettingAsLong( PwmSetting.REPORTING_MAX_CACHE_AGE ), TimeDuration.Unit.SECONDS ) );
+        builder.maxCacheAge( TimeDuration.of( Long.parseLong( config.readAppProperty( AppProperty.REPORTING_MAX_REPORT_AGE_SECONDS ) ), TimeDuration.Unit.SECONDS ) );
         builder.searchFilter( config.readSettingAsUserPermission( PwmSetting.REPORTING_USER_MATCH ) );
         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() )
         {

+ 9 - 72
server/src/main/java/password/pwm/svc/report/ReportStatusInfo.java

@@ -22,20 +22,29 @@
 
 package password.pwm.svc.report;
 
+import lombok.Builder;
+import lombok.Data;
 import password.pwm.error.ErrorInformation;
 import password.pwm.util.java.TimeDuration;
 
 import java.io.Serializable;
 import java.time.Instant;
 
+@Data
+@Builder( toBuilder = true )
 public class ReportStatusInfo implements Serializable
 {
+    @Builder.Default
     private TimeDuration jobDuration = TimeDuration.ZERO;
+
+    private Instant startDate;
     private Instant finishDate;
     private int count;
     private int errors;
     private ErrorInformation lastError;
     private String settingsHash;
+
+    @Builder.Default
     private ReportEngineProcess currentProcess = ReportEngineProcess.None;
 
     public enum ReportEngineProcess
@@ -58,76 +67,4 @@ public class ReportStatusInfo implements Serializable
             return label;
         }
     }
-
-
-    public ReportStatusInfo( final String settingsHash )
-    {
-        this.settingsHash = settingsHash;
-    }
-
-    public String getSettingsHash( )
-    {
-        return settingsHash;
-    }
-
-    public TimeDuration getJobDuration( )
-    {
-        return jobDuration;
-    }
-
-    public void setJobDuration( final TimeDuration jobDuration )
-    {
-        this.jobDuration = jobDuration;
-    }
-
-    public Instant getFinishDate( )
-    {
-        return finishDate;
-    }
-
-    public void setFinishDate( final Instant finishDate )
-    {
-        this.finishDate = finishDate;
-    }
-
-    public int getCount( )
-    {
-        return count;
-    }
-
-    public void setCount( final int count )
-    {
-        this.count = count;
-    }
-
-
-    public int getErrors( )
-    {
-        return errors;
-    }
-
-    public void setErrors( final int errors )
-    {
-        this.errors = errors;
-    }
-
-    public ErrorInformation getLastError( )
-    {
-        return lastError;
-    }
-
-    public void setLastError( final ErrorInformation lastError )
-    {
-        this.lastError = lastError;
-    }
-
-    public ReportEngineProcess getCurrentProcess( )
-    {
-        return currentProcess;
-    }
-
-    public void setCurrentProcess( final ReportEngineProcess currentProcess )
-    {
-        this.currentProcess = currentProcess;
-    }
 }

+ 96 - 227
server/src/main/java/password/pwm/svc/report/ReportSummaryData.java

@@ -23,6 +23,8 @@
 package password.pwm.svc.report;
 
 import com.novell.ldapchai.cr.Answer;
+import lombok.Builder;
+import lombok.Value;
 import password.pwm.config.Configuration;
 import password.pwm.config.option.DataStorageMethod;
 import password.pwm.i18n.Admin;
@@ -44,12 +46,12 @@ import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Collectors;
 
+@Value
 public class ReportSummaryData
 {
     private static final long MS_DAY = TimeDuration.DAY.asMillis();
     private static final BigInteger TWO = new BigInteger( "2" );
 
-    private Instant meanCacheTime;
     private final AtomicInteger totalUsers = new AtomicInteger( 0 );
     private final AtomicInteger hasResponses = new AtomicInteger( 0 );
     private final AtomicInteger hasResponseSetTime = new AtomicInteger( 0 );
@@ -63,6 +65,7 @@ public class ReportSummaryData
     private final AtomicInteger pwExpired = new AtomicInteger( 0 );
     private final AtomicInteger pwPreExpired = new AtomicInteger( 0 );
     private final AtomicInteger pwWarnPeriod = new AtomicInteger( 0 );
+    private final AtomicInteger hasReceivedPwExpireNotification = new AtomicInteger( 0 );
 
     private final Map<DataStorageMethod, AtomicInteger> responseStorage = new ConcurrentHashMap<>();
     private final Map<Answer.FormatType, AtomicInteger> responseFormatType = new ConcurrentHashMap<>();
@@ -73,6 +76,7 @@ public class ReportSummaryData
     private final Map<Integer, AtomicInteger> responseSetDays = new ConcurrentHashMap<>();
     private final Map<Integer, AtomicInteger> otpSetDays = new ConcurrentHashMap<>();
     private final Map<Integer, AtomicInteger> loginDays = new ConcurrentHashMap<>();
+    private final Map<Integer, AtomicInteger> pwExpireNotificationDays = new ConcurrentHashMap<>();
 
     private ReportSummaryData( )
     {
@@ -92,27 +96,13 @@ public class ReportSummaryData
                 reportSummaryData.responseSetDays.put( day, new AtomicInteger( 0 ) );
                 reportSummaryData.otpSetDays.put( day, new AtomicInteger( 0 ) );
                 reportSummaryData.loginDays.put( day, new AtomicInteger( 0 ) );
+                reportSummaryData.pwExpireNotificationDays.put( day, new AtomicInteger( 0 ) );
             }
         }
 
         return reportSummaryData;
     }
 
-    public int getTotalUsers( )
-    {
-        return totalUsers.get();
-    }
-
-    public int getHasResponses( )
-    {
-        return hasResponses.get();
-    }
-
-    public int getHasPasswordExpirationTime( )
-    {
-        return hasPasswordExpirationTime.get();
-    }
-
     public Map<DataStorageMethod, Integer> getResponseStorage( )
     {
         return Collections.unmodifiableMap( responseStorage.entrySet()
@@ -129,141 +119,77 @@ public class ReportSummaryData
                         e -> e.getValue().get() ) ) );
     }
 
-    public Instant getMeanCacheTime( )
-    {
-        return meanCacheTime;
-    }
-
     void update( final UserCacheRecord userCacheRecord )
     {
-        update( userCacheRecord, true );
-    }
-
-    void remove( final UserCacheRecord userCacheRecord )
-    {
-        update( userCacheRecord, false );
-    }
-
-    @SuppressWarnings( "checkstyle:MethodLength" )
-    private void update( final UserCacheRecord userCacheRecord, final boolean adding )
-    {
-        final int modifier = adding ? 1 : -1;
-
-        totalUsers.addAndGet( modifier );
+        totalUsers.incrementAndGet();
 
-        updateMeanTime( userCacheRecord.cacheTimestamp, adding );
-
-        if ( userCacheRecord.hasResponses )
+        if ( userCacheRecord.isHasResponses() )
         {
-            hasResponses.addAndGet( modifier );
+            hasResponses.incrementAndGet();
         }
 
-        if ( userCacheRecord.hasHelpdeskResponses )
+        if ( userCacheRecord.isHasHelpdeskResponses() )
         {
-            hasHelpdeskResponses.addAndGet( modifier );
+            hasHelpdeskResponses.incrementAndGet();
         }
 
-        if ( userCacheRecord.responseSetTime != null )
+        if ( userCacheRecord.getResponseSetTime() != null )
         {
-            hasResponseSetTime.addAndGet( modifier );
-
-            for ( final Map.Entry<Integer, AtomicInteger> entry : responseSetDays.entrySet() )
-            {
-                final Integer day = entry.getKey();
-                entry.getValue().addAndGet( calcTimeWindow( userCacheRecord.responseSetTime, MS_DAY * day, adding ) );
-            }
+            hasResponseSetTime.incrementAndGet();
+            incrementIfWithinTimeWindow( userCacheRecord, responseSetDays );
         }
 
-        if ( userCacheRecord.passwordExpirationTime != null )
+        if ( userCacheRecord.getPasswordExpirationTime() != null )
         {
-            hasPasswordExpirationTime.addAndGet( modifier );
-
-            for ( final Map.Entry<Integer, AtomicInteger> entry : pwExpireDays.entrySet() )
-            {
-                final Integer day = entry.getKey();
-                entry.getValue().addAndGet( calcTimeWindow( userCacheRecord.passwordExpirationTime, MS_DAY * day, adding ) );
-            }
+            hasPasswordExpirationTime.incrementAndGet();
+            incrementIfWithinTimeWindow( userCacheRecord, pwExpireDays );
         }
 
-        if ( userCacheRecord.accountExpirationTime != null )
+        if ( userCacheRecord.getAccountExpirationTime() != null )
         {
-            hasAccountExpirationTime.addAndGet( modifier );
-
-            for ( final Map.Entry<Integer, AtomicInteger> entry : accountExpireDays.entrySet() )
-            {
-                final Integer day = entry.getKey();
-                entry.getValue().addAndGet( calcTimeWindow( userCacheRecord.accountExpirationTime, MS_DAY * day, adding ) );
-            }
+            hasAccountExpirationTime.incrementAndGet();
+            incrementIfWithinTimeWindow( userCacheRecord, accountExpireDays );
         }
 
-        if ( userCacheRecord.lastLoginTime != null )
+        if ( userCacheRecord.getLastLoginTime() != null )
         {
-            hasLoginTime.addAndGet( modifier );
-
-            for ( final Map.Entry<Integer, AtomicInteger> entry : loginDays.entrySet() )
-            {
-                final Integer day = entry.getKey();
-                entry.getValue().addAndGet( calcTimeWindow( userCacheRecord.lastLoginTime, MS_DAY * day, adding ) );
-            }
+            hasLoginTime.incrementAndGet();
+            incrementIfWithinTimeWindow( userCacheRecord, loginDays );
         }
 
-        if ( userCacheRecord.passwordChangeTime != null )
+        if ( userCacheRecord.getPasswordChangeTime() != null )
         {
-            hasChangePwTime.addAndGet( modifier );
+            hasChangePwTime.incrementAndGet();
+            incrementIfWithinTimeWindow( userCacheRecord, changePwDays );
+        }
 
-            for ( final Map.Entry<Integer, AtomicInteger> entry : changePwDays.entrySet() )
-            {
-                final Integer day = entry.getKey();
-                entry.getValue().addAndGet( calcTimeWindow( userCacheRecord.passwordChangeTime, MS_DAY * day, adding ) );
-            }
+        if ( userCacheRecord.getPasswordExpirationNoticeSendTime() != null )
+        {
+            hasReceivedPwExpireNotification.incrementAndGet();
+            incrementIfWithinTimeWindow( userCacheRecord, pwExpireNotificationDays );
         }
 
-        if ( userCacheRecord.passwordStatus != null )
+        if ( userCacheRecord.getPasswordStatus() != null )
         {
-            if ( adding )
+            if ( userCacheRecord.getPasswordStatus().isExpired() )
             {
-                if ( userCacheRecord.passwordStatus.isExpired() )
-                {
-                    pwExpired.incrementAndGet();
-                }
-                if ( userCacheRecord.passwordStatus.isPreExpired() )
-                {
-                    pwPreExpired.incrementAndGet();
-                }
-                if ( userCacheRecord.passwordStatus.isWarnPeriod() )
-                {
-                    pwWarnPeriod.incrementAndGet();
-                }
+                pwExpired.incrementAndGet();
             }
-            else
+            if ( userCacheRecord.getPasswordStatus().isPreExpired() )
             {
-                if ( userCacheRecord.passwordStatus.isExpired() )
-                {
-                    pwExpired.decrementAndGet();
-                }
-                if ( userCacheRecord.passwordStatus.isPreExpired() )
-                {
-                    pwPreExpired.decrementAndGet();
-                }
-                if ( userCacheRecord.passwordStatus.isWarnPeriod() )
-                {
-                    pwWarnPeriod.decrementAndGet();
-                }
+                pwPreExpired.incrementAndGet();
+            }
+            if ( userCacheRecord.getPasswordStatus().isWarnPeriod() )
+            {
+                pwWarnPeriod.incrementAndGet();
             }
         }
 
-        if ( userCacheRecord.responseStorageMethod != null )
+        if ( userCacheRecord.getResponseStorageMethod() != null )
         {
-            final DataStorageMethod method = userCacheRecord.responseStorageMethod;
+            final DataStorageMethod method = userCacheRecord.getResponseStorageMethod();
             responseStorage.putIfAbsent( method, new AtomicInteger( 0 ) );
-            if ( adding )
-            {
-                responseStorage.get( method ).incrementAndGet();
-            }
-            else
-            {
-                responseStorage.get( method ).decrementAndGet();
-            }
+            responseStorage.get( method ).incrementAndGet();
         }
 
         if ( userCacheRecord.getLdapProfile() != null )
@@ -273,86 +199,54 @@ public class ReportSummaryData
             {
                 ldapProfile.put( userProfile, new AtomicInteger( 0 ) );
             }
-            if ( adding )
-            {
-                ldapProfile.get( userProfile ).incrementAndGet();
-            }
-            else
-            {
-                ldapProfile.get( userProfile ).decrementAndGet();
-            }
+            ldapProfile.get( userProfile ).incrementAndGet();
         }
 
-        if ( userCacheRecord.responseFormatType != null )
+        if ( userCacheRecord.getResponseFormatType() != null )
         {
-            final Answer.FormatType type = userCacheRecord.responseFormatType;
+            final Answer.FormatType type = userCacheRecord.getResponseFormatType();
             responseFormatType.putIfAbsent( type, new AtomicInteger( 0 ) );
-            if ( adding )
-            {
-                responseFormatType.get( type ).incrementAndGet();
-            }
-            else
-            {
-                responseFormatType.get( type ).decrementAndGet();
-            }
+            responseFormatType.get( type ).incrementAndGet();
         }
 
         if ( userCacheRecord.isHasOtpSecret() )
         {
-            hasOtpSecret.addAndGet( modifier );
+            hasOtpSecret.incrementAndGet();
         }
 
         if ( userCacheRecord.getOtpSecretSetTime() != null )
         {
-            hasOtpSecretSetTime.addAndGet( modifier );
-
-            for ( final Map.Entry<Integer, AtomicInteger> entry : otpSetDays.entrySet() )
-            {
-                final int day = entry.getKey();
-                entry.getValue().addAndGet( calcTimeWindow( userCacheRecord.getOtpSecretSetTime(), MS_DAY * day, adding ) );
-            }
+            hasOtpSecretSetTime.incrementAndGet();
+            incrementIfWithinTimeWindow( userCacheRecord, otpSetDays );
         }
     }
 
-    private void updateMeanTime( final Instant newTime, final boolean adding )
+    private void incrementIfWithinTimeWindow(
+            final UserCacheRecord userCacheRecord,
+            final Map<Integer, AtomicInteger> map
+    )
     {
-        if ( meanCacheTime == null )
+        for ( final Map.Entry<Integer, AtomicInteger> entry : map.entrySet() )
         {
-            if ( adding )
-            {
-                meanCacheTime = newTime;
-            }
-            return;
-        }
-
-        final BigInteger currentMillis = BigInteger.valueOf( meanCacheTime.toEpochMilli() );
-        final BigInteger newMillis = BigInteger.valueOf( newTime.toEpochMilli() );
-        final BigInteger combinedMillis = currentMillis.add( newMillis );
-        final BigInteger halvedMillis = combinedMillis.divide( TWO );
-        meanCacheTime = Instant.ofEpochMilli( halvedMillis.longValue() );
-    }
-
-    private int calcTimeWindow( final Instant eventDate, final long timeWindow, final boolean adding )
-    {
-        if ( eventDate == null )
-        {
-            return 0;
-        }
-
-        final TimeDuration timeBoundary = TimeDuration.of( timeWindow, TimeDuration.Unit.MILLISECONDS );
-        final TimeDuration eventDifference = TimeDuration.fromCurrent( eventDate );
+            final int day = entry.getKey();
+            final Instant eventDate = userCacheRecord.getOtpSecretSetTime();
+            final long timeWindow = MS_DAY * day;
+            final AtomicInteger number = entry.getValue();
 
-        if ( timeWindow >= 0 && eventDate.isAfter( Instant.now() ) && eventDifference.isShorterThan( timeBoundary ) )
-        {
-            return adding ? 1 : -1;
-        }
+            if ( eventDate != null )
+            {
+                final TimeDuration timeBoundary = TimeDuration.of( timeWindow, TimeDuration.Unit.MILLISECONDS );
+                final TimeDuration eventDifference = TimeDuration.fromCurrent( eventDate );
 
-        if ( timeWindow < 0 && eventDate.isBefore( Instant.now() ) && eventDifference.isShorterThan( timeBoundary ) )
-        {
-            return adding ? 1 : -1;
+                if (
+                        ( timeWindow >= 0 && eventDate.isAfter( Instant.now() ) && eventDifference.isShorterThan( timeBoundary ) )
+                                || ( timeWindow < 0 && eventDate.isBefore( Instant.now() ) && eventDifference.isShorterThan( timeBoundary ) )
+                )
+                {
+                    number.incrementAndGet();
+                }
+            }
         }
-
-        return 0;
     }
 
 
@@ -450,80 +344,54 @@ public class ReportSummaryData
             }
         }
 
+        if ( this.hasReceivedPwExpireNotification.get() > 0 )
+        {
+            returnCollection.add( new PresentationRow( "Has Received PwExpiry Notice", Integer.toString( this.hasReceivedPwExpireNotification.get() ), null ) );
+            for ( final Integer day : new TreeSet<>( pwExpireNotificationDays.keySet() ) )
+            {
+                if ( day < 0 )
+                {
+                    returnCollection.add( new PresentationRow( "PwExpireNotice " + day, Integer.toString( this.pwExpireNotificationDays.get( day ).get() ), null ) );
+                }
+            }
+        }
+
+
         return returnCollection;
     }
 
+    @Value
+    @Builder( toBuilder = true )
     public static class PresentationRow
     {
         private String label;
         private String count;
         private String pct;
-
-        public PresentationRow(
-                final String label,
-                final String count,
-                final String pct
-        )
-        {
-            this.label = label;
-            this.count = count;
-            this.pct = pct;
-        }
-
-        public String getLabel( )
-        {
-            return label;
-        }
-
-        public String getCount( )
-        {
-            return count;
-        }
-
-        public String getPct( )
-        {
-            return pct;
-        }
     }
 
+    @Value
     public static class PresentationRowBuilder
     {
         private final Configuration config;
         private final int totalUsers;
         private final Locale locale;
 
-        public PresentationRowBuilder(
-                final Configuration config,
-                final int totalUsers,
-                final Locale locale
-        )
+        PresentationRow makeRow( final String labelKey, final int valueCount )
         {
-            this.config = config;
-            this.totalUsers = totalUsers;
-            this.locale = locale;
+            return makeRow( labelKey, valueCount, null );
         }
 
-        public PresentationRow makeRow( final String labelKey, final int valueCount )
+        PresentationRow makeRow( final String labelKey, final int valueCount, final String replacement )
         {
-            return makeRow( labelKey, valueCount, null );
+            return makeRowImpl( labelKey, valueCount, replacement );
         }
 
-        public PresentationRow makeRow( final String labelKey, final int valueCount, final String replacement )
+        PresentationRow makeNoPctRow( final String labelKey, final int valueCount, final String replacement )
         {
-            final String display = replacement == null
-                    ? LocaleHelper.getLocalizedMessage( locale, labelKey, config, Admin.class )
-                    : LocaleHelper.getLocalizedMessage( locale, labelKey, config, Admin.class, new String[]
-                    {
-                            replacement,
-                    }
-            );
-            final String pct = valueCount > 0 ? new Percent( valueCount, totalUsers ).pretty( 2 ) : "";
-            final PwmNumberFormat numberFormat = PwmNumberFormat.forLocale( locale );
-            final String formattedCount = numberFormat.format( valueCount );
-            return new PresentationRow( display, formattedCount, pct );
+            return makeRowImpl( labelKey, valueCount, replacement ).toBuilder().pct( null ).build();
         }
 
-        public PresentationRow makeNoPctRow( final String labelKey, final int valueCount, final String replacement )
+        private PresentationRow makeRowImpl( final String labelKey, final int valueCount, final String replacement )
         {
             final String display = replacement == null
                     ? LocaleHelper.getLocalizedMessage( locale, labelKey, config, Admin.class )
@@ -532,9 +400,10 @@ public class ReportSummaryData
                             replacement,
                     }
             );
+            final String pct = valueCount > 0 ? new Percent( valueCount, totalUsers ).pretty( 2 ) : "";
             final PwmNumberFormat numberFormat = PwmNumberFormat.forLocale( locale );
             final String formattedCount = numberFormat.format( valueCount );
-            return new PresentationRow( display, formattedCount, null );
+            return new PresentationRow( display, formattedCount, pct );
         }
     }
 }

+ 56 - 51
server/src/main/java/password/pwm/svc/report/UserCacheRecord.java

@@ -23,9 +23,8 @@
 package password.pwm.svc.report;
 
 import com.novell.ldapchai.cr.Answer;
-import lombok.AccessLevel;
-import lombok.Getter;
-import lombok.Setter;
+import lombok.Builder;
+import lombok.Value;
 import password.pwm.bean.PasswordStatus;
 import password.pwm.config.option.DataStorageMethod;
 import password.pwm.error.PwmUnrecoverableException;
@@ -34,85 +33,91 @@ import password.pwm.ldap.UserInfo;
 import java.io.Serializable;
 import java.time.Instant;
 
-@Getter
-@Setter( AccessLevel.PRIVATE )
+@Value
+@Builder
 public class UserCacheRecord implements Serializable
 {
-    public String userDN;
-    public String ldapProfile;
-    public String userGUID;
+    private String userDN;
+    private String ldapProfile;
+    private String userGUID;
 
-    public String username;
-    public String email;
+    private String username;
+    private String email;
 
-    public Instant cacheTimestamp = Instant.now();
+    private PasswordStatus passwordStatus;
+    private Instant passwordExpirationTime;
+    private Instant passwordChangeTime;
+    private Instant lastLoginTime;
+    private Instant accountExpirationTime;
+    private Instant passwordExpirationNoticeSendTime;
 
-    public PasswordStatus passwordStatus;
-    public Instant passwordExpirationTime;
-    public Instant passwordChangeTime;
-    public Instant lastLoginTime;
-    public Instant accountExpirationTime;
+    private boolean hasResponses;
+    private boolean hasHelpdeskResponses;
+    private Instant responseSetTime;
+    private DataStorageMethod responseStorageMethod;
+    private Answer.FormatType responseFormatType;
 
-    public boolean hasResponses;
-    public boolean hasHelpdeskResponses;
-    public Instant responseSetTime;
-    public DataStorageMethod responseStorageMethod;
-    public Answer.FormatType responseFormatType;
+    private boolean hasOtpSecret;
+    private Instant otpSecretSetTime;
 
-    public boolean hasOtpSecret;
-    public Instant otpSecretSetTime;
+    private boolean requiresPasswordUpdate;
+    private boolean requiresResponseUpdate;
+    private boolean requiresProfileUpdate;
 
-    public boolean requiresPasswordUpdate;
-    public boolean requiresResponseUpdate;
-    public boolean requiresProfileUpdate;
+    private Instant cacheTimestamp;
 
-    void addUiBeanData(
+    static UserCacheRecord fromUserInfo(
             final UserInfo userInfo
     )
             throws PwmUnrecoverableException
     {
-        this.setUserDN( userInfo.getUserIdentity().getUserDN() );
-        this.setLdapProfile( userInfo.getUserIdentity().getLdapProfileID() );
-        this.setUsername( userInfo.getUsername() );
-        this.setEmail( userInfo.getUserEmailAddress() );
-        this.setUserGUID( userInfo.getUserGuid() );
-
-        this.setPasswordStatus( userInfo.getPasswordStatus() );
-
-        this.setPasswordChangeTime( userInfo.getPasswordLastModifiedTime() );
-        this.setPasswordExpirationTime( userInfo.getPasswordExpirationTime() );
-        this.setLastLoginTime( userInfo.getLastLdapLoginTime() );
-        this.setAccountExpirationTime( userInfo.getAccountExpirationTime() );
-
-        this.setHasResponses( userInfo.getResponseInfoBean() != null );
-        this.setResponseSetTime( userInfo.getResponseInfoBean() != null
+        final UserCacheRecordBuilder builder = new UserCacheRecordBuilder();
+        builder.userDN( userInfo.getUserIdentity().getUserDN() );
+        builder.ldapProfile( userInfo.getUserIdentity().getLdapProfileID() );
+        builder.username( userInfo.getUsername() );
+        builder.email( userInfo.getUserEmailAddress() );
+        builder.userGUID( userInfo.getUserGuid() );
+
+        builder.passwordStatus( userInfo.getPasswordStatus() );
+
+        builder.passwordChangeTime( userInfo.getPasswordLastModifiedTime() );
+        builder.passwordExpirationTime( userInfo.getPasswordExpirationTime() );
+        builder.lastLoginTime( userInfo.getLastLdapLoginTime() );
+        builder.accountExpirationTime( userInfo.getAccountExpirationTime() );
+        builder.passwordExpirationNoticeSendTime( userInfo.getPasswordExpirationNoticeSendTime() );
+
+        builder.hasResponses( userInfo.getResponseInfoBean() != null );
+        builder.responseSetTime( userInfo.getResponseInfoBean() != null
                 ? userInfo.getResponseInfoBean().getTimestamp()
                 : null
         );
-        this.setResponseStorageMethod( userInfo.getResponseInfoBean() != null
+        builder.responseStorageMethod( userInfo.getResponseInfoBean() != null
                 ? userInfo.getResponseInfoBean().getDataStorageMethod()
                 : null
         );
-        this.setResponseFormatType( userInfo.getResponseInfoBean() != null
+        builder.responseFormatType( userInfo.getResponseInfoBean() != null
                 ? userInfo.getResponseInfoBean().getFormatType()
                 : null
         );
 
-        this.setRequiresPasswordUpdate( userInfo.isRequiresNewPassword() );
-        this.setRequiresResponseUpdate( userInfo.isRequiresResponseConfig() );
-        this.setRequiresProfileUpdate( userInfo.isRequiresUpdateProfile() );
-        this.setCacheTimestamp( Instant.now() );
+        builder.requiresPasswordUpdate( userInfo.isRequiresNewPassword() );
+        builder.requiresResponseUpdate( userInfo.isRequiresResponseConfig() );
+        builder.requiresProfileUpdate( userInfo.isRequiresUpdateProfile() );
 
-        this.setHasOtpSecret( userInfo.getOtpUserRecord() != null );
-        this.setOtpSecretSetTime( userInfo.getOtpUserRecord() != null && userInfo.getOtpUserRecord().getTimestamp() != null
+        builder.hasOtpSecret( userInfo.getOtpUserRecord() != null );
+        builder.otpSecretSetTime( userInfo.getOtpUserRecord() != null && userInfo.getOtpUserRecord().getTimestamp() != null
                 ? userInfo.getOtpUserRecord().getTimestamp()
                 : null
         );
 
-        this.setHasHelpdeskResponses( userInfo.getResponseInfoBean() != null
+        builder.hasHelpdeskResponses( userInfo.getResponseInfoBean() != null
                 && userInfo.getResponseInfoBean().getHelpdeskCrMap() != null
                 && !userInfo.getResponseInfoBean().getHelpdeskCrMap().isEmpty()
         );
+
+        builder.cacheTimestamp( Instant.now() );
+
+        return builder.build();
     }
 
 }

+ 6 - 23
server/src/main/java/password/pwm/svc/report/UserCacheService.java

@@ -59,24 +59,14 @@ public class UserCacheService implements PwmService
         return status;
     }
 
-    public UserCacheRecord updateUserCache( final UserInfo userInfo )
+    UserCacheRecord updateUserCache( final UserInfo userInfo )
             throws PwmUnrecoverableException
     {
         final StorageKey storageKey = StorageKey.fromUserInfo( userInfo, pwmApplication );
 
-        boolean preExisting = false;
         try
         {
-            UserCacheRecord userCacheRecord = readStorageKey( storageKey );
-            if ( userCacheRecord == null )
-            {
-                userCacheRecord = new UserCacheRecord();
-            }
-            else
-            {
-                preExisting = true;
-            }
-            userCacheRecord.addUiBeanData( userInfo );
+            final UserCacheRecord userCacheRecord = UserCacheRecord.fromUserInfo( userInfo );
             store( userCacheRecord );
             return userCacheRecord;
         }
@@ -86,24 +76,17 @@ public class UserCacheService implements PwmService
         }
 
         {
-            final boolean finalPreExisting = preExisting;
-            LOGGER.trace( () -> "updateCache: " + ( finalPreExisting ? "updated existing" : "created new" ) + " user cache for "
+            LOGGER.trace( () -> "updateCache: read user cache for "
                     + userInfo.getUserIdentity() + " user key " + storageKey.getKey() );
         }
         return null;
     }
 
-    public UserCacheRecord readStorageKey( final StorageKey storageKey ) throws LocalDBException
+    UserCacheRecord readStorageKey( final StorageKey storageKey ) throws LocalDBException
     {
         return cacheStore.read( storageKey );
     }
 
-    public boolean removeStorageKey( final StorageKey storageKey )
-            throws LocalDBException
-    {
-        return cacheStore.remove( storageKey );
-    }
-
     public void store( final UserCacheRecord userCacheRecord )
             throws LocalDBException, PwmUnrecoverableException
     {
@@ -208,14 +191,14 @@ public class UserCacheService implements PwmService
             return key;
         }
 
-        public static StorageKey fromUserInfo( final UserInfo userInfo, final PwmApplication pwmApplication )
+        static StorageKey fromUserInfo( final UserInfo userInfo, final PwmApplication pwmApplication )
                 throws PwmUnrecoverableException
         {
             final String userGUID = userInfo.getUserGuid();
             return fromUserGUID( userGUID, pwmApplication );
         }
 
-        public static StorageKey fromUserIdentity( final PwmApplication pwmApplication, final UserIdentity userIdentity )
+        static StorageKey fromUserIdentity( final PwmApplication pwmApplication, final UserIdentity userIdentity )
                 throws ChaiUnavailableException, PwmUnrecoverableException
         {
             final String userGUID = LdapOperationsHelper.readLdapGuidValue( pwmApplication, null, userIdentity, true );

+ 14 - 19
server/src/main/java/password/pwm/svc/stats/StatisticsManager.java

@@ -30,7 +30,6 @@ import password.pwm.error.PwmException;
 import password.pwm.health.HealthRecord;
 import password.pwm.http.PwmRequest;
 import password.pwm.svc.PwmService;
-import password.pwm.util.AlertHandler;
 import password.pwm.util.java.JavaHelper;
 import password.pwm.util.java.JsonUtil;
 import password.pwm.util.java.TimeDuration;
@@ -329,7 +328,7 @@ public class StatisticsManager implements PwmService
         localDB.put( LocalDB.DB.PWM_STATS, DB_KEY_INITIAL_DAILY_KEY, initialDailyKey.toString() );
 
         {
-            // setup a timer to roll over at 0 Zula and one to write current stats every 10 seconds
+            // setup a timer to roll over at 0 Zulu and one to write current stats regularly
             executorService = JavaHelper.makeBackgroundExecutor( pwmApplication, this.getClass() );
             pwmApplication.scheduleFixedRateJob( new FlushTask(), executorService, DB_WRITE_FREQUENCY, DB_WRITE_FREQUENCY );
             final TimeDuration delayTillNextZulu = TimeDuration.fromCurrent( JavaHelper.nextZuluZeroTime() );
@@ -367,25 +366,21 @@ public class StatisticsManager implements PwmService
 
     }
 
-    private void resetDailyStats( )
+    public Map<String, String> dailyStatisticsAsLabelValueMap()
     {
-        try
-        {
-            final Map<String, String> emailValues = new LinkedHashMap<>();
-            for ( final Statistic statistic : Statistic.values() )
-            {
-                final String key = statistic.getLabel( PwmConstants.DEFAULT_LOCALE );
-                final String value = statsDaily.getStatistic( statistic );
-                emailValues.put( key, value );
-            }
-
-            AlertHandler.alertDailyStats( pwmApplication, emailValues );
-        }
-        catch ( Exception e )
+        final Map<String, String> emailValues = new LinkedHashMap<>();
+        for ( final Statistic statistic : Statistic.values() )
         {
-            LOGGER.error( "error while generating daily alert statistics: " + e.getMessage() );
+            final String key = statistic.getLabel( PwmConstants.DEFAULT_LOCALE );
+            final String value = statsDaily.getStatistic( statistic );
+            emailValues.put( key, value );
         }
 
+        return Collections.unmodifiableMap( emailValues );
+    }
+
+    private void resetDailyStats( )
+    {
         currentDailyKey = new DailyKey( new Date() );
         statsDaily = new StatisticsBundle();
         LOGGER.debug( () -> "reset daily statistics" );
@@ -452,7 +447,7 @@ public class StatisticsManager implements PwmService
 
         public DailyKey( final String value )
         {
-            final String strippedValue = value.substring( DB_KEY_PREFIX_DAILY.length(), value.length() );
+            final String strippedValue = value.substring( DB_KEY_PREFIX_DAILY.length() );
             final String[] splitValue = strippedValue.split( "_" );
             year = Integer.parseInt( splitValue[ 0 ] );
             day = Integer.parseInt( splitValue[ 1 ] );
@@ -465,7 +460,7 @@ public class StatisticsManager implements PwmService
         @Override
         public String toString( )
         {
-            return DB_KEY_PREFIX_DAILY + String.valueOf( year ) + "_" + String.valueOf( day );
+            return DB_KEY_PREFIX_DAILY + year + "_" + day;
         }
 
         public DailyKey previous( )

+ 11 - 9
server/src/main/java/password/pwm/svc/token/TokenService.java

@@ -634,7 +634,7 @@ public class TokenService implements PwmService
                 && tokenPayload.getUserIdentity() != null
                 && tokenPayload.getData() != null
                 && tokenPayload.getData().containsKey( PwmConstants.TOKEN_KEY_PWD_CHG_DATE )
-                )
+        )
         {
             try
             {
@@ -739,13 +739,15 @@ public class TokenService implements PwmService
             pwmApplication.getIntruderManager().mark( RecordType.TOKEN_DEST, toAddress, null );
 
             final EmailItemBean configuredEmailSetting = tokenSendInfo.getConfiguredEmailSetting();
-            pwmApplication.getEmailQueue().submitEmailImmediate( new EmailItemBean(
-                    toAddress,
-                    configuredEmailSetting.getFrom(),
-                    configuredEmailSetting.getSubject(),
-                    configuredEmailSetting.getBodyPlain().replace( "%TOKEN%", tokenSendInfo.getTokenKey() ),
-                    configuredEmailSetting.getBodyHtml().replace( "%TOKEN%", tokenSendInfo.getTokenKey() )
-            ), tokenSendInfo.getUserInfo(), tokenSendInfo.getMacroMachine() );
+            final EmailItemBean tokenizedEmail = configuredEmailSetting.applyBodyReplacement(
+                    "%TOKEN%",
+                    tokenSendInfo.getTokenKey() );
+
+            pwmApplication.getEmailQueue().submitEmailImmediate(
+                    tokenizedEmail,
+                    tokenSendInfo.getUserInfo(),
+                    tokenSendInfo.getMacroMachine() );
+
             LOGGER.debug( () -> "token email added to send queue for " + toAddress );
             return true;
         }
@@ -771,7 +773,7 @@ public class TokenService implements PwmService
             LOGGER.debug( () -> "token SMS added to send queue for " + smsNumber );
             return true;
         }
-        }
+    }
 
     static TimeDuration maxTokenAge( final Configuration configuration )
     {

+ 41 - 9
server/src/main/java/password/pwm/util/AlertHandler.java → server/src/main/java/password/pwm/util/DailySummaryJob.java

@@ -29,9 +29,9 @@ import password.pwm.PwmConstants;
 import password.pwm.bean.EmailItemBean;
 import password.pwm.config.PwmSetting;
 import password.pwm.error.PwmUnrecoverableException;
-import password.pwm.health.HealthMonitor;
 import password.pwm.health.HealthRecord;
 import password.pwm.i18n.Display;
+import password.pwm.svc.PwmService;
 import password.pwm.svc.report.ReportSummaryData;
 import password.pwm.util.java.JavaHelper;
 import password.pwm.util.java.TimeDuration;
@@ -46,21 +46,53 @@ import java.util.Locale;
 import java.util.Map;
 import java.util.TreeMap;
 
-public abstract class AlertHandler
+public class DailySummaryJob implements Runnable
 {
-    private static final PwmLogger LOGGER = PwmLogger.forClass( AlertHandler.class );
+    private static final PwmLogger LOGGER = PwmLogger.forClass( DailySummaryJob.class );
 
+    private final PwmApplication pwmApplication;
 
-    public static void alertDailyStats(
-            final PwmApplication pwmApplication,
-            final Map<String, String> dailyStatistics
-    ) throws PwmUnrecoverableException
+    public DailySummaryJob( final PwmApplication pwmApplication )
+    {
+        this.pwmApplication = pwmApplication;
+    }
+
+    @Override
+    public void run()
+    {
+        try
+        {
+            alertDailyStats();
+        }
+        catch ( Exception e )
+        {
+            LOGGER.error( "error while generating daily alert statistics: " + e.getMessage() );
+        }
+
+
+    }
+
+    private void alertDailyStats(
+
+    )
+            throws PwmUnrecoverableException
     {
         if ( !checkIfEnabled( pwmApplication, PwmSetting.EVENTS_ALERT_DAILY_SUMMARY ) )
         {
+            LOGGER.debug( () -> "skipping daily summary alert job, setting "
+                    + PwmSetting.EVENTS_ALERT_DAILY_SUMMARY.toMenuLocationDebug( null, PwmConstants.DEFAULT_LOCALE )
+                    + " not configured" );
             return;
         }
 
+        if ( pwmApplication.getStatisticsManager().status() != PwmService.STATUS.OPEN )
+        {
+            LOGGER.debug( () -> "skipping daily summary alert job, statistics service is not open" );
+            return;
+        }
+
+        final Map<String, String> dailyStatistics = pwmApplication.getStatisticsManager().dailyStatisticsAsLabelValueMap();
+
         final Locale locale = PwmConstants.DEFAULT_LOCALE;
 
         for ( final String toAddress : pwmApplication.getConfig().readSettingAsStringArray( PwmSetting.AUDIT_EMAIL_SYSTEM_TO ) )
@@ -108,7 +140,7 @@ public abstract class AlertHandler
 
         {
             // health check data
-            final Collection<HealthRecord> healthRecords = pwmApplication.getHealthMonitor().getHealthRecords( HealthMonitor.CheckTimeliness.Immediate );
+            final Collection<HealthRecord> healthRecords = pwmApplication.getHealthMonitor().getHealthRecords();
             textBody.append( "-- Health Check Results --\n" );
             htmlBody.append( "<h2>Health Check Results</h2>" );
 
@@ -157,7 +189,7 @@ public abstract class AlertHandler
         textBody.append( "\n" );
         htmlBody.append( "<br/>" );
 
-        if ( pwmApplication.getConfig().readSettingAsBoolean( PwmSetting.REPORTING_ENABLE ) )
+        if ( pwmApplication.getConfig().readSettingAsBoolean( PwmSetting.REPORTING_ENABLE_DAILY_JOB ) )
         {
             final List<ReportSummaryData.PresentationRow> summaryData = pwmApplication.getReportService()
                     .getSummaryData().asPresentableCollection( pwmApplication.getConfig(), locale );

+ 74 - 0
server/src/main/java/password/pwm/util/ServletUtility.java

@@ -0,0 +1,74 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2018 The PWM Project
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+ */
+
+package password.pwm.util;
+
+import org.apache.commons.io.IOUtils;
+import password.pwm.PwmConstants;
+import password.pwm.error.ErrorInformation;
+import password.pwm.error.PwmError;
+import password.pwm.error.PwmUnrecoverableException;
+
+import javax.servlet.http.HttpServletRequest;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.io.StringWriter;
+
+public final class ServletUtility
+{
+    private ServletUtility()
+    {
+    }
+
+    public static String readRequestBodyAsString( final HttpServletRequest req, final int maxChars )
+            throws IOException, PwmUnrecoverableException
+    {
+        final StringWriter stringWriter = new StringWriter();
+        final Reader readerStream = new InputStreamReader(
+                req.getInputStream(),
+                PwmConstants.DEFAULT_CHARSET
+        );
+
+        try
+        {
+            IOUtils.copy( readerStream, stringWriter );
+        }
+        catch ( Exception e )
+        {
+            final String errorMsg = "error reading request body stream: " + e.getMessage();
+            throw new PwmUnrecoverableException( new ErrorInformation( PwmError.ERROR_INTERNAL, errorMsg ) );
+        }
+        finally
+        {
+            IOUtils.closeQuietly( readerStream );
+        }
+
+        final String stringValue = stringWriter.toString();
+        if ( stringValue.length() > maxChars )
+        {
+            final String msg = "input request body is to big, size=" + stringValue.length() + ", max=" + maxChars;
+            throw new PwmUnrecoverableException( new ErrorInformation( PwmError.ERROR_INTERNAL, msg ) );
+        }
+        return stringValue;
+    }
+}

+ 31 - 28
server/src/main/java/password/pwm/util/java/TimeDuration.java

@@ -41,6 +41,7 @@ import java.util.ArrayList;
 import java.util.List;
 import java.util.Locale;
 import java.util.concurrent.TimeUnit;
+import java.util.function.BooleanSupplier;
 
 /**
  * <p>An immutable class representing a time period.  The internal value of the time period is
@@ -329,8 +330,8 @@ public class TimeDuration implements Comparable, Serializable
             segments.add( fractionalTimeDetail.days
                     + " "
                     + ( fractionalTimeDetail.days == 1
-                            ? LocaleHelper.getLocalizedMessage( locale, Display.Display_Day, null )
-                            : LocaleHelper.getLocalizedMessage( locale, Display.Display_Days, null ) )
+                    ? LocaleHelper.getLocalizedMessage( locale, Display.Display_Day, null )
+                    : LocaleHelper.getLocalizedMessage( locale, Display.Display_Days, null ) )
             );
         }
 
@@ -340,8 +341,8 @@ public class TimeDuration implements Comparable, Serializable
             segments.add( fractionalTimeDetail.hours
                     + " "
                     + ( fractionalTimeDetail.hours == 1
-                            ? LocaleHelper.getLocalizedMessage( locale, Display.Display_Hour, null )
-                            : LocaleHelper.getLocalizedMessage( locale, Display.Display_Hours, null ) )
+                    ? LocaleHelper.getLocalizedMessage( locale, Display.Display_Hour, null )
+                    : LocaleHelper.getLocalizedMessage( locale, Display.Display_Hours, null ) )
             );
         }
 
@@ -351,8 +352,8 @@ public class TimeDuration implements Comparable, Serializable
             segments.add( fractionalTimeDetail.minutes
                     + " "
                     + ( fractionalTimeDetail.minutes == 1
-                            ? LocaleHelper.getLocalizedMessage( locale, Display.Display_Minute, null )
-                            : LocaleHelper.getLocalizedMessage( locale, Display.Display_Minutes, null ) )
+                    ? LocaleHelper.getLocalizedMessage( locale, Display.Display_Minute, null )
+                    : LocaleHelper.getLocalizedMessage( locale, Display.Display_Minutes, null ) )
             );
         }
 
@@ -430,48 +431,50 @@ public class TimeDuration implements Comparable, Serializable
         return "TimeDuration[" + this.asCompactString() + "]";
     }
 
+
     /**
      * Pause the calling thread the specified amount of time.
      *
-     * @param sleepTimeMS - a time duration in milliseconds
      * @return time actually spent sleeping
      */
-    public static TimeDuration pause( final long sleepTimeMS )
+    @CheckReturnValue( when = When.NEVER )
+    public TimeDuration pause( )
     {
-        if ( sleepTimeMS < 1 )
-        {
-            return TimeDuration.ZERO;
-        }
+        return pause( this, () -> false );
+    }
 
+    @CheckReturnValue( when = When.NEVER )
+    public TimeDuration pause(
+            final BooleanSupplier interruptBoolean
+    )
+    {
+        return pause( this, interruptBoolean );
+    }
+
+    @CheckReturnValue( when = When.NEVER )
+    public TimeDuration pause(
+            final TimeDuration predicateCheckInterval,
+            final BooleanSupplier interruptBoolean
+    )
+    {
         final long startTime = System.currentTimeMillis();
-        do
+        final long pauseTime = JavaHelper.rangeCheck( this.asMillis(), this.asMillis(), predicateCheckInterval.asMillis()  );
+
+        while ( ( System.currentTimeMillis() - startTime ) < this.asMillis() && !interruptBoolean.getAsBoolean() )
         {
             try
             {
-                final long sleepTime = sleepTimeMS - ( System.currentTimeMillis() - startTime );
-                Thread.sleep( sleepTime > 0 ? sleepTime : 5 );
+                Thread.sleep( pauseTime );
             }
             catch ( InterruptedException e )
             {
-                //who cares
+                // ignore
             }
         }
-        while ( ( System.currentTimeMillis() - startTime ) < sleepTimeMS );
 
         return TimeDuration.fromCurrent( startTime );
     }
 
-    /**
-     * Pause the calling thread the specified amount of time.
-     *
-     * @return time actually spent sleeping
-     */
-    @CheckReturnValue( when = When.NEVER )
-    public TimeDuration pause( )
-    {
-        return pause( this.as( Unit.MILLISECONDS ) );
-    }
-
     public Duration asDuration()
     {
         return Duration.of( this.ms, ChronoUnit.MILLIS );

+ 6 - 10
server/src/main/java/password/pwm/util/operations/PasswordUtility.java

@@ -184,7 +184,7 @@ public class PasswordUtility
             final String toAddress,
             final Locale userLocale
     )
-            throws PwmOperationalException, PwmUnrecoverableException
+            throws PwmUnrecoverableException
     {
         final Configuration config = pwmApplication.getConfig();
         final EmailItemBean configuredEmailSetting = config.readSettingAsEmail( PwmSetting.EMAIL_SENDPASSWORD, userLocale );
@@ -195,18 +195,14 @@ public class PasswordUtility
             return new ErrorInformation( PwmError.ERROR_INTERNAL, errorMsg );
         }
 
-        final EmailItemBean emailItemBean = new EmailItemBean(
-                configuredEmailSetting.getTo(),
-                configuredEmailSetting.getFrom(),
-                configuredEmailSetting.getSubject(),
-                configuredEmailSetting.getBodyPlain().replace( "%TOKEN%", newPassword.getStringValue() ),
-                configuredEmailSetting.getBodyHtml().replace( "%TOKEN%", newPassword.getStringValue() )
-        );
+        final EmailItemBean emailItemBean = configuredEmailSetting.applyBodyReplacement(
+                "%TOKEN%",
+                newPassword.getStringValue() );
+
         pwmApplication.getEmailQueue().submitEmail(
                 emailItemBean,
                 userInfo,
-                macroMachine
-        );
+                macroMachine );
 
 
         LOGGER.debug( () -> "new password email to " + userInfo.getUserIdentity() + " added to send queue for " + toAddress );

+ 3 - 4
server/src/main/java/password/pwm/ws/client/rest/form/FormDataRequestBean.java

@@ -23,19 +23,18 @@
 package password.pwm.ws.client.rest.form;
 
 import lombok.Builder;
-import lombok.Getter;
+import lombok.Value;
 import password.pwm.config.value.data.FormConfiguration;
 
 import java.io.Serializable;
 import java.util.List;
 import java.util.Map;
 
-@Getter
+@Value
 @Builder
 public class FormDataRequestBean implements Serializable
 {
-
-    @Getter
+    @Value
     @Builder
     public static class FormInfo implements Serializable
     {

+ 2 - 3
server/src/main/java/password/pwm/ws/client/rest/form/FormDataResponseBean.java

@@ -23,12 +23,12 @@
 package password.pwm.ws.client.rest.form;
 
 import lombok.Builder;
-import lombok.Getter;
+import lombok.Value;
 
 import java.io.Serializable;
 import java.util.Map;
 
-@Getter
+@Value
 @Builder
 public class FormDataResponseBean implements Serializable
 {
@@ -36,5 +36,4 @@ public class FormDataResponseBean implements Serializable
     private String errorMessage;
     private String errorDetail;
     private Map<String, String> formValues;
-
 }

+ 5 - 22
server/src/main/java/password/pwm/ws/server/rest/RestHealthServer.java

@@ -70,12 +70,9 @@ public class RestHealthServer extends RestServlet
     private RestResultBean doPwmHealthPlainGet( final RestRequest restRequest )
             throws PwmUnrecoverableException
     {
-        final boolean requestImmediateParam = restRequest.readParameterAsBoolean( PARAM_IMMEDIATE_REFRESH );
-
         try
         {
-            final HealthMonitor.CheckTimeliness timeliness = determineDataTimeliness( requestImmediateParam );
-            final String resultString = restRequest.getPwmApplication().getHealthMonitor().getMostSevereHealthStatus( timeliness ).toString() + "\n";
+            final String resultString = restRequest.getPwmApplication().getHealthMonitor().getMostSevereHealthStatus().toString() + "\n";
             StatisticsManager.incrementStat( restRequest.getPwmApplication(), Statistic.REST_HEALTH );
             return RestResultBean.withData( resultString );
         }
@@ -91,38 +88,24 @@ public class RestHealthServer extends RestServlet
     private RestResultBean doPwmHealthJsonGet( final RestRequest restRequest )
             throws PwmUnrecoverableException, IOException
     {
-        final boolean requestImmediateParam = restRequest.readParameterAsBoolean( PARAM_IMMEDIATE_REFRESH );
-
-        final HealthData jsonOutput = processGetHealthCheckData( restRequest.getPwmApplication(), restRequest.getLocale(), requestImmediateParam );
+        final HealthData jsonOutput = processGetHealthCheckData( restRequest.getPwmApplication(), restRequest.getLocale() );
         StatisticsManager.incrementStat( restRequest.getPwmApplication(), Statistic.REST_HEALTH );
         return RestResultBean.withData( jsonOutput );
     }
 
-    private static HealthMonitor.CheckTimeliness determineDataTimeliness(
-            final boolean refreshImmediate
-    )
-            throws PwmUnrecoverableException
-    {
-        return refreshImmediate
-                ? HealthMonitor.CheckTimeliness.Immediate
-                : HealthMonitor.CheckTimeliness.CurrentButNotAncient;
-    }
-
     public static HealthData processGetHealthCheckData(
             final PwmApplication pwmApplication,
-            final Locale locale,
-            final boolean refreshImmediate
+            final Locale locale
     )
             throws IOException, PwmUnrecoverableException
     {
         final HealthMonitor healthMonitor = pwmApplication.getHealthMonitor();
-        final HealthMonitor.CheckTimeliness timeliness = determineDataTimeliness( refreshImmediate );
-        final List<password.pwm.health.HealthRecord> healthRecords = new ArrayList<>( healthMonitor.getHealthRecords( timeliness ) );
+        final List<password.pwm.health.HealthRecord> healthRecords = new ArrayList<>( healthMonitor.getHealthRecords() );
         final List<HealthRecord> healthRecordBeans = HealthRecord.fromHealthRecords( healthRecords, locale,
                 pwmApplication.getConfig() );
         final HealthData healthData = new HealthData();
         healthData.timestamp = healthMonitor.getLastHealthCheckTime();
-        healthData.overall = healthMonitor.getMostSevereHealthStatus( timeliness ).toString();
+        healthData.overall = healthMonitor.getMostSevereHealthStatus().toString();
         healthData.records = healthRecordBeans;
         return healthData;
     }

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

@@ -276,6 +276,7 @@ queue.syslog.maxAgeMs=2592000000
 queue.syslog.maxCount=100000
 reporting.ldap.searchTimeoutMs=1800000
 reporting.ldap.searchThreads=8
+reporting.maxReportAgeSeconds=864000
 recaptcha.clientJsUrl=//www.google.com/recaptcha/api.js
 recaptcha.clientIframeUrl=//www.google.com/recaptcha/api/noscript
 recaptcha.validateUrl=https://www.google.com/recaptcha/api/siteverify

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

@@ -711,12 +711,6 @@
             <property key="Maximum">65535</property>
         </properties>
     </setting>
-    <setting hidden="false" key="email.default.fromAddresses" level="1">
-        <flag>emailSyntax</flag>
-        <default>
-            <value>noreply@example.org</value>
-        </default>
-    </setting>
     <setting hidden="false" key="email.smtp.username" level="1">
         <default>
             <value />
@@ -2583,11 +2577,6 @@
         <ldapPermission actor="proxy" access="read"/>
         <default />
     </setting>
-    <setting hidden="false" key="challenge.token.enable" level="1" required="true">
-        <default>
-            <value>false</value>
-        </default>
-    </setting>
     <setting hidden="false" key="challenge.token.sendMethod" level="1" required="true">
         <default>
             <value><![CDATA[EMAILONLY]]></value>
@@ -2893,13 +2882,6 @@
             <option value="checkbox">checkbox</option>
         </options>
     </setting>
-    <setting hidden="false" key="guest.creationUniqueAttributes" level="1">
-        <regex>^[a-zA-Z][a-zA-Z0-9-]*$</regex>
-        <default>
-            <value><![CDATA[cn]]></value>
-            <value><![CDATA[mail]]></value>
-        </default>
-    </setting>
     <setting hidden="false" key="guest.writeAttributes" level="1">
         <ldapPermission actor="guestManager" access="write"/>
         <default />
@@ -2938,11 +2920,6 @@
             <value>true</value>
         </default>
     </setting>
-    <setting hidden="false" key="activateUser.token.verification" level="1" required="true">
-        <default>
-            <value>false</value>
-        </default>
-    </setting>
     <setting hidden="false" key="display.activateUser.agreement" level="1">
         <flag>MacroSupport</flag>
         <default/>
@@ -3880,11 +3857,6 @@
             <value><![CDATA[{"type":"ldapQuery","ldapProfileID":"all","ldapQuery":"(objectClass=*)"}]]></value>
         </default>
     </setting>
-    <setting hidden="false" key="reporting.maxCacheAge" level="1">
-        <default>
-            <value>2592000</value>
-        </default>
-    </setting>
     <setting hidden="false" key="reporting.ldap.maxQuerySize" level="2">
         <default>
             <value>100000</value>
@@ -4173,8 +4145,6 @@
     <category hidden="false" key="EMAIL_SERVERS">
         <profile setting="email.profile.list"/>
     </category>
-    <category hidden="false" key="EMAIL_PROFILE_SETTING">
-    </category>
     <category hidden="false" key="EMAIL">
     </category>
     <category hidden="false" key="EMAIL_SETTINGS">

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

@@ -659,7 +659,6 @@ Setting_Description_reporting.job.timeOffset=Specify the number of seconds past
 Setting_Description_reporting.ldap.maxQuerySize=Specify the maximum number of records read during a reporting query search.  Setting this value to a larger sizes requires more Java heap memory.
 Setting_Description_reporting.ldap.searchFilter=Add an LDAP search filter @PwmAppName@ uses when running a reporting job.  If blank @PwmAppName@ auto-generates a filter based on the login query setting.
 Setting_Description_reporting.ldap.userMatch=Select users to include in the reporting job.
-Setting_Description_reporting.maxCacheAge=Specify the maximum age of a cached report record before @PwmAppName@ discards it.  @PwmAppName@ periodically purges records older than this age from the local report data cache.  @PwmAppName@ does this so that the deleted records that it deletes from the LDAP directory are eventually removed from the report cache.  The default value of 2592000 seconds is equal to 30 days.<br/><br/>Specify the value in seconds.
 Setting_Description_reporting.summary.dayValues=Specify day intervals to include in report summary data.
 Setting_Description_response.hashMethod=<p>Select the method of hashing @PwmAppName@ uses to store responses.  Storing the responses as plaintext might facilitate synchronization or migration to other systems but is not secure.</p><p>This setting only controls how @PwmAppName@ writes the responses. @PwmAppName@ can always read stored responses in other formats. @PwmAppName@ cannot convert existing responses until a user re-saves their responses. You can use the reporting engine to identify and count the hash types in use.</p>
 Setting_Description_security.cspHeader=Set the HTTP Content-Security-Policy header.  This header instructs the browser to limit the locations from which it loads fonts, scripts, and CSS files.
@@ -1180,7 +1179,6 @@ Setting_Label_reporting.job.timeOffset=Reporting Job Time Offset
 Setting_Label_reporting.ldap.maxQuerySize=Maximum LDAP Query Size
 Setting_Label_reporting.ldap.searchFilter=Reporting Search Filter
 Setting_Label_reporting.ldap.userMatch=Reporting User Match
-Setting_Label_reporting.maxCacheAge=Maximum Cache Age
 Setting_Label_reporting.summary.dayValues=Reporting Summary Day Intervals
 Setting_Label_response.hashMethod=Responses Storage Hashing Method
 Setting_Label_security.cspHeader=HTTP Content Security Policy Header

+ 124 - 0
server/src/test/java/password/pwm/config/PwmSettingXmlTest.java

@@ -0,0 +1,124 @@
+/*
+ * Password Management Servlets (PWM)
+ * http://www.pwm-project.org
+ *
+ * Copyright (c) 2006-2009 Novell, Inc.
+ * Copyright (c) 2009-2018 The PWM Project
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
+ */
+
+package password.pwm.config;
+
+import org.junit.AfterClass;
+import org.junit.Assert;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import password.pwm.util.java.JavaHelper;
+import password.pwm.util.java.XmlDocument;
+import password.pwm.util.java.XmlElement;
+import password.pwm.util.java.XmlFactory;
+
+import java.io.InputStream;
+import java.util.List;
+
+public class PwmSettingXmlTest
+{
+    private static XmlDocument xmlDocument;
+
+    @BeforeClass
+    public static void setUp() throws Exception
+    {
+        try ( InputStream inputStream = PwmSetting.class.getClassLoader().getResourceAsStream( PwmSettingXml.SETTING_XML_FILENAME ) )
+        {
+            xmlDocument = XmlFactory.getFactory().parseXml( inputStream );
+        }
+    }
+
+    @AfterClass
+    public static void tearDown()
+    {
+        xmlDocument = null;
+    }
+
+    @Test
+    public void testSettingElementIsInXml()
+    {
+        for ( final PwmSetting pwmSetting : PwmSetting.values() )
+        {
+            final XmlElement element = PwmSettingXml.readSettingXml( pwmSetting );
+            Assert.assertNotNull( "no XML settings node in PwmSetting.xml for setting " + pwmSetting.getKey(), element );
+        }
+    }
+
+    @Test
+    public void testXmlElementIsInSettings()
+    {
+        final List<XmlElement> settingElements = xmlDocument.evaluateXpathToElements( "/settings/setting" );
+        Assert.assertFalse( settingElements.isEmpty() );
+        for ( final XmlElement element : settingElements )
+        {
+            final String key = element.getAttributeValue( "key" );
+
+            final String errorMsg = "PwmSetting.xml contains setting key of '"
+                    + key + "' which does not exist in PwmSetting.java";
+            Assert.assertNotNull( errorMsg, PwmSetting.forKey( key ) );
+        }
+    }
+
+    @Test
+    public void testCategoryElementIsInXml()
+    {
+        for ( final PwmSettingCategory pwmSettingCategory : PwmSettingCategory.values() )
+        {
+            final XmlElement element = PwmSettingXml.readCategoryXml( pwmSettingCategory );
+            Assert.assertNotNull( "no XML category node in PwmSetting.xml for setting " + pwmSettingCategory.getKey(), element );
+        }
+    }
+
+    @Test
+    public void testXmlElementIsInCategory()
+    {
+        final List<XmlElement> categoryElements = xmlDocument.evaluateXpathToElements( "/settings/category" );
+        Assert.assertFalse( categoryElements.isEmpty() );
+        for ( final XmlElement element : categoryElements )
+        {
+            final String key = element.getAttributeValue( "key" );
+            final PwmSettingCategory category = JavaHelper.readEnumFromString( PwmSettingCategory.class, null, key );
+
+            final String errorMsg = "PwmSetting.xml contains category key of '"
+                    + key + "' which does not exist in PwmSettingCategory.java";
+            Assert.assertNotNull( errorMsg, category );
+        }
+    }
+
+    @Test
+    public void testXmlCategoryProfileElementIsValidSetting()
+    {
+        final List<XmlElement> profileElements = xmlDocument.evaluateXpathToElements( "/settings/category/profile" );
+        Assert.assertFalse( profileElements.isEmpty() );
+        for ( final XmlElement element : profileElements )
+        {
+            final String settingKey = element.getAttributeValue( "setting" );
+            final PwmSetting setting = PwmSetting.forKey( settingKey );
+
+            final String errorMsg = "PwmSetting.xml contains category/profile@setting key of '"
+                    + settingKey + "' which does not exist in PwmSetting.java";
+            Assert.assertNotNull( errorMsg, setting );
+        }
+    }
+}
+
+

+ 0 - 2
webapp/src/main/webapp/WEB-INF/jsp/configguide-cr_policy.jsp

@@ -66,8 +66,6 @@
     </div>
     <div class="push"></div>
 </div>
-
-configeditor-settings-challenges.js
 <pwm:script>
     <script type="text/javascript">
         PWM_GLOBAL['startupFunctions'].push(function(){

+ 3 - 2
webapp/src/main/webapp/public/resources/js/main.js

@@ -216,8 +216,8 @@ PWM_MAIN.initPage = function() {
     PWM_MAIN.TimestampHandler.initAllElements();
 
     ShowHidePasswordHandler.initAllForms();
-
-    PWM_MAIN.log('initPage completed');
+    var loadTime = window.performance.timing.domContentLoadedEventEnd-window.performance.timing.navigationStart;
+    PWM_MAIN.log('initPage completed [load time=' + loadTime + ']');
 };
 
 PWM_MAIN.initDisplayTabPreferences = function() {
@@ -271,6 +271,7 @@ PWM_MAIN.applyFormAttributes = function() {
         var hrefValue = linkElement.getAttribute('href');
         if (hrefValue && hrefValue.charAt(0) !== '#') {
             PWM_MAIN.addEventHandler(linkElement, "click", function (event) {
+                console.log('intercepted anchor click event');
                 event.preventDefault();
                 PWM_MAIN.showWaitDialog({loadFunction: function () {
                         PWM_MAIN.gotoUrl(hrefValue);