From c73ce7587d2d6649db6e7199240a094794e50cd5 Mon Sep 17 00:00:00 2001 From: Josh Cummings Date: Fri, 1 Mar 2024 13:41:16 -0700 Subject: [PATCH] Use Method Security Enhancements --- servlet/spring-boot/java/data/README.adoc | 51 +++++++++++++++++++ servlet/spring-boot/java/data/build.gradle | 2 - .../spring-boot/java/data/gradle.properties | 2 +- .../src/main/java/example/AuthorizeRead.java | 33 ++++++++++++ .../main/java/example/DataApplication.java | 40 +++++++++++++++ .../data/src/main/java/example/Message.java | 7 +++ .../main/java/example/MessageController.java | 43 ++++++++++++++++ .../main/java/example/MessageRepository.java | 2 + .../java/data/src/main/java/example/Null.java | 31 +++++++++++ .../java/data/src/main/java/example/User.java | 5 ++ .../src/main/resources/application.properties | 17 +++++++ .../java/example/DataApplicationTests.java | 43 +++++++++++++++- 12 files changed, 272 insertions(+), 4 deletions(-) create mode 100644 servlet/spring-boot/java/data/README.adoc create mode 100644 servlet/spring-boot/java/data/src/main/java/example/AuthorizeRead.java create mode 100644 servlet/spring-boot/java/data/src/main/java/example/MessageController.java create mode 100644 servlet/spring-boot/java/data/src/main/java/example/Null.java create mode 100644 servlet/spring-boot/java/data/src/main/resources/application.properties diff --git a/servlet/spring-boot/java/data/README.adoc b/servlet/spring-boot/java/data/README.adoc new file mode 100644 index 0000000..b2ca244 --- /dev/null +++ b/servlet/spring-boot/java/data/README.adoc @@ -0,0 +1,51 @@ += Spring Data Sample + +After running this sample like so: + +.Java +[source,java,role="primary"] +---- +./gradlew :bootRun +---- + +Then you can query for messages using `luke/password` and `rob/password`. + +Because the domain objects are secured, you will see a subset of fields with `luke`. + +For example, querying `/` with `luke`, you'll see: + +```json + ... + { + "created": "2014-07-12T16:00:00Z", + "id": 112, + "summary": "Is this secure?", + "text": "This message is for Luke", + "to": { + "email": "luke@example.com", + "id": "luke", + "password": "password" + } + } + ... +``` + +However, with `rob`, you'll also see `firstName` and `lastName` like so: + +```json + ... + { + "created": "2014-07-12T04:00:00Z", + "id": 102, + "summary": "Is this secure?", + "text": "This message is for Rob", + "to": { + "email": "rob@example.com", + "firstName": "Rob", + "id": "rob", + "lastName": "Winch", + "password": "password" + } + } + ... +``` \ No newline at end of file diff --git a/servlet/spring-boot/java/data/build.gradle b/servlet/spring-boot/java/data/build.gradle index 4e66bd4..a7d91e3 100644 --- a/servlet/spring-boot/java/data/build.gradle +++ b/servlet/spring-boot/java/data/build.gradle @@ -5,8 +5,6 @@ plugins { id 'java' } -ext['spring-security.version'] = '6.3.0-SNAPSHOT' - repositories { mavenCentral() maven { url "https://repo.spring.io/milestone" } diff --git a/servlet/spring-boot/java/data/gradle.properties b/servlet/spring-boot/java/data/gradle.properties index bab2f75..99cba37 100644 --- a/servlet/spring-boot/java/data/gradle.properties +++ b/servlet/spring-boot/java/data/gradle.properties @@ -1,4 +1,4 @@ version=6.1.1 -spring-security.version=6.1.1 +spring-security.version=6.3.0-RC1 org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError org.gradle.caching=true diff --git a/servlet/spring-boot/java/data/src/main/java/example/AuthorizeRead.java b/servlet/spring-boot/java/data/src/main/java/example/AuthorizeRead.java new file mode 100644 index 0000000..b2a4257 --- /dev/null +++ b/servlet/spring-boot/java/data/src/main/java/example/AuthorizeRead.java @@ -0,0 +1,33 @@ +/* + * Copyright 2024 the original author or authors. + * + * 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 + * + * https://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 example; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authorization.method.HandleAuthorizationDenied; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@PreAuthorize(value = "hasAuthority('{value}:read')") +@HandleAuthorizationDenied(handlerClass = Null.class) +public @interface AuthorizeRead { + String value(); +} diff --git a/servlet/spring-boot/java/data/src/main/java/example/DataApplication.java b/servlet/spring-boot/java/data/src/main/java/example/DataApplication.java index 367115a..9ff9252 100644 --- a/servlet/spring-boot/java/data/src/main/java/example/DataApplication.java +++ b/servlet/spring-boot/java/data/src/main/java/example/DataApplication.java @@ -16,12 +16,52 @@ package example; +import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Role; +import org.springframework.security.authorization.method.AuthorizationAdvisorProxyFactory; +import org.springframework.security.authorization.method.AuthorizationAdvisorProxyFactory.TargetVisitor; +import org.springframework.security.authorization.method.PrePostTemplateDefaults; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; @SpringBootApplication +@EnableMethodSecurity public class DataApplication { + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static Customizer skipValueTypes() { + return (f) -> f.setTargetVisitor(TargetVisitor.defaultsSkipValueTypes()); + } + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static PrePostTemplateDefaults templateDefaults() { + return new PrePostTemplateDefaults(); + } + + @Bean + public UserDetailsService userDetailsService() { + return new InMemoryUserDetailsManager( + User.withDefaultPasswordEncoder() + .username("rob") + .password("password") + .authorities("message:read", "user:read") + .build(), + User.withDefaultPasswordEncoder() + .username("luke") + .password("password") + .authorities("message:read") + .build() + ); + } + public static void main(String[] args) { SpringApplication.run(DataApplication.class, args); } diff --git a/servlet/spring-boot/java/data/src/main/java/example/Message.java b/servlet/spring-boot/java/data/src/main/java/example/Message.java index 5b1d203..0f60bee 100644 --- a/servlet/spring-boot/java/data/src/main/java/example/Message.java +++ b/servlet/spring-boot/java/data/src/main/java/example/Message.java @@ -18,13 +18,18 @@ package example; import java.time.Instant; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.ManyToOne; +import org.springframework.security.authorization.method.AuthorizeReturnObject; + @Entity +@JsonSerialize(as = Message.class) +@AuthorizeReturnObject public class Message { @Id @@ -64,6 +69,7 @@ public class Message { this.created = created; } + @AuthorizeRead("message") public String getText() { return this.text; } @@ -72,6 +78,7 @@ public class Message { this.text = text; } + @AuthorizeRead("message") public String getSummary() { return this.summary; } diff --git a/servlet/spring-boot/java/data/src/main/java/example/MessageController.java b/servlet/spring-boot/java/data/src/main/java/example/MessageController.java new file mode 100644 index 0000000..45b0f21 --- /dev/null +++ b/servlet/spring-boot/java/data/src/main/java/example/MessageController.java @@ -0,0 +1,43 @@ +/* + * Copyright 2024 the original author or authors. + * + * 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 + * + * https://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 example; + +import java.util.List; +import java.util.Optional; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class MessageController { + private final MessageRepository messages; + + public MessageController(MessageRepository messages) { + this.messages = messages; + } + + @GetMapping + List getMessages() { + return this.messages.findAll(); + } + + @GetMapping("/{id}") + Optional getMessages(Long id) { + return this.messages.findById(id); + } + +} diff --git a/servlet/spring-boot/java/data/src/main/java/example/MessageRepository.java b/servlet/spring-boot/java/data/src/main/java/example/MessageRepository.java index 52d4e7a..9d90d9d 100644 --- a/servlet/spring-boot/java/data/src/main/java/example/MessageRepository.java +++ b/servlet/spring-boot/java/data/src/main/java/example/MessageRepository.java @@ -20,6 +20,7 @@ import java.util.List; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.CrudRepository; +import org.springframework.security.authorization.method.AuthorizeReturnObject; import org.springframework.stereotype.Repository; /** @@ -28,6 +29,7 @@ import org.springframework.stereotype.Repository; * @author Rob Winch */ @Repository +@AuthorizeReturnObject public interface MessageRepository extends CrudRepository { @Query("select m from Message m where m.to.id = ?#{ authentication.name }") diff --git a/servlet/spring-boot/java/data/src/main/java/example/Null.java b/servlet/spring-boot/java/data/src/main/java/example/Null.java new file mode 100644 index 0000000..bdb8946 --- /dev/null +++ b/servlet/spring-boot/java/data/src/main/java/example/Null.java @@ -0,0 +1,31 @@ +/* + * Copyright 2024 the original author or authors. + * + * 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 + * + * https://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 example; + +import org.aopalliance.intercept.MethodInvocation; + +import org.springframework.security.authorization.AuthorizationResult; +import org.springframework.security.authorization.method.MethodAuthorizationDeniedHandler; +import org.springframework.stereotype.Component; + +@Component +public class Null implements MethodAuthorizationDeniedHandler { + @Override + public Object handleDeniedInvocation(MethodInvocation methodInvocation, AuthorizationResult authorizationResult) { + return null; + } +} diff --git a/servlet/spring-boot/java/data/src/main/java/example/User.java b/servlet/spring-boot/java/data/src/main/java/example/User.java index 3faa67d..3eed6b6 100644 --- a/servlet/spring-boot/java/data/src/main/java/example/User.java +++ b/servlet/spring-boot/java/data/src/main/java/example/User.java @@ -16,6 +16,8 @@ package example; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.annotation.JsonSerialize; import jakarta.persistence.Entity; import jakarta.persistence.Id; @@ -25,6 +27,7 @@ import jakarta.persistence.Id; * @author Rob Winch */ @Entity(name = "users") +@JsonSerialize(as = User.class, contentUsing = JsonSerializer.class) public class User { @Id @@ -46,6 +49,7 @@ public class User { this.id = id; } + @AuthorizeRead("user") public String getFirstName() { return this.firstName; } @@ -54,6 +58,7 @@ public class User { this.firstName = firstName; } + @AuthorizeRead("user") public String getLastName() { return this.lastName; } diff --git a/servlet/spring-boot/java/data/src/main/resources/application.properties b/servlet/spring-boot/java/data/src/main/resources/application.properties new file mode 100644 index 0000000..71a3130 --- /dev/null +++ b/servlet/spring-boot/java/data/src/main/resources/application.properties @@ -0,0 +1,17 @@ +# +# Copyright 2024 the original author or authors. +# +# 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 +# +# https://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. +# + +spring.jackson.default-property-inclusion=non_null \ No newline at end of file diff --git a/servlet/spring-boot/java/data/src/test/java/example/DataApplicationTests.java b/servlet/spring-boot/java/data/src/test/java/example/DataApplicationTests.java index 7824f80..cf4f6ba 100644 --- a/servlet/spring-boot/java/data/src/test/java/example/DataApplicationTests.java +++ b/servlet/spring-boot/java/data/src/test/java/example/DataApplicationTests.java @@ -22,9 +22,12 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.test.context.support.WithMockUser; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatNoException; /** * @author Rob Winch @@ -37,9 +40,47 @@ public class DataApplicationTests { @Test @WithMockUser("rob") - void findAllOnlyToCurrentUser() { + void findAllOnlyToCurrentUserCantReadMessage() { List messages = this.repository.findAll(); assertThat(messages).hasSize(3); + for (Message message : messages) { + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(message::getSummary); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(message::getText); + } } + @Test + @WithMockUser(username="rob", authorities="message:read") + void findAllOnlyToCurrentUserCanReadMessage() { + List messages = this.repository.findAll(); + assertThat(messages).hasSize(3); + for (Message message : messages) { + assertThatNoException().isThrownBy(message::getSummary); + assertThatNoException().isThrownBy(message::getText); + } + } + + @Test + @WithMockUser(username="rob", authorities="message:read") + void findAllOnlyToCurrentUserCantReadUserDetails() { + List messages = this.repository.findAll(); + assertThat(messages).hasSize(3); + for (Message message : messages) { + User user = message.getTo(); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(user::getFirstName); + assertThatExceptionOfType(AccessDeniedException.class).isThrownBy(user::getLastName); + } + } + + @Test + @WithMockUser(username="rob", authorities={ "message:read", "user:read" }) + void findAllOnlyToCurrentUserCanReadUserDetails() { + List messages = this.repository.findAll(); + assertThat(messages).hasSize(3); + for (Message message : messages) { + User user = message.getTo(); + assertThatNoException().isThrownBy(user::getFirstName); + assertThatNoException().isThrownBy(user::getLastName); + } + } }