This is an automated email from the ASF dual-hosted git repository.
lprimak pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/shiro.git
The following commit(s) were added to refs/heads/main by this push:
new 12d194b66 [#2931] - Rebuild expired Faces views during form replay
without a preliminary GET (#2930)
12d194b66 is described below
commit 12d194b66eb692c368f69519f5bcc06893540136
Author: Lenny Primak <[email protected]>
AuthorDate: Tue Oct 27 16:27:11 2026 -0400
[#2931] - Rebuild expired Faces views during form replay without a
preliminary GET (#2930)
* enh: simplify code and process by implementing FormResubmitViewHandler
instead of scraping the view state via extra GET request during rebuilding of
expired views during form data saving replay
* code review
* refactor: form replay handling by removing unused AjaxReplay logic and
improving view rebuilding during expired session handling
addresses all valid review comments
---
support/jakarta-ee/README.adoc | 71 +++++----
support/jakarta-ee/pom.xml | 5 -
.../shiro/ee/filters/FormResubmitRequest.java | 15 +-
.../shiro/ee/filters/FormResubmitResponse.java | 2 +-
.../shiro/ee/filters/FormResubmitSupport.java | 177 ++++-----------------
.../shiro/ee/filters/FormResubmitViewHandler.java | 57 +++++++
.../org/apache/shiro/ee/filters/ShiroFilter.java | 46 +-----
.../src/main/resources/META-INF/faces-config.xml | 3 +
.../shiro/ee/filters/FormResubmitDispatchTest.java | 100 ++++++++++++
.../shiro/ee/filters/FormResubmitRequestTest.java | 14 +-
.../ee/filters/FormResubmitViewHandlerTest.java | 177 +++++++++++++++++++++
.../apache/shiro/ee/filters/FormSupportTest.java | 80 ++--------
...dateTest.java => ViewRebuildCandidateTest.java} | 50 +++---
13 files changed, 460 insertions(+), 337 deletions(-)
diff --git a/support/jakarta-ee/README.adoc b/support/jakarta-ee/README.adoc
index 936fbbee5..056b1ded6 100644
--- a/support/jakarta-ee/README.adoc
+++ b/support/jakarta-ee/README.adoc
@@ -33,50 +33,56 @@ opposed to changing the way Shiro works by integrating CDI
== Form resubmission
-Saved forms are replayed within the current web application using
-`RequestDispatcher.forward`, without an outbound HTTP connection. The replay
-uses the current Shiro subject, session, and browser response. Saved form
+After a login flow, saved forms are replayed within the current web application
+using `RequestDispatcher.forward`, without an outbound HTTP connection. The
+replay uses the current Shiro subject, session, and browser response. Saved
form
parameters replace those of the login request. Replay targets must read form
data through the parameter accessors; the raw request body is not
reconstructed.
-For server-side Faces state saving, a buffered GET obtains a new view state
-before the POST. A calling Faces context is restored after each dispatch.
+For server-side Faces state saving, Shiro's `FormResubmitViewHandler` rebuilds
+an expired view during a marked POST using OmniFaces' view builder, like
+`o:enableRestorableView`, without requiring the tag on each page. Expired
+same-origin POSTs that do not require a login continue through the normal
filter
+chain; Shiro does not consume their body or dispatch them a second time. After
+login, the same view handler rebuilds the view during the forwarded replay.
+Existing views are restored normally, and unmarked requests and client-side
+state saving are unaffected.
Each replay's status, headers, cookies and body are captured rather than
written
to the browser response, which is also shielded from `reset()`, `resetBuffer()`
and `flushBuffer()`. Only a successful replay is applied, with its headers and
cookies. Response metadata setters (such as content type and encoding) and
generic header read-back retain the wrapped response's behavior.
-Captured state from view-state GETs and failed attempts is discarded instead
+Captured state from failed attempts is discarded instead
of applied to the login response, preserving its session cookies.
Saved form data is decoded with the request's character encoding, falling back
to the servlet context's and then UTF-8.
-A Faces Ajax submission replayed in place (see below) stays an Ajax request:
-the saved partial request is replayed with the refreshed view state, and its
-partial response is passed through to the waiting Ajax client, without a full
-page refresh. Since the server-side view is rebuilt from scratch, the replay
-renders the whole view (`@all`) by default, which resynchronizes the page and
-the view state of all its forms with the server. Set the
+A Faces Ajax request with an expired server-side view stays an Ajax request and
+continues through the normal lifecycle without a full page refresh. The rebuilt
+view renders the whole view (`@all`) by default, which resynchronizes the page
+and the view state of all its forms with the server. Set the
`org.apache.shiro.form-resubmit.ajax-render-all.disabled` context parameter to
`true` to keep the form's own render targets instead; components outside them
-then keep their pre-expiry client state, and with Mojarra, other forms on the
-page keep their expired view state. A partial response that reports an
-unhandled Faces error is treated as a failed replay and falls back to a
redirect
-to the saved request, as a failed full-page replay does. After a login flow,
-the browser is on the login page instead, so the submission is replayed as a
-full-page action and the browser is redirected to the saved request.
+then keep their pre-expiry client state. After a login flow, the browser is on
+the login page instead, so the submission is replayed as a full-page action and
+the browser is redirected to the saved request. Stateful saved forms lose their
+Ajax fields so the complete form is processed; stateless views and client-side
+state saving retain the fields to preserve partial execution. The forwarded
+request always hides the `Faces-Request` header.
=== Replay without a login flow
When a POST arrives with a session id that no longer resolves to a session,
-and the subject is either remembered or anonymous, the form is replayed in
-place as soon as Shiro's security chain permits the request, with no
-"session expired" login page in between. For a page that requires login,
-the authentication filter still runs first and saves the form for replay
-after login instead. Only same-origin, `application/x-www-form-urlencoded`
-submissions are replayed, and never with client-side Faces state saving,
-where no view state is lost with the session.
+and the subject is either remembered or anonymous, the request continues in
+place as soon as Shiro's security chain permits it, with no "session expired"
+login page in between. Shiro marks the request so an expired server-side Faces
+view can be rebuilt, without consuming the request body or dispatching it
+again. For a page that requires login, the authentication filter still runs
+first and saves the form for replay after login instead. Only same-origin,
+`application/x-www-form-urlencoded` submissions are eligible, and client-side
+Faces state saving is unaffected because its view state is not lost with the
+session.
Set the `org.apache.shiro.form-resubmit.anonymous.disabled` context parameter
to `true` to limit this to remembered subjects, or
@@ -114,15 +120,16 @@ to the saved page.
=== Application filter configuration
-Shiro's Jakarta EE filter is mapped to `DispatcherType.FORWARD`, so the
forwarded
-target's security chain runs again. Application filters needed during replay
-must also be mapped to `FORWARD`, not only `REQUEST`. Leave Shiro's
+Shiro's Jakarta EE filter is mapped to `DispatcherType.FORWARD`, so an
+after-login forwarded target's security chain runs again. Application filters
+needed during this replay must also be mapped to `FORWARD`, not only `REQUEST`.
+Leave Shiro's
`filterOncePerRequest` disabled when using form resubmission.
-Replay remains in the same servlet request lifecycle. Application filters and
-request-scoped components should not assume that a replay starts a new external
-request. Saved targets must be within the current context; servlet-private
-`WEB-INF` and `META-INF` resources cannot be replay targets.
+After-login replay remains in the same servlet request lifecycle. Application
+filters and request-scoped components should not assume that a replay starts a
+new external request. Saved targets must be within the current context;
+servlet-private `WEB-INF` and `META-INF` resources cannot be replay targets.
Response `reset()` does not support switching between a writer and an output
stream.
Resubmission is best-effort. Replays are buffered, so if the forward fails or
diff --git a/support/jakarta-ee/pom.xml b/support/jakarta-ee/pom.xml
index 51530ef0d..8eca548e3 100644
--- a/support/jakarta-ee/pom.xml
+++ b/support/jakarta-ee/pom.xml
@@ -75,11 +75,6 @@
<artifactId>omnifaces</artifactId>
<version>5.5.6</version>
</dependency>
- <dependency>
- <groupId>org.jsoup</groupId>
- <artifactId>jsoup</artifactId>
- <version>1.23.2</version>
- </dependency>
<!-- Test Dependencies -->
<dependency>
diff --git
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitRequest.java
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitRequest.java
index 16fca2903..5120ff78a 100644
---
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitRequest.java
+++
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitRequest.java
@@ -24,7 +24,6 @@ import java.util.List;
import java.util.Map;
import lombok.Getter;
import lombok.experimental.Delegate;
-import org.apache.shiro.ee.filters.FormResubmitSupport.AjaxReplay;
import org.apache.shiro.web.util.WebUtils;
import org.omnifaces.filter.MutableRequestFilter.MutableRequest;
@@ -37,10 +36,9 @@ import
org.omnifaces.filter.MutableRequestFilter.MutableRequest;
final class FormResubmitRequest extends HttpServletRequestWrapper {
private static final List<String> DISPATCH_SCOPED_PREFIXES =
List.of("jakarta.faces.", "com.sun.faces.",
"org.apache.myfaces.", "org.omnifaces.",
"jakarta.servlet.forward.", "jakarta.servlet.include.",
- FormResubmitSupport.FORM_IS_RESUBMITTED);
+ FormResubmitSupport.REBUILD_EXPIRED_VIEW);
private static final String FACES_REQUEST_HEADER = "Faces-Request";
- private final @Getter(onMethod_ = @Override) String method;
- private final AjaxReplay ajaxReplay;
+ private final @Getter(onMethod_ = @Override) String method =
FormResubmitSupport.HttpMethod.POST;
private final @Delegate(types = Parameters.class) MutableRequest
parameters;
private final Map<String, Object> attributes = new HashMap<>();
@@ -52,11 +50,8 @@ final class FormResubmitRequest extends
HttpServletRequestWrapper {
Map<String, String[]> getParameterMap();
}
- FormResubmitRequest(HttpServletRequest request, String method, Map<String,
List<String>> formFields,
- AjaxReplay ajaxReplay) {
+ FormResubmitRequest(HttpServletRequest request, Map<String, List<String>>
formFields) {
super(unwrap(request));
- this.method = method;
- this.ajaxReplay = ajaxReplay;
parameters = new MutableRequest(request) {
@Override
public Map<String, List<String>> getMutableParameterMap() {
@@ -82,8 +77,8 @@ final class FormResubmitRequest extends
HttpServletRequestWrapper {
* A full-page replay's response is translated for the original Ajax
client by the caller,
* so the target must not see the Ajax header through any accessor
*/
- private boolean isHidden(String header) {
- return !ajaxReplay.isPassThrough() &&
FACES_REQUEST_HEADER.equalsIgnoreCase(header);
+ private static boolean isHidden(String header) {
+ return FACES_REQUEST_HEADER.equalsIgnoreCase(header);
}
@Override
diff --git
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitResponse.java
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitResponse.java
index c73ad3615..37d0edc7d 100644
---
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitResponse.java
+++
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitResponse.java
@@ -36,7 +36,7 @@ import org.omnifaces.servlet.BufferedHttpServletResponse;
/**
* Captures a replay's status, headers, cookies and body instead of committing
them to the browser.
* Nothing reaches the browser response until {@link #applyTo} is called for a
successful replay,
- * so view-state GETs, failed attempts and Faces error handling (which resets
the response)
+ * so failed attempts and Faces error handling (which resets the response)
* cannot disturb what the login request has already written, such as session
cookies.
*/
final class FormResubmitResponse extends BufferedHttpServletResponse {
diff --git
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitSupport.java
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitSupport.java
index 7e343f4c3..78d23725c 100644
---
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitSupport.java
+++
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitSupport.java
@@ -20,8 +20,6 @@ import static
org.apache.shiro.SecurityUtils.isSecurityManagerTypeOf;
import static org.apache.shiro.SecurityUtils.unwrapSecurityManager;
import static
org.apache.shiro.ee.filters.FormAuthenticationFilter.LOGIN_URL_ATTR_NAME;
import static
jakarta.faces.component.behavior.ClientBehaviorContext.BEHAVIOR_SOURCE_PARAM_NAME;
-import static
jakarta.faces.context.PartialViewContext.ALL_PARTIAL_PHASE_CLIENT_IDS;
-import static
jakarta.faces.context.PartialViewContext.PARTIAL_RENDER_PARAM_NAME;
import static jakarta.servlet.http.HttpServletResponse.SC_FOUND;
import static jakarta.servlet.http.HttpServletResponse.SC_OK;
import static org.apache.shiro.ee.filters.FormResubmitSupportCookies.addCookie;
@@ -31,7 +29,6 @@ import static
org.apache.shiro.ee.filters.FormResubmitSupportCookies.getCookieAg
import org.apache.shiro.crypto.CryptoException;
import org.apache.shiro.ee.filters.Forms.FallbackPredicate;
import static
org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isAnonymousFormResubmitDisabled;
-import static
org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isFormResubmitAjaxRenderAllDisabled;
import static
org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isFormResubmitDisabled;
import java.io.IOException;
import java.net.URI;
@@ -53,7 +50,6 @@ import static
org.apache.shiro.web.filter.authz.SslFilter.DEFAULT_HTTPS_PORT;
import static org.apache.shiro.web.filter.authz.SslFilter.HTTPS_SCHEME;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Consumer;
-import java.util.regex.Pattern;
import java.util.stream.Collectors;
import jakarta.faces.context.FacesContext;
import jakarta.servlet.ServletContext;
@@ -75,9 +71,6 @@ import org.apache.shiro.mgt.SessionsSecurityManager;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.web.session.mgt.DefaultWebSessionManager;
import org.apache.shiro.web.util.WebUtils;
-import org.jsoup.Jsoup;
-import org.jsoup.parser.Parser;
-import org.jsoup.select.Elements;
import org.omnifaces.util.Faces;
import org.omnifaces.util.ResourcePaths;
import org.omnifaces.util.Servlets;
@@ -92,11 +85,10 @@ import org.omnifaces.util.Utils;
public class FormResubmitSupport {
static final String SHIRO_FORM_DATA_KEY = "org.apache.shiro.form-data-key";
static final String SESSION_EXPIRED_PARAMETER =
"org.apache.shiro.sessionExpired";
- static final String FORM_IS_RESUBMITTED =
"org.apache.shiro.form-is-resubmitted";
+ static final String REBUILD_EXPIRED_VIEW =
"org.apache.shiro.rebuild-expired-view";
static final String FORM_DATA_CACHE = "org.apache.shiro.form-data-cache";
// encoded view state
private static final String FACES_VIEW_STATE = "jakarta.faces.ViewState";
- private static final Pattern STATEFUL_VIEW_STATE_PATTERN =
Pattern.compile("-?\\d+:-?\\d+");
private static final String FACES_PARTIAL_PREFIX =
"jakarta.faces.partial.";
private static final String FACES_BEHAVIOR_PREFIX =
"jakarta.faces.behavior.";
private static final String SEC_FETCH_SITE = "Sec-Fetch-Site";
@@ -112,62 +104,13 @@ public class FormResubmitSupport {
static final String LOCATION = "Location";
}
- /**
- * Where a saved form's replay is triggered from
- */
- enum ReplayFlow {
- /** the browser is still on the saved form's page, whose submission
found the session expired */
- IN_PLACE,
- /** the browser is on the login page, having just authenticated */
- AFTER_LOGIN
- }
-
- /**
- * How a saved Faces Ajax submission is replayed
- */
- enum AjaxReplay {
- /** as a full-page submission of the same command, for a browser that
isn't awaiting the partial response */
- FULL_PAGE,
- /** as-is, with its partial response passed through to the waiting
Ajax client */
- PASS_THROUGH,
- /** passed through, re-rendering the whole rebuilt view so the page
resynchronizes with the server */
- RENDER_ALL;
-
- /**
- * @return the replay the original request calls for, before the saved
form itself is considered
- */
- static AjaxReplay of(ReplayFlow flow, HttpServletRequest request) {
- if (flow != ReplayFlow.IN_PLACE ||
!Servlets.isFacesAjaxRequest(request)) {
- return FULL_PAGE;
- }
- return
isFormResubmitAjaxRenderAllDisabled(request.getServletContext()) ? PASS_THROUGH
: RENDER_ALL;
- }
-
- /**
- * @return this replay narrowed to the saved form: only a Faces Ajax
submission can be passed through,
- * and a stateless view isn't rebuilt, so there is nothing to
resynchronize
- */
- AjaxReplay forForm(boolean isPartialAjaxRequest, boolean isStateless) {
- if (!isPartialAjaxRequest) {
- return FULL_PAGE;
- }
- return isStateless && this == RENDER_ALL ? PASS_THROUGH : this;
- }
-
- boolean isPassThrough() {
- return this != FULL_PAGE;
- }
- }
-
/**
* Form fields prepared for replay
*
* @param result decoded form fields, by name
* @param isPartialAjaxRequest whether the saved form was submitted via
Faces Ajax
- * @param ajaxReplay how the form is replayed
*/
- record PartialAjaxResult(Map<String, List<String>> result, boolean
isPartialAjaxRequest,
- AjaxReplay ajaxReplay) { }
+ record PartialAjaxResult(Map<String, List<String>> result, boolean
isPartialAjaxRequest) { }
static void savePostDataForResubmit(HttpServletRequest request,
HttpServletResponse response, @NonNull String loginUrl) {
if (isSavableForm(request) &&
isSecurityManagerTypeOf(getSecurityManager(), DefaultSecurityManager.class)) {
@@ -215,17 +158,17 @@ public class FormResubmitSupport {
}
/**
- * Whether a POST that arrived without a session should be replayed in
place, without a login flow,
- * once the security chain permits it: for a remembered subject, or for an
anonymous subject whose
- * session has expired, i.e. the browser presented a session id that no
longer resolves to a session.
- * Only same-origin, form-encoded submissions are replayed, and only when
server-side Faces view state
+ * Whether a POST that arrived without a session should rebuild its
server-side Faces view during
+ * the request: for a remembered subject, or for an anonymous subject
whose session has expired,
+ * i.e. the browser presented a session id that no longer resolves to a
session.
+ * Only same-origin, form-encoded submissions are eligible, and only when
server-side Faces view state
* could have been lost with the session.
*
* @param subject the subject created for the request, without a session
* @param request the current request
- * @return true if the form should be replayed
+ * @return true if an expired server-side Faces view may be rebuilt
*/
- static boolean isDirectResubmitCandidate(@NonNull Subject subject,
ServletRequest request) {
+ static boolean isViewRebuildCandidate(@NonNull Subject subject,
ServletRequest request) {
if (!(request instanceof HttpServletRequest httpRequest) ||
!isSavableForm(httpRequest)) {
return false;
}
@@ -433,7 +376,7 @@ public class FormResubmitSupport {
String formData = getSavedFormDataFromKey(savedFormDataKey,
cache::set);
try {
if (formData != null && resubmit &&
!isFormDataDiscarded(request)) {
- resubmitSavedForm(formData, savedRequest, request,
response, ReplayFlow.AFTER_LOGIN);
+ resubmitSavedForm(formData, savedRequest, request,
response);
doRedirectAtEnd = false;
} else {
// nothing to submit, or the user chose to discard it:
forget it either way
@@ -516,8 +459,8 @@ public class FormResubmitSupport {
* @param savedRequest already validated by {@link #normalizeSavedRequest}
*/
static void resubmitSavedForm(@NonNull String savedFormData, @NonNull
String savedRequest,
- HttpServletRequest originalRequest, HttpServletResponse
originalResponse, ReplayFlow flow) {
- if (!replaySavedForm(savedFormData, savedRequest, originalRequest,
originalResponse, flow)) {
+ HttpServletRequest originalRequest, HttpServletResponse
originalResponse) {
+ if (!replaySavedForm(savedFormData, savedRequest, originalRequest,
originalResponse)) {
doFacesRedirect(originalRequest, originalResponse, savedRequest);
}
}
@@ -526,7 +469,7 @@ public class FormResubmitSupport {
* @return whether the response has been fully written
*/
private static boolean replaySavedForm(String savedFormData, String
savedRequest,
- HttpServletRequest originalRequest, HttpServletResponse
originalResponse, ReplayFlow flow) {
+ HttpServletRequest originalRequest, HttpServletResponse
originalResponse) {
if (FormResubmitRequest.isResubmit(originalRequest)) {
log.debug("Recursive form resubmission, skipping replay");
return false;
@@ -542,20 +485,16 @@ public class FormResubmitSupport {
Servlets.setNoCacheHeaders(originalRequest, originalResponse);
try {
var savedFormFields = parseFormData(savedFormData,
getFormCharset(originalRequest, servletContext));
- PartialAjaxResult formData = prepareFormData(savedFormFields,
dispatchPath, originalRequest,
- originalResponse, servletContext, AjaxReplay.of(flow,
originalRequest));
+ boolean isStateless = isJSFClientStateSavingMethod(servletContext)
|| !isJSFStatefulForm(savedFormFields);
+ PartialAjaxResult formData = noJSFAjaxRequests(savedFormFields,
isStateless);
var response = new FormResubmitResponse(originalResponse);
- forward(dispatchPath, originalRequest, response, HttpMethod.POST,
formData.result, formData.ajaxReplay);
+ forward(dispatchPath, originalRequest, response, formData.result);
if (isFailed(response.getStatus())) {
log.debug("Form resubmit to {} failed with status {}",
dispatchPath, response.getStatus());
return false;
}
- if (formData.ajaxReplay.isPassThrough() &&
isPartialResponseError(response.getBufferAsString())) {
- log.debug("Form resubmit to {} failed with a Faces Ajax
error", dispatchPath);
- return false;
- }
processResubmitResponse(response, originalRequest,
originalResponse, savedRequest,
- formData.isPartialAjaxRequest, flow);
+ formData.isPartialAjaxRequest);
} catch (ServletException | IOException | RuntimeException e) {
log.warn("Unable to resubmit form to {}", dispatchPath, e);
return false;
@@ -570,15 +509,6 @@ public class FormResubmitSupport {
return status != SC_OK && status != SC_FOUND;
}
- /**
- * Faces reports an unhandled exception in an Ajax request as a successful
partial response that carries
- * an error element, which would reach only the Ajax client's error
callback.
- * Such a replay is considered failed, so that it falls back to a redirect
like a full-page one.
- */
- static boolean isPartialResponseError(@NonNull String responseBody) {
- return Jsoup.parse(responseBody,
Parser.xmlParser()).selectFirst("partial-response > error") != null;
- }
-
/**
* Derives the dispatcher path from a saved request that is already
verified to be within the context path.
* The container's dispatcher strips path parameters, decodes and
normalizes the path before resolving it,
@@ -605,9 +535,9 @@ public class FormResubmitSupport {
* @param path dispatch path already verified by {@link #replaySavedForm}
to resolve to a dispatcher
*/
private static void forward(String path, HttpServletRequest
originalRequest, HttpServletResponse response,
- String method, Map<String, List<String>> formFields, AjaxReplay
ajaxReplay)
+ Map<String, List<String>> formFields)
throws ServletException, IOException {
- var request = new FormResubmitRequest(originalRequest, method,
formFields, ajaxReplay);
+ var request = new FormResubmitRequest(originalRequest, formFields);
var dispatcher =
originalRequest.getServletContext().getRequestDispatcher(path);
if (!hasFacesContext()) {
dispatcher.forward(request, response);
@@ -656,29 +586,17 @@ public class FormResubmitSupport {
}).orElse(StandardCharsets.UTF_8);
}
- private static PartialAjaxResult prepareFormData(Map<String, List<String>>
savedFormFields, String path,
- HttpServletRequest request, HttpServletResponse response,
ServletContext servletContext,
- AjaxReplay ajaxReplay) throws IOException, ServletException {
- boolean isStateless = isJSFClientStateSavingMethod(servletContext) ||
!isJSFStatefulForm(savedFormFields);
- var formFields = new LinkedHashMap<>(savedFormFields);
- if (!isStateless) {
- refreshJSFViewState(path, request, response, formFields);
- }
- return noJSFAjaxRequests(formFields, isStateless, ajaxReplay);
- }
-
/**
* Only a successful replay reaches the browser, with its headers and
cookies.
- * An Ajax replay's partial response is passed through to the Ajax client
that submitted the form.
- * Otherwise, the Ajax client is redirected to see the full-page replay's
outcome.
+ * A saved Ajax submission redirects to the saved page after its action is
replayed.
+ * Otherwise, the full-page replay's response body is returned.
*/
private static void processResubmitResponse(FormResubmitResponse response,
HttpServletRequest originalRequest,
- HttpServletResponse originalResponse, String savedRequest, boolean
isPartialAjaxRequest,
- ReplayFlow flow) throws IOException {
+ HttpServletResponse originalResponse, String savedRequest, boolean
isPartialAjaxRequest) throws IOException {
int status = response.getStatus();
response.applyTo(originalResponse);
originalResponse.setStatus(status);
- if (isPartialAjaxRequest && (status == SC_FOUND || flow ==
ReplayFlow.AFTER_LOGIN)) {
+ if (isPartialAjaxRequest) {
doFacesRedirect(originalRequest, originalResponse, savedRequest);
} else {
originalResponse.getOutputStream().write(response.getBuffer());
@@ -705,59 +623,24 @@ public class FormResubmitSupport {
return null;
}
- private static void refreshJSFViewState(String path, HttpServletRequest
request,
- HttpServletResponse response, Map<String, List<String>>
formFields) throws IOException, ServletException {
- // view-state GET headers and cookies stay captured and are never
applied to the browser response
- var htmlResponse = new FormResubmitResponse(response);
- forward(path, request, htmlResponse, HttpMethod.GET, Map.of(),
AjaxReplay.FULL_PAGE);
- if (htmlResponse.getStatus() == SC_OK) {
-
Optional.ofNullable(extractJSFNewViewState(htmlResponse.getBufferAsString())).ifPresent(viewState
-> {
- log.debug("Replaced ViewState: {}", viewState);
- formFields.put(FACES_VIEW_STATE, List.of(viewState));
- });
- }
- }
-
- /**
- * @return view state of the first form in the rendered view, or null if
there is none
- */
- static String extractJSFNewViewState(@NonNull String responseBody) {
- Elements elts =
Jsoup.parse(responseBody).select("input[name=%s]".formatted(FACES_VIEW_STATE));
- return elts.isEmpty() ? null :
Objects.requireNonNull(elts.first()).attr("value");
- }
-
/**
* Turns a Faces Ajax submission into a full-page submission of the same
command.
- * The Ajax fields are only kept for stateless views, where they can't
fail view state restoration.
+ * For a stateful view, its Ajax fields are removed so the complete form
is processed and any expired view rebuilt.
+ * A stateless view can retain the Ajax fields and its partial execute.
*/
static PartialAjaxResult noJSFAjaxRequests(Map<String, List<String>>
formFields, boolean isStateless) {
- return noJSFAjaxRequests(formFields, isStateless,
AjaxReplay.FULL_PAGE);
- }
-
- /**
- * Keeps a Faces Ajax submission intact when it's passed through to the
waiting Ajax client,
- * or when the view is stateless so the Ajax fields can't fail view state
restoration.
- * Otherwise, turns it into a full-page submission of the same command.
- */
- static PartialAjaxResult noJSFAjaxRequests(Map<String, List<String>>
formFields, boolean isStateless,
- AjaxReplay ajaxReplay) {
- var fullForm = new LinkedHashMap<String, List<String>>();
+ var result = new LinkedHashMap<String, List<String>>();
+ boolean isPartialAjaxRequest =
formFields.keySet().stream().anyMatch(FormResubmitSupport::isFacesAjaxField);
formFields.forEach((name, values) -> {
- if (!isFacesAjaxField(name)) {
- fullForm.put(name, values);
+ if (isStateless || !isFacesAjaxField(name)) {
+ result.put(name, values);
}
});
- boolean isPartialAjaxRequest = fullForm.size() != formFields.size();
- var replay = ajaxReplay.forForm(isPartialAjaxRequest, isStateless);
- var result = isStateless || replay.isPassThrough() ? new
LinkedHashMap<>(formFields) : fullForm;
- if (replay == AjaxReplay.RENDER_ALL) {
- result.put(PARTIAL_RENDER_PARAM_NAME,
List.of(ALL_PARTIAL_PHASE_CLIENT_IDS));
- }
// The source value becomes the submitted command's parameter name
formFields.getOrDefault(BEHAVIOR_SOURCE_PARAM_NAME, List.of()).stream()
.filter(source -> !Utils.isEmpty(source)).findFirst()
.ifPresent(source -> result.putIfAbsent(source, List.of("")));
- return new PartialAjaxResult(result, isPartialAjaxRequest, replay);
+ return new PartialAjaxResult(result, isPartialAjaxRequest);
}
private static boolean isFacesAjaxField(String name) {
@@ -767,7 +650,7 @@ public class FormResubmitSupport {
static boolean isJSFStatefulForm(@NonNull Map<String, List<String>>
formFields) {
return formFields.getOrDefault(FACES_VIEW_STATE, List.of()).stream()
- .anyMatch(viewState ->
STATEFUL_VIEW_STATE_PATTERN.matcher(viewState).matches());
+ .anyMatch(viewState -> !viewState.isBlank() &&
!"stateless".equals(viewState));
}
static boolean isJSFClientStateSavingMethod(ServletContext servletContext)
{
diff --git
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitViewHandler.java
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitViewHandler.java
new file mode 100644
index 000000000..cb33041ab
--- /dev/null
+++
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/FormResubmitViewHandler.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.shiro.ee.filters;
+
+import java.io.IOException;
+import static
org.apache.shiro.ee.filters.FormResubmitSupport.REBUILD_EXPIRED_VIEW;
+import jakarta.faces.application.ViewHandler;
+import jakarta.faces.application.ViewHandlerWrapper;
+import jakarta.faces.component.UIViewRoot;
+import jakarta.faces.context.FacesContext;
+import static
org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isFormResubmitAjaxRenderAllDisabled;
+import lombok.SneakyThrows;
+import org.omnifaces.util.ComponentsLocal;
+import org.omnifaces.util.FacesLocal;
+
+/**
+ * Rebuilds expired server-side views only for marked or replayed Shiro form
submissions.
+ */
+public class FormResubmitViewHandler extends ViewHandlerWrapper {
+ public FormResubmitViewHandler(ViewHandler wrapped) {
+ super(wrapped);
+ }
+
+ @Override
+ @SneakyThrows(IOException.class)
+ public UIViewRoot restoreView(FacesContext context, String viewId) {
+ var view = super.restoreView(context, viewId);
+ var request = FacesLocal.getRequest(context);
+ boolean rebuildExpiredView =
Boolean.TRUE.equals(request.getAttribute(REBUILD_EXPIRED_VIEW));
+ if (view == null && context.isPostback() &&
(FormResubmitRequest.isResubmit(request) || rebuildExpiredView)
+ &&
!FormResubmitSupport.isJSFClientStateSavingMethod(FacesLocal.getServletContext(context)))
{
+ if (rebuildExpiredView &&
context.getPartialViewContext().isAjaxRequest()
+ &&
!isFormResubmitAjaxRenderAllDisabled(FacesLocal.getServletContext(context))) {
+ context.getPartialViewContext().setRenderAll(true);
+ }
+ boolean processingEvents = context.isProcessingEvents();
+ context.setProcessingEvents(true);
+ try {
+ return ComponentsLocal.buildView(context, viewId);
+ } finally {
+ context.setProcessingEvents(processingEvents);
+ }
+ }
+ return view;
+ }
+}
diff --git
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/ShiroFilter.java
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/ShiroFilter.java
index c00a35da6..733fbae74 100644
---
a/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/ShiroFilter.java
+++
b/support/jakarta-ee/src/main/java/org/apache/shiro/ee/filters/ShiroFilter.java
@@ -14,15 +14,8 @@
package org.apache.shiro.ee.filters;
import static
org.apache.shiro.ee.cdi.ShiroScopeContext.addScopeSessionListeners;
-import static
org.apache.shiro.ee.filters.FormResubmitSupport.FORM_IS_RESUBMITTED;
-import static org.apache.shiro.ee.filters.FormResubmitSupport.getPostData;
-import static
org.apache.shiro.ee.filters.FormResubmitSupport.isDirectResubmitCandidate;
-import static org.apache.shiro.ee.filters.FormResubmitSupport.isLoginUrl;
-import static org.apache.shiro.ee.filters.FormResubmitSupport.isPostRequest;
-import static
org.apache.shiro.ee.filters.FormResubmitSupport.normalizeSavedRequest;
-import static org.apache.shiro.ee.filters.FormResubmitSupport.redirectToView;
-import static
org.apache.shiro.ee.filters.FormResubmitSupport.resubmitSavedForm;
-import org.apache.shiro.ee.filters.FormResubmitSupport.ReplayFlow;
+import static
org.apache.shiro.ee.filters.FormResubmitSupport.REBUILD_EXPIRED_VIEW;
+import static
org.apache.shiro.ee.filters.FormResubmitSupport.isViewRebuildCandidate;
import static
org.apache.shiro.ee.listeners.EnvironmentLoaderListener.getCharacterEncoding;
import static
org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isCharEncodingEnabled;
import static
org.apache.shiro.ee.listeners.EnvironmentLoaderListener.isShiroEEDisabled;
@@ -169,9 +162,9 @@ public class ShiroFilter extends
org.apache.shiro.web.servlet.ShiroFilter {
log.debug("Create Session Failed", e);
}
var newSubject = wrapped.createSubject(context);
- if (session == null && isDirectResubmitCandidate(newSubject,
webContext.getServletRequest())) {
- log.debug("Subject {} without a session, form will be
replayed", newSubject.getPrincipal());
-
webContext.getServletRequest().setAttribute(FORM_IS_RESUBMITTED, Boolean.TRUE);
+ if (session == null && isViewRebuildCandidate(newSubject,
webContext.getServletRequest())) {
+ log.debug("Subject {} without a session, expired Faces
view may be rebuilt", newSubject.getPrincipal());
+
webContext.getServletRequest().setAttribute(REBUILD_EXPIRED_VIEW, Boolean.TRUE);
}
return newSubject;
} else {
@@ -240,34 +233,7 @@ public class ShiroFilter extends
org.apache.shiro.web.servlet.ShiroFilter {
origChain.doFilter(request, response);
} else {
setCharacterEncodingIfNeeded(request);
- // Replay only once the security chain has permitted the request,
so that a denied request
- // still saves its form for the login flow, with its body intact
- super.executeChain(request, response, (chainRequest,
chainResponse) -> {
- if
(Boolean.TRUE.equals(chainRequest.getAttribute(FORM_IS_RESUBMITTED))) {
- chainRequest.removeAttribute(FORM_IS_RESUBMITTED);
- if (isPostRequest(chainRequest) &&
!isLoginUrl(WebUtils.toHttp(chainRequest))) {
- resubmitForm(WebUtils.toHttp(chainRequest),
WebUtils.toHttp(chainResponse));
- return;
- }
- }
- origChain.doFilter(chainRequest, chainResponse);
- });
- }
- }
-
- /**
- * Replays the current POST, whose session has been lost, in place of the
request's own processing
- */
- private static void resubmitForm(HttpServletRequest httpRequest,
HttpServletResponse httpResponse) {
- String postData = getPostData(httpRequest);
- // never log the body itself: anonymous replays may carry credentials
or other secrets
- log.debug("Resubmitting POST to {} ({} bytes of form data)",
httpRequest.getRequestURI(), postData.length());
- // the raw request URI need not be canonical, nor start with the
context path
- String savedRequest =
normalizeSavedRequest(Servlets.getRequestURIWithQueryString(httpRequest),
httpRequest);
- if (savedRequest == null) {
- redirectToView(httpRequest, httpResponse);
- } else {
- resubmitSavedForm(postData, savedRequest, httpRequest,
httpResponse, ReplayFlow.IN_PLACE);
+ super.executeChain(request, response, origChain);
}
}
diff --git a/support/jakarta-ee/src/main/resources/META-INF/faces-config.xml
b/support/jakarta-ee/src/main/resources/META-INF/faces-config.xml
index 0f2860c65..6959e5af4 100644
--- a/support/jakarta-ee/src/main/resources/META-INF/faces-config.xml
+++ b/support/jakarta-ee/src/main/resources/META-INF/faces-config.xml
@@ -23,6 +23,9 @@
xmlns="https://jakarta.ee/xml/ns/jakartaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee
https://jakarta.ee/xml/ns/jakartaee/web-facesconfig_4_0.xsd">
+ <application>
+
<view-handler>org.apache.shiro.ee.filters.FormResubmitViewHandler</view-handler>
+ </application>
<component>
<component-type>org.apache.shiro.ee.faces.tags.PrincipalTag</component-type>
<component-class>org.apache.shiro.ee.faces.tags.PrincipalTag</component-class>
diff --git
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitDispatchTest.java
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitDispatchTest.java
new file mode 100644
index 000000000..576e80f2c
--- /dev/null
+++
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitDispatchTest.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.shiro.ee.filters;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import jakarta.servlet.RequestDispatcher;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.ServletOutputStream;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class FormResubmitDispatchTest {
+ @Mock
+ private HttpServletRequest request;
+ @Mock
+ private HttpServletResponse response;
+ @Mock
+ private ServletContext servletContext;
+ @Mock
+ private RequestDispatcher dispatcher;
+ @Mock
+ private ServletOutputStream output;
+
+ @Test
+ void afterLoginReplayUsesOnePostAndNoGet() throws Exception {
+ replayAfterLogin(false);
+ }
+
+ @Test
+ void afterLoginAjaxReplayProcessesCommandThenRedirects() throws Exception {
+ replayAfterLogin(true);
+ }
+
+ private void replayAfterLogin(boolean ajaxForm) throws Exception {
+ when(request.getContextPath()).thenReturn("/app");
+ when(request.getRequestURL()).thenReturn(new
StringBuffer("https://localhost/app/form.xhtml"));
+ when(request.getServletContext()).thenReturn(servletContext);
+
when(request.getAttributeNames()).thenReturn(Collections.emptyEnumeration());
+ when(servletContext.getContextPath()).thenReturn("/app");
+
when(servletContext.getRequestDispatcher("/form.xhtml")).thenReturn(dispatcher);
+ if (!ajaxForm) {
+ when(response.getOutputStream()).thenReturn(output);
+ }
+ byte[] result = "replayed".getBytes(StandardCharsets.UTF_8);
+ doAnswer(invocation -> {
+ HttpServletRequest replay = invocation.getArgument(0);
+ HttpServletResponse captured = invocation.getArgument(1);
+ assertThat(replay.getMethod()).isEqualTo("POST");
+
assertThat(replay.getParameter("jakarta.faces.ViewState")).isEqualTo("123:456");
+ assertThat(replay.getParameter("name")).isEqualTo("Jack & Jill");
+ assertThat(FormResubmitRequest.isResubmit(replay)).isTrue();
+ if (ajaxForm) {
+
assertThat(replay.getParameter("jakarta.faces.partial.ajax")).isNull();
+
assertThat(replay.getParameter("jakarta.faces.source")).isNull();
+ assertThat(replay.getParameter("form:submit")).isEmpty();
+ assertThat(replay.getHeader("Faces-Request")).isNull();
+ }
+ captured.getOutputStream().write(result);
+ return null;
+ }).when(dispatcher).forward(any(), any());
+
+ String formData =
"name=Jack+%26+Jill&jakarta.faces.ViewState=123%3A456"
+ + (ajaxForm ?
"&jakarta.faces.partial.ajax=true&jakarta.faces.source=form%3Asubmit" : "");
+ FormResubmitSupport.resubmitSavedForm(formData,
+ "/app/form.xhtml", request, response);
+
+ verify(dispatcher).forward(any(), any());
+ if (ajaxForm) {
+ verify(response).sendRedirect("/app/form.xhtml");
+ verify(response, never()).getOutputStream();
+ } else {
+ verify(output).write(result);
+ verify(response, never()).sendRedirect(any());
+ }
+ }
+}
diff --git
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitRequestTest.java
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitRequestTest.java
index 5238bbefb..418af2253 100644
---
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitRequestTest.java
+++
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitRequestTest.java
@@ -17,7 +17,6 @@ import java.util.Collections;
import java.util.List;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
-import org.apache.shiro.ee.filters.FormResubmitSupport.AjaxReplay;
import static org.assertj.core.api.Assertions.assertThat;
@@ -48,24 +47,13 @@ class FormResubmitRequestTest {
.thenReturn(Collections.enumeration(List.of("partial/ajax")));
}
- private FormResubmitRequest request(AjaxReplay ajaxReplay) {
- return new FormResubmitRequest(login, "POST", Map.of(), ajaxReplay);
- }
-
@Test
void facesRequestHeaderIsHiddenFromAllAccessorsOnFullPageReplay() {
- var request = request(AjaxReplay.FULL_PAGE);
+ var request = new FormResubmitRequest(login, Map.of());
assertThat(request.getHeader("Faces-Request")).isNull();
assertThat(request.getHeader("faces-request")).isNull();
assertThat(Collections.list(request.getHeaders("Faces-Request"))).isEmpty();
assertThat(Collections.list(request.getHeaderNames())).containsExactly("Host");
}
- @Test
- void facesRequestHeaderIsVisibleOnPassThroughReplay() {
- var request = request(AjaxReplay.PASS_THROUGH);
-
assertThat(request.getHeader("Faces-Request")).isEqualTo("partial/ajax");
-
assertThat(Collections.list(request.getHeaders("Faces-Request"))).containsExactly("partial/ajax");
-
assertThat(Collections.list(request.getHeaderNames())).containsExactly("Faces-Request",
"Host");
- }
}
diff --git
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitViewHandlerTest.java
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitViewHandlerTest.java
new file mode 100644
index 000000000..7aba8ad9b
--- /dev/null
+++
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormResubmitViewHandlerTest.java
@@ -0,0 +1,177 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.shiro.ee.filters;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicBoolean;
+import jakarta.faces.application.ViewHandler;
+import jakarta.faces.component.UIViewRoot;
+import jakarta.faces.context.ExternalContext;
+import jakarta.faces.context.FacesContext;
+import jakarta.faces.context.PartialViewContext;
+import jakarta.servlet.ServletContext;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequestWrapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.omnifaces.util.ComponentsLocal;
+
+import static
jakarta.faces.application.StateManager.STATE_SAVING_METHOD_PARAM_NAME;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class FormResubmitViewHandlerTest {
+ private static final String VIEW_ID = "/form.xhtml";
+ @Mock
+ private FacesContext context;
+ @Mock
+ private ExternalContext externalContext;
+ @Mock
+ private ServletContext servletContext;
+ @Mock
+ private HttpServletRequest request;
+ @Mock
+ private ViewHandler wrapped;
+ @Mock
+ private UIViewRoot view;
+ @Mock
+ private PartialViewContext partialViewContext;
+ @Mock
+ private MockedStatic<ComponentsLocal> components;
+
+ private FormResubmitViewHandler handler;
+
+ @BeforeEach
+ void setUp() {
+ handler = new FormResubmitViewHandler(wrapped);
+
lenient().when(context.getExternalContext()).thenReturn(externalContext);
+ lenient().when(externalContext.getRequest()).thenReturn(request);
+
lenient().when(externalContext.getContext()).thenReturn(servletContext);
+ }
+
+ private void replay() {
+ when(context.isPostback()).thenReturn(true);
+
when(request.getAttributeNames()).thenReturn(Collections.emptyEnumeration());
+ var replay = new FormResubmitRequest(request, Map.of());
+ when(externalContext.getRequest()).thenReturn(new
HttpServletRequestWrapper(replay));
+ }
+
+ @Test
+ void existingViewIsRestoredNormally() {
+ when(wrapped.restoreView(context, VIEW_ID)).thenReturn(view);
+ assertThat(handler.restoreView(context, VIEW_ID)).isSameAs(view);
+ components.verifyNoInteractions();
+ }
+
+ @Test
+ void expiredReplayUsesOmniFacesViewBuilder() {
+ replay();
+ components.when(() -> ComponentsLocal.buildView(context,
VIEW_ID)).thenReturn(view);
+ assertThat(handler.restoreView(context, VIEW_ID)).isSameAs(view);
+ components.verify(() -> ComponentsLocal.buildView(context, VIEW_ID));
+ }
+
+ @Test
+ void expiredMarkedRequestUsesOmniFacesViewBuilder() {
+ when(context.isPostback()).thenReturn(true);
+
when(request.getAttribute(FormResubmitSupport.REBUILD_EXPIRED_VIEW)).thenReturn(Boolean.TRUE);
+ when(context.getPartialViewContext()).thenReturn(partialViewContext);
+ when(partialViewContext.isAjaxRequest()).thenReturn(true);
+ components.when(() -> ComponentsLocal.buildView(context,
VIEW_ID)).thenReturn(view);
+ assertThat(handler.restoreView(context, VIEW_ID)).isSameAs(view);
+ components.verify(() -> ComponentsLocal.buildView(context, VIEW_ID));
+ verify(partialViewContext).setRenderAll(true);
+ }
+
+ @Test
+ void markedRequestKeepsRenderTargetsWhenRenderAllIsDisabled() {
+ when(context.isPostback()).thenReturn(true);
+
when(request.getAttribute(FormResubmitSupport.REBUILD_EXPIRED_VIEW)).thenReturn(Boolean.TRUE);
+ when(context.getPartialViewContext()).thenReturn(partialViewContext);
+ when(partialViewContext.isAjaxRequest()).thenReturn(true);
+
when(servletContext.getAttribute("org.apache.shiro.form-resubmit.ajax-render-all.disabled"))
+ .thenReturn(Boolean.TRUE);
+ components.when(() -> ComponentsLocal.buildView(context,
VIEW_ID)).thenReturn(view);
+
+ assertThat(handler.restoreView(context, VIEW_ID)).isSameAs(view);
+
+ verify(partialViewContext, never()).setRenderAll(anyBoolean());
+ }
+
+ @Test
+ void viewBuildEnablesAndRestoresProcessingEvents() {
+ replay();
+ var processingEvents = new AtomicBoolean();
+ when(context.isProcessingEvents()).thenAnswer(invocation ->
processingEvents.get());
+ doAnswer(invocation -> {
+ processingEvents.set(invocation.getArgument(0));
+ return null;
+ }).when(context).setProcessingEvents(anyBoolean());
+ components.when(() -> ComponentsLocal.buildView(context,
VIEW_ID)).thenAnswer(invocation -> {
+ assertThat(processingEvents.get()).isTrue();
+ return view;
+ });
+
+ assertThat(handler.restoreView(context, VIEW_ID)).isSameAs(view);
+
+ assertThat(processingEvents.get()).isFalse();
+ verify(context).setProcessingEvents(true);
+ verify(context).setProcessingEvents(false);
+ }
+
+ @Test
+ void ordinaryExpiredPostbackIsNotRebuilt() {
+ when(context.isPostback()).thenReturn(true);
+ assertThat(handler.restoreView(context, VIEW_ID)).isNull();
+ components.verifyNoInteractions();
+ }
+
+ @Test
+ void initialRequestIsNotRebuilt() {
+ assertThat(handler.restoreView(context, VIEW_ID)).isNull();
+ components.verifyNoInteractions();
+ }
+
+ @Test
+ void clientSideStateIsNotRebuilt() {
+ replay();
+
when(servletContext.getInitParameter(STATE_SAVING_METHOD_PARAM_NAME)).thenReturn("client");
+ assertThat(handler.restoreView(context, VIEW_ID)).isNull();
+ components.verifyNoInteractions();
+ }
+
+ @Test
+ void buildFailurePropagatesToReplayFallback() {
+ replay();
+ var failure = new IOException("Cannot build view");
+ components.when(() -> ComponentsLocal.buildView(context,
VIEW_ID)).thenThrow(failure);
+ assertThatExceptionOfType(IOException.class)
+ .isThrownBy(() -> handler.restoreView(context,
VIEW_ID)).isSameAs(failure);
+ verify(context).setProcessingEvents(true);
+ verify(context).setProcessingEvents(false);
+ }
+}
diff --git
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormSupportTest.java
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormSupportTest.java
index a7d458681..1cc947e9a 100644
---
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormSupportTest.java
+++
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/FormSupportTest.java
@@ -15,11 +15,6 @@ package org.apache.shiro.ee.filters;
import org.apache.shiro.ee.filters.FormResubmitSupport.PartialAjaxResult;
-import static
org.apache.shiro.ee.filters.FormResubmitSupport.AjaxReplay.FULL_PAGE;
-import static
org.apache.shiro.ee.filters.FormResubmitSupport.AjaxReplay.PASS_THROUGH;
-import static
org.apache.shiro.ee.filters.FormResubmitSupport.AjaxReplay.RENDER_ALL;
-import static
org.apache.shiro.ee.filters.FormResubmitSupport.extractJSFNewViewState;
-import static
org.apache.shiro.ee.filters.FormResubmitSupport.isPartialResponseError;
import static org.apache.shiro.ee.filters.FormResubmitSupport.getDispatchPath;
import static org.apache.shiro.ee.filters.FormResubmitSupport.getFormCharset;
import static org.apache.shiro.ee.filters.FormResubmitSupport.getReferer;
@@ -248,10 +243,13 @@ class FormSupportTest {
+ "j_idt11=Submit+...&jakarta.faces.ViewState"
+ "=-8335355445345003673%3A-6008443334776649058";
assertThat(isJSFStatefulForm(parseFormData(statefulFormData))).isTrue();
+ assertThat(isJSFStatefulForm(parseFormData(
+
"jakarta.faces.ViewState=YjJ1dDkzQmFzZTY0LW15ZmFjZXM="))).isTrue();
String statelessFormData
=
"j_idt5=j_idt5&j_idt5%3Aj_idt7=aaa&j_idt5%3Aj_idt9=bbb&j_idt5%3A"
+ "j_idt11=Submit+...&jakarta.faces.ViewState=stateless";
assertThat(isJSFStatefulForm(parseFormData(statelessFormData))).isFalse();
+
assertThat(isJSFStatefulForm(parseFormData("jakarta.faces.ViewState="))).isFalse();
assertThatExceptionOfType(NullPointerException.class).isThrownBy(() ->
isJSFStatefulForm(null));
String nonJSFFormData
=
"j_idt5=j_idt5&j_idt5%3Aj_idt7=aaa&j_idt5%3Aj_idt9=bbb&j_idt5%3A"
@@ -274,9 +272,9 @@ class FormSupportTest {
@Test
void parseFormDataHonoursCharset() {
assertThat(FormResubmitSupport.parseFormData("name=J%F6rg",
StandardCharsets.ISO_8859_1))
- .containsExactly(Map.entry("name", List.of("J\u00f6rg")));
+ .containsExactly(Map.entry("name", List.of("Jörg")));
assertThat(FormResubmitSupport.parseFormData("name=J%C3%B6rg",
StandardCharsets.UTF_8))
- .containsExactly(Map.entry("name", List.of("J\u00f6rg")));
+ .containsExactly(Map.entry("name", List.of("Jörg")));
}
@Test
@@ -295,24 +293,12 @@ class FormSupportTest {
assertThat(getFormCharset(request,
servletContext)).isEqualTo(StandardCharsets.UTF_8);
}
- @Test
- void extractViewState() {
- assertThatExceptionOfType(NullPointerException.class).isThrownBy(() ->
extractJSFNewViewState(null));
- assertThat(extractJSFNewViewState("")).isNull();
- assertThat(extractJSFNewViewState("xxx")).isNull();
- assertThat(extractJSFNewViewState("<input
name=\"jakarta.faces.ViewState\" value=\"123:456\"/>"))
- .isEqualTo("123:456");
- assertThat(extractJSFNewViewState("<input
name=\"jakarta.faces.ViewState\" value=\"-123:-456\"/>"
- + "<input name=\"jakarta.faces.ViewState\"
value=\"-789:-012\"/>"))
- .isEqualTo("-123:-456");
- }
-
@Test
void noAjaxRequests() {
assertThat(noJSFAjaxRequests(parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456"
+ "&jakarta.faces.partial.ajax=true&hello=bye"),
false)).isEqualTo(new PartialAjaxResult(
parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456&hello=bye"),
- true, FULL_PAGE));
+ true));
assertThat(noJSFAjaxRequests(parseFormData("j_idt12=j_idt12&j_idt12:j_idt14=asdf&j_idt12:j_idt16=asdf"
+
"&jakarta.faces.ViewState=7709788254588873136:-8052771455757429917"
+ "&jakarta.faces.source=j_idt12:j_idt18"
@@ -324,56 +310,22 @@ class FormSupportTest {
.isEqualTo(new
PartialAjaxResult(parseFormData("j_idt12=j_idt12&j_idt12:j_idt14=asdf"
+ "&j_idt12:j_idt16=asdf"
+
"&jakarta.faces.ViewState=7709788254588873136:-8052771455757429917&j_idt12:j_idt18="),
- true, FULL_PAGE));
+ true));
}
@Test
- void ajaxFieldsAreKeptForPassThrough() {
- String ajaxForm =
"aaa=bbb&jakarta.faces.ViewState=-123:-456&jakarta.faces.source=j_idt12:j_idt18"
+ void statelessAjaxFieldsAreKeptForReplay() {
+ String ajaxForm =
"aaa=bbb&jakarta.faces.ViewState=stateless&jakarta.faces.source=j_idt12:j_idt18"
+
"&jakarta.faces.partial.render=j_idt12&jakarta.faces.partial.ajax=true";
- assertThat(noJSFAjaxRequests(parseFormData(ajaxForm), false,
PASS_THROUGH)).isEqualTo(new PartialAjaxResult(
- parseFormData(ajaxForm + "&j_idt12:j_idt18="), true,
PASS_THROUGH));
- // a non-Ajax form submitted while the Ajax client is on the page
isn't an Ajax replay
-
assertThat(noJSFAjaxRequests(parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456"),
false, RENDER_ALL))
- .isEqualTo(new
PartialAjaxResult(parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456"),
- false, FULL_PAGE));
- }
-
- @Test
- void partialResponseErrorIsDetected() {
- assertThat(isPartialResponseError("""
- <?xml version='1.0' encoding='UTF-8'?>
- <partial-response id="j_id1">
- <error>
- <error-name>class
jakarta.faces.application.ViewExpiredException</error-name>
- <error-message><![CDATA[viewId:/index.xhtml - View
/index.xhtml could not be restored.]]></error-message>
- </error>
- </partial-response>""")).isTrue();
- assertThat(isPartialResponseError("""
- <?xml version='1.0' encoding='UTF-8'?>
- <partial-response id="j_id1">
- <changes>
- <update id="form"><![CDATA[<error>not an
error</error>]]></update>
- <update
id="j_id1:jakarta.faces.ViewState:0"><![CDATA[-123:-456]]></update>
- </changes>
- </partial-response>""")).isFalse();
- assertThat(isPartialResponseError("<html><body>full
page</body></html>")).isFalse();
+ assertThat(noJSFAjaxRequests(parseFormData(ajaxForm),
true)).isEqualTo(new PartialAjaxResult(
+ parseFormData(ajaxForm + "&j_idt12:j_idt18="), true));
}
@Test
- void ajaxReplayOfRebuiltViewRendersAll() {
- String ajaxForm =
"aaa=bbb&jakarta.faces.ViewState=-123:-456&jakarta.faces.source=j_idt12:j_idt18"
- +
"&jakarta.faces.partial.render=j_idt12&jakarta.faces.partial.ajax=true";
- assertThat(noJSFAjaxRequests(parseFormData(ajaxForm), false,
RENDER_ALL).result())
- .containsEntry("jakarta.faces.partial.render", List.of("@all"))
- .containsEntry("jakarta.faces.partial.ajax", List.of("true"));
- // the render targets stay in place when a stateless view isn't rebuilt
- assertThat(noJSFAjaxRequests(parseFormData(ajaxForm), true,
RENDER_ALL))
- .isEqualTo(new PartialAjaxResult(parseFormData(ajaxForm +
"&j_idt12:j_idt18="), true, PASS_THROUGH));
- // or without any render targets
-
assertThat(noJSFAjaxRequests(parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456"
- +
"&jakarta.faces.source=j_idt12:j_idt18&jakarta.faces.partial.ajax=true"),
false, RENDER_ALL)
- .result()).containsEntry("jakarta.faces.partial.render",
List.of("@all"));
+ void nonAjaxFieldsAreUnchangedForReplay() {
+
assertThat(noJSFAjaxRequests(parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456"),
false))
+ .isEqualTo(new
PartialAjaxResult(parseFormData("aaa=bbb&jakarta.faces.ViewState=-123:-456"),
+ false));
}
@Test
@@ -387,7 +339,7 @@ class FormSupportTest {
+ "&jakarta.faces.partial.ajax=true"), false);
assertThat(result).isEqualTo(new PartialAjaxResult(
parseFormData("text=a%26b%2Bc%3Dd&jakarta.faces.ViewState=123%3A456&j_idt12%3Aj_idt18="),
- true, FULL_PAGE));
+ true));
assertThat(result.result()).containsEntry("text", List.of("a&b+c=d"));
}
diff --git
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/DirectResubmitCandidateTest.java
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/ViewRebuildCandidateTest.java
similarity index 76%
rename from
support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/DirectResubmitCandidateTest.java
rename to
support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/ViewRebuildCandidateTest.java
index 5ecf30552..3bc4370d1 100644
---
a/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/DirectResubmitCandidateTest.java
+++
b/support/jakarta-ee/src/test/java/org/apache/shiro/ee/filters/ViewRebuildCandidateTest.java
@@ -15,7 +15,7 @@ package org.apache.shiro.ee.filters;
import static
jakarta.faces.application.StateManager.STATE_SAVING_METHOD_CLIENT;
import static
jakarta.faces.application.StateManager.STATE_SAVING_METHOD_PARAM_NAME;
-import static
org.apache.shiro.ee.filters.FormResubmitSupport.isDirectResubmitCandidate;
+import static
org.apache.shiro.ee.filters.FormResubmitSupport.isViewRebuildCandidate;
import static org.apache.shiro.ee.filters.FormResubmitSupport.isFormUrlEncoded;
import static org.apache.shiro.ee.filters.FormResubmitSupport.isSavableForm;
import static org.assertj.core.api.Assertions.assertThat;
@@ -32,11 +32,11 @@ import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
/**
- * Replaying a form in place, without a login flow, for remembered and
anonymous subjects
+ * Rebuilding expired views without a login flow for remembered and anonymous
subjects
*/
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
-class DirectResubmitCandidateTest {
+class ViewRebuildCandidateTest {
private static final String FORM_RESUBMIT_DISABLED_PARAM =
"org.apache.shiro.form-resubmit.disabled";
private static final String FORM_RESUBMIT_ANONYMOUS_DISABLED_PARAM =
"org.apache.shiro.form-resubmit.anonymous.disabled";
@@ -59,68 +59,68 @@ class DirectResubmitCandidateTest {
}
@Test
- void anonymousExpiredSessionIsReplayed() {
- assertThat(isDirectResubmitCandidate(subject, request)).isTrue();
+ void anonymousExpiredSessionAllowsViewRebuild() {
+ assertThat(isViewRebuildCandidate(subject, request)).isTrue();
}
@Test
- void anonymousWithoutPriorSessionIsNotReplayed() {
+ void anonymousWithoutPriorSessionDoesNotAllowViewRebuild() {
when(request.getRequestedSessionId()).thenReturn(null);
- assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ assertThat(isViewRebuildCandidate(subject, request)).isFalse();
}
@Test
- void rememberedIsReplayedEvenWithoutPriorSession() {
+ void rememberedAllowsViewRebuildEvenWithoutPriorSession() {
when(request.getRequestedSessionId()).thenReturn(null);
when(subject.isRemembered()).thenReturn(true);
- assertThat(isDirectResubmitCandidate(subject, request)).isTrue();
+ assertThat(isViewRebuildCandidate(subject, request)).isTrue();
}
@Test
- void anonymousReplayCanBeDisabled() {
+ void anonymousViewRebuildCanBeDisabled() {
when(servletContext.getAttribute(FORM_RESUBMIT_ANONYMOUS_DISABLED_PARAM)).thenReturn(Boolean.TRUE);
- assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ assertThat(isViewRebuildCandidate(subject, request)).isFalse();
}
@Test
- void anonymousDisablingKeepsRememberedReplay() {
+ void anonymousDisablingKeepsRememberedViewRebuild() {
when(servletContext.getAttribute(FORM_RESUBMIT_ANONYMOUS_DISABLED_PARAM)).thenReturn(Boolean.TRUE);
when(subject.isRemembered()).thenReturn(true);
- assertThat(isDirectResubmitCandidate(subject, request)).isTrue();
+ assertThat(isViewRebuildCandidate(subject, request)).isTrue();
}
@Test
- void globalDisablingStopsAllReplays() {
+ void globalDisablingStopsAllViewRebuilds() {
when(servletContext.getAttribute(FORM_RESUBMIT_DISABLED_PARAM)).thenReturn(Boolean.TRUE);
- assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ assertThat(isViewRebuildCandidate(subject, request)).isFalse();
when(subject.isRemembered()).thenReturn(true);
- assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ assertThat(isViewRebuildCandidate(subject, request)).isFalse();
}
@Test
- void clientStateSavingNeedsNoReplay() {
+ void clientStateSavingNeedsNoViewRebuild() {
when(servletContext.getInitParameter(STATE_SAVING_METHOD_PARAM_NAME)).thenReturn(STATE_SAVING_METHOD_CLIENT);
- assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ assertThat(isViewRebuildCandidate(subject, request)).isFalse();
}
@Test
- void getIsNotReplayed() {
+ void getDoesNotAllowViewRebuild() {
when(request.getMethod()).thenReturn("GET");
- assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ assertThat(isViewRebuildCandidate(subject, request)).isFalse();
}
@Test
- void crossSitePostIsNotReplayed() {
+ void crossSitePostDoesNotAllowViewRebuild() {
when(request.getHeader("Sec-Fetch-Site")).thenReturn("cross-site");
- assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ assertThat(isViewRebuildCandidate(subject, request)).isFalse();
}
@Test
- void nonFormContentIsNotReplayed() {
+ void nonFormContentDoesNotAllowViewRebuild() {
when(request.getContentType()).thenReturn("application/json");
- assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ assertThat(isViewRebuildCandidate(subject, request)).isFalse();
when(request.getContentType()).thenReturn("multipart/form-data;
boundary=xyz");
- assertThat(isDirectResubmitCandidate(subject, request)).isFalse();
+ assertThat(isViewRebuildCandidate(subject, request)).isFalse();
}
@Test