diff --git a/examples/java-client/pom.xml b/examples/java-client/pom.xml index 561945b..655a0ab 100644 --- a/examples/java-client/pom.xml +++ b/examples/java-client/pom.xml @@ -21,7 +21,7 @@ org.geoserver.acl gs-acl-bom - ${acl.version} + ${project.version} pom import diff --git a/examples/pom.xml b/examples/pom.xml index fc9d018..43e0066 100644 --- a/examples/pom.xml +++ b/examples/pom.xml @@ -16,7 +16,6 @@ java-client - 2.1-SNAPSHOT UTF-8 UTF-8 diff --git a/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/InternalSecurityConfiguration.java b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/InternalSecurityAutoConfiguration.java similarity index 65% rename from src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/InternalSecurityConfiguration.java rename to src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/InternalSecurityAutoConfiguration.java index 2fd9741..e9925fc 100644 --- a/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/InternalSecurityConfiguration.java +++ b/src/artifacts/api/src/main/java/org/geoserver/acl/autoconfigure/security/InternalSecurityAutoConfiguration.java @@ -6,6 +6,7 @@ import static org.springframework.util.StringUtils.hasText; +import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; @@ -19,7 +20,7 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.factory.PasswordEncoderFactories; -import org.springframework.security.crypto.password.DelegatingPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.provisioning.InMemoryUserDetailsManager; import java.util.ArrayList; @@ -30,26 +31,27 @@ @ConditionalOnInternalAuthenticationEnabled @EnableConfigurationProperties(SecurityConfigProperties.class) @Slf4j(topic = "org.geoserver.acl.autoconfigure.security") -public class InternalSecurityConfiguration { +public class InternalSecurityAutoConfiguration { @Bean AuthenticationProvider internalAuthenticationProvider( - @Qualifier("internalUserDetailsService") - UserDetailsService internalUserDetailsService) { + @Qualifier("internalUserDetailsService") UserDetailsService internalUserDetailsService, + PasswordEncoder encoder) { DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); provider.setAuthoritiesMapper(new NullAuthoritiesMapper()); provider.setUserDetailsService(internalUserDetailsService); - DelegatingPasswordEncoder encoder = - (DelegatingPasswordEncoder) - PasswordEncoderFactories.createDelegatingPasswordEncoder(); - provider.setPasswordEncoder(encoder); return provider; } + @Bean + PasswordEncoder passwordEncoder() { + return PasswordEncoderFactories.createDelegatingPasswordEncoder(); + } + @Bean("internalUserDetailsService") UserDetailsService internalUserDetailsService(SecurityConfigProperties config) { @@ -58,14 +60,15 @@ UserDetailsService internalUserDetailsService(SecurityConfigProperties config) { Collection authUsers = new ArrayList<>(); users.forEach( (username, userinfo) -> { - validate(username, userinfo); - log.info( - "Loading internal user {}, admin: {}, enabled: {}", - username, - userinfo.isAdmin(), - userinfo.isEnabled()); - UserDetails user = toUserDetails(username, userinfo); - authUsers.add(user); + if (userinfo.isEnabled()) { + log.info( + "Loading internal user {}, admin: {}, enabled: {}", + username, + userinfo.isAdmin(), + userinfo.isEnabled()); + UserDetails user = toUserDetails(username, userinfo); + authUsers.add(user); + } }); long enabledUsers = authUsers.stream().filter(UserDetails::isEnabled).count(); @@ -79,20 +82,27 @@ UserDetailsService internalUserDetailsService(SecurityConfigProperties config) { } private UserDetails toUserDetails( - String username, SecurityConfigProperties.Internal.UserInfo u) { + String username, SecurityConfigProperties.Internal.UserInfo userinfo) { + validate(username, userinfo); return User.builder() .username(username) - .password(u.getPassword()) - .authorities(u.authorities()) - .disabled(!u.isEnabled()) + .password(ensurePrefixed(userinfo.getPassword())) + .authorities(userinfo.authorities()) + .disabled(!userinfo.isEnabled()) .build(); } - private void validate(final String name, SecurityConfigProperties.Internal.UserInfo info) { - if (info.isEnabled()) { - if (!hasText(name)) throw new IllegalArgumentException("User has no name: " + info); - if (!hasText(info.getPassword())) - throw new IllegalArgumentException("User has no password " + name + ": " + info); + private String ensurePrefixed(@NonNull String password) { + if (!password.matches("(\\{.+\\}).+")) { + return "{noop}%s".formatted(password); } + + return password; + } + + private void validate(final String name, SecurityConfigProperties.Internal.UserInfo info) { + if (!hasText(name)) throw new IllegalArgumentException("User has no name: " + info); + if (!hasText(info.getPassword())) + throw new IllegalArgumentException("User %s has no password".formatted(name)); } } diff --git a/src/artifacts/api/src/main/resources/META-INF/spring.factories b/src/artifacts/api/src/main/resources/META-INF/spring.factories index 4634ff2..67afced 100644 --- a/src/artifacts/api/src/main/resources/META-INF/spring.factories +++ b/src/artifacts/api/src/main/resources/META-INF/spring.factories @@ -7,7 +7,7 @@ org.springframework.boot.autoconfigure.EnableAutoConfiguration=\ org.geoserver.acl.autoconfigure.persistence.JPAIntegrationAutoConfiguration,\ org.geoserver.acl.autoconfigure.api.RulesApiAutoConfiguration,\ org.geoserver.acl.autoconfigure.security.AclServiceSecurityAutoConfiguration,\ -org.geoserver.acl.autoconfigure.security.InternalSecurityConfiguration,\ +org.geoserver.acl.autoconfigure.security.InternalSecurityAutoConfiguration,\ org.geoserver.acl.autoconfigure.security.PreAuthenticationSecurityAutoConfiguration,\ org.geoserver.acl.autoconfigure.security.AuthenticationManagerAutoConfiguration,\ org.geoserver.acl.autoconfigure.springdoc.SpringDocAutoConfiguration,\ diff --git a/src/artifacts/api/src/main/resources/application.yml b/src/artifacts/api/src/main/resources/application.yml index abb8d6e..bf1edc4 100644 --- a/src/artifacts/api/src/main/resources/application.yml +++ b/src/artifacts/api/src/main/resources/application.yml @@ -107,6 +107,7 @@ springdoc: enabled: true #results in /swagger-ui/index.html path: /index.html + disable-swagger-default-url: true try-it-out-enabled: true tags-sorter: alpha @@ -158,20 +159,23 @@ geoserver: admin: true enabled: ${acl.users.admin.enabled} password: "${acl.users.admin.password}" - # password is the bcrypt encoded value, for example, for pwd s3cr3t: + # the following sample password is the bcrypt encoded value, for example, for pwd s3cr3t: # password: "{bcrypt}$2a$10$eMyaZRLZBAZdor8nOX.qwuwOyWazXjR2hddGLCT6f6c382WiwdQGG" geoserver: # special user for GeoServer to ACL communication # Using a `{noop}` default credentials for performance, since bcrypt adds a significant per-request overhead - # in the orther of 100ms. In production it should be replaced by a docker/k8s secret + # in the orther of 100ms. In production it should be replaced by a docker/k8s secret. To simplify defining and + # reusing secrets for both the server and client config, a noop encrypted password is allowed not to have the + # {noop} prefix. admin: true enabled: ${acl.users.geoserver.enabled} password: "${acl.users.geoserver.password}" +# Sample non-admin user: # user: # admin: false # enabled: true # # password is the bcrypt encoded value for s3cr3t -# password: "{bcrypt}$2a$10$eMyaZRLZBAZdor8nOX.qwuwOyWazXjR2hddGLCT6f6c382WiwdQGG" +# password: "{noop}changeme" --- spring.config.activate.on-profile: local diff --git a/src/artifacts/api/src/main/resources/values.yml b/src/artifacts/api/src/main/resources/values.yml index 62a1c9b..24a2946 100644 --- a/src/artifacts/api/src/main/resources/values.yml +++ b/src/artifacts/api/src/main/resources/values.yml @@ -29,7 +29,7 @@ acl.security.basic.enabled: true acl.users.admin.enabled: true acl.users.admin.password: "{bcrypt}$2a$10$eMyaZRLZBAZdor8nOX.qwuwOyWazXjR2hddGLCT6f6c382WiwdQGG" acl.users.geoserver.enabled: true -acl.users.geoserver.password: "{noop}s3cr3t" +acl.users.geoserver.password: "s3cr3t" #Example of how to add additional users: #geoserver: diff --git a/src/artifacts/api/src/test/java/org/geoserver/acl/autoconfigure/security/InternalSecurityAutoConfigurationTest.java b/src/artifacts/api/src/test/java/org/geoserver/acl/autoconfigure/security/InternalSecurityAutoConfigurationTest.java new file mode 100644 index 0000000..3eef9f2 --- /dev/null +++ b/src/artifacts/api/src/test/java/org/geoserver/acl/autoconfigure/security/InternalSecurityAutoConfigurationTest.java @@ -0,0 +1,148 @@ +/* (c) 2023 Open Source Geospatial Foundation - all rights reserved + * This code is licensed under the GPL 2.0 license, available at the root + * application directory. + */ +package org.geoserver.acl.autoconfigure.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; + +import java.util.List; + +class InternalSecurityAutoConfigurationTest { + + private ApplicationContextRunner runner = + new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(InternalSecurityAutoConfiguration.class)); + + @Test + void conditionalOnInternalAuthenticationEnabledIsDisabledByDefault() { + runner.run( + context -> + assertThat(context) + .hasNotFailed() + .doesNotHaveBean(AuthenticationProvider.class)); + } + + @Test + void conditionalOnInternalAuthenticationEnabled() { + runner.withPropertyValues("geoserver.acl.security.internal.enabled=true") + .run( + context -> + assertThat(context) + .hasNotFailed() + .hasSingleBean(UserDetailsService.class) + .hasSingleBean(PasswordEncoder.class) + .hasSingleBean(AuthenticationProvider.class) + .getBean(AuthenticationProvider.class) + .isExactlyInstanceOf(DaoAuthenticationProvider.class)); + } + + @Test + void testUsers() { + runner.withPropertyValues( + "geoserver.acl.security.internal.enabled=true", + // define some users + // admin user + "geoserver.acl.security.internal.users.testadmin.enabled=true", + "geoserver.acl.security.internal.users.testadmin.admin=true", + "geoserver.acl.security.internal.users.testadmin.password={bcrypt}$2a$10$eMyaZRLZBAZdor8nOX.qwuwOyWazXjR2hddGLCT6f6c382WiwdQGG", + // non-admin user + "geoserver.acl.security.internal.users.testuser.enabled=true", + "geoserver.acl.security.internal.users.testuser.admin=false", + "geoserver.acl.security.internal.users.testuser.password={noop}changeme") + .run( + context -> { + assertThat(context) + .hasNotFailed() + .hasSingleBean(UserDetailsService.class) + .getBean(UserDetailsService.class) + .isInstanceOf(InMemoryUserDetailsManager.class); + + UserDetailsService service = context.getBean(UserDetailsService.class); + UserDetails admin = service.loadUserByUsername("testadmin"); + assertThat(admin.getPassword()) + .isEqualTo( + "{bcrypt}$2a$10$eMyaZRLZBAZdor8nOX.qwuwOyWazXjR2hddGLCT6f6c382WiwdQGG"); + assertThat(admin.getAuthorities()) + .hasSize(1) + .map(GrantedAuthority::getAuthority) + .isEqualTo(List.of("ROLE_ADMIN")); + + UserDetails user = service.loadUserByUsername("testuser"); + assertThat(user.getPassword()).isEqualTo("{noop}changeme"); + assertThat(user.getAuthorities()) + .hasSize(1) + .map(GrantedAuthority::getAuthority) + .isEqualTo(List.of("ROLE_USER")); + }); + } + + @Test + void testFailureOnEnabledPasswordLessUser() { + runner.withPropertyValues( + "geoserver.acl.security.internal.enabled=true", + // ill-defined passwsord-less user + "geoserver.acl.security.internal.users.baduser.enabled=true") + .run( + context -> { + assertThat(context) + .hasFailed() + .getFailure() + .hasMessageContaining("User baduser has no password"); + }); + } + + @Test + void testDisabledPasswordLessUserIgnored() { + runner.withPropertyValues( + "geoserver.acl.security.internal.enabled=true", + // ill-defined but disabled passwsord-less user + "geoserver.acl.security.internal.users.baduser.enabled=false", + "geoserver.acl.security.internal.users.baduser.admin=true") + .run( + context -> { + assertThat(context).hasNotFailed(); + + UserDetailsService service = context.getBean(UserDetailsService.class); + + assertThrows( + UsernameNotFoundException.class, + () -> service.loadUserByUsername("baduser")); + }); + } + + @Test + void testUsersPlainTextPasswordMappedAsNoop() { + runner.withPropertyValues( + "geoserver.acl.security.internal.enabled=true", + "geoserver.acl.security.internal.users.testuser.enabled=true", + "geoserver.acl.security.internal.users.testuser.admin=false", + "geoserver.acl.security.internal.users.testuser.password=changeme") + .run( + context -> { + UserDetailsService service = context.getBean(UserDetailsService.class); + UserDetails user = service.loadUserByUsername("testuser"); + assertThat(user.getPassword()) + .as( + """ + plain text password should have been mapped as \ + a '{noop}' prefixed literal + """) + .isEqualTo("{noop}changeme"); + }); + } +}