Quellcode durchsuchen

Use Method Security Enhancements

Josh Cummings vor 1 Jahr
Ursprung
Commit
c73ce7587d

+ 51 - 0
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"
+        }
+    }
+  ...
+```

+ 0 - 2
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" }

+ 1 - 1
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

+ 33 - 0
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();
+}

+ 40 - 0
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<AuthorizationAdvisorProxyFactory> 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);
 	}

+ 7 - 0
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;
 	}

+ 43 - 0
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<Message> getMessages() {
+		return this.messages.findAll();
+	}
+
+	@GetMapping("/{id}")
+	Optional<Message> getMessages(Long id) {
+		return this.messages.findById(id);
+	}
+
+}

+ 2 - 0
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<Message, Long> {
 
 	@Query("select m from Message m where m.to.id = ?#{ authentication.name }")

+ 31 - 0
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;
+	}
+}

+ 5 - 0
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;
 	}

+ 17 - 0
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

+ 42 - 1
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<Message> 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<Message> 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<Message> 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<Message> messages = this.repository.findAll();
+		assertThat(messages).hasSize(3);
+		for (Message message : messages) {
+			User user = message.getTo();
+			assertThatNoException().isThrownBy(user::getFirstName);
+			assertThatNoException().isThrownBy(user::getLastName);
+		}
+	}
 }