Add pre-authenticated (trusted reverse-proxy) mode to the REST app by arief-hidayat · Pull Request #4256 · flowable/flowable-engine · GitHub
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 @@ -12,9 +12,14 @@
*/
package org.flowable.rest.conf;

import java.util.ArrayList;
import java.util.List;

import org.apache.commons.lang3.StringUtils;
import org.flowable.idm.api.IdmIdentityService;
import org.flowable.rest.app.properties.RestAppProperties;
import org.flowable.rest.security.BasicAuthenticationProvider;
import org.flowable.rest.security.PreAuthenticatedUserDetailsService;
import org.flowable.rest.security.SecurityConstants;
import org.springframework.boot.actuate.info.InfoEndpoint;
import org.springframework.boot.health.actuate.endpoint.HealthEndpoint;
Expand All @@ -28,25 +33,42 @@
import org.springframework.security.config.annotation.web.configurers.CsrfConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider;
import org.springframework.security.web.authentication.preauth.RequestHeaderAuthenticationFilter;
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
import org.springframework.security.web.util.matcher.IpAddressMatcher;

@Configuration(proxyBeanMethods = false)
@EnableWebSecurity
public class SecurityConfiguration {


protected static final String MODE_PRE_AUTH = "pre-auth";
protected static final String MODE_VERIFY_PRIVILEGE = "verify-privilege";

protected final RestAppProperties restAppProperties;

public SecurityConfiguration(RestAppProperties restAppProperties) {
this.restAppProperties = restAppProperties;
}

@Bean
public AuthenticationProvider authenticationProvider() {
public AuthenticationProvider authenticationProvider(IdmIdentityService idmIdentityService) {
if (isPreAuth()) {
// The reverse proxy has already authenticated the caller; this provider only loads
// the user's privileges from IDM. No password is checked.
PreAuthenticatedUserDetailsService userDetailsService = new PreAuthenticatedUserDetailsService(idmIdentityService);
userDetailsService.setVerifyRestApiPrivilege(isVerifyRestApiPrivilege());

PreAuthenticatedAuthenticationProvider provider = new PreAuthenticatedAuthenticationProvider();
provider.setPreAuthenticatedUserDetailsService(userDetailsService);
return provider;
}

BasicAuthenticationProvider basicAuthenticationProvider = new BasicAuthenticationProvider();
basicAuthenticationProvider.setVerifyRestApiPrivilege(isVerifyRestApiPrivilege());
return basicAuthenticationProvider;
}

@Bean
public SecurityFilterChain restApiSecurity(HttpSecurity http, AuthenticationProvider authenticationProvider) throws Exception {
HttpSecurity httpSecurity = http.authenticationProvider(authenticationProvider)
Expand All @@ -67,7 +89,7 @@ public SecurityFilterChain restApiSecurity(HttpSecurity http, AuthenticationProv
httpSecurity
.authorizeHttpRequests(
authorizeRequests -> authorizeRequests.requestMatchers(PathPatternRequestMatcher.withDefaults().matcher("/docs/**")).denyAll());

}

httpSecurity
Expand All @@ -82,25 +104,77 @@ public SecurityFilterChain restApiSecurity(HttpSecurity http, AuthenticationProv
if (isVerifyRestApiPrivilege()) {
httpSecurity
.authorizeHttpRequests(authorizeRequests -> authorizeRequests.anyRequest().hasAuthority(SecurityConstants.PRIVILEGE_ACCESS_REST_API));

} else {
httpSecurity
.authorizeHttpRequests(authorizeRequests -> authorizeRequests.anyRequest().authenticated());
}

httpSecurity.httpBasic(Customizer.withDefaults());
if (isPreAuth()) {
// Identity comes from a header set by a trusted proxy, not HTTP Basic. The filter
// builds a PreAuthenticatedAuthenticationToken from the header, which the
// PreAuthenticatedAuthenticationProvider above resolves against IDM.
RequestHeaderAuthenticationFilter preAuthFilter = trustedProxyAware(
new RequestHeaderAuthenticationFilter());
preAuthFilter.setPrincipalRequestHeader(restAppProperties.getPreAuth().getPrincipalHeader());
// Missing header simply yields an anonymous request that the authorization rules
// above reject with 401/403, rather than a 500.
preAuthFilter.setExceptionIfHeaderMissing(false);
preAuthFilter.setAuthenticationManager(authentication -> authenticationProvider.authenticate(authentication));
httpSecurity.addFilterBefore(preAuthFilter, org.springframework.security.web.authentication.AnonymousAuthenticationFilter.class);
} else {
httpSecurity.httpBasic(Customizer.withDefaults());
}

return http.build();
}

protected boolean isVerifyRestApiPrivilege() {
String authMode = restAppProperties.getAuthenticationMode();
if (StringUtils.isNotEmpty(authMode)) {
return "verify-privilege".equals(authMode);
// 'pre-auth' keeps privilege verification on: identity is trusted, authorization is not.
return MODE_VERIFY_PRIVILEGE.equals(authMode) || MODE_PRE_AUTH.equals(authMode);
}
return true; // checking privilege is the default
}


protected boolean isPreAuth() {
return MODE_PRE_AUTH.equals(restAppProperties.getAuthenticationMode());
}

/**
* Wraps the pre-auth filter so that, when a trusted-proxy allowlist is configured, the
* principal header is only read from requests whose transport peer address matches the
* allowlist. A request from any other source is treated as if it carried no header
* (principal resolves to {@code null}) and is denied by the authorization rules, exactly
* like a missing header. With no allowlist configured the plain filter is returned and
* behaviour is unchanged.
*/
protected RequestHeaderAuthenticationFilter trustedProxyAware(RequestHeaderAuthenticationFilter delegate) {
List<String> trustedProxies = restAppProperties.getPreAuth().getTrustedProxies();
if (trustedProxies == null || trustedProxies.isEmpty()) {
return delegate;
}
List<IpAddressMatcher> matchers = new ArrayList<>(trustedProxies.size());
for (String entry : trustedProxies) {
matchers.add(new IpAddressMatcher(entry));
}
return new RequestHeaderAuthenticationFilter() {

@Override
protected Object getPreAuthenticatedPrincipal(jakarta.servlet.http.HttpServletRequest request) {
String remoteAddr = request.getRemoteAddr();
for (IpAddressMatcher matcher : matchers) {
if (matcher.matches(remoteAddr)) {
return super.getPreAuthenticatedPrincipal(request);
}
}
// Untrusted source: ignore the header entirely.
return null;
}
};
}

protected boolean isSwaggerDocsEnabled() {
return restAppProperties.isSwaggerDocsEnabled();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.flowable.rest.app;

import static org.assertj.core.api.Assertions.assertThat;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

/**
* Complements {@link FlowableRestApplicationPreAuthUntrustedProxyTest}: with an allowlist that
* DOES contain the test client's loopback source, a request carrying a privileged principal
* header is honoured and succeeds. Together the two tests pin both sides of the trusted-proxy
* contract — trusted source honoured, untrusted source ignored.
*
* @author Arief Hidayat
*/
@SpringBootTest(
webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {
"flowable.rest.app.authentication-mode=pre-auth",
"flowable.rest.app.pre-auth.principal-header=X-Forwarded-User",
// Both IPv4 and IPv6 loopback, since the test client may connect over either.
"flowable.rest.app.pre-auth.trusted-proxies=127.0.0.1/32,::1"
}
)
@AutoConfigureTestRestTemplate
public class FlowableRestApplicationPreAuthTrustedProxyTest {

@LocalServerPort
private int serverPort;

@Autowired
private TestRestTemplate restTemplate;

@Test
public void principalHeaderFromTrustedSourceIsHonoured() {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Forwarded-User", "rest-admin");
HttpEntity<?> request = new HttpEntity<>(headers);

String url = "http://localhost:" + serverPort + "/flowable-rest/service/repository/process-definitions";
ResponseEntity<String> entity = restTemplate.exchange(url, HttpMethod.GET, request, String.class);

assertThat(entity.getStatusCode())
.as("principal header from a trusted source address")
.isEqualTo(HttpStatus.OK);
}
}
Loading