SpringSecurity (5) SpringBoot + JWT + CSRF 动态权限的实现

文章目录

  • 分析
    • 如何实现
    • 整体时序
    • 代码实现
      • 用户 - 角色 - 权限的数据结构
      • SecurityConfiguration
        • 动态权限配置
          • DynamicFilterInvocationSecurityMetadataSource
          • DynamicAccessDecisionManager
          • FilterSecurityInterceptorPostProcessor
        • CsrfFilter
        • JWTAuthenticationFilter
        • JWTAuthorizationFilter
      • 约定统一返回格式
  • 测试
  • 总结
  • Reference

本文介绍在 SpringSecurity + SpringBoot Web 架构下, 如何实现动态权限. 并将使用 UserDetailsService ( SpringSecurity (2) UserDetailsService) 从数据库中获取用户信息, 以 Json Web Token 作为 access-token ( SpringSecurity (3) SpringBoot + JWT 实现身份认证和权限验证), 并相应 SpringSecurity 的 CSRF 策略 ( SpringSecurity (4) CSRF 与 CSRF-TOKEN 的处理)
实现一个较为完整的身份验证和权限认证模块.


实际生产环境中, 一个用户往往存在多种角色, 而一个角色对应着一个权限集. 之前的做法是把用户角色写死在 SecurityConfiguration 中, 这样每次权限变化都需要重启服务, 我们肯定期望, SpringSecurity 权限控制策略能够响应权限的变化.


完整代码


项目结构

│  demo-spring-security-dynamic-authorization.iml
│  pom.xml
│  README.md
│  
├─doc
│  └─diagram
│          demo-spring-security-dynamic-authorization-sequence.png
│          demo-spring-security-dynamic-authorization-sequence.vsdx
│          
└─src
   ├─main
   │  ├─java
   │  │  └─cn
   │  │      └─caplike
   │  │          └─demo
   │  │              └─spring
   │  │                  └─security
   │  │                      └─dynamic
   │  │                          └─authorization
   │  │                              │  DynamicAuthorizationApplication.java
   │  │                              │  
   │  │                              ├─configuration
   │  │                              │  └─security
   │  │                              │      │  ApplicationConfiguration.java
   │  │                              │      │  CsrfTokenRedisRepository.java
   │  │                              │      │  CustomAccessDeniedHandler.java
   │  │                              │      │  CustomAuthenticationEntryPoint.java
   │  │                              │      │  CustomAuthenticationProvider.java
   │  │                              │      │  SecurityConfiguration.java
   │  │                              │      │  
   │  │                              │      ├─exception
   │  │                              │      │      UserInfoIncompleteException.java
   │  │                              │      │      
   │  │                              │      ├─filter
   │  │                              │      │      HttpServletRequestWrapFilter.java
   │  │                              │      │      JWTAuthenticationFilter.java
   │  │                              │      │      JWTAuthorizationFilter.java
   │  │                              │      │      
   │  │                              │      └─popedom
   │  │                              │              DynamicAccessDecisionManager.java
   │  │                              │              DynamicFilterInvocationSecurityMetadataSource.java
   │  │                              │              FilterSecurityInterceptorPostProcessor.java
   │  │                              │              
   │  │                              ├─controller
   │  │                              │      AdminController.java
   │  │                              │      AuthController.java
   │  │                              │      CustomErrorController.java
   │  │                              │      UserController.java
   │  │                              │      
   │  │                              ├─domain
   │  │                              │  ├─builder
   │  │                              │  │      SecurityResponseDtoBuilder.java
   │  │                              │  │      
   │  │                              │  ├─dto
   │  │                              │  │      CustomUserDetailsDto.java
   │  │                              │  │      RolePopedomDto.java
   │  │                              │  │      SecurityResponseDto.java
   │  │                              │  │      UserRoleDto.java
   │  │                              │  │      
   │  │                              │  └─entity
   │  │                              │          Popedom.java
   │  │                              │          Role.java
   │  │                              │          User.java
   │  │                              │          
   │  │                              ├─mapper
   │  │                              │      RoleMapper.java
   │  │                              │      UserMapper.java
   │  │                              │      
   │  │                              ├─service
   │  │                              │      CustomUserDetailsService.java
   │  │                              │      
   │  │                              └─util
   │  │                                      AccessTokenUtils.java
   │  │                                      BeanUtils.java
   │  │                                      CsrfTokenUtils.java
   │  │                                      RequestUtils.java
   │  │                                      ResponseUtils.java
   │  │                                      TimeUtils.java
   │  │                                      
   │  └─resources
   │          application.yml
   │          
   └─test
       └─java
           ├─cn
           │  └─caplike
           │      └─demo
           │          └─spring
           │              └─security
           │                  └─dynamic
           │                      └─authorization
           │                          ├─domain
           │                          │  └─builder
           │                          │          SecurityResponseDtoBuilderTest.java
           │                          │          
           │                          └─mapper
           │                                  RoleMapperTest.java
           │                                  UserMapperTest.java
           │                                  
           ├─com
           │  └─alibaba
           │      └─fastjson
           │              JSONTest.java
           │              
           └─org
               └─springframework
                   └─util
                           AntPathMatcherTest.java

分析

如何实现

