-
Notifications
You must be signed in to change notification settings - Fork 43
Expand file tree
/
Copy pathHtmxAwareAuthenticationEntryPoint.java
More file actions
86 lines (76 loc) · 4.02 KB
/
HtmxAwareAuthenticationEntryPoint.java
File metadata and controls
86 lines (76 loc) · 4.02 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
package com.digitalsanctuary.spring.user.security;
import java.io.IOException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
/**
* An {@link AuthenticationEntryPoint} that detects HTMX requests and returns a JSON 401 response instead of the
* default 302 redirect to the login page.
*
* <p>When an HTMX-powered page has polling or dynamic fragment requests and the user's session expires, Spring
* Security's default entry point sends a 302 redirect to the login page. HTMX transparently follows the redirect and
* swaps the full login page HTML into each target element, breaking the UI. This entry point intercepts HTMX requests
* (identified by the {@code HX-Request} header) and returns a 401 status with a JSON body and an {@code HX-Redirect}
* header, allowing the HTMX client to handle the session expiry gracefully.</p>
*
* <p>Non-HTMX requests are delegated to the wrapped {@link AuthenticationEntryPoint}, preserving the existing
* redirect behavior for standard browser requests.</p>
*
* @see <a href="https://htmx.org/attributes/hx-request/">HTMX HX-Request Header</a>
* @see <a href="https://htmx.org/headers/hx-redirect/">HTMX HX-Redirect Response Header</a>
*/
@Slf4j
public class HtmxAwareAuthenticationEntryPoint implements AuthenticationEntryPoint {
static final String HX_REQUEST_HEADER = "HX-Request";
static final String HX_REDIRECT_HEADER = "HX-Redirect";
private final AuthenticationEntryPoint delegate;
private final String loginUrl;
/**
* Creates a new HTMX-aware authentication entry point.
*
* @param delegate the entry point to delegate to for non-HTMX requests
* @param loginUrl the login page URL used in the JSON response and HX-Redirect header
*/
public HtmxAwareAuthenticationEntryPoint(AuthenticationEntryPoint delegate, String loginUrl) {
this.delegate = delegate;
this.loginUrl = loginUrl;
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException, ServletException {
if (isHtmxRequest(request)) {
log.debug("HTMX request detected for URI {}; returning 401 with JSON body and HX-Redirect to {}",
request.getRequestURI(), loginUrl);
if (response.isCommitted()) {
log.warn("Response already committed for HTMX request to {}; cannot write 401 response",
request.getRequestURI());
return;
}
// Prepend the servlet context path so deployments with server.servlet.context-path work correctly.
// LoginUrlAuthenticationEntryPoint does the same when building its redirect URL.
String contextPath = request.getContextPath();
String fullLoginUrl = contextPath + loginUrl;
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=UTF-8");
response.setHeader(HX_REDIRECT_HEADER, fullLoginUrl);
String escapedLoginUrl = fullLoginUrl
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t");
response.getWriter().write("{\"error\":\"authentication_required\","
+ "\"message\":\"Session expired. Please log in.\","
+ "\"loginUrl\":\"" + escapedLoginUrl + "\"}");
} else {
delegate.commence(request, response, authException);
}
}
private boolean isHtmxRequest(HttpServletRequest request) {
return "true".equalsIgnoreCase(request.getHeader(HX_REQUEST_HEADER));
}
}