diff --git a/config/config.gradle b/config/config.gradle index e8ecb0f285..4cc33f79b7 100644 --- a/config/config.gradle +++ b/config/config.gradle @@ -54,7 +54,7 @@ dependencies { testCompile('org.openid4java:openid4java-nodeps:0.9.6') { exclude group: 'com.google.code.guice', module: 'guice' } - testCompile('org.springframework.data:spring-data-jpa:1.4.1.RELEASE') { + testCompile("org.springframework.data:spring-data-jpa:$springDataJpaVersion") { exclude group: 'org.aspectj', module: 'aspectjrt' } diff --git a/data/data.gradle b/data/data.gradle new file mode 100644 index 0000000000..0d27da17e3 --- /dev/null +++ b/data/data.gradle @@ -0,0 +1,5 @@ +dependencies { + compile project(':spring-security-core'), + "org.springframework.data:spring-data-commons:$springDataCommonsVersion" + +} \ No newline at end of file diff --git a/data/src/main/java/org/springframework/security/data/repository/query/SecurityEvaluationContextExtension.java b/data/src/main/java/org/springframework/security/data/repository/query/SecurityEvaluationContextExtension.java new file mode 100644 index 0000000000..3af6556aa9 --- /dev/null +++ b/data/src/main/java/org/springframework/security/data/repository/query/SecurityEvaluationContextExtension.java @@ -0,0 +1,117 @@ +/* + * Copyright 2002-2014 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 + * + * 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.springframework.security.data.repository.query; + +import org.springframework.data.repository.query.spi.EvaluationContextExtension; +import org.springframework.data.repository.query.spi.EvaluationContextExtensionSupport; +import org.springframework.data.repository.query.spi.Function; +import org.springframework.security.access.expression.SecurityExpressionRoot; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.Map; + +/** + *

+ * By defining this object as a Bean, Spring Security is exposed as SpEL expressions for creating Spring Data + * queries. + *

+ * + *

With Java based configuration, we can define the bean using the following:

+ * + *

For example, if you return a UserDetails that extends the following User object:

+ * + *
+ * @Entity
+ * public class User {
+ *     @GeneratedValue(strategy = GenerationType.AUTO)
+ *     @Id
+ *     private Long id;
+ *
+ *     ...
+ * 
+ * + *

And you have a Message object that looks like the following:

+ * + *
+ * @Entity
+ * public class Message {
+ *     @Id
+ *     @GeneratedValue(strategy = GenerationType.AUTO)
+ *     private Long id;
+ *
+ *     @OneToOne
+ *     private User to;
+ *
+ *     ...
+ * 
+ * + * You can use the following {@code Query} annotation to search for only messages that are to the current user: + * + *
+ * @Repository
+ * public interface SecurityMessageRepository extends MessageRepository {
+ *
+ *     @Query("select m from Message m where m.to.id = ?#{ principal?.id }")
+ *     List findAll();
+ * }
+ * 
+ * + * This works because the principal in this instance is a User which has an id field on it. + * + * @since 4.0 + * @author Rob Winch + */ +public class SecurityEvaluationContextExtension extends EvaluationContextExtensionSupport { + private Authentication authentication; + + /** + * Creates a new instance that uses the current {@link Authentication} found on the + * {@link org.springframework.security.core.context.SecurityContextHolder}. + */ + public SecurityEvaluationContextExtension() { + } + + /** + * Creates a new instance that always uses the same {@link Authentication} object. + * + * @param authentication the {@link Authentication} to use + */ + public SecurityEvaluationContextExtension(Authentication authentication) { + this.authentication = authentication; + } + + @Override + public String getExtensionId() { + return "security"; + } + + @Override + public Object getRootObject() { + Authentication authentication = getAuthentication(); + return new SecurityExpressionRoot(authentication) {}; + } + + private Authentication getAuthentication() { + if(authentication != null) { + return authentication; + } + + SecurityContext context = SecurityContextHolder.getContext(); + return context.getAuthentication(); + } +} \ No newline at end of file diff --git a/data/src/test/java/org/springframework/security/data/repository/query/SecurityEvaluationContextExtensionTests.java b/data/src/test/java/org/springframework/security/data/repository/query/SecurityEvaluationContextExtensionTests.java new file mode 100644 index 0000000000..6084626277 --- /dev/null +++ b/data/src/test/java/org/springframework/security/data/repository/query/SecurityEvaluationContextExtensionTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2002-2014 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 + * + * 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.springframework.security.data.repository.query; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.springframework.security.access.expression.SecurityExpressionRoot; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.fest.assertions.Assertions.assertThat; + +public class SecurityEvaluationContextExtensionTests { + SecurityEvaluationContextExtension securityExtension; + + @Before + public void setup() { + securityExtension = new SecurityEvaluationContextExtension(); + } + + @After + public void cleanup() { + SecurityContextHolder.clearContext(); + } + + @Test(expected = IllegalArgumentException.class) + public void getRootObjectSecurityContextHolderAuthenticationNull() { + getRoot().getAuthentication(); + } + + @Test + public void getRootObjectSecurityContextHolderAuthentication() { + TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", "password", "ROLE_USER"); + SecurityContextHolder.getContext().setAuthentication(authentication); + + assertThat(getRoot().getAuthentication()).isSameAs(authentication); + } + + @Test + public void getRootObjectExplicitAuthenticationOverridesSecurityContextHolder() { + TestingAuthenticationToken explicit = new TestingAuthenticationToken("explicit", "password", "ROLE_EXPLICIT"); + securityExtension = new SecurityEvaluationContextExtension(explicit); + + TestingAuthenticationToken authentication = new TestingAuthenticationToken("user", "password", "ROLE_USER"); + SecurityContextHolder.getContext().setAuthentication(authentication); + + assertThat(getRoot().getAuthentication()).isSameAs(explicit); + } + + @Test + public void getRootObjectExplicitAuthentication() { + TestingAuthenticationToken explicit = new TestingAuthenticationToken("explicit", "password", "ROLE_EXPLICIT"); + securityExtension = new SecurityEvaluationContextExtension(explicit); + + assertThat(getRoot().getAuthentication()).isSameAs(explicit); + } + + private SecurityExpressionRoot getRoot() { + return (SecurityExpressionRoot) securityExtension.getRootObject(); + } +} \ No newline at end of file diff --git a/gradle/javaprojects.gradle b/gradle/javaprojects.gradle index d0a0a35d3b..52b43670c1 100644 --- a/gradle/javaprojects.gradle +++ b/gradle/javaprojects.gradle @@ -28,6 +28,8 @@ ext.groovyVersion = '2.0.5' ext.spockVersion = '0.7-groovy-2.0' ext.gebVersion = '0.9.0' ext.thymeleafVersion = '2.1.3.RELEASE' +ext.springDataJpaVersion = '1.7.0.M1' +ext.springDataCommonsVersion = '1.9.0.M1' ext.spockDependencies = [ dependencies.create("org.spockframework:spock-spring:$spockVersion") { diff --git a/samples/data-jc/build.gradle b/samples/data-jc/build.gradle new file mode 100644 index 0000000000..4f743199c6 --- /dev/null +++ b/samples/data-jc/build.gradle @@ -0,0 +1,10 @@ +dependencies { + compile project(':spring-security-data'), + project(':spring-security-config'), + "org.springframework.data:spring-data-jpa:$springDataJpaVersion", + "org.hibernate.javax.persistence:hibernate-jpa-2.0-api:1.0.0.Final", + "org.hsqldb:hsqldb:2.2.8", + "javax.validation:validation-api:1.0.0.GA", + "org.hibernate:hibernate-validator:4.2.0.Final" + +} \ No newline at end of file diff --git a/samples/data-jc/src/main/java/samples/DataConfig.java b/samples/data-jc/src/main/java/samples/DataConfig.java new file mode 100644 index 0000000000..23c488ad61 --- /dev/null +++ b/samples/data-jc/src/main/java/samples/DataConfig.java @@ -0,0 +1,85 @@ +/* + * Copyright 2002-2014 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 + * + * 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 samples; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder; +import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.orm.jpa.vendor.Database; +import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter; +import org.springframework.security.data.repository.query.SecurityEvaluationContextExtension; +import org.springframework.transaction.PlatformTransactionManager; +import samples.data.Message; + +import javax.sql.DataSource; + +/** + * @author Rob Winch + */ +@Configuration +@ComponentScan +@EnableJpaRepositories +public class DataConfig { + + @Bean + public SecurityEvaluationContextExtension expressionEvaluationContextProvider() { + return new SecurityEvaluationContextExtension(); + } + + @Bean + public DataSource dataSource() { + EmbeddedDatabaseBuilder builder = new EmbeddedDatabaseBuilder(); + return builder.setType(EmbeddedDatabaseType.HSQL).build(); + } + + @Bean + public LocalContainerEntityManagerFactoryBean entityManagerFactory() { + HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter(); + vendorAdapter.setDatabase(Database.HSQL); + vendorAdapter.setGenerateDdl(true); + + LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean(); + factory.setJpaVendorAdapter(vendorAdapter); + factory.setPackagesToScan(Message.class.getPackage().getName()); + factory.setDataSource(dataSource()); + + return factory; + } + + @Bean + @DependsOn("entityManagerFactory") + public ResourceDatabasePopulator initDatabase(DataSource dataSource) throws Exception { + ResourceDatabasePopulator populator = new ResourceDatabasePopulator(); + populator.addScript(new ClassPathResource("data.sql")); + populator.populate(dataSource.getConnection()); + return populator; + } + + @Bean + public PlatformTransactionManager transactionManager() { + JpaTransactionManager txManager = new JpaTransactionManager(); + txManager.setEntityManagerFactory(entityManagerFactory().getObject()); + return txManager; + } +} diff --git a/samples/data-jc/src/main/java/samples/data/Message.java b/samples/data-jc/src/main/java/samples/data/Message.java new file mode 100644 index 0000000000..05a8be43aa --- /dev/null +++ b/samples/data-jc/src/main/java/samples/data/Message.java @@ -0,0 +1,82 @@ +/* + * Copyright 2002-2014 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 + * + * 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 samples.data; + + +import java.util.Calendar; + +import javax.persistence.*; + +import org.hibernate.validator.constraints.NotEmpty; + +@Entity +public class Message { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + + @NotEmpty(message = "Message is required.") + private String text; + + @NotEmpty(message = "Summary is required.") + private String summary; + + @Version + private Calendar created = Calendar.getInstance(); + + @OneToOne + private User to; + + public User getTo() { + return to; + } + + public void setTo(User to) { + this.to = to; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Calendar getCreated() { + return created; + } + + public void setCreated(Calendar created) { + this.created = created; + } + + public String getText() { + return text; + } + + public void setText(String text) { + this.text = text; + } + + public String getSummary() { + return summary; + } + + public void setSummary(String summary) { + this.summary = summary; + } +} diff --git a/samples/data-jc/src/main/java/samples/data/MessageRepository.java b/samples/data-jc/src/main/java/samples/data/MessageRepository.java new file mode 100644 index 0000000000..878279cda0 --- /dev/null +++ b/samples/data-jc/src/main/java/samples/data/MessageRepository.java @@ -0,0 +1,26 @@ +/* + * Copyright 2002-2014 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 + * + * 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 samples.data; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +/** + * @author Rob Winch + */ +@Repository +public interface MessageRepository extends JpaRepository { +} diff --git a/samples/data-jc/src/main/java/samples/data/SecurityMessageRepository.java b/samples/data-jc/src/main/java/samples/data/SecurityMessageRepository.java new file mode 100644 index 0000000000..e44f993417 --- /dev/null +++ b/samples/data-jc/src/main/java/samples/data/SecurityMessageRepository.java @@ -0,0 +1,32 @@ +/* + * Copyright 2002-2014 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 + * + * 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 samples.data; + + +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * @author Rob Winch + */ +@Repository +public interface SecurityMessageRepository extends MessageRepository { + @Override + @Query("select m from Message m where m.to.id = ?#{ principal?.id }") + List findAll(); +} \ No newline at end of file diff --git a/samples/data-jc/src/main/java/samples/data/User.java b/samples/data-jc/src/main/java/samples/data/User.java new file mode 100644 index 0000000000..57b70d3f93 --- /dev/null +++ b/samples/data-jc/src/main/java/samples/data/User.java @@ -0,0 +1,79 @@ +/* + * Copyright 2002-2014 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 + * + * 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 samples.data; + +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; + +/** + * @author Rob Winch + */ +@Entity +public class User { + @GeneratedValue(strategy = GenerationType.AUTO) + @Id + private Long id; + + private String firstName; + + private String lastName; + + private String email; + + private String password; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getFirstName() { + return firstName; + } + + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + public String getLastName() { + return lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} \ No newline at end of file diff --git a/samples/data-jc/src/test/java/samples/data/SecurityMessageRepositoryTests.java b/samples/data-jc/src/test/java/samples/data/SecurityMessageRepositoryTests.java new file mode 100644 index 0000000000..567baeb214 --- /dev/null +++ b/samples/data-jc/src/test/java/samples/data/SecurityMessageRepositoryTests.java @@ -0,0 +1,73 @@ +/* + * Copyright 2002-2014 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 + * + * 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 samples.data; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.junit4.SpringJUnit4ClassRunner; +import samples.DataConfig; + +import java.util.List; + +import static org.fest.assertions.Assertions.assertThat; + +/** + * @author Rob Winch + */ +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(classes = DataConfig.class) +public class SecurityMessageRepositoryTests { + @Autowired + SecurityMessageRepository repository; + + User user; + + @Before + public void setup() { + user = new User(); + user.setId(0L); + List authorities = + AuthorityUtils.createAuthorityList("ROLE_USER"); + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(user, "notused", authorities); + SecurityContextHolder + .getContext() + .setAuthentication(authentication); + } + + @After + public void cleanup() { + SecurityContextHolder.clearContext(); + } + + @Test + public void findAllOnlyToCurrentUser() { + Long expectedId = user.getId(); + List messages = repository.findAll(); + assertThat(messages.size()).isEqualTo(3); + for(Message m : messages) { + assertThat(m.getTo().getId()).isEqualTo(expectedId); + } + } +} diff --git a/samples/data-jc/src/test/resources/data.sql b/samples/data-jc/src/test/resources/data.sql new file mode 100644 index 0000000000..97c60b155b --- /dev/null +++ b/samples/data-jc/src/test/resources/data.sql @@ -0,0 +1,10 @@ +insert into user(id,email,password,firstName,lastName) values (0,'rob@example.com','password','Rob','Winch'); +insert into user(id,email,password,firstName,lastName) values (1,'luke@example.com','password','Luke','Taylor'); + +insert into message(id,created,to_id,summary,text) values (100,'2014-07-10 10:00:00',0,'Hello Rob','This message is for Rob'); +insert into message(id,created,to_id,summary,text) values (101,'2014-07-10 14:00:00',0,'How are you Rob?','This message is for Rob'); +insert into message(id,created,to_id,summary,text) values (102,'2014-07-11 22:00:00',0,'Is this secure?','This message is for Rob'); + +insert into message(id,created,to_id,summary,text) values (110,'2014-07-12 10:00:00',1,'Hello Luke','This message is for Luke'); +insert into message(id,created,to_id,summary,text) values (111,'2014-07-12 10:00:00',1,'Greetings Luke','This message is for Luke'); +insert into message(id,created,to_id,summary,text) values (112,'2014-07-12 10:00:00',1,'Is this secure?','This message is for Luke'); \ No newline at end of file diff --git a/samples/messages-jc/build.gradle b/samples/messages-jc/build.gradle index 3828855da9..2f65ec2984 100644 --- a/samples/messages-jc/build.gradle +++ b/samples/messages-jc/build.gradle @@ -24,7 +24,7 @@ dependencies { compile('org.hibernate:hibernate-entitymanager:3.6.10.Final') { exclude group:'javassist', module: 'javassist' } - compile('org.springframework.data:spring-data-jpa:1.3.4.RELEASE') { + compile("org.springframework.data:spring-data-jpa:$springDataJpaVersion") { exclude group:'org.aspectj', module:'aspectjrt' } } diff --git a/settings.gradle b/settings.gradle index e07136f421..13ca32b1c0 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,5 +1,6 @@ def String[] modules = [ 'core', + 'data', 'remoting', 'web', 'ldap', @@ -19,6 +20,7 @@ def String[] samples = [ 'openid-xml', 'aspectj-xml', 'aspectj-jc', + 'data-jc', 'gae-xml', 'dms-xml', 'preauth-xml',