Spring Security+JWT(二)

一、JWT

1.1 是什么?为什么要使用它?

互联网服务器离不开用户认证,一般流程是下面这样。

1、用户向服务器发送用户名和密码。

2、服务器验证通过后,在当前对话(session)里面保存相关数据,比如用户角色、登录时间等等。

3、服务器向用户返回一个 session_id,写入用户的 Cookie。

4、用户随后的每一次请求,都会通过 Cookie,将 session_id 传回服务器。

5、服务器收到 session_id,找到前期保存的数据,由此得知用户的身份。

  这种模式的问题在于,扩展性(scaling)不好。单机当然没有问题,如果是服务器集群,或者是跨域的服务导向架构,就要求 session 数据共享,每台服务器都能够读取 session。

  举例来说,A 网站和 B 网站是同一家公司的关联服务。现在要求,用户只要在其中一个网站登录,再访问另一个网站就会自动登录,请问怎么实现?

  一种解决方案是 session 数据持久化,写入数据库或别的持久层。各种服务收到请求后,都向持久层请求数据。这种方案的优点是架构清晰,缺点是工程量比较大。另外,持久层万一挂了,就会单点失败。

  另一种方案是服务器索性不保存 session 数据了,所有数据都保存在客户端,每次请求都发回服务器。JWT 就是这种方案的一个代表。

  JSON Web Token(JWT)是一个非常轻巧的规范。这个规范允许我们使用JWT在用户和服务器之间传递安全可靠的信息 。

扫描二维码关注公众号,回复: 9422974 查看本文章

1.2 JWT的结构

JWT包含了使用.分隔的三部分:

  • Header 头部

    1. 声明类型,这里是JWT

    2. 加密算法,自定义

  • Payload 载荷,就是有效数据,在官方文档中(RFC7519),这里给了7个示例信息:

    1. iss (issuer):表示签发人

    2. exp (expiration time):表示token过期时间

    3. sub (subject):主题

    4. aud (audience):受众

    5. nbf (Not Before):生效时间

    6. iat (Issued At):签发时间

    7. jti (JWT ID):编号

  • Signature 签名

  是整个数据的认证信息。一般根据前两步的数据,再加上服务的的密钥secret(密钥保存在服务端,不能泄露给客户端),通过Header中配置的加密算法生成。用于验证整个数据完整和可靠性。

1.3 JWT工作流程

下面是一个JWT的工作流程图。模拟一下实际的流程是这样的(假设受保护的API在/protected中)

1.用户导航到登录页,输入用户名、密码,进行登录
2.服务器验证登录鉴权,如果用户合法,根据用户的信息和服务器的规则生成JWT Token
3.服务器将该token以json形式返回(不一定要json形式,这里说的是一种常见的做法)
4.用户得到token,存在localStorage、cookie或其它数据存储形式中。
5.以后用户请求/protected中的API时,在请求的header中加入 Authorization: Bearer xxxx(token)。此处注意token之前有一个7字符长度的 Bearer
6.服务器端对此token进行检验,如果合法就解析其中内容,根据其拥有的权限和自己的业务逻辑给出对应的响应结果。
7.用户取得结果

1.4 JWT 的几个特点

(1)JWT 默认是不加密,但也是可以加密的。生成原始 Token 以后,可以用密钥再加密一次。

(2)JWT 不加密的情况下,不能将秘密数据写入 JWT。

(3)JWT 不仅可以用于认证,也可以用于交换信息。有效使用 JWT,可以降低服务器查询数据库的次数。

(4)JWT 的最大缺点是,由于服务器不保存 session 状态,因此无法在使用过程中废止某个 token,或者更改 token 的权限。也就是说,一旦 JWT 签发了,在到期之前就会始终有效,除非服务器部署额外的逻辑。

(5)JWT 本身包含了认证信息,一旦泄露,任何人都可以获得该令牌的所有权限。为了减少盗用,JWT 的有效期应该设置得比较短。对于一些比较重要的权限,使用时应该再次对用户进行认证。

(6)为了减少盗用,JWT 不应该使用 HTTP 协议明码传输,要使用 HTTPS 协议传输。

1.5 JWT存在的问题

JWT 也不是天衣无缝,由客户端维护登录状态带来的一些问题在这里依然存在,举例如下:

  1. 续签问题,这是被很多人诟病的问题之一,传统的cookie+session的方案天然的支持续签,但是jwt由于服务端不保存用户状态,因此很难完美解决续签问题,如果引入redis,虽然可以解决问题,但是jwt也变得不伦不类了。

  2. 注销问题,由于服务端不再保存用户信息,所以一般可以通过修改secret来实现注销,服务端secret修改后,已经颁发的未过期的token就会认证失败,进而实现注销,不过毕竟没有传统的注销方便。

  3. 密码重置,密码重置后,原本的token依然可以访问系统,这时候也需要强制修改secret。

  4. 基于第2点和第3点,一般建议不同用户取不同secret。

二、SpringSecurity

Spring Security 是为基于Spring的应用程序提供声明式安全保护的安全性框架。

一般来说,Web 应用的安全性包括用户认证Authentication)和用户授权Authorization两个部分。

  用户认证:指的是验证某个用户是否为系统中的合法主体,也就是说用户能否访问该系统。用户认证一般要求用户提供用户名和密码。系统通过校验用户名和密码来完成认证过程。

  用户授权:指的是验证某个用户是否有权限执行某个操作。在一个系统中,不同用户所具有的权限是不同的。比如对一个文件来说,有的用户只能进行读取,而有的用户可以进行修改。一般来说,系统会为不同的用户分配不同的角色,而每个角色则对应一系列的权限。

  对于上面提到的两种应用情景,Spring Security 框架都有很好的支持。

三、案例

3.1 导入依赖

<!--jwt-->
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-api</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-impl</artifactId>
            <version>${jjwt.version}</version>
        </dependency>
        <dependency>
            <groupId>io.jsonwebtoken</groupId>
            <artifactId>jjwt-jackson</artifactId>
            <version>${jjwt.version}</version>
        </dependency>

3.2 配置application.properties

JWT的主要配置

#jwt
jwt:
  header: Authorization
  # 令牌前缀
  token-start-with: Bearer
  # 必须使用最少88位的Base64对该令牌进行编码
  base64-secret: ZmQ0ZGI5NjQ0MDQwY2I4MjMxY2Y3ZmI3MjdhN2ZmMjNhODViOTg1ZGE0NTBjMGM4NDA5NzYxMjdjOWMwYWRmZTBlZjlhNGY3ZTg4Y2U3YTE1ODVkZDU5Y2Y3OGYwZWE1NzUzNWQ2YjFjZDc0NGMxZWU2MmQ3MjY1NzJmNTE0MzI=
  # 令牌过期时间 此处单位/毫秒 ,默认4小时,可在此网站生成 https://www.convertworld.com/zh-hans/time/milliseconds.html
  token-validity-in-seconds: 14400000
  # 在线用户key
  online-key: online-token
  # 验证码
  code-key: code-key

3.3 实体类VO

JwtUser、AuthUse(登录信息)

/**
 * JWT封装VO
 */
@Getter
@AllArgsConstructor
public class JwtUser implements UserDetails {

    private final Long id;

    private final String username;

    private final String nickName;

    private final String sex;

    @JsonIgnore
    private final String password;

    private final String avatar;

    private final String email;

    private final String phone;

    private final String dept;

    private final String job;

    @JsonIgnore
    private final Collection<GrantedAuthority> authorities;

    private final boolean enabled;

    private Timestamp createTime;

    @JsonIgnore
    private final Date lastPasswordResetDate;

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

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

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

    @JsonIgnore
    @Override
    public String getPassword() {
        return password;
    }

    @Override
    public boolean isEnabled() {
        return enabled;
    }

    public Collection getRoles() {
        return authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.toSet());
    }
}
/**
 * 登录用户VO
 */
@Getter
@Setter
public class AuthUser {

