[BAEL-5584] Article code (#12157)

This commit is contained in:
psevestre 2022-05-03 02:42:41 -03:00 committed by GitHub
parent 833e3f9e9f
commit 26d944ceaa
12 changed files with 410 additions and 0 deletions

View File

@ -46,6 +46,7 @@
<module>spring-security-web-x509</module>
<module>spring-session</module>
<module>spring-social-login</module>
<module>spring-security-opa</module>
</modules>
</project>

View File

@ -0,0 +1,49 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.baeldung</groupId>
<artifactId>parent-boot-2</artifactId>
<version>0.0.1-SNAPSHOT</version>
<relativePath>../../parent-boot-2</relativePath>
</parent>
<artifactId>spring-security-opa</artifactId>
<description>Spring Security with OPA authorization</description>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.0.1-jre</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
</project>

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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";
}

View File

@ -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<AuthorizationContext> 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<AuthorizationDecision> 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<Map<String,Object>> toAuthorizationPayload(Mono<Authentication> auth, AuthorizationContext context) {
// @formatter:off
return auth
.defaultIfEmpty(new AnonymousAuthenticationToken("**ANONYMOUS**", new Object(), Arrays.asList(new SimpleGrantedAuthority("ANONYMOUS"))))
.map( a -> {
Map<String,String> headers = context.getExchange().getRequest()
.getHeaders()
.toSingleValueMap();
Map<String,Object> attributes = ImmutableMap.<String,Object>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<String,Object> input = ImmutableMap.<String,Object>builder()
.put("input",attributes)
.build();
return input;
});
// @formatter:on
}
}

View File

@ -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<Account> getAccount(@PathVariable("accountId") String accountId) {
return accountService.findByAccountId(accountId);
}
}

View File

@ -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;
}
}

View File

@ -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<String, Account> accounts = ImmutableMap.<String, Account>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<Account> findByAccountId(String accountId) {
return Mono.just(accounts.get(accountId))
.switchIfEmpty(Mono.error(new IllegalArgumentException("invalid.account")));
}
}

View File

@ -0,0 +1,3 @@
# OPA configuration properties
opa:
endpoint: http://localhost:8181/v1/data/baeldung/auth/account

View File

@ -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();
}
}

View File

@ -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]
}