SpringBoot+Vue先后端分离,使用SpringSecurity完美处理权限问题(二)

当先后端分离时,权限问题的处理也和咱们传统的处理方式有一点差别。笔者前几天恰好在负责一个项目的权限管理模块,如今权限管理模块已经作完了,我想经过5-6篇文章,来介绍一下项目中遇到的问题以及个人解决方案,但愿这个系列可以给小伙伴一些帮助。本系列文章并非手把手的教程,主要介绍了核心思路并讲解了核心代码,完整的代码小伙伴们能够在GitHub上star并clone下来研究。另外,本来计划把项目跑起来放到网上供小伙伴们查看,可是以前买服务器为了省钱,内存只有512M,两个应用跑不起来(已经有一个V部落开源项目在运行),所以小伙伴们只能将就看一下下面的截图了,GitHub上有部署教程,部署到本地也能够查看完整效果。html


项目地址:https://github.com/lenve/vhr 前端

上篇文章咱们对项目作了一个总体的介绍,从本文开始,咱们就来实现咱们的权限管理模块。因为先后端分离,所以咱们先来完成后台接口,完成以后,能够先用POSTMAN或者RESTClient等工具进行测试,测试成功以后,咱们再来着手开发前端。 java

本文是本系列的第二篇,建议先阅读前面的文章有助于更好的理解本文: mysql

1.SpringBoot+Vue先后端分离,使用SpringSecurity完美处理权限问题(一)git

建立SpringBoot项目

在IDEA中建立SpringBoot项目,建立完成以后,添加以下依赖:github

<dependencies>
    <dependency>
        <groupId>org.mybatis.spring.boot</groupId>
        <artifactId>mybatis-spring-boot-starter</artifactId>
        <version>1.3.1</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>druid</artifactId>
        <version>1.0.29</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
</dependencies>

这些都是常规的依赖,有SpringBoot、SpringSecurity、Druid数据库链接池,还有数据库驱动。 web

而后在application.properties中配置数据库,以下:spring

spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/vhr?useUnicode=true&characterEncoding=UTF-8
spring.datasource.username=root
spring.datasource.password=123

server.port=8082

OK,至此,咱们的工程就建立好了。sql

建立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用来执行数据库的查询操做,这个不在本系列的介绍范围内,全部涉及到数据库的操做都将只介绍方法的做用。

自定义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<Role>,即这种规则的路径须要哪些角色才能访问。

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类。

自定义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方法中的逻辑。

自定义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();
    }
}

配置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来发起一个登陆请求,看到以下结果则表示登陆成功:

图片描述

关注公众号,能够及时接收到最新文章:
图片描述