From f5d629d2bf3e2f03db0f4134cf5dd810b1f25b15 Mon Sep 17 00:00:00 2001 From: John Niang Date: Mon, 20 Jun 2022 11:26:18 +0800 Subject: [PATCH] Add user support for authentication and authorization (#2163) * Move role and rolebinding extensions into core package * Add UserExtensionUserDetailService to find user and update password * Rename DefaultUserDetailService * Fix test errors * Add SuperAdminInitializer to initialize first user * Add unit tests for DefaultUserDetailService and UserService * Add test for verifying SuperAdminInitializer * Fix unstable test due to database reusability Signed-off-by: johnniang --- .../halo/app/config/HaloConfiguration.java | 18 ++ .../app/config/WebServerSecurityConfig.java | 27 ++- .../run/halo/app/core/extension/Role.java | 133 ++++++++++++ .../halo/app/core/extension/RoleBinding.java | 95 +++++++++ .../run/halo/app/core/extension/User.java | 87 ++++++++ .../service/DefaultRoleBindingService.java} | 4 +- .../extension/service/DefaultRoleService.java | 38 ++++ .../service/RoleBindingService.java} | 5 +- .../core/extension/service/RoleService.java | 19 ++ .../core/extension/service/UserService.java | 12 ++ .../extension/service/UserServiceImpl.java | 31 +++ .../halo/app/extension/GroupVersionKind.java | 4 + .../app/extension/JSONExtensionConverter.java | 7 +- .../run/halo/app/infra/SchemeInitializer.java | 8 +- .../security/DefaultUserDetailService.java | 68 +++++++ .../app/security/SuperAdminInitializer.java | 106 ++++++++++ .../authorization/AuthorizingVisitor.java | 3 +- .../authorization/DefaultRoleGetter.java | 25 --- .../authorization/DefaultRuleResolver.java | 26 ++- .../security/authorization/PolicyRule.java | 108 ---------- .../authorization/PolicyRuleList.java | 3 +- .../authorization/RbacRequestEvaluation.java | 19 +- .../RequestInfoAuthorizationManager.java | 5 +- .../halo/app/security/authorization/Role.java | 23 --- .../security/authorization/RoleBinding.java | 36 ---- .../security/authorization/RoleGetter.java | 13 -- .../app/security/authorization/RoleRef.java | 28 --- .../authorization/RuleAccumulator.java | 4 +- .../app/security/authorization/Subject.java | 30 --- .../config/ExtensionConfigurationTest.java | 11 +- .../service/UserServiceImplTest.java | 79 +++++++ .../app/extension/GroupVersionKindTest.java | 79 +++++++ ...t.java => JsonExtensionConverterTest.java} | 4 +- .../PluginLifeCycleManagerControllerTest.java | 12 +- .../DefaultUserDetailServiceTest.java | 192 ++++++++++++++++++ .../security/SuperAdminInitializerTest.java | 55 +++++ .../authentication/jwt/LoginTest.java | 34 +++- .../authorization/AuthorizationTest.java | 9 +- ...ava => DefaultRoleBindingServiceTest.java} | 9 +- .../authorization/PolicyRuleTest.java | 9 +- .../RequestInfoResolverTest.java | 46 +++-- 41 files changed, 1164 insertions(+), 360 deletions(-) create mode 100644 src/main/java/run/halo/app/config/HaloConfiguration.java create mode 100644 src/main/java/run/halo/app/core/extension/Role.java create mode 100644 src/main/java/run/halo/app/core/extension/RoleBinding.java create mode 100644 src/main/java/run/halo/app/core/extension/User.java rename src/main/java/run/halo/app/{security/authorization/DefaultRoleBindingLister.java => core/extension/service/DefaultRoleBindingService.java} (93%) create mode 100644 src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java rename src/main/java/run/halo/app/{security/authorization/RoleBindingLister.java => core/extension/service/RoleBindingService.java} (76%) create mode 100644 src/main/java/run/halo/app/core/extension/service/RoleService.java create mode 100644 src/main/java/run/halo/app/core/extension/service/UserService.java create mode 100644 src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java create mode 100644 src/main/java/run/halo/app/security/DefaultUserDetailService.java create mode 100644 src/main/java/run/halo/app/security/SuperAdminInitializer.java delete mode 100644 src/main/java/run/halo/app/security/authorization/DefaultRoleGetter.java delete mode 100644 src/main/java/run/halo/app/security/authorization/PolicyRule.java delete mode 100644 src/main/java/run/halo/app/security/authorization/Role.java delete mode 100644 src/main/java/run/halo/app/security/authorization/RoleBinding.java delete mode 100644 src/main/java/run/halo/app/security/authorization/RoleGetter.java delete mode 100644 src/main/java/run/halo/app/security/authorization/RoleRef.java delete mode 100644 src/main/java/run/halo/app/security/authorization/Subject.java create mode 100644 src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java create mode 100644 src/test/java/run/halo/app/extension/GroupVersionKindTest.java rename src/test/java/run/halo/app/extension/{JSONExtensionConverterTest.java => JsonExtensionConverterTest.java} (96%) create mode 100644 src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java create mode 100644 src/test/java/run/halo/app/security/SuperAdminInitializerTest.java rename src/test/java/run/halo/app/security/authorization/{DefaultRoleBindingListerTest.java => DefaultRoleBindingServiceTest.java} (84%) diff --git a/src/main/java/run/halo/app/config/HaloConfiguration.java b/src/main/java/run/halo/app/config/HaloConfiguration.java new file mode 100644 index 000000000..dc228149a --- /dev/null +++ b/src/main/java/run/halo/app/config/HaloConfiguration.java @@ -0,0 +1,18 @@ +package run.halo.app.config; + +import com.fasterxml.jackson.annotation.JsonInclude; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +public class HaloConfiguration { + + @Bean + Jackson2ObjectMapperBuilderCustomizer objectMapperCustomizer() { + return builder -> { + builder.serializationInclusion(JsonInclude.Include.NON_NULL); + }; + } + +} diff --git a/src/main/java/run/halo/app/config/WebServerSecurityConfig.java b/src/main/java/run/halo/app/config/WebServerSecurityConfig.java index 2c8f87210..82d36e7e7 100644 --- a/src/main/java/run/halo/app/config/WebServerSecurityConfig.java +++ b/src/main/java/run/halo/app/config/WebServerSecurityConfig.java @@ -14,9 +14,7 @@ import org.springframework.http.codec.ServerCodecConfigurer; import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; import org.springframework.security.config.web.server.SecurityWebFiltersOrder; import org.springframework.security.config.web.server.ServerHttpSecurity; -import org.springframework.security.core.userdetails.MapReactiveUserDetailsService; import org.springframework.security.core.userdetails.ReactiveUserDetailsService; -import org.springframework.security.core.userdetails.User; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.jwt.JwtEncoder; @@ -27,11 +25,13 @@ import org.springframework.security.oauth2.jwt.SupplierReactiveJwtDecoder; import org.springframework.security.web.server.SecurityWebFilterChain; import org.springframework.security.web.server.csrf.CookieServerCsrfTokenRepository; import org.springframework.web.reactive.function.server.ServerResponse; +import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.extension.service.UserService; import run.halo.app.infra.properties.JwtProperties; +import run.halo.app.security.DefaultUserDetailService; import run.halo.app.security.authentication.jwt.LoginAuthenticationFilter; import run.halo.app.security.authentication.jwt.LoginAuthenticationManager; import run.halo.app.security.authorization.RequestInfoAuthorizationManager; -import run.halo.app.security.authorization.RoleGetter; /** * Security configuration for WebFlux. @@ -52,15 +52,18 @@ public class WebServerSecurityConfig { SecurityWebFilterChain apiFilterChain(ServerHttpSecurity http, ServerCodecConfigurer codec, ServerResponse.Context context, - RoleGetter roleGetter) { + UserService userService, + RoleService roleService) { http.csrf().disable() .securityMatcher(pathMatchers("/api/**", "/apis/**")) .authorizeExchange(exchanges -> - exchanges.anyExchange().access(new RequestInfoAuthorizationManager(roleGetter))) + exchanges.anyExchange().access(new RequestInfoAuthorizationManager(roleService))) // for reuse the JWT authentication .oauth2ResourceServer().jwt(); - var loginManager = new LoginAuthenticationManager(userDetailsService(), passwordEncoder()); + var loginManager = new LoginAuthenticationManager( + userDetailsService(userService, roleService), + passwordEncoder()); var loginFilter = new LoginAuthenticationFilter(loginManager, codec, jwtEncoder(), @@ -92,15 +95,9 @@ public class WebServerSecurityConfig { } @Bean - ReactiveUserDetailsService userDetailsService() { - //TODO Implement details service when User Extension is ready. - return new MapReactiveUserDetailsService( - // for test - User.withDefaultPasswordEncoder().username("user").password("password").roles("USER") - .build(), - // for test - User.withDefaultPasswordEncoder().username("admin").password("password").roles("ADMIN") - .build()); + ReactiveUserDetailsService userDetailsService(UserService userService, + RoleService roleService) { + return new DefaultUserDetailService(userService, roleService); } @Bean diff --git a/src/main/java/run/halo/app/core/extension/Role.java b/src/main/java/run/halo/app/core/extension/Role.java new file mode 100644 index 000000000..6b72ed0cd --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/Role.java @@ -0,0 +1,133 @@ +package run.halo.app.core.extension; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +/** + * @author guqing + * @since 2.0.0 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@GVK(group = "", + version = "v1alpha1", + kind = "Role", + plural = "roles", + singular = "role") +public class Role extends AbstractExtension { + + @Schema(minLength = 1) + List rules; + + /** + * PolicyRule holds information that describes a policy rule, but does not contain information + * about whom the rule applies to or which namespace the rule applies to. + * + * @author guqing + * @since 2.0.0 + */ + @Data + public static class PolicyRule { + + /** + * APIGroups is the name of the APIGroup that contains the resources. + * If multiple API groups are specified, any action requested against one of the enumerated + * resources in any API group will be allowed. + */ + String[] apiGroups; + + /** + * Resources is a list of resources this rule applies to. '*' represents all resources in + * the specified apiGroups. + * '*/foo' represents the subresource 'foo' for all resources in + * the specified apiGroups. + */ + String[] resources; + + /** + * ResourceNames is an optional white list of names that the rule applies to. An empty set + * means that everything is allowed. + */ + String[] resourceNames; + + /** + * NonResourceURLs is a set of partial urls that a user should have access to. + * *s are allowed, but only as the full, final step in the path + * If an action is not a resource API request, then the URL is split on '/' and is checked + * against the NonResourceURLs to look for a match. + * Since non-resource URLs are not namespaced, this field is only applicable for + * ClusterRoles referenced from a ClusterRoleBinding. + * Rules can either apply to API resources (such as "pods" or "secrets") or non-resource + * URL paths (such as "/api"), but not both. + */ + String[] nonResourceURLs; + + /** + * about who the rule applies to or which namespace the rule applies to. + */ + String[] verbs; + + public PolicyRule() { + this(null, null, null, null, null); + } + + public PolicyRule(String[] apiGroups, String[] resources, String[] resourceNames, + String[] nonResourceURLs, String[] verbs) { + this.apiGroups = nullElseEmpty(apiGroups); + this.resources = nullElseEmpty(resources); + this.resourceNames = nullElseEmpty(resourceNames); + this.nonResourceURLs = nullElseEmpty(nonResourceURLs); + this.verbs = nullElseEmpty(verbs); + } + + String[] nullElseEmpty(String... items) { + if (items == null) { + return new String[] {}; + } + return items; + } + + public static class Builder { + String[] apiGroups; + String[] resources; + String[] resourceNames; + String[] nonResourceURLs; + String[] verbs; + + public Builder apiGroups(String... apiGroups) { + this.apiGroups = apiGroups; + return this; + } + + public Builder resources(String... resources) { + this.resources = resources; + return this; + } + + public Builder resourceNames(String... resourceNames) { + this.resourceNames = resourceNames; + return this; + } + + public Builder nonResourceURLs(String... nonResourceURLs) { + this.nonResourceURLs = nonResourceURLs; + return this; + } + + public Builder verbs(String... verbs) { + this.verbs = verbs; + return this; + } + + public PolicyRule build() { + return new PolicyRule(apiGroups, resources, resourceNames, nonResourceURLs, verbs); + } + } + } +} diff --git a/src/main/java/run/halo/app/core/extension/RoleBinding.java b/src/main/java/run/halo/app/core/extension/RoleBinding.java new file mode 100644 index 000000000..7f99904cf --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/RoleBinding.java @@ -0,0 +1,95 @@ +package run.halo.app.core.extension; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.ToString; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +/** + * RoleBinding references a role, but does not contain it. + * It can reference a Role in the global. + * It adds who information via Subjects. + * + * @author guqing + * @since 2.0.0 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@GVK(group = "", + version = "v1alpha1", + kind = "RoleBinding", + plural = "rolebindings", + singular = "rolebinding") +public class RoleBinding extends AbstractExtension { + + /** + * Subjects holds references to the objects the role applies to. + */ + List subjects; + + /** + * RoleRef can reference a Role in the current namespace or a ClusterRole in the global + * namespace. + * If the RoleRef cannot be resolved, the Authorizer must return an error. + */ + RoleRef roleRef; + + /** + * RoleRef contains information that points to the role being used. + * + * @author guqing + * @since 2.0.0 + */ + @Data + public static class RoleRef { + + /** + * Kind is the type of resource being referenced. + */ + String kind; + + /** + * Name is the name of resource being referenced. + */ + String name; + + /** + * APIGroup is the group for the resource being referenced. + */ + String apiGroup; + } + + /** + * @author guqing + * @since 2.0.0 + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Subject { + /** + * Kind of object being referenced. Values defined by this API group are "User", "Group", + * and "ServiceAccount". + * If the Authorizer does not recognize the kind value, the Authorizer should report + * an error. + */ + String kind; + + /** + * Name of the object being referenced. + */ + String name; + + /** + * APIGroup holds the API group of the referenced subject. + * Defaults to "" for ServiceAccount subjects. + * Defaults to "rbac.authorization.halo.run" for User and Group subjects. + */ + String apiGroup; + } +} diff --git a/src/main/java/run/halo/app/core/extension/User.java b/src/main/java/run/halo/app/core/extension/User.java new file mode 100644 index 000000000..55da16b58 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/User.java @@ -0,0 +1,87 @@ +package run.halo.app.core.extension; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.List; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +/** + * The extension represents user details of Halo. + * + * @author johnniang + */ +@Data +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) +@GVK(group = "", + version = "v1alpha1", + kind = "User", + singular = "user", + plural = "users") +public class User extends AbstractExtension { + + @Schema(required = true) + private UserSpec spec; + + private UserStatus status; + + @Data + public static class UserSpec { + + @Schema(required = true) + private String displayName; + + private String avatar; + + @Schema(required = true) + private String email; + + private String phone; + + private String password; + + private String bio; + + private Instant registeredAt; + + private Boolean twoFactorAuthEnabled; + + private Boolean disabled; + + private Integer loginHistoryLimit; + + } + + @Data + public static class UserStatus { + + private Instant lastLoginAt; + + private List loginHistories; + + } + + @Data + public static class LoginHistory { + + @Schema(required = true) + private Instant loginAt; + + @Schema(required = true) + private String sourceIp; + + @Schema(required = true) + private String userAgent; + + @Schema(required = true) + private Boolean successful; + + private String reason; + + } + +} diff --git a/src/main/java/run/halo/app/security/authorization/DefaultRoleBindingLister.java b/src/main/java/run/halo/app/core/extension/service/DefaultRoleBindingService.java similarity index 93% rename from src/main/java/run/halo/app/security/authorization/DefaultRoleBindingLister.java rename to src/main/java/run/halo/app/core/extension/service/DefaultRoleBindingService.java index 411ac359f..d73c9ad09 100644 --- a/src/main/java/run/halo/app/security/authorization/DefaultRoleBindingLister.java +++ b/src/main/java/run/halo/app/core/extension/service/DefaultRoleBindingService.java @@ -1,4 +1,4 @@ -package run.halo.app.security.authorization; +package run.halo.app.core.extension.service; import java.util.Collection; import java.util.Set; @@ -20,7 +20,7 @@ import org.springframework.security.web.authentication.AnonymousAuthenticationFi * @since 2.0.0 */ @Slf4j -public class DefaultRoleBindingLister implements RoleBindingLister { +public class DefaultRoleBindingService implements RoleBindingService { private static final String SCOPE_AUTHORITY_PREFIX = "SCOPE_"; private static final String ROLE_AUTHORITY_PREFIX = "ROLE_"; diff --git a/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java b/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java new file mode 100644 index 000000000..4b28a73c3 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java @@ -0,0 +1,38 @@ +package run.halo.app.core.extension.service; + +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import run.halo.app.core.extension.Role; +import run.halo.app.core.extension.RoleBinding; +import run.halo.app.core.extension.RoleBinding.RoleRef; +import run.halo.app.core.extension.RoleBinding.Subject; +import run.halo.app.extension.ExtensionClient; + +/** + * @author guqing + * @since 2.0.0 + */ +@Service +public class DefaultRoleService implements RoleService { + + private final ExtensionClient extensionClient; + + public DefaultRoleService(ExtensionClient extensionClient) { + this.extensionClient = extensionClient; + } + + @Override + @NonNull + public Role getRole(@NonNull String name) { + return extensionClient.fetch(Role.class, name).orElseThrow(); + } + + @Override + public Flux listRoleRefs(Subject subject) { + return Flux.fromIterable(extensionClient.list(RoleBinding.class, + binding -> binding.getSubjects().contains(subject), + null)) + .map(RoleBinding::getRoleRef); + } +} diff --git a/src/main/java/run/halo/app/security/authorization/RoleBindingLister.java b/src/main/java/run/halo/app/core/extension/service/RoleBindingService.java similarity index 76% rename from src/main/java/run/halo/app/security/authorization/RoleBindingLister.java rename to src/main/java/run/halo/app/core/extension/service/RoleBindingService.java index 422dca709..9b19082ba 100644 --- a/src/main/java/run/halo/app/security/authorization/RoleBindingLister.java +++ b/src/main/java/run/halo/app/core/extension/service/RoleBindingService.java @@ -1,4 +1,4 @@ -package run.halo.app.security.authorization; +package run.halo.app.core.extension.service; import java.util.Collection; import java.util.Set; @@ -9,7 +9,8 @@ import org.springframework.security.core.GrantedAuthority; * @since 2.0.0 */ @FunctionalInterface -public interface RoleBindingLister { +public interface RoleBindingService { Set listBoundRoleNames(Collection authorities); + } diff --git a/src/main/java/run/halo/app/core/extension/service/RoleService.java b/src/main/java/run/halo/app/core/extension/service/RoleService.java new file mode 100644 index 000000000..e477095bc --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/service/RoleService.java @@ -0,0 +1,19 @@ +package run.halo.app.core.extension.service; + +import org.springframework.lang.NonNull; +import reactor.core.publisher.Flux; +import run.halo.app.core.extension.Role; +import run.halo.app.core.extension.RoleBinding.RoleRef; +import run.halo.app.core.extension.RoleBinding.Subject; + +/** + * @author guqing + * @since 2.0.0 + */ +public interface RoleService { + + @NonNull + Role getRole(String name); + + Flux listRoleRefs(Subject subject); +} diff --git a/src/main/java/run/halo/app/core/extension/service/UserService.java b/src/main/java/run/halo/app/core/extension/service/UserService.java new file mode 100644 index 000000000..149c98ac4 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/service/UserService.java @@ -0,0 +1,12 @@ +package run.halo.app.core.extension.service; + +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.User; + +public interface UserService { + + Mono getUser(String username); + + Mono updatePassword(String username, String newPassword); + +} diff --git a/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java b/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java new file mode 100644 index 000000000..308755a56 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/service/UserServiceImpl.java @@ -0,0 +1,31 @@ +package run.halo.app.core.extension.service; + +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.User; +import run.halo.app.extension.ExtensionClient; + +@Service +public class UserServiceImpl implements UserService { + + private final ExtensionClient client; + + public UserServiceImpl(ExtensionClient client) { + this.client = client; + } + + @Override + public Mono getUser(String username) { + return Mono.justOrEmpty(client.fetch(User.class, username)); + } + + @Override + public Mono updatePassword(String username, String newPassword) { + return getUser(username) + .doOnNext(user -> { + user.getSpec().setPassword(newPassword); + client.update(user); + }) + .then(); + } +} diff --git a/src/main/java/run/halo/app/extension/GroupVersionKind.java b/src/main/java/run/halo/app/extension/GroupVersionKind.java index a0a3d4099..85785e2ba 100644 --- a/src/main/java/run/halo/app/extension/GroupVersionKind.java +++ b/src/main/java/run/halo/app/extension/GroupVersionKind.java @@ -27,6 +27,10 @@ public record GroupVersionKind(String group, String version, String kind) { return new GroupVersion(group, version); } + public GroupKind groupKind() { + return new GroupKind(group, kind); + } + public boolean hasGroup() { return StringUtils.hasText(group); } diff --git a/src/main/java/run/halo/app/extension/JSONExtensionConverter.java b/src/main/java/run/halo/app/extension/JSONExtensionConverter.java index 9dfb00f27..1c9060583 100644 --- a/src/main/java/run/halo/app/extension/JSONExtensionConverter.java +++ b/src/main/java/run/halo/app/extension/JSONExtensionConverter.java @@ -1,5 +1,6 @@ package run.halo.app.extension; +import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.networknt.schema.JsonSchemaFactory; @@ -34,6 +35,7 @@ public class JSONExtensionConverter implements ExtensionConverter { OBJECT_MAPPER = Jackson2ObjectMapperBuilder.json() .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) .featuresToDisable(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS) + .serializationInclusion(JsonInclude.Include.NON_NULL) .build(); } @@ -48,11 +50,6 @@ public class JSONExtensionConverter implements ExtensionConverter { var scheme = schemeManager.get(gvk); var storeName = ExtensionUtil.buildStoreName(scheme, extension.getMetadata().getName()); try { - if (logger.isDebugEnabled()) { - logger.debug("JSON schema({}): {}", scheme.type(), - scheme.jsonSchema().toPrettyString()); - } - var data = OBJECT_MAPPER.writeValueAsBytes(extension); var validator = jsonSchemaFactory.getSchema(scheme.jsonSchema()); diff --git a/src/main/java/run/halo/app/infra/SchemeInitializer.java b/src/main/java/run/halo/app/infra/SchemeInitializer.java index e6edc6ca1..10f4e9d03 100644 --- a/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -4,11 +4,12 @@ import org.springframework.boot.context.event.ApplicationStartedEvent; import org.springframework.context.ApplicationListener; import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; +import run.halo.app.core.extension.Role; +import run.halo.app.core.extension.RoleBinding; +import run.halo.app.core.extension.User; import run.halo.app.extension.SchemeManager; import run.halo.app.plugin.Plugin; import run.halo.app.security.authentication.pat.PersonalAccessToken; -import run.halo.app.security.authorization.Role; -import run.halo.app.security.authorization.RoleBinding; @Component public class SchemeInitializer implements ApplicationListener { @@ -22,8 +23,9 @@ public class SchemeInitializer implements ApplicationListener updatePassword(UserDetails user, String newPassword) { + return Mono.just(user) + .map(userDetails -> withNewPassword(user, newPassword)) + .flatMap(userDetails -> userService.updatePassword( + userDetails.getUsername(), + userDetails.getPassword()) + .then(Mono.just(userDetails)) + ); + } + + @Override + public Mono findByUsername(String username) { + return userService.getUser(username).flatMap(user -> { + final var userGvk = + new run.halo.app.core.extension.User().groupVersionKind(); + var subject = new Subject(userGvk.kind(), username, userGvk.group()); + return roleService.listRoleRefs(subject) + .filter(this::isRoleRef) + .map(RoleRef::getName) + .collectList() + .map(roleNames -> User.builder() + .username(username) + .password(user.getSpec().getPassword()) + .roles(roleNames.toArray(new String[0])) + .build()); + }); + } + + private boolean isRoleRef(RoleRef roleRef) { + var roleGvk = new Role().groupVersionKind(); + var gk = new GroupKind(roleRef.getApiGroup(), roleRef.getKind()); + return gk.equals(roleGvk.groupKind()); + } + + private UserDetails withNewPassword(UserDetails userDetails, String newPassword) { + return User.withUserDetails(userDetails) + .password(newPassword) + .build(); + } + +} diff --git a/src/main/java/run/halo/app/security/SuperAdminInitializer.java b/src/main/java/run/halo/app/security/SuperAdminInitializer.java new file mode 100644 index 000000000..3bf790c1d --- /dev/null +++ b/src/main/java/run/halo/app/security/SuperAdminInitializer.java @@ -0,0 +1,106 @@ +package run.halo.app.security; + +import java.time.Instant; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.RandomStringUtils; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import run.halo.app.core.extension.Role; +import run.halo.app.core.extension.Role.PolicyRule; +import run.halo.app.core.extension.RoleBinding; +import run.halo.app.core.extension.RoleBinding.RoleRef; +import run.halo.app.core.extension.RoleBinding.Subject; +import run.halo.app.core.extension.User; +import run.halo.app.core.extension.User.UserSpec; +import run.halo.app.extension.ExtensionClient; +import run.halo.app.extension.Metadata; + +@Slf4j +@Component +public class SuperAdminInitializer implements ApplicationListener { + + private final ExtensionClient client; + private final PasswordEncoder passwordEncoder; + + public SuperAdminInitializer(ExtensionClient client, PasswordEncoder passwordEncoder) { + this.client = client; + this.passwordEncoder = passwordEncoder; + } + + @Override + public void onApplicationEvent(ApplicationStartedEvent event) { + client.fetch(User.class, "admin").ifPresentOrElse(user -> { + // do nothing if admin has been initialized + }, () -> { + var admin = createAdmin(); + var superRole = createSuperRole(); + var roleBinding = bindAdminAndSuperRole(admin, superRole); + client.create(admin); + client.create(superRole); + client.create(roleBinding); + }); + } + + RoleBinding bindAdminAndSuperRole(User admin, Role superRole) { + var metadata = new Metadata(); + metadata.setName("admin-super-role-binding"); + var roleRef = new RoleRef(); + roleRef.setName(superRole.getMetadata().getName()); + roleRef.setApiGroup(superRole.groupVersionKind().group()); + roleRef.setKind(superRole.getKind()); + + var subject = new Subject(); + subject.setName(admin.getMetadata().getName()); + subject.setApiGroup(admin.groupVersionKind().group()); + subject.setKind(admin.groupVersionKind().kind()); + + var roleBinding = new RoleBinding(); + roleBinding.setMetadata(metadata); + roleBinding.setRoleRef(roleRef); + roleBinding.setSubjects(List.of(subject)); + + return roleBinding; + } + + Role createSuperRole() { + var metadata = new Metadata(); + metadata.setName("super-role"); + + var superRule = new PolicyRule.Builder() + .apiGroups("*") + .resources("*") + .nonResourceURLs("*") + .verbs("*") + .build(); + + var role = new Role(); + role.setMetadata(metadata); + role.setRules(List.of(superRule)); + return role; + } + + User createAdmin() { + var metadata = new Metadata(); + metadata.setName("admin"); + + var spec = new UserSpec(); + spec.setDisplayName("Administrator"); + spec.setDisabled(false); + spec.setRegisteredAt(Instant.now()); + spec.setTwoFactorAuthEnabled(false); + spec.setEmail("admin@halo.run"); + // generate password + var randomPassword = RandomStringUtils.randomAlphanumeric(16); + log.info("=== Generated random password: {} for initial user: {} ===", + randomPassword, metadata.getName()); + spec.setPassword(passwordEncoder.encode(randomPassword)); + + var user = new User(); + user.setMetadata(metadata); + user.setSpec(spec); + return user; + } +} diff --git a/src/main/java/run/halo/app/security/authorization/AuthorizingVisitor.java b/src/main/java/run/halo/app/security/authorization/AuthorizingVisitor.java index 64c4b672a..e4df9ddcb 100644 --- a/src/main/java/run/halo/app/security/authorization/AuthorizingVisitor.java +++ b/src/main/java/run/halo/app/security/authorization/AuthorizingVisitor.java @@ -2,6 +2,7 @@ package run.halo.app.security.authorization; import java.util.ArrayList; import java.util.List; +import run.halo.app.core.extension.Role; /** * authorizing visitor short-circuits once allowed, and collects any resolution errors encountered. @@ -25,7 +26,7 @@ class AuthorizingVisitor implements RuleAccumulator { } @Override - public boolean visit(String source, PolicyRule rule, Throwable error) { + public boolean visit(String source, Role.PolicyRule rule, Throwable error) { if (rule != null && requestEvaluation.ruleAllows(requestAttributes, rule)) { this.allowed = true; this.reason = String.format("RBAC: allowed by %s", source); diff --git a/src/main/java/run/halo/app/security/authorization/DefaultRoleGetter.java b/src/main/java/run/halo/app/security/authorization/DefaultRoleGetter.java deleted file mode 100644 index 37a4f2183..000000000 --- a/src/main/java/run/halo/app/security/authorization/DefaultRoleGetter.java +++ /dev/null @@ -1,25 +0,0 @@ -package run.halo.app.security.authorization; - -import org.springframework.lang.NonNull; -import org.springframework.stereotype.Component; -import run.halo.app.extension.ExtensionClient; - -/** - * @author guqing - * @since 2.0.0 - */ -@Component -public class DefaultRoleGetter implements RoleGetter { - - private final ExtensionClient extensionClient; - - public DefaultRoleGetter(ExtensionClient extensionClient) { - this.extensionClient = extensionClient; - } - - @Override - @NonNull - public Role getRole(@NonNull String name) { - return extensionClient.fetch(Role.class, name).orElseThrow(); - } -} diff --git a/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java b/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java index 3756d90cc..366789f9f 100644 --- a/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java +++ b/src/main/java/run/halo/app/security/authorization/DefaultRuleResolver.java @@ -6,6 +6,10 @@ import java.util.Set; import lombok.Data; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.util.Assert; +import run.halo.app.core.extension.Role; +import run.halo.app.core.extension.service.DefaultRoleBindingService; +import run.halo.app.core.extension.service.RoleBindingService; +import run.halo.app.core.extension.service.RoleService; /** * @author guqing @@ -14,12 +18,12 @@ import org.springframework.util.Assert; @Data public class DefaultRuleResolver implements AuthorizationRuleResolver { - private RoleGetter roleGetter; + private RoleService roleService; - private RoleBindingLister roleBindingLister = new DefaultRoleBindingLister(); + private RoleBindingService roleBindingService = new DefaultRoleBindingService(); - public DefaultRuleResolver(RoleGetter roleGetter) { - this.roleGetter = roleGetter; + public DefaultRuleResolver(RoleService roleService) { + this.roleService = roleService; } @Override @@ -39,12 +43,12 @@ public class DefaultRuleResolver implements AuthorizationRuleResolver { @Override public void visitRulesFor(UserDetails user, RuleAccumulator visitor) { - Set roleNames = roleBindingLister.listBoundRoleNames(user.getAuthorities()); + Set roleNames = roleBindingService.listBoundRoleNames(user.getAuthorities()); - List rules = Collections.emptyList(); + List rules = Collections.emptyList(); for (String roleName : roleNames) { try { - Role role = roleGetter.getRole(roleName); + Role role = roleService.getRole(roleName); rules = role.getRules(); } catch (Exception e) { if (visitor.visit(null, null, e)) { @@ -53,7 +57,7 @@ public class DefaultRuleResolver implements AuthorizationRuleResolver { } String source = roleBindingDescriber(roleName, user.getUsername()); - for (PolicyRule rule : rules) { + for (Role.PolicyRule rule : rules) { if (!visitor.visit(source, rule, null)) { return; } @@ -65,8 +69,8 @@ public class DefaultRuleResolver implements AuthorizationRuleResolver { return String.format("Binding role [%s] to [%s]", roleName, subject); } - public void setRoleBindingLister(RoleBindingLister roleBindingLister) { - Assert.notNull(roleBindingLister, "The roleBindingLister must not be null."); - this.roleBindingLister = roleBindingLister; + public void setRoleBindingService(RoleBindingService roleBindingService) { + Assert.notNull(roleBindingService, "The roleBindingLister must not be null."); + this.roleBindingService = roleBindingService; } } diff --git a/src/main/java/run/halo/app/security/authorization/PolicyRule.java b/src/main/java/run/halo/app/security/authorization/PolicyRule.java deleted file mode 100644 index 665c52073..000000000 --- a/src/main/java/run/halo/app/security/authorization/PolicyRule.java +++ /dev/null @@ -1,108 +0,0 @@ -package run.halo.app.security.authorization; - -import lombok.Data; - -/** - * PolicyRule holds information that describes a policy rule, but does not contain information - * about whom the rule applies to or which namespace the rule applies to. - * - * @author guqing - * @since 2.0.0 - */ -@Data -public class PolicyRule { - - /** - * APIGroups is the name of the APIGroup that contains the resources. - * If multiple API groups are specified, any action requested against one of the enumerated - * resources in any API group will be allowed. - */ - String[] apiGroups; - - /** - * Resources is a list of resources this rule applies to. '*' represents all resources in - * the specified apiGroups. - * '*/foo' represents the subresource 'foo' for all resources in the specified apiGroups. - */ - String[] resources; - - /** - * ResourceNames is an optional white list of names that the rule applies to. An empty set - * means that everything is allowed. - */ - String[] resourceNames; - - /** - * NonResourceURLs is a set of partial urls that a user should have access to. - * *s are allowed, but only as the full, final step in the path - * If an action is not a resource API request, then the URL is split on '/' and is checked - * against the NonResourceURLs to look for a match. - * Since non-resource URLs are not namespaced, this field is only applicable for - * ClusterRoles referenced from a ClusterRoleBinding. - * Rules can either apply to API resources (such as "pods" or "secrets") or non-resource - * URL paths (such as "/api"), but not both. - */ - String[] nonResourceURLs; - - /** - * about who the rule applies to or which namespace the rule applies to. - */ - String[] verbs; - - public PolicyRule() { - this(null, null, null, null, null); - } - - public PolicyRule(String[] apiGroups, String[] resources, String[] resourceNames, - String[] nonResourceURLs, String[] verbs) { - this.apiGroups = nullElseEmpty(apiGroups); - this.resources = nullElseEmpty(resources); - this.resourceNames = nullElseEmpty(resourceNames); - this.nonResourceURLs = nullElseEmpty(nonResourceURLs); - this.verbs = nullElseEmpty(verbs); - } - - String[] nullElseEmpty(String... items) { - if (items == null) { - return new String[] {}; - } - return items; - } - - public static class Builder { - String[] apiGroups; - String[] resources; - String[] resourceNames; - String[] nonResourceURLs; - String[] verbs; - - public Builder apiGroups(String... apiGroups) { - this.apiGroups = apiGroups; - return this; - } - - public Builder resources(String... resources) { - this.resources = resources; - return this; - } - - public Builder resourceNames(String... resourceNames) { - this.resourceNames = resourceNames; - return this; - } - - public Builder nonResourceURLs(String... nonResourceURLs) { - this.nonResourceURLs = nonResourceURLs; - return this; - } - - public Builder verbs(String... verbs) { - this.verbs = verbs; - return this; - } - - public PolicyRule build() { - return new PolicyRule(apiGroups, resources, resourceNames, nonResourceURLs, verbs); - } - } -} diff --git a/src/main/java/run/halo/app/security/authorization/PolicyRuleList.java b/src/main/java/run/halo/app/security/authorization/PolicyRuleList.java index 6297c226c..6ce0b4076 100644 --- a/src/main/java/run/halo/app/security/authorization/PolicyRuleList.java +++ b/src/main/java/run/halo/app/security/authorization/PolicyRuleList.java @@ -3,12 +3,13 @@ package run.halo.app.security.authorization; import java.util.ArrayList; import java.util.LinkedList; import java.util.List; +import run.halo.app.core.extension.Role; /** * @author guqing * @since 2.0.0 */ -public class PolicyRuleList extends LinkedList { +public class PolicyRuleList extends LinkedList { private final List errors = new ArrayList<>(4); /** diff --git a/src/main/java/run/halo/app/security/authorization/RbacRequestEvaluation.java b/src/main/java/run/halo/app/security/authorization/RbacRequestEvaluation.java index 2b50816c6..542fee3b2 100644 --- a/src/main/java/run/halo/app/security/authorization/RbacRequestEvaluation.java +++ b/src/main/java/run/halo/app/security/authorization/RbacRequestEvaluation.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Objects; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.StringUtils; +import run.halo.app.core.extension.Role; /** * @author guqing @@ -17,8 +18,8 @@ public class RbacRequestEvaluation { String NonResourceAll = "*"; } - public boolean rulesAllow(Attributes requestAttributes, List rules) { - for (PolicyRule rule : rules) { + public boolean rulesAllow(Attributes requestAttributes, List rules) { + for (Role.PolicyRule rule : rules) { if (ruleAllows(requestAttributes, rule)) { return true; } @@ -26,7 +27,7 @@ public class RbacRequestEvaluation { return false; } - protected boolean ruleAllows(Attributes requestAttributes, PolicyRule rule) { + protected boolean ruleAllows(Attributes requestAttributes, Role.PolicyRule rule) { if (requestAttributes.isResourceRequest()) { String combinedResource = requestAttributes.getResource(); if (StringUtils.isNotBlank(requestAttributes.getSubresource())) { @@ -43,7 +44,7 @@ public class RbacRequestEvaluation { && nonResourceURLMatches(rule, requestAttributes.getPath()); } - protected boolean verbMatches(PolicyRule rule, String requestedVerb) { + protected boolean verbMatches(Role.PolicyRule rule, String requestedVerb) { for (String ruleVerb : rule.getVerbs()) { if (Objects.equals(ruleVerb, WildCard.VerbAll)) { return true; @@ -55,7 +56,7 @@ public class RbacRequestEvaluation { return false; } - protected boolean apiGroupMatches(PolicyRule rule, String requestedGroup) { + protected boolean apiGroupMatches(Role.PolicyRule rule, String requestedGroup) { for (String ruleGroup : rule.getApiGroups()) { if (Objects.equals(ruleGroup, WildCard.APIGroupAll)) { return true; @@ -67,8 +68,8 @@ public class RbacRequestEvaluation { return false; } - protected boolean resourceMatches(PolicyRule rule, String combinedRequestedResource, - String requestedSubresource) { + protected boolean resourceMatches(Role.PolicyRule rule, String combinedRequestedResource, + String requestedSubresource) { for (String ruleResource : rule.getResources()) { // if everything is allowed, we match if (Objects.equals(ruleResource, WildCard.ResourceAll)) { @@ -94,7 +95,7 @@ public class RbacRequestEvaluation { return false; } - protected boolean resourceNameMatches(PolicyRule rule, String requestedName) { + protected boolean resourceNameMatches(Role.PolicyRule rule, String requestedName) { if (ArrayUtils.isEmpty(rule.getResourceNames())) { return true; } @@ -106,7 +107,7 @@ public class RbacRequestEvaluation { return false; } - protected boolean nonResourceURLMatches(PolicyRule rule, String requestedURL) { + protected boolean nonResourceURLMatches(Role.PolicyRule rule, String requestedURL) { for (String ruleURL : rule.getNonResourceURLs()) { if (Objects.equals(ruleURL, WildCard.NonResourceAll)) { return true; diff --git a/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java b/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java index 9e93fda52..57b71fb27 100644 --- a/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java +++ b/src/main/java/run/halo/app/security/authorization/RequestInfoAuthorizationManager.java @@ -14,6 +14,7 @@ import org.springframework.security.web.server.authorization.AuthorizationContex import org.springframework.util.Assert; import org.springframework.util.CollectionUtils; import reactor.core.publisher.Mono; +import run.halo.app.core.extension.service.RoleService; @Slf4j public class RequestInfoAuthorizationManager @@ -23,8 +24,8 @@ public class RequestInfoAuthorizationManager private final AuthorizationRuleResolver ruleResolver; - public RequestInfoAuthorizationManager(RoleGetter roleGetter) { - this.ruleResolver = new DefaultRuleResolver(roleGetter); + public RequestInfoAuthorizationManager(RoleService roleService) { + this.ruleResolver = new DefaultRuleResolver(roleService); } @Override diff --git a/src/main/java/run/halo/app/security/authorization/Role.java b/src/main/java/run/halo/app/security/authorization/Role.java deleted file mode 100644 index 39946e00b..000000000 --- a/src/main/java/run/halo/app/security/authorization/Role.java +++ /dev/null @@ -1,23 +0,0 @@ -package run.halo.app.security.authorization; - -import io.swagger.v3.oas.annotations.media.Schema; -import java.util.List; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.ToString; -import run.halo.app.extension.AbstractExtension; -import run.halo.app.extension.GVK; - -/** - * @author guqing - * @since 2.0.0 - */ -@Data -@EqualsAndHashCode(callSuper = true) -@ToString(callSuper = true) -@GVK(group = "", version = "v1alpha1", kind = "Role", plural = "roles", singular = "role") -public class Role extends AbstractExtension { - - @Schema(minLength = 1) - List rules; -} diff --git a/src/main/java/run/halo/app/security/authorization/RoleBinding.java b/src/main/java/run/halo/app/security/authorization/RoleBinding.java deleted file mode 100644 index 92fb8ffbc..000000000 --- a/src/main/java/run/halo/app/security/authorization/RoleBinding.java +++ /dev/null @@ -1,36 +0,0 @@ -package run.halo.app.security.authorization; - -import java.util.List; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.ToString; -import run.halo.app.extension.AbstractExtension; -import run.halo.app.extension.GVK; - -/** - * RoleBinding references a role, but does not contain it. - * It can reference a Role in the global. - * It adds who information via Subjects. - * - * @author guqing - * @since 2.0.0 - */ -@Data -@EqualsAndHashCode(callSuper = true) -@ToString(callSuper = true) -@GVK(group = "", version = "v1alpha1", kind = "RoleBinding", plural = "rolebindings", - singular = "rolebinding") -public class RoleBinding extends AbstractExtension { - - /** - * Subjects holds references to the objects the role applies to. - */ - List subjects; - - /** - * RoleRef can reference a Role in the current namespace or a ClusterRole in the global - * namespace. - * If the RoleRef cannot be resolved, the Authorizer must return an error. - */ - RoleRef roleRef; -} diff --git a/src/main/java/run/halo/app/security/authorization/RoleGetter.java b/src/main/java/run/halo/app/security/authorization/RoleGetter.java deleted file mode 100644 index b8ad1dce5..000000000 --- a/src/main/java/run/halo/app/security/authorization/RoleGetter.java +++ /dev/null @@ -1,13 +0,0 @@ -package run.halo.app.security.authorization; - -import org.springframework.lang.NonNull; - -/** - * @author guqing - * @since 2.0.0 - */ -public interface RoleGetter { - - @NonNull - Role getRole(String name); -} diff --git a/src/main/java/run/halo/app/security/authorization/RoleRef.java b/src/main/java/run/halo/app/security/authorization/RoleRef.java deleted file mode 100644 index c6fd6ab8f..000000000 --- a/src/main/java/run/halo/app/security/authorization/RoleRef.java +++ /dev/null @@ -1,28 +0,0 @@ -package run.halo.app.security.authorization; - -import lombok.Data; - -/** - * RoleRef contains information that points to the role being used. - * - * @author guqing - * @since 2.0.0 - */ -@Data -public class RoleRef { - - /** - * Kind is the type of resource being referenced. - */ - String kind; - - /** - * Name is the name of resource being referenced. - */ - String name; - - /** - * APIGroup is the group for the resource being referenced. - */ - String apiGroup; -} diff --git a/src/main/java/run/halo/app/security/authorization/RuleAccumulator.java b/src/main/java/run/halo/app/security/authorization/RuleAccumulator.java index c457fb9bb..1e7efc31e 100644 --- a/src/main/java/run/halo/app/security/authorization/RuleAccumulator.java +++ b/src/main/java/run/halo/app/security/authorization/RuleAccumulator.java @@ -1,9 +1,11 @@ package run.halo.app.security.authorization; +import run.halo.app.core.extension.Role; + /** * @author guqing * @since 2.0.0 */ public interface RuleAccumulator { - boolean visit(String source, PolicyRule rule, Throwable err); + boolean visit(String source, Role.PolicyRule rule, Throwable err); } diff --git a/src/main/java/run/halo/app/security/authorization/Subject.java b/src/main/java/run/halo/app/security/authorization/Subject.java deleted file mode 100644 index 93b24cf2d..000000000 --- a/src/main/java/run/halo/app/security/authorization/Subject.java +++ /dev/null @@ -1,30 +0,0 @@ -package run.halo.app.security.authorization; - -import lombok.Data; - -/** - * @author guqing - * @since 2.0.0 - */ -@Data -public class Subject { - /** - * Kind of object being referenced. Values defined by this API group are "User", "Group", - * and "ServiceAccount". - * If the Authorizer does not recognized the kind value, the Authorizer should report - * an error. - */ - String kind; - - /** - * Name of the object being referenced. - */ - String name; - - /** - * APIGroup holds the API group of the referenced subject. - * Defaults to "" for ServiceAccount subjects. - * Defaults to "rbac.authorization.k8s.io" for User and Group subjects. - */ - String apiGroup; -} diff --git a/src/test/java/run/halo/app/config/ExtensionConfigurationTest.java b/src/test/java/run/halo/app/config/ExtensionConfigurationTest.java index a64d8c8d1..0b273ed08 100644 --- a/src/test/java/run/halo/app/config/ExtensionConfigurationTest.java +++ b/src/test/java/run/halo/app/config/ExtensionConfigurationTest.java @@ -18,13 +18,12 @@ import org.springframework.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.web.reactive.server.WebTestClient; +import run.halo.app.core.extension.Role; +import run.halo.app.core.extension.service.RoleService; import run.halo.app.extension.FakeExtension; import run.halo.app.extension.Metadata; import run.halo.app.extension.Scheme; import run.halo.app.extension.SchemeManager; -import run.halo.app.security.authorization.PolicyRule; -import run.halo.app.security.authorization.Role; -import run.halo.app.security.authorization.RoleGetter; @SpringBootTest @AutoConfigureWebTestClient @@ -38,18 +37,18 @@ class ExtensionConfigurationTest { SchemeManager schemeManager; @MockBean - RoleGetter roleGetter; + RoleService roleService; @BeforeEach void setUp() { // disable authorization - var rule = new PolicyRule(); + var rule = new Role.PolicyRule(); rule.setApiGroups(new String[] {"*"}); rule.setResources(new String[] {"*"}); rule.setVerbs(new String[] {"*"}); var role = new Role(); role.setRules(List.of(rule)); - when(roleGetter.getRole(anyString())).thenReturn(role); + when(roleService.getRole(anyString())).thenReturn(role); } @AfterEach diff --git a/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java b/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java new file mode 100644 index 000000000..58b536711 --- /dev/null +++ b/src/test/java/run/halo/app/core/extension/service/UserServiceImplTest.java @@ -0,0 +1,79 @@ +package run.halo.app.core.extension.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import reactor.test.StepVerifier; +import run.halo.app.core.extension.User; +import run.halo.app.extension.ExtensionClient; + +@ExtendWith(MockitoExtension.class) +class UserServiceImplTest { + + @Mock + ExtensionClient client; + + @InjectMocks + UserServiceImpl userService; + + @Test + void shouldGetEmptyUserIfUserNotFoundInExtension() { + when(client.fetch(User.class, "faker")).thenReturn(Optional.empty()); + + StepVerifier.create(userService.getUser("faker")) + .verifyComplete(); + + verify(client, times(1)).fetch(eq(User.class), eq("faker")); + } + + @Test + void shouldGetUserIfUserFoundInExtension() { + User fakeUser = new User(); + when(client.fetch(User.class, "faker")).thenReturn(Optional.of(fakeUser)); + + StepVerifier.create(userService.getUser("faker")) + .assertNext(user -> assertEquals(fakeUser, user)) + .verifyComplete(); + + verify(client, times(1)).fetch(eq(User.class), eq("faker")); + } + + @Test + void shouldUpdatePasswordIfUserFoundInExtension() { + User fakeUser = new User(); + fakeUser.setSpec(new User.UserSpec()); + + when(client.fetch(User.class, "faker")).thenReturn(Optional.of(fakeUser)); + + StepVerifier.create(userService.updatePassword("faker", "new-fake-password")) + .verifyComplete(); + + verify(client, times(1)).fetch(eq(User.class), eq("faker")); + verify(client, times(1)).update(argThat(extension -> { + var user = (User) extension; + return "new-fake-password".equals(user.getSpec().getPassword()); + })); + } + + @Test + void shouldNotUpdatePasswordIfUserNotFoundInExtension() { + when(client.fetch(User.class, "faker")).thenReturn(Optional.empty()); + + StepVerifier.create(userService.updatePassword("faker", "new-fake-password")) + .verifyComplete(); + + verify(client, times(1)).fetch(eq(User.class), eq("faker")); + verify(client, times(0)).update(any()); + } +} diff --git a/src/test/java/run/halo/app/extension/GroupVersionKindTest.java b/src/test/java/run/halo/app/extension/GroupVersionKindTest.java new file mode 100644 index 000000000..05750afaf --- /dev/null +++ b/src/test/java/run/halo/app/extension/GroupVersionKindTest.java @@ -0,0 +1,79 @@ +package run.halo.app.extension; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static run.halo.app.extension.GroupVersionKind.fromAPIVersionAndKind; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class GroupVersionKindTest { + + @Test + void testFromApiVersionAndKind() { + record TestCase(String apiVersion, String kind, GroupVersionKind expected, + Class exception) { + } + + List.of( + new TestCase("v1alpha1", "Fake", new GroupVersionKind("", "v1alpha1", "Fake"), null), + new TestCase("fake.halo.run/v1alpha1", "Fake", + new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"), null), + new TestCase("", "", null, IllegalArgumentException.class), + new TestCase("", "Fake", null, IllegalArgumentException.class), + new TestCase("v1alpha1", "", null, IllegalArgumentException.class), + new TestCase("fake.halo.run/v1alpha1/v1alpha2", "Fake", null, + IllegalArgumentException.class) + ).forEach(testCase -> { + if (testCase.exception != null) { + assertThrows(testCase.exception, () -> { + fromAPIVersionAndKind(testCase.apiVersion, testCase.kind); + }); + } else { + var got = fromAPIVersionAndKind(testCase.apiVersion, testCase.kind); + assertEquals(testCase.expected, got); + } + }); + } + + @Test + void testHasGroup() { + record TestCase(GroupVersionKind gvk, boolean hasGroup) { + } + + List.of( + new TestCase(new GroupVersionKind("", "v1alpha1", "Fake"), false), + new TestCase(new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"), true) + ).forEach(testCase -> assertEquals(testCase.hasGroup, testCase.gvk.hasGroup())); + } + + @Test + void testGroupKind() { + record TestCase(GroupVersionKind gvk, GroupKind gk) { + } + + List.of( + new TestCase(new GroupVersionKind("", "v1alpha1", "Fake"), new GroupKind("", "Fake")), + new TestCase(new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"), + new GroupKind("fake.halo.run", "Fake")) + ).forEach(testCase -> { + assertEquals(testCase.gk, testCase.gvk.groupKind()); + }); + } + + @Test + void testGroupVersion() { + record TestCase(GroupVersionKind gvk, GroupVersion gv) { + } + + List.of( + new TestCase(new GroupVersionKind("", "v1alpha1", "Fake"), + new GroupVersion("", "v1alpha1")), + new TestCase(new GroupVersionKind("fake.halo.run", "v1alpha1", "Fake"), + new GroupVersion("fake.halo.run", "v1alpha1")) + ).forEach(testCase -> { + assertEquals(testCase.gv, testCase.gvk.groupVersion()); + }); + } + +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/extension/JSONExtensionConverterTest.java b/src/test/java/run/halo/app/extension/JsonExtensionConverterTest.java similarity index 96% rename from src/test/java/run/halo/app/extension/JSONExtensionConverterTest.java rename to src/test/java/run/halo/app/extension/JsonExtensionConverterTest.java index 73c772eaf..25ac99131 100644 --- a/src/test/java/run/halo/app/extension/JSONExtensionConverterTest.java +++ b/src/test/java/run/halo/app/extension/JsonExtensionConverterTest.java @@ -12,7 +12,7 @@ import run.halo.app.extension.exception.ExtensionConvertException; import run.halo.app.extension.exception.SchemaViolationException; import run.halo.app.extension.store.ExtensionStore; -class JSONExtensionConverterTest { +class JsonExtensionConverterTest { JSONExtensionConverter converter; @@ -71,7 +71,7 @@ class JSONExtensionConverterTest { fake.setKind("Fake"); var error = assertThrows(SchemaViolationException.class, () -> converter.convertTo(fake)); assertEquals(1, error.getErrors().size()); - assertEquals("$.metadata.name: null found, string expected", + assertEquals("$.metadata.name: is missing but it is required", error.getErrors().iterator().next().getMessage()); } diff --git a/src/test/java/run/halo/app/plugin/PluginLifeCycleManagerControllerTest.java b/src/test/java/run/halo/app/plugin/PluginLifeCycleManagerControllerTest.java index 554f76f11..9a93e4dff 100644 --- a/src/test/java/run/halo/app/plugin/PluginLifeCycleManagerControllerTest.java +++ b/src/test/java/run/halo/app/plugin/PluginLifeCycleManagerControllerTest.java @@ -13,10 +13,9 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.reactive.server.WebTestClient; +import run.halo.app.core.extension.Role; +import run.halo.app.core.extension.service.RoleService; import run.halo.app.extension.Metadata; -import run.halo.app.security.authorization.DefaultRoleGetter; -import run.halo.app.security.authorization.PolicyRule; -import run.halo.app.security.authorization.Role; /** * @author guqing @@ -29,8 +28,9 @@ class PluginLifeCycleManagerControllerTest { @Autowired WebTestClient webClient; + @MockBean - DefaultRoleGetter defaultRoleGetter; + RoleService roleService; @MockBean HaloPluginManager haloPluginManager; @@ -46,13 +46,13 @@ class PluginLifeCycleManagerControllerTest { Metadata metadata = new Metadata(); metadata.setName("test-plugin-lifecycle-role"); role.setMetadata(metadata); - PolicyRule policyRule = new PolicyRule.Builder() + Role.PolicyRule policyRule = new Role.PolicyRule.Builder() .apiGroups("plugin.halo.run") .resources("plugins", "plugins/startup", "plugins/stop") .verbs("*") .build(); role.setRules(List.of(policyRule)); - when(defaultRoleGetter.getRole("USER")).thenReturn(role); + when(roleService.getRole("USER")).thenReturn(role); when(haloPluginManager.startPlugin(any())).thenReturn(PluginState.STARTED); when(haloPluginManager.stopPlugin(any())).thenReturn(PluginState.STOPPED); } diff --git a/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java b/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java new file mode 100644 index 000000000..f3e167a29 --- /dev/null +++ b/src/test/java/run/halo/app/security/DefaultUserDetailServiceTest.java @@ -0,0 +1,192 @@ +package run.halo.app.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import run.halo.app.core.extension.Role; +import run.halo.app.core.extension.RoleBinding.RoleRef; +import run.halo.app.core.extension.RoleBinding.Subject; +import run.halo.app.core.extension.service.RoleService; +import run.halo.app.core.extension.service.UserService; +import run.halo.app.extension.Metadata; + +@ExtendWith(MockitoExtension.class) +class DefaultUserDetailServiceTest { + + @Mock + UserService userService; + + @Mock + RoleService roleService; + + @InjectMocks + DefaultUserDetailService userDetailService; + + @Test + void shouldUpdatePasswordSuccessfully() { + var fakeUser = createFakeUserDetails(); + + when(userService.updatePassword("faker", "new-fake-password")).thenReturn( + Mono.just("").then() + ); + + var userDetailsMono = userDetailService.updatePassword(fakeUser, "new-fake-password"); + + StepVerifier.create(userDetailsMono) + .expectSubscription() + .assertNext(userDetails -> assertEquals("new-fake-password", userDetails.getPassword())) + .verifyComplete(); + + verify(userService, times(1)).updatePassword(eq("faker"), eq("new-fake-password")); + } + + @Test + void shouldReturnErrorWhenFailedToUpdatePassword() { + var fakeUser = createFakeUserDetails(); + + var exception = mock(RuntimeException.class); + when(userService.updatePassword("faker", "new-fake-password")).thenReturn( + Mono.error(exception) + ); + + var userDetailsMono = userDetailService.updatePassword(fakeUser, "new-fake-password"); + + StepVerifier.create(userDetailsMono) + .expectSubscription() + .expectErrorMatches(throwable -> throwable == exception) + .verify(); + verify(userService, times(1)).updatePassword(eq("faker"), eq("new-fake-password")); + } + + @Test + void shouldFindUserDetailsByExistingUsername() { + var foundUser = createFakeUser(); + + var roleGvk = new Role().groupVersionKind(); + var roleRef = new RoleRef(); + roleRef.setKind(roleGvk.kind()); + roleRef.setApiGroup(roleGvk.group()); + roleRef.setName("fake-role"); + + var userGvk = foundUser.groupVersionKind(); + var subject = new Subject(userGvk.kind(), "faker", userGvk.group()); + + when(userService.getUser("faker")).thenReturn(Mono.just(foundUser)); + when(roleService.listRoleRefs(subject)).thenReturn(Flux.just(roleRef)); + + var userDetailsMono = userDetailService.findByUsername("faker"); + + StepVerifier.create(userDetailsMono) + .expectSubscription() + .assertNext(gotUser -> { + assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername()); + assertEquals(foundUser.getSpec().getPassword(), gotUser.getPassword()); + assertEquals(List.of("ROLE_fake-role"), + gotUser.getAuthorities() + .stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList())); + }) + .verifyComplete(); + } + + @Test + void shouldFindUserDetailsByExistingUsernameButKindOfRoleRefIsNotRole() { + var foundUser = createFakeUser(); + + var roleGvk = new Role().groupVersionKind(); + var roleRef = new RoleRef(); + roleRef.setKind("FakeRole"); + roleRef.setApiGroup("fake.halo.run"); + roleRef.setName("fake-role"); + + var userGvk = foundUser.groupVersionKind(); + var subject = new Subject(userGvk.kind(), "faker", userGvk.group()); + + when(userService.getUser("faker")).thenReturn(Mono.just(foundUser)); + when(roleService.listRoleRefs(subject)).thenReturn(Flux.just(roleRef)); + + var userDetailsMono = userDetailService.findByUsername("faker"); + + StepVerifier.create(userDetailsMono) + .expectSubscription() + .assertNext(gotUser -> { + assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername()); + assertEquals(foundUser.getSpec().getPassword(), gotUser.getPassword()); + assertEquals(0, gotUser.getAuthorities().size()); + }) + .verifyComplete(); + } + + @Test + void shouldFindUserDetailsByExistingUsernameButWithoutAnyRoles() { + var foundUser = createFakeUser(); + + var userGvk = foundUser.groupVersionKind(); + var subject = new Subject(userGvk.kind(), "faker", userGvk.group()); + + when(userService.getUser("faker")).thenReturn(Mono.just(foundUser)); + when(roleService.listRoleRefs(subject)).thenReturn(Flux.empty()); + + var userDetailsMono = userDetailService.findByUsername("faker"); + + StepVerifier.create(userDetailsMono) + .expectSubscription() + .assertNext(gotUser -> { + assertEquals(foundUser.getMetadata().getName(), gotUser.getUsername()); + assertEquals(foundUser.getSpec().getPassword(), gotUser.getPassword()); + assertEquals(0, gotUser.getAuthorities().size()); + }) + .verifyComplete(); + } + + @Test + void shouldNotFindUserDetailsByNonExistingUsername() { + when(userService.getUser("non-existing-user")).thenReturn(Mono.empty()); + + var userDetailsMono = userDetailService.findByUsername("non-existing-user"); + + StepVerifier.create(userDetailsMono) + .expectSubscription() + .verifyComplete(); + } + + UserDetails createFakeUserDetails() { + return User.builder() + .username("faker") + .password("fake-password") + .roles("fake-role") + .build(); + } + + run.halo.app.core.extension.User createFakeUser() { + var metadata = new Metadata(); + metadata.setName("faker"); + + var userSpec = new run.halo.app.core.extension.User.UserSpec(); + userSpec.setPassword("fake-password"); + + var user = new run.halo.app.core.extension.User(); + user.setMetadata(metadata); + user.setSpec(userSpec); + return user; + + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java b/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java new file mode 100644 index 000000000..e09f528ab --- /dev/null +++ b/src/test/java/run/halo/app/security/SuperAdminInitializerTest.java @@ -0,0 +1,55 @@ +package run.halo.app.security; + +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.web.reactive.server.WebTestClient; +import run.halo.app.core.extension.Role; +import run.halo.app.core.extension.RoleBinding; +import run.halo.app.core.extension.User; +import run.halo.app.extension.ExtensionClient; + +@SpringBootTest +@AutoConfigureWebTestClient +@AutoConfigureTestDatabase +class SuperAdminInitializerTest { + + @Autowired + @SpyBean + ExtensionClient client; + + @Autowired + WebTestClient webClient; + + @Test + void checkSuperAdminInitialization() { + verify(client, times(1)).fetch(eq(User.class), eq("admin")); + verify(client, times(1)).create(argThat(extension -> { + if (extension instanceof User user) { + return "admin".equals(user.getMetadata().getName()); + } + return false; + })); + verify(client, times(1)).create(argThat(extension -> { + if (extension instanceof Role role) { + return "super-role".equals(role.getMetadata().getName()); + } + return false; + })); + verify(client, times(1)).create(argThat(extension -> { + if (extension instanceof RoleBinding roleBinding) { + return "admin-super-role-binding".equals(roleBinding.getMetadata().getName()); + } + return false; + })); + } + +} diff --git a/src/test/java/run/halo/app/security/authentication/jwt/LoginTest.java b/src/test/java/run/halo/app/security/authentication/jwt/LoginTest.java index d7412e9e1..a2461d47b 100644 --- a/src/test/java/run/halo/app/security/authentication/jwt/LoginTest.java +++ b/src/test/java/run/halo/app/security/authentication/jwt/LoginTest.java @@ -3,16 +3,23 @@ package run.halo.app.security.authentication.jwt; import static org.hamcrest.Matchers.equalTo; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.when; import com.nimbusds.jwt.JWTClaimNames; +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.reactive.AutoConfigureWebTestClient; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.security.core.userdetails.ReactiveUserDetailsService; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.jwt.ReactiveJwtDecoder; import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Mono; @SpringBootTest @AutoConfigureWebTestClient @@ -21,6 +28,21 @@ class LoginTest { @Autowired WebTestClient webClient; + @MockBean + ReactiveUserDetailsService userDetailsService; + + @BeforeEach + void setUp(@Autowired PasswordEncoder passwordEncoder) { + when(userDetailsService.findByUsername("user")).thenReturn(Mono.just( + User.builder() + .passwordEncoder(passwordEncoder::encode) + .username("user") + .password("password") + .roles("USER") + .build() + )); + } + @Test void logintWithoutLoginRequest() { webClient.post().uri("/api/auth/token").exchange().expectStatus().isUnauthorized(); @@ -46,6 +68,7 @@ class LoginTest { @Test void loginWithInvalidCredential() { + when(userDetailsService.findByUsername("user")).thenReturn(Mono.empty()); var request = new LoginAuthenticationConverter.UsernamePasswordRequest(); request.setUsername("user"); request.setPassword("invalid_password"); @@ -57,7 +80,16 @@ class LoginTest { } @Test - void loginWithValidCredential(@Autowired ReactiveJwtDecoder jwtDecoder) { + void loginWithValidCredential(@Autowired ReactiveJwtDecoder jwtDecoder, + @Autowired PasswordEncoder passwordEncoder) { + when(userDetailsService.findByUsername("user")).thenReturn(Mono.just( + User.builder() + .passwordEncoder(passwordEncoder::encode) + .username("user") + .password("password") + .roles("USER") + .build() + )); var request = new LoginAuthenticationConverter.UsernamePasswordRequest(); request.setUsername("user"); request.setPassword("password"); diff --git a/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java b/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java index 0407c917b..3141635f0 100644 --- a/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java +++ b/src/test/java/run/halo/app/security/authorization/AuthorizationTest.java @@ -28,6 +28,9 @@ import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Role; +import run.halo.app.core.extension.Role.PolicyRule; +import run.halo.app.core.extension.service.RoleService; import run.halo.app.security.LoginUtils; @SpringBootTest @@ -42,7 +45,7 @@ class AuthorizationTest { ReactiveUserDetailsService userDetailsService; @MockBean - RoleGetter roleGetter; + RoleService roleService; @Test void accessProtectedApiWithoutSufficientRole() { @@ -67,7 +70,7 @@ class AuthorizationTest { new PolicyRule.Builder().apiGroups("fake.halo.run").verbs("list").resources("posts") .build())); - when(roleGetter.getRole(eq("post.read"))).thenReturn(role); + when(roleService.getRole(eq("post.read"))).thenReturn(role); var token = LoginUtils.login(webClient, "user", "password").block(); webClient.get().uri("/apis/fake.halo.run/v1/posts") @@ -80,7 +83,7 @@ class AuthorizationTest { .header(HttpHeaders.AUTHORIZATION, "Bearer " + token).exchange() .expectStatus().isForbidden(); - verify(roleGetter, times(2)).getRole("post.read"); + verify(roleService, times(2)).getRole("post.read"); } @TestConfiguration diff --git a/src/test/java/run/halo/app/security/authorization/DefaultRoleBindingListerTest.java b/src/test/java/run/halo/app/security/authorization/DefaultRoleBindingServiceTest.java similarity index 84% rename from src/test/java/run/halo/app/security/authorization/DefaultRoleBindingListerTest.java rename to src/test/java/run/halo/app/security/authorization/DefaultRoleBindingServiceTest.java index 60cb101b2..9b31222e4 100644 --- a/src/test/java/run/halo/app/security/authorization/DefaultRoleBindingListerTest.java +++ b/src/test/java/run/halo/app/security/authorization/DefaultRoleBindingServiceTest.java @@ -10,21 +10,22 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; +import run.halo.app.core.extension.service.DefaultRoleBindingService; /** - * Tests for {@link DefaultRoleBindingLister}. + * Tests for {@link DefaultRoleBindingService}. * * @author guqing * @since 2.0.0 */ // @ExtendWith(SpringExtension.class) -public class DefaultRoleBindingListerTest { +public class DefaultRoleBindingServiceTest { - private DefaultRoleBindingLister roleBindingLister; + private DefaultRoleBindingService roleBindingLister; @BeforeEach void setUp() { - roleBindingLister = new DefaultRoleBindingLister(); + roleBindingLister = new DefaultRoleBindingService(); } @AfterEach diff --git a/src/test/java/run/halo/app/security/authorization/PolicyRuleTest.java b/src/test/java/run/halo/app/security/authorization/PolicyRuleTest.java index 4bd6f7284..42631f496 100644 --- a/src/test/java/run/halo/app/security/authorization/PolicyRuleTest.java +++ b/src/test/java/run/halo/app/security/authorization/PolicyRuleTest.java @@ -7,10 +7,11 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import run.halo.app.core.extension.Role; import run.halo.app.infra.utils.JsonUtils; /** - * Tests for {@link PolicyRule}. + * Tests for {@link Role.PolicyRule}. * * @author guqing * @since 2.0.0 @@ -25,20 +26,20 @@ class PolicyRuleTest { @Test public void constructPolicyRule() throws JsonProcessingException { - PolicyRule policyRule = new PolicyRule(null, null, null, null, null); + Role.PolicyRule policyRule = new Role.PolicyRule(null, null, null, null, null); assertThat(policyRule).isNotNull(); JsonNode policyRuleJson = objectMapper.valueToTree(policyRule); assertThat(policyRuleJson).isEqualTo(objectMapper.readTree(""" {"apiGroups":[],"resources":[],"resourceNames":[],"nonResourceURLs":[],"verbs":[]} """)); - PolicyRule policyByBuilder = new PolicyRule.Builder().build(); + Role.PolicyRule policyByBuilder = new Role.PolicyRule.Builder().build(); JsonNode policyByBuilderJson = objectMapper.valueToTree(policyByBuilder); assertThat(policyByBuilderJson).isEqualTo(objectMapper.readTree(""" {"apiGroups":[],"resources":[],"resourceNames":[],"nonResourceURLs":[],"verbs":[]} """)); - PolicyRule policyNonNull = new PolicyRule.Builder() + Role.PolicyRule policyNonNull = new Role.PolicyRule.Builder() .apiGroups("group") .resources("resource-1", "resource-2") .resourceNames("resourceName") diff --git a/src/test/java/run/halo/app/security/authorization/RequestInfoResolverTest.java b/src/test/java/run/halo/app/security/authorization/RequestInfoResolverTest.java index 70c80426c..4307b0036 100644 --- a/src/test/java/run/halo/app/security/authorization/RequestInfoResolverTest.java +++ b/src/test/java/run/halo/app/security/authorization/RequestInfoResolverTest.java @@ -3,6 +3,9 @@ package run.halo.app.security.authorization; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; import static org.springframework.mock.http.server.reactive.MockServerHttpRequest.method; import java.util.Collection; @@ -15,6 +18,9 @@ import org.springframework.http.HttpMethod; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.AuthorityUtils; import org.springframework.security.core.userdetails.User; +import run.halo.app.core.extension.Role; +import run.halo.app.core.extension.Role.PolicyRule; +import run.halo.app.core.extension.service.RoleService; import run.halo.app.extension.Metadata; /** @@ -100,30 +106,32 @@ public class RequestInfoResolverTest { @Test public void defaultRuleResolverTest() { - DefaultRuleResolver ruleResolver = new DefaultRuleResolver(name -> { - // role getter - Role role = new Role(); - List rules = List.of( - new PolicyRule.Builder().apiGroups("").resources("posts").verbs("list", "get") - .build(), - new PolicyRule.Builder().apiGroups("").resources("categories").verbs("*").build(), - new PolicyRule.Builder().nonResourceURLs("/healthy").verbs("get", "post", "head") - .build()); - role.setRules(rules); - Metadata metadata = new Metadata(); - metadata.setName("ruleReadPost"); - role.setMetadata(metadata); - return role; - }); + var roleService = mock(RoleService.class); + var ruleResolver = new DefaultRuleResolver(roleService); + + Role role = new Role(); + List rules = List.of( + new PolicyRule.Builder().apiGroups("").resources("posts").verbs("list", "get") + .build(), + new PolicyRule.Builder().apiGroups("").resources("categories").verbs("*").build(), + new PolicyRule.Builder().nonResourceURLs("/healthy").verbs("get", "post", "head") + .build()); + role.setRules(rules); + Metadata metadata = new Metadata(); + metadata.setName("ruleReadPost"); + role.setMetadata(metadata); + + when(roleService.getRole(anyString())).thenReturn(role); + // list bound role names - ruleResolver.setRoleBindingLister( + ruleResolver.setRoleBindingService( (Collection authorities) -> Set.of("ruleReadPost")); User user = new User("admin", "123456", AuthorityUtils.createAuthorityList("ruleReadPost")); // resolve user rules - List rules = ruleResolver.rulesFor(user); - assertThat(rules).isNotNull(); + List resolvedRules = ruleResolver.rulesFor(user); + assertThat(resolvedRules).isNotNull(); RbacRequestEvaluation rbacRequestEvaluation = new RbacRequestEvaluation(); for (RequestResolveCase requestResolveCase : getRequestResolveCases()) { @@ -133,7 +141,7 @@ public class RequestInfoResolverTest { RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); AttributesRecord attributes = new AttributesRecord(user, requestInfo); - boolean allowed = rbacRequestEvaluation.rulesAllow(attributes, rules); + boolean allowed = rbacRequestEvaluation.rulesAllow(attributes, resolvedRules); assertThat(allowed).isEqualTo(requestResolveCase.expected); } }