From b8639393f1c7b085ee7bc044750d3ba97726df16 Mon Sep 17 00:00:00 2001 From: Austin Lynes Date: Fri, 14 Aug 2026 15:55:35 -0500 Subject: [PATCH] server: trusted-header (reverse-proxy SSO) authentication for the web panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in via CC_TRUSTED_HEADER_AUTH (default false) + CC_TRUSTED_HEADER_NAME (default X-Remote-User): an authenticating reverse proxy that strips client-supplied copies of the header and injects its own can sign users into the web panel without the login form — the standard self-hosted SSO pattern (Authelia, oauth2-proxy, CDN workers). Native clients are untouched: /login, /logout, /clipsocket and /p2psignaling are excluded from the filter and keep username/password + session auth exactly as today. Design notes: - Emits the same UsernamePasswordAuthenticationToken/UserPrincipal pair as form login, so the STOMP principal cast, isAdmin() checks and /whoami behave identically. - Checks isEnabled() only — never isAccountNonLocked(), which feeds the brute-force tracker and would count SSO requests as failed attempts. - Persists the SecurityContext to the session (Spring Security 6 no longer auto-saves), so the user lookup runs once per session. - Unknown/disabled users fall through to the normal login flow. 77 lines: one new filter, two @Value properties (surfaced in the admin panel's properties view via toString), one addFilterBefore. --- .../config/ClipCascadeProperties.java | 31 +++++ .../config/SecurityConfiguration.java | 11 +- .../TrustedHeaderAuthenticationFilter.java | 110 ++++++++++++++++++ 3 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/TrustedHeaderAuthenticationFilter.java diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java index 165eb0484..6842e7cfd 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java @@ -29,6 +29,23 @@ public class ClipCascadeProperties { @Value("${CC_SIGNUP_ENABLED:false}") private boolean signupEnabled; + /* + * Flag to enable trusted-header (reverse-proxy / SSO) authentication for + * the web panel (default: false). When enabled, a request carrying a + * username in the header named by CC_TRUSTED_HEADER_NAME is authenticated + * as that user without the login form. ONLY enable when the server is + * reachable exclusively through a reverse proxy that authenticates users, + * STRIPS any client-supplied copy of the header, and injects its own — + * otherwise a spoofed header is a full authentication bypass. Native + * client paths (/login, /clipsocket, /p2psignaling) are unaffected. + */ + @Value("${CC_TRUSTED_HEADER_AUTH:false}") + private boolean trustedHeaderAuth; + + // Header carrying the SSO username (default: X-Remote-User) + @Value("${CC_TRUSTED_HEADER_NAME:X-Remote-User}") + private String trustedHeaderName; + /* * Maximum number of repeated failed attempts for unique IP addresses allowed * before lockout (default: 15) @@ -315,6 +332,18 @@ public boolean getSignupEnabled() { return signupEnabled; } + public boolean isTrustedHeaderAuth() { + return trustedHeaderAuth; + } + + public boolean getTrustedHeaderAuth() { + return trustedHeaderAuth; + } + + public String getTrustedHeaderName() { + return trustedHeaderName; + } + public int getMaxUniqueIpAttempts() { return maxUniqueIpAttempts; } @@ -466,6 +495,8 @@ public String toString() { ",\n maxMessageSizeInBytes='" + getMaxMessageSizeInBytes() + "'" + ",\n allowedOrigins='" + getAllowedOrigins() + "'" + ",\n signupEnabled='" + isSignupEnabled() + "'" + + ",\n trustedHeaderAuth='" + isTrustedHeaderAuth() + "'" + + ",\n trustedHeaderName='" + getTrustedHeaderName() + "'" + ",\n maxUniqueIpAttempts='" + getMaxUniqueIpAttempts() + "'" + ",\n maxAttemptsPerIp='" + getMaxAttemptsPerIp() + "'" + ",\n lockTimeoutSeconds='" + getLockTimeoutSeconds() + "'" + diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java index cfac30269..fa4dbe11f 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java @@ -12,6 +12,7 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.session.HttpSessionEventPublisher; import com.acme.clipcascade.service.BruteForceProtectionService; import com.acme.clipcascade.service.FacadeUserService; @@ -24,17 +25,20 @@ public class SecurityConfiguration { private final BCryptPasswordEncoder bCryptPasswordEncoder; private final BruteForceProtectionService bruteForceProtectionService; private final FacadeUserService facadeUserService; + private final ClipCascadeProperties clipCascadeProperties; SecurityConfiguration( UserDetailsService userDetailsService, BCryptPasswordEncoder bCryptPasswordEncoder, BruteForceProtectionService bruteForceProtectionService, - FacadeUserService facadeUserService) { + FacadeUserService facadeUserService, + ClipCascadeProperties clipCascadeProperties) { this.userDetailsService = userDetailsService; this.bCryptPasswordEncoder = bCryptPasswordEncoder; this.bruteForceProtectionService = bruteForceProtectionService; this.facadeUserService = facadeUserService; + this.clipCascadeProperties = clipCascadeProperties; } // SessionRegistry bean to store session information @@ -80,6 +84,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .maximumSessions(-1) // Allow unlimited sessions .sessionRegistry(sessionRegistry()) // Use the session registry .expiredSessionStrategy(new CustomExpiredSession())) // Custom expired session strategy + // Reverse-proxy SSO for the web panel — inert unless + // CC_TRUSTED_HEADER_AUTH=true (see TrustedHeaderAuthenticationFilter) + .addFilterBefore( + new TrustedHeaderAuthenticationFilter(clipCascadeProperties, userDetailsService), + UsernamePasswordAuthenticationFilter.class) .build(); } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/TrustedHeaderAuthenticationFilter.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/TrustedHeaderAuthenticationFilter.java new file mode 100644 index 000000000..5aaae23b8 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/TrustedHeaderAuthenticationFilter.java @@ -0,0 +1,110 @@ +package com.acme.clipcascade.config; + +import java.io.IOException; + +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.context.SecurityContextRepository; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.acme.clipcascade.model.UserPrincipal; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/** + * Trusted-header (reverse-proxy / SSO) authentication for the web panel. + * + * When enabled (CC_TRUSTED_HEADER_AUTH, default false), a request carrying + * the username in the header named by CC_TRUSTED_HEADER_NAME (default + * X-Remote-User) is authenticated as that user without the login form. This + * is the standard self-hosted SSO pattern: an authenticating reverse proxy + * (Authelia, oauth2-proxy, a CDN worker, ...) verifies identity, STRIPS any + * client-supplied copy of the header, and injects its own. + * + * ONLY enable this when the server is reachable exclusively through such a + * proxy — with the port exposed directly, a spoofed header is a full + * authentication bypass. + * + * Deliberately conservative: + * - Native-client paths (/login, /logout, /clipsocket, /p2psignaling) are + * never touched; clients keep username/password + session auth unchanged. + * - Emits the same UsernamePasswordAuthenticationToken/UserPrincipal pair as + * form login, so downstream code (STOMP principal, isAdmin() checks, + * /whoami) behaves identically. + * - Checks isEnabled() only — never isAccountNonLocked(), which feeds the + * brute-force tracker and would count SSO requests as failed attempts. + * - Persists the context to the session, so the lookup runs once per + * session, not per request. + * - An unknown or disabled user falls through to the normal login flow. + */ +public class TrustedHeaderAuthenticationFilter extends OncePerRequestFilter { + + private final ClipCascadeProperties clipCascadeProperties; + private final UserDetailsService userDetailsService; + private final SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository(); + + public TrustedHeaderAuthenticationFilter( + ClipCascadeProperties clipCascadeProperties, + UserDetailsService userDetailsService) { + this.clipCascadeProperties = clipCascadeProperties; + this.userDetailsService = userDetailsService; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + if (!clipCascadeProperties.isTrustedHeaderAuth()) { + return true; + } + String path = request.getServletPath(); + return path.equals("/login") + || path.equals("/logout") + || path.equals("/clipsocket") || path.startsWith("/clipsocket/") + || path.equals("/p2psignaling") || path.startsWith("/p2psignaling/"); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + Authentication existing = SecurityContextHolder.getContext().getAuthentication(); + if (existing != null && existing.isAuthenticated() + && !(existing instanceof AnonymousAuthenticationToken)) { + filterChain.doFilter(request, response); // already authenticated (session) + return; + } + + String username = request.getHeader(clipCascadeProperties.getTrustedHeaderName()); + if (username == null || username.isBlank()) { + filterChain.doFilter(request, response); + return; + } + + try { + UserPrincipal principal = (UserPrincipal) userDetailsService + .loadUserByUsername(username.trim()); + if (principal.isEnabled()) { + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + principal, null, principal.getAuthorities()); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + securityContextRepository.saveContext(context, request, response); + } + } catch (UsernameNotFoundException e) { + // unknown user -> fall through to the normal login flow + } + + filterChain.doFilter(request, response); + } +}