一、前言
最近負責支付寶小程序后端項目設計,這里主要分享一下用戶會話、接口鑒權的設計。參考過微信小程序后端的設計,會話需要依靠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。