Spring Security系列之五 前后端分离项目用户授权

2,158 阅读9分钟

Spring Security系列之五 前后端分离项目用户授权

章节

Spring Security系列之一 简单介绍和实战

Spring Security系列之二 认证流程分析

Spring Security系列之三 自定义短信登录认证

Spring Security系列之四 前后端分离项目用jwt做认证

Spring Security系列之五 前后端分离项目用户授权

Spring Security系列之六 授权流程分析

通过前面几篇文章相信大家对登录认证也比较熟悉了,所以从现在开始就进入Spring Security的另外一个核心功能点的实践:用户权限认证。在一个系统中,不同用户所具有的权限是不同的。比如对一个文件来说,有的用户只能进行读取,而有的用户可以进行修改。一般来说,系统会为不同的用户分配不同的角色,而每个角色则对应一系列的权限。

对于web系统来说,其实就是相当于指定拥有哪些角色的用户才能访问接口 ,现在就假定我们的系统有两个角色,一个为普通用户,一个为管理员。普通用户能做的事儿管理员都能做,管理员能做的事儿普通人不一定能做。当然真实的业务情况可能比这个复杂好几倍,我这里只是抛砖引玉而已,希望大家不要不知好歹,继续追问下去。

创建两个Controller接口,一个为普通用户和管理员都能访问的:

@RestController
@RequestMapping("normal")
public class NormalResourceController {
    @GetMapping("/resource")
    public ResponseEntity<String> getResource(){
        return ResponseEntity.ok("成功获取normal资源");
    }
}

再创建一个Controller接口,只有管理员才能访问:

@RestController
@RequestMapping("/admin")
public class AdminResourceController {
    @GetMapping("/resource")
    public ResponseEntity<String> getResource(){
        return ResponseEntity.ok("成功获取admin资源");
    }

}

在数据库role表中添加两个角色:

idrole_name
1admin
2normal_user

目前我们在数据库中有两个用户:

idusernamepassword
1test12a2a10$pjHyw9MSGC/i6k546Ii/0uLFgTK4WYB4.8bSRq7yB4dy.ZpBLxOha
2test22a2a10$pjHyw9MSGC/i6k546Ii/0uLFgTK4WYB4.8bSRq7yB4dy.ZpBLxOha

我们给test1用户添加一个admin角色和normal_user角色,给test2用户添加normal_user角色,在user_role表中添加数据:

iduser_idrole_id
111
212
322

我们系统的user对象是实现了UserDetails接口的,并且重写了getAuthorities方法,在这个方法里我们需要把用户的角色信息查出来,包装成一个GrantedAuthority对象:

public class User implements UserDetails {
    private static final long serialVersionUID = -16523804109585173L;
    private Integer id;
    private String username;
    private String password;
    private List<Role> roleList;

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return roleList.stream().map(role ->
                new SimpleGrantedAuthority(role.getRoleName())).collect(Collectors.toList());
    }
}

简单实现

上面的准备工作做好了,接下来我们在security中添加下面的配置:

@Override
protected void configure(HttpSecurity http) throws Exception {
    //super.configure(http);
    http.formLogin()
        .disable()
        //添加header设置,支持跨域和ajax请求  本地测试先注释了
        //.cors().and()
        //.addFilterAfter(corsFilter(), CorsFilter.class)
        .apply(smsAuthenticationSecurityConfig).and()
        .apply(jwtAuthenticationSecurityConfig).and()
        .apply(jwtRequestSecurityConfig).and()
        // 设置URL的授权
        .authorizeRequests()
        // 这里需要将登录页面放行
        .antMatchers("/login","/verifyCode","/smsLogin","/failure","/jwtLogin").permitAll()
        .antMatchers("/admin/**").hasAuthority("admin")
        .antMatchers("/normal/**").hasAnyAuthority("admin","normal_user")
        // anyRequest() 所有请求   authenticated() 必须被认证
        .anyRequest()
        .authenticated().and()
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
        // 关闭csrf
        .csrf().disable();
}

调用antMatchers().hasAuthority()表明用户必须拥有某个角色才能访问这些路径。

测试一下,使用test1用户登录生成的token,访问上面两个controller都没有问题:

使用test2用户登录生成的token访问admin会提示403错误:

这样一个简单的用户权限认证就实现了。

自定义权限认证

