diff --git a/src/main/java/run/halo/app/config/WebSecurityConfig.java b/src/main/java/run/halo/app/config/WebSecurityConfig.java index 3b80726c3..044d56341 100644 --- a/src/main/java/run/halo/app/config/WebSecurityConfig.java +++ b/src/main/java/run/halo/app/config/WebSecurityConfig.java @@ -10,6 +10,7 @@ import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; import java.security.interfaces.RSAPrivateKey; import java.security.interfaces.RSAPublicKey; +import java.util.List; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.security.authentication.AuthenticationManager; @@ -43,9 +44,16 @@ import run.halo.app.identity.authentication.ProviderContextFilter; import run.halo.app.identity.authentication.ProviderSettings; import run.halo.app.identity.authentication.verifier.BearerTokenAuthenticationFilter; import run.halo.app.identity.authentication.verifier.JwtProvidedDecoderAuthenticationManagerResolver; +import run.halo.app.identity.authorization.AuthorizationFilter; +import run.halo.app.identity.authorization.PolicyRule; +import run.halo.app.identity.authorization.Role; +import run.halo.app.identity.authorization.RoleBinding; +import run.halo.app.identity.authorization.RoleRef; +import run.halo.app.identity.authorization.Subject; import run.halo.app.identity.entrypoint.JwtAccessDeniedHandler; import run.halo.app.identity.entrypoint.JwtAuthenticationEntryPoint; import run.halo.app.infra.properties.JwtProperties; +import run.halo.app.infra.types.ObjectMeta; /** * @author guqing @@ -85,6 +93,7 @@ public class WebSecurityConfig { .addFilterBefore(new BearerTokenAuthenticationFilter(authenticationManagerResolver()), BasicAuthenticationFilter.class) .addFilterAfter(providerContextFilter, SecurityContextPersistenceFilter.class) + .addFilterBefore(authorizationFilter(), FilterSecurityInterceptor.class) .sessionManagement( (session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .exceptionHandling((exceptions) -> exceptions @@ -94,6 +103,49 @@ public class WebSecurityConfig { return http.build(); } + public AuthorizationFilter authorizationFilter() { + // TODO fake role and role bindings, only used for testing/development + // It'll be deleted next time + return new AuthorizationFilter(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); + ObjectMeta objectMeta = new ObjectMeta(); + objectMeta.setName("ruleReadPost"); + role.setObjectMeta(objectMeta); + return role; + }, () -> { + // role binding lister + RoleBinding roleBinding = new RoleBinding(); + + ObjectMeta objectMeta = new ObjectMeta(); + objectMeta.setName("userRoleBinding"); + roleBinding.setObjectMeta(objectMeta); + + Subject subject = new Subject(); + subject.setName("user"); + subject.setKind("User"); + subject.setApiGroup(""); + roleBinding.setSubjects(List.of(subject)); + + RoleRef roleRef = new RoleRef(); + roleRef.setKind("Role"); + roleRef.setName("ruleReadPost"); + roleRef.setApiGroup(""); + roleBinding.setRoleRef(roleRef); + + return List.of(roleBinding); + }); + } + AuthenticationManagerResolver authenticationManagerResolver() { return new JwtProvidedDecoderAuthenticationManagerResolver(jwtDecoder()); } @@ -149,6 +201,8 @@ public class WebSecurityConfig { @Bean public InMemoryUserDetailsManager userDetailsService() { + // TODO fake role and role bindings, only used for testing/development + // It'll be deleted next time UserDetails user = User.withUsername("user") .password(passwordEncoder().encode("123456")) .roles("USER") diff --git a/src/main/java/run/halo/app/identity/HelloController.java b/src/main/java/run/halo/app/identity/HelloController.java index 6acb98a86..350faac40 100644 --- a/src/main/java/run/halo/app/identity/HelloController.java +++ b/src/main/java/run/halo/app/identity/HelloController.java @@ -1,21 +1,28 @@ package run.halo.app.identity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; /** * A controller should ONLY be used during testing for this PR. + * TODO It'll be deleted next time * * @author guqing * @since 2.0.0 */ @RestController -@RequestMapping("/tests") +@RequestMapping("/posts") public class HelloController { @GetMapping public String hello() { return "Now you see me."; } + + @GetMapping("/{name}") + public String getByName(@PathVariable String name) { + return "Name:" + name + "-->Now you see me."; + } } diff --git a/src/main/java/run/halo/app/identity/TagController.java b/src/main/java/run/halo/app/identity/TagController.java new file mode 100644 index 000000000..169f9d905 --- /dev/null +++ b/src/main/java/run/halo/app/identity/TagController.java @@ -0,0 +1,28 @@ +package run.halo.app.identity; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * A controller should ONLY be used during testing for this PR. + * TODO It'll be deleted next time + * + * @author guqing + * @since 2.0.0 + */ +@RestController +@RequestMapping("/tags") +public class TagController { + + @GetMapping + public String hello() { + return "Tag you see me."; + } + + @GetMapping("/{name}") + public String getByName(@PathVariable String name) { + return "Tag name:" + name + "-->Now you see me."; + } +} diff --git a/src/main/java/run/halo/app/identity/authorization/Attributes.java b/src/main/java/run/halo/app/identity/authorization/Attributes.java new file mode 100644 index 000000000..a5019e308 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/Attributes.java @@ -0,0 +1,68 @@ +package run.halo.app.identity.authorization; + +import org.springframework.security.core.userdetails.UserDetails; + +/** + * Attributes is used by an Authorizer to get information about a request + * that is used to make an authorization decision. + * + * @author guqing + * @since 2.0.0 + */ +public interface Attributes { + /** + * @return the UserDetails object to authorize + */ + UserDetails getUser(); + + /** + * @return the verb associated with API requests(this includes get, list, + * watch, create, update, patch, delete, deletecollection, and proxy) + * or the lower-cased HTTP verb associated with non-API requests(this + * includes get, put, post, patch, and delete) + */ + String getVerb(); + + /** + * @return when isReadOnly() == true, the request has no side effects, other than + * caching, logging, and other incidentals. + */ + boolean isReadOnly(); + + /** + * @return The kind of object, if a request is for a REST object. + */ + String getResource(); + + /** + * @return the subresource being requested, if present. + */ + String getSubresource(); + + /** + * @return the name of the object as parsed off the request. This will not be + * present for all request types, but will be present for: get, update, delete + */ + String getName(); + + /** + * @return The group of the resource, if a request is for a REST object. + */ + String getApiGroup(); + + /** + * @return the version of the group requested, if a request is for a REST object. + */ + String getApiVersion(); + + /** + * @return true for requests to API resources, like /api/v1/nodes, + * and false for non-resource endpoints like /api, /healthz + */ + boolean isResourceRequest(); + + /** + * @return returns the path of the request + */ + String getPath(); +} diff --git a/src/main/java/run/halo/app/identity/authorization/AttributesRecord.java b/src/main/java/run/halo/app/identity/authorization/AttributesRecord.java new file mode 100644 index 000000000..f61d7fc27 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/AttributesRecord.java @@ -0,0 +1,70 @@ +package run.halo.app.identity.authorization; + +import org.springframework.security.core.userdetails.UserDetails; + +/** + * @author guqing + * @since 2.0.0 + */ +public class AttributesRecord implements Attributes { + private final RequestInfo requestInfo; + private final UserDetails user; + + public AttributesRecord(UserDetails user, RequestInfo requestInfo) { + this.requestInfo = requestInfo; + this.user = user; + } + + @Override + public UserDetails getUser() { + return this.user; + } + + @Override + public String getVerb() { + return requestInfo.getVerb(); + } + + @Override + public boolean isReadOnly() { + String verb = requestInfo.getVerb(); + return "get".equals(verb) + || "list".equals(verb) + || "watch".equals(verb); + } + + @Override + public String getResource() { + return requestInfo.getResource(); + } + + @Override + public String getSubresource() { + return requestInfo.getSubresource(); + } + + @Override + public String getName() { + return requestInfo.getName(); + } + + @Override + public String getApiGroup() { + return requestInfo.getApiGroup(); + } + + @Override + public String getApiVersion() { + return requestInfo.getApiVersion(); + } + + @Override + public boolean isResourceRequest() { + return requestInfo.isResourceRequest(); + } + + @Override + public String getPath() { + return requestInfo.getPath(); + } +} diff --git a/src/main/java/run/halo/app/identity/authorization/AuthorizationFilter.java b/src/main/java/run/halo/app/identity/authorization/AuthorizationFilter.java new file mode 100644 index 000000000..bb7fe9140 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/AuthorizationFilter.java @@ -0,0 +1,98 @@ +package run.halo.app.identity.authorization; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.lang.NonNull; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.security.web.access.AccessDeniedHandlerImpl; +import org.springframework.util.Assert; +import org.springframework.util.CollectionUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * An authorization filter that restricts access to the URL. + * + * @author guqing + * @since 2.0.0 + */ +@Slf4j +public class AuthorizationFilter extends OncePerRequestFilter { + private AuthorizationRuleResolver ruleResolver; + private AccessDeniedHandler accessDeniedHandler = new AccessDeniedHandlerImpl(); + + public AuthorizationFilter(RoleGetter roleGetter, RoleBindingLister roleBindingLister) { + this.ruleResolver = new DefaultRuleResolver(roleGetter, roleBindingLister); + } + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) + throws ServletException, IOException { + RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); + Authentication authentication = getAuthentication(); + UserDetails userDetails = createUserDetails(authentication); + + AttributesRecord attributes = new AttributesRecord(userDetails, requestInfo); + + // visitor rules + AuthorizingVisitor authorizingVisitor = new AuthorizingVisitor(attributes); + ruleResolver.visitRulesFor(userDetails, authorizingVisitor); + + if (!authorizingVisitor.isAllowed()) { + // print errors + showErrorMessage(authorizingVisitor.getErrors()); + // handle it + accessDeniedHandler.handle(request, response, + new AccessDeniedException("Access is denied")); + return; + } + log.debug(authorizingVisitor.getReason()); + filterChain.doFilter(request, response); + } + + private void showErrorMessage(List errors) { + if (CollectionUtils.isEmpty(errors)) { + return; + } + for (Throwable error : errors) { + log.error("Access decision error: ", error); + } + } + + private Authentication getAuthentication() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null) { + throw new AuthenticationCredentialsNotFoundException( + "An Authentication object was not found in the SecurityContext"); + } + return authentication; + } + + private UserDetails createUserDetails(Authentication authentication) { + Assert.notNull(authentication, "The authentication must not be null."); + return User.withUsername(authentication.getName()) + .authorities(authentication.getAuthorities()) + .password("N/A") + .build(); + } + + public void setRuleResolver(AuthorizationRuleResolver ruleResolver) { + Assert.notNull(ruleResolver, "ruleResolver must not be null."); + this.ruleResolver = ruleResolver; + } + + public void setAccessDeniedHandler(AccessDeniedHandler accessDeniedHandler) { + this.accessDeniedHandler = accessDeniedHandler; + } +} diff --git a/src/main/java/run/halo/app/identity/authorization/AuthorizationRuleResolver.java b/src/main/java/run/halo/app/identity/authorization/AuthorizationRuleResolver.java new file mode 100644 index 000000000..cf979b5f6 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/AuthorizationRuleResolver.java @@ -0,0 +1,31 @@ +package run.halo.app.identity.authorization; + +import org.springframework.security.core.userdetails.UserDetails; + +/** + * @author guqing + * @since 2.0.0 + */ +public interface AuthorizationRuleResolver { + + /** + * rulesFor returns the list of rules that apply to a given user. + * If an error is returned, the slice of PolicyRules may not be complete, + * but it contains all retrievable rules. + * This is done because policy rules are purely additive and policy determinations + * can be made on the basis of those rules that are found. + * + * @param user authenticated user info + */ + PolicyRuleList rulesFor(UserDetails user); + + /** + * visitRulesFor invokes visitor() with each rule that applies to a given user + * and each error encountered resolving those rules. Rule may be null if err is non-nil. + * If visitor() returns false, visiting is short-circuited. + * + * @param user user info + * @param visitor visitor + */ + void visitRulesFor(UserDetails user, RuleAccumulator visitor); +} diff --git a/src/main/java/run/halo/app/identity/authorization/AuthorizingVisitor.java b/src/main/java/run/halo/app/identity/authorization/AuthorizingVisitor.java new file mode 100644 index 000000000..a8a8ba7a8 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/AuthorizingVisitor.java @@ -0,0 +1,51 @@ +package run.halo.app.identity.authorization; + +import java.util.ArrayList; +import java.util.List; + +/** + * authorizing visitor short-circuits once allowed, and collects any resolution errors encountered + * + * @author guqing + * @since 2.0.0 + */ +class AuthorizingVisitor implements RuleAccumulator { + private final RbacRequestEvaluation requestEvaluation = new RbacRequestEvaluation(); + + private final Attributes requestAttributes; + + private boolean allowed; + + private String reason; + + private final List errors = new ArrayList<>(4); + + public AuthorizingVisitor(Attributes requestAttributes) { + this.requestAttributes = requestAttributes; + } + + @Override + public boolean visit(String source, PolicyRule rule, Throwable error) { + if (rule != null && requestEvaluation.ruleAllows(requestAttributes, rule)) { + this.allowed = true; + this.reason = String.format("RBAC: allowed by %s", source); + return false; + } + if (error != null) { + this.errors.add(error); + } + return true; + } + + public boolean isAllowed() { + return allowed; + } + + public String getReason() { + return reason; + } + + public List getErrors() { + return errors; + } +} diff --git a/src/main/java/run/halo/app/identity/authorization/DefaultRuleResolver.java b/src/main/java/run/halo/app/identity/authorization/DefaultRuleResolver.java new file mode 100644 index 000000000..9b6ceee76 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/DefaultRuleResolver.java @@ -0,0 +1,101 @@ +package run.halo.app.identity.authorization; + +import java.util.Collections; +import java.util.List; +import lombok.Data; +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.core.userdetails.UserDetails; + +/** + * @author guqing + * @since 2.0.0 + */ +@Data +public class DefaultRuleResolver implements AuthorizationRuleResolver { + private static final String USER_KIND = "User"; + RoleGetter roleGetter; + RoleBindingLister roleBindingLister; + + public DefaultRuleResolver(RoleGetter roleGetter, RoleBindingLister roleBindingLister) { + this.roleGetter = roleGetter; + this.roleBindingLister = roleBindingLister; + } + + @Override + public PolicyRuleList rulesFor(UserDetails user) { + PolicyRuleList policyRules = new PolicyRuleList(); + visitRulesFor(user, (source, rule, err) -> { + if (rule != null) { + policyRules.add(rule); + } + if (err != null) { + policyRules.addError(err); + } + return true; + }); + return policyRules; + } + + @Override + public void visitRulesFor(UserDetails user, RuleAccumulator visitor) { + List roleBindings = Collections.emptyList(); + try { + roleBindings = roleBindingLister.listRoleBindings(); + } catch (Exception e) { + if (visitor.visit(null, null, e)) { + return; + } + } + + for (RoleBinding roleBinding : roleBindings) { + AppliesResult appliesResult = appliesTo(user, roleBinding.subjects); + if (!appliesResult.applies) { + continue; + } + + Subject subject = roleBinding.subjects.get(appliesResult.subjectIndex); + + List rules = Collections.emptyList(); + try { + Role role = roleGetter.getRole(roleBinding.roleRef.name); + rules = role.getRules(); + } catch (Exception e) { + if (visitor.visit(null, null, e)) { + return; + } + } + + String source = roleBindingDescriber(roleBinding, subject); + for (PolicyRule rule : rules) { + if (!visitor.visit(source, rule, null)) { + return; + } + } + } + } + + String roleBindingDescriber(RoleBinding roleBinding, Subject subject) { + String describeSubject = String.format("%s %s", subject.kind, subject.name); + return String.format("RoleBinding %s of %s %s to %s", roleBinding.getName(), + roleBinding.roleRef.getKind(), roleBinding.roleRef.getName(), describeSubject); + } + + AppliesResult appliesTo(UserDetails user, List bindingSubjects) { + for (int i = 0; i < bindingSubjects.size(); i++) { + if (appliesToUser(user, bindingSubjects.get(i))) { + return new AppliesResult(true, i); + } + } + return new AppliesResult(false, 0); + } + + boolean appliesToUser(UserDetails user, Subject subject) { + if (USER_KIND.equals(subject.kind)) { + return StringUtils.equals(user.getUsername(), subject.name); + } + return false; + } + + record AppliesResult(boolean applies, int subjectIndex) { + } +} diff --git a/src/main/java/run/halo/app/identity/authorization/NonResourceRuleInfo.java b/src/main/java/run/halo/app/identity/authorization/NonResourceRuleInfo.java new file mode 100644 index 000000000..ee3fb590b --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/NonResourceRuleInfo.java @@ -0,0 +1,8 @@ +package run.halo.app.identity.authorization; + +/** + * @author guqing + * @since 2.0.0 + */ +public class NonResourceRuleInfo { +} diff --git a/src/main/java/run/halo/app/identity/authorization/PolicyRule.java b/src/main/java/run/halo/app/identity/authorization/PolicyRule.java new file mode 100644 index 000000000..9cbfcc56c --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/PolicyRule.java @@ -0,0 +1,105 @@ +package run.halo.app.identity.authorization; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * PolicyRule holds information that describes a policy rule, but does not contain information + * about who the rule applies to or which namespace the rule applies to. + * + * @author guqing + * @since 2.0.0 + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +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 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; + } + + String[] nullElseEmpty(String... items) { + if (items == null) { + return new String[] {}; + } + return items; + } + + public PolicyRule build() { + return new PolicyRule( + nullElseEmpty(apiGroups), + nullElseEmpty(resources), + nullElseEmpty(resourceNames), + nullElseEmpty(nonResourceURLs), + nullElseEmpty(verbs) + ); + } + } +} diff --git a/src/main/java/run/halo/app/identity/authorization/PolicyRuleInfo.java b/src/main/java/run/halo/app/identity/authorization/PolicyRuleInfo.java new file mode 100644 index 000000000..278ebab12 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/PolicyRuleInfo.java @@ -0,0 +1,12 @@ +package run.halo.app.identity.authorization; + +import java.util.List; + +/** + * @author guqing + * @since 2.0.0 + */ +public class PolicyRuleInfo { + List resourceRules; + List nonResourceRules; +} diff --git a/src/main/java/run/halo/app/identity/authorization/PolicyRuleList.java b/src/main/java/run/halo/app/identity/authorization/PolicyRuleList.java new file mode 100644 index 000000000..0b92936ae --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/PolicyRuleList.java @@ -0,0 +1,34 @@ +package run.halo.app.identity.authorization; + +import java.util.ArrayList; +import java.util.LinkedList; +import java.util.List; + +/** + * @author guqing + * @since 2.0.0 + */ +public class PolicyRuleList extends LinkedList { + private final List errors = new ArrayList<>(4); + + /** + * @return true if an error occurred when parsing PolicyRules + */ + public boolean hasErrors() { + return !errors.isEmpty(); + } + + public List getErrors() { + return errors; + } + + public PolicyRuleList addError(Throwable error) { + errors.add(error); + return this; + } + + public PolicyRuleList addErrors(List errors) { + this.errors.addAll(errors); + return this; + } +} diff --git a/src/main/java/run/halo/app/identity/authorization/RbacRequestEvaluation.java b/src/main/java/run/halo/app/identity/authorization/RbacRequestEvaluation.java new file mode 100644 index 000000000..351819ca8 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/RbacRequestEvaluation.java @@ -0,0 +1,125 @@ +package run.halo.app.identity.authorization; + +import java.util.List; +import java.util.Objects; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; + +/** + * @author guqing + * @since 2.0.0 + */ +public class RbacRequestEvaluation { + interface WildCard { + String APIGroupAll = "*"; + String ResourceAll = "*"; + String VerbAll = "*"; + String NonResourceAll = "*"; + } + + public boolean rulesAllow(Attributes requestAttributes, List rules) { + for (PolicyRule rule : rules) { + if (ruleAllows(requestAttributes, rule)) { + return true; + } + } + return false; + } + + protected boolean ruleAllows(Attributes requestAttributes, PolicyRule rule) { + if (requestAttributes.isResourceRequest()) { + String combinedResource = requestAttributes.getResource(); + if (StringUtils.isNotBlank(requestAttributes.getSubresource())) { + combinedResource = + requestAttributes.getResource() + "/" + requestAttributes.getSubresource(); + } + + return verbMatches(rule, requestAttributes.getVerb()) + && apiGroupMatches(rule, requestAttributes.getApiGroup()) + && resourceMatches(rule, combinedResource, requestAttributes.getSubresource()) + && resourceNameMatches(rule, requestAttributes.getName()); + } + return verbMatches(rule, requestAttributes.getVerb()) + && nonResourceURLMatches(rule, requestAttributes.getPath()); + } + + protected boolean verbMatches(PolicyRule rule, String requestedVerb) { + for (String ruleVerb : rule.getVerbs()) { + if (Objects.equals(ruleVerb, WildCard.VerbAll)) { + return true; + } + if (Objects.equals(ruleVerb, requestedVerb)) { + return true; + } + } + return false; + } + + protected boolean apiGroupMatches(PolicyRule rule, String requestedGroup) { + for (String ruleGroup : rule.getApiGroups()) { + if (Objects.equals(ruleGroup, WildCard.APIGroupAll)) { + return true; + } + if (Objects.equals(ruleGroup, requestedGroup)) { + return true; + } + } + return false; + } + + protected boolean resourceMatches(PolicyRule rule, String combinedRequestedResource, + String requestedSubresource) { + for (String ruleResource : rule.getResources()) { + // if everything is allowed, we match + if (Objects.equals(ruleResource, WildCard.ResourceAll)) { + return true; + } + // if we have an exact match, we match + if (Objects.equals(ruleResource, combinedRequestedResource)) { + return true; + } + + // We can also match a */subresource. + // if there isn't a subresource, then continue + if (StringUtils.isBlank(requestedSubresource)) { + continue; + } + // if the rule isn't in the format */subresource, then we don't match, continue + if (StringUtils.length(ruleResource) == StringUtils.length(requestedSubresource) + 2 + && StringUtils.startsWith(ruleResource, "*/") + && StringUtils.startsWith(ruleResource, requestedSubresource)) { + return true; + } + } + return false; + } + + protected boolean resourceNameMatches(PolicyRule rule, String requestedName) { + if (ArrayUtils.isEmpty(rule.getResourceNames())) { + return true; + } + for (String ruleName : rule.getResourceNames()) { + if (Objects.equals(ruleName, requestedName)) { + return true; + } + } + return false; + } + + protected boolean nonResourceURLMatches(PolicyRule rule, String requestedURL) { + for (String ruleURL : rule.getNonResourceURLs()) { + if (Objects.equals(ruleURL, WildCard.NonResourceAll)) { + return true; + } + if (Objects.equals(ruleURL, requestedURL)) { + return true; + } + if (StringUtils.startsWith(ruleURL, WildCard.NonResourceAll) + && StringUtils.startsWith(requestedURL, + StringUtils.stripEnd(ruleURL, WildCard.NonResourceAll))) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/run/halo/app/identity/authorization/RequestInfo.java b/src/main/java/run/halo/app/identity/authorization/RequestInfo.java new file mode 100644 index 000000000..9c496b44c --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/RequestInfo.java @@ -0,0 +1,48 @@ +package run.halo.app.identity.authorization; + +import java.util.Objects; +import lombok.Getter; +import lombok.ToString; +import org.apache.commons.lang3.StringUtils; + +/** + * @author guqing + * @since 2.0.0 + */ +@Getter +@ToString +public class RequestInfo { + boolean isResourceRequest; + final String path; + String namespace; + String verb; + String apiPrefix; + String apiGroup; + String apiVersion; + String resource; + String subresource; + String name; + String[] parts; + + public RequestInfo(boolean isResourceRequest, String path, String verb) { + this(isResourceRequest, path, null, verb, null, null, null, null, null, null, null); + } + + public RequestInfo(boolean isResourceRequest, String path, String namespace, String verb, + String apiPrefix, + String apiGroup, + String apiVersion, String resource, String subresource, String name, + String[] parts) { + this.isResourceRequest = isResourceRequest; + this.path = StringUtils.defaultString(path, ""); + this.namespace = StringUtils.defaultString(namespace, ""); + this.verb = StringUtils.defaultString(verb, ""); + this.apiPrefix = StringUtils.defaultString(apiPrefix, ""); + this.apiGroup = StringUtils.defaultString(apiGroup, ""); + this.apiVersion = StringUtils.defaultString(apiVersion, ""); + this.resource = StringUtils.defaultString(resource, ""); + this.subresource = StringUtils.defaultString(subresource, ""); + this.name = StringUtils.defaultString(name, ""); + this.parts = Objects.requireNonNullElseGet(parts, () -> new String[] {}); + } +} diff --git a/src/main/java/run/halo/app/identity/authorization/RequestInfoFactory.java b/src/main/java/run/halo/app/identity/authorization/RequestInfoFactory.java new file mode 100644 index 000000000..01230a7c4 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/RequestInfoFactory.java @@ -0,0 +1,205 @@ +package run.halo.app.identity.authorization; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.Objects; +import java.util.Set; +import org.apache.commons.lang3.StringUtils; + +/** + * @author guqing + * @since 2.0.0 + */ +public class RequestInfoFactory { + public static final RequestInfoFactory INSTANCE = + new RequestInfoFactory(Set.of("api", "apis"), Set.of("api")); + + /** + * without leading and trailing slashes + */ + final Set apiPrefixes; + /** + * without leading and trailing slashes + */ + final Set grouplessApiPrefixes; + + final Set specialVerbs; + + public RequestInfoFactory(Set apiPrefixes, Set grouplessApiPrefixes) { + this(apiPrefixes, grouplessApiPrefixes, Set.of("proxy", "watch")); + } + + public RequestInfoFactory(Set apiPrefixes, Set grouplessApiPrefixes, + Set specialVerbs) { + this.apiPrefixes = apiPrefixes; + this.grouplessApiPrefixes = grouplessApiPrefixes; + this.specialVerbs = specialVerbs; + } + + /** + * newRequestInfo returns the information from the http request. If error is not occurred, + * RequestInfo holds the information as best it is known before the failure + * It handles both resource and non-resource requests and fills in all the pertinent + * information + * for each. + *

