使用Spring Security为菜单增加权限

上篇我们通过扫描注解自动生成了菜单,本篇我们通过Spring Security来加上权限验证。

接上篇 https://www.jianshu.com/p/ef5854cce4eb


Spring Security配置

Spring Security 是Spring提供的安全控制组件,它本身提供了很多功能,我们目前用不到,Spring Security支持通过配置来启用、禁用这些功能。本文打算实现基于Token的认证、授权模式,先对Spring Security进行配置。
Spring Security的详细使用可以在网上搜索相关资料。

@Configuration
@EnableWebSecurity
public class MVCWebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    public void configure(WebSecurity web) throws Exception {
        super.configure(web);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.headers().frameOptions().disable();

        http
            .exceptionHandling()
                //未登录时的handler
                .authenticationEntryPoint(new UnauthenticatedEntryPoint())
                //无权限时的handler
                .accessDeniedHandler(new UnauthorizedAccessDeniedHandler())
            .and()
                //如果是微服务 需要禁用session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            .and()
                //不关心跨域
                .csrf().disable()
                //我们自己实现匿名
                .anonymous().disable()
                //我们自己实现登录
                .formLogin().disable()
                //我们自己实现注销
                .logout().disable()
                //业务逻辑
                .addFilterAt(new TokenAuthenticationFilter(), RememberMeAuthenticationFilter.class)
        ;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        super.configure(auth);
    }
}

核心部分

登录和鉴权

我们先实现一个 Token Principal类,这个类最终也是 JSP的 userPrincipal

public class AuthorityAuthenticationToken extends AbstractAuthenticationToken {
    private String principal;

    public AuthorityAuthenticationToken(String principal, Collection authorities) {
        super(authorities);
        this.principal = principal;
        setAuthenticated(true);
    }

    public AuthorityAuthenticationToken(String principal) {
        super(null);
        this.principal = principal;
        setAuthenticated(true);
    }

    @Override
    public Object getCredentials() {
        return null; //不支持密码
    }

    @Override
    public Object getPrincipal() {
        return principal;
    }
}

然后声明两个接口,分别用于认证和鉴权。

public interface ITokenProvider extends Ordered {
    String getToken(HttpServletRequest request);
}
public interface ITokenConverter extends Ordered {
    AuthorityAuthenticationToken decodeToken(String token);
}

这两个接口继承了org.springframework.core.Ordered接口,然后我们实现一个过滤器来进行登录。

public class TokenAuthenticationFilter implements Filter {
    private ITokenProvider[] providers;
    private ITokenConverter[] converters;

    private AuthenticationDetailsSource authenticationDetailsSource = new WebAuthenticationDetailsSource();

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    private final AnonymousAuthenticationToken anonymousUser = new AnonymousAuthenticationToken(
            UUID.randomUUID().toString(), "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS")
    );

    private String getToken(HttpServletRequest request) {
        if (providers == null) {  //幂等的 所以没加锁
            providers = SpringApplicationContextHolder.getInstance()
                .getBeansOfType(ITokenProvider.class)
                .values().stream()
                .sorted(Comparator.comparingInt(ITokenProvider::getOrder)) //有序
                .toArray(ITokenProvider[]::new);
        }

        for (ITokenProvider provider : providers) {
            String token = provider.getToken(request);
            if (StringUtils.hasText(token)) //取第一个成功的
                return token;
        }

        return null;
    }

    private AuthorityAuthenticationToken decodeToken(String token) {
        if (StringUtils.isEmpty(token))
            return null;

        if (converters == null) {
            converters = SpringApplicationContextHolder.getInstance()
                    .getBeansOfType(ITokenConverter.class)
                    .values().stream()
                    .sorted(Comparator.comparingInt(ITokenConverter::getOrder)) //有序
                    .toArray(ITokenConverter[]::new);
        }

        for (ITokenConverter converter : converters) {
            AuthorityAuthenticationToken u = converter.decodeToken(token);
            if (u != null) //取第一个成功的
                return u;
        }

        return null;
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        try {
            HttpServletRequest request = (HttpServletRequest) req;
            String token = getToken(request);
            AuthorityAuthenticationToken authToken = null;

            if (StringUtils.hasText(token)) {
                authToken = decodeToken(token);
            }

            if (authToken != null) {
                authToken.setDetails(authenticationDetailsSource.buildDetails(request));
                SecurityContextHolder.getContext().setAuthentication(authToken);
            } else {
                SecurityContextHolder.getContext().setAuthentication(anonymousUser);
            }
        } catch (Exception e) {
            SecurityContextHolder.getContext().setAuthentication(anonymousUser);
        }

        chain.doFilter(req, res);
    }

    @Override
    public void destroy() {

    }
}

