feat: rewrite SecurityConfig — single filter chain, Logto OAuth2 Resource Server

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-05 12:35:45 +02:00
parent f89be09e04
commit 396c00749e
2 changed files with 41 additions and 52 deletions

View File

@@ -1,104 +1,98 @@
package net.siegeln.cameleer.saas.config;
import net.siegeln.cameleer.saas.auth.JwtAuthenticationFilter;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtValidators;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.util.ResourceRetriever;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import java.net.URL;
import java.util.Set;
import java.util.ArrayList;
import java.util.List;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final JwtAuthenticationFilter machineTokenFilter;
private final TenantResolutionFilter tenantResolutionFilter;
public SecurityConfig(JwtAuthenticationFilter machineTokenFilter, TenantResolutionFilter tenantResolutionFilter) {
this.machineTokenFilter = machineTokenFilter;
public SecurityConfig(TenantResolutionFilter tenantResolutionFilter) {
this.tenantResolutionFilter = tenantResolutionFilter;
}
@Bean
@Order(1)
public SecurityFilterChain machineAuthFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/agent/**", "/api/license/verify/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.addFilterBefore(machineTokenFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/auth/verify").permitAll()
.requestMatchers("/api/config").permitAll()
.requestMatchers("/", "/index.html", "/login", "/callback", "/environments/**", "/license", "/admin/**").permitAll()
.requestMatchers("/", "/index.html", "/login", "/callback",
"/environments/**", "/license", "/admin/**").permitAll()
.requestMatchers("/assets/**", "/favicon.ico").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> {}))
.addFilterBefore(machineTokenFilter, UsernamePasswordAuthenticationFilter.class)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
.addFilterAfter(tenantResolutionFilter, BearerTokenAuthenticationFilter.class);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
List<GrantedAuthority> authorities = new ArrayList<>();
var roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r)));
}
var orgRoles = jwt.getClaimAsStringList("organization_roles");
if (orgRoles != null) {
orgRoles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_org_" + r)));
}
return authorities;
});
return converter;
}
@Bean
public JwtDecoder jwtDecoder(
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri,
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") String issuerUri) throws Exception {
// Logto issues tokens with typ "at+jwt" (RFC 9068). Spring Security's default
// decoder only allows typ "JWT". Build a custom processor that accepts both.
var jwkSource = JWKSourceBuilder.create(new URL(jwkSetUri)).build();
var keySelector = new JWSVerificationKeySelector<SecurityContext>(
JWSAlgorithm.ES384, jwkSource);
var jwtProcessor = new DefaultJWTProcessor<SecurityContext>();
jwtProcessor.setJWSKeySelector(keySelector);
// Allow both "JWT" and "at+jwt" token types
jwtProcessor.setJWSTypeVerifier((type, context) -> { /* accept any type */ });
var processor = new DefaultJWTProcessor<SecurityContext>();
processor.setJWSKeySelector(keySelector);
processor.setJWSTypeVerifier((type, context) -> { /* accept JWT and at+jwt */ });
var decoder = new NimbusJwtDecoder(jwtProcessor);
var decoder = new NimbusJwtDecoder(processor);
if (issuerUri != null && !issuerUri.isEmpty()) {
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer(issuerUri));
}
return decoder;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -29,10 +29,6 @@ management:
show-details: when-authorized
cameleer:
jwt:
expiration: 86400 # 24 hours in seconds
private-key-path: ${CAMELEER_JWT_PRIVATE_KEY_PATH:}
public-key-path: ${CAMELEER_JWT_PUBLIC_KEY_PATH:}
identity:
logto-endpoint: ${LOGTO_ENDPOINT:}
logto-public-endpoint: ${LOGTO_PUBLIC_ENDPOINT:}
@@ -49,7 +45,6 @@ cameleer:
deployment-thread-pool-size: 4
container-memory-limit: ${CAMELEER_CONTAINER_MEMORY_LIMIT:512m}
container-cpu-shares: ${CAMELEER_CONTAINER_CPU_SHARES:512}
bootstrap-token: ${CAMELEER_AUTH_TOKEN:}
cameleer3-server-endpoint: ${CAMELEER3_SERVER_ENDPOINT:http://cameleer3-server:8081}
domain: ${DOMAIN:localhost}
clickhouse: