REST 安全性與 JWT 使用 Java 和 Spring Security

已發表: 2022-03-11

安全

安全是便利的敵人,反之亦然。 這句話適用於任何系統,無論是虛擬的還是真實的,從實體房屋入口到網上銀行平台。 工程師一直在努力為給定的用例找到合適的平衡點,傾向於一側或另一側。 通常,當出現新的威脅時,我們會轉向安全而遠離便利。 然後,我們看看是否可以在不過多降低安全性的情況下恢復一些失去的便利。 而且,這種惡性循環會永遠持續下去。

spring security 教程:安全性與便利性圖解

安全是便利的敵人,反之亦然。
鳴叫

讓我們嘗試檢查一下今天的 REST 安全狀態,使用簡單的 Spring 安全教程來演示它的實際操作。

REST(代表 Representational State Transfer)服務最初是一種極其簡化的 Web 服務方法,它具有大量規範和繁瑣的格式,例如用於描述服務的 WSDL,或用於指定消息格式的 SOAP。 在 REST 中,我們沒有這些。 我們可以在純文本文件中描述 REST 服務,並使用我們想要的任何消息格式,例如 JSON、XML 甚至是純文本。 簡化的方法也適用於 REST 服務的安全性; 沒有定義的標準強加一種特定的方式來驗證用戶。

儘管 REST 服務沒有太多的規定,但一個重要的問題是缺少狀態。 這意味著服務器不保留任何客戶端狀態,會話就是一個很好的例子。 因此,服務器響應每個請求,就好像它是客戶端發出的第一個請求一樣。 然而,即使是現在,許多實現仍然使用基於 cookie 的身份驗證,它繼承自標準網站架構設計。 REST 的無狀態方法使得會話 cookie 從安全角度來看是不合適的,但儘管如此,它們仍然被廣泛使用。 除了忽略所需的無國籍狀態外,簡化方法是一種預期的安全權衡。 與用於 Web 服務的 WS-Security 標準相比,創建和使用 REST 服務要容易得多,因此便利性達到了頂峰。 權衡是相當渺茫的安全性。 會話劫持和跨站請求偽造 (XSRF) 是最常見的安全問題。

在試圖擺脫服務器的客戶端會話時,偶爾會使用一些其他方法,例如基本或摘要 HTTP 身份驗證。 兩者都使用Authorization標頭來傳輸用戶憑據,並添加了一些編碼(HTTP Basic)或加密(HTTP Digest)。 當然,它們也存在與網站相同的缺陷:必須通過 HTTPS 使用 HTTP Basic,因為用戶名和密碼以易於可逆的 base64 編碼發送,而 HTTP Digest 強制使用已證明不安全的過時 MD5 散列。

最後,一些實現使用任意令牌來驗證客戶端。 這個選項似乎是我們目前最好的。 如果實施得當,它可以解決 HTTP Basic、HTTP Digest 或會話 cookie 的所有安全問題,使用簡單,並且遵循無狀態模式。

然而,對於這樣的任意令牌,幾乎沒有涉及標準。 每個服務提供商都有自己的想法,知道在令牌中放入什麼,以及如何對其進行編碼或加密。 使用來自不同提供商的服務需要額外的設置時間,只是為了適應所使用的特定令牌格式。 另一方面,其他方法(會話 cookie、HTTP Basic 和 HTTP Digest)為開發人員所熟知,幾乎所有設備上的所有瀏覽器都可以開箱即用地使用它們。 框架和語言已經為這些方法做好了準備,具有內置函數可以無縫地處理每個方法。

智威湯遜認證

JWT(從 JSON Web Token 縮寫)是使用令牌在 Web 上進行身份驗證的一般標準缺失的標準,不僅適用於 REST 服務。 目前,它處於 RFC 7519 的草案狀態。它很健壯,可以承載大量信息,但儘管它的大小相對較小,但使用起來仍然很簡單。 與任何其他令牌一樣,JWT 可用於在身份提供者和服務提供者(不一定是相同的系統)之間傳遞經過身份驗證的用戶的身份。 它還可以攜帶用戶的所有聲明,例如授權數據,因此服務提供者不需要進入數據庫或外部系統來驗證每個請求的用戶角色和權限; 該數據是從令牌中提取的。

以下是 JWT 安全設計的工作方式:

JWT java流程圖解

  • 客戶端通過將其憑據發送給身份提供者來登錄。
  • 身份提供者驗證憑據; 如果一切正常,它會檢索用戶數據,生成包含將用於訪問服務的用戶詳細信息和權限的 JWT,並在 JWT 上設置過期時間(可能無限制)。
  • 身份提供者簽名,如果需要,加密 JWT 並將其發送給客戶端,作為對帶有憑據的初始請求的響應。
  • 客戶端將 JWT 存儲有限或無限的時間,具體取決於身份提供者設置的到期時間。
  • 客戶端將存儲的 JWT 發送到服務提供商的每個請求的 Authorization 標頭中。
  • 對於每個請求,服務提供者從Authorization標頭中獲取 JWT 並對其進行解密,如果需要,驗證簽名,如果一切正常,則提取用戶數據和權限。 僅基於此數據,並且無需在數據庫中查找更多詳細信息或聯繫身份提供者,它就可以接受或拒絕客戶請求。 唯一的要求是身份和服務提供者有一個加密協議,以便服務可以驗證簽名甚至解密哪個身份被加密。

此流程提供了極大的靈活性,同時仍保持事物的安全性和易於開發。 通過使用這種方法,很容易將新的服務器節點添加到服務提供者集群中,初始化它們時只有驗證簽名和解密令牌的能力,方法是為它們提供一個共享的密鑰。 不需要會話複製、數據庫同步或節點間通信。 充分發揮 REST 的作用。

JWT 與其他​​任意令牌的主要區別在於令牌內容的標準化。 另一種推薦的方法是使用 Bearer 方案在Authorization標頭中發送 JWT 令牌。 標頭的內容應如下所示:

 Authorization: Bearer <token>

REST 安全實施

為了使 REST 服務按預期工作,與經典的多頁面網站相比,我們需要一種稍微不同的授權方法。

當客戶端請求安全資源時,REST 服務器不會通過重定向到登錄頁面來觸發身份驗證過程,而是使用請求本身中可用的數據(在本例中為 JWT 令牌)對所有請求進行身份驗證。 如果此類身份驗證失敗,則重定向將毫無意義。 REST API 只是發送一個 HTTP 代碼 401(未經授權)響應,客戶端應該知道該做什麼; 例如,瀏覽器將顯示一個動態 div 以允許用戶提供用戶名和密碼。

另一方面,在經典的多頁面網站中成功驗證後,用戶通過使用 HTTP 代碼 301(永久移動)重定向到主頁,或者更好的是,用戶最初請求觸發的頁面認證過程。 使用 REST,這又是沒有意義的。 相反,我們將繼續執行請求,就好像資源根本沒有受到保護一樣,返回 HTTP 代碼 200(OK)和預期的響應正文。

Spring 安全示例

使用 JWT 和 Java 的 Spring REST 安全性

現在,讓我們看看如何使用 Java 和 Spring 實現基於 JWT 令牌的 REST API,同時盡可能地重用 Spring Security 默認行為。

正如預期的那樣,Spring Security 框架附帶了許多處理“舊”授權機制的現成插件類:會話 cookie、HTTP Basic 和 HTTP Digest。 但是,它缺乏對 JWT 的原生支持,我們需要親自動手才能使其工作。 有關更詳細的概述,您應該查閱 Spring Security 官方文檔。

現在,讓我們開始使用web.xml中通常的Spring Security 過濾器定義

 <filter> <filter-name>springSecurityFilterChain</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> </filter> <filter-mapping> <filter-name>springSecurityFilterChain</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

請注意,Spring Security 過濾器的名稱必須完全是springSecurityFilterChain才能使 Spring 配置的其餘部分開箱即用。