+ * Valid Inputs: + *

+ * Resource paths + *

+     * /apis/{api-group}/{version}/namespaces
+     * /api/{version}/namespaces
+     * /api/{version}/namespaces/{namespace}
+     * /api/{version}/namespaces/{namespace}/{resource}
+     * /api/{version}/namespaces/{namespace}/{resource}/{resourceName}
+     * /api/{version}/{resource}
+     * /api/{version}/{resource}/{resourceName}
+     * 
+ * + *
+     * Special verbs without subresources:
+     * /api/{version}/proxy/{resource}/{resourceName}
+     * /api/{version}/proxy/namespaces/{namespace}/{resource}/{resourceName}
+     * 
+ * + *
+     * Special verbs with subresources:
+     * /api/{version}/watch/{resource}
+     * /api/{version}/watch/namespaces/{namespace}/{resource}
+     * 
+ * + *
+     * NonResource paths
+     * /apis/{api-group}/{version}
+     * /apis/{api-group}
+     * /apis
+     * /api/{version}
+     * /api
+     * /healthz
+     * 
+ * + * @param request http request + * @return request holds the information of both resource and non-resource requests + */ + public RequestInfo newRequestInfo(HttpServletRequest request) { + // non-resource request default + RequestInfo requestInfo = new RequestInfo( + false, + request.getRequestURI(), + StringUtils.lowerCase(request.getMethod()) + ); + + String[] currentParts = splitPath(request.getRequestURI()); + if (currentParts.length < 3) { + // return a non-resource request + return requestInfo; + } + + if (!apiPrefixes.contains(currentParts[0])) { + // return a non-resource request + return requestInfo; + } + requestInfo.apiPrefix = currentParts[0]; + currentParts = Arrays.copyOfRange(currentParts, 1, currentParts.length); + + if (!grouplessApiPrefixes.contains(requestInfo.apiPrefix)) { + // one part (APIPrefix) has already been consumed, so this is actually "do we have + // four parts?" + if (currentParts.length < 3) { + // return a non-resource request + return requestInfo; + } + + requestInfo.apiGroup = StringUtils.defaultString(currentParts[0], ""); + currentParts = Arrays.copyOfRange(currentParts, 1, currentParts.length); + } + requestInfo.isResourceRequest = true; + requestInfo.apiVersion = currentParts[0]; + currentParts = Arrays.copyOfRange(currentParts, 1, currentParts.length); + // handle input of form /{specialVerb}/* + Set specialVerbs = Set.of("proxy", "watch"); + if (specialVerbs.contains(currentParts[0])) { + if (currentParts.length < 2) { + throw new IllegalArgumentException( + String.format("unable to determine kind and namespace from url, %s", + request.getRequestURI())); + } + requestInfo.verb = currentParts[0]; + currentParts = Arrays.copyOfRange(currentParts, 1, currentParts.length); + } else { + requestInfo.verb = switch (request.getMethod()) { + case "POST" -> "create"; + case "GET", "HEAD" -> "get"; + case "PUT" -> "update"; + case "PATCH" -> "patch"; + case "DELETE" -> "delete"; + default -> ""; + }; + } + Set namespaceSubresources = Set.of("status", "finalize"); + // URL forms: /namespaces/{namespace}/{kind}/*, where parts are adjusted to be relative + // to kind + if (Objects.equals(currentParts[0], "namespaces")) { + if (currentParts.length > 1) { + requestInfo.namespace = currentParts[1]; + + // if there is another step after the namespace name and it is not a known + // namespace subresource + // move currentParts to include it as a resource in its own right + if (currentParts.length > 2 && !namespaceSubresources.contains(currentParts[2])) { + currentParts = Arrays.copyOfRange(currentParts, 2, currentParts.length); + } + } + } else { + requestInfo.namespace = ""; + } + + // parsing successful, so we now know the proper value for .Parts + requestInfo.parts = currentParts; + Set specialVerbsNoSubresources = Set.of("proxy"); + // parts look like: resource/resourceName/subresource/other/stuff/we/don't/interpret + if (requestInfo.parts.length >= 3 && !specialVerbsNoSubresources.contains( + requestInfo.verb)) { + requestInfo.subresource = requestInfo.parts[2]; + } + + if (requestInfo.parts.length >= 2) { + requestInfo.name = requestInfo.parts[1]; + } + + if (requestInfo.parts.length >= 1) { + requestInfo.resource = requestInfo.parts[0]; + } + + // if there's no name on the request and we thought it was a get before, then the actual + // verb is a list or a watch + if (requestInfo.name.length() == 0 && "get".equals(requestInfo.verb)) { + String watch = request.getParameter("watch"); + if (isWatch(watch)) { + requestInfo.verb = "watch"; + } else { + requestInfo.verb = "list"; + } + } + // if there's no name on the request and we thought it was a deleted before, then the + // actual verb is deletecollection + if (requestInfo.name.length() == 0 + && Objects.equals(requestInfo.verb, "delete")) { + requestInfo.verb = "deletecollection"; + } + return requestInfo; + } + + public String[] splitPath(String path) { + path = StringUtils.strip(path, "/"); + if (StringUtils.isEmpty(path)) { + return new String[] {}; + } + return StringUtils.split(path, "/"); + } + + boolean isWatch(String requestParam) { + return "1".equals(requestParam) + || "true".equals(requestParam); + } +} diff --git a/src/main/java/run/halo/app/identity/authorization/ResourceRuleInfo.java b/src/main/java/run/halo/app/identity/authorization/ResourceRuleInfo.java new file mode 100644 index 000000000..31b1b372c --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/ResourceRuleInfo.java @@ -0,0 +1,8 @@ +package run.halo.app.identity.authorization; + +/** + * @author guqing + * @since 2.0.0 + */ +public class ResourceRuleInfo { +} diff --git a/src/main/java/run/halo/app/identity/authorization/Role.java b/src/main/java/run/halo/app/identity/authorization/Role.java new file mode 100644 index 000000000..ec4d6f0a2 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/Role.java @@ -0,0 +1,20 @@ +package run.halo.app.identity.authorization; + +import java.util.List; +import lombok.Data; +import run.halo.app.infra.types.ObjectMeta; +import run.halo.app.infra.types.TypeMeta; + +/** + * @author guqing + * @since 2.0.0 + */ +@Data +public class Role { + + TypeMeta typeMeta; + + ObjectMeta objectMeta; + + List rules; +} diff --git a/src/main/java/run/halo/app/identity/authorization/RoleBinding.java b/src/main/java/run/halo/app/identity/authorization/RoleBinding.java new file mode 100644 index 000000000..53100042f --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/RoleBinding.java @@ -0,0 +1,44 @@ +package run.halo.app.identity.authorization; + +import java.util.List; +import lombok.Data; +import org.apache.commons.lang3.StringUtils; +import run.halo.app.infra.types.ObjectMeta; +import run.halo.app.infra.types.TypeMeta; + +/** + * // RoleBinding references a role, but does not contain it. It can reference a Role in the + * same namespace or a ClusterRole in the global namespace. + * // It adds who information via Subjects and namespace information by which namespace it exists + * in. RoleBindings in a given + * // namespace only have effect in that namespace. + * + * @author guqing + * @since 2.0.0 + */ +@Data +public class RoleBinding { + + TypeMeta typeMeta; + + ObjectMeta objectMeta; + + /** + * 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; + + public String getName() { + if (objectMeta == null) { + return StringUtils.EMPTY; + } + return objectMeta.getName(); + } +} diff --git a/src/main/java/run/halo/app/identity/authorization/RoleBindingLister.java b/src/main/java/run/halo/app/identity/authorization/RoleBindingLister.java new file mode 100644 index 000000000..608207bc8 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/RoleBindingLister.java @@ -0,0 +1,12 @@ +package run.halo.app.identity.authorization; + +import java.util.List; + +/** + * @author guqing + * @since 2.0.0 + */ +@FunctionalInterface +public interface RoleBindingLister { + List listRoleBindings(); +} diff --git a/src/main/java/run/halo/app/identity/authorization/RoleGetter.java b/src/main/java/run/halo/app/identity/authorization/RoleGetter.java new file mode 100644 index 000000000..14e3cb3dc --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/RoleGetter.java @@ -0,0 +1,13 @@ +package run.halo.app.identity.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/identity/authorization/RoleRef.java b/src/main/java/run/halo/app/identity/authorization/RoleRef.java new file mode 100644 index 000000000..bfd4ca381 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/RoleRef.java @@ -0,0 +1,28 @@ +package run.halo.app.identity.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/identity/authorization/RuleAccumulator.java b/src/main/java/run/halo/app/identity/authorization/RuleAccumulator.java new file mode 100644 index 000000000..b5cebb2e0 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/RuleAccumulator.java @@ -0,0 +1,9 @@ +package run.halo.app.identity.authorization; + +/** + * @author guqing + * @since 2.0.0 + */ +public interface RuleAccumulator { + boolean visit(String source, PolicyRule rule, Throwable err); +} diff --git a/src/main/java/run/halo/app/identity/authorization/Subject.java b/src/main/java/run/halo/app/identity/authorization/Subject.java new file mode 100644 index 000000000..45e6f5c04 --- /dev/null +++ b/src/main/java/run/halo/app/identity/authorization/Subject.java @@ -0,0 +1,30 @@ +package run.halo.app.identity.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/main/java/run/halo/app/infra/types/ObjectMeta.java b/src/main/java/run/halo/app/infra/types/ObjectMeta.java new file mode 100644 index 000000000..bd7470a4b --- /dev/null +++ b/src/main/java/run/halo/app/infra/types/ObjectMeta.java @@ -0,0 +1,94 @@ +package run.halo.app.infra.types; + +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.Data; + +/** + * ObjectMeta is metadata that all persisted resources must have, which includes all objects + * users must create. + * + * @author guqing + * @since 2.0.0 + */ +@Data +public class ObjectMeta { + + /** + * Name must be unique within a namespace. Is required when creating resources, although + * some resources may allow a client to request the generation of an appropriate name + * automatically. Name is primarily intended for creation idempotence and configuration + * definition. + * Cannot be updated. + * Cannot be updated. + * More info: names + * +optional + */ + String name; + + /** + * GenerateName is an optional prefix, used by the server, to generate a unique + * name ONLY IF the Name field has not been provided. + * If this field is used, the name returned to the client will be different + * than the name passed. This value will also be combined with a unique suffix. + * The provided value has the same validation rules as the Name field, + * and may be truncated by the length of the suffix required to make the value + * unique on the server. + *

+ * If this field is specified and the generated name exists, the server will return a 409. + *

+ * Applied only if Name is not specified. + * More info: + * idempotency + * +optional + */ + String generateName; + + /** + * UID is the unique in time and space value for this object. It is typically generated by + * the server on successful creation of a resource and is not allowed to change on PUT + * operations. + *

+ * Populated by the system. + * Read-only. + * More info: uids + * +optional + */ + UUID uid; + + /** + * An opaque value that represents the internal version of this object that can + * be used by clients to determine when objects have changed. May be used for optimistic + * concurrency, change detection, and the watch operation on a resource or set of resources. + * Clients must treat these values as opaque and passed unmodified back to the server. + * They may only be valid for a particular resource or set of resources. + *

+ * Populated by the system. + * Read-only. + * Value must be treated as opaque by clients and . + * More info: + * concurrency-control-and-consistency + */ + String resourceVersion; + + /** + * A sequence number representing a specific generation of the desired state. + * Populated by the system. Read-only. + * +optional + */ + Long generation; + + /** + * CreationTimestamp is a timestamp representing the server time when this object was + * created. It is not guaranteed to be set in happens-before order across separate operations. + * Clients may not set this value. It is represented in RFC3339 form and is in UTC. + *

+ * Populated by the system. + * Read-only. + * Null for lists. + * More info: + * metadata + * +optional + */ + LocalDateTime creationTimestamp; +} diff --git a/src/main/java/run/halo/app/infra/types/TypeMeta.java b/src/main/java/run/halo/app/infra/types/TypeMeta.java new file mode 100644 index 000000000..9b47ecdc0 --- /dev/null +++ b/src/main/java/run/halo/app/infra/types/TypeMeta.java @@ -0,0 +1,36 @@ +package run.halo.app.infra.types; + +import lombok.Data; + +/** + * TypeMeta describes an individual object in an API response or request + * with strings representing the type of the object and its API schema version. + * Structures that are versioned or persisted should inline TypeMeta. + * + * @author guqing + * @since 2.0.0 + */ +@Data +public class TypeMeta { + + /** + * Kind is a string value representing the REST resource this object represents. + * Servers may infer this from the endpoint the client submits requests to. + * Cannot be updated. + * In CamelCase. + * More info: + * resources + * +optional + */ + String apiVersion; +} diff --git a/src/test/java/run/halo/app/authorization/RequestInfoResolverTest.java b/src/test/java/run/halo/app/authorization/RequestInfoResolverTest.java new file mode 100644 index 000000000..d001da5f3 --- /dev/null +++ b/src/test/java/run/halo/app/authorization/RequestInfoResolverTest.java @@ -0,0 +1,320 @@ +package run.halo.app.authorization; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.commons.lang3.RegExUtils; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.core.authority.AuthorityUtils; +import org.springframework.security.core.userdetails.User; +import org.springframework.util.MultiValueMap; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; +import run.halo.app.identity.authorization.AttributesRecord; +import run.halo.app.identity.authorization.DefaultRuleResolver; +import run.halo.app.identity.authorization.PolicyRule; +import run.halo.app.identity.authorization.RbacRequestEvaluation; +import run.halo.app.identity.authorization.RequestInfo; +import run.halo.app.identity.authorization.RequestInfoFactory; +import run.halo.app.identity.authorization.Role; +import run.halo.app.identity.authorization.RoleBinding; +import run.halo.app.identity.authorization.RoleRef; +import run.halo.app.identity.authorization.Subject; +import run.halo.app.infra.types.ObjectMeta; + +/** + * Tests for {@link RequestInfoFactory}. + * + * @author guqing + * @see RbacRequestEvaluation + * @see RequestInfo + * @see DefaultRuleResolver + * @since 2.0.0 + */ +public class RequestInfoResolverTest { + + @Test + public void requestInfoTest() { + for (SuccessCase successCase : getTestRequestInfos()) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod(successCase.method); + String url = RegExUtils.removePattern(successCase.url, "\\?.*"); + request.setRequestURI(url); + getParameters(successCase.url).forEach((k, v) -> { + request.addParameter(k, v.toArray(new String[]{})); + }); + + RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); + assertThat(requestInfo).isNotNull(); + assertThat(requestInfo.getVerb()).isEqualTo(successCase.expectedVerb); + assertThat(requestInfo.getApiPrefix()).isEqualTo(successCase.expectedAPIPrefix); + assertThat(requestInfo.getApiGroup()).isEqualTo(successCase.expectedAPIGroup); + assertThat(requestInfo.getApiVersion()).isEqualTo(successCase.expectedAPIVersion); + assertThat(requestInfo.getResource()).isEqualTo(successCase.expectedResource); + assertThat(requestInfo.getSubresource()).isEqualTo(successCase.expectedSubresource); + assertThat(requestInfo.getName()).isEqualTo(successCase.expectedName); + assertThat(requestInfo.getParts()).isEqualTo(successCase.expectedParts); + } + } + + @Test + public void nonApiRequestInfoTest() { + Map map = new HashMap<>(); + map.put("simple groupless", new NonApiCase("/api/version/resource", true)); + map.put("simple group", + new NonApiCase("/apis/group/version/resource/name/subresource", true)); + map.put("more steps", + new NonApiCase("/api/version/resource/name/subresource", true)); + map.put("group list", new NonApiCase("/apis/batch/v1/job", true)); + map.put("group get", new NonApiCase("/apis/batch/v1/job/foo", true)); + map.put("group subresource", new NonApiCase("/apis/batch/v1/job/foo/scale", true)); + + // bad case + map.put("bad root", new NonApiCase("/not-api/version/resource", false)); + map.put("group without enough steps", + new NonApiCase("/apis/extensions/v1beta1", false)); + map.put("group without enough steps 2", + new NonApiCase("/apis/extensions/v1beta1/", false)); + map.put("not enough steps", new NonApiCase("/api/version", false)); + map.put("one step", new NonApiCase("/api", false)); + map.put("zero step", new NonApiCase("/", false)); + map.put("empty", new NonApiCase("", false)); + + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("GET"); + map.forEach((k, v) -> { + request.setRequestURI(v.url); + RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); + if (requestInfo.isResourceRequest() != v.expected) { + throw new RuntimeException( + String.format("%s: expected %s, actual %s", k, v.expected, + requestInfo.isResourceRequest())); + } + }); + } + + @Test + public void errorCaseTest() { + List errorCases = List.of(new ErrorCases("no resource path", "/"), + new ErrorCases("just apiversion", "/api/version/"), + new ErrorCases("just prefix, group, version", "/apis/group/version/"), + new ErrorCases("apiversion with no resource", "/api/version/"), + new ErrorCases("bad prefix", "/badprefix/version/resource"), + new ErrorCases("missing api group", "/apis/version/resource") + ); + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod("GET"); + for (ErrorCases errorCase : errorCases) { + request.setRequestURI(errorCase.url); + RequestInfo apiRequestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); + if (apiRequestInfo.isResourceRequest()) { + throw new RuntimeException( + String.format("%s: expected non-resource request", errorCase.desc)); + } + } + } + + @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); + ObjectMeta objectMeta = new ObjectMeta(); + objectMeta.setName("ruleReadPost"); + role.setObjectMeta(objectMeta); + return role; + }, () -> { + // role binding lister + RoleBinding roleBinding = new RoleBinding(); + + Subject subject = new Subject(); + subject.setName("admin"); + subject.setKind("User"); + subject.setApiGroup(""); + roleBinding.setSubjects(List.of(subject)); + + RoleRef roleRef = new RoleRef(); + roleRef.setKind("Role"); + roleRef.setName("ruleReadPost"); + roleRef.setApiGroup(""); + roleBinding.setRoleRef(roleRef); + + return List.of(roleBinding); + }); + + User user = new User("admin", "123456", AuthorityUtils.createAuthorityList("ruleReadPost")); + // resolve user rules + List rules = ruleResolver.rulesFor(user); + assertThat(rules).isNotNull(); + + RbacRequestEvaluation rbacRequestEvaluation = new RbacRequestEvaluation(); + for (RequestResolveCase requestResolveCase : getRequestResolveCases()) { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.setMethod(requestResolveCase.method); + request.setRequestURI(requestResolveCase.url); + RequestInfo requestInfo = RequestInfoFactory.INSTANCE.newRequestInfo(request); + + AttributesRecord attributes = new AttributesRecord(user, requestInfo); + boolean allowed = rbacRequestEvaluation.rulesAllow(attributes, rules); + assertThat(allowed).isEqualTo(requestResolveCase.expected); + } + } + + + public record NonApiCase(String url, boolean expected){} + + public record ErrorCases(String desc, String url) {} + + List getRequestResolveCases() { + return List.of(new RequestResolveCase("/api/v1/tags", "GET", false), + new RequestResolveCase("/api/v1/tags/tagName", "GET", false), + + new RequestResolveCase("/api/v1/categories/aName", "GET", true), + new RequestResolveCase("/api/v1//categories", "POST", true), + new RequestResolveCase("/api/v1/categories", "DELETE", true), + new RequestResolveCase("/api/v1/posts", "GET", true), + new RequestResolveCase("/api/v1/posts/aName", "GET", true), + + new RequestResolveCase("/api/v1/posts", "DELETE", false), + new RequestResolveCase("/api/v1/posts/aName", "UPDATE", false), + + // group resource url + new RequestResolveCase("/apis/group/v1/posts", "GET", false), + + // non resource url + new RequestResolveCase("/healthy", "GET", true), + new RequestResolveCase("/healthy", "POST", true), + new RequestResolveCase("/healthy", "HEAD", true), + new RequestResolveCase("//healthy", "GET", false), + new RequestResolveCase("/healthy/name", "GET", false), + new RequestResolveCase("/healthy1", "GET", false), + + new RequestResolveCase("//healthy//name", "GET", false), + new RequestResolveCase("/", "GET", false) + ); + } + + public record RequestResolveCase(String url, String method, boolean expected) {} + + + public record SuccessCase(String method, + String url, + String expectedVerb, + String expectedAPIPrefix, + String expectedAPIGroup, + String expectedAPIVersion, + String expectedNamespace, + String expectedResource, + String expectedSubresource, + String expectedName, + String[] expectedParts) {} + + + List getTestRequestInfos() { + String namespaceAll = "*"; + return List.of( + new SuccessCase("GET", "/api/v1/namespaces", "list", "api", + "", "v1", "", + "namespaces", "", "", new String[] {"namespaces"}), + new SuccessCase("GET", "/api/v1/namespaces/other", "get", "api", "", "v1", "other", + "namespaces", "", "other", new String[] {"namespaces", "other"}), + + new SuccessCase("GET", "/api/v1/namespaces/other/posts", "list", "api", "", "v1", + "other", "posts", "", "", new String[] {"posts"}), + new SuccessCase("GET", "/api/v1/namespaces/other/posts/foo", "get", "api", "", "v1", + "other", "posts", "", "foo", new String[] {"posts", "foo"}), + new SuccessCase("HEAD", "/api/v1/namespaces/other/posts/foo", "get", "api", "", "v1", + "other", "posts", "", "foo", new String[] {"posts", "foo"}), + new SuccessCase("GET", "/api/v1/posts", "list", "api", "", "v1", namespaceAll, "posts", + "", "", new String[] {"posts"}), + new SuccessCase("HEAD", "/api/v1/posts", "list", "api", "", "v1", namespaceAll, "posts", + "", "", new String[] {"posts"}), + new SuccessCase("GET", "/api/v1/namespaces/other/posts/foo", "get", "api", "", "v1", + "other", "posts", "", "foo", new String[] {"posts", "foo"}), + new SuccessCase("GET", "/api/v1/namespaces/other/posts", "list", "api", "", "v1", + "other", "posts", "", "", new String[] {"posts"}), + + // special verbs + new SuccessCase("GET", "/api/v1/proxy/namespaces/other/posts/foo", "proxy", "api", "", + "v1", "other", "posts", "", "foo", new String[] {"posts", "foo"}), + new SuccessCase("GET", + "/api/v1/proxy/namespaces/other/posts/foo/subpath/not/a/subresource", + "proxy", "api", "", "v1", "other", + "posts", "", "foo", + new String[] {"posts", "foo", "subpath", "not", "a", "subresource"}), + new SuccessCase("GET", "/api/v1/watch/posts", "watch", "api", "", "v1", namespaceAll, + "posts", "", "", new String[] {"posts"}), + new SuccessCase("GET", "/api/v1/posts?watch=true", "watch", "api", "", "v1", + namespaceAll, "posts", "", "", new String[] {"posts"}), + new SuccessCase("GET", "/api/v1/posts?watch=false", "list", "api", "", "v1", + namespaceAll, "posts", "", "", new String[] {"posts"}), + new SuccessCase("GET", "/api/v1/watch/namespaces/other/posts", "watch", "api", "", "v1", + "other", "posts", "", "", new String[] {"posts"}), + new SuccessCase("GET", "/api/v1/namespaces/other/posts?watch=1", "watch", "api", "", + "v1", "other", "posts", "", + "", new String[] {"posts"}), + new SuccessCase("GET", "/api/v1/namespaces/other/posts?watch=0", "list", + "api", "", "v1", + "other", "posts", "", "", new String[] {"posts"}), + + // subresource identification + new SuccessCase("GET", "/api/v1/namespaces/other/posts/foo/status", "get", "api", "", + "v1", "other", "posts", "status", "foo", new String[] {"posts", "foo", "status"}), + new SuccessCase("GET", "/api/v1/namespaces/other/posts/foo/proxy/subpath", "get", "api", + "", "v1", "other", "posts", "proxy", "foo", + new String[] {"posts", "foo", "proxy", "subpath"}), + new SuccessCase("PUT", "/api/v1/namespaces/other/finalize", "update", "api", "", "v1", + "other", "namespaces", "finalize", "other", + new String[] {"namespaces", "other", "finalize"}), + new SuccessCase("PUT", "/api/v1/namespaces/other/status", "update", "api", "", "v1", + "other", "namespaces", "status", "other", + new String[] {"namespaces", "other", "status"}), + + // verb identification + new SuccessCase("PATCH", "/api/v1/namespaces/other/posts/foo", "patch", "api", "", "v1", + "other", "posts", "", "foo", new String[] {"posts", "foo"}), + new SuccessCase("DELETE", "/api/v1/namespaces/other/posts/foo", "delete", "api", "", + "v1", "other", "posts", "", "foo", new String[] {"posts", "foo"}), + new SuccessCase("POST", "/api/v1/namespaces/other/posts", "create", "api", "", "v1", + "other", "posts", "", "", new String[] {"posts"}), + + // deletecollection verb identification + new SuccessCase("DELETE", "/api/v1/nodes", "deletecollection", "api", "", "v1", "", + "nodes", "", "", new String[] {"nodes"}), + new SuccessCase("DELETE", "/api/v1/namespaces", "deletecollection", "api", "", "v1", "", + "namespaces", "", "", new String[] {"namespaces"}), + new SuccessCase("DELETE", "/api/v1/namespaces/other/posts", "deletecollection", "api", + "", "v1", "other", "posts", "", "", new String[] {"posts"}), + new SuccessCase("DELETE", "/apis/extensions/v1/namespaces/other/posts", + "deletecollection", "apis", "extensions", "v1", "other", "posts", "", "", + new String[] {"posts"}), + + // api group identification + new SuccessCase("POST", "/apis/extensions/v1/namespaces/other/posts", "create", "apis", + "extensions", "v1", "other", "posts", "", "", new String[] {"posts"}), + + // api version identification + new SuccessCase("POST", "/apis/extensions/v1beta3/namespaces/other/posts", "create", + "apis", "extensions", "v1beta3", "other", "posts", "", "", new String[] {"posts"}) + ); + } + + + static MultiValueMap getParameters(String requestUri) { + UriComponents uriComponents = UriComponentsBuilder.fromUriString(requestUri) + .build(); + return uriComponents.getQueryParams(); + } +} diff --git a/src/test/java/run/halo/app/integration/security/AuthenticationTest.java b/src/test/java/run/halo/app/integration/security/AuthenticationTest.java index 1a9692797..f6af57a47 100644 --- a/src/test/java/run/halo/app/integration/security/AuthenticationTest.java +++ b/src/test/java/run/halo/app/integration/security/AuthenticationTest.java @@ -10,8 +10,14 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; -import org.springframework.http.HttpStatus; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.stereotype.Controller; import org.springframework.test.context.TestPropertySource; @@ -21,16 +27,16 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; -import run.halo.app.config.WebSecurityConfig; +import run.halo.app.identity.entrypoint.JwtAccessDeniedHandler; +import run.halo.app.identity.entrypoint.JwtAuthenticationEntryPoint; /** * @author guqing * @date 2022-04-12 */ -@TestPropertySource(properties = {"halo.security.oauth2.jwt.public-key-location=classpath:app.pub", - "halo.security.oauth2.jwt.private-key-location=classpath:app.key"}) @WebMvcTest -@Import(WebSecurityConfig.class) +@Import(TestWebSecurityConfig.class) +@TestPropertySource(properties = {"spring.main.allow-bean-definition-overriding=true"}) public class AuthenticationTest { private MockMvc mockMvc; @@ -58,7 +64,7 @@ public class AuthenticationTest { public void securedApiTest() throws Exception { mockMvc.perform(get("/api/secured")) .andDo(print()) - .andExpect(status().is(HttpStatus.UNAUTHORIZED.value())) + .andExpect(status().is(401)) .andExpect(content().string("Unauthorized")); } @@ -80,4 +86,25 @@ public class AuthenticationTest { return "You can't see me."; } } + + @EnableWebSecurity + @TestConfiguration + static class TestWebSecurityConfig { + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests((authorize) -> authorize + .antMatchers("/api/**", "/apis/**").authenticated() + ) + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(Customizer.withDefaults()) + .sessionManagement( + (session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling((exceptions) -> exceptions + .authenticationEntryPoint(new JwtAuthenticationEntryPoint()) + .accessDeniedHandler(new JwtAccessDeniedHandler()) + ); + return http.build(); + } + } } diff --git a/src/test/java/run/halo/app/integration/security/AuthorizationFilterTest.java b/src/test/java/run/halo/app/integration/security/AuthorizationFilterTest.java new file mode 100644 index 000000000..62bb489fa --- /dev/null +++ b/src/test/java/run/halo/app/integration/security/AuthorizationFilterTest.java @@ -0,0 +1,152 @@ +package run.halo.app.integration.security; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.stereotype.Controller; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; +import run.halo.app.identity.authorization.AuthorizationFilter; + +/** + * Tests for {@link AuthorizationFilter}. + * + * @author guqing + * @since 2.0.0 + */ +public class AuthorizationFilterTest extends AuthorizationTestSuit { + + private MockMvc mockMvc; + + @Autowired + SecurityFilterChain securityFilterChain; + + private String accessToken; + + @BeforeEach + public void setUp() throws Exception { + mockMvc = setUpMock(PostController.class, TagController.class, + HealthyController.class); + OAuth2AccessTokenResponse tokenResponse = mockAuth(); + accessToken = tokenResponse.getAccessToken().getTokenValue(); + } + + @Test + public void resourceRequestShouldOk() throws Exception { + mockMvc.perform(get("/api/v1/posts") + .headers(withBearerToken(accessToken))) + .andDo(print()) + .andExpect(status().is2xxSuccessful()) + .andExpect(content().string("Now you see me.")); + } + + @Test + public void resourceNameRequestShouldOk() throws Exception { + mockMvc.perform(get("/api/v1/posts/halo") + .headers(withBearerToken(accessToken))) + .andDo(print()) + .andExpect(status().is2xxSuccessful()) + .andExpect(content().string("Name: halo, Now you see me.")); + } + + @Test + public void resourceRequestWhenHaveNoRightShould403Status() throws Exception { + mockMvc.perform(get("/api/v1/tags") + .headers(withBearerToken(accessToken))) + .andDo(print()) + .andExpect(status().isForbidden()); + } + + @Test + public void resourceNameRequestWhenHaveNoRightShould403Status() throws Exception { + mockMvc.perform(get("/api/v1/tags/tag-test") + .headers(withBearerToken(accessToken))) + .andDo(print()) + .andExpect(status().isForbidden()); + } + + + @Test + public void resourceRequestWhenNotExistsThenShould404() throws Exception { + mockMvc.perform(get("/api/v1/categories") + .headers(withBearerToken(accessToken))) + .andDo(print()) + .andExpect(status().isNotFound()); + } + + @Test + public void nonResourceRequestWhenHaveRightShouldOk() throws Exception { + mockMvc.perform(get("/healthy") + .headers(withBearerToken(accessToken))) + .andDo(print()) + .andExpect(status().is2xxSuccessful()) + .andExpect(content().string("ok.")); + } + + @Test + public void nonResourceRequestWhenHaveNoRightShould403() throws Exception { + mockMvc.perform(get("/healthy/halo") + .headers(withBearerToken(accessToken))) + .andDo(print()) + .andExpect(status().isForbidden()); + } + + @RestController + @RequestMapping("/api/v1/posts") + public static class PostController { + + @GetMapping + public String hello() { + return "Now you see me."; + } + + @GetMapping("/{name}") + public String getByName(@PathVariable String name) { + return "Name: " + name + ", Now you see me."; + } + } + + @RestController + @RequestMapping("/api/v1/tags") + public static class TagController { + + @GetMapping + public String hello() { + return "Tag you see me."; + } + + @GetMapping("/{name}") + public String getByName(@PathVariable String name) { + return "Tag name:" + name + "-->Now you see me."; + } + } + + @Controller + @RequestMapping("/healthy") + public static class HealthyController { + + @GetMapping + @ResponseBody + public String check() { + return "ok."; + } + + @ResponseBody + @GetMapping("/{name}") + public String check(@PathVariable String name) { + return name + ": should not be seen."; + } + } + +} diff --git a/src/test/java/run/halo/app/integration/security/AuthorizationTestSuit.java b/src/test/java/run/halo/app/integration/security/AuthorizationTestSuit.java new file mode 100644 index 000000000..5a72f2a20 --- /dev/null +++ b/src/test/java/run/halo/app/integration/security/AuthorizationTestSuit.java @@ -0,0 +1,63 @@ +package run.halo.app.integration.security; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.Filter; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.security.oauth2.core.endpoint.DefaultMapOAuth2AccessTokenResponseConverter; +import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.util.Assert; + +/** + * @author guqing + * @since 2.0.0 + */ +@WebMvcTest +@Import(TestWebSecurityConfig.class) +public class AuthorizationTestSuit { + private MockMvc mockMvc; + @Autowired + SecurityFilterChain securityFilterChain; + @Autowired + ObjectMapper objectMapper; + + public MockMvc setUpMock(Object... controllers) { + mockMvc = MockMvcBuilders.standaloneSetup(controllers) + .addFilters(securityFilterChain.getFilters().toArray(new Filter[] {})) + .build(); + return mockMvc; + } + + public OAuth2AccessTokenResponse mockAuth() throws Exception { + Assert.notNull(mockMvc, "call setUpMock(args) method first."); + MvcResult mvcResult = mockMvc.perform(post("/api/v1/oauth2/token") + .param("username", "test_user") + .param("password", "123456") + .param("grant_type", "password")) + .andExpect(status().is2xxSuccessful()) + .andReturn(); + String content = mvcResult.getResponse().getContentAsString(); + Map stringStringMap = + objectMapper.readValue(content, new TypeReference<>() {}); + DefaultMapOAuth2AccessTokenResponseConverter converter = + new DefaultMapOAuth2AccessTokenResponseConverter(); + return converter.convert(stringStringMap); + } + + public HttpHeaders withBearerToken(String token) { + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.add(HttpHeaders.AUTHORIZATION, "Bearer " + token); + return httpHeaders; + } +} diff --git a/src/test/java/run/halo/app/integration/security/TestWebSecurityConfig.java b/src/test/java/run/halo/app/integration/security/TestWebSecurityConfig.java new file mode 100644 index 000000000..bb39e201b --- /dev/null +++ b/src/test/java/run/halo/app/integration/security/TestWebSecurityConfig.java @@ -0,0 +1,211 @@ +package run.halo.app.integration.security; + +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.proc.SecurityContext; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.util.List; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationManagerResolver; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.access.intercept.FilterSecurityInterceptor; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; +import org.springframework.security.web.context.SecurityContextPersistenceFilter; +import org.springframework.test.context.TestPropertySource; +import run.halo.app.identity.authentication.InMemoryOAuth2AuthorizationService; +import run.halo.app.identity.authentication.JwtGenerator; +import run.halo.app.identity.authentication.OAuth2AuthorizationService; +import run.halo.app.identity.authentication.OAuth2PasswordAuthenticationProvider; +import run.halo.app.identity.authentication.OAuth2RefreshTokenAuthenticationProvider; +import run.halo.app.identity.authentication.OAuth2TokenEndpointFilter; +import run.halo.app.identity.authentication.ProviderContextFilter; +import run.halo.app.identity.authentication.ProviderSettings; +import run.halo.app.identity.authentication.verifier.BearerTokenAuthenticationFilter; +import run.halo.app.identity.authentication.verifier.JwtProvidedDecoderAuthenticationManagerResolver; +import run.halo.app.identity.authorization.AuthorizationFilter; +import run.halo.app.identity.authorization.PolicyRule; +import run.halo.app.identity.authorization.Role; +import run.halo.app.identity.authorization.RoleBinding; +import run.halo.app.identity.authorization.RoleRef; +import run.halo.app.identity.authorization.Subject; +import run.halo.app.infra.properties.JwtProperties; +import run.halo.app.infra.types.ObjectMeta; + +/** + * @author guqing + * @since 2.0.0 + */ +@TestConfiguration +@EnableWebSecurity +@TestPropertySource(properties = {"halo.security.oauth2.jwt.public-key-location=classpath:app.pub", + "halo.security.oauth2.jwt.private-key-location=classpath:app.key"}) +@EnableConfigurationProperties(JwtProperties.class) +public class TestWebSecurityConfig { + private final RSAPublicKey key; + + private final RSAPrivateKey priv; + + private final AuthenticationManagerBuilder authenticationManagerBuilder; + + public TestWebSecurityConfig(JwtProperties jwtProperties, + AuthenticationManagerBuilder authenticationManagerBuilder) throws IOException { + this.key = jwtProperties.readPublicKey(); + this.priv = jwtProperties.readPrivateKey(); + this.authenticationManagerBuilder = authenticationManagerBuilder; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + ProviderSettings providerSettings = providerSettings(); + ProviderContextFilter providerContextFilter = new ProviderContextFilter(providerSettings); + http + .authorizeHttpRequests((authorize) -> authorize + .antMatchers(providerSettings.getTokenEndpoint()).permitAll() + .antMatchers("/api/**", "/apis/**").authenticated() + ) + .csrf(AbstractHttpConfigurer::disable) + .httpBasic(Customizer.withDefaults()) + .addFilterBefore(new OAuth2TokenEndpointFilter(authenticationManager(), + providerSettings.getTokenEndpoint()), + FilterSecurityInterceptor.class) + .addFilterBefore(new BearerTokenAuthenticationFilter(authenticationManagerResolver()), + BasicAuthenticationFilter.class) + .addFilterAfter(providerContextFilter, SecurityContextPersistenceFilter.class) + .addFilterBefore(authorizationFilter(), FilterSecurityInterceptor.class) + .sessionManagement( + (session) -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + return http.build(); + } + + public AuthorizationFilter authorizationFilter() { + return new AuthorizationFilter(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); + ObjectMeta objectMeta = new ObjectMeta(); + objectMeta.setName("ruleReadPost"); + role.setObjectMeta(objectMeta); + return role; + }, () -> { + // role binding lister + RoleBinding roleBinding = new RoleBinding(); + + ObjectMeta objectMeta = new ObjectMeta(); + objectMeta.setName("userRoleBinding"); + roleBinding.setObjectMeta(objectMeta); + + Subject subject = new Subject(); + subject.setName("test_user"); + subject.setKind("User"); + subject.setApiGroup(""); + roleBinding.setSubjects(List.of(subject)); + + RoleRef roleRef = new RoleRef(); + roleRef.setKind("Role"); + roleRef.setName("ruleReadPost"); + roleRef.setApiGroup(""); + roleBinding.setRoleRef(roleRef); + + return List.of(roleBinding); + }); + } + + AuthenticationManagerResolver authenticationManagerResolver() { + return new JwtProvidedDecoderAuthenticationManagerResolver(jwtDecoder()); + } + + @Bean + AuthenticationManager authenticationManager() throws Exception { + authenticationManagerBuilder.authenticationProvider(passwordAuthenticationProvider()) + .authenticationProvider(oauth2RefreshTokenAuthenticationProvider()); + return authenticationManagerBuilder.getOrBuild(); + } + + @Bean + JwtDecoder jwtDecoder() { + return NimbusJwtDecoder.withPublicKey(this.key).build(); + } + + @Bean + JwtEncoder jwtEncoder() { + JWK jwk = new RSAKey.Builder(this.key).privateKey(this.priv).build(); + JWKSource jwks = new ImmutableJWKSet<>(new JWKSet(jwk)); + return new NimbusJwtEncoder(jwks); + } + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + OAuth2AuthorizationService oauth2AuthorizationService() { + return new InMemoryOAuth2AuthorizationService(); + } + + @Bean + OAuth2PasswordAuthenticationProvider passwordAuthenticationProvider() { + OAuth2PasswordAuthenticationProvider authenticationProvider = + new OAuth2PasswordAuthenticationProvider(jwtGenerator(), oauth2AuthorizationService()); + authenticationProvider.setUserDetailsService(userDetailsService()); + authenticationProvider.setPasswordEncoder(passwordEncoder()); + return authenticationProvider; + } + + @Bean + OAuth2RefreshTokenAuthenticationProvider oauth2RefreshTokenAuthenticationProvider() { + return new OAuth2RefreshTokenAuthenticationProvider(oauth2AuthorizationService(), + jwtGenerator()); + } + + @Bean + JwtGenerator jwtGenerator() { + return new JwtGenerator(jwtEncoder()); + } + + @Bean + public InMemoryUserDetailsManager userDetailsService() { + UserDetails user = User.withUsername("test_user") + .password(passwordEncoder().encode("123456")) + .roles("ruleReadPost") + .build(); + return new InMemoryUserDetailsManager(user); + } + + @Bean + ProviderSettings providerSettings() { + return ProviderSettings.builder().build(); + } +}