Spring Security结合JWT实现认证与授权

5,375

Spring Security系列文章

经过这段时间的学习,我们已不满足于普通案例的实操,想着啥时候找个更贴合实际应用的案例练练手,比如说 SpringSecurity+JWT,正好将近期的知识点糅合起来,也算是实操了基于 Token 的认证与授权。

闲话少说,我们直奔主题,开始本次案例的学习。

SpringSecurity认证与授权

自定义认证处理

还记得上一篇文章中提到的验证码处理逻辑吗?我们通过自定义 DaoAuthenticationProvider 实现类,并重写 additionalAuthenticationChecks 方法,将验证码比对逻辑和密码比对逻辑放在一起。但如果只是为了校验验证码,还有一种实现方式:可以自定义一个过滤器,并把这个自定义的过滤器放入 SpringSecurity 过滤器链中,每次请求都会通过该过滤器。但该方法存在一个弊端,我们只希望登录请求经过该过滤器即可,其他请求是不需要经过该过滤器的

Spring Security 的默认 Filter 链:

 SecurityContextPersistenceFilter
->HeaderWriterFilter
->LogoutFilter
->UsernamePasswordAuthenticationFilter
->RequestCacheAwareFilter
->SecurityContextHolderAwareRequestFilter
->SessionManagementFilter
->ExceptionTranslationFilter
->FilterSecurityInterceptor

这些过滤器按照既定的优先级排列,最终形成一个过滤器链,如下图所示。开发人员也可以自定义过滤器,并通过 @Order 注解来调整自定义过滤器在过滤器链中的位置。

过滤器执行顺序

下面我们重点关注 UsernamePasswordAuthenticationFilter,该过滤器用于处理基于表单方式的登录验证,该过滤器默认只有当请求方法为post、请求页面为/login时过滤器才生效,如果想修改默认拦截url,只需在刚才介绍的Spring Security配置类WebSecurityConfig中配置该过滤器的拦截url:.loginProcessingUrl("url")即可;

当用户发送登录请求的时候,首先进入到 UsernamePasswordAuthenticationFilter 中进行校验。

Spring Security认证流程

打断点发送登录请求进入源码中,我们会发现它会进入到 UsernamePasswordAuthenticationFilter,在该类中,有一个attemptAuthentication方法在这个方法中,会获取请求传入的 username 以及 password 参数的信息,然后使用构造器 new UsernamePasswordAuthenticationToken(username, password)封装为一个 UsernamePasswordAuthenticationToken 对象,在这个构造器内部会将对应的信息赋值给各自的本地变量,并且会调用父类 AbstractAuthenticationToken 构造器,传一个 null值进去,为什么是 null 呢?因为刚开始并没有认证,因此用户没有任何权限,并且设置没有认证的信息(setAuthenticated(false)),最后会进入AuthenticationManager 接口的实现类 ProviderManager 中,接着就调用 authenticate 方法。

看到这里是不是有点熟悉,这不就来到了前一篇文章中提到的 DaoAuthenticationProvider 嘛,它的父类是 AbstractUserDetailsAuthenticationProvider,其中就包括 authenticate 方法,这里就不重复介绍了。

综上可知,UsernamePasswordAuthenticationFilterDaoAuthenticationProvider 是 SpringSecurity 认证处理的核心逻辑,主要是校验输入的账号密码是否合规。需要注意的是,之前的案例中,我们都没有 /login 的处理逻辑,全权交由 SpringScurity 处理,在实际应用中我们并不会这样做。

现在回看一下我们的需求,即登录认证成功后,之后访问其他接口需要携带认证结果,可以是 token,后端需要验证认证结果是否有效。那么就要求我们自定义一个过滤器,针对每次请求携带的认证结果。那么首先需要自定义认证逻辑,即处理用户调用 /login 的逻辑,最终返回认证结果,所以我们要剔除掉 UsernamePasswordAuthenticationFilter。这里我们暂时不介绍认证逻辑,重点关注如何解析认证结果。

OncePerRequestFilter

OncePerRequestFilter 是 Spring Boot 里面的一个过滤器抽象类,其同样在 Spring Security 里面被广泛用到,这个过滤器抽象类通常被用于继承实现并在每次请求时只执行一次过滤。

OncePerRequestFilter 继承 Filter,而 doFilter 是 Filter 接口中的方法,doFilterInternal是OncePerRequestFilter 中的一个抽象方法