接下來是與安全性相關的 Spring bean 的 XML 聲明。 為了簡化 XML,我們將通過將xmlns="http://www.springframework.org/schema/security"添加到根 XML 元素來將默認命名空間設置為security 。 XML 的其餘部分如下所示:

 <global-method-security pre-post-annotations="enabled" /> (1) <http pattern="/api/login" security="none"/> (2) <http pattern="/api/signup" security="none"/> <http pattern="/api/**" entry-point-ref="restAuthenticationEntryPoint" create-session="stateless"> (3) <csrf disabled="true"/> (4) <custom-filter before="FORM_LOGIN_FILTER" ref="jwtAuthenticationFilter"/> (5) </http> <beans:bean class="com.toptal.travelplanner.security.JwtAuthenticationFilter"> (6) <beans:property name="authenticationManager" ref="authenticationManager" /> <beans:property name="authenticationSuccessHandler" ref="jwtAuthenticationSuccessHandler" /> (7) </beans:bean> <authentication-manager alias="authenticationManager"> <authentication-provider ref="jwtAuthenticationProvider" /> (8) </authentication-manager>
  • (1) 在這一行中,我們在上下文中的任何 spring bean 上激活@PreFilter@PreAuthorize@PostFilter@PostAuthorize註釋。
  • (2) 我們定義登錄和註冊端點以跳過安全性; 即便是“匿名者”也應該能夠做到這兩個操作。
  • (3) 接下來,我們定義應用於所有請求的過濾器鏈,同時添加兩個重要配置:入口點引用和將會話創建設置為stateless (我們不希望出於安全目的而創建會話,因為我們為每個請求使用令牌) .
  • (4) 我們不需要csrf保護,因為我們的代幣不受它的影響。
  • (5) 接下來,我們在 Spring 的預定義過濾器鏈中插入我們的特殊身份驗證過濾器,就在表單登錄過濾器之前。
  • (6) 這個bean是我們的認證過濾器的聲明; 因為它擴展了 Spring 的AbstractAuthenticationProcessingFilter ,我們需要在 XML 中聲明它來連接它的屬性(自動連接在這裡不起作用)。 稍後我們將解釋過濾器的作用。
  • (7) AbstractAuthenticationProcessingFilter的默認成功處理程序對於 REST 用途來說不夠好,因為它將用戶重定向到成功頁面; 這就是我們在這裡設置自己的原因。
  • (8) 我們的過濾器使用authenticationManager創建的提供者的聲明來對用戶進行身份驗證。

現在讓我們看看我們如何實現上面 XML 中聲明的特定類。 請注意,Spring 將為我們連接它們。 我們從最簡單的開始。

