03.动态处理角色和资源的关系

 

这是非常核心的一块内容。

3.1 创建 Hr 和 HrService

首先我们需要创建 Hr 类,即我们的用户类,该类实现了 UserDetails 接口,该类的属性如下:

public class Hr implements UserDetails {
    private Long id;
    private String name;
    private String phone;
    private String telephone;
    private String address;
    private boolean enabled;
    private String username;
    private String password;
    private String remark;
    private List<Role> roles;
    private String userface;
	//getter/setter省略
}

如果小伙伴对属性的含义有疑问,可以参考1.权限数据库设计.

UserDetails 接口默认有几个方法需要实现,这几个方法中,除了 isEnabled 返回了正常的 enabled 之外,其他的方法我都统一返回 true,因为我这里的业务逻辑并不涉及到账户的锁定、密码的过期等等,只有账户是否被禁用,因此只处理了 isEnabled 方法,这一块小伙伴可以根据自己的实际情况来调整。

另外,UserDetails 中还有一个方法叫做 getAuthorities,该方法用来获取当前用户所具有的角色,但是小伙伴也看到了,我的 Hr 中有一个 roles 属性用来描述当前用户的角色,因此我的 getAuthorities 方法的实现如下:

public Collection<? extends GrantedAuthority> getAuthorities() {
    List<GrantedAuthority> authorities = new ArrayList<>();
    for (Role role : roles) {
        authorities.add(new SimpleGrantedAuthority(role.getName()));
    }
    return authorities;
}

即直接从 roles 中获取当前用户所具有的角色,构造 SimpleGrantedAuthority 然后返回即可。

创建好 Hr 之后,接下来我们需要创建 HrService,用来执行登录等操作,HrService 需要实现 UserDetailsService 接口,如下:

@Service
@Transactional
public class HrService implements UserDetailsService {

    @Autowired
    HrMapper hrMapper;

    @Override
    public UserDetails loadUserByUsername(String s) throws UsernameNotFoundException {
        Hr hr = hrMapper.loadUserByUsername(s);
        if (hr == null) {
            throw new UsernameNotFoundException("用户名不对");
        }
        return hr;
    }
}

这里最主要是实现了 UserDetailsService 接口中的 loadUserByUsername 方法,在执行登录的过程中,这个方法将根据用户名去查找用户,如果用户不存在,则抛出 UsernameNotFoundException 异常,否则直接将查到的 Hr 返回。

HrMapper 用来执行数据库的查询操作,这个不在本系列的介绍范围内,所有涉及到数据库的操作都将只介绍方法的作用。

3.2 自定义FilterInvocationSecurityMetadataSource

FilterInvocationSecurityMetadataSource 有一个默认的实现类DefaultFilterInvocationSecurityMetadataSource,该类的主要功能就是通过当前的请求地址,获取该地址需要的用户角色,我们照猫画虎,自己也定义一个 FilterInvocationSecurityMetadataSource,如下:

@Component
public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
    @Autowired
    MenuService menuService;
    AntPathMatcher antPathMatcher = new AntPathMatcher();

    @Override
    public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
        //获取请求地址
        String requestUrl = ((FilterInvocation) o).getRequestUrl();
        if ("/login_p".equals(requestUrl)) {
            return null;
        }
        List<Menu> allMenu = menuService.getAllMenu();
        for (Menu menu : allMenu) {
            if (antPathMatcher.match(menu.getUrl(), requestUrl)&&menu.getRoles().size()>0) {
                List<Role> roles = menu.getRoles();
                int size = roles.size();
                String[] values = new String[size];
                for (int i = 0; i < size; i++) {
                    values[i] = roles.get(i).getName();
                }
                return SecurityConfig.createList(values);
            }
        }
        //没有匹配上的资源,都是登录访问
        return SecurityConfig.createList("ROLE_LOGIN");
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return FilterInvocation.class.isAssignableFrom(aClass);
    }
}

