SpringSecurity整合JWT

1、前言

  最近負責支付寶小程序後端項目設計,這裏主要分享一下用戶會話、接口鑑權的設計。參考過微信小程序後端的設計,會話須要依靠redis。相關的開發人員和我說依靠Redis並非很靠譜,redis在業務高峯期不穩定,容易出現問題,總會出現用戶會話丟失、超時的問題。以前聽過JWT相關的設計,決定嘗試一下。前端

2、什麼是JWT

  JSON Web Token(JWT)是一個開放標準(RFC 7519),它定義了一種緊湊且獨立的方式,用於在各方之間做爲JSON對象安全地傳輸信息。此信息能夠經過數字簽名進行驗證和信任。JWT可使用祕密(使用HMAC算法)或使用RSA或ECDSA的公鑰/私鑰對進行簽名。雖然JWT能夠加密以在各方之間提供保密,但咱們將專一於簽名令牌。簽名令牌能夠驗證其中包含的聲明的完整性,而加密令牌則隱藏其餘方的聲明。當使用公鑰/私鑰對簽名令牌時,簽名還證實只有持有私鑰的一方是簽署它的一方。web

  更多參考:Introduction to JSON Web Tokensredis

3、JWT優點

  JWT支持多種方式的信息加密,驗證時並不須要依賴緩存。支持存儲用戶非敏感信息、超時、刷新等操做,JWT由前端在用戶發送請求時自動放入header中,能夠有效避免CSRF攻擊,用來維護服務端和用戶會話再好也不過了。算法

4、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

5、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的區別segmentfault

  • 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微信小程序

6、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

7、自定義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中;

8、自定義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側率,避免測試接口沒必要要的麻煩;

 9、測試結果

  

  

  

10、總結

  這一次後端鑑權模塊的設計也是屬於本身的一次突破吧,先後端的聯調沒有出現太大的岔子。最終順利的上線了!!!另外分享一下在閱讀spring security源碼時的收穫:AutowireBeanFactoryObjectPostProcessor。對,沒錯,就是這個對象後置處理器。若是你閱讀了spring security的源碼,你會發現不少對象,好比WebSecurity、ProviderManager、各個安全Filter等,這些對象的建立並非經過bean定義的形式被容器發現和註冊進入spring容器的,而是直接new出來的。AutowireBeanFactoryObjectPostProcessor這個工具類可使這些對象具備容器bean一樣的生命週期,也能注入相應的依賴,從而進入準備好被使用的狀態。參考Spring Security Config 5.1.2 源碼解析 -- 工具類 AutowireBeanFactoryObjectPostProcessor緩存

相關文章
相關標籤/搜索