    @NotBlank
    private String username;

    @NotBlank
    private String password;

    private String code;

    private String uuid = "";

    @Override
    public String toString() {
        return "{username=" + username  + ", password= ******}";
    }
}

3.4 权限配置类

 JwtAccessDeniedHandler:处理认证过的用户访问无权限资源

JwtAuthenticationEntryPoint:用来解决匿名用户访问无权限资源时的异常

TokenProvider:用于生成令牌,验证等等一些操作

TokenFilter:Jwt 过滤器,验证令牌token是否合法

/**
 * 认证过的用户访问无权限资源
 */
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

   @Override
   public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException {
      //当用户在没有授权的情况下访问受保护的REST资源时,将调用此方法发送403 Forbidden响应
      response.sendError(HttpServletResponse.SC_FORBIDDEN, accessDeniedException.getMessage());
   }
}
/**
 * 用来解决匿名用户访问无权限资源时的异常
 * 禁止(代替默认)弹出登录页面,返回错误信息
 */
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint,Serializable {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException authException) throws IOException {
        // 当用户尝试访问安全的REST资源而不提供任何凭据时,将调用此方法发送401 响应
        response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException==null?"Unauthorized":authException.getMessage());
    }
}
/**
 * 用于生成令牌,验证等等一些操作
 */
@Slf4j
@Component
public class TokenProvider implements InitializingBean {

   private final SecurityProperties properties;
   private static final String AUTHORITIES_KEY = "auth";
   private Key key;

   public TokenProvider(SecurityProperties properties) {
      this.properties = properties;
   }


   @Override
   public void afterPropertiesSet() {
      byte[] keyBytes = Decoders.BASE64.decode(properties.getBase64Secret());
      this.key = Keys.hmacShaKeyFor(keyBytes);
   }

   public String createToken(Authentication authentication) {
      String authorities = authentication.getAuthorities().stream()
         .map(GrantedAuthority::getAuthority)
         .collect(Collectors.joining(","));

      long now = (new Date()).getTime();
      Date validity = new Date(now + properties.getTokenValidityInSeconds());

      return Jwts.builder()
              //主题
         .setSubject(authentication.getName())
              //自定义属性 放入用户拥有的权限
         .claim(AUTHORITIES_KEY, authorities)
              //签名算法和密钥
         .signWith(key, SignatureAlgorithm.HS512)
              //失效时间
         .setExpiration(validity)
         .compact();
   }

   Authentication getAuthentication(String token) {
      //解析token
      Claims claims = Jwts.parser()
         .setSigningKey(key)
         .parseClaimsJws(token)
         .getBody();

      Collection<? extends GrantedAuthority> authorities =
         Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(","))
            .map(SimpleGrantedAuthority::new)
            .collect(Collectors.toList());

      User principal = new User(claims.getSubject(), "", authorities);

      return new UsernamePasswordAuthenticationToken(principal, token, authorities);
   }

   boolean validateToken(String authToken) {
      try {
         Jwts.parser().setSigningKey(key).parseClaimsJws(authToken);
         return true;
      } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) {
         log.info("Invalid JWT signature.");
         e.printStackTrace();
      } catch (ExpiredJwtException e) {
         log.info("Expired JWT token.");
         e.printStackTrace();
      } catch (UnsupportedJwtException e) {
         log.info("Unsupported JWT token.");
         e.printStackTrace();
      } catch (IllegalArgumentException e) {
         log.info("JWT token compact of handler are invalid.");
         e.printStackTrace();
      }
      return false;
   }

   public String getToken(HttpServletRequest request){
      final String requestHeader = request.getHeader(properties.getHeader());
      if (requestHeader != null && requestHeader.startsWith(properties.getTokenStartWith())) {
         return requestHeader.substring(7);
      }
      return null;
   }
}
/**
 * Jwt 过滤器
 * 验证令牌token是否合法
 */
@Slf4j
public class TokenFilter extends GenericFilterBean {

   private final TokenProvider tokenProvider;

