feat: Add rule resolver and authorization filter (#1904)

* feat: Add request info resolver and evaluation

* feat: Add attributes record

* feat: Add rule resolver and authorization filter

* feat: Add authorization intergation test case

* chore: add todo for only testing class
pull/2071/head
guqing 2022-04-29 14:39:08 +08:00 committed by GitHub
parent 9a0dc50653
commit d9bccdb5e1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 2119 additions and 7 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Throwable> 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<Throwable> getErrors() {
return errors;
}
}

View File

@ -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<RoleBinding> 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<PolicyRule> 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<Subject> 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) {
}
}

View File

@ -0,0 +1,8 @@
package run.halo.app.identity.authorization;
/**
* @author guqing
* @since 2.0.0
*/
public class NonResourceRuleInfo {
}

View File

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

View File

@ -0,0 +1,12 @@
package run.halo.app.identity.authorization;
import java.util.List;
/**
* @author guqing
* @since 2.0.0
*/
public class PolicyRuleInfo {
List<ResourceRuleInfo> resourceRules;
List<NonResourceRuleInfo> nonResourceRules;
}

View File

@ -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<PolicyRule> {
private final List<Throwable> errors = new ArrayList<>(4);
/**
* @return true if an error occurred when parsing PolicyRules
*/
public boolean hasErrors() {
return !errors.isEmpty();
}
public List<Throwable> getErrors() {
return errors;
}
public PolicyRuleList addError(Throwable error) {
errors.add(error);
return this;
}
public PolicyRuleList addErrors(List<Throwable> errors) {
this.errors.addAll(errors);
return this;
}
}

View File

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

View File

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

View File

@ -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<String> apiPrefixes;
/**
* without leading and trailing slashes
*/
final Set<String> grouplessApiPrefixes;
final Set<String> specialVerbs;
public RequestInfoFactory(Set<String> apiPrefixes, Set<String> grouplessApiPrefixes) {
this(apiPrefixes, grouplessApiPrefixes, Set.of("proxy", "watch"));
}
public RequestInfoFactory(Set<String> apiPrefixes, Set<String> grouplessApiPrefixes,
Set<String> 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.
* <p>
* Valid Inputs:
* <p>
* Resource paths
* <pre>
* /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}
* </pre>
*
* <pre>
* Special verbs without subresources:
* /api/{version}/proxy/{resource}/{resourceName}
* /api/{version}/proxy/namespaces/{namespace}/{resource}/{resourceName}
* </pre>
*
* <pre>
* Special verbs with subresources:
* /api/{version}/watch/{resource}
* /api/{version}/watch/namespaces/{namespace}/{resource}
* </pre>
*
* <pre>
* NonResource paths
* /apis/{api-group}/{version}
* /apis/{api-group}
* /apis
* /api/{version}
* /api
* /healthz
* </pre>
*
* @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<String> 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<String> 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<String> 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);
}
}

View File

@ -0,0 +1,8 @@
package run.halo.app.identity.authorization;
/**
* @author guqing
* @since 2.0.0
*/
public class ResourceRuleInfo {
}

View File

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

View File

@ -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<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;
public String getName() {
if (objectMeta == null) {
return StringUtils.EMPTY;
}
return objectMeta.getName();
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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: <a href="http://kubernetes.io/docs/user-guide/identifiers#names">names</a>
* +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.
* <p>
* If this field is specified and the generated name exists, the server will return a 409.
* <p>
* Applied only if Name is not specified.
* More info:
* <a href="https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency">idempotency</a>
* +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.
* <p>
* Populated by the system.
* Read-only.
* More info: <a href="http://kubernetes.io/docs/user-guide/identifiers#uids">uids</a>
* +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.
* <p>
* Populated by the system.
* Read-only.
* Value must be treated as opaque by clients and .
* More info:
* <a href="https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency">concurrency-control-and-consistency</a>
*/
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.
* <p>
* Populated by the system.
* Read-only.
* Null for lists.
* More info:
* <a href="https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata">metadata</a>
* +optional
*/
LocalDateTime creationTimestamp;
}

View File

@ -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:
* <a href="https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds>types-kinds</a>
* +optional
*/
String kind;
/**
* APIVersion defines the versioned schema of this representation of an object.
* Servers should convert recognized schemas to the latest internal value, and
* may reject unrecognized values.
* More info:
* <a href="https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources">resources</a>
* +optional
*/
String apiVersion;
}

View File

@ -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<String, NonApiCase> 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> 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<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);
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<PolicyRule> 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<RequestResolveCase> 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<SuccessCase> 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<String, String> getParameters(String requestUri) {
UriComponents uriComponents = UriComponentsBuilder.fromUriString(requestUri)
.build();
return uriComponents.getQueryParams();
}
}

View File

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

View File

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

View File

@ -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<String, Object> 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;
}
}

View File

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