public final void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws ServletException, IOException {
  if (request instanceof HttpServletRequest && response instanceof HttpServletResponse) {
    HttpServletRequest httpRequest = (HttpServletRequest)request;
    HttpServletResponse httpResponse = (HttpServletResponse)response;
    String alreadyFilteredAttributeName = this.getAlreadyFilteredAttributeName();
    boolean hasAlreadyFilteredAttribute = request.getAttribute(alreadyFilteredAttributeName) != null;
    if (!this.skipDispatch(httpRequest) && !this.shouldNotFilter(httpRequest)) {
      if (hasAlreadyFilteredAttribute) {
        if (DispatcherType.ERROR.equals(request.getDispatcherType())) {
          this.doFilterNestedErrorDispatch(httpRequest, httpResponse, filterChain);
          return;
        }

        filterChain.doFilter(request, response);
      } else {
        request.setAttribute(alreadyFilteredAttributeName, Boolean.TRUE);

        try {
          this.doFilterInternal(httpRequest, httpResponse, filterChain);
        } finally {
          request.removeAttribute(alreadyFilteredAttributeName);
        }
      }
    } else {
      filterChain.doFilter(request, response);
    }

  } else {
    throw new ServletException("OncePerRequestFilter just supports HTTP requests");
  }
}

由上可知,OncePerRequestFilter.doFilter 方法中通过 request.getAttribute 判断当前过滤器是否已执行,若未执行过,则调用doFilterInternal方法,交由其子类实现。经测试发现,SpringSecurity 的过滤器都会执行 doFilterInternal 方法。

所以我们自定义过滤器,只需要继承 OncePerRequestFilter,并重写 doFilterInternal 方法。

@Slf4j
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

  @Autowired
  private MyUserDetailsService userDetailsService;
  @Autowired
  private JwtTokenUtil jwtTokenUtil;
  @Value("${jwt.tokenHeader}")
  private String tokenHeader;
  @Value("${jwt.tokenHead}")
  private String tokenHead;

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
      FilterChain filterChain) throws ServletException, IOException {
    String authHeader = request.getHeader(this.tokenHeader);
    if (authHeader != null && authHeader.startsWith(this.tokenHead)) {
      String authToken = authHeader.substring(this.tokenHead.length());// The part after "Bearer "
      String username = jwtTokenUtil.getUserNameFromToken(authToken);
      logger.info("checking username:" + username);
      if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
        UserDetails userDetails = this.userDetailsService.loadUserByUsername(username);
        if (jwtTokenUtil.validateToken(authToken, userDetails)) {
          UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
              userDetails, null, userDetails.getAuthorities());
          authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
          logger.info("authenticated user:" + username);
          SecurityContextHolder.getContext().setAuthentication(authentication);
        }
      }
    }
    filterChain.doFilter(request, response);
  }
}

上述方法大致流程如下:

  1. 从请求头获取token信息;
  2. 如果 token 不为 null,且格式正确,则获取 token 中关键信息,然后调用 JWT 工具类根据 token 解析出用户名。根据用户名去数据库里获取具体信息,与 token 进行校验,匹配成功则将用户信息封装到 UsernamePasswordAuthenticationToken 对象中,并设置到安全上下文中。

在 SecurityConfig 中这样配置自定义的过滤器:

http.addFilterBefore(jwtAuthenticationTokenFilter,
            UsernamePasswordAuthenticationFilter.class);// 自定义认证过滤器

自定义权限处理

Spring Security 可以通过 http.authorizeRequests() 对web请求进行授权保护。Spring Security 使用标准Filter建立了对web请求的拦截,最终实现对资源的授权访问。授权流程如下:

授权流程图

分析授权流程:

  1. 拦截请求,已认证用户访问受保护的web资源将被 SecurityFilterChain 中的 FilterSecurityInterceptor 的子类拦截。

  2. 获取资源访问策略,FilterSecurityInterceptor 会从 SecurityMetadataSource 的子类 DefaultFilterInvocationSecurityMetadataSource 获取要访问当前资源所需要的权限 Collection 。

SecurityMetadataSource 其实就是读取访问策略的抽象,而读取的内容,其实就是我们配置的访问规则, 读取访问策略如:

http.csrf().disable()   //屏蔽CSRF控制,即spring security不再限制CSRF
.authorizeRequests()
.antMatchers("/r/r1").hasAuthority("p1")
.antMatchers("/r/r2").hasAuthority("p2")

具体来说是 DefaultFilterInvocationSecurityMetadataSource 文件中的 getAttributes()方法,该方法会读取上述访问规则,然后封装到 Collection 对象中。如果我们自定义 SecurityMetadataSource 实现类,则不会再执行 DefaultFilterInvocationSecurityMetadataSource 代码逻辑,即使配置 antMatchers("/r/r1").hasAuthority("p1") 也是无用。

  1. 最后,FilterSecurityInterceptor 会调用 AccessDecisionManager 进行授权决策,若决策通过,则允许访问资 源,否则将禁止访问。

AccessDecisionManager(访问决策管理器)的核心接口如下:

public interface AccessDecisionManager {
   /**   
   * 通过传递的参数来决定用户是否有访问对应受保护资源的权限   
   */   
    void decide(Authentication authentication , Object object, Collection<ConfigAttribute> 
configAttributes ) throws AccessDeniedException, InsufficientAuthenticationException;
 //略..     
}

这里着重说明一下decide的参数:

  • authentication:要访问资源的访问者的身份
  • object:要访问的受保护资源,web请求对应FilterInvocation
  • configAttributes:是受保护资源的访问策略,通过SecurityMetadataSource获取。

decide接口就是用来鉴定当前用户是否有访问对应受保护资源的权限。

AccessDecisionManager

自定义 AccessDecisionManager: 实现授权逻辑校验,decide 方法请求参数中的 configAttributes 可以通过我们自定义的 SecurityMetadataSource 实现类获取。

public class DynamicAccessDecisionManager implements AccessDecisionManager {

  @Override
  public void decide(Authentication authentication, Object object,
      Collection<ConfigAttribute> configAttributes)
      throws AccessDeniedException, InsufficientAuthenticationException {
    // 当接口未被配置资源时直接放行
    if (CollUtil.isEmpty(configAttributes)) {
      return;
    }
    Iterator<ConfigAttribute> iterator = configAttributes.iterator();
    while (iterator.hasNext()) {
      ConfigAttribute configAttribute = iterator.next();
      //将访问所需资源或用户拥有资源进行比对
      String needAuthority = configAttribute.getAttribute();
      for (GrantedAuthority grantedAuthority : authentication.getAuthorities()) {
        if (needAuthority.trim().equals(grantedAuthority.getAuthority())) {
          return;
        }
      }
    }
    throw new AccessDeniedException("抱歉,您没有访问权限");
  }

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

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

SecurityMetadataSource

DynamicSecurityMetadataSource 与 DefaultFilterInvocationSecurityMetadataSource 同等级,不会读取 SecurityConfig 文件中配置的 antMatchers("/r/r1").hasAuthority("p1")

public class DynamicSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {

  private static Map<String, ConfigAttribute> configAttributeMap = null;
  @Autowired
  private DynamicSecurityService dynamicSecurityService;

  @PostConstruct
  public void loadDataSource() {
    configAttributeMap = dynamicSecurityService.loadDataSource();
  }

  public void clearDataSource() {
    configAttributeMap.clear();
    configAttributeMap = null;
  }

  @Override
  public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
    if (configAttributeMap == null) {
      this.loadDataSource();
    }
    List<ConfigAttribute> configAttributes = new ArrayList<>();
    //获取当前访问的路径
    String url = ((FilterInvocation) object).getRequestUrl();
    String path = URLUtil.getPath(url);
    PathMatcher pathMatcher = new AntPathMatcher();
    Iterator<String> iterator = configAttributeMap.keySet().iterator();
    //获取访问该路径所需资源
    while (iterator.hasNext()) {
      String pattern = iterator.next();
      if (pathMatcher.match(pattern, path)) {
        configAttributes.add(configAttributeMap.get(pattern));
      }
    }
    // 未设置操作请求权限,返回空集合
    return configAttributes;
  }

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

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

DynamicSecurityService 用来读取 permission 表,获取权限配置。

@Service
public class DynamicSecurityService {

  @Autowired
  private PermissionMapper permissionMapper;

  // 加载资源ANT通配符和资源对应MAP
  public Map<String, ConfigAttribute> loadDataSource() {
    Map<String, ConfigAttribute> urlAndResourceNameMap = new ConcurrentHashMap<>();
    List<Permission> permissions = permissionMapper.findAll();
    permissions.forEach(permission -> urlAndResourceNameMap
        .put(permission.getUrl(), new SecurityConfig(permission.getName())));
    return urlAndResourceNameMap;
  }
}

FilterSecurityInterceptor

FilterSecurityInterceptor 拦截器,用于判断当前请求身份认证是否成功,是否有相应的权限,当身份认证失败或者权限不足的时候便会抛出相应的异常;

Spring Security使用FilterSecurityInterceptor过滤器来进行URL权限校验,实际使用流程大致如下:

  1. 通过数据库动态配置url资源权限
  2. 系统启动时,通过FilterSecurityInterceptor滤器到数据库加载系统资源权限列表
  3. 用户登陆时通过自定义的UserDetailsService加载当前用户的角色列表
  4. 当有请求访问时,通过FilterSecurityInterceptor对比系统资源权限列表和用户资源权限列表(在用户登录时添加到用户信息中)来判断用户是否有该url的访问权限。

自定义URL权限验证需要在FilterSecurityInterceptor自定义的配置项

  1. DynamicSecurityMetadataSource:实现FilterInvocationSecurityMetadataSource接口,在实现类中加载资源权限,并在filterSecurityInterceptor中注入该实现类。
  2. DynamicAccessDecisionManager:通过实现AccessDecisionManager接口自定义一个决策管理器,判断是否有访问权限。判断逻辑可以写在决策管理器的决策方法中,也可以通过投票器实现,除了框架提供的三种投票器还可以添加自定义投票器。自定义投票器通过实现AccessDecisionVoter接口来实现。

具体代码如下:

public class DynamicSecurityFilter extends AbstractSecurityInterceptor implements Filter {

  @Autowired
  private IgnoreUrlsConfig ignoreUrlsConfig;
  @Autowired
  private DynamicSecurityMetadataSource dynamicSecurityMetadataSource;

  @Autowired
  public void myAccessDecisionManager(DynamicAccessDecisionManager dynamicAccessDecisionManager) {
    super.setAccessDecisionManager(dynamicAccessDecisionManager);
  }

  @Override
  public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse,
      FilterChain filterChain) throws IOException, ServletException {
    HttpServletRequest request = (HttpServletRequest) servletRequest;
    FilterInvocation fi = new FilterInvocation(servletRequest, servletResponse, filterChain);
    //OPTIONS请求直接放行
    if (request.getMethod().equals(HttpMethod.OPTIONS.toString())) {
      fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
      return;
    }
    //白名单请求直接放行
    PathMatcher pathMatcher = new AntPathMatcher();
    for (String path : ignoreUrlsConfig.getUrls()) {
      if (pathMatcher.match(path, request.getRequestURI())) {
        fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
        return;
      }
    }
    //此处会调用AccessDecisionManager中的decide方法进行鉴权操作
    InterceptorStatusToken token = super.beforeInvocation(fi);
    try {
      fi.getChain().doFilter(fi.getRequest(), fi.getResponse());
    } finally {
      super.afterInvocation(token, null);
    }
  }

  @Override
  public Class<?> getSecureObjectClass() {
    return FilterInvocation.class;
  }

  @Override
  public SecurityMetadataSource obtainSecurityMetadataSource() {
    return dynamicSecurityMetadataSource;
  }

}

在 yaml 文件中设置白名单,然后读取这些 api。

secure:
  ignored:
    urls: #安全路径白名单
      - /swagger-ui/
      - /swagger-resources/**
      - /**/v2/api-docs
      - /login
      - /register

IgnoreUrlsConfig 相当于之前 SecurityConfig 文件中的 http.antMatchers("").permitAll()

@Getter
@Setter
@ConfigurationProperties(prefix = "secure.ignored")
public class IgnoreUrlsConfig {

  private List<String> urls = new ArrayList<>();

}

自定义异常处理

Spring Security 中的异常主要分为两大类:一类是认证异常,另一类是授权相关的异常。

HttpSecurity 提供的 exceptionHandling() 方法用来提供异常处理。该方法构造出 ExceptionHandlingConfigurer 异常处理配置类。该配置类提供了两个实用接口:

  • AuthenticationEntryPoint 该类用来统一处理 AuthenticationException 异常
  • AccessDeniedHandler 该类用来统一处理 AccessDeniedException 异常

AuthenticationEntryPoint

被 ExceptionTranslationFilter 用来作为认证方案的入口,即当用户请求处理过程中遇见认证异常时,被异常处理器(ExceptionTranslationFilter)用来开启特定的认证流程。接口定义如下:

public interface AuthenticationEntryPoint {
  void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException;
}

其中,request 是遇到了认证异常的用户请求,response 是将要返回给用户的响应,authException 请求过程中遇见的认证异常。

自定义 AuthenticationEntryPoint 实现类如下:

public class MyAuthenticationEntryPoint implements AuthenticationEntryPoint {

  @Override
  public void commence(HttpServletRequest request, HttpServletResponse response,
      AuthenticationException authException) throws IOException, ServletException {
    response.setHeader("Access-Control-Allow-Origin", "*");
    response.setHeader("Cache-Control", "no-cache");
    response.setCharacterEncoding("UTF-8");
    response.setContentType("application/json");
    response.getWriter().println(JSONUtil.parse(Result.unauthorized(authException.getMessage())));
    response.getWriter().flush();
  }
}

Spring Security Web 内置AuthenticationEntryPoint实现类

Spring Security WebAuthenticationEntryPoint提供了一些内置实现 :

Http403ForbiddenEntryPoint

设置响应状态字为403,并非触发一个真正的认证流程。通常在一个预验证(pre-authenticated authentication)已经得出结论需要拒绝用户请求的情况被用于拒绝用户请求。

HttpStatusEntryPoint

设置特定的响应状态字,并非触发一个真正的认证流程。

LoginUrlAuthenticationEntryPoint

根据配置计算出登录页面url,将用户重定向到该登录页面从而开始一个认证流程。

BasicAuthenticationEntryPoint

对应标准Http Basic认证流程的触发动作,向响应写入状态字401和头部WWW-Authenticate:"Basic realm="xxx"触发标准Http Basic认证流程。

DigestAuthenticationEntryPoint

对应标准Http Digest认证流程的触发动作,向响应写入状态字401和头部WWW-Authenticate:"Digest realm="xxx"触发标准Http Digest认证流程。

DelegatingAuthenticationEntryPoint

这是一个代理,将认证任务委托给所代理的多个AuthenticationEntryPoint对象,其中一个被标记为缺省AuthenticationEntryPoint。

AccessDeniedHandler

处理授权异常,接口定义如下:

public interface AccessDeniedHandler {
  void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException;
}

自定义 AccessDeniedHandler 实现类如下:

public class MyAccessDeniedHandler implements AccessDeniedHandler {

  @Override
  public void handle(HttpServletRequest request, HttpServletResponse response,
      AccessDeniedException accessDeniedException) throws IOException, ServletException {
    response.setHeader("Access-Control-Allow-Origin", "*");
    response.setHeader("Cache-Control", "no-cache");
    response.setCharacterEncoding("UTF-8");
    response.setContentType("application/json");
    response.getWriter()
        .println(JSONUtil.parse(Result.forbidden(accessDeniedException.getMessage())));
    response.getWriter().flush();
  }
}

关于上述自定义的认证与授权处理,以及异常处理,需要在 SecurityConfig 文件中加以配置,如下所示:

@Configuration
public class SecurityConfig {

  @Bean
  public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
  }

  @Bean
  public IgnoreUrlsConfig ignoreUrlsConfig() {
    return new IgnoreUrlsConfig();
  }

  @Bean
  public MyAccessDeniedHandler myAccessDeniedHandler() {
    return new MyAccessDeniedHandler();
  }

  @Bean
  public MyAuthenticationEntryPoint myAuthenticationEntryPoint() {
    return new MyAuthenticationEntryPoint();
  }

  @Bean
  public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter() {
    return new JwtAuthenticationTokenFilter();
  }

  @ConditionalOnBean(name = "dynamicSecurityService")
  @Bean
  public DynamicAccessDecisionManager dynamicAccessDecisionManager() {
    return new DynamicAccessDecisionManager();
  }

  @ConditionalOnBean(name = "dynamicSecurityService")
  @Bean
  public DynamicSecurityMetadataSource dynamicSecurityMetadataSource() {
    return new DynamicSecurityMetadataSource();
  }

  @ConditionalOnBean(name = "dynamicSecurityService")
  @Bean
  public DynamicSecurityFilter dynamicSecurityFilter() {
    return new DynamicSecurityFilter();
  }

  //跨域
  @Autowired
  private CorsFilter corsFilter;

  @Bean
  public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = http
        .authorizeRequests();
    //不需要保护的资源路径允许访问
    for (String url : ignoreUrlsConfig().getUrls()) {
      registry.antMatchers(url).permitAll();
    }
    //允许跨域请求的OPTIONS请求
    registry.antMatchers(HttpMethod.OPTIONS)
        .permitAll();

    registry.and()
        .csrf().disable()   //屏蔽CSRF控制,即spring security不再限制CSRF
        .authorizeRequests()
        .anyRequest().authenticated();

    registry.and()
        .sessionManagement()
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        .and()
        .addFilterBefore(corsFilter, CsrfFilter.class)//跨域配置
        .exceptionHandling()  //异常处理,下面是自定义的两个异常
        .accessDeniedHandler(myAccessDeniedHandler())//授权异常捕获
        .authenticationEntryPoint(myAuthenticationEntryPoint())//认证异常捕获
        .and()
        .addFilterBefore(jwtAuthenticationTokenFilter(),
            UsernamePasswordAuthenticationFilter.class);// 自定义认证过滤器

    //添加动态权限校验过滤器
    registry.and().addFilterBefore(dynamicSecurityFilter(), FilterSecurityInterceptor.class);
    return http.build();
  }
}

JWT

那么如何使用 JWT 呢?此时我们需要使用一个叫做 JJWT 的库。

JJWT

JJWT 是一个提供端到端的 JWT 创建和验证的 Java 库。永远免费和开源(Apache License,版本2.0),JJWT 很容易使用和理解。它被设计成一个以建筑为中心的流畅界面,隐藏了它的大部分复杂性。

  • JJWT 的目标是最容易使用和理解用于在 JVM 上创建和验证 JSON Web 令牌(JWTs)的库。
  • JJWT 是基于 JWT、JWS、JWE、JWK 和 JWA RFC规范的Java实现。
  • JJWT 还添加了一些不属于规范的便利扩展,比如 JWT 压缩和索赔强制。

JJWT 规范兼容

  • 创建和解析明文压缩JWTs
  • 创建、解析和验证所有标准JWS算法的数字签名压缩JWTs(又称JWSs):
  • HS256:使用SHA-256的HMAC
  • HS384:使用SHA-384的HMAC
  • HS512:使用SHA-512的HMAC
  • RS256:使用SHA-256的RSASSA-PKCS-v1_5
  • RS384:使用SHA-384的RSASSA-PKCS-v1_5
  • RS512:使用SHA-512的RSASSA-PKCS-v1_5
  • PS256:使用SHA-256的RSASSA-PSS和使用SHA-256的MGF1
  • PS384:使用SHA-384的RSASSA-PSS和使用SHA-384的MGF1
  • PS512:使用SHA-512的RSASSA-PSS和使用SHA-512的MGF1
  • ES256:使用P-256和SHA-256的ECDSA
  • ES384:使用P-384和SHA-384的ECDSA
  • ES512:使用P-521和SHA-512的ECDSA

实际应用

导入 maven 依赖

<!-- https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt -->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>0.9.1</version>
</dependency>

JWT token的工具类

用于生成和解析JWT token的工具类

/**
 * JwtToken生成的工具类 JWT token的格式:header.payload.signature header的格式(算法、token的类型): {"alg":
 * "HS512","typ": "JWT"} payload的格式(用户名、创建时间、生成时间): {"sub":"wang","created":1489079981393,"exp":1489684781}
 * signature的生成算法: HMACSHA512(base64UrlEncode(header) + "." +base64UrlEncode(payload),secret)
 */
@Slf4j
@Component
public class JwtTokenUtil {

  private static final String CLAIM_KEY_USERNAME = "sub";
  private static final String CLAIM_KEY_CREATED = "created";
  @Value("${jwt.secret}")
  private String secret;
  @Value("${jwt.expiration}")
  private Long expiration;
  @Value("${jwt.tokenHead}")
  private String tokenHead;

  /**
   * 根据负责生成JWT的token
   */
  private String generateToken(Map<String, Object> claims) {
    return Jwts.builder()
        .setClaims(claims)
        .setExpiration(generateExpirationDate())
        .signWith(SignatureAlgorithm.HS512, secret)
        .compact();
  }

  /**
   * 从token中获取JWT中的负载
   */
  private Claims getClaimsFromToken(String token) {
    Claims claims = null;
    try {
      claims = Jwts.parser()
          .setSigningKey(secret)
          .parseClaimsJws(token)
          .getBody();
    } catch (Exception e) {
      log.info("JWT格式验证失败:{}", token);
    }
    return claims;
  }

  /**
   * 生成token的过期时间
   */
  private Date generateExpirationDate() {
    return new Date(System.currentTimeMillis() + expiration * 1000);
  }

  /**
   * 从token中获取登录用户名
   */
  public String getUserNameFromToken(String token) {
    String username;
    try {
      Claims claims = getClaimsFromToken(token);
      username = claims.getSubject();
    } catch (Exception e) {
      username = null;
    }
    return username;
  }

  /**
   * 验证token是否还有效
   *
   * @param token       客户端传入的token
   * @param userDetails 从数据库中查询出来的用户信息
   */
  public boolean validateToken(String token, UserDetails userDetails) {
    String username = getUserNameFromToken(token);
    return username.equals(userDetails.getUsername()) && !isTokenExpired(token);
  }

  /**
   * 判断token是否已经失效
   */
  private boolean isTokenExpired(String token) {
    Date expiredDate = getExpiredDateFromToken(token);
    return expiredDate.before(new Date());
  }

  /**
   * 从token中获取过期时间
   */
  private Date getExpiredDateFromToken(String token) {
    Claims claims = getClaimsFromToken(token);
    return claims.getExpiration();
  }

  /**
   * 根据用户信息生成token
   */
  public String generateToken(UserDetails userDetails) {
    Map<String, Object> claims = new HashMap<>();
    claims.put(CLAIM_KEY_USERNAME, userDetails.getUsername());
    claims.put(CLAIM_KEY_CREATED, DateUtil.date());
    return generateToken(claims);
  }

  /**
   * 当原来的token没过期时是可以刷新的
   *
   * @param oldToken 带tokenHead的token
   */
  public String refreshHeadToken(String oldToken) {
    if (StrUtil.isEmpty(oldToken)) {
      return null;
    }
    String token = oldToken.substring(tokenHead.length());
    if (StrUtil.isEmpty(token)) {
      return null;
    }
    //token校验不通过
    Claims claims = getClaimsFromToken(token);
    if (Objects.isNull(claims)) {
      return null;
    }
    //如果token已经过期,不支持刷新
    if (isTokenExpired(token)) {
      return null;
    }
    //如果token在30分钟之内刚刷新过,返回原token
    if (tokenRefreshJustBefore(token, 30 * 60)) {
      return token;
    } else {
      claims.put(CLAIM_KEY_CREATED, new Date());
      return generateToken(claims);
    }
  }