这样做显然不够灵活,我们如果增加了其他接口还需要在这里配置,这种硬编码的方式不是很好,而且前后端分离项目,前端路由也需要做验证,我们需要动态的创建资源和角色对应的关系。

实现权限认证接口

要实现动态的权限验证,当然要先要获取对应的资源,然后再将他们对应哪些角色可以访问的关系表示出来。

Spring Security是通过SecurityMetadataSource来加载访问资源时所需要的具体权限,所以第一步需要实现SecurityMetadataSourceSecurityMetadataSource是一个接口:

public interface SecurityMetadataSource extends AopInfrastructureBean {
    Collection<ConfigAttribute> getAttributes(Object var1) throws IllegalArgumentException;

    Collection<ConfigAttribute> getAllConfigAttributes();

    boolean supports(Class<?> var1);
}

它继承了AopInfrastructureBean接口,这个接口并没有任何方法,它只是作为一个标记,标记为实现AOP的基类,如果任何类实现了这个接口,那么这个类是不会被AOP给代理的,即使它能被切面切进去。

然后就是SecurityMetadataSource的几个方法:

Collection<ConfigAttribute> getAttributes(Object var1) throws IllegalArgumentException;

获取某个受保护的安全对象object的所需要的权限信息,返回一组ConfigAttribute对象的集合,如果该安全对象object不被当前SecurityMetadataSource对象支持,则抛出异常IllegalArgumentException

上面是机翻的,可能看得有点懵,通俗的来讲就是,传入了一个object,然后返回了需要访问这个object对象所需要的权限。如果当前SecurityMetadataSource这个对象不支持当前的object,就会报错,支不支持就看第三个方法。

Collection<ConfigAttribute> getAllConfigAttributes();

获取该SecurityMetadataSource对象中保存的针对所有安全对象的权限信息的集合。该方法的主要目的是被AbstractSecurityInterceptor用于启动时校验每个ConfigAttribute对象。

这个方法就没什么好说的了,只是返回所有权限信息列表而已。

可以查看一下SecurityMetadataSource接口的关系继承图:

可以看到SecurityMetadataSource有两个子接口,

  • FilterInvocationSecurityMetadataSource只是一个标识接口,表示安全对象是web请求FilterInvocation的安全元数据源,本身并无任何内容。

  • MethodSecurityMetadataSource表示安全对象是方法调用MethodInvocation的安全元数据源,接口如下:

    public interface MethodSecurityMetadataSource extends SecurityMetadataSource {
    
    	Collection<ConfigAttribute> getAttributes(Method method, Class<?> targetClass);
    
    }
    

    一般用于方法间调用时的权限验证。

我们这里是web项目,实现FilterInvocationSecurityMetadataSource就行了:

@Component
public class UrlMetadataSource implements FilterInvocationSecurityMetadataSource {
    @Autowired
    private PowerService powerService;
    private final AntPathMatcher matcher = new AntPathMatcher();
    public static final String NEED_LOGIN = "NEED_LOGIN";

    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        String requestUrl = ((FilterInvocation) object).getRequestUrl();
        List<Power> powers = powerService.queryAll();
        for (Power power : powers) {
            if (matcher.match(power.getUrl(),requestUrl)){
                //数据库中存了这个路由,说明是需要角色才能访问的
                List<Role> roleList = power.getRoleList();
                if (CollectionUtils.isEmpty(roleList)){
                    break;
                }
                return roleList.stream().map(item -> new SecurityConfig(item.getRoleName().trim())).collect(Collectors.toList());
            }
        }
        //该路径没有对应哪一个角色才能访问
        return SecurityConfig.createList(NEED_LOGIN);
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }
    /**
     * 告知调用者当前SecurityMetadataSource是否支持此类安全对象,只有支持的时候,才能对这类安全对象调用getAttributes方法
     */
    @Override
    public boolean supports(Class<?> clazz) {
        return clazz.isAssignableFrom(FilterInvocation.class);
    }
}

首先我们从request中获取了当前访问的资源,然后使用PowerService查询出数据库中所有资源,资源类如下:

@Data
public class Power implements Serializable {
    private static final long serialVersionUID = -25876673587503659L;
    private Integer id;
    private String title;
    private String url;
    private List<Role> roleList;
}

然后将这个请求url和数据库中查询出来的所有url pattern一一对照,看符合哪一个url pattern,然后就获取到该url pattern所对应的角色。