在官方文档"访问控制 (Access-Control (Authorization) in Spring Security)"一章中看到一个名为 AbstractSecurityInterceptor 的抽象类, 这个类提供了用于处理 secure object (如 JoinPoint, FilterInvocationMethodInvocation ) 请求的工作流程: AbstractSecurityInterceptor 做的核心工作就是从当前请求中获取 ConfigAttribute (由 SecurityMetadataSource 提供, 被用于保存相关的配置属性, 譬如我们的权限信息), 并将其随着 Authentication, secure object 提交给 AccessDecisionManager 执行权限判断.
(每个 secure object 类型都有"专属"的继承于 AbstractSecurityInterceptor 的拦截器 (如 FilterInvocationFilterSecurityInterceptor))
SpringSecurity (5) SpringBoot + JWT + CSRF 动态权限的实现_第1张图片
所以要做的就是确定 security object, 写一个用于提供 ConfigAttributeSecurityMetadataSource 以及用于执行访问控制判断的 AccessDecisionManager.
并且可以确定, 我们要使用的 security object 就是 FilterInvocation, 因为它持有与 HTTP 过滤器相关的对象.
SpringSecurity (5) SpringBoot + JWT + CSRF 动态权限的实现_第2张图片
查看 SecurityMetadataSource 的层级结构发现, 它有一个 标记接口1: FilterInvocationSecurityMetadataSource, 所以自然地, 我们自己的 SecurityMetadataSource 应当实现于这个标记接口.

整体时序

分析了如何实现, 以及官方提供的相关类. 在代码实现之前, 先来看看整体时序图:
SpringSecurity (5) SpringBoot + JWT + CSRF 动态权限的实现_第3张图片

代码实现

有关 Csrf 部分和 JWT 部分请参看前面两篇文章 (SpringSecurity (4) CSRF 与 CSRF-TOKEN 的处理 & SpringSecurity (3) SpringBoot + JWT 实现身份认证和权限验证).
本文主要着重的是动态权限的部分.

用户 - 角色 - 权限的数据结构

设定一个用户可以拥有多个角色, 而没一个角色代持有多个资源的访问权限, 它们是一对多对多的关系. 所以, 我们至少需要 5 张表来支撑整个权限部分, 它们分别是: 用户表, 角色表以及用户角色中间表, 还有权限表以及角色权限中间表.

❉ 用户表
user
❉ 角色表
role
❉ 权限表
权限决定了一个角色对于特定 URI 是否有访问的权限, 支持通配符.
popedom
❉ 用户 - 角色中间表 & 角色 - 权限中间表
role-user role-popedom


代码层级, 提供用于查询"用户-角色对应关系"的方法以及"角色-权限对应关系"的两个方法:

public class UserRoleDto {

    // ~ USER Fields
    // -----------------------------------------------------------------------------------------------------------------
    private String name;
    private String password;

    // ~ ROLE_USER Fields
    private Set<String> roles;
}
@Mapper
@Repository
public interface UserMapper {

    /**
     * Description: 获取 用户-角色s 对象
     *
     * @param username 用户名
     * @return cn.caplike.demo.spring.security.dynamic.authorization.domain.dto.UserRoleDto
     * @author LiKe
     * @date 2020-05-12 13:51:58
     */
    @Select("SELECT * FROM USER where name = #{username}")
    @Results({
            @Result(property = "name", column = "name"),
            @Result(property = "password", column = "password"),
            @Result(property = "roles", column = "id", javaType = Set.class,
                    many = @Many(
                            select = "cn.caplike.demo.spring.security.dynamic.authorization.mapper.RoleMapper.queryRoleName",
                            fetchType = FetchType.EAGER
                    )
            )
    })
    UserRoleDto getUserRoleDto(String username);
}

@Data
@NoArgsConstructor
public class RolePopedomDto {

    private String roleName;

    /**
     * POPEDOM request_url
     */
    private String requestUrl;
}
@Mapper
@Repository
public interface RoleMapper {

    @Select("SELECT " +
            "   r.name " +
            "FROM " +
            "   ROLE r " +
            "WHERE " +
            "   r.id IN ( SELECT ru.role_id FROM ROLE_USER ru WHERE ru.user_id = #{userId} )")
    Set<String> queryRoleName(int userId);

    /**
     * Description: 获取角色名称和权限 URL 的对应关系
     *
     * @return java.util.List
     * @author LiKe
     * @date 2020-05-07 14:13:36
     */
    @Select("SELECT " +
            "   r.name as role_name, " +
            "   rpp.request_url " +
            "FROM " +
            "   ROLE r" +
            "   LEFT JOIN ( SELECT rp.role_id, p.request_url FROM ROLE_POPEDOM rp LEFT JOIN POPEDOM p ON rp.popedom_id = p.id ) rpp ON r.id = rpp.role_id")
    List<RolePopedomDto> queryRolePopedomDto();
}

SecurityConfiguration

@Configuration
public class SecurityConfiguration extends WebSecurityConfigurerAdapter {

    // ~ Constant
    // -----------------------------------------------------------------------------------------------------------------

    public static final String ACCESS_TOKEN = "access-token";

    public static final String CSRF_TOKEN = "csrf-token";

    public static final String LOGIN_URI = "/auth/login";

    public static final String REGISTER_URI = "/auth/register";

    // ~ Authentication configure
    // -----------------------------------------------------------------------------------------------------------------

    /**
     * AuthenticationProvider
     */
    private AuthenticationProvider authenticationProvider;
    /**
     * AccessDecisionManager
     */
    private AccessDecisionManager accessDecisionManager;

    // ~ HttpSecurity configure
    // -----------------------------------------------------------------------------------------------------------------

    /**
     * SecurityMetadataSource
     */
    private FilterInvocationSecurityMetadataSource securityMetadataSource;
    /**
     * {@link CsrfTokenRedisRepository}
     */
    private CsrfTokenRepository csrfTokenRepository;