  /**
   * 判断token在指定时间内是否刚刚刷新过
   *
   * @param token 原token
   * @param time  指定时间(秒)
   */
  private boolean tokenRefreshJustBefore(String token, int time) {
    Claims claims = getClaimsFromToken(token);
    Date created = claims.get(CLAIM_KEY_CREATED, Date.class);
    Date refreshDate = new Date();
    //刷新时间在创建时间的指定时间内
    if (refreshDate.after(created) && refreshDate.before(DateUtil.offsetSecond(created, time))) {
      return true;
    }
    return false;
  }
}

项目实践

数据库

稍微复杂点的后台系统都会涉及到用户权限管理,既然我们选择使用 Spring Security 这一安全框架,那么就需要考虑如何来设计一套权限管理系统。首先需要知道的是,权限就是对数据(系统的实体类)和数据可进行的操作(增删查改)的集中管理。要构建一个可用的权限管理系统,涉及到三个核心类:一个是用户User,一个是角色Role,最后是权限Permission

用户角色,角色权限都是多对多关系,即一个用户拥有多个角色,一个角色属于多个用户;一个角色拥有多个权限,一个权限属于多个角色。这种方式需要指定用户有哪些角色,而角色又有哪些权限。

执行如下 SQL 语句,来构建数据表并初始化数据。

CREATE TABLE `user` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `username` varchar(50) DEFAULT NULL,
  `password` varchar(100) DEFAULT NULL,
  `phone` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户表';