如果getAttributes(Object o)方法返回null的话,意味着当前这个请求不需要任何角色就能访问,甚至不需要登录。但是在我的整个业务中,并不存在这样的请求,对于所有未匹配到的路径,都需要认证后才可以访问,所以我在这里返回一个NEED_LOGIN的角色,这种角色在数据库中并不存在,因此我将在下一步的角色比对过程中特殊处理这种角色。

getAttributes(Object o)方法返回的角色列表最终传给AccessDecisionManager,所以我们接下来看AccessDecisionManager的实现。

实现权限决策器

知道了当前访问的url需要的具体权限,接下来就是决策当前的访问是否能通过权限验证了。实现如下:

@Component
public class UrlAccessDecisionManager implements AccessDecisionManager {
    @Override
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) {
        for (ConfigAttribute attribute : configAttributes) {
            String needRole = attribute.getAttribute();
            if (UrlMetadataSource.NEED_LOGIN.equals(needRole) && authentication instanceof AnonymousAuthenticationToken) {
                throw new InsufficientAuthenticationException("用户需要登录");
            }
            //当前用户的角色
            Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
            //对比访问需要的角色,只要有一个满足就行
            for (GrantedAuthority userRole : authorities) {
                if (userRole.getAuthority().equals(needRole)){
                    return;
                }
            }
        }
        throw new AccessDeniedException("权限不足");
    }

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

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

我们主要讲decide方法,这个方法有三个参数

  • authentication:包含了当前的用户信息,包括拥有的权限。这里的权限来源就是前面我们系统user类的getAuthorities返回的角色权限列表。
  • object:FilterInvocation对象,可以得到request等web资源
  • configAttributes:就是上面的getAttributes方法返回的角色列表

因为我们系统中对于所有的资源,都需要登录才能访问,通过authentication instanceof AnonymousAuthenticationToken判断用户有没有登录,没有登录就抛出异常。

然后就是用户权限的判断,我们这里判断条件是只要当前用户具有一种角色,就可以访问这个资源。兄弟们也可以根据自己的业务来实现。

配置实现类

上面权限的资源和验证我们已经都实现了,接下来就是指定让Spring Security使用我们自定义的实现类了:

@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
    private SmsAuthenticationSecurityConfig smsAuthenticationSecurityConfig;
    @Autowired
    private JwtAuthenticationSecurityConfig jwtAuthenticationSecurityConfig;
    @Autowired
    private JwtRequestSecurityConfig jwtRequestSecurityConfig;

    @Autowired
    private UrlMetadataSource urlMetadataSource;
    @Autowired
    private UrlAccessDecisionManager urlAccessDecisionManager;

    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers("/login","/verifyCode","/smsLogin","/failure","/jwtLogin");
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //super.configure(http);
        http.formLogin()
                .disable()
                //添加header设置,支持跨域和ajax请求
                //.cors().and()
                //.addFilterAfter(corsFilter(), CorsFilter.class)
                .apply(smsAuthenticationSecurityConfig).and()
                .apply(jwtAuthenticationSecurityConfig).and()
                .apply(jwtRequestSecurityConfig).and()
                // 设置URL的授权
                .authorizeRequests()
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <O extends FilterSecurityInterceptor> O postProcess(O object) {
                        object.setAccessDecisionManager(urlAccessDecisionManager);
                        object.setSecurityMetadataSource(urlMetadataSource);
                        return object;
                    }
                })
                // 这里需要将登录页面放行
                //.antMatchers("/login","/verifyCode","/smsLogin","/failure","/jwtLogin").permitAll()
                //.antMatchers("/admin/**").hasAuthority("admin")
                //.antMatchers("/normal/**").hasAnyAuthority("admin","normal_user")
                // anyRequest() 所有请求   authenticated() 必须被认证
                .anyRequest()
                .authenticated().and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                // 关闭csrf
                .csrf().disable();
    }
}

我们这里使用withObjectPostProcessor方法,在创建默认的FilterSecurityInterceptor的时候把我们的accessDecisionManagersecurityMetadataSource设置进去。

这里需要注意的是,我们用原来.antMatchers().permitAll()做的拦截白名单,实际上security会通过设置一个匿名用户来访问资源,这样就会被我们自定义的UrlMetadataSource给拦截掉,所以说这个地方的白名单需要提到最外面配置。