Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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() + "'" +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}