    /**
     * {@link RedisService}
     */
    private RedisService redisService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) {
        auth.authenticationProvider(authenticationProvider);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                // ~ CSRF
                // -----------------------------------------------------------------------------------------------------
                .csrf().ignoringAntMatchers(LOGIN_URI, REGISTER_URI).csrfTokenRepository(csrfTokenRepository)
                .and()
                .addFilterBefore(new HttpServletRequestWrapFilter(), CsrfFilter.class)

                .authorizeRequests()

                // ~ 基础权限设定
                // -----------------------------------------------------------------------------------------------------
                .antMatchers("/auth/**").permitAll()

                // ~ 动态权限设定
                // -----------------------------------------------------------------------------------------------------
                .anyRequest().authenticated()
                .withObjectPostProcessor(new FilterSecurityInterceptorPostProcessor(accessDecisionManager, securityMetadataSource))

                // ~ 禁用 Session: Spring Security will never create an HttpSession and it will never use it to obtain the SecurityContext
                // -----------------------------------------------------------------------------------------------------
                .and()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                // ~ 添加 JWTAuthenticationFilter 和 JWTAuthorizationFilter
                // -----------------------------------------------------------------------------------------------------
                .and()
                .addFilterAt(new JWTAuthenticationFilter(authenticationManager(), redisService), UsernamePasswordAuthenticationFilter.class)
                .addFilterAfter(new JWTAuthorizationFilter(redisService), JWTAuthenticationFilter.class)

                // ~ 异常处理: 处理 AccessDeniedException 和 AuthenticationException
                // -----------------------------------------------------------------------------------------------------
                .exceptionHandling()
                .accessDeniedHandler(new CustomAccessDeniedHandler())
                .authenticationEntryPoint(new CustomAuthenticationEntryPoint());
    }

    // ~ Autowired
    // -----------------------------------------------------------------------------------------------------------------

    @Autowired
    public void setAccessDecisionManager(@Qualifier("dynamicAccessDecisionManager") AccessDecisionManager accessDecisionManager) {
        this.accessDecisionManager = accessDecisionManager;
    }

    @Autowired
    public void setSecurityMetadataSource(@Qualifier("dynamicFilterInvocationSecurityMetadataSource") FilterInvocationSecurityMetadataSource securityMetadataSource) {
        this.securityMetadataSource = securityMetadataSource;
    }

    @Autowired
    public void setAuthenticationProvider(@Qualifier("customAuthenticationProvider") AuthenticationProvider authenticationProvider) {
        this.authenticationProvider = authenticationProvider;
    }

    @Autowired
    public void setRedisService(RedisService redisService) {
        this.redisService = redisService;
    }

    @Autowired
    public void setCsrfTokenRepository(@Qualifier("csrfTokenRedisRepository") CsrfTokenRepository csrfTokenRepository) {
        this.csrfTokenRepository = csrfTokenRepository;
    }
}

(依然从配置类切入, 包含 csrf 相关配置, 身份认证过滤器以及权限验证过滤器, 请参看之前文章获取相关支持)

动态权限配置

主要关注 .withObjectPostProcessor(new FilterSecurityInterceptorPostProcessor(accessDecisionManager, securityMetadataSource)) 这行代码. 我们指定了一个 ObjectPostProcessor, 就是 SpringSecurity 的 Java 配置方式不会暴露出它配置的对象的全部属性, 如果需要配置额外的属性, 就当通过这个 ObjectPostProcessor 来进行. 它能修改通过 Java 配置方式配置的对象实例. 比如, 如果你想为 FilterSecurityInterceptor 配置 filterSecurityPublishAuthorizationSuccess 属性, 你可以这么做:
SpringSecurity (5) SpringBoot + JWT + CSRF 动态权限的实现_第4张图片


依据在文章一开始的分析, 我们知道 AbstractSecurityInterceptor 控制了整个权限检验的工作流程, 从核心源码中也可以看到, 它首先会从 SecurityMetadataSource 中获取 ConfigAttribute 的集合, 然后交给 AccessDecisionManager 判断是否放行请求. 简而言之, 我们需要一个 SecurityInterceptor 实现类, 一个 ConfigAttribute 提供源: SecurityMetadataSource 以及一个访问决策管理器: AccessDecisionManager.
SpringSecurity (5) SpringBoot + JWT + CSRF 动态权限的实现_第5张图片
经过之前的分析我们也知道, 对于 WEB 请求, 可以确定, security object 类型为 FilterInvocation, 拦截器为用于处理 FilterInvocationFilterSecurityInterceptor.
从官方说明中可以看到它是用于执行 HTTP 资源安全策略的过滤器的实现. 与这个拦截器配对的 SecurityMetadataSource 就是 FilterInvocationSecurityMetadataSource
SpringSecurity (5) SpringBoot + JWT + CSRF 动态权限的实现_第6张图片
总结一下, 方式就是用 ObjectPostProcessor 来配置 FilterSecurityInterceptor (setAccessDecisionManager & setSecurityMetadataSource), FilterSecurityInterceptor 决定了 security objectFilterInvocation, FilterSecurityInterceptor 的超类 AbstractSecurityInterceptor 调用 (匹配) 的 SecurityMetadataSource (也就是 FilterInvocationSecurityMetadataSource 的实现类) 的 getAttributes 封装 ConfigAttribute, 传给 AccessDecisionManager 进行权限验证.


下面上具体代码:

DynamicFilterInvocationSecurityMetadataSource

implements FilterInvocationSecurityMetadataSource, 用于提供配置属性的类.
如之前所述, 这个类的主要职责就是将权限数据封装成 ConfigAttribute, 供 AccessDecisionManager 使用.
(生产环境肯定应该将结果放到缓存中. 然后在前端做一个配置页面供高权限的用户更改权限后, 手动重新加载权限数据)
权限的数据结构为:

{
	"roleName": "角色名",
	"requestUrl": "资源路径"
}