关于自定义这个类,我说如下几点:

  1. 一开始注入了 MenuService,MenuService 的作用是用来查询数据库中 url pattern 和 role 的对应关系,查询结果是一个 List 集合,集合中是 Menu 类,Menu 类有两个核心属性,一个是 url pattern,即匹配规则(比如 /admin/** ),还有一个是 List,即这种规则的路径需要哪些角色才能访问。
  2. 我们可以从 getAttributes(Object o) 方法的参数 o 中提取出当前的请求 url,然后将这个请求 url 和数据库中查询出来的所有 url pattern一一对照,看符合哪一个 url pattern,然后就获取到该 url pattern 所对应的角色,当然这个角色可能有多个,所以遍历角色,最后利用 SecurityConfig.createList 方法来创建一个角色集合。
  3. 第二步的操作中,涉及到一个优先级问题,比如我的地址是 /employee/basic/hello ,这个地址既能被 /employee/** 匹配,也能被 /employee/basic/** 匹配,这就要求我们从数据库查询的时候对数据进行排序,将 /employee/basic/** 类型的 url pattern 放在集合的前面去比较。
  4. 如果 getAttributes(Object o) 方法返回 null 的话,意味着当前这个请求不需要任何角色就能访问,甚至不需要登录。但是在我的整个业务中,并不存在这样的请求,我这里的要求是,所有未匹配到的路径,都是认证(登录)后可访问,因此我在这里返回一个 ROLE_LOGIN 的角色,这种角色在我的角色数据库中并不存在,因此我将在下一步的角色比对过程中特殊处理这种角色。
  5. 如果地址是 /login_p,这个是登录页,不需要任何角色即可访问,直接返回 null。
  6. getAttributes(Object o) 方法返回的集合最终会来到 AccessDecisionManager 类中,接下来我们再来看 AccessDecisionManager 类。

3.3 自定义 AccessDecisionManager

自定义 UrlAccessDecisionManager 类实现 AccessDecisionManager 接口,如下:

@Component
public class UrlAccessDecisionManager implements AccessDecisionManager {
    @Override
    public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, AuthenticationException {
        Iterator<ConfigAttribute> iterator = collection.iterator();
        while (iterator.hasNext()) {
            ConfigAttribute ca = iterator.next();
            //当前请求需要的权限
            String needRole = ca.getAttribute();
            if ("ROLE_LOGIN".equals(needRole)) {
                if (authentication instanceof AnonymousAuthenticationToken) {
                    throw new BadCredentialsException("未登录");
                } else
                    return;
            }
            //当前用户所具有的权限
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            for (GrantedAuthority authority : authorities) {
                if (authority.getAuthority().equals(needRole)) {
                    return;
                }
            }
        }
        throw new AccessDeniedException("权限不足!");
    }

    @Override
    public boolean supports(ConfigAttribute configAttribute) {
        return true;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}

关于这个类,我说如下几点:

  1. decide 方法接收三个参数,其中第一个参数中保存了当前登录用户的角色信息,第三个参数则是 UrlFilterInvocationSecurityMetadataSource 中的 getAttributes 方法传来的,表示当前请求需要的角色(可能有多个)。
  2. 如果当前请求需要的权限为 ROLE_LOGIN 则表示登录即可访问,和角色没有关系,此时我需要判断 authentication 是不是 AnonymousAuthenticationToken 的一个实例,如果是,则表示当前用户没有登录,没有登录就抛一个 BadCredentialsException 异常,登录了就直接返回,则这个请求将被成功执行。
  3. 遍历 collection,同时查看当前用户的角色列表中是否具备需要的权限,如果具备就直接返回,否则就抛异常。
  4. 这里涉及到一个 all 和 any 的问题:假设当前用户具备角色 A、角色 B,当前请求需要角色 B、角色 C,那么是要当前用户要包含所有请求角色才算授权成功还是只要包含一个就算授权成功?我这里采用了第二种方案,即只要包含一个即可。小伙伴可根据自己的实际情况调整 decide 方法中的逻辑。

3.4 自定义AccessDeniedHandler

通过自定义 AccessDeniedHandler 我们可以自定义 403 响应的内容,如下:

@Component
public class AuthenticationAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest httpServletRequest, HttpServletResponse resp, AccessDeniedException e) throws IOException, ServletException {
        resp.setStatus(HttpServletResponse.SC_FORBIDDEN);
        resp.setCharacterEncoding("UTF-8");
        PrintWriter out = resp.getWriter();
        out.write("{\"status\":\"error\",\"msg\":\"权限不足,请联系管理员!\"}");
        out.flush();
        out.close();
    }
}

3.5 配置 WebSecurityConfig

最后在 webSecurityConfig 中完成简单的配置即可,如下:

@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    HrService hrService;
    @Autowired
    UrlFilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource;
    @Autowired
    UrlAccessDecisionManager urlAccessDecisionManager;
    @Autowired
    AuthenticationAccessDeniedHandler authenticationAccessDeniedHandler;

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

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/index.html", "/static/**");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                        o.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource);
                        o.setAccessDecisionManager(urlAccessDecisionManager);
                        return o;
                    }
                }).and().formLogin().loginPage("/login_p").loginProcessingUrl("/login").usernameParameter("username").passwordParameter("password").permitAll().failureHandler(new AuthenticationFailureHandler() {
            @Override
            public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
                httpServletResponse.setContentType("application/json;charset=utf-8");
                PrintWriter out = httpServletResponse.getWriter();
                StringBuffer sb = new StringBuffer();
                sb.append("{\"status\":\"error\",\"msg\":\"");
                if (e instanceof UsernameNotFoundException || e instanceof BadCredentialsException) {
                    sb.append("用户名或密码输入错误,登录失败!");
                } else if (e instanceof DisabledException) {
                    sb.append("账户被禁用,登录失败,请联系管理员!");
                } else {
                    sb.append("登录失败!");
                }
                sb.append("\"}");
                out.write(sb.toString());
                out.flush();
                out.close();
            }
        }).successHandler(new AuthenticationSuccessHandler() {
            @Override
            public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Authentication authentication) throws IOException, ServletException {
                httpServletResponse.setContentType("application/json;charset=utf-8");
                PrintWriter out = httpServletResponse.getWriter();
                ObjectMapper objectMapper = new ObjectMapper();
                String s = "{\"status\":\"success\",\"msg\":" + objectMapper.writeValueAsString(HrUtils.getCurrentHr()) + "}";
                out.write(s);
                out.flush();
                out.close();
            }
        }).and().logout().permitAll().and().csrf().disable().exceptionHandling().accessDeniedHandler(authenticationAccessDeniedHandler);
    }
}

关于这个配置,我说如下几点:

  1. 在 configure(HttpSecurity http) 方法中,通过 withObjectPostProcessor 将刚刚创建的 UrlFilterInvocationSecurityMetadataSource 和 UrlAccessDecisionManager 注入进来。到时候,请求都会经过刚才的过滤器(除了 configure(WebSecurity web)方法忽略的请求)。
  2. successHandler 中配置登录成功时返回的 JSON,登录成功时返回当前用户的信息。
  3. failureHandler 表示登录失败,登录失败的原因可能有多种,我们根据不同的异常输出不同的错误提示即可。

OK,这些操作都完成之后,我们可以通过 POSTMAN 或者 RESTClient 来发起一个登录请求,看到如下结果则表示登录成功:

p282

扫码关注微信公众号 江南一点雨,回复 2TB,获取超 2TB Java 学习教程~

喜欢这篇文章吗?扫码关注公众号【江南一点雨】【江南一点雨】专注于 SPRING BOOT+微服务以及前后端分离技术,每天推送原创技术干货,关注后回复 JAVA,领取松哥为你精心准备的 JAVA 干货!

本文遵守 Attribution-NonCommercial 4.0 International 许可协议。 Attribution-NonCommercial 4.0 International