RestAuthenticationEntryPoint.java

 public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { // This is invoked when user tries to access a secured REST resource without supplying any credentials // We should just send a 401 Unauthorized response because there is no 'login page' to redirect to response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized"); } }

如上所述,當身份驗證失敗時,此類僅返回 HTTP 代碼 401(未授權),覆蓋默認的 Spring 重定向。

JwtAuthenticationSuccessHandler.java

 public class JwtAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { // We do not need to do anything extra on REST authentication success, because there is no page to redirect to } }

這個簡單的覆蓋刪除了成功身份驗證的默認行為(重定向到主頁或用戶請求的任何其他頁面)。 如果你想知道為什麼我們不需要重寫AuthenticationFailureHandler ,那是因為如果沒有設置重定向 URL,默認實現不會重定向到任何地方,所以我們只是避免設置 URL,這已經足夠了。

JwtAuthenticationFilter.java

 public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter { public JwtAuthenticationFilter() { super("/**"); } @Override protected boolean requiresAuthentication(HttpServletRequest request, HttpServletResponse response) { return true; } @Override public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { String header = request.getHeader("Authorization"); if (header == null || !header.startsWith("Bearer ")) { throw new JwtTokenMissingException("No JWT token found in request headers"); } String authToken = header.substring(7); JwtAuthenticationToken authRequest = new JwtAuthenticationToken(authToken); return getAuthenticationManager().authenticate(authRequest); } @Override protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { super.successfulAuthentication(request, response, chain, authResult); // As this authentication is in HTTP header, after success we need to continue the request normally // and return the response as if the resource was not secured at all chain.doFilter(request, response); } }

這個類是我們JWT認證過程的入口點; 過濾器從請求標頭中提取 JWT 令牌,並將身份驗證委託給注入的AuthenticationManager 。 如果未找到令牌,則會引發異常以停止處理請求。 我們還需要一個覆蓋來成功驗證,因為默認的 Spring 流將停止過濾器鏈並繼續進行重定向。 請記住,如上所述,我們需要鏈完全執行,包括生成響應。

JwtAuthenticationProvider.java

 public class JwtAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider { @Autowired private JwtUtil jwtUtil; @Override public boolean supports(Class<?> authentication) { return (JwtAuthenticationToken.class.isAssignableFrom(authentication)); } @Override protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { } @Override protected UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException { JwtAuthenticationToken jwtAuthenticationToken = (JwtAuthenticationToken) authentication; String token = jwtAuthenticationToken.getToken(); User parsedUser = jwtUtil.parseToken(token); if (parsedUser == null) { throw new JwtTokenMalformedException("JWT token is not valid"); } List<GrantedAuthority> authorityList = AuthorityUtils.commaSeparatedStringToAuthorityList(parsedUser.getRole()); return new AuthenticatedUser(parsedUser.getId(), parsedUser.getUsername(), token, authorityList); } }

在這個類中,我們使用 Spring 的默認AuthenticationManager ,但我們使用自己的AuthenticationProvider注入它來執行實際的身份驗證過程。 為了實現這一點,我們擴展了AbstractUserDetailsAuthenticationProvider ,它只要求我們根據身份驗證請求返回UserDetails ,在我們的例子中,是包裝在JwtAuthenticationToken類中的 JWT 令牌。 如果令牌無效,我們會拋出異常。 但是,如果它是有效的並且JwtUtil解密成功,我們將提取用戶詳細信息(我們將在JwtUtil類中看到確切的方式),而根本不訪問數據庫。 有關用戶的所有信息,包括他或她的角色,都包含在令牌本身中。

JwtUtil.java

 public class JwtUtil { @Value("${jwt.secret}") private String secret; /** * Tries to parse specified String as a JWT token. If successful, returns User object with username, id and role prefilled (extracted from token). * If unsuccessful (token is invalid or not containing all required user properties), simply returns null. * * @param token the JWT token to parse * @return the User object extracted from specified token or null if a token is invalid. */ public User parseToken(String token) { try { Claims body = Jwts.parser() .setSigningKey(secret) .parseClaimsJws(token) .getBody(); User u = new User(); u.setUsername(body.getSubject()); u.setId(Long.parseLong((String) body.get("userId"))); u.setRole((String) body.get("role")); return u; } catch (JwtException | ClassCastException e) { return null; } } /** * Generates a JWT token containing username as subject, and userId and role as additional claims. These properties are taken from the specified * User object. Tokens validity is infinite. * * @param u the user for which the token will be generated * @return the JWT token */ public String generateToken(User u) { Claims claims = Jwts.claims().setSubject(u.getUsername()); claims.put("userId", u.getId() + ""); claims.put("role", u.getRole()); return Jwts.builder() .setClaims(claims) .signWith(SignatureAlgorithm.HS512, secret) .compact(); } }

最後, JwtUtil類負責將令牌解析為User對象並從User對像生成令牌。 這很簡單,因為它使用jjwt庫來完成所有 JWT 工作。 在我們的示例中,我們只是將用戶名、用戶 ID 和用戶角色存儲在令牌中。 我們還可以存儲更多任意內容並添加更多安全功能,例如令牌的過期時間。 如上所示,在AuthenticationProvider中使用了對令牌的解析。 generateToken()方法是從登錄和註冊 REST 服務調用的,這些服務是不安全的,不會觸發任何安全檢查,也不會要求請求中存在令牌。 最後,它根據用戶生成將返回給客戶端的令牌。

結論

儘管舊的標準化安全方法(會話 cookie、HTTP Basic 和 HTTP Digest)也適用於 REST 服務,但它們都存在問題,如果使用更好的標準可以很好地避免這些問題。 JWT 及時出現以挽救局面,最重要的是,它非常接近成為 IETF 標準。

JWT 的主要優勢是以無狀態且因此可擴展的方式處理用戶身份驗證,同時使用最新的加密標準確保一切安全。 在令牌本身中存儲聲明(用戶角色和權限)在分佈式系統架構中創造了巨大的好處,其中發出請求的服務器無法訪問身份驗證數據源。