-
Notifications
You must be signed in to change notification settings - Fork 43
Add HTMX-aware AuthenticationEntryPoint for session expiry handling #295
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9d1359b
15e5af8
5749a79
7d77c10
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,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)); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,59 @@ | ||||||
| package com.digitalsanctuary.spring.user.security; | ||||||
|
|
||||||
| import org.springframework.beans.factory.annotation.Value; | ||||||
| import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; | ||||||
| import org.springframework.context.annotation.Bean; | ||||||
| import org.springframework.context.annotation.Configuration; | ||||||
| import org.springframework.context.annotation.Primary; | ||||||
| import org.springframework.security.web.AuthenticationEntryPoint; | ||||||
| import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; | ||||||
| import lombok.extern.slf4j.Slf4j; | ||||||
|
|
||||||
| /** | ||||||
| * Auto-configuration for the {@link AuthenticationEntryPoint}. | ||||||
| * | ||||||
| * <p>Registers an {@link HtmxAwareAuthenticationEntryPoint} that wraps the appropriate inner entry point | ||||||
| * (form-login or OAuth2) when no custom {@link AuthenticationEntryPoint} bean is defined by the consuming | ||||||
| * application.</p> | ||||||
| * | ||||||
| * <p>Consuming applications can override this by defining their own {@link AuthenticationEntryPoint} bean:</p> | ||||||
| * <pre>{@code | ||||||
| * @Bean | ||||||
| * public AuthenticationEntryPoint myCustomEntryPoint() { | ||||||
| * return new MyCustomAuthenticationEntryPoint(); | ||||||
| * } | ||||||
| * }</pre> | ||||||
| */ | ||||||
| @Slf4j | ||||||
| @Configuration | ||||||
| public class HtmxAwareAuthenticationEntryPointConfiguration { | ||||||
|
|
||||||
| @Value("${user.security.loginPageURI}") | ||||||
| private String loginPageURI; | ||||||
|
|
||||||
| @Value("${spring.security.oauth2.enabled:false}") | ||||||
| private boolean oauth2Enabled; | ||||||
|
|
||||||
| /** | ||||||
| * Creates the default {@link AuthenticationEntryPoint} bean. This bean is only registered when no custom | ||||||
| * {@link AuthenticationEntryPoint} bean is provided by the consuming application. | ||||||
| * | ||||||
| * @return an {@link HtmxAwareAuthenticationEntryPoint} wrapping the appropriate inner entry point | ||||||
| */ | ||||||
| @Bean | ||||||
| @Primary | ||||||
| @ConditionalOnMissingBean(AuthenticationEntryPoint.class) | ||||||
|
||||||
| @ConditionalOnMissingBean(AuthenticationEntryPoint.class) | |
| @ConditionalOnMissingBean(name = "authenticationEntryPoint") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ | |
| import org.springframework.security.core.userdetails.UserDetailsService; | ||
| import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; | ||
| import org.springframework.security.crypto.password.PasswordEncoder; | ||
| import org.springframework.security.web.AuthenticationEntryPoint; | ||
| import org.springframework.security.web.SecurityFilterChain; | ||
| import org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler; | ||
| import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; | ||
|
|
@@ -122,6 +123,7 @@ public class WebSecurityConfig { | |
| @Value("${user.dev.auto-login-enabled:false}") | ||
| private boolean devAutoLoginEnabled; | ||
|
|
||
| private final AuthenticationEntryPoint authenticationEntryPoint; | ||
| private final UserDetailsService userDetailsService; | ||
| private final LoginSuccessService loginSuccessService; | ||
| private final LogoutSuccessService logoutSuccessService; | ||
|
Comment on lines
+126
to
129
|
||
|
|
@@ -150,6 +152,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti | |
| http.formLogin( | ||
| formLogin -> formLogin.loginPage(loginPageURI).loginProcessingUrl(loginActionURI).successHandler(loginSuccessService).permitAll()); | ||
|
|
||
| // Always configure exception handling with the injected entry point (HTMX-aware by default) | ||
| http.exceptionHandling(handling -> handling.authenticationEntryPoint(authenticationEntryPoint)); | ||
|
|
||
| // Configure remember-me only if explicitly enabled and key is provided | ||
| if (rememberMeEnabled && rememberMeKey != null && !rememberMeKey.trim().isEmpty()) { | ||
| http.rememberMe(rememberMe -> rememberMe.key(rememberMeKey).userDetailsService(userDetailsService)); | ||
|
|
@@ -211,10 +216,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti | |
| * @throws Exception the exception | ||
| */ | ||
| private void setupOAuth2(HttpSecurity http) throws Exception { | ||
| CustomOAuth2AuthenticationEntryPoint loginAuthenticationEntryPoint = new CustomOAuth2AuthenticationEntryPoint(null, loginPageURI); | ||
|
|
||
| http.exceptionHandling(handling -> handling.authenticationEntryPoint(loginAuthenticationEntryPoint)) | ||
| .oauth2Login(o -> o.loginPage(loginPageURI).successHandler(loginSuccessService).failureHandler((request, response, exception) -> { | ||
| // Entry point is handled globally in securityFilterChain via the injected authenticationEntryPoint bean | ||
| http.oauth2Login(o -> o.loginPage(loginPageURI).successHandler(loginSuccessService).failureHandler((request, response, exception) -> { | ||
| log.error("WebSecurityConfig.configure: OAuth2 login failure: {}", exception.getMessage()); | ||
| request.getSession().setAttribute("error.message", exception.getMessage()); | ||
| response.sendRedirect(loginPageURI); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| package com.digitalsanctuary.spring.user.security; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
|
|
||
| import org.junit.jupiter.api.DisplayName; | ||
| import org.junit.jupiter.api.Nested; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.springframework.boot.autoconfigure.AutoConfigurations; | ||
| import org.springframework.boot.test.context.runner.ApplicationContextRunner; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.security.web.AuthenticationEntryPoint; | ||
| import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; | ||
|
|
||
| @DisplayName("HtmxAwareAuthenticationEntryPointConfiguration Tests") | ||
| class HtmxAwareAuthenticationEntryPointConfigurationTest { | ||
|
|
||
| // Register as auto-configuration so it is processed after user-defined beans, | ||
| // which is required for @ConditionalOnMissingBean to evaluate correctly. | ||
| private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() | ||
| .withConfiguration(AutoConfigurations.of(HtmxAwareAuthenticationEntryPointConfiguration.class)) | ||
| .withPropertyValues( | ||
| "user.security.loginPageURI=/user/login.html" | ||
| ); | ||
|
|
||
| @Nested | ||
| @DisplayName("Non-OAuth2 Configuration") | ||
| class NonOAuth2Configuration { | ||
|
|
||
| @Test | ||
| @DisplayName("Should register HtmxAwareAuthenticationEntryPoint wrapping LoginUrlAuthenticationEntryPoint when OAuth2 disabled") | ||
| void shouldRegisterHtmxEntryPointWrappingLoginUrlWhenOAuth2Disabled() { | ||
| contextRunner | ||
| .withPropertyValues("spring.security.oauth2.enabled=false") | ||
| .run(context -> { | ||
| assertThat(context).hasSingleBean(AuthenticationEntryPoint.class); | ||
| assertThat(context.getBean(AuthenticationEntryPoint.class)) | ||
| .isInstanceOf(HtmxAwareAuthenticationEntryPoint.class); | ||
| }); | ||
| } | ||
|
|
||
| @Test | ||
| @DisplayName("Should register HtmxAwareAuthenticationEntryPoint when OAuth2 property absent") | ||
| void shouldRegisterHtmxEntryPointWhenOAuth2PropertyAbsent() { | ||
| contextRunner.run(context -> { | ||
| assertThat(context).hasSingleBean(AuthenticationEntryPoint.class); | ||
| assertThat(context.getBean(AuthenticationEntryPoint.class)) | ||
| .isInstanceOf(HtmxAwareAuthenticationEntryPoint.class); | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| @Nested | ||
| @DisplayName("OAuth2 Configuration") | ||
| class OAuth2Configuration { | ||
|
|
||
| @Test | ||
| @DisplayName("Should register HtmxAwareAuthenticationEntryPoint when OAuth2 enabled") | ||
| void shouldRegisterHtmxEntryPointWhenOAuth2Enabled() { | ||
| contextRunner | ||
| .withPropertyValues("spring.security.oauth2.enabled=true") | ||
| .run(context -> { | ||
| assertThat(context).hasSingleBean(AuthenticationEntryPoint.class); | ||
| assertThat(context.getBean(AuthenticationEntryPoint.class)) | ||
| .isInstanceOf(HtmxAwareAuthenticationEntryPoint.class); | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| @Nested | ||
| @DisplayName("Consumer Override via @ConditionalOnMissingBean") | ||
| class ConsumerOverride { | ||
|
|
||
| @Test | ||
| @DisplayName("Should not register library bean when consumer provides an AuthenticationEntryPoint") | ||
| void shouldNotRegisterLibraryBeanWhenConsumerProvidesEntryPoint() { | ||
| contextRunner | ||
| .withUserConfiguration(ConsumerEntryPointConfiguration.class) | ||
| .run(context -> { | ||
| assertThat(context).hasSingleBean(AuthenticationEntryPoint.class); | ||
| assertThat(context.getBean(AuthenticationEntryPoint.class)) | ||
| .isInstanceOf(LoginUrlAuthenticationEntryPoint.class); | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| @Configuration | ||
| static class ConsumerEntryPointConfiguration { | ||
| @Bean | ||
| public AuthenticationEntryPoint consumerEntryPoint() { | ||
| return new LoginUrlAuthenticationEntryPoint("/custom/login"); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The HTMX branch builds JSON manually and only partially escapes
loginUrl(quotes/backslashes). This can still produce invalid JSON for other characters (e.g., newlines) and is easy to regress. Prefer serializing a small DTO/Map with the project’s JacksonObjectMapper(used elsewhere for safe JSON escaping) and set an explicit character encoding (e.g., UTF-8) before writing.