CREATE TABLE `role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) DEFAULT NULL,
  `desc` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='角色表';

INSERT into `role`(name,`desc`) values('admin','管理员');
INSERT into `role`(name,`desc`) values('role1','角色1');
INSERT into `role`(name,`desc`) values('role2','角色2');

CREATE TABLE `permission` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) DEFAULT NULL,
  `url` varchar(50) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='权限表';

INSERT into permission(name,url) values('all','/*');
INSERT into permission(name,url) values('home','/home/*');
INSERT into permission(name,url) values('product','/product/*');
INSERT into permission(name,url) values('customer','/customer/*');


CREATE TABLE `user_role` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `uid` int(11) DEFAULT NULL,
  `rid` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `users_role_ibfk_1` (`uid`),
  KEY `users_role_ibfk_2` (`rid`),
  CONSTRAINT `users_role_ibfk_1` FOREIGN KEY (`uid`) REFERENCES `user` (`id`),
  CONSTRAINT `users_role_ibfk_2` FOREIGN KEY (`rid`) REFERENCES `role` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='用户角色对照表';


CREATE TABLE `role_permission` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `rid` int(11) DEFAULT NULL ,
  `pid` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `role_permission_ibfk_1` (`rid`),
  KEY `role_permission_ibfk_2` (`pid`),
  CONSTRAINT `role_permission_ibfk_1` FOREIGN KEY (`rid`) REFERENCES `role` (`id`),
  CONSTRAINT `role_permission_ibfk_2` FOREIGN KEY (`pid`) REFERENCES `permission` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='角色权限对照表';

待会我们可以注册用户,对照表数据则需要在数据库中手动新增,暂时未提供相关接口。

代码

项目中有两个 controller 文件,一个用于用户登录,另一个资源访问,这里简单贴一下代码,感兴趣的可以去我的 github 上下载源码。

@RestController
public class UserController {

  @Autowired
  private UserService userService;
  @Value("${jwt.tokenHeader}")
  private String tokenHeader;
  @Value("${jwt.tokenHead}")
  private String tokenHead;

  @PostMapping("/register")
  public Result register(@RequestBody UserRequest userRequest) {
    userService.register(userRequest);
    return Result.ok();
  }

  @PostMapping("/login")
  public Result<Object> login(@RequestParam("username") String username,
      @RequestParam("password") String password) {
    String token = userService.login(username, password);
    Map<String, String> tokenMap = new HashMap<>();
    tokenMap.put("token", token);
    tokenMap.put("tokenHead", tokenHead);
    return Result.ok(tokenMap);
  }

  @PostMapping("/refreshToken")
  public Result<Object> refreshToken(@RequestBody HttpServletRequest request) {
    String token = request.getHeader(tokenHeader);
    String refreshToken = userService.refreshToken(token);
    Map<String, String> tokenMap = new HashMap<>();
    tokenMap.put("token", refreshToken);
    tokenMap.put("tokenHead", tokenHead);
    return Result.ok(tokenMap);
  }

}

@RestController
public class ResourceController {

  @GetMapping("/home/level1")
  public Result getHomeLevel1() {
    return Result.ok("获取访问Home目录下的Level1的权限");
  }

  @GetMapping("/home/level2")
  public Result getHomeLevel2() {
    return Result.ok("获取访问Home目录下的Level2的权限");
  }

  @GetMapping("/customer/level1")
  public Result getCustomerLevel1() {
    return Result.ok("获取访问Customer目录下的Level1的权限");
  }

  @GetMapping("/customer/level2")
  public Result getCustomerLevel2() {
    return Result.ok("获取访问Customer目录下的Level2的权限");
  }

  @GetMapping("/product/level1")
  public Result getProductLevel1() {
    return Result.ok("获取访问Product目录下的Level3的权限");
  }

  @GetMapping("/product/level2")
  public Result getProductLevel2() {
    return Result.ok("获取访问Product目录下的Level的权限");
  }
}

首先我们需要自定义 UserDetails

@Setter
@Builder
public class MyUserDetails implements UserDetails {

  private User user;
  private List<Permission> permissionList;

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

  @Override
  public String getPassword() {
    return user.getPassword();
  }

  @Override
  public String getUsername() {
    return user.getUsername();
  }

  @Override
  public boolean isAccountNonExpired() {
    return true;
  }

  @Override
  public boolean isAccountNonLocked() {
    return true;
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  @Override
  public boolean isEnabled() {
    return !Objects.isNull(user);
  }
}

接着处理自定义 UserDetailsService

@Component
@RequiredArgsConstructor
public class MyUserDetailsService implements UserDetailsService {

  private final UserMapper userMapper;
  private final PermissionMapper permissionMapper;

  @Override
  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    //根据账号去数据库查询...
    User user = userMapper.selectByUserName(username);
    if (!Objects.isNull(user)) {
      List<Permission> permissionList = permissionMapper.findPermissionsByUserId(user.getId());
      return MyUserDetails.builder().user(user).permissionList(permissionList).build();
    }

    throw new UsernameNotFoundException("用户名或密码错误");
  }

}

以及处理用户注册登录的服务

@Service
@Slf4j
@RequiredArgsConstructor
public class UserService {

  private final MyUserDetailsService userDetailsService;
  private final PasswordEncoder passwordEncoder;
  private final JwtTokenUtil jwtTokenUtil;
  private final UserStruct userStruct;
  private final UserMapper userMapper;

  public String login(String username, String password) {
    String token = null;
    try {

      UserDetails userDetails = userDetailsService.loadUserByUsername(username);
      if (!passwordEncoder.matches(password, userDetails.getPassword())) {
        BusinessException.fail("密码不正确");
      }
      if (!userDetails.isEnabled()) {
        BusinessException.fail("帐号已被禁用");
      }
      UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
          userDetails, null, userDetails.getAuthorities());
      SecurityContextHolder.getContext().setAuthentication(authentication);
      token = jwtTokenUtil.generateToken(userDetails);
    } catch (AuthenticationException e) {
      log.error("登录异常,detail" + e.getMessage());
    }
    return token;
  }

  public void register(UserRequest userRequest) {
    User user = userMapper.selectByUserName(userRequest.getUsername());
    if (Objects.nonNull(user)) {
      BusinessException.fail("用户名已存在!");
    }
    String encodePassword = passwordEncoder.encode(userRequest.getPassword());
    User obj = userStruct.toUser(userRequest);
    obj.setPassword(encodePassword);
    userMapper.insert(obj);
  }

  public String refreshToken(String oldToken) {
    return jwtTokenUtil.refreshHeadToken(oldToken);
  }
}

测试

注册用户

用户注册

用户登录并返回 token

用户登录并返回 token

手动给 hresh3 用户赋予 role1 角色,即具备 home 目录下的访问权限。

复制登录后获取到的 token,访问 home/level1,可以正常访问。

有权访问页面

如果想要访问 customer 目录,则会提示无权访问。

无权访问

总结

《从零打造项目》系列的文章并不是一口气就写完了的,有些知识点也是边学边用,比如 SpringSecurity 安全框架,虽然之前简单学过,但仅限于皮毛,底层逻辑不了解,更无法独自造轮子。关于 SpringSecurity 的学习其实远不止这些内容,想要继续学习推荐大家阅读《深入浅出 Spring Security》,或者作者在网上发布的一系列文章

本文贴合实际应用,详细介绍了如何自定义认证和授权逻辑,测试代码基本满足一个简单项目的需求。至此,关于 SpringSecurity 的学习暂时到此为止,目前掌握的内容差不多可以满足项目需求,所以接下来我会继续完成商城项目的开发。

参考文献

SpringSecurity系列 之 AuthenticationEntryPoint接口及其实现类的用法

Spring Security 实战干货:自定义异常处理

spring security中自定义AccessDeniedHandler不生效的实验记录

OncePerRequestFilter的作用

Spring Security教程(八):用户认证流程源码详解

Spring Security 认证流程源码详解

spring boot security 授权--自定义AccessDecisionManager和AccessDecisionVoter