Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,13 +134,13 @@ Spring Boot 4.x brings significant changes including Spring Security 7 and requi
<dependency>
<groupId>com.digitalsanctuary</groupId>
<artifactId>ds-spring-user-framework</artifactId>
<version>5.1.0</version>
<version>5.1.1</version>
</dependency>
```

**Gradle:**
```groovy
implementation 'com.digitalsanctuary:ds-spring-user-framework:5.1.0'
implementation 'com.digitalsanctuary:ds-spring-user-framework:5.1.1'
```

#### Spring Boot 4.x Key Changes
Expand Down Expand Up @@ -213,7 +213,7 @@ Follow these steps to get up and running with the Spring User Framework in your

**Spring Boot 4.0 / 4.1 (Java 21+):**
```groovy
implementation 'com.digitalsanctuary:ds-spring-user-framework:5.1.0'
implementation 'com.digitalsanctuary:ds-spring-user-framework:5.1.1'
```

**Spring Boot 3.5 (Java 17+):**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.digitalsanctuary.spring.user.security;

import java.util.List;
import java.util.Set;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
Expand All @@ -15,10 +16,19 @@
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.session.SessionRegistry;
import org.springframework.security.core.session.SessionRegistryImpl;
import org.springframework.http.MediaType;
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.savedrequest.HttpSessionRequestCache;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.session.HttpSessionEventPublisher;
import org.springframework.security.web.util.matcher.AndRequestMatcher;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import org.springframework.security.web.util.matcher.NegatedRequestMatcher;
import org.springframework.security.web.util.matcher.RequestHeaderRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import jakarta.servlet.http.HttpServletRequest;
import com.digitalsanctuary.spring.user.UserConfiguration;
import com.digitalsanctuary.spring.user.roles.RolesAndPrivilegesConfig;
import com.digitalsanctuary.spring.user.util.AppUrlResolver;
Expand All @@ -27,7 +37,8 @@

/**
* Auto-configuration that contributes the library's core, consumer-overridable security beans:
* {@link PasswordEncoder}, {@link SessionRegistry}, {@link RoleHierarchy}, and {@link DaoAuthenticationProvider}.
* {@link PasswordEncoder}, {@link SessionRegistry}, {@link RoleHierarchy}, {@link DaoAuthenticationProvider}, and the hardened
* {@link org.springframework.security.web.savedrequest.RequestCache}.
*
* <p>
* Each bean is guarded by {@link ConditionalOnMissingBean}, so a consuming application can fully replace any of them simply by defining their own bean
Expand Down Expand Up @@ -169,6 +180,87 @@ public AuthenticationEventPublisher authenticationEventPublisher(ApplicationEven
return new DefaultAuthenticationEventPublisher(applicationEventPublisher);
}

/**
* File extensions that identify static-asset fetches. Requests for these are never worth replaying after login, and browsers frequently fetch
* them automatically (icons, manifests, fonts) while the login page itself is rendering &mdash; which would otherwise overwrite the user's real
* saved destination.
*/
private static final Set<String> STATIC_ASSET_EXTENSIONS = Set.of("png", "jpg", "jpeg", "gif", "ico", "svg", "webp", "avif", "css", "js", "mjs",
"map", "woff", "woff2", "ttf", "otf", "eot", "webmanifest", "json", "xml");

/**
* Root-level paths that browsers and crawlers probe automatically without any markup referencing them. Safari/iOS in particular requests
* {@code /apple-touch-icon.png} and {@code /apple-touch-icon-precomposed.png} on every page load.
*/
private static final Set<String> AUTO_PROBED_PATH_PREFIXES = Set.of("/apple-touch-icon", "/favicon", "/.well-known/");

/**
* Creates the library's default {@link RequestCache}: an {@link HttpSessionRequestCache} that only saves requests that plausibly represent a
* user navigation worth returning to after login &mdash; {@code GET} requests that explicitly accept {@code text/html} and are not XHR
* ({@code X-Requested-With: XMLHttpRequest}), HTMX ({@code HX-Request}) or static-asset/auto-probed requests (favicons, apple-touch icons,
* {@code /.well-known/**}, common static file extensions).
*
* <p>
* Why this matters: Spring Security's default request cache saves <em>any</em> request that triggers authentication. Browsers automatically
* probe protected-by-default URLs such as {@code /apple-touch-icon.png} while the login page renders, overwriting the saved deep link the user
* actually clicked &mdash; so the post-login redirect lands on {@code /apple-touch-icon.png?continue} (typically a 404/error page) instead of
* the user's destination. The hardened matcher makes the saved request survive those probes.
* </p>
*
* <p>
* Backs off entirely if the consuming application defines its own {@link RequestCache} bean, which is also the hook for consumers who want
* Spring Security's default behavior back ({@code new HttpSessionRequestCache()}).
* </p>
*
* @return the hardened {@link HttpSessionRequestCache}
*/
@Bean
@ConditionalOnMissingBean(RequestCache.class)
public RequestCache requestCache() {
MediaTypeRequestMatcher acceptsHtml = new MediaTypeRequestMatcher(MediaType.TEXT_HTML);
// Treat "Accept: */*" as NOT an HTML navigation: real browser navigations always list text/html explicitly,
// while auto-probes (touch icons, prefetchers) and API clients typically send */* or a concrete non-HTML type.
acceptsHtml.setIgnoredMediaTypes(Set.of(MediaType.ALL));

RequestMatcher isGet = request -> "GET".equalsIgnoreCase(request.getMethod());
RequestMatcher isStaticAssetOrProbe = UserSecurityBeansAutoConfiguration::isStaticAssetOrAutoProbe;

RequestMatcher savableNavigation = new AndRequestMatcher(isGet, acceptsHtml,
new NegatedRequestMatcher(new RequestHeaderRequestMatcher("X-Requested-With", "XMLHttpRequest")),
new NegatedRequestMatcher(new RequestHeaderRequestMatcher("HX-Request")), new NegatedRequestMatcher(isStaticAssetOrProbe));

HttpSessionRequestCache requestCache = new HttpSessionRequestCache();
requestCache.setRequestMatcher(savableNavigation);
return requestCache;
}

/**
* Returns true when the request targets a static asset or a path that browsers/crawlers probe automatically (and which should therefore never
* become a post-login redirect target).
*
* @param request the request to classify
* @return true when the request is a static-asset fetch or well-known auto-probe
*/
private static boolean isStaticAssetOrAutoProbe(HttpServletRequest request) {
// getRequestURI() excludes the query string but is URL-encoded and unnormalized. That is acceptable here: this
// is a fail-open save-side heuristic (misclassification at worst saves an odd redirect target), never an
// authorization decision, so an encoded dot (%2E) slipping past extension detection has no security impact.
String path = request.getRequestURI().substring(request.getContextPath().length()).toLowerCase();
for (String prefix : AUTO_PROBED_PATH_PREFIXES) {
if (path.startsWith(prefix)) {
return true;
}
}
int lastDot = path.lastIndexOf('.');
int lastSlash = path.lastIndexOf('/');
if (lastDot > lastSlash) {
String extension = path.substring(lastDot + 1);
// .html/.htm are real pages, everything else in the static set is an asset fetch
return STATIC_ASSET_EXTENSIONS.contains(extension);
}
return false;
}

/**
* Creates the library's default {@link AppUrlResolver}, which builds the base URL for security-sensitive email links (password reset, email
* verification) from the configured canonical URL ({@code user.security.appUrl}) and/or the trusted-host allow-list
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.DelegatingMissingAuthorityAccessDeniedHandler;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.webauthn.authentication.WebAuthnAuthenticationFilter;
import com.digitalsanctuary.spring.user.service.DSOAuth2UserService;
import com.digitalsanctuary.spring.user.service.DSOidcUserService;
Expand Down Expand Up @@ -116,6 +117,7 @@ public class WebSecurityConfig {
private final MfaConfigProperties mfaConfigProperties;
private final Environment environment;
private final ApplicationEventPublisher applicationEventPublisher;
private final RequestCache requestCache;

/**
* Builds the library's security filter chain for Spring Security.
Expand Down Expand Up @@ -143,6 +145,12 @@ public SecurityFilterChain buildSecurityFilterChain(HttpSecurity http, SessionRe
// Always configure exception handling with the injected entry point (HTMX-aware by default)
http.exceptionHandling(handling -> handling.authenticationEntryPoint(authenticationEntryPoint));

// Use the hardened RequestCache (see UserSecurityBeansAutoConfiguration.requestCache()) so automatic browser
// probes of protected URLs (e.g. Safari fetching /apple-touch-icon.png while the login page renders) cannot
// overwrite the user's saved deep link and hijack the post-login redirect. Consumer-overridable via a
// RequestCache bean.
http.requestCache(cache -> cache.requestCache(requestCache));

// 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));
Expand Down Expand Up @@ -303,6 +311,24 @@ private List<String> getUnprotectedURIsList() {
// Add the required user pages and actions to the unprotected URIs from configuration
List<String> unprotectedURIs = new ArrayList<String>();
unprotectedURIs.addAll(Arrays.asList(getUnprotectedURIsArray()));
// Auto-unprotect the always-public paths that browsers and crawlers probe automatically without any markup
// referencing them. Because this widens the permitAll surface for EVERY consumer (who never opted in), the
// patterns are deliberately scoped to exactly the auto-probed paths, not a broad prefix:
// - /apple-touch-icon*.png : Safari/iOS probes /apple-touch-icon.png, -precomposed, and sized variants
// (e.g. -120x120, -120x120-precomposed) on every page load; every one ends in .png. The .png anchor keeps a
// route like /apple-touch-icon-admin protected.
// - /favicon.* : the browser auto-probe is /favicon.ico (the .EXT anchor also covers /favicon.svg|.png root
// probes while keeping a route like /favicon-report protected). Sized favicons (/favicon-32x32.png) are
// <link>-referenced assets, not auto-probes, and are served from the app's static path.
// - /.well-known/** : RFC 8615 well-known URIs (security.txt, ACME challenges, passkey/associated-domain
// files) are public by design.
// These are never sensitive, and leaving them protected means every probe 302s to the login page. The intent
// mirrors the exclusion set in the hardened RequestCache (see UserSecurityBeansAutoConfiguration.requestCache()),
// though that save-side heuristic may be broader since misclassifying there merely fails open (a request is not
// saved) rather than granting access. Consumers no longer need to remember to list these manually.
unprotectedURIs.add("/apple-touch-icon*.png");
unprotectedURIs.add("/favicon.*");
unprotectedURIs.add("/.well-known/**");
unprotectedURIs.add(loginPageURI);
unprotectedURIs.add(loginActionURI);
unprotectedURIs.add(logoutSuccessURI);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.stereotype.Service;
import org.thymeleaf.util.StringUtils;
import com.digitalsanctuary.spring.user.audit.AuditEvent;
Expand All @@ -13,7 +14,6 @@
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

/**
Expand All @@ -28,13 +28,32 @@
* @see SavedRequestAwareAuthenticationSuccessHandler
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class LoginSuccessService extends SavedRequestAwareAuthenticationSuccessHandler {

/** The event publisher. */
private final ApplicationEventPublisher eventPublisher;

/**
* Constructs the login success handler and wires in the application's effective {@link RequestCache}.
*
* <p>
* The injected {@code requestCache} is passed to {@link #setRequestCache(RequestCache)} so this handler <em>reads</em> the saved request from the
* exact same cache the security filter chain <em>writes</em> to. Both sides therefore honor a consumer-supplied {@link RequestCache} bean (see
* {@code UserSecurityBeansAutoConfiguration.requestCache()}); without this, {@link SavedRequestAwareAuthenticationSuccessHandler} would fall back to
* its own default {@link org.springframework.security.web.savedrequest.HttpSessionRequestCache} and a consumer who overrode the cache with a
* different implementation (or a different session attribute) would get their saved request written to one store and read from another, silently
* breaking the post-login redirect to the originally-requested page.
* </p>
*
* @param eventPublisher the application event publisher used to emit login audit events
* @param requestCache the effective {@link RequestCache} bean (the library's hardened default, or a consumer override)
*/
public LoginSuccessService(ApplicationEventPublisher eventPublisher, RequestCache requestCache) {
this.eventPublisher = eventPublisher;
super.setRequestCache(requestCache);
}

/** The login success uri. */
@Value("${user.security.loginSuccessURI}")
private String loginSuccessUri;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -799,8 +799,16 @@ void detachAppender() {
}

private long disabledWarnings() {
return appender.list.stream()
// UserAPI's logger is a JVM-global singleton, and this suite runs tests in parallel (see
// junit-platform.properties). warnIfInitialPasswordSetDisabled() is a @PostConstruct hook, so every other
// test that boots a Spring context (flag false, no StepUpService) fires this same WARN on its own thread
// into this shared appender. Count only warnings emitted on the current test thread — this test invokes
// the method synchronously — so a concurrently-booting context cannot pollute the count. Snapshot the list
// first (List.copyOf) to avoid a ConcurrentModificationException from a concurrent append during iteration.
String testThread = Thread.currentThread().getName();
return List.copyOf(appender.list).stream()
.filter(event -> event.getLevel() == Level.WARN)
.filter(event -> testThread.equals(event.getThreadName()))
.filter(event -> event.getFormattedMessage().contains("/user/setPassword is disabled by default"))
.count();
}
Expand Down
Loading
Loading