mirror of https://github.com/halo-dev/halo
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 classpull/2071/head
parent
9a0dc50653
commit
d9bccdb5e1
|
@ -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")
|
||||
|
|
|
@ -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.";
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.";
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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) {
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package run.halo.app.identity.authorization;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class NonResourceRuleInfo {
|
||||
}
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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[] {});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package run.halo.app.identity.authorization;
|
||||
|
||||
/**
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class ResourceRuleInfo {
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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.";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue