diff --git a/modules/flowable-app-rest/src/main/java/org/flowable/rest/app/properties/RestAppProperties.java b/modules/flowable-app-rest/src/main/java/org/flowable/rest/app/properties/RestAppProperties.java index 2a89dfa4299..5888965618a 100644 --- a/modules/flowable-app-rest/src/main/java/org/flowable/rest/app/properties/RestAppProperties.java +++ b/modules/flowable-app-rest/src/main/java/org/flowable/rest/app/properties/RestAppProperties.java @@ -12,7 +12,9 @@ */ package org.flowable.rest.app.properties; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.Set; import org.springframework.boot.context.properties.ConfigurationProperties; @@ -31,6 +33,10 @@ public class RestAppProperties { * Configures the way user credentials are verified when doing a REST API call: * 'any-user' : the user needs to exist and the password need to match. Any user is allowed to do the call (this is the pre 6.3.0 behavior) * 'verify-privilege' : the user needs to exist, the password needs to match and the user needs to have the 'rest-api' privilege + * 'pre-auth' : the request is trusted to have been authenticated by a reverse proxy in front of the app, and the user id is + * read from a request header (see {@link PreAuth}) instead of HTTP Basic. The password is not checked; privileges + * are still loaded from the IDM engine so authorization behaves as with 'verify-privilege'. Only use this when the + * app cannot be reached except through a trusted proxy that strips any client-supplied copy of the header. * If nothing set, defaults to 'verify-privilege' */ private String authenticationMode = "verify-privilege"; @@ -51,6 +57,9 @@ public class RestAppProperties { @NestedConfigurationProperty private final Admin admin = new Admin(); + @NestedConfigurationProperty + private final PreAuth preAuth = new PreAuth(); + /** * The default role prefix that needs to be used by Spring Security. */ @@ -88,6 +97,10 @@ public Admin getAdmin() { return admin; } + public PreAuth getPreAuth() { + return preAuth; + } + public String getRolePrefix() { return rolePrefix; } @@ -139,6 +152,53 @@ public void setLastName(String lastName) { } } + /** + * Settings for the 'pre-auth' authentication mode, where a trusted reverse proxy has already + * authenticated the caller and passes the user id in a request header. + */ + public static class PreAuth { + + /** + * The request header that carries the already-authenticated user id. Defaults to + * {@code X-Forwarded-User}, which is what most authenticating reverse proxies emit + * (oauth2-proxy, and Databricks Apps also forwards {@code X-Forwarded-Email} / + * {@code X-Forwarded-Preferred-Username}). + */ + private String principalHeader = "X-Forwarded-User"; + + /** + * Optional defence-in-depth allowlist of trusted proxy source addresses, as IPs or CIDR + * ranges (e.g. {@code 10.0.0.0/8}, {@code 192.168.1.5}). Empty by default, which keeps + * the behaviour of trusting the principal header on every request. + * + *

When set, the principal header is only honoured if the request's transport + * peer ({@code ServletRequest#getRemoteAddr()}, not an {@code X-Forwarded-For} + * value) matches one of these entries; otherwise the request is treated as if it carried + * no header and is denied. This binds the trusted identity to the proxy it came from + * rather than to the header alone, so a single misconfiguration (the app becoming + * reachable off the proxy, or a proxy that forwards an inbound {@code X-Forwarded-*} + * header) cannot be exploited from an arbitrary source. It complements, and does not + * replace, the requirement that the proxy strip client-supplied copies of the header. + */ + private List trustedProxies = new ArrayList<>(); + + public String getPrincipalHeader() { + return principalHeader; + } + + public void setPrincipalHeader(String principalHeader) { + this.principalHeader = principalHeader; + } + + public List getTrustedProxies() { + return trustedProxies; + } + + public void setTrustedProxies(List trustedProxies) { + this.trustedProxies = trustedProxies; + } + } + public static class Cors { /** * Enable/disable CORS filter. diff --git a/modules/flowable-app-rest/src/main/java/org/flowable/rest/conf/SecurityConfiguration.java b/modules/flowable-app-rest/src/main/java/org/flowable/rest/conf/SecurityConfiguration.java index f1792a9e8d1..d6f529f3947 100644 --- a/modules/flowable-app-rest/src/main/java/org/flowable/rest/conf/SecurityConfiguration.java +++ b/modules/flowable-app-rest/src/main/java/org/flowable/rest/conf/SecurityConfiguration.java @@ -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; @@ -28,12 +33,18 @@ 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) { @@ -41,12 +52,23 @@ public SecurityConfiguration(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) @@ -67,7 +89,7 @@ public SecurityFilterChain restApiSecurity(HttpSecurity http, AuthenticationProv httpSecurity .authorizeHttpRequests( authorizeRequests -> authorizeRequests.requestMatchers(PathPatternRequestMatcher.withDefaults().matcher("/docs/**")).denyAll()); - + } httpSecurity @@ -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 trustedProxies = restAppProperties.getPreAuth().getTrustedProxies(); + if (trustedProxies == null || trustedProxies.isEmpty()) { + return delegate; + } + List 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(); } diff --git a/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthTrustedProxyTest.java b/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthTrustedProxyTest.java new file mode 100644 index 00000000000..1c206c5fb2c --- /dev/null +++ b/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthTrustedProxyTest.java @@ -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 entity = restTemplate.exchange(url, HttpMethod.GET, request, String.class); + + assertThat(entity.getStatusCode()) + .as("principal header from a trusted source address") + .isEqualTo(HttpStatus.OK); + } +} diff --git a/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthUntrustedProxyTest.java b/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthUntrustedProxyTest.java new file mode 100644 index 00000000000..792fe0d58f7 --- /dev/null +++ b/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthUntrustedProxyTest.java @@ -0,0 +1,73 @@ +/* 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; + +/** + * Pins the trusted-proxy contract: with an allowlist configured that does NOT contain the test + * client's source address, a request carrying a valid principal header must still be rejected. + * This is the case a plain header-present / header-absent suite cannot distinguish — a spoofed + * header from an untrusted source looks identical to a legitimate one unless the source address + * is checked. + * + *

The allowlist is {@code 10.0.0.0/8}; the {@link TestRestTemplate} connects from loopback + * ({@code 127.0.0.1}), which is outside that range, so the header is ignored and the request is + * denied. + * + * @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", + "flowable.rest.app.pre-auth.trusted-proxies=10.0.0.0/8" + } +) +@AutoConfigureTestRestTemplate +public class FlowableRestApplicationPreAuthUntrustedProxyTest { + + @LocalServerPort + private int serverPort; + + @Autowired + private TestRestTemplate restTemplate; + + @Test + public void principalHeaderFromUntrustedSourceIsIgnored() { + HttpHeaders headers = new HttpHeaders(); + // A valid, privileged user id -- but arriving from a source outside the allowlist. + headers.set("X-Forwarded-User", "rest-admin"); + HttpEntity request = new HttpEntity<>(headers); + + String url = "http://localhost:" + serverPort + "/flowable-rest/service/repository/process-definitions"; + ResponseEntity entity = restTemplate.exchange(url, HttpMethod.GET, request, String.class); + + assertThat(entity.getStatusCode()) + .as("spoofed principal header from an untrusted source address") + .isEqualTo(HttpStatus.FORBIDDEN); + } +} diff --git a/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthenticationTest.java b/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthenticationTest.java new file mode 100644 index 00000000000..ebfd71a66c6 --- /dev/null +++ b/modules/flowable-app-rest/src/test/java/org/flowable/rest/app/FlowableRestApplicationPreAuthenticationTest.java @@ -0,0 +1,163 @@ +/* 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 java.util.List; + +import org.flowable.idm.api.IdmIdentityService; +import org.flowable.idm.api.Privilege; +import org.flowable.idm.api.User; +import org.flowable.rest.security.SecurityConstants; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +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.context.TestConfiguration; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +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; + +/** + * Verifies the {@code pre-auth} authentication mode: the caller's id is taken from a trusted + * request header (as set by a reverse proxy) instead of HTTP Basic, and authorization still + * uses the privileges loaded from the IDM engine. + * + * @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" + } +) +@AutoConfigureTestRestTemplate +@Import(FlowableRestApplicationPreAuthenticationTest.TestBootstrapConfiguration.class) +public class FlowableRestApplicationPreAuthenticationTest { + + protected static final String PRINCIPAL_HEADER = "X-Forwarded-User"; + + @LocalServerPort + private int serverPort; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private IdmIdentityService idmIdentityService; + + @Test + public void requestWithoutPrincipalHeaderIsRejected() { + ResponseEntity entity = restTemplate.getForEntity(processDefinitionsUrl(), String.class); + + // In pre-auth mode there is no HTTP Basic challenge to issue, so a request that carries + // no principal header is an anonymous request that the authorization rules deny: 403, + // not a 401 with a WWW-Authenticate prompt. A trusted proxy is expected to always set + // the header, so this is the "proxy misconfigured / bypassed" path. + assertThat(entity.getStatusCode()) + .as("GET process-definitions without a principal header") + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + public void userWithRestApiPrivilegeCanAccessRestApiViaHeader() { + List privileges = idmIdentityService.createPrivilegeQuery().userId("rest-admin").list(); + assertThat(privileges) + .extracting(Privilege::getName) + .as("rest-admin privileges") + .contains(SecurityConstants.PRIVILEGE_ACCESS_REST_API); + + HttpEntity request = new HttpEntity<>(headerFor("rest-admin")); + ResponseEntity entity = restTemplate.exchange(processDefinitionsUrl(), HttpMethod.GET, request, String.class); + + assertThat(entity.getStatusCode()) + .as("GET process-definitions as rest-admin") + .isEqualTo(HttpStatus.OK); + } + + @Test + public void userWithoutRestApiPrivilegeIsForbidden() { + User user = idmIdentityService.createUserQuery().userId("test-user").singleResult(); + assertThat(user).as("test-user").isNotNull(); + List privileges = idmIdentityService.createPrivilegeQuery().userId("test-user").list(); + assertThat(privileges) + .extracting(Privilege::getName) + .as("test-user privileges") + .doesNotContain(SecurityConstants.PRIVILEGE_ACCESS_REST_API); + + HttpEntity request = new HttpEntity<>(headerFor("test-user")); + ResponseEntity entity = restTemplate.exchange(processDefinitionsUrl(), HttpMethod.GET, request, String.class); + + assertThat(entity.getStatusCode()) + .as("GET process-definitions as test-user (no access-rest-api)") + .isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + public void adminUserCanAccessActuatorViaHeader() { + HttpEntity request = new HttpEntity<>(headerFor("rest-admin")); + String actuatorUrl = "http://localhost:" + serverPort + "/flowable-rest/actuator"; + ResponseEntity entity = restTemplate.exchange(actuatorUrl, HttpMethod.GET, request, Object.class); + + assertThat(entity.getStatusCode()) + .as("GET actuator as rest-admin (has access-admin)") + .isEqualTo(HttpStatus.OK); + } + + @Test + public void nonAdminUserCannotAccessActuatorViaHeader() { + HttpEntity request = new HttpEntity<>(headerFor("test-user")); + String actuatorUrl = "http://localhost:" + serverPort + "/flowable-rest/actuator"; + ResponseEntity entity = restTemplate.exchange(actuatorUrl, HttpMethod.GET, request, String.class); + + assertThat(entity.getStatusCode()) + .as("GET actuator as test-user (no access-admin)") + .isEqualTo(HttpStatus.FORBIDDEN); + } + + private String processDefinitionsUrl() { + return "http://localhost:" + serverPort + "/flowable-rest/service/repository/process-definitions"; + } + + protected static HttpHeaders headerFor(String userId) { + HttpHeaders headers = new HttpHeaders(); + headers.set(PRINCIPAL_HEADER, userId); + return headers; + } + + @TestConfiguration + public static class TestBootstrapConfiguration { + + @Bean + public CommandLineRunner initTestUsers(IdmIdentityService idmIdentityService) { + return args -> { + User testUser = idmIdentityService.createUserQuery().userId("test-user").singleResult(); + if (testUser == null) { + User user = idmIdentityService.newUser("test-user"); + user.setPassword("test"); + idmIdentityService.saveUser(user); + } + }; + } + } + +} diff --git a/modules/flowable-rest/src/main/java/org/flowable/rest/security/PreAuthenticatedUserDetailsService.java b/modules/flowable-rest/src/main/java/org/flowable/rest/security/PreAuthenticatedUserDetailsService.java new file mode 100644 index 00000000000..1587a93b4b8 --- /dev/null +++ b/modules/flowable-rest/src/main/java/org/flowable/rest/security/PreAuthenticatedUserDetailsService.java @@ -0,0 +1,82 @@ +/* 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.security; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import org.flowable.idm.api.IdmIdentityService; +import org.flowable.idm.api.Privilege; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.AuthenticationUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; + +/** + * Resolves the granted authorities for a user that a trusted reverse proxy has already + * authenticated and whose id is presented in a request header (see the {@code pre-auth} + * authentication mode of the REST app). + * + *

The privileges are loaded from the IDM engine for the header-provided user id, mirroring + * {@link BasicAuthenticationProvider}: with privilege verification on, the user's actual + * privileges are granted (so the {@code access-rest-api} / {@code access-admin} checks behave + * identically to HTTP Basic); with it off, {@code access-rest-api} is granted unconditionally. + * + *

No password is checked here. Authentication has already happened at the proxy, and this + * mode is only safe when the app is not reachable except through that proxy and the proxy + * strips any client-supplied copy of the principal header. + * + * @author Arief Hidayat + */ +public class PreAuthenticatedUserDetailsService + implements AuthenticationUserDetailsService { + + protected final IdmIdentityService idmIdentityService; + protected boolean verifyRestApiPrivilege; + + public PreAuthenticatedUserDetailsService(IdmIdentityService idmIdentityService) { + this.idmIdentityService = idmIdentityService; + } + + @Override + public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) { + String userId = token.getName(); + + Collection grantedAuthorities = new ArrayList<>(); + if (verifyRestApiPrivilege) { + List privileges = idmIdentityService.createPrivilegeQuery().userId(userId).list(); + for (Privilege privilege : privileges) { + grantedAuthorities.add(new SimpleGrantedAuthority(privilege.getName())); + } + } else { + // Matches BasicAuthenticationProvider: when the privilege is not verified, grant it + // so the downstream authorization rule is satisfied for any authenticated user. + grantedAuthorities.add(new SimpleGrantedAuthority(SecurityConstants.PRIVILEGE_ACCESS_REST_API)); + } + + // A UserDetails must carry a (non-empty) password; it is never used, as no credential is + // checked in this flow. + return new User(userId, "", grantedAuthorities); + } + + public boolean isVerifyRestApiPrivilege() { + return verifyRestApiPrivilege; + } + + public void setVerifyRestApiPrivilege(boolean verifyRestApiPrivilege) { + this.verifyRestApiPrivilege = verifyRestApiPrivilege; + } +} diff --git a/modules/flowable-rest/src/test/java/org/flowable/rest/security/PreAuthenticatedUserDetailsServiceTest.java b/modules/flowable-rest/src/test/java/org/flowable/rest/security/PreAuthenticatedUserDetailsServiceTest.java new file mode 100644 index 00000000000..c3c8b0b3218 --- /dev/null +++ b/modules/flowable-rest/src/test/java/org/flowable/rest/security/PreAuthenticatedUserDetailsServiceTest.java @@ -0,0 +1,94 @@ +/* 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.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import java.util.List; + +import org.flowable.idm.api.IdmIdentityService; +import org.flowable.idm.api.Privilege; +import org.flowable.idm.api.PrivilegeQuery; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; + +/** + * @author Arief Hidayat + */ +@ExtendWith(MockitoExtension.class) +class PreAuthenticatedUserDetailsServiceTest { + + @Mock + protected IdmIdentityService idmIdentityService; + + @Mock + protected PrivilegeQuery privilegeQuery; + + @Mock + protected Privilege accessRestApi; + + @Mock + protected Privilege accessAdmin; + + protected PreAuthenticatedUserDetailsService service; + + @BeforeEach + void setUp() { + service = new PreAuthenticatedUserDetailsService(idmIdentityService); + } + + @Test + void withPrivilegeVerificationGrantsTheUsersActualPrivileges() { + service.setVerifyRestApiPrivilege(true); + lenient().when(accessRestApi.getName()).thenReturn(SecurityConstants.PRIVILEGE_ACCESS_REST_API); + lenient().when(accessAdmin.getName()).thenReturn(SecurityConstants.ACCESS_ADMIN); + when(idmIdentityService.createPrivilegeQuery()).thenReturn(privilegeQuery); + when(privilegeQuery.userId("alice")).thenReturn(privilegeQuery); + when(privilegeQuery.list()).thenReturn(List.of(accessRestApi, accessAdmin)); + + UserDetails details = service.loadUserDetails(new PreAuthenticatedAuthenticationToken("alice", "n/a")); + + assertThat(details.getUsername()).isEqualTo("alice"); + assertThat(details.getAuthorities().stream().map(GrantedAuthority::getAuthority)) + .containsExactlyInAnyOrder(SecurityConstants.PRIVILEGE_ACCESS_REST_API, SecurityConstants.ACCESS_ADMIN); + } + + @Test + void withoutPrivilegeVerificationGrantsAccessRestApiUnconditionally() { + service.setVerifyRestApiPrivilege(false); + + UserDetails details = service.loadUserDetails(new PreAuthenticatedAuthenticationToken("bob", "n/a")); + + assertThat(details.getUsername()).isEqualTo("bob"); + assertThat(details.getAuthorities().stream().map(GrantedAuthority::getAuthority)) + .containsExactly(SecurityConstants.PRIVILEGE_ACCESS_REST_API); + } + + @Test + void neverConsultsIdmWhenPrivilegeVerificationIsOff() { + service.setVerifyRestApiPrivilege(false); + + service.loadUserDetails(new PreAuthenticatedAuthenticationToken("carol", "n/a")); + + // No password is checked and, with verification off, no IDM lookup happens either. + org.mockito.Mockito.verifyNoInteractions(idmIdentityService); + } +}