在 Spring 授權伺服器中使用帶有 PKCE 的單頁應用程式進行身份驗證
一、簡介
在本教程中,我們將討論 OAuth 2.0 公共客戶端的程式碼交換證明金鑰 (PKCE) 的使用。
2. 背景
OAuth 2.0 公共用戶端(例如單頁應用程式 (SPA) 或利用Authorization Code Grant行動應用程式)容易受到授權代碼攔截攻擊。如果用戶端與伺服器之間的通訊發生在不安全的網路上,則惡意攻擊者可能會攔截來自授權端點的授權程式碼。
如果攻擊者可以存取授權代碼,則可以使用它來取得存取權杖。一旦攻擊者擁有存取令牌,它就可以像合法應用程式使用者一樣存取受保護的應用程式資源,從而嚴重損害應用程式。例如,如果存取權杖與金融應用程式相關聯,則攻擊者可能會獲得對敏感應用程式資訊的存取權。
2.1. OAuth程式碼攔截攻擊
在本節中,讓我們討論 Oauth 授權代碼攔截攻擊是如何發生的:
上圖展示了惡意攻擊者如何濫用授權碼取得存取權杖的流程:
- 合法的 OAuth 應用程式使用其 Web 瀏覽器以及所有必要的詳細資訊啟動 OAuth 授權請求流程
- Web瀏覽器將請求傳送到授權伺服器
- 授權伺服器將授權碼傳回瀏覽器
- 在此階段,如果通訊發生在不安全的通道上,惡意使用者可能會存取授權程式碼
- 惡意使用者交換授權碼授權以從授權伺服器取得存取權令牌
- 由於授權有效,授權伺服器向惡意應用程式發出存取權杖。惡意應用程式可以濫用存取權杖來代表合法應用程式存取受保護的資源
程式碼交換的證明金鑰是 OAuth 框架的擴展,旨在減輕這種攻擊。
3. PKCE 與 OAuth
PKCE 擴充功能包括 OAuth 授權代碼授予流程的以下附加步驟:
- 客戶端應用程式在初始授權請求中傳送兩個附加參數
code_challenge和code_challenge_method - 客戶端在下一步中發送
code_verifier,同時交換授權碼以獲得存取令牌
首先,啟用 PKCE 的客戶端應用程式選擇一個動態建立的加密隨機金鑰,稱為code_verifier 。此code_verifier對於每個授權請求都是唯一的。根據PKCE 規範, code_verifier值的長度必須在 43 到 128 個八位元組之間。
此外, code_verifier只能包含字母數字 ASCII 字元和一些允許的符號。其次,使用支援的code_challenge_method將code_verifier轉換為code_challenge 。目前支援的轉換方法有plain和S256 。 plain是無操作轉換,並使code_challange值與code_verifier相同。 S256方法先產生code_verifier的SHA-256哈希,然後對哈希值執行Base64編碼。
3.1.防止OAuth程式碼攔截攻擊
下圖演示了 PKCE 擴充功能如何防止存取權杖被盜:
- 合法的 OAuth 應用程式使用其 Web 瀏覽器以及所有必要的詳細資訊以及**
code_challenge和code_challenge_method參數來啟動 OAuth 授權請求流。** - Web 瀏覽器將請求傳送到授權伺服器並儲存客戶端應用程式的
code_challenge和code_challenge_method - 授權伺服器將授權碼傳回瀏覽器
- 在此階段,如果通訊發生在不安全的通道上,惡意使用者可能會存取授權程式碼
- 惡意使用者嘗試交換授權碼授權以從授權伺服器取得存取權杖。然而,惡意使用者不知道需要與請求一起發送的
code_verifier。授權伺服器拒絕惡意應用程式的存取權杖請求 - 合法應用程式提供
code_verifier以及授權來取得存取權杖。授權伺服器根據提供的code_verifier和它先前根據授權代碼授予請求儲存的code_challenge_method計算code_challenge。它將計算出的code_challange與先前儲存的code_challenge相符。這些值始終匹配,並向客戶端頒發存取令牌 - 客戶端可以使用此存取權杖來存取應用程式資源
4. 附 Spring Security 的 PKCE
從版本 6.3 開始, Spring Security 支援 servlet 和反應式 Web 應用程式的 PKCE 。但是,預設並未啟用它,因為並非所有身分提供者都支援 PKCE 擴充。當用戶端在不受信任的環境(例如本機應用程式或基於 Web 瀏覽器的應用程式)中執行且client_secret為空或未提供且client-authentication-method設定為none時, PKCE 會自動用於公共客戶端。
4.1. Maven 配置
Spring授權伺服器支援PKCE擴充。因此,為 Spring 授權伺服器應用程式包含 PKCE 支援的簡單方法是包含spring-boot-starter-oauth2-authorization-server依賴項:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-authorization-server</artifactId>
<version>3.3.0</version>
</dependency>
4.2.註冊公眾客戶
接下來,讓我們透過在application.yml檔案中配置以下屬性來註冊公共單頁應用程式用戶端:
spring:
security:
oauth2:
authorizationserver:
client:
public-client:
registration:
client-id: "public-client"
client-authentication-methods:
- "none"
authorization-grant-types:
- "authorization_code"
redirect-uris:
- "http://127.0.0.1:3000/callback"
scopes:
- "openid"
- "profile"
- "email"
require-authorization-consent: true
require-proof-key: true
在上面的程式碼片段中,我們將client_id註冊為public-client並將client-authentication-methods註冊為none 。 require-authorization-consent要求最終使用者在成功身份驗證後提供額外的同意來存取設定檔和電子郵件範圍。 **require-proof-key**配置可防止 PKCE 降級攻擊。
啟用require-proof-key配置後,授權伺服器不允許任何在沒有code_challenge情況下繞過 PKCE 流的惡意嘗試。其餘配置是向授權伺服器註冊客戶端的標準配置。
4.3. Spring安全配置
接下來,讓我們定義授權伺服器的SecurityFileChain配置:
@Bean
@Order(1)
SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class)
.oidc(Customizer.withDefaults());
http.exceptionHandling((exceptions) -> exceptions.defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))
.oauth2ResourceServer((oauth2) -> oauth2.jwt(Customizer.withDefaults()));
return http.cors(Customizer.withDefaults())
.build();
}
在上面的配置中,我們首先應用授權伺服器的預設安全設定。然後,我們為 OIDC、CORS 和 Oauth2 資源伺服器套用 Spring 安全性預設設定。現在讓我們定義另一個SecurityFilterChain配置,該配置將應用於其他 HTTP 請求,例如登入頁面:
@Bean
@Order(2)
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests((authorize) -> authorize.anyRequest()
.authenticated())
.formLogin(Customizer.withDefaults());
return http.cors(Customizer.withDefaults())
.build();
}
在此範例中,我們使用一個非常簡單的 React 應用程式作為我們的公共用戶端。該應用程式在http://127.0.0.1:3000上運行。授權伺服器在不同的連接埠 9000 上運行。
@Bean
CorsConfigurationSource corsConfigurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
CorsConfiguration config = new CorsConfiguration();
config.addAllowedHeader("*");
config.addAllowedMethod("*");
config.addAllowedOrigin("http://127.0.0.1:3000");
config.setAllowCredentials(true);
source.registerCorsConfiguration("/**", config);
return source;
}
我們正在定義一個具有允許的來源、標頭、方法和其他配置的CorsConfigurationSource實例。請注意,在上面的設定中,我們使用 IP 位址 127.0.0.1 而不是localhost因為後者是不允許的。最後,讓我們定義一個UserDetailsService實例來定義授權伺服器中的使用者。
@Bean
UserDetailsService userDetailsService() {
PasswordEncoder passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
UserDetails userDetails = User.builder()
.username("john")
.password("password")
.passwordEncoder(passwordEncoder::encode)
.roles("USER")
.build();
return new InMemoryUserDetailsManager(userDetails);
}
透過上述配置,我們將能夠使用使用者名稱john和password作為密碼向授權伺服器進行身份驗證。
4.4.公共客戶申請
現在讓我們談談公共客戶端。出於演示目的,我們使用一個簡單的 React 應用程式作為單頁應用程式。此應用程式使用[oidc-client-ts](https://github.com/authts/oidc-client-ts)庫來提供客戶端 OIDC 和 OAuth2 支援。 SPA 應用程式配置有以下配置:
const pkceAuthConfig = {
authority: 'http://127.0.0.1:9000/',
client_id: 'public-client',
redirect_uri: 'http://127.0.0.1:3000/callback',
response_type: 'code',
scope: 'openid profile email',
post_logout_redirect_uri: 'http://127.0.0.1:3000/',
userinfo_endpoint: 'http://127.0.0.1:9000/userinfo',
response_mode: 'query',
code_challenge_method: 'S256',
};
export default pkceAuthConfig;
authority配置為 Spring 授權伺服器的位址,即http://127.0.0.1:9000 。程式碼質詢方法參數配置為S256。這些配置用於準備UserManager實例,稍後我們將使用該實例來呼叫授權伺服器。該應用程式有兩個端點 - 用於存取應用程式登陸頁面的「/」和處理來自授權伺服器的回調請求的「 callback 」端點:
import React, { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Login from './components/LoginHandler';
import CallbackHandler from './components/CallbackHandler';
import pkceAuthConfig from './pkceAuthConfig';
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
function App() {
const [authenticated, setAuthenticated] = useState(null);
const [userInfo, setUserInfo] = useState(null);
const userManager = new UserManager({
userStore: new WebStorageStateStore({ store: window.localStorage }),
...pkceAuthConfig,
});
function doAuthorize() {
userManager.signinRedirect({state: '6c2a55953db34a86b876e9e40ac2a202',});
}
useEffect(() => {
userManager.getUser().then((user) => {
if (user) {
setAuthenticated(true);
}
else {
setAuthenticated(false);
}
});
}, [userManager]);
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Login authentication={authenticated} handleLoginRequest={doAuthorize}/>}/>
<Route path="/callback"
element={<CallbackHandler
authenticated={authenticated}
setAuth={setAuthenticated}
userManager={userManager}
userInfo={userInfo}
setUserInfo={setUserInfo}/>}/>
</Routes>
</BrowserRouter>
);
}
export default App;
5. 測試
我們將使用啟用了 OIDC 用戶端支援的 React 應用程式來測試流程。要安裝所需的依賴項,我們需要從應用程式的根目錄執行npm install命令。然後,我們將使用npm start命令啟動應用程式。
5.1.取得授權碼授予申請
此用戶端應用程式執行以下兩個活動: 首先,造訪http://127.0.0.1:3000主頁會呈現登入頁面。這是 SPA 應用程式的登入頁面: 接下來,一旦我們繼續登錄,SPA 應用程式就會使用code_challenge和code_challenge_method呼叫 Spring Authorization 伺服器:
我們可以注意到向 Spring 授權伺服器http://127.0.0.1:9000發出的請求,其中包含以下參數:
http://127.0.0.1:9000/oauth2/authorize?
client_id=public-client&
redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fcallback&
response_type=code&
scope=openid+profile+email&
state=301b4ce8bdaf439990efd840bce1449b&
code_challenge=kjOAp0NLycB6pMChdB7nbL0oGG0IQ4664OwQYUegzF0&
code_challenge_method=S256&
response_mode=query
授權伺服器將請求重新導向至 Spring Security 登入頁面:
一旦我們提供登入憑證,授權請求同意額外的 Oauth 範圍設定檔和電子郵件。這是由於授權伺服器中將require-authorization-consent配置為 true 所致:
5.2.交換存取權杖的授權代碼
如果我們完成登錄,授權伺服器會傳回授權碼。隨後,SPA 向授權伺服器請求另一個 HTTP 以取得存取權杖。 SPA 提供在先前請求中獲得的授權碼以及code_challenge來取得access_token :
對於上述請求,Spring 授權伺服器使用存取權杖進行回應:
接下來,我們存取授權伺服器中的userinfo端點以存取使用者詳細資訊。我們提供帶有授權 HTTP 標頭的access_token作為承載令牌來存取此端點。該用戶資訊是從userinfo詳細資訊中列印出來的:
六,結論
在本文中,我們示範如何在具有 Spring 授權伺服器的單頁應用程式中使用 OAuth 2.0 PKCE 擴充功能。我們開始討論公共客戶端對 PKCE 的需求,並探討了 Spring 授權伺服器中使用 PKCE 流程的設定。最後,我們利用 React 應用程式來示範流程。所有原始碼均可在 GitHub 上取得。