diff --git a/spring-security-modules/pom.xml b/spring-security-modules/pom.xml index 9fdfde282d..bb36909c79 100644 --- a/spring-security-modules/pom.xml +++ b/spring-security-modules/pom.xml @@ -46,6 +46,7 @@ spring-security-web-x509 spring-session spring-social-login + spring-security-opa \ No newline at end of file diff --git a/spring-security-modules/spring-security-opa/pom.xml b/spring-security-modules/spring-security-opa/pom.xml new file mode 100644 index 0000000000..6665c33db3 --- /dev/null +++ b/spring-security-modules/spring-security-opa/pom.xml @@ -0,0 +1,49 @@ + + 4.0.0 + + com.baeldung + parent-boot-2 + 0.0.1-SNAPSHOT + ../../parent-boot-2 + + spring-security-opa + Spring Security with OPA authorization + + + + org.springframework.boot + spring-boot-starter-webflux + + + + org.springframework.boot + spring-boot-starter-security + + + + org.projectlombok + lombok + + + + com.google.guava + guava + 31.0.1-jre + + + + org.springframework.boot + spring-boot-devtools + + + + org.springframework.security + spring-security-test + + + org.springframework.boot + spring-boot-configuration-processor + true + + + \ No newline at end of file diff --git a/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/Application.java b/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/Application.java new file mode 100644 index 0000000000..789a1f803f --- /dev/null +++ b/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/Application.java @@ -0,0 +1,13 @@ +package com.baeldung.security.opa; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + +} diff --git a/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/config/OpaConfiguration.java b/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/config/OpaConfiguration.java new file mode 100644 index 0000000000..e24fdbcf35 --- /dev/null +++ b/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/config/OpaConfiguration.java @@ -0,0 +1,25 @@ +package com.baeldung.security.opa.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +@EnableConfigurationProperties(OpaProperties.class) +public class OpaConfiguration { + + private final OpaProperties opaProperties; + + @Bean + public WebClient opaWebClient(WebClient.Builder builder) { + + return builder + .baseUrl(opaProperties.getEndpoint()) + .build(); + } + +} diff --git a/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/config/OpaProperties.java b/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/config/OpaProperties.java new file mode 100644 index 0000000000..acc23a2fd2 --- /dev/null +++ b/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/config/OpaProperties.java @@ -0,0 +1,14 @@ +package com.baeldung.security.opa.config; + +import javax.annotation.Nonnull; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import lombok.Data; + +@ConfigurationProperties(prefix = "opa") +@Data +public class OpaProperties { + @Nonnull + private String endpoint = "http://localhost:8181"; +} diff --git a/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/config/SecurityConfiguration.java b/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/config/SecurityConfiguration.java new file mode 100644 index 0000000000..7e10cb2e8a --- /dev/null +++ b/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/config/SecurityConfiguration.java @@ -0,0 +1,110 @@ +package com.baeldung.security.opa.config; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import org.reactivestreams.Publisher; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ClientHttpRequest; +import org.springframework.security.authentication.AnonymousAuthenticationToken; +import org.springframework.security.authorization.AuthorizationDecision; +import org.springframework.security.authorization.ReactiveAuthorizationManager; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.authorization.AuthorizationContext; +import org.springframework.web.reactive.function.BodyInserter; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.ClientResponse; +import org.springframework.web.reactive.function.client.WebClient; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.collect.ImmutableMap; + +import reactor.core.publisher.Mono; + +@Configuration +public class SecurityConfiguration { + + + @Bean + public SecurityWebFilterChain accountAuthorization(ServerHttpSecurity http, @Qualifier("opaWebClient")WebClient opaWebClient) { + + // @formatter:on + return http + .httpBasic() + .and() + .authorizeExchange(exchanges -> { + exchanges + .pathMatchers("/account/*") + .access(opaAuthManager(opaWebClient)); + }) + .build(); + // @formatter:on + + } + + @Bean + public ReactiveAuthorizationManager opaAuthManager(WebClient opaWebClient) { + + return (auth, context) -> { + return opaWebClient.post() + .accept(MediaType.APPLICATION_JSON) + .contentType(MediaType.APPLICATION_JSON) + .body(toAuthorizationPayload(auth,context), Map.class) + .exchangeToMono(this::toDecision); + }; + } + + private Mono toDecision(ClientResponse response) { + + if ( !response.statusCode().is2xxSuccessful()) { + return Mono.just(new AuthorizationDecision(false)); + } + + return response + .bodyToMono(ObjectNode.class) + .map(node -> { + boolean authorized = node.path("result").path("authorized").asBoolean(false); + return new AuthorizationDecision(authorized); + }); + + } + + private Publisher> toAuthorizationPayload(Mono auth, AuthorizationContext context) { + // @formatter:off + return auth + .defaultIfEmpty(new AnonymousAuthenticationToken("**ANONYMOUS**", new Object(), Arrays.asList(new SimpleGrantedAuthority("ANONYMOUS")))) + .map( a -> { + + Map headers = context.getExchange().getRequest() + .getHeaders() + .toSingleValueMap(); + + Map attributes = ImmutableMap.builder() + .put("principal",a.getName()) + .put("authorities", + a.getAuthorities() + .stream() + .map(g -> g.getAuthority()) + .collect(Collectors.toList())) + .put("uri", context.getExchange().getRequest().getURI().getPath()) + .put("headers",headers) + .build(); + + Map input = ImmutableMap.builder() + .put("input",attributes) + .build(); + + return input; + }); + // @formatter:on + } +} diff --git a/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/controller/AccountController.java b/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/controller/AccountController.java new file mode 100644 index 0000000000..ba0ff61554 --- /dev/null +++ b/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/controller/AccountController.java @@ -0,0 +1,23 @@ +package com.baeldung.security.opa.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +import com.baeldung.security.opa.domain.Account; +import com.baeldung.security.opa.service.AccountService; + +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; + +@RestController +@RequiredArgsConstructor +public class AccountController { + + private final AccountService accountService; + + @GetMapping("/account/{accountId}") + public Mono getAccount(@PathVariable("accountId") String accountId) { + return accountService.findByAccountId(accountId); + } +} diff --git a/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/domain/Account.java b/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/domain/Account.java new file mode 100644 index 0000000000..db494627a8 --- /dev/null +++ b/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/domain/Account.java @@ -0,0 +1,25 @@ +package com.baeldung.security.opa.domain; + +import java.math.BigDecimal; + +import lombok.Data; + +@Data +public class Account { + + private String id; + private BigDecimal balance; + private String currency; + + + public static Account of(String id, BigDecimal balance, String currency) { + Account acc = new Account(); + acc.setId(id); + acc.setBalance(balance); + acc.setCurrency(currency); + + return acc; + } + + +} diff --git a/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/service/AccountService.java b/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/service/AccountService.java new file mode 100644 index 0000000000..18968019f9 --- /dev/null +++ b/spring-security-modules/spring-security-opa/src/main/java/com/baeldung/security/opa/service/AccountService.java @@ -0,0 +1,37 @@ +/** + * + */ +package com.baeldung.security.opa.service; + +import java.math.BigDecimal; +import java.util.Map; + +import org.springframework.stereotype.Service; + +import com.baeldung.security.opa.domain.Account; +import com.google.common.collect.ImmutableMap; + +import reactor.core.publisher.Mono; + +/** + * @author Philippe + * + */ +@Service +public class AccountService { + + private Map accounts = ImmutableMap.builder() + .put("0001", Account.of("0001", BigDecimal.valueOf(100.00), "USD")) + .put("0002", Account.of("0002", BigDecimal.valueOf(101.00), "EUR")) + .put("0003", Account.of("0003", BigDecimal.valueOf(102.00), "BRL")) + .put("0004", Account.of("0004", BigDecimal.valueOf(103.00), "AUD")) + .put("0005", Account.of("0005", BigDecimal.valueOf(10400.00), "JPY")) + .build(); + + + public Mono findByAccountId(String accountId) { + return Mono.just(accounts.get(accountId)) + .switchIfEmpty(Mono.error(new IllegalArgumentException("invalid.account"))); + } + +} diff --git a/spring-security-modules/spring-security-opa/src/main/resources/application.yaml b/spring-security-modules/spring-security-opa/src/main/resources/application.yaml new file mode 100644 index 0000000000..6fb9200277 --- /dev/null +++ b/spring-security-modules/spring-security-opa/src/main/resources/application.yaml @@ -0,0 +1,3 @@ +# OPA configuration properties +opa: + endpoint: http://localhost:8181/v1/data/baeldung/auth/account \ No newline at end of file diff --git a/spring-security-modules/spring-security-opa/src/test/java/com/baeldung/security/opa/controller/AccountControllerLiveTest.java b/spring-security-modules/spring-security-opa/src/test/java/com/baeldung/security/opa/controller/AccountControllerLiveTest.java new file mode 100644 index 0000000000..7469fd327c --- /dev/null +++ b/spring-security-modules/spring-security-opa/src/test/java/com/baeldung/security/opa/controller/AccountControllerLiveTest.java @@ -0,0 +1,67 @@ +package com.baeldung.security.opa.controller; + +import static org.springframework.security.test.web.reactive.server.SecurityMockServerConfigurers.springSecurity; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.reactive.server.WebTestClient; + +// !!! NOTICE: Start OPA server before running this test class !!! +@SpringBootTest +@ActiveProfiles("test") +class AccountControllerLiveTest { + + @Autowired + ApplicationContext context; + WebTestClient rest; + + @BeforeEach + public void setup() { + this.rest = WebTestClient.bindToApplicationContext(this.context) + .apply(springSecurity()) + .configureClient() + .build(); + } + + + @Test + @WithMockUser(username = "user1", roles = { "account:read:0001"} ) + void testGivenValidUser_thenSuccess() { + rest.get() + .uri("/account/0001") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .is2xxSuccessful(); + } + + @Test + @WithMockUser(username = "user1", roles = { "account:read:0002"} ) + void testGivenValidUser_thenUnauthorized() { + rest.get() + .uri("/account/0001") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .isForbidden(); + } + + @Test + @WithMockUser(username = "user1", roles = {} ) + void testGivenNoAuthorities_thenForbidden() { + rest.get() + .uri("/account/0001") + .accept(MediaType.APPLICATION_JSON) + .exchange() + .expectStatus() + .isForbidden(); + } + + +} diff --git a/spring-security-modules/spring-security-opa/src/test/rego/account.rego b/spring-security-modules/spring-security-opa/src/test/rego/account.rego new file mode 100644 index 0000000000..567d531bb4 --- /dev/null +++ b/spring-security-modules/spring-security-opa/src/test/rego/account.rego @@ -0,0 +1,43 @@ +# +# Simple authorization rule for accounts +# +# Assumes an input document with the following properties: +# +# resource: requested resource +# method: request method +# authorities: Granted authorities +# headers: Request headers +# +package baeldung.auth.account + +# Not authorized by default +default authorized = false + +# Authorize when there are no rules that deny access to the resource and +# there's at least one rule allowing +authorized = true { + count(deny) == 0 + count(allow) > 0 +} + +# Allow access to /public +allow["public"] { + regex.match("^/public/.*",input.uri) +} + +# Account API requires authenticated user +deny["account_api_authenticated"] { + regex.match("^/account/.*",input.uri) + regex.match("ANONYMOUS",input.principal) +} + +# Authorize access to account if principal has +# matching authority +allow["account_api_authorized"] { + regex.match("^/account/.+",input.uri) + parts := split(input.uri,"/") + account := parts[2] + role := concat(":",[ "ROLE_account", "read", account] ) + role == input.authorities[i] +} +