这里允许 TokenProviderTokenConverter同时存在多份,以便在同一个服务中支持多种授权方式。

权限校验

Spring Security的权限校验是投票机制的,默认实现了一票否决制(只要有一票不允许就不能访问)、一票允许值(与否决相对的,只要有一票允许就可以访问)、多票优胜。Spring Security默认采用的是一票允许值,我们这里采用一票否决制。
此外Spring Security的权限校验是基于注解的,我们希望能基于我们的菜单注解,所以需要在投票之前进行注解数据转换,在这里我们采用一种简单粗暴的办法,用一个Wrapper把投票器包起来,在Wrapper中进行数据转换。

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MVCMethodSecurityConfig extends GlobalMethodSecurityConfiguration {
    @Override
    protected AccessDecisionManager accessDecisionManager() {
        AbstractAccessDecisionManager decisionManager = (AbstractAccessDecisionManager)super.accessDecisionManager();
        //wrapper和一票否决  虽然其实只有一票
        return new AccessDecisionManagerWrapper(new UnanimousBased(decisionManager.getDecisionVoters()));
    }
}

Wrapper类

public class AccessDecisionManagerWrapper implements AccessDecisionManager {
    private AbstractAccessDecisionManager wrapped;
    private Logger logger = LoggerFactory.getLogger(AccessDecisionManagerWrapper.class);
    private AuthorizeAttributeLoader loader = new AuthorizeAttributeLoader();


    public AccessDecisionManagerWrapper(AbstractAccessDecisionManager wrapped) {
        this.wrapped = wrapped;
    }

    private boolean isAnonymous(Authentication authentication) {
        return (authentication instanceof AnonymousAuthenticationToken);
    }

    @Override
    public void decide(Authentication authentication, Object object, Collection configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
        if (authentication == null || !authentication.isAuthenticated()) {    //啥信息都没有
            wrapped.decide(authentication, object, configAttributes);
        } else {
            //注解数据转换
            configAttributes = loader.loadAuthorizeAttribute(object, configAttributes);

            wrapped.decide(authentication, object, configAttributes);
        }
    }

    @Override
    public boolean supports(ConfigAttribute attribute) {
        return wrapped.supports(attribute);
    }

    @Override
    public boolean supports(Class clazz) {
        return wrapped.supports(clazz);
    }
}

注解转换部分,我们先声明一个IAuthorizeAttributeProvider接口,以便扩展到其他功能。

public interface IAuthorizeAttributeProvider {
    void loadAttribute(Class aClass, Method method, AuthorizeAttributeContainer container);
}
public class AuthorizeAttributeLoader {
    //理论上运行过程中 这个是不会变化的 缓存起来
    private Map> attrCache = new ConcurrentHashMap<>();

    private Class getTargetClass(MethodInvocation mi) {
        Object target = mi.getThis();

        if (target != null) {
            return target instanceof Class ? (Class) target
                    : AopProxyUtils.ultimateTargetClass(target);
        }

        return null;
    }

    public Collection loadAuthorizeAttribute(Object object, Collection configAttributes) {
        if (object instanceof MethodInvocation) {

            MethodInvocation mi = (MethodInvocation) object;
            Collection result = attrCache.get(mi);
            if (result == null) {
                Method method = mi.getMethod();

                AuthorizeAttributeContainer container = new AuthorizeAttributeContainer(configAttributes);
                SpringApplicationContextHolder.getInstance()
                        .getBeansOfType(IAuthorizeAttributeProvider.class)
                        .forEach((k, v) -> v.loadAttribute(getTargetClass(mi), method, container));

                result = new ArrayList<>(container.getAttributes());
                attrCache.put(mi, result);
            }

            return result;
        }

        return configAttributes;
    }
}

核心功能部分的操作都是幂等的,所以没有加锁,最后我们再实现未登录和权限校验失败的监听。

//这个类在MVCWebSecurityConfig注册的
public class UnauthenticatedEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException, ServletException {
        response.setStatus(HttpStatus.OK.value());

        //未登录,跳转到/401
        RequestDispatcher dispatcher = request.getRequestDispatcher(
                "/401?redirect=" + response.encodeRedirectURL(request.getRequestURI())
        );

        dispatcher.forward(request, response);
    }
//这个类在MVCWebSecurityConfig注册的
public class UnauthorizedAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException e) throws IOException, ServletException {
        response.setStatus(HttpStatus.OK.value());
        //没权限,跳转到403
        RequestDispatcher dispatcher = request.getRequestDispatcher(
                "/403?redirect=" + response.encodeRedirectURL(request.getRequestURI())
        );

