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 @@ -23,6 +23,7 @@
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.core.env.EnumerablePropertySource;
import org.springframework.core.env.PropertySource;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileSystemResource;
Expand Down Expand Up @@ -135,7 +136,12 @@ public PropertySource<?> dynamicYamlPropertySource(ConfigurableEnvironment envir
environment.getPropertySources().addFirst(propertySource);
}

log.debug("Loaded properties: {}", propertySource.getSource());
// Names only: values include secrets such as security.scimToken.
log.debug(
"Loaded property keys: {}",
propertySource instanceof EnumerablePropertySource<?> keys
? Arrays.toString(keys.getPropertyNames())
: propertySource.getName());

return propertySource;
}
Expand Down Expand Up @@ -975,6 +981,10 @@ public static class Security {
private long loginResetTimeMinutes;
private String loginMethod = "all";
private String customGlobalAPIKey;
// Bearer token (32+ chars) for SCIM 2.0 provisioning at /api/v1/scim/v2; blank disables it.
@ToString.Exclude private String scimToken;
// SCIM DELETE erases the user and their files; when false it only deactivates them.
private boolean scimDeleteRemovesUser = false;
private Jwt jwt = new Jwt();
private Validation validation = new Validation();
private Timestamp timestamp = new Timestamp();
Expand Down
2 changes: 2 additions & 0 deletions app/core/src/main/resources/settings.yml.template
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ security:
ssoAutoLogin: false
loginAttemptCount: 5 # lock user account after 5 tries; when using e.g. Fail2Ban you can deactivate the function with -1
loginResetTimeMinutes: 120 # lock account for 2 hours after x attempts
scimToken: "" # Bearer token (at least 32 characters) for SCIM 2.0 user provisioning at /api/v1/scim/v2 (Okta, Entra ID). Leave empty to disable
scimDeleteRemovesUser: false # When true, a SCIM DELETE permanently erases the user and their files; when false it only deactivates them
loginMethod: all # Accepts values like 'all' and 'normal'(only Login with Username/Password), 'oauth2'(only Login with OAuth2) or 'saml2'(only Login with SAML2)
initialLogin:
username: "" # initial username for the first login
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationFailureHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticationSuccessHandler;
import stirling.software.proprietary.security.saml2.CustomSaml2ResponseAuthenticationConverter;
import stirling.software.proprietary.security.scim.ScimTokenFilter;
import stirling.software.proprietary.security.service.ApiKeyAuthenticationService;
import stirling.software.proprietary.security.service.CustomOAuth2UserService;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
Expand Down Expand Up @@ -301,6 +302,9 @@ private SecurityFilterChain configureSecurity(
// OncePerRequestFilter with proper config-driven limits.
// .addFilterBefore(rateLimitingFilter,
// UsernamePasswordAuthenticationFilter.class)
// Same order as the JWT filter; registered first so SCIM's bearer token wins.
.addFilterBefore(
new ScimTokenFilter(securityProperties), UserAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UserAuthenticationFilter.class);

http.sessionManagement(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package stirling.software.proprietary.security.scim;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.List;
import java.util.Locale;

import org.springframework.http.HttpHeaders;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken;
import org.springframework.web.filter.OncePerRequestFilter;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import lombok.extern.slf4j.Slf4j;

import stirling.software.common.model.ApplicationProperties;

/** Authenticates SCIM requests with the configured bearer token and nothing else. */
@Slf4j
public class ScimTokenFilter extends OncePerRequestFilter {

public static final String AUTHORITY = "ROLE_SCIM";
static final int MIN_TOKEN_LENGTH = 32;

// Not a String or UserDetails, so later filters don't look it up as a user account.
public enum ScimClient {
SCIM;

@Override
public String toString() {
return "scim-provisioning";
}
}

private final ApplicationProperties.Security security;

public ScimTokenFilter(ApplicationProperties.Security security) {
this.security = security;
String token = security.getScimToken();
if (token != null && !token.isBlank() && token.length() < MIN_TOKEN_LENGTH) {
log.warn("security.scimToken is under {} characters; SCIM stays off", MIN_TOKEN_LENGTH);
}
}

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
// Servlet path is already decoded and normalised by the container.
return !request.getServletPath().toLowerCase(Locale.ROOT).startsWith("/api/v1/scim/");
}

@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String token = security.getScimToken();
String header = request.getHeader(HttpHeaders.AUTHORIZATION);
if (token == null
|| token.length() < MIN_TOKEN_LENGTH
|| header == null
|| !MessageDigest.isEqual(
("Bearer " + token).getBytes(StandardCharsets.UTF_8),
header.getBytes(StandardCharsets.UTF_8))) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/scim+json");
response.getWriter()
.write(
"{\"schemas\":[\"urn:ietf:params:scim:api:messages:2.0:Error\"],"
+ "\"status\":\"401\",\"detail\":\"Invalid SCIM token\"}");
return;
}
SecurityContextHolder.getContext()
.setAuthentication(
new PreAuthenticatedAuthenticationToken(
ScimClient.SCIM,
null,
List.of(new SimpleGrantedAuthority(AUTHORITY))));
chain.doFilter(request, response);
}
}
Loading
Loading