   TokenFilter(TokenProvider tokenProvider) {
      this.tokenProvider = tokenProvider;
   }

   @Override
   public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
      throws IOException, ServletException {
      HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
      String token = resolveToken(httpServletRequest);
      String requestRri = httpServletRequest.getRequestURI();
      // 验证 token 是否存在
      OnlineUser onlineUser = null;
      try {
         SecurityProperties properties = SpringContextHolder.getBean(SecurityProperties.class);
         OnlineUserService onlineUserService = SpringContextHolder.getBean(OnlineUserService.class);
         onlineUser = onlineUserService.getOne(properties.getOnlineKey() + token);
      } catch (ExpiredJwtException e) {
         log.error(e.getMessage());
      }
      if (onlineUser != null && StringUtils.hasText(token) && tokenProvider.validateToken(token)) {
         Authentication authentication = tokenProvider.getAuthentication(token);
         SecurityContextHolder.getContext().setAuthentication(authentication);
         log.debug("set Authentication to security context for '{}', uri: {}", authentication.getName(), requestRri);
      } else {
         log.debug("no valid JWT token found, uri: {}", requestRri);
      }
      filterChain.doFilter(servletRequest, servletResponse);
   }

   private String resolveToken(HttpServletRequest request) {
      SecurityProperties properties = SpringContextHolder.getBean(SecurityProperties.class);
      String bearerToken = request.getHeader(properties.getHeader());
      if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(properties.getTokenStartWith())) {
         return bearerToken.substring(7);
      }
      return null;
   }
}

3.5 配置类和用户服务类

UserDetailsServiceImpl:处理用户信息

SecurityProperties:配置文件获取类

SecurityConfig:主配置类

@Service("userDetailsService")
@Transactional(propagation = Propagation.SUPPORTS, readOnly = true, rollbackFor = Exception.class)
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserService userService;

    private final RoleService roleService;

    public UserDetailsServiceImpl(UserService userService, RoleService roleService) {
        this.userService = userService;
        this.roleService = roleService;
    }

    @Override
    public UserDetails loadUserByUsername(String username){
        UserDto user = userService.findByName(username);
        if (user == null) {
            throw new BadRequestException("账号不存在");
        } else {
            if (!user.getEnabled()) {
                throw new BadRequestException("账号未激活");
            }
            return createJwtUser(user);
        }
    }

    private UserDetails createJwtUser(UserDto user) {
        return new JwtUser(
                user.getId(),
                user.getUsername(),
                user.getNickName(),
                user.getSex(),
                user.getPassword(),
                user.getAvatar(),
                user.getEmail(),
                user.getPhone(),
                Optional.ofNullable(user.getDept()).map(DeptSmallDto::getName).orElse(null),
                Optional.ofNullable(user.getJob()).map(JobSmallDto::getName).orElse(null),
                roleService.mapToGrantedAuthorities(user),
                user.getEnabled(),
                user.getCreateTime(),
                user.getLastPasswordResetTime()
        );
    }
}
/**
 * Jwt参数配置
 */
@Data
@Configuration
@ConfigurationProperties(prefix = "jwt")
public class SecurityProperties {

    /** Request Headers : Authorization */
    private String header;

    /** 令牌前缀,最后留个空格 Bearer */
    private String tokenStartWith;

    /** 必须使用最少88位的Base64对该令牌进行编码 */
    private String base64Secret;

    /** 令牌过期时间 此处单位/毫秒 */
    private Long tokenValidityInSeconds;

    /** 在线用户 key,根据 key 查询 redis 中在线用户的数据 */
    private String onlineKey;

    /** 验证码 key */
    private String codeKey;