        dispatcher.forward(request, response);
    }
}

菜单部分

实现了核心部分后,我们基于核心部分扩展修改菜单部分。

注解

首先,在注解中增加权限码。

@Target({ ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@PreAuthorize("isAuthenticated()")  //这个注解必须有,启用Spring Security的投票机制
public @interface MenuItem {
    String label();
    String icon();
    String code();  //权限码
}

然后实现菜单注解到Spring Security的注解的转换。

@Component
public class MenuAuthorizeAttributeProvider implements IAuthorizeAttributeProvider {

    private ExpressionBasedAnnotationAttributeFactory attributeFactory = ExpressionAttrFactoryManager.getFactory();

    @Override
    public void loadAttribute(Class aClass, Method method, AuthorizeAttributeContainer container) {
        MenuItem menuItem = method.getDeclaredAnnotation(MenuItem.class);
        if (menuItem == null)
            return;

        String code = menuItem.code();
        if ("*".equals(code))
            return;

        //实现一个扩展,管理员有所有菜单的权限
        String exp;
        if ("admin".equals(code)) {
            exp = "hasAuthority('admin')";
        } else {
            //有菜单权限或是管理员
            exp = "hasAuthority('" + code + "') || hasAuthority('admin')";
        }

        container.addAttribute(
                attributeFactory.createPreInvocationAttribute(null, null, exp)
        );
    }
}

菜单服务

扩展菜单服务,能过滤当前用户能看到的菜单列表,由于菜单服务没有权限的信息,所以我们需要声明一个接口,由权限服务来实现。

public interface IUserAuthorityProvider {
    Set getCurrentUserAuthorities();
}
     //菜单服务的主要修改部分
    public List getUserMenu() {
        Set authorities = provider.getCurrentUserAuthorities();
        if (authorities.isEmpty())
            return Collections.emptyList();

        List menu = getAllMenu();
        if (authorities.contains("admin"))
            return menu;  //管理员能看到所有菜单

        return menu.stream()
                .filter(m -> authorities.contains(m.getCode()))
                .collect(Collectors.toList());
    }

测试

核心部分和菜单部分分别暴漏了3个接口,我们依次实现这3个接口。

登录验证

@Component
public class UrlParameterTokenProvider implements ITokenProvider {
    @Override
    public String getToken(HttpServletRequest request) {
        //这里应该是从cookie或http头获取用户是否登录,token是否有效
       //单纯测试,我们从URL获取
        return request.getParameter("token");
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

鉴权

@Component
public class TestTokenDecoder implements ITokenConverter {
    @Override
    public AuthorityAuthenticationToken decodeToken(String token) {
        if (StringUtils.isEmpty(token))
            return null;

        //这里是加载用户有哪些权限
        List authorities = new ArrayList<>(2);

        switch (token) {
            case "1":
                //菜单1的权限
                authorities.add(new SimpleGrantedAuthority("test1"));
                break;

            case "2":
                //菜单2的权限
                authorities.add(new SimpleGrantedAuthority("test2"));
                break;

            case "admin":
                authorities.add(new SimpleGrantedAuthority("admin"));
                break;
        }

        return new AuthorityAuthenticationToken(token, authorities);
    }

    @Override
    public int getOrder() {
        return 0;
    }
}

加载当前用户的权限

@Component
public class TestUserAuthoritiesProvider implements IUserAuthorityProvider {
    @Override
    public Set getCurrentUserAuthorities() {
        //正常应该有业务提供这个数据,暂时从Principal获取
        HttpServletRequest request = ServletHolder.getCurrentRequest();
        Principal principal = request.getUserPrincipal();

        if (principal == null)  //未登录
            return Collections.emptySet();

        if (!(principal instanceof AbstractAuthenticationToken)) {
            return Collections.emptySet();
        }

        Collection authorities = ((AbstractAuthenticationToken) principal).getAuthorities();
        return authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet());
    }
}

错误页面

public class ErrorController {
    @RequestMapping("/401")
    @ResponseBody
    public String on401(String redirect) {
        //应该显示登录界面
        return "请登录后在访问";
    }

    @RequestMapping("/403")
    @ResponseBody
    public String on403(String redirect) {
        return "您没有权限访问此功能";
    }
}

未登录

无权限

普通有权限用户

管理员

完整代码见 https://github.com/giafei/spring-security-token

你可能感兴趣的:(使用Spring Security为菜单增加权限)