本 Demo 暂时从数据库中获取权限数据.
public boolean supports(Class clazz) 保证了只有特定的 security object 才会被当前的 SecurityMetadataSource 处理.
正如前面说的, public Collection getAttributes(Object object) 会被 AbstractSecurityInterceptor 调用, ConfigAttribute 的结果集传递给 AccessDecisionManager, 接下来我们再来看看 AccessDecisionManager.
(程序中还做了一个额外处理: 过滤掉权限数据中不匹配当前 requestUrl 的记录)

/**
 * {@link FilterInvocationSecurityMetadataSource} 的实现.
* 为 {@link org.springframework.security.access.AccessDecisionManager} 提供 configAttributes * * @author LiKe * @version 1.0.0 * @date 2020-05-06 14:09 */
@Slf4j @Component public class DynamicFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource { private static final String CLASS_NAME = "Dynamic filter invocation security metadata source"; private RoleMapper roleMapper; private AntPathMatcher antPathMatcher; @Override public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException { // 因为 supports 方法只放行了 FilterInvocation 和其父类以及接口, 所以 // 总是 dynamic filter invocation security metadata source, getAttributes: org.springframework.security.web.FilterInvocation // log.debug("{}#getAttributes: {}", CLASS_NAME, object.getClass().getCanonicalName()); FilterInvocation filterInvocation = (FilterInvocation) object; // http://localhost:18903/dynamic-authorization/user/1 的 /user/1 final String requestUrl = filterInvocation.getRequestUrl(); log.debug("{}#getAttributes: {}", CLASS_NAME, requestUrl); // 查询数据库获取角色和权限的对应关系 // TODO 这个对应关系应该放到缓存中. 如果有更改的需要, 可以在前端提供一个操作手动重新加载权限对应关系 final List<RolePopedomDto> rolePopedomDtos = roleMapper.queryRolePopedomDto(); final String[] matchedRoles = rolePopedomDtos.stream() .filter(rolePopedomDto -> antPathMatcher.match(rolePopedomDto.getRequestUrl(), requestUrl)) .map(RolePopedomDto::getRoleName).collect(Collectors.toSet()).toArray(new String[]{}); // 如果返回 null 或者 空集合, 不会调用 AccessDecisionManager 的 decide 方法, // ref AbstractSecurityInterceptor 199 行 if (matchedRoles.length > 0) { return SecurityConfig.createList(matchedRoles); } // 如果当前请求的 URL 没有在 角色-权限对应关系中存在, 则表示匿名用户也可访问 log.debug("{}#getAttributes :: anonymous user.", CLASS_NAME); return SecurityConfig.createList("ROLE_ANONYMOUS"); } @Override public Collection<ConfigAttribute> getAllConfigAttributes() { log.debug("{}#getAllConfigAttributes", CLASS_NAME); // TODO 这个对应关系应该放到缓存中. 如果有更改的需要, 可以在前端提供一个操作手动重新加载权限对应关系 return SecurityConfig.createList( roleMapper.queryRolePopedomDto().stream() .map(RolePopedomDto::getRoleName).collect(Collectors.toSet()).toArray(new String[]{}) ); } @Override public boolean supports(Class<?> clazz) { log.debug("{}#supports: {}", CLASS_NAME, clazz.getCanonicalName()); return FilterInvocation.class.isAssignableFrom(clazz); } // ~ Autowired // ----------------------------------------------------------------------------------------------------------------- @Autowired public void setRoleMapper(RoleMapper roleMapper) { this.roleMapper = roleMapper; } @Autowired public void setAntPathMatcher(AntPathMatcher antPathMatcher) { this.antPathMatcher = antPathMatcher; } // ~ Bean // ----------------------------------------------------------------------------------------------------------------- @Bean public AntPathMatcher antPathMatcher() { return new AntPathMatcher(); } }
DynamicAccessDecisionManager

implements AccessDecideManager, 在 “整体时序” 中知道, AbstractSecurityInterceptor#beforeInvocation(Object) 的调用时机实在 JWTAuthorizationFilter 之后, 所以这时的 SecurityContextHolder 肯定持有完整的 Authentication 对象. 从而, 近乎理所应当的, 我们当然可以用 AccessDecisionManager#decide(Authentication, Object, Collection) 的参数 Authentication 中的权限数据和 SecurityMetadataSource 提供的权限数据比较.
(由于我们在 SecurityMetadataSource 中放行了类型为 FilterInvocationsecurity object, 所以 decide 方法第二个参数的真实类型就是 FilterInvocation)

/**
 * {@link AccessDecisionManager} 的实现
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-05-06 10:27
 */
@Slf4j
@Component
public class DynamicAccessDecisionManager implements AccessDecisionManager {
    private static final String CLASS_NAME = "Dynamic access decision manager";

    @Override
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
        // log.debug("{}#authentication: {}", CLASS_NAME, JSON.toJSONString(authentication));

        // In this case, object always be FilterInvocation
        // log.debug("{}#object: {}", CLASS_NAME, object.getClass().getCanonicalName());

        // 根据 requestUrl 过滤后的角色列表
        // log.debug("{}#configAttributes: {}", CLASS_NAME, JSON.toJSONString(configAttributes));

        final Set<String> requiredRolesByTargetResources = configAttributes.stream().map(ConfigAttribute::getAttribute).collect(Collectors.toSet());
        final Collection<? extends GrantedAuthority> incomingRequestAuthorities = authentication.getAuthorities();
        for (GrantedAuthority grantedAuthority : incomingRequestAuthorities) {
            // 如果当前 Authentication 有 requestUrl 的权限, 则认证通过
            // TODO 用 AntPathMatcher 匹配
            if (requiredRolesByTargetResources.contains(grantedAuthority.getAuthority())) {
                log.debug("{}#decide :: authenticate success.", CLASS_NAME);
                return;
            }
        }

