• SpringSecurity整合JWT


    一、前言

      最近负责支付宝小程序后端项目设计,这里主要分享一下用户会话、接口鉴权的设计。参考过微信小程序后端的设计,会话需要依靠redis。相关的开发人员和我说依靠Redis并不是很靠谱,redis在业务高峰期不稳定,容易出现问题,总会出现用户会话丢失、超时的问题。之前听过JWT相关的设计,决定尝试一下。

    二、什么是JWT

      JSON Web Token(JWT)是一个开放标准(RFC 7519),它定义了一种紧凑且独立的方式,用于在各方之间作为JSON对象安全地传输信息。此信息可以通过数字签名进行验证和信任。JWT可以使用秘密(使用HMAC算法)或使用RSA或ECDSA的公钥/私钥对进行签名。虽然JWT可以加密以在各方之间提供保密,但我们将专注于签名令牌。签名令牌可以验证其中包含的声明的完整性,而加密令牌则隐藏其他方的声明。当使用公钥/私钥对签名令牌时,签名还证明只有持有私钥的一方是签署它的一方。

      更多参考:Introduction to JSON Web Tokens

    三、JWT优势

      JWT支持多种方式的信息加密,验证时并不需要依赖缓存。支持存储用户非敏感信息、超时、刷新等操作,JWT由前端在用户发送请求时自动放入header中,可以有效避免CSRF攻击,用来维护服务端和用户会话再好也不过了。

    四、JWT工具类

    public class JwtUtils {
    
        /**
         * 创建token
         *
         * @param claim  claim中为userId
         * @param secret 创建token密钥
         * @return token
         */
        public static String createToken(Map claim, String secret) {
            long expirationDate = AlipayServiceAppletConstants.EXPIRATION_DATE;
            LocalDateTime nowTime = LocalDateTime.now();
            return Jwts.builder().setClaims(claim)
                    .setSubject("AlipayApplet") //设置token主题
                    .setIssuedAt(localDateTimeToDate(nowTime)) //设置token发布时间
                    .setExpiration(getExpirationDate(nowTime, expirationDate)) // 设置token过期时间
                    .signWith(SignatureAlgorithm.HS512, secret)
                    .compact();
        }
    
        /**
         * 将LocalDateTime转换为Date
         *
         * @param localDateTime
         * @return Date
         */
        public static Date localDateTimeToDate(LocalDateTime localDateTime) {
            ZoneId zoneId = ZoneId.systemDefault();
            ZonedDateTime zdt = localDateTime.atZone(zoneId);
            return Date.from(zdt.toInstant());
        }
    
        /**
         * 获取token过期的时间
         *
         * @param createTime       token创建时间
         * @param calendarInterval token有效时间间隔
         * @return
         */
        public static Date getExpirationDate(LocalDateTime createTime, long calendarInterval) {
            LocalDateTime expirationDate = createTime.plus(calendarInterval, ChronoUnit.MINUTES);
            return localDateTimeToDate(expirationDate);
        }
    
    
        /**
         * JWT  解析token是否正确
         *
         * @param token
         * @return
         * @throws Exception
         */
        public static Claims parseToken(String token) throws ExpiredJwtException {
    
            Claims claims = Jwts.parser()
                    .setSigningKey(AlipayServiceAppletConstants.ALIPAY_APPLET_SECRET)
                    .parseClaimsJws(token)
                    .getBody();
    
            return claims;
    
        }
    
        /**
         * token 刷新:
         * 1.小于TIME_OUT直接通过;
         * 2.大于TIME_OUT 小于FORBID_REFRES_HTIME需要刷新;
         * 3.超过FORBID_REFRES_HTIME 直接返回禁用刷新;
         *
         * @param oldToken
         * @return
         */
        public static String refresh(String oldToken) {
            long tokenDurationTime = AlipayServiceAppletConstants.EXPIRATION_DATE;//token持续时间/分钟
            long tokenRefreshDurationTime = AlipayServiceAppletConstants.ALIPAY_APPLET_FORBID_REFRES_HTIME;//token允许刷新时间/分钟
    
            try {
                getExpirationDate(oldToken);
            } catch (ExpiredJwtException e) {
                try {
                    long expirationTime = TimeUnit.MINUTES.convert(e.getClaims().getExpiration().toInstant().getEpochSecond(), TimeUnit.SECONDS);
                    long nowTime = TimeUnit.MINUTES.convert(Instant.now().getEpochSecond(), TimeUnit.SECONDS);
                    long tokenTimeout = nowTime - expirationTime;
    
                    /*2.大于TIME_OUT 小于FORBID_REFRES_HTIME需要刷新*/
                    if (tokenTimeout >= tokenDurationTime && tokenTimeout <= tokenRefreshDurationTime) {
                        return createToken(e.getClaims(), AlipayServiceAppletConstants.ALIPAY_APPLET_SECRET);
                    }
                } catch (Exception ex) {
                    throw new RuntimeException("会话刷新异常...", ex);
                }
            }
            /*3.超过FORBID_REFRES_HTIME 直接返回禁用刷新*/
            throw new RuntimeException("会话不允许刷新...");
        }
    
        public static Date getExpirationDate(String token) throws ExpiredJwtException {
            Claims claims = parseToken(token);
            Date expiration = claims.getExpiration();
            return expiration;
        }
    
        public static String resolveUserId() {
            Assert.notNull(SecurityContextHolder.getContext().getAuthentication(), "授权信息不能为NULL.");
            Map<String, Object> userDetail = (Map<String, Object>) SecurityContextHolder.getContext().getAuthentication().getDetails();
            String userId = (String) userDetail.get("userId");
            return userId;
        }
    }

      JWT工具类主要功能:token生成、token刷新、token解析、根据token中的用户标识提取用户信息。

    五、Spring Security相关知识预热

      这个类定义了spring security内置的filter的优先级

    final class FilterComparator implements Comparator<Filter>, Serializable {
        private static final int STEP = 100;
        private Map<String, Integer> filterToOrder = new HashMap<String, Integer>();
    
        FilterComparator() {
            int order = 100;
            put(ChannelProcessingFilter.class, order);
            order += STEP;
            put(ConcurrentSessionFilter.class, order);
            order += STEP;
            put(WebAsyncManagerIntegrationFilter.class, order);
            order += STEP;
            put(SecurityContextPersistenceFilter.class, order);
            order += STEP;
            put(HeaderWriterFilter.class, order);
            order += STEP;
            put(CorsFilter.class, order);
            order += STEP;
            put(CsrfFilter.class, order);
            order += STEP;
            put(LogoutFilter.class, order);
            order += STEP;
            put(X509AuthenticationFilter.class, order);
            order += STEP;
            put(AbstractPreAuthenticatedProcessingFilter.class, order);
            order += STEP;
            filterToOrder.put("org.springframework.security.cas.web.CasAuthenticationFilter",
                    order);
            order += STEP;
            put(UsernamePasswordAuthenticationFilter.class, order);
            order += STEP;
            put(ConcurrentSessionFilter.class, order);
            order += STEP;
            filterToOrder.put(
                    "org.springframework.security.openid.OpenIDAuthenticationFilter", order);
            order += STEP;
            put(DefaultLoginPageGeneratingFilter.class, order);
            order += STEP;
            put(ConcurrentSessionFilter.class, order);
            order += STEP;
            put(DigestAuthenticationFilter.class, order);
            order += STEP;
            put(BasicAuthenticationFilter.class, order);
            order += STEP;
            put(RequestCacheAwareFilter.class, order);
            order += STEP;
            put(SecurityContextHolderAwareRequestFilter.class, order);
            order += STEP;
            put(JaasApiIntegrationFilter.class, order);
            order += STEP;
            put(RememberMeAuthenticationFilter.class, order);
            order += STEP;
            put(AnonymousAuthenticationFilter.class, order);
            order += STEP;
            put(SessionManagementFilter.class, order);
            order += STEP;
            put(ExceptionTranslationFilter.class, order);
            order += STEP;
            put(FilterSecurityInterceptor.class, order);
            order += STEP;
            put(SwitchUserFilter.class, order);
        }
    
        //......
    }

      Spring Security 的permitAll以及webIgnore的区别

    • web ignore比较适合配置前端相关的静态资源,它是完全绕过spring security的所有filter的;
    • 而permitAll,会给没有登录的用户适配一个AnonymousAuthenticationToken,设置到SecurityContextHolder,方便后面的filter可以统一处理authentication。
    • 参考链接:https://segmentfault.com/a/1190000012160850

      Spring Security Authentication (认证)原理

    • AuthenticationManager通过委托AuthenticationProvider来实现认证;
    • AuthenticationProvider会调用UserDetailsService拿到UserDetails对象并封装最终的 Authentication 对象放到SecurityContextHolder中;
    • SecurityContextHolder 是 Spring Security 最基础的对象,用于存储应用程序当前安全上下文的详细信息,这些信息后续会被用于授权;

      参考链接:https://www.jianshu.com/p/e8e0e366184e

    六、SpringSecurity基本配置

    @Configuration
    public class AlipayAppletSecurityConfig extends WebSecurityConfigurerAdapter {
    
        @Override
        public void configure(WebSecurity web) {
            web.ignoring().antMatchers("/alipay-applet/login");
            web.ignoring().antMatchers("/alipay-applet/ag");
            web.ignoring().regexMatchers("^(?!(/alipay-applet)).*$");
        }
    
        @Override
        protected void configure(AuthenticationManagerBuilder auth) {
            auth.authenticationProvider(new TokenAuthenticationProvider(new SecurityProviderManager()));
        }
    
        @Override
        protected void configure(HttpSecurity http) throws Exception {
            //禁用缓存
            http.headers().cacheControl();
            http.csrf().disable()
                    .authorizeRequests()
                    .antMatchers("/alipay-applet/**").authenticated()
                    .and()
                    .formLogin().disable() //不要UsernamePasswordAuthenticationFilter
                    .httpBasic().disable() //不要BasicAuthenticationFilter
                    .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                    .securityContext().and()
                    .anonymous().disable()
                    .servletApi();
    
            AuthenticationManager authenticationManager = authenticationManager();
            TokenAuthenticationFilter filter = new TokenAuthenticationFilter(authenticationManager);
            http.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);
        }
    
        @Bean
        public CorsFilter corsFilter() {
            //1.添加CORS配置信息
            CorsConfiguration config = new CorsConfiguration();
            //放行哪些原始域
            config.addAllowedOrigin("*");
            //是否发送Cookie信息
            config.setAllowCredentials(true);
            //放行哪些原始域(请求方式)
            config.addAllowedMethod("*");
            //放行哪些原始域(头部信息)
            config.addAllowedHeader("*");
            //暴漏刷新token的header
            config.addExposedHeader(AlipayAppletSecurityConstants.RFRESH_TOKEN_HEADER_NAME);
            //2.添加映射路径
            UrlBasedCorsConfigurationSource configSource = new UrlBasedCorsConfigurationSource();
            configSource.registerCorsConfiguration("/alipay-applet/**", config);
    
            //3.返回新的CorsFilter.
            return new CorsFilter(configSource);
        }
    }
    • web ignore配置:忽略非支付宝后端服务的请求、忽略用户登录的请求、忽略支付宝回调请求;
    • 添加自定义AuthenticationProvider;
    • 禁用缓存、不启用CSRF配置(因为是基于token认证,不用担心csrf攻击)、去掉UsernamePasswordAuthenticationFilter和BasicAuthenticationFilter、session策略为STATELESS、禁止匿名访问;
    • CORS设置(针对支付宝小程序后端服务),暴露指定的response header;
    • 添加自定义AuthenticationFilter

    七、自定义AuthenticationFilter

    class TokenAuthenticationFilter extends OncePerRequestFilter {
        private static Logger LOGGER = LoggerFactory.getLogger(TokenAuthenticationFilter.class);
    
        private final AuthenticationManager authenticationManager;
    
        public TokenAuthenticationFilter(AuthenticationManager authenticationManager) {
            this.authenticationManager = authenticationManager;
        }
    
        @Override
        protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                        FilterChain filterChain) throws ServletException {
            try {
                if (SecurityContextHolder.getContext().getAuthentication() != null) {
                    filterChain.doFilter(request, response);
                    //已经完成认证
                    return;
                }
    
                StatelessTokenAuthentication authentication = new StatelessTokenAuthentication(request, response);
                Authentication authResult = authenticationManager.authenticate(authentication);
                Assert.isTrue(authResult.isAuthenticated(), "Token is not authenticated!");
                SecurityContextHolder.getContext().setAuthentication(authResult);
                filterChain.doFilter(request, response);
            } catch (Exception e) {
                LOGGER.error("TokenAuthenticationFilter异常...", e);
                try {
                    WmhcomplexmsgcenterErrorHandler.handleCore(request, response, e);
                } catch (ServiceException ex) {
                    throw new ServletException(ex);
                }
            }
        }
    }
    • 通过SecurityContextHolder.getContext().getAuthentication() != null来判断当前请求是否已经被认证;
    • 构造需要认证的StatelessTokenAuthentication用户凭证信息;
    • 通过AuthenticationManager 验证用户凭证并
    • 返回认证后StatelessTokenAuthentication信息,并绑定到SecurityContextHolder中;

    八、自定义AuthenticationProvider

    class TokenAuthenticationProvider implements AuthenticationProvider {
    
        private final SecurityProviderManager providerManager;
    
        public TokenAuthenticationProvider(SecurityProviderManager providerManager) {
            this.providerManager = providerManager;
        }
    
        @Override
        public Authentication authenticate(Authentication authentication) throws AuthenticationException {
            StatelessTokenAuthentication tokenAuth = (StatelessTokenAuthentication) authentication;
            StatelessTokenAuthentication.Credentials credentials = (StatelessTokenAuthentication.Credentials) tokenAuth.getCredentials();
            //查找Token
            HttpServletRequest request = credentials.getRequest();
            try {
                return providerManager.parseToken(request);
            } catch (ExpiredJwtException e) {
                HttpServletResponse response = credentials.getResponse();
                try {
                    return providerManager.tryRefreshAndParseToken(request, response);
                } catch (Exception ex) {
                    throw new InternalAuthenticationServiceException("重新鉴权出错,请重新登陆...", ex);
                }
            } catch (Exception e) {
                throw new InternalAuthenticationServiceException("鉴权出错,请重新登陆...", e);
            }
        }
    
        @Override
        public boolean supports(Class<?> authentication) {
            return ClassUtils.isAssignable(StatelessTokenAuthentication.class, authentication);
        }
    }
    • 验证StatelessTokenAuthentication信息【解析JWT】;
    • JWT过期,在一定时间范围内,自动刷新JWT并写入response header中;
    class SecurityProviderManager {
        private static Logger LOGGER = LoggerFactory.getLogger(SecurityProviderManager.class);
    
        private static final String DEFAULT_TOKEN = "ALIPAY#APPLET_DEFAULT#TOKEN[1qa2ws3ed!@#$%^]";
    
        private String resolveToken(HttpServletRequest request) {
            String token = request.getHeader(AlipayAppletSecurityConstants.TOKEN_HEADER_NAME);
            if (StringUtils.isBlank(token)) {
                throw new TokenNotFoundException("找不到Token, header name is " + AlipayAppletSecurityConstants.TOKEN_HEADER_NAME);
            }
            return token;
        }
    
        public Authentication parseToken(HttpServletRequest request) {
            String token = this.resolveToken(request);
    
            Object userDetail;
            try {
                if (!(token.startsWith(DEFAULT_TOKEN) && (userDetail = parseDefaultToken(token)) != null)) {
                    userDetail = JwtUtils.parseToken(token);
                }
            } catch (ExpiredJwtException e) {
                throw e;
            } catch (Exception e) {
                throw new IllegalStateException(String.format("token解析异常..., token=%s", token), e);
            }
    
            if (null == userDetail) {
                throw new IllegalStateException("用户对象不能为null! token=" + token);
            }
            return new StatelessTokenAuthentication(userDetail);
        }
    
        public Authentication tryRefreshAndParseToken(HttpServletRequest request, HttpServletResponse response) {
            String token = this.resolveToken(request);
    
            String refreshToken;
            try {
                refreshToken = JwtUtils.refresh(token);
            } catch (Exception e) {
                throw new IllegalStateException("token刷新异常... token=" + token, e);
            }
    
            Object userDetail;
            try {
                userDetail = JwtUtils.parseToken(refreshToken);
            } catch (Exception e) {
                throw new IllegalStateException("token解析异常..., refresh_token=" + refreshToken, e);
            }
    
            if (null == userDetail) {
                throw new IllegalStateException("用户对象不能为null! refresh_token=" + refreshToken);
            }
    
            response.addHeader(AlipayAppletSecurityConstants.RFRESH_TOKEN_HEADER_NAME, refreshToken);
            return new StatelessTokenAuthentication(userDetail);
        }
    
        private static Object parseDefaultToken(String token) {
            String[] session = token.split(":");
            if (session.length == 2) {
                LOGGER.info("alipay applet default token info is " + token);
                return new HashMap<String, Object>() {
                    {
                        put("userId", session[1]);
                    }
                };
            } else {
                LOGGER.error(String.format("alipay applet default token= %s 不合法", token));
            }
            return null;
        }
    }
    • 解析JWT,获取用户信息;
    • 刷新JWT,通知前端,保证会话不会断开;
    • 默认Token侧率,避免测试接口不必要的麻烦;

     九、测试结果

      

      

      

    十、总结

      这一次后端鉴权模块的设计也是属于自己的一次突破吧,前后端的联调没有出现太大的岔子。最终顺利的上线了!!!另外分享一下在阅读spring security源码时的收获:AutowireBeanFactoryObjectPostProcessor。对,没错,就是这个对象后置处理器。如果你阅读了spring security的源码,你会发现很多对象,比如WebSecurity、ProviderManager、各个安全Filter等,这些对象的创建并不是通过bean定义的形式被容器发现和注册进入spring容器的,而是直接new出来的。AutowireBeanFactoryObjectPostProcessor这个工具类可以使这些对象具有容器bean同样的生命周期,也能注入相应的依赖,从而进入准备好被使用的状态。参考Spring Security Config 5.1.2 源码解析 -- 工具类 AutowireBeanFactoryObjectPostProcessor

  • 相关阅读:
    寒假补习记录_4
    寒假补习记录_3
    寒假补习记录_2
    寒假补习记录_1
    公文流转系统编程
    Javaweb编程
    转:在静态方法中访问类的实例成员
    Java字段初始化规律
    原码,反码,补码浅谈
    java第二节课课后
  • 原文地址:https://www.cnblogs.com/hujunzheng/p/10287250.html
Copyright © 2020-2023  润新知