    public String getTokenStartWith() {
        return tokenStartWith + " ";
    }
}
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private final TokenProvider tokenProvider;
    private final CorsFilter corsFilter;
    private final JwtAuthenticationEntryPoint authenticationErrorHandler;
    private final JwtAccessDeniedHandler jwtAccessDeniedHandler;
    private final ApplicationContext applicationContext;

    public SecurityConfig(TokenProvider tokenProvider, CorsFilter corsFilter, JwtAuthenticationEntryPoint authenticationErrorHandler, JwtAccessDeniedHandler jwtAccessDeniedHandler, ApplicationContext applicationContext) {
        this.tokenProvider = tokenProvider;
        this.corsFilter = corsFilter;
        this.authenticationErrorHandler = authenticationErrorHandler;
        this.jwtAccessDeniedHandler = jwtAccessDeniedHandler;
        this.applicationContext = applicationContext;
    }

    @Bean
    GrantedAuthorityDefaults grantedAuthorityDefaults() {
        // 去除 ROLE_ 前缀
        return new GrantedAuthorityDefaults("");
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        // 密码加密方式
        return new BCryptPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception {
        // 搜寻匿名标记 url: @AnonymousAccess
        Map<RequestMappingInfo, HandlerMethod> handlerMethodMap = applicationContext.getBean(RequestMappingHandlerMapping.class).getHandlerMethods();
        Set<String> anonymousUrls = new HashSet<>();
        for (Map.Entry<RequestMappingInfo, HandlerMethod> infoEntry : handlerMethodMap.entrySet()) {
            HandlerMethod handlerMethod = infoEntry.getValue();
            AnonymousAccess anonymousAccess = handlerMethod.getMethodAnnotation(AnonymousAccess.class);
            if (null != anonymousAccess) {
                anonymousUrls.addAll(infoEntry.getKey().getPatternsCondition().getPatterns());
            }
        }
        httpSecurity
                // 禁用 CSRF
                .csrf().disable()
                .addFilterBefore(corsFilter, UsernamePasswordAuthenticationFilter.class)
                // 授权异常
                .exceptionHandling()
                .authenticationEntryPoint(authenticationErrorHandler)
                .accessDeniedHandler(jwtAccessDeniedHandler)

                // 防止iframe 造成跨域
                .and()
                .headers()
                .frameOptions()
                .disable()

                // 不创建会话
                .and()
                .sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                .authorizeRequests()
                // 静态资源等等
                .antMatchers(
                        HttpMethod.GET,
                        "/*.html",
                        "/**/*.html",
                        "/**/*.css",
                        "/**/*.js",
                        "/webSocket/**"
                ).permitAll()
                // swagger 文档
                .antMatchers("/swagger-ui.html").permitAll()
                .antMatchers("/swagger-resources/**").permitAll()
                .antMatchers("/webjars/**").permitAll()
                .antMatchers("/*/api-docs").permitAll()
                // 文件
                .antMatchers("/avatar/**").permitAll()
                .antMatchers("/file/**").permitAll()
                // 阿里巴巴 druid
                .antMatchers("/druid/**").permitAll()
                // 放行OPTIONS请求
                .antMatchers(HttpMethod.OPTIONS, "/**").permitAll()
                // 自定义匿名访问所有url放行 : 允许匿名和带权限以及登录用户访问
                .antMatchers(anonymousUrls.toArray(new String[0])).permitAll()
                // 所有请求都需要认证
                .anyRequest().authenticated()
                .and().apply(securityConfigurerAdapter());
    }

    private TokenConfigurer securityConfigurerAdapter() {
        return new TokenConfigurer(tokenProvider);
    }
}

番外

  有两个和 JWT 相关的过滤器配置:

  1. 登录请求拦截器:UsernamePasswordAuthenticationFilter

    在用户的登录的过滤器中校验用户是否登录成功,如果登录成功,则生成一个token返回给客户端,登录失败则给前端一个登录失败的提示。

  2. token验证拦截器:BasicAuthenticationFilter 继承 OncePerRequestFilter 继承 GenericFilterBean。 自定义拦截器,继承这三个任意一个。

    第二个过滤器则是当其他请求发送来,校验token的过滤器,如果校验成功,就让请求继续执行。

Spring Security远不止这些,还需继续学习!!!

猜你喜欢

转载自www.cnblogs.com/FondWang/p/12367891.html