diff --git a/servlet/spring-boot/java/hello/gradle/libs.versions.toml b/servlet/spring-boot/java/hello/gradle/libs.versions.toml deleted file mode 120000 index 8adb6c9..0000000 --- a/servlet/spring-boot/java/hello/gradle/libs.versions.toml +++ /dev/null @@ -1 +0,0 @@ -../../../../../gradle/libs.versions.toml \ No newline at end of file diff --git a/servlet/spring-boot/java/hello/src/main/resources/templates/index.html b/servlet/spring-boot/java/hello/src/main/resources/templates/index.html deleted file mode 100644 index 4e71378..0000000 --- a/servlet/spring-boot/java/hello/src/main/resources/templates/index.html +++ /dev/null @@ -1,9 +0,0 @@ - - - Hello Security! - - -

Hello Security

- Log Out - - \ No newline at end of file diff --git a/servlet/spring-boot/java/hello/src/test/java/example/HelloApplicationTests.java b/servlet/spring-boot/java/hello/src/test/java/example/HelloApplicationTests.java deleted file mode 100644 index ac8c2a2..0000000 --- a/servlet/spring-boot/java/hello/src/test/java/example/HelloApplicationTests.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2002-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.junit.jupiter.api.Test; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.web.servlet.MockMvc; - -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; - -/** - * @author Rob Winch - */ -@SpringBootTest -@AutoConfigureMockMvc -public class HelloApplicationTests { - - @Autowired - private MockMvc mockMvc; - - @Test - void indexThenOk() throws Exception { - // @formatter:off - this.mockMvc.perform(get("/")) - .andExpect(status().isOk()); - // @formatter:on - } - -} diff --git a/servlet/spring-boot/java/security/README.adoc b/servlet/spring-boot/java/security/README.adoc new file mode 100644 index 0000000..3cf160e --- /dev/null +++ b/servlet/spring-boot/java/security/README.adoc @@ -0,0 +1,72 @@ += A CRUD Spring LDAP application using Spring Boot + +The application is protected by Spring Security and uses an embedded UnboundID container for its LDAP server. + +You can authenticate with HTTP basic using `dante`/`secret`: + +[source,bash] +---- +curl --user dante:secret localhost:8080/people +---- + +And you should see a response like this one: + +[source,bash] +---- +[ +[ + { + "dn": "uid=dante,ou=people", + "lastName": "Alvarez", + "username": "dante" + }, + { + "dn": "uid=hal,ou=people", + "lastName": "Hal", + "username": "hal" + }, + { + "dn": "uid=may,ou=people", + "lastName": "May", + "username": "may" + }, + // ... +} +---- + +Or, if you use `hal`/`sorrydave`, you'll see more information: + +[source,bash] +---- +[ + { + "dn": "uid=dante,ou=people", + "lastName": "Alvarez", + "name": "Dante Alvarez", + "username": "dante" + }, + { + "dn": "uid=hal,ou=people", + "lastName": "Hal", + "name": "Hal 2000", + "username": "hal" + }, + { + "dn": "uid=may,ou=people", + "lastName": "May", + "name": "May Bea", + "username": "may" + }, + // ... +---- + +The sample supports the following operations: + +* `GET /people` - retrieve all the people in the application +* `GET /people/uid=may,ou=people` - retrieve May Bea's details; you can replace the DN with another one to see another person's information +* `GET /people/me` - retrieve the current user's details +* `POST /people` - add a new person, for example `{ "username": "newuser", "sn": "User", "cn": "New User" }` +* `PUT /people/uid=may,ou=people` - update May Bea's details, supports partial update; you can replace the DN with another one to make changes to a different entry +* `DELETE /people/uid=may,ou=people` - remove May Bea from the system; you can replace the DN with another one to remove a different entry + +To run the sample, do `./gradlew :bootRun`. diff --git a/servlet/spring-boot/java/hello/build.gradle b/servlet/spring-boot/java/security/build.gradle similarity index 56% rename from servlet/spring-boot/java/hello/build.gradle rename to servlet/spring-boot/java/security/build.gradle index d80999c..2141c23 100644 --- a/servlet/spring-boot/java/hello/build.gradle +++ b/servlet/spring-boot/java/security/build.gradle @@ -11,12 +11,17 @@ repositories { maven { url "https://repo.spring.io/snapshot" } } +ext['spring-security.version'] = "6.4.0-SNAPSHOT" dependencies { - implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-data-ldap' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.security:spring-security-ldap' + implementation 'com.unboundid:unboundid-ldapsdk:7.0.1' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' } tasks.withType(Test).configureEach { diff --git a/servlet/spring-boot/java/hello/gradle.properties b/servlet/spring-boot/java/security/gradle.properties similarity index 73% rename from servlet/spring-boot/java/hello/gradle.properties rename to servlet/spring-boot/java/security/gradle.properties index 06c276c..817ab8a 100644 --- a/servlet/spring-boot/java/hello/gradle.properties +++ b/servlet/spring-boot/java/security/gradle.properties @@ -1,4 +1,4 @@ version=6.1.1 -spring-ldap.version=6.4.0-SNAPSHOT +spring-ldap.version=3.3.0-SNAPSHOT org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError org.gradle.caching=true diff --git a/servlet/spring-boot/java/security/gradle/libs.versions.toml b/servlet/spring-boot/java/security/gradle/libs.versions.toml new file mode 100644 index 0000000..4266b1c --- /dev/null +++ b/servlet/spring-boot/java/security/gradle/libs.versions.toml @@ -0,0 +1,12 @@ +[versions] +org-springframework-boot = "3.4.0-SNAPSHOT" + +[libraries] +org-springframework-spring-framework-bom = "org.springframework:spring-framework-bom:6.2.0-M6" +org-springframework-data-spring-data-bom = "org.springframework.data:spring-data-bom:2024.0.2" +org-springframework-ldap-spring-ldap-core = "org.springframework.ldap:spring-ldap-core:3.3.0-SNAPSHOT" +org-springframework-ldap-spring-ldap-test = "org.springframework.ldap:spring-ldap-test:3.3.0-SNAPSHOT" + +[plugins] +io-spring-dependency-management = { id = "io.spring.dependency-management", version = "1.1.6" } +org-springframework-boot = { id = "org.springframework.boot", version.ref = "org-springframework-boot" } diff --git a/servlet/spring-boot/java/hello/gradle/wrapper/gradle-wrapper.jar b/servlet/spring-boot/java/security/gradle/wrapper/gradle-wrapper.jar similarity index 100% rename from servlet/spring-boot/java/hello/gradle/wrapper/gradle-wrapper.jar rename to servlet/spring-boot/java/security/gradle/wrapper/gradle-wrapper.jar diff --git a/servlet/spring-boot/java/hello/gradle/wrapper/gradle-wrapper.properties b/servlet/spring-boot/java/security/gradle/wrapper/gradle-wrapper.properties similarity index 100% rename from servlet/spring-boot/java/hello/gradle/wrapper/gradle-wrapper.properties rename to servlet/spring-boot/java/security/gradle/wrapper/gradle-wrapper.properties diff --git a/servlet/spring-boot/java/hello/gradlew b/servlet/spring-boot/java/security/gradlew similarity index 100% rename from servlet/spring-boot/java/hello/gradlew rename to servlet/spring-boot/java/security/gradlew diff --git a/servlet/spring-boot/java/hello/gradlew.bat b/servlet/spring-boot/java/security/gradlew.bat similarity index 100% rename from servlet/spring-boot/java/hello/gradlew.bat rename to servlet/spring-boot/java/security/gradlew.bat diff --git a/servlet/spring-boot/java/hello/settings.gradle b/servlet/spring-boot/java/security/settings.gradle similarity index 100% rename from servlet/spring-boot/java/hello/settings.gradle rename to servlet/spring-boot/java/security/settings.gradle diff --git a/servlet/spring-boot/java/security/src/main/java/example/Person.java b/servlet/spring-boot/java/security/src/main/java/example/Person.java new file mode 100644 index 0000000..9e14647 --- /dev/null +++ b/servlet/spring-boot/java/security/src/main/java/example/Person.java @@ -0,0 +1,95 @@ +/* + * Copyright 2002-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 javax.naming.Name; + +import example.security.NullMethodAuthorizationDeniedHandler; + +import org.springframework.ldap.odm.annotations.Attribute; +import org.springframework.ldap.odm.annotations.DnAttribute; +import org.springframework.ldap.odm.annotations.Entry; +import org.springframework.ldap.odm.annotations.Id; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authorization.method.HandleAuthorizationDenied; + +@Entry(objectClasses = { "inetOrgPerson", "organizationalPerson", "person", "top" }, base = "ou=people") +public class Person { + + @Id + private Name dn; + + @DnAttribute(value = "uid", index = 1) + @Attribute(name = "uid") + private String username; + + @Attribute(name = "cn") + private String name; + + @Attribute(name = "sn") + private String lastName; + + public Person() { + + } + + public Person(String username) { + this.username = username; + } + + public Person(Person person) { + this.dn = person.getDn(); + this.username = person.getUsername(); + this.name = person.getName(); + this.lastName = person.getLastName(); + } + + public Name getDn() { + return this.dn; + } + + public void setDn(Name dn) { + this.dn = dn; + } + + public String getUsername() { + return this.username; + } + + public void setUsername(String username) { + this.username = username; + } + + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + @HandleAuthorizationDenied(handlerClass = NullMethodAuthorizationDeniedHandler.class) + public String getName() { + return this.name; + } + + public void setName(String name) { + this.name = name; + } + + public String getLastName() { + return this.lastName; + } + + public void setLastName(String lastName) { + this.lastName = lastName; + } + +} diff --git a/servlet/spring-boot/java/security/src/main/java/example/PersonContextMapper.java b/servlet/spring-boot/java/security/src/main/java/example/PersonContextMapper.java new file mode 100644 index 0000000..f501959 --- /dev/null +++ b/servlet/spring-boot/java/security/src/main/java/example/PersonContextMapper.java @@ -0,0 +1,84 @@ +/* + * Copyright 2002-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.Collection; +import java.util.List; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import example.ldap.DirContextOperationsMapper; + +import org.springframework.ldap.core.DirContextAdapter; +import org.springframework.ldap.core.DirContextOperations; +import org.springframework.ldap.core.LdapClient; +import org.springframework.ldap.odm.core.ObjectDirectoryMapper; +import org.springframework.ldap.odm.core.impl.DefaultObjectDirectoryMapper; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.ldap.userdetails.UserDetailsContextMapper; + +public final class PersonContextMapper implements UserDetailsContextMapper { + + private final LdapClient ldap; + + private final ObjectDirectoryMapper odm = new DefaultObjectDirectoryMapper(); + + public PersonContextMapper(LdapClient ldap) { + this.ldap = ldap; + } + + @Override + public UserDetails mapUserFromContext(DirContextOperations ctx, String username, + Collection authorities) { + Person person = this.odm.mapFromLdapDataEntry(ctx, Person.class); + DirContextOperationsMapper toAuthority = (c) -> { + List members = List.of(c.getStringAttributes("uniqueMember")); + return (members.contains(ctx.getNameInNamespace())) ? "ROLE_ADMIN" : "ROLE_USER"; + }; + String authority = this.ldap.search().name("cn=managers,ou=groups").toObject(toAuthority); + return new UserDetailsPerson(person, authority); + } + + @Override + public void mapUserToContext(UserDetails user, DirContextAdapter ctx) { + throw new UnsupportedOperationException("not supported"); + } + + @JsonSerialize(as = Person.class) + public static class UserDetailsPerson extends Person implements UserDetails { + + Collection authorities; + + public UserDetailsPerson(Person person, String authority) { + super(person); + this.authorities = List.of(new SimpleGrantedAuthority(authority)); + } + + @Override + public Collection getAuthorities() { + return this.authorities; + } + + @Override + public String getPassword() { + return null; + } + + } + +} diff --git a/servlet/spring-boot/java/security/src/main/java/example/PersonController.java b/servlet/spring-boot/java/security/src/main/java/example/PersonController.java new file mode 100644 index 0000000..f34700d --- /dev/null +++ b/servlet/spring-boot/java/security/src/main/java/example/PersonController.java @@ -0,0 +1,113 @@ +/* + * Copyright 2002-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.io.IOException; + +import javax.naming.Name; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.directory.ModificationItem; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.springframework.ldap.core.DirContextOperations; +import org.springframework.ldap.core.LdapClient; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.authorization.method.AuthorizeReturnObject; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Controller for "/". + * + * @author Josh Cummings + */ +@RestController +@RequestMapping("/people") +@AuthorizeReturnObject +public class PersonController { + + private final PersonRepository persons; + + private final ObjectMapper mapper; + + private final LdapClient ldap; + + public PersonController(PersonRepository persons, ObjectMapper mapper, LdapClient ldap) { + this.persons = persons; + this.mapper = mapper; + this.ldap = ldap; + } + + @GetMapping + @PreAuthorize("hasAuthority('ROLE_USER')") + public Iterable list() { + Iterable people = this.persons.findAll(); + return people; + } + + @GetMapping("/me") + @PreAuthorize("isAuthenticated()") + public Person me(@AuthenticationPrincipal Person person) { + return person; + } + + @GetMapping("/{uid}") + @PreAuthorize("hasAuthority('ROLE_USER')") + public Person get(@PathVariable("uid") Name uid) { + return this.persons.findByDn(uid); + } + + @PostMapping + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + public Person create(@RequestBody Person person) { + return this.persons.save(person); + } + + @PutMapping("/{uid}") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + public Person update(@PathVariable("uid") Name uid, @RequestBody String person) throws IOException { + Person toUpdate = this.persons.findByDn(uid); + this.mapper.readerForUpdating(toUpdate).readValue(person, Person.class); + toUpdate.setDn(uid); + return this.persons.save(toUpdate); + } + + @PutMapping("/{uid}/password") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + public void updatePassword(@PathVariable("uid") Name uid, @RequestBody Attributes attributes) { + Attribute password = attributes.get("userPassword"); + this.ldap.modify(uid) + .attributes(new ModificationItem(DirContextOperations.REPLACE_ATTRIBUTE, password)) + .execute(); + } + + @DeleteMapping("/{uid}") + @PreAuthorize("hasAuthority('ROLE_ADMIN')") + public void delete(@PathVariable("uid") Name uid) { + this.persons.deleteById(uid); + } + +} diff --git a/servlet/spring-boot/java/hello/src/main/java/example/IndexController.java b/servlet/spring-boot/java/security/src/main/java/example/PersonRepository.java similarity index 65% rename from servlet/spring-boot/java/hello/src/main/java/example/IndexController.java rename to servlet/spring-boot/java/security/src/main/java/example/PersonRepository.java index 2114050..9d25dad 100644 --- a/servlet/spring-boot/java/hello/src/main/java/example/IndexController.java +++ b/servlet/spring-boot/java/security/src/main/java/example/PersonRepository.java @@ -16,20 +16,15 @@ package example; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.GetMapping; +import javax.naming.Name; -/** - * Controller for "/". - * - * @author Joe Grandja - */ -@Controller -public class IndexController { +import org.springframework.data.ldap.repository.LdapRepository; +import org.springframework.ldap.NameNotFoundException; - @GetMapping("/") - public String index() { - return "index"; +public interface PersonRepository extends LdapRepository { + + default Person findByDn(Name dn) { + return findById(dn).orElseThrow(() -> new NameNotFoundException("user not found")); } } diff --git a/servlet/spring-boot/java/hello/src/main/java/example/HelloApplication.java b/servlet/spring-boot/java/security/src/main/java/example/SecurityApplication.java similarity index 90% rename from servlet/spring-boot/java/hello/src/main/java/example/HelloApplication.java rename to servlet/spring-boot/java/security/src/main/java/example/SecurityApplication.java index 6bd754a..69ed34c 100644 --- a/servlet/spring-boot/java/hello/src/main/java/example/HelloApplication.java +++ b/servlet/spring-boot/java/security/src/main/java/example/SecurityApplication.java @@ -25,10 +25,10 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; * @author Joe Grandja */ @SpringBootApplication -public class HelloApplication { +public class SecurityApplication { public static void main(String[] args) { - SpringApplication.run(HelloApplication.class, args); + SpringApplication.run(SecurityApplication.class, args); } } diff --git a/servlet/spring-boot/java/security/src/main/java/example/SecurityConfig.java b/servlet/spring-boot/java/security/src/main/java/example/SecurityConfig.java new file mode 100644 index 0000000..577c018 --- /dev/null +++ b/servlet/spring-boot/java/security/src/main/java/example/SecurityConfig.java @@ -0,0 +1,89 @@ +/* + * Copyright 2002-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.springframework.beans.factory.config.BeanDefinition; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; +import org.springframework.ldap.core.ContextSource; +import org.springframework.ldap.core.LdapClient; +import org.springframework.ldap.core.support.BaseLdapPathContextSource; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.ldap.DefaultSpringSecurityContextSource; +import org.springframework.security.ldap.authentication.BindAuthenticator; +import org.springframework.security.ldap.authentication.LdapAuthenticationProvider; +import org.springframework.security.ldap.authentication.LdapAuthenticator; +import org.springframework.security.ldap.server.UnboundIdContainer; +import org.springframework.security.web.SecurityFilterChain; + +import static org.springframework.security.config.Customizer.withDefaults; + +@Configuration(proxyBeanMethods = false) +@EnableMethodSecurity +public class SecurityConfig { + + @Bean + @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + static RoleHierarchy roles() { + return RoleHierarchyImpl.withDefaultRolePrefix().role("ADMIN").implies("USER").build(); + } + + @Bean + SecurityFilterChain http(HttpSecurity http) throws Exception { + http.authorizeHttpRequests((requests) -> requests.anyRequest().authenticated()) + .httpBasic(withDefaults()) + .csrf((csrf) -> csrf.disable()); + return http.build(); + } + + @Bean + UnboundIdContainer ldapContainer() { + UnboundIdContainer container = new UnboundIdContainer("dc=springframework,dc=org", "classpath:users.ldif"); + container.setPort(0); + return container; + } + + @Bean + ContextSource contextSource(UnboundIdContainer container) { + int port = container.getPort(); + return new DefaultSpringSecurityContextSource("ldap://localhost:" + port + "/dc=springframework,dc=org"); + } + + @Bean + BindAuthenticator authenticator(BaseLdapPathContextSource contextSource) { + BindAuthenticator authenticator = new BindAuthenticator(contextSource); + authenticator.setUserDnPatterns(new String[] { "uid={0},ou=people" }); + return authenticator; + } + + @Bean + LdapAuthenticationProvider authenticationProvider(LdapAuthenticator authenticator, LdapClient ldap) { + LdapAuthenticationProvider provider = new LdapAuthenticationProvider(authenticator); + provider.setUserDetailsContextMapper(new PersonContextMapper(ldap)); + return provider; + } + + @Bean + LdapClient ldapClient(ContextSource contextSource) { + return LdapClient.create(contextSource); + } + +} diff --git a/servlet/spring-boot/java/security/src/main/java/example/WebConfig.java b/servlet/spring-boot/java/security/src/main/java/example/WebConfig.java new file mode 100644 index 0000000..2a2474f --- /dev/null +++ b/servlet/spring-boot/java/security/src/main/java/example/WebConfig.java @@ -0,0 +1,44 @@ +/* + * Copyright 2002-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 javax.naming.Name; +import javax.naming.directory.Attributes; + +import com.fasterxml.jackson.databind.module.SimpleModule; +import example.ldap.AttributesDeserializer; +import example.ldap.AttributesSerializer; +import example.ldap.NameDeserializer; +import example.ldap.NameSerializer; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +public class WebConfig { + + @Bean + public SimpleModule nameModule() { + SimpleModule module = new SimpleModule(); + module.addSerializer(Name.class, new NameSerializer()); + module.addDeserializer(Name.class, new NameDeserializer()); + module.addSerializer(Attributes.class, new AttributesSerializer()); + module.addDeserializer(Attributes.class, new AttributesDeserializer()); + return module; + } + +} diff --git a/servlet/spring-boot/java/security/src/main/java/example/ldap/AttributesDeserializer.java b/servlet/spring-boot/java/security/src/main/java/example/ldap/AttributesDeserializer.java new file mode 100644 index 0000000..d1e49e0 --- /dev/null +++ b/servlet/spring-boot/java/security/src/main/java/example/ldap/AttributesDeserializer.java @@ -0,0 +1,49 @@ +/* + * Copyright 2002-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.ldap; + +import java.io.IOException; +import java.util.Map; + +import javax.naming.directory.Attributes; + +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import org.springframework.ldap.core.NameAwareAttributes; + +public class AttributesDeserializer extends JsonDeserializer { + + @Override + public Attributes deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JacksonException { + NameAwareAttributes attributes = new NameAwareAttributes(); + if (p.currentToken() == JsonToken.START_OBJECT) { + p.nextToken(); + } + Map map = p.getCodec().readValue(p, new TypeReference<>() { + }); + for (Map.Entry entry : map.entrySet()) { + attributes.put(entry.getKey(), entry.getValue()); + } + return attributes; + } + +} diff --git a/servlet/spring-boot/java/security/src/main/java/example/ldap/AttributesSerializer.java b/servlet/spring-boot/java/security/src/main/java/example/ldap/AttributesSerializer.java new file mode 100644 index 0000000..c56f052 --- /dev/null +++ b/servlet/spring-boot/java/security/src/main/java/example/ldap/AttributesSerializer.java @@ -0,0 +1,59 @@ +/* + * Copyright 2002-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.ldap; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import javax.naming.directory.Attributes; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +import org.springframework.ldap.core.NameAwareAttribute; +import org.springframework.ldap.core.NameAwareAttributes; +import org.springframework.util.CollectionUtils; + +public class AttributesSerializer extends JsonSerializer { + + @Override + public void serialize(Attributes value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + if (!(value instanceof NameAwareAttributes attributes)) { + serializers.defaultSerializeValue(value, gen); + return; + } + Iterator iterator = CollectionUtils.toIterator(attributes.getAll()); + while (iterator.hasNext()) { + NameAwareAttribute attribute = iterator.next(); + if (attribute.size() == 0) { + serializers.defaultSerializeField(attribute.getID(), null, gen); + } + else if (attribute.size() == 1) { + serializers.defaultSerializeField(attribute.getID(), attribute.get(), gen); + } + else { + List mapElement = new ArrayList<>(); + attribute.forEach(mapElement::add); + serializers.defaultSerializeField(attribute.getID(), mapElement, gen); + } + } + } + +} diff --git a/servlet/spring-boot/java/security/src/main/java/example/ldap/DirContextOperationsMapper.java b/servlet/spring-boot/java/security/src/main/java/example/ldap/DirContextOperationsMapper.java new file mode 100644 index 0000000..03a380a --- /dev/null +++ b/servlet/spring-boot/java/security/src/main/java/example/ldap/DirContextOperationsMapper.java @@ -0,0 +1,33 @@ +/* + * Copyright 2002-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.ldap; + +import javax.naming.NamingException; + +import org.springframework.ldap.core.ContextMapper; +import org.springframework.ldap.core.DirContextOperations; + +public interface DirContextOperationsMapper extends ContextMapper { + + @Override + default T mapFromContext(Object o) throws NamingException { + return mapFromDirContextOperations((DirContextOperations) o); + } + + T mapFromDirContextOperations(DirContextOperations source) throws NamingException; + +} diff --git a/servlet/spring-boot/java/security/src/main/java/example/ldap/NameDeserializer.java b/servlet/spring-boot/java/security/src/main/java/example/ldap/NameDeserializer.java new file mode 100644 index 0000000..4ca560f --- /dev/null +++ b/servlet/spring-boot/java/security/src/main/java/example/ldap/NameDeserializer.java @@ -0,0 +1,42 @@ +/* + * Copyright 2002-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.ldap; + +import java.io.IOException; + +import javax.naming.Name; + +import com.fasterxml.jackson.core.JacksonException; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import org.springframework.ldap.support.LdapUtils; + +public class NameDeserializer extends JsonDeserializer { + + @Override + public Name deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JacksonException { + if (p.currentToken() == JsonToken.START_OBJECT) { + p.nextToken(); + } + String value = p.getCodec().readValue(p, String.class); + return LdapUtils.newLdapName(value); + } + +} diff --git a/servlet/spring-boot/java/security/src/main/java/example/ldap/NameFormatter.java b/servlet/spring-boot/java/security/src/main/java/example/ldap/NameFormatter.java new file mode 100644 index 0000000..2e61564 --- /dev/null +++ b/servlet/spring-boot/java/security/src/main/java/example/ldap/NameFormatter.java @@ -0,0 +1,43 @@ +/* + * Copyright 2002-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.ldap; + +import java.util.Locale; + +import javax.naming.Name; + +import org.springframework.format.Formatter; +import org.springframework.lang.NonNull; +import org.springframework.ldap.support.LdapUtils; +import org.springframework.stereotype.Component; + +@Component +public class NameFormatter implements Formatter { + + @Override + @NonNull + public String print(@NonNull Name object, @NonNull Locale locale) { + return object.toString(); + } + + @Override + @NonNull + public Name parse(@NonNull String text, @NonNull Locale locale) { + return LdapUtils.newLdapName(text); + } + +} diff --git a/servlet/spring-boot/java/security/src/main/java/example/ldap/NameNotFoundHandler.java b/servlet/spring-boot/java/security/src/main/java/example/ldap/NameNotFoundHandler.java new file mode 100644 index 0000000..ad29321 --- /dev/null +++ b/servlet/spring-boot/java/security/src/main/java/example/ldap/NameNotFoundHandler.java @@ -0,0 +1,32 @@ +/* + * Copyright 2002-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.ldap; + +import org.springframework.http.ResponseEntity; +import org.springframework.ldap.NameNotFoundException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +public class NameNotFoundHandler { + + @ExceptionHandler(NameNotFoundException.class) + public ResponseEntity notFound() { + return ResponseEntity.notFound().build(); + } + +} diff --git a/servlet/spring-boot/java/security/src/main/java/example/ldap/NameSerializer.java b/servlet/spring-boot/java/security/src/main/java/example/ldap/NameSerializer.java new file mode 100644 index 0000000..1006000 --- /dev/null +++ b/servlet/spring-boot/java/security/src/main/java/example/ldap/NameSerializer.java @@ -0,0 +1,35 @@ +/* + * Copyright 2002-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.ldap; + +import java.io.IOException; + +import javax.naming.Name; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; + +public class NameSerializer extends JsonSerializer { + + @Override + public void serialize(Name name, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) + throws IOException { + serializerProvider.defaultSerializeValue(name.toString(), jsonGenerator); + } + +} diff --git a/servlet/spring-boot/java/security/src/main/java/example/security/NullMethodAuthorizationDeniedHandler.java b/servlet/spring-boot/java/security/src/main/java/example/security/NullMethodAuthorizationDeniedHandler.java new file mode 100644 index 0000000..fabd263 --- /dev/null +++ b/servlet/spring-boot/java/security/src/main/java/example/security/NullMethodAuthorizationDeniedHandler.java @@ -0,0 +1,33 @@ +/* + * Copyright 2002-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.security; + +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 NullMethodAuthorizationDeniedHandler implements MethodAuthorizationDeniedHandler { + + @Override + public Object handleDeniedInvocation(MethodInvocation methodInvocation, AuthorizationResult authorizationResult) { + return null; + } + +} diff --git a/servlet/spring-boot/java/security/src/main/resources/application.properties b/servlet/spring-boot/java/security/src/main/resources/application.properties new file mode 100644 index 0000000..269845c --- /dev/null +++ b/servlet/spring-boot/java/security/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.jackson.default-property-inclusion=non_null \ No newline at end of file diff --git a/servlet/spring-boot/java/security/src/main/resources/users.ldif b/servlet/spring-boot/java/security/src/main/resources/users.ldif new file mode 100644 index 0000000..7d1c9b1 --- /dev/null +++ b/servlet/spring-boot/java/security/src/main/resources/users.ldif @@ -0,0 +1,65 @@ +dn: ou=groups,dc=springframework,dc=org +objectclass: top +objectclass: organizationalUnit +ou: groups + +dn: ou=people,dc=springframework,dc=org +objectclass: top +objectclass: organizationalUnit +ou: people + +dn: uid=user,ou=people,dc=springframework,dc=org +objectclass: top +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +cn: User User +sn: User +uid: user +userPassword: password + +dn: uid=may,ou=people,dc=springframework,dc=org +objectclass: top +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +cn: May Bea +sn: May +uid: may +userPassword: later + +dn: uid=hal,ou=people,dc=springframework,dc=org +objectclass: top +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +cn: Hal 2000 +sn: Hal +uid: hal +userPassword: sorrydave + +dn: uid=dante,ou=people,dc=springframework,dc=org +objectclass: top +objectclass: person +objectclass: organizationalPerson +objectclass: inetOrgPerson +cn: Dante Alvarez +sn: Alvarez +uid: dante +userPassword: secret + +dn: cn=developers,ou=groups,dc=springframework,dc=org +objectclass: top +objectclass: groupOfUniqueNames +cn: developers +ou: developer +uniqueMember: uid=dante,ou=people,dc=springframework,dc=org +uniqueMember: uid=may,ou=people,dc=springframework,dc=org + +dn: cn=managers,ou=groups,dc=springframework,dc=org +objectclass: top +objectclass: groupOfUniqueNames +cn: managers +ou: manager +uniqueMember: uid=hal,ou=people,dc=springframework,dc=org +uniqueMember: uid=may,ou=people,dc=springframework,dc=org \ No newline at end of file diff --git a/servlet/spring-boot/java/security/src/test/java/example/SecurityApplicationTests.java b/servlet/spring-boot/java/security/src/test/java/example/SecurityApplicationTests.java new file mode 100644 index 0000000..860a0f7 --- /dev/null +++ b/servlet/spring-boot/java/security/src/test/java/example/SecurityApplicationTests.java @@ -0,0 +1,166 @@ +/* + * Copyright 2002-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 com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.ldap.support.LdapUtils; +import org.springframework.security.ldap.server.UnboundIdContainer; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * @author Rob Winch + */ +@SpringBootTest +@AutoConfigureMockMvc +public class SecurityApplicationTests { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper mapper; + + @Autowired + UnboundIdContainer container; + + @BeforeEach + void setup() { + this.container.start(); + } + + @AfterEach + void teardown() { + this.container.stop(); + } + + @Test + @WithMockUser + void authenticatedPersonsThenOk() throws Exception { + // @formatter:off + String json = this.mockMvc.perform(get("/people")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + List people = this.mapper.readValue(json, new TypeReference<>() { }); + assertThat(people).hasSize(4) + .extracting(Person::getUsername).containsExactlyInAnyOrder("user", "may", "hal", "dante"); + assertThat(people) + .extracting(Person::getName).containsOnlyNulls(); + // @formatter:on + } + + @Test + @WithMockUser(roles = "ADMIN") + void adminAuthenticatedPersonsThenOk() throws Exception { + // @formatter:off + String json = this.mockMvc.perform(get("/people")) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + List people = this.mapper.readValue(json, new TypeReference<>() { }); + assertThat(people).hasSize(4) + .extracting(Person::getUsername).containsExactlyInAnyOrder("user", "may", "hal", "dante"); + assertThat(people) + .extracting(Person::getName).containsExactlyInAnyOrder( + "User User", "May Bea", "Hal 2000", "Dante Alvarez"); + // @formatter:on + } + + @Test + void basicAuthenticatedPersonsThenOk() throws Exception { + // @formatter:off + String json = this.mockMvc.perform(get("/people").with(httpBasic("dante", "secret"))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + List people = this.mapper.readValue(json, new TypeReference<>() { }); + assertThat(people).hasSize(4) + .extracting(Person::getUsername).containsExactlyInAnyOrder("user", "may", "hal", "dante"); + assertThat(people) + .extracting(Person::getName).containsOnlyNulls(); + // @formatter:on + } + + @Test + void basicAdminAuthenticatedPersonsThenOk() throws Exception { + // @formatter:off + String json = this.mockMvc.perform(get("/people").with(httpBasic("may", "later"))) + .andExpect(status().isOk()) + .andReturn().getResponse().getContentAsString(); + List people = this.mapper.readValue(json, new TypeReference<>() { }); + assertThat(people).hasSize(4) + .extracting(Person::getUsername).containsExactlyInAnyOrder("user", "may", "hal", "dante"); + assertThat(people) + .extracting(Person::getName).containsExactlyInAnyOrder( + "User User", "May Bea", "Hal 2000", "Dante Alvarez"); + // @formatter:on + } + + @Test + void meThenCurrentPerson() throws Exception { + String json = this.mockMvc.perform(get("/people/me").with(httpBasic("user", "password"))) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + Person person = this.mapper.readValue(json, Person.class); + assertThat(person.getUsername()).isEqualTo("user"); + } + + @Test + @WithMockUser(roles = "ADMIN") + void partiallyUpdatePersonThenUpdated() throws Exception { + Person person = new Person("dante"); + person.setDn(LdapUtils.newLdapName("uid=dante,ou=people")); + person.setUsername("ari"); + String json = this.mapper.writeValueAsString(person); + String updated = this.mockMvc + .perform(put("/people/" + person.getDn()).content(json).contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(); + person = this.mapper.readValue(updated, Person.class); + assertThat(person.getUsername()).isEqualTo("ari"); + assertThat(person.getDn().toString()).isEqualTo("uid=ari,ou=people"); + } + + @Test + @WithMockUser(roles = "ADMIN") + void deletePersonThenRemoved() throws Exception { + String dn = "uid=hal,ou=people"; + this.mockMvc.perform(delete("/people/" + dn)).andExpect(status().isOk()); + this.mockMvc.perform(get("/people/" + dn)).andExpect(status().isNotFound()); + } + +} diff --git a/settings.gradle b/settings.gradle index 305641e..412f071 100644 --- a/settings.gradle +++ b/settings.gradle @@ -20,6 +20,7 @@ plugins { } include ":servlet:spring-boot:java:boot" +include ":servlet:spring-boot:java:security" include ":servlet:xml:java:odm" include ":servlet:xml:java:plain" include ":servlet:xml:java:user-admin"