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 <johnniang@fastmail.com>
pull/2161/head
John Niang 2022-06-20 11:26:18 +08:00 committed by GitHub
parent e52db6859f
commit f5d629d2bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 1164 additions and 360 deletions

View File

@ -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);
};
}
}

View File

@ -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

View File

@ -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<PolicyRule> 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.
* '*&#47;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);
}
}
}
}

View File

@ -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<Subject> 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;
}
}

View File

@ -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<LoginHistory> 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;
}
}

View File

@ -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_";

View File

@ -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<RoleRef> listRoleRefs(Subject subject) {
return Flux.fromIterable(extensionClient.list(RoleBinding.class,
binding -> binding.getSubjects().contains(subject),
null))
.map(RoleBinding::getRoleRef);
}
}

View File

@ -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<String> listBoundRoleNames(Collection<? extends GrantedAuthority> authorities);
}

View File

@ -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<RoleRef> listRoleRefs(Subject subject);
}

View File

@ -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<User> getUser(String username);
Mono<Void> updatePassword(String username, String newPassword);
}

View File

@ -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<User> getUser(String username) {
return Mono.justOrEmpty(client.fetch(User.class, username));
}
@Override
public Mono<Void> updatePassword(String username, String newPassword) {
return getUser(username)
.doOnNext(user -> {
user.getSpec().setPassword(newPassword);
client.update(user);
})
.then();
}
}

View File

@ -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);
}

View File

@ -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());

View File

@ -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<ApplicationStartedEvent> {
@ -22,8 +23,9 @@ public class SchemeInitializer implements ApplicationListener<ApplicationStarted
@Override
public void onApplicationEvent(@NonNull ApplicationStartedEvent event) {
schemeManager.register(Role.class);
schemeManager.register(RoleBinding.class);
schemeManager.register(PersonalAccessToken.class);
schemeManager.register(Plugin.class);
schemeManager.register(RoleBinding.class);
schemeManager.register(User.class);
}
}

View File

@ -0,0 +1,68 @@
package run.halo.app.security;
import org.springframework.security.core.userdetails.ReactiveUserDetailsPasswordService;
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import reactor.core.publisher.Mono;
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.GroupKind;
public class DefaultUserDetailService
implements ReactiveUserDetailsService, ReactiveUserDetailsPasswordService {
private final UserService userService;
private final RoleService roleService;
public DefaultUserDetailService(UserService userService, RoleService roleService) {
this.userService = userService;
this.roleService = roleService;
}
@Override
public Mono<UserDetails> 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<UserDetails> 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();
}
}

View File

@ -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<ApplicationStartedEvent> {
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;
}
}

View File

@ -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);

View File

@ -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();
}
}

View File

@ -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<String> roleNames = roleBindingLister.listBoundRoleNames(user.getAuthorities());
Set<String> roleNames = roleBindingService.listBoundRoleNames(user.getAuthorities());
List<PolicyRule> rules = Collections.emptyList();
List<Role.PolicyRule> 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;
}
}

View File

@ -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.
* '*&#47;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);
}
}
}

View File

@ -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<PolicyRule> {
public class PolicyRuleList extends LinkedList<Role.PolicyRule> {
private final List<Throwable> errors = new ArrayList<>(4);
/**

View File

@ -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<PolicyRule> rules) {
for (PolicyRule rule : rules) {
public boolean rulesAllow(Attributes requestAttributes, List<Role.PolicyRule> 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;

View File

@ -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

View File

@ -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<PolicyRule> rules;
}

View File

@ -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<Subject> 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;
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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;
}

View File

@ -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

View File

@ -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());
}
}

View File

@ -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<? extends Throwable> 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());
});
}
}

View File

@ -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());
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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;
}));
}
}

View File

@ -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");

View File

@ -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

View File

@ -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

View File

@ -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")

View File

@ -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<PolicyRule> 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<PolicyRule> 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<? extends GrantedAuthority> authorities) -> Set.of("ruleReadPost"));
User user = new User("admin", "123456", AuthorityUtils.createAuthorityList("ruleReadPost"));
// resolve user rules
List<PolicyRule> rules = ruleResolver.rulesFor(user);
assertThat(rules).isNotNull();
List<PolicyRule> 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);
}
}