        // TODO 需要查阅源码
        // 如果不执行 SecurityContextHolder.clearContext() 即使抛出 AccessDeniedException,
        // 最终 httpSecurity.exceptionHandling 也会捕获到 InsufficientAuthenticationException
        SecurityContextHolder.clearContext();
        throw new InsufficientAuthenticationException("权限不足");
    }

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

    @Override
    public boolean supports(Class<?> clazz) {
        log.debug("{}#supports {}", CLASS_NAME, JSON.toJSONString(clazz));
        return true;
    }
}
FilterSecurityInterceptorPostProcessor

最后, 如之前所述, 我们需要利用 ObjectPostProcessor 机制为没有暴露特定属性的 FilterSecurityInterceptor 配置 AccessDecisionManagerSecurityMetadataSource:

/**
 * {@link FilterSecurityInterceptor} PostProcessor
 *
 * @author LiKe
 * @version 1.0.0
 * @date 2020-05-08 18:14
 */
public class FilterSecurityInterceptorPostProcessor implements ObjectPostProcessor<FilterSecurityInterceptor> {

    private final AccessDecisionManager accessDecisionManager;

    private final FilterInvocationSecurityMetadataSource securityMetadataSource;

    public FilterSecurityInterceptorPostProcessor(AccessDecisionManager accessDecisionManager, FilterInvocationSecurityMetadataSource securityMetadataSource) {
        this.accessDecisionManager = accessDecisionManager;
        this.securityMetadataSource = securityMetadataSource;
    }

    @Override
    public <O extends FilterSecurityInterceptor> O postProcess(O filterSecurityInterceptor) {
        filterSecurityInterceptor.setAccessDecisionManager(accessDecisionManager);
        filterSecurityInterceptor.setSecurityMetadataSource(securityMetadataSource);
        return filterSecurityInterceptor;
    }
}

这样, 我们动态权限的配置就完成了. 再来简单看看 (详细介绍请看之前的文章) 身份认证和权限验证的过滤器:
(实现思路和 SpringSecurity (3) SpringBoot + JWT 实现身份认证和权限验证 一致)

CsrfFilter

这是 SpringSecurity 自带的过滤器, 它的执行时间早于 UsernamePasswordAuthenticationFilter, 主要调用 CsrfTokenRepository 维护 csrf-token (详细介绍请看 SpringSecurity (4) CSRF 与 CSRF-TOKEN 的处理). 由于我们禁用 Session, 需要把 csrf-token 和 JWT 缓存, 所以, 需要自己实现 CsrfTokenRepository, 原理和之前的文章一致:
这里需要注意的一个优化点是, 对于注册这种端点, 我们不需要后端返回 csrf-token, 所以需要避免过滤器执行 CsrfTokenRepository 的 saveToken 方法, 这里采用的措施是生成一个临时令牌, 由于注册端点 /auth/register 本身被配置为 csrf 策略忽略, 所以并不会用这个临时的令牌与缓存中的比较, 也不会为了一个无效的 csrf-token 额外的进行一次缓存操作.

@Component
public class CsrfTokenRedisRepository implements CsrfTokenRepository {

    /**
     * parameterName
     */
    private static final String CSRF_PARAMETER_NAME = "_csrf";

    /**
     * headerName, csrf-token 在请求头中的 HEADER-NAME
     */
    private static final String CSRF_HEADER_NAME = "X-CSRF-TOKEN";

    private static final String CSRF_TOKEN = SecurityConfiguration.CSRF_TOKEN;
    /**
     * {@link CsrfTokenRedisRepository#saveToken(CsrfToken, HttpServletRequest, HttpServletResponse)} 白名单, 放行 /auth/register 注册端点
     */
    private static final Set<String> IGNORING_SAVING_TOKEN_LIST = Stream.of(SecurityConfiguration.REGISTER_URI).collect(Collectors.toSet());
    /**
     * {@link RedisService}
     */
    private RedisService redisService;
    /**
     * {@link User}#name, 用户名
     */
    private String name;

    /**
     * 如果 generateToken 被调用了, 则该方法也会被调用
     */
    @SneakyThrows
    @Override
    public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
        log.debug("csrf filter: redis csrf token repository: save token");

        if (Objects.isNull(token)) {
            redisService.delete(RedisKey.builder().prefix(name).suffix(CSRF_TOKEN).build());
            return;
        }

        // 缓存 csrf-token
        final String csrfToken = token.getToken();
        redisService.setValue(RedisKey.builder().prefix(name).suffix(CSRF_TOKEN).build(), csrfToken);

        if (StringUtils.equals(RequestUtils.getQualifiedURI(request), SecurityConfiguration.LOGIN_URI)) {
            // 登录端点的请求在登录成功后设置 JWTAuthenticationFilter#successfulAuthentication
            return;
        }

        // 将 csrf-token 放入响应头
        response.setHeader(CSRF_TOKEN, csrfToken);
    }

    /**
     * 首先被调用
     */
    @SneakyThrows
    @Override
    public CsrfToken loadToken(HttpServletRequest request) {
        log.debug("csrf filter: redis csrf token repository: load token");

        final User user = JSON.parseObject(request.getInputStream(), StandardCharsets.UTF_8, User.class);
        if (Objects.nonNull(user)) {
            this.name = user.getName();
        } else {
            try {
                this.name = AccessTokenUtils.getSubject(
                        request.getHeader(AccessTokenUtils.AUTHORIZATION_HEADER).replaceFirst(AccessTokenUtils.BEARER_TOKEN_TYPE, StringUtils.EMPTY)
                );
            } catch (Exception ignored) {
                throw new UserInfoIncompleteException("Cannot retrieve user's name from request (neither inputStream nor header Authorization)");
            }
        }

        if (StringUtils.isBlank(this.name)) {
            throw new UserInfoIncompleteException("Cannot retrieve user's name from request (neither inputStream nor header Authorization)");
        }

        // ~ 避免触发 saveToken
        final String qualifiedURI = RequestUtils.getQualifiedURI(request);
        if (IGNORING_SAVING_TOKEN_LIST.contains(qualifiedURI)) {
            return generateToken(request);
        }

        // 返回正常的 Token
        final String csrfToken = getCachedToken();
        return StringUtils.isBlank(csrfToken) ? null : new DefaultCsrfToken(CSRF_HEADER_NAME, CSRF_PARAMETER_NAME, csrfToken);
    }

    /**
     * 当 loadToken 返回 null 时, 会调用 generateToken 随后调用 saveToken
     */
    @Override
    public CsrfToken generateToken(HttpServletRequest request) {
        return new DefaultCsrfToken(CSRF_HEADER_NAME, CSRF_PARAMETER_NAME, CsrfTokenUtils.create());
    }

    /**
     * Description: 从缓存中获取 csrf-token
     *
     * @return java.lang.String
     * @author LiKe
     * @date 2020-05-13 09:54:16
     */
    private String getCachedToken() {
        final String csrfToken = redisService.getValue(RedisKey.builder().prefix(name).suffix(CSRF_TOKEN).build(), String.class);

        if (StringUtils.isNoneBlank(csrfToken)) {
            return csrfToken;
        }

        return StringUtils.EMPTY;
    }

    // ~ Autowired
    // -----------------------------------------------------------------------------------------------------------------

    @Autowired
    public void setRedisService(RedisService redisService) {
        this.redisService = redisService;
    }
}

JWTAuthenticationFilter

在认证成功的时候在请求头中置入 JWT 和 csrf-token. 认证失败的时候执行清理工作.
ResponseUtils 封装了响应头的方法, 用于给前端返回统一格式的数据.
用户登陆后, 会通过该过滤器对用户信息进行校验, 如果校验通过, 会在响应头中置入 JWT 和 csrf-token (由 CsrfFilter 操作 CsrfTokenRespository 生成), 然后停止后续执行, 返回.

public class JWTAuthenticationFilter extends UsernamePasswordAuthenticationFilter {

    private static final String AUTHENTICATION_SUCCESS_MESSAGE = "Login success.";

    private static final String AUTHENTICATION_INCOMPLETE_MESSAGE = "User credential is null.";

    private static final String USERNAME_PARAMETER_NAME = "name";

    private static final String CSRF_TOKEN = SecurityConfiguration.CSRF_TOKEN;

    private static final String ACCESS_TOKEN = SecurityConfiguration.ACCESS_TOKEN;

    /**
     * {@link AuthenticationManager }
     */
    private final AuthenticationManager authenticationManager;

    /**
     * {@link RedisService }
     */
    private final RedisService redisService;

    private User user;

    public JWTAuthenticationFilter(AuthenticationManager authenticationManager, RedisService redisService) {
        this.authenticationManager = authenticationManager;
        this.redisService = redisService;

        // 当 requestUrl 是 /auth/login 时会经过这个过滤器
        super.setFilterProcessesUrl(SecurityConfiguration.LOGIN_URI);
        super.setUsernameParameter(USERNAME_PARAMETER_NAME);
    }

    @SneakyThrows
    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
        // 数据是通过 requestBody 传输
        this.user = JSON.parseObject(request.getInputStream(), StandardCharsets.UTF_8, User.class);

        if (Objects.isNull(user)) {
            ResponseUtils.forbiddenResponse(response, AUTHENTICATION_INCOMPLETE_MESSAGE);
            return null;
        }

        try {
            return authenticationManager.authenticate(
                    new UsernamePasswordAuthenticationToken(user.getName(), user.getPassword())
            );
        } catch (AuthenticationException authenticationException) {
            unsuccessfulAuthentication(request, response, authenticationException);

            // 终止后续执行
            return null;
        }
    }

    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException {
        log.debug("Successful authentication.");

        SecurityContextHolder.getContext().setAuthentication(authResult);
        final CustomUserDetailsDto customUserDetailsDto = (CustomUserDetailsDto) authResult.getPrincipal();

        // ~ 登陆成功后, 给响应头置入 access-token 和 csrf-token

        response.setHeader(
                ACCESS_TOKEN,
                // 缓存 access-token
                redisService.setValue(
                        RedisKey.builder().prefix(customUserDetailsDto.getName()).suffix(ACCESS_TOKEN).build(),
                        AccessTokenUtils.create(customUserDetailsDto),
                        AccessTokenUtils.LIFE_TIME
                )
        );

        response.setHeader(
                CSRF_TOKEN,
                redisService.getValue(
                        RedisKey.builder().prefix(customUserDetailsDto.getName()).suffix(CSRF_TOKEN).build(),
                        String.class
                )
        );

        ResponseUtils.okResponse(response, AUTHENTICATION_SUCCESS_MESSAGE);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException {
        log.debug("Unsuccessful authentication : delete cached csrf-token.");

        // 如果登陆失败, 清楚在 CsrfFilter 阶段已经缓存的无效的 csrf-token, 不会有 csrf-token 返回
        redisService.delete(RedisKey.builder().prefix(user.getName()).suffix(SecurityConfiguration.CSRF_TOKEN).build());

        ResponseUtils.forbiddenResponse(response, failed.getMessage());
    }
}

JWTAuthorizationFilter

权限验证过滤器的执行时机晚于身份认证过滤器, 承担着判断请求头中是否存在有效的 JWT, 封装 Authentication 对象, 并延长 JWT 过期时间的职责.

@Slf4j
public class JWTAuthorizationFilter extends OncePerRequestFilter {

    private static final String UNAUTHORIZED_MESSAGE = "Unauthorized access.";

    /**
     * access-token 过期 或者 不是有效的 access-token
     */
    private static final String ACCESS_TOKEN_EXPIRED_MESSAGE = "Invalid access-token.";

    /**
     * 白名单, 放行 /auth/register 注册端点
     */
    private static final Set<String> WHITE_LIST = Stream.of(SecurityConfiguration.REGISTER_URI).collect(Collectors.toSet());

    /**
     * {@link RedisService }
     */
    private final RedisService redisService;

    public JWTAuthorizationFilter(RedisService redisService) {
        this.redisService = redisService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        final String authorization = request.getHeader(AccessTokenUtils.AUTHORIZATION_HEADER);

        // ~ without access-token
        // -------------------------------------------------------------------------------------------------------------
        if (WHITE_LIST.contains(RequestUtils.getQualifiedURI(request))) {
            filterChain.doFilter(request, response);
            return;
        }

        if (StringUtils.isBlank(authorization)) {
            ResponseUtils.unauthorizedResponse(response, UNAUTHORIZED_MESSAGE);
            return;
        }

        // ~ with access-token
        // -------------------------------------------------------------------------------------------------------------

        final String jwt = authorization.replaceFirst(AccessTokenUtils.BEARER_TOKEN_TYPE, StringUtils.EMPTY);
        final CustomUserDetailsDto customUserDetailsDto = AccessTokenUtils.getCustomUserDetails(jwt);

        // 如果 access-token 已经失效
        final RedisKey accessTokenKey = RedisKey.builder().prefix(customUserDetailsDto.getName()).suffix(SecurityConfiguration.ACCESS_TOKEN).build();
        if (Objects.isNull(redisService.getValue(accessTokenKey, String.class))) {
            ResponseUtils.unauthorizedResponse(response, ACCESS_TOKEN_EXPIRED_MESSAGE);
            return;
        }

        // 每次授权的访问, 都延长 access-token 的过期时间, 返回新的 token
        redisService.expire(accessTokenKey, AccessTokenUtils.LIFE_TIME);

        // 认证
        SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
                customUserDetailsDto.getName(),
                customUserDetailsDto.getPassword(),
                customUserDetailsDto.getAuthorities()
        ));

        filterChain.doFilter(request, response);
    }
}

约定统一返回格式

我们还需要确保无论是正常的响应, 还是异常的响应, 都给前端返回统一的 JSON. 约定前后端交互的 JSON 格式为:

{
    "timestamp": "格式为 yyyy-MM-dd HH:mm:ss 的时间",
    "status": 200,
    "message": "消息",
    "data": "数据 (如果有), 默认 {}"
}

根据 SpringBoot 用 @ControllerAdvice 和 BasicErrorController 处理异常并返回结构一致的 JSON 中的思路, 利用 BasicErrorController 来处理 @ControllerAdvice 以外的异常:

/**
 * {@link BasicErrorController }
* Ref: https://stackoverflow.com/questions/34366964/spring-boot-controlleradvice-exception-handler-not-firing * * @author LiKe * @version 1.0.0 * @date 2020-05-13 17:09 */
@RestController public class CustomErrorController extends BasicErrorController { private static final String EXCEPTION = "javax.servlet.error.exception"; // private static final String STATUS_CODE_KEY = "javax.servlet.error.status_code"; private static final String EXCEPTION_MESSAGE = "message"; private static final String TIMESTAMP = "timestamp"; public CustomErrorController(ErrorAttributes errorAttributes) { super(errorAttributes, new CustomErrorProperties()); } @RequestMapping public ResponseEntity<Map<String, Object>> error(HttpServletRequest request) { return responseEntity(request); } private ResponseEntity<Map<String, Object>> responseEntity(HttpServletRequest request) { final Map<String, Object> body = getErrorAttributes(request, false); final Object exception = request.getAttribute(EXCEPTION); // ~ UserInfoIncompleteException responseEntity if (exception instanceof UserInfoIncompleteException) { return new ResponseEntity<>( BeanUtils.toMap( SecurityResponseDtoBuilder.of().with(builder -> { builder.timestamp = TimeUtils.fromDate((Date) MapUtils.getObject(body, TIMESTAMP)); builder.httpStatus = HttpStatus.FORBIDDEN; builder.message = MapUtils.getString(body, EXCEPTION_MESSAGE); }).build().toString() ), HttpStatus.FORBIDDEN ); } return super.error(request); } /** * {@link ErrorProperties} */ private static class CustomErrorProperties extends ErrorProperties { public CustomErrorProperties() { // 在 HttpServletRequest 中加入属性 javax.servlet.error.exception, 指明发生的具体异常 super.setIncludeException(true); } } }

同时封装一个响应工具类 ResponseUtils, 保证响应信息体结构的一致性:

@Component
public final class ResponseUtils {
    private static final String RESPONSE_CONTENT_TYPE = "application/json;charset=UTF-8";

    private static void preHandle(HttpServletResponse response) {
        response.setContentType(RESPONSE_CONTENT_TYPE);
        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
    }

    private static void wrapResponse(HttpStatus httpStatus, HttpServletResponse response, String message) throws IOException {
        preHandle(response);
        response.setStatus(httpStatus.value());
        try (final PrintWriter writer = response.getWriter()) {
            writer.write(
                    SecurityResponseDtoBuilder.of().with(securityResponseDtoBuilder -> {
                        securityResponseDtoBuilder.httpStatus = httpStatus;
                        securityResponseDtoBuilder.message = message;
                    }).build().toString()
            );
            writer.flush();
        }
    }

    /**
     * Description: 包装 HttpStatus 为 200 的 Response
     *
     * @param response {@link HttpServletResponse}
     * @param message  消息
     * @return void
     * @author LiKe
     * @date 2020-05-09 10:27:59
     */
    public static void okResponse(HttpServletResponse response, String message) throws IOException {
        wrapResponse(HttpStatus.OK, response, message);
    }

    /**
     * Description: 包装 HttpStatus 为 401 的 Response
     *
     * @param response {@link HttpServletResponse}
     * @param message  消息
     * @return void
     * @author LiKe
     * @date 2020-05-08 18:31:22
     */
    public static void unauthorizedResponse(HttpServletResponse response, String message) throws IOException {
        wrapResponse(HttpStatus.UNAUTHORIZED, response, message);
    }

    /**
     * Description: 包装 HttpStatus 为 403 的 Response
     *
     * @param response {@link HttpServletResponse}
     * @param message  消息
     * @return void
     * @author LiKe
     * @date 2020-05-08 18:31:09
     */
    public static void forbiddenResponse(HttpServletResponse response, String message) throws IOException {
        wrapResponse(HttpStatus.FORBIDDEN, response, message);
    }

    private ResponseUtils() {
    }
}

测试

核心代码和思路基本上介绍完了, 剩下的请查阅文章开头分享的代码地址.

❉ 登陆
针对端点 /auth/login, 我们在请求体重置入用户信息数据:

{
	"name": "like",
	"password": "root"
}

登陆成功的响应信息:

{
    "timestamp": "2020-05-26 10:10:42",
    "status": 200,
    "message": "Login success.",
    "data": "{}"
}

同时, 在响应头中, 后端也如约返回了 JWT 和 csrf-token, redis 中也有缓存数据:
在这里插入图片描述
SpringSecurity (5) SpringBoot + JWT + CSRF 动态权限的实现_第7张图片
❉ 访问权限内资源, 动态改变权限
由于当前用户有 ROLE_USER 的角色, 在请求头中携带 JWT 和 csrf-token 访问端点 /user/1, 可以看到 redis 中 JWT 的过期时间更新了, 同时服务也返回给前端新的 csrf-token:
SpringSecurity (5) SpringBoot + JWT + CSRF 动态权限的实现_第8张图片
SpringSecurity (5) SpringBoot + JWT + CSRF 动态权限的实现_第9张图片


手动修改 ROLE_USER 表的对应关系, 将 ID 为 1 的用户的角色 ID 改为 ROLE_ADMIN 的 ID (1), 重新登陆后再次访问, 可以看到服务端正确的返回了 403 的状态信息, 响应头中也被置入了新的 csrf-token:

{
    "timestamp": "2020-05-26 10:33:40",
    "status": 401,
    "message": "权限不足",
    "data": "{}"
}

动态权限就生效了, 如果直接更改 ROLE_POPEDOM 数据, 是不用重新登陆的.
(这里有一个优化的地方就是如果我们改变了角色后需要重新登陆, 这里可以做一个刷新的操作, 或者不要把权限信息放到 JWT 中)

❉ 异常的访问
如果请求中没有携带 csrf-token, 后端也会返回一致的提示信息:

{
    "timestamp": "2020-05-26 10:38:44",
    "status": 403,
    "message": "Invalid csrf-token.",
    "data": "{}"
}

这是由于在 CsrfFilter#doFilterInternal 中在 csrf-token 缺失或是无效时, 都会调用 accessDeniedHandler, 而在 SecurityConfiguration 中, 我们显示指定了 accessDeniedHandler.

如果登陆请求中用户信息不完整, 后端响应为:

{
    "data": "{}",
    "message": "Cannot retrieve user's name from request (neither inputStream nor header Authorization)",
    "timestamp": "2020-05-26 10:43:08",
    "status": 403
}

这是因为我们在 CsrfTokenRepository 中显示抛出了 UserInfoIncompleteException 异常, 同时在自定义的 BasicErrorController 中处理了该异常, 将本应该返回 500 的错误更改为符合业务逻辑的 403 错误, 并转换成约定的数据格式.

总结

本文介绍了动态权限的原理及思路以及如何实现. 由于是基于前后端分离以及以 JWT 作为令牌类型的形式, 整个服务禁用 Session, 所以把令牌交给了 redis 管理. 结合本系列前面几篇文章, 算是一个小小的整合. 作为一个基于 SpringSecurity + SpringBoot 的 Auth 模块, 初具雏形.
目前可以想到的优化也就只有 SecurityMetadataSource 中的权限数据缓存和 JWT 中的角色信息缓存.
限于篇幅 (其实已经很长了) 并未粘贴全部代码, 源码请见文章开头.

下一篇, 开始 OAuth 2.0 的整合 …

- END -

Reference

  • SpringSecurity (1) Quickstart
  • SpringSecurity (2) UserDetailsService
  • SpringSecurity (3) SpringBoot + JWT 实现身份认证和权限验证
  • SpringSecurity (4) CSRF 与 CSRF-TOKEN 的处理

  • How do I define the secured URLs within an application dynamically?
  • Access-Control (Authorization) in Spring Security
  • Post Processing Configured Objects

  1. 标记接口是一种计算机科学中的设计模式. 它提供了一种关联 class 的元数据的方式. 标记了 class 运行时的额外特性. 如 Serializable 接口. ↩︎

你可能感兴趣的:(#,SpringSecurity)