移轉應用程式以搭配 Kafka 的 Azure 事件中樞 使用無密碼連線
本文說明如何從傳統驗證方法遷移至使用 Kafka Azure 事件中樞 更安全、無密碼連線。
必須驗證對 Kafka Azure 事件中樞 的應用程式要求。 Azure 事件中樞 for Kafka 提供不同方式讓應用程式安全地連線。 其中一種方法是使用 連接字串。 不過,您應該盡可能在應用程式中排定無密碼連線的優先順序。
Spring Cloud Azure 4.3.0 之後支持無密碼連線。 本文是從 Spring Cloud Stream Kafka 應用程式移除認證的移轉指南。
比較驗證選項
當應用程式向 Kafka 的 Azure 事件中樞 進行驗證時,它會提供授權的實體來連線事件中樞命名空間。 Apache Kafka 通訊協定提供多個簡單驗證和安全性層 (SASL) 機制來進行驗證。 根據 SASL 機制,您可以使用兩個驗證選項來授權存取您的安全資源:Microsoft Entra 驗證和共用存取簽章 (SAS) 驗證。
Microsoft Entra 驗證
Microsoft Entra 驗證是一種機制,可使用Microsoft Entra 標識符中定義的身分識別,連線到 Kafka Azure 事件中樞。 透過Microsoft Entra 驗證,您可以在集中位置管理服務主體身分識別和其他 Microsoft 服務,以簡化許可權管理。
使用 Microsoft Entra ID 進行驗證可提供下列優點:
- 以統一的方式驗證跨 Azure 服務的使用者。
- 在單一位置管理密碼原則和密碼輪替。
- Microsoft Entra ID 支援多種形式的驗證,這可以消除儲存密碼的需求。
- 客戶可以使用外部 (Microsoft Entra ID) 群組來管理事件中樞許可權。
- 針對連線至 Kafka Azure 事件中樞 的應用程式,支援令牌型驗證。
SAS 驗證
事件中樞也提供共用存取簽章(SAS),以委派存取 Kafka 資源的事件中樞。
雖然可以使用 SAS 連線到 Kafka Azure 事件中樞,但請務必謹慎使用。 您必須勤奮地不要在不安全的位置公開 連接字串。 任何獲得 連接字串 存取權的人都可以進行驗證。 例如,如果不小心將 連接字串 簽入原始檔控制、透過不安全的電子郵件傳送、貼入錯誤的聊天,或由不應該擁有許可權的人員檢視,惡意使用者可能會存取應用程式。 相反地,使用 OAuth 2.0 令牌型機制來授權存取,可提供優於 SAS 的安全性和易於使用。 請考慮更新您的應用程式以使用無密碼連線。
無密碼連線簡介
透過無密碼連線,您可以連線到 Azure 服務,而不需將任何認證儲存在應用程式程式代碼、其組態檔或環境變數中。
許多 Azure 服務都支援無密碼連線,例如透過 Azure 受控識別。 這些技術提供強固的安全性功能,您可以從 Azure 身分識別用戶端連結庫使用 DefaultAzureCredential 來實作。 在本教學課程中,您將瞭解如何更新現有的應用程式以使用DefaultAzureCredential
,而不是 連接字串 之類的替代方案。
DefaultAzureCredential
支援多個驗證方法,並在執行階段自動判斷應該使用何者。 此方法可讓您的應用程式在不同的環境中 (本機開發或實際執行環境) 使用不同的驗證方法,而不需要實作環境特有的程式碼。
您可以在 Azure 身分識別連結庫概觀中找到搜尋認證的順序和位置DefaultAzureCredential
。 例如,在本機工作時, DefaultAzureCredential
通常會使用開發人員用來登入Visual Studio的帳戶進行驗證。 當應用程式部署至 Azure 時, DefaultAzureCredential
會自動切換為使用 受控識別。 此轉移不需要變更程式碼。
若要確保連線是無密碼的,您必須同時考慮本機開發和生產環境。 如果任一位置都需要 連接字串,則應用程式不是無密碼的。
在本機開發環境中,您可以使用適用於 Visual Studio Code 或 IntelliJ 的 Azure CLI、Azure PowerShell、Visual Studio 或 Azure 外掛程式進行驗證。 在此情況下,您可以在應用程式中使用該認證,而不是設定屬性。
當您將應用程式部署至 Azure 主控環境,例如虛擬機時,您可以在該環境中指派受控識別。 然後,您不需要提供認證來連線到 Azure 服務。
注意
受控識別提供安全性身分識別來代表應用程式或服務。 身分識別由 Azure 平台負責管理,因此您不需要佈建或輪替任何密碼。 您可以在概觀文件中深入了解受控識別。
移轉現有的應用程式以使用無密碼連線
下列步驟說明如何將現有的應用程式移轉至使用無密碼連線,而不是 SAS 解決方案。
0) 準備工作環境以進行本機開發驗證
首先,使用下列命令來設定一些環境變數。
export AZ_RESOURCE_GROUP=<YOUR_RESOURCE_GROUP>
export AZ_EVENTHUBS_NAMESPACE_NAME=<YOUR_EVENTHUBS_NAMESPACE_NAME>
export AZ_EVENTHUB_NAME=<YOUR_EVENTHUB_NAME>
將預留位置取代為下列值,本文通篇都將使用這些值:
<YOUR_RESOURCE_GROUP>
:您將使用之資源組名。<YOUR_EVENTHUBS_NAMESPACE_NAME>
:您將使用的 Azure 事件中樞 命名空間名稱。<YOUR_EVENTHUB_NAME>
:您將使用的事件中樞名稱。
1) 授與 Azure 事件中樞 的許可權
如果您想要使用 Microsoft Entra 驗證在本機執行此範例,請確定您的使用者帳戶已透過適用於 IntelliJ 的 Azure 工具組、Visual Studio Code Azure 帳戶外掛程式或 Azure CLI 進行驗證。 此外,請確定帳戶已獲得足夠的許可權。
在 Azure 入口網站中,使用主要搜尋列或左側導覽找出您的事件中樞命名空間。
在 [事件中樞概觀] 頁面上,從左側功能表中選取 [訪問控制][IAM ]。
在 [存取控制 (IAM)] 頁面上,選取 [角色指派] 索引標籤。
從頂端功能表選取 [新增 ],然後 從產生的下拉功能表中新增角色指派 。
使用搜尋方塊,從結果篩選出所需的角色。 在此範例中,搜尋 Azure 事件中樞 數據傳送者和 Azure 事件中樞 數據接收者,然後選取相符的結果,然後選擇 [下一步]。
在 [指派存取權] 底下,選取 [使用者、群組或服務主體],然後選擇 [選取成員]。
在對話方塊中,搜尋 Microsoft Entra 使用者名稱 (通常是您的 user@domain 電子郵件地址),然後在對話方塊底部選擇 [選取]。
選取 [檢閱 + 指派] 以移至最終頁面,然後再次選取 [檢閱 + 指派] 以完成此程序。
如需授與存取角色的詳細資訊,請參閱 使用 Microsoft Entra ID 授權事件中樞資源的存取權。
2) 登入並移轉應用程式程式代碼以使用無密碼連線
針對本機開發,請確定您已使用您指派角色給事件中樞的相同Microsoft Entra 帳戶進行驗證。 您可以透過 Azure CLI、Visual Studio、Azure PowerShell 或其他工具 (例如 IntelliJ) 進行驗證。
使用下列命令透過 Azure CLI 登入 Azure:
az login
接下來,使用下列步驟來更新 Spring Kafka 應用程式以使用無密碼連線。 雖然在概念上很類似,但每個架構都會使用不同的實作詳細數據。
在您的專案中,開啟 pom.xml 檔案,並新增下列參考:
<dependency> <groupId>com.azure</groupId> <artifactId>azure-identity</artifactId> <version>1.6.0</version> </dependency>
移轉之後,請在您的專案中實 作 AuthenticationCallbackHandler 和 OAuthBearerToken 以進行 OAuth2 驗證,如下列範例所示。
public class KafkaOAuth2AuthenticateCallbackHandler implements AuthenticateCallbackHandler { private static final Duration ACCESS_TOKEN_REQUEST_BLOCK_TIME = Duration.ofSeconds(30); private static final String TOKEN_AUDIENCE_FORMAT = "%s://%s/.default"; private Function<TokenCredential, Mono<OAuthBearerTokenImp>> resolveToken; private final TokenCredential credential = new DefaultAzureCredentialBuilder().build(); @Override public void configure(Map<String, ?> configs, String mechanism, List<AppConfigurationEntry> jaasConfigEntries) { TokenRequestContext request = buildTokenRequestContext(configs); this.resolveToken = tokenCredential -> tokenCredential.getToken(request).map(OAuthBearerTokenImp::new); } private TokenRequestContext buildTokenRequestContext(Map<String, ?> configs) { URI uri = buildEventHubsServerUri(configs); String tokenAudience = buildTokenAudience(uri); TokenRequestContext request = new TokenRequestContext(); request.addScopes(tokenAudience); return request; } @SuppressWarnings("unchecked") private URI buildEventHubsServerUri(Map<String, ?> configs) { String bootstrapServer = Arrays.asList(configs.get(BOOTSTRAP_SERVERS_CONFIG)).get(0).toString(); bootstrapServer = bootstrapServer.replaceAll("\\[|\\]", ""); URI uri = URI.create("https://" + bootstrapServer); return uri; } private String buildTokenAudience(URI uri) { return String.format(TOKEN_AUDIENCE_FORMAT, uri.getScheme(), uri.getHost()); } @Override public void handle(Callback[] callbacks) throws UnsupportedCallbackException { for (Callback callback : callbacks) { if (callback instanceof OAuthBearerTokenCallback) { OAuthBearerTokenCallback oauthCallback = (OAuthBearerTokenCallback) callback; this.resolveToken .apply(credential) .doOnNext(oauthCallback::token) .doOnError(throwable -> oauthCallback.error("invalid_grant", throwable.getMessage(), null)) .block(ACCESS_TOKEN_REQUEST_BLOCK_TIME); } else { throw new UnsupportedCallbackException(callback); } } } @Override public void close() { // NOOP } }
public class OAuthBearerTokenImp implements OAuthBearerToken { private final AccessToken accessToken; private final JWTClaimsSet claims; public OAuthBearerTokenImp(AccessToken accessToken) { this.accessToken = accessToken; try { claims = JWTParser.parse(accessToken.getToken()).getJWTClaimsSet(); } catch (ParseException exception) { throw new SaslAuthenticationException("Unable to parse the access token", exception); } } @Override public String value() { return accessToken.getToken(); } @Override public Long startTimeMs() { return claims.getIssueTime().getTime(); } @Override public long lifetimeMs() { return claims.getExpirationTime().getTime(); } @Override public Set<String> scope() { // Referring to https://docs.microsoft.com/azure/active-directory/develop/access-tokens#payload-claims, the scp // claim is a String, which is presented as a space separated list. return Optional.ofNullable(claims.getClaim("scp")) .map(s -> Arrays.stream(((String) s) .split(" ")) .collect(Collectors.toSet())) .orElse(null); } @Override public String principalName() { return (String) claims.getClaim("upn"); } public boolean isExpired() { return accessToken.isExpired(); } }
當您建立 Kafka 產生者或取用者時,請新增支援 SASL/OAUTHBEARER 機制所需的設定。 下列範例顯示您的程式代碼在移轉前後的外觀。 在這兩個範例中,以事件中
<eventhubs-namespace>
樞命名空間的名稱取代 佔位符。在移轉之前,您的程式代碼看起來應該像下列範例:
Properties properties = new Properties(); properties.put(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, "<eventhubs-namespace>.servicebus.windows.net:9093"); properties.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "SASL_SSL"); properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName()); properties.put(SaslConfigs.SASL_MECHANISM, "PLAIN"); properties.put(SaslConfigs.SASL_JAAS_CONFIG, String.format("org.apache.kafka.common.security.plain.PlainLoginModule required username=\"$ConnectionString\" password=\"%s\";", connectionString)); return new KafkaProducer<>(properties);
移轉之後,您的程式代碼看起來應該像下列範例。 在此範例中,請將
<path-to-your-KafkaOAuth2AuthenticateCallbackHandler>
佔位元取代為您實KafkaOAuth2AuthenticateCallbackHandler
作 的完整類別名稱。Properties properties = new Properties(); properties.put(CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, "<eventhubs-namespace>.servicebus.windows.net:9093"); properties.put(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, "SASL_SSL"); properties.put(SaslConfigs.SASL_MECHANISM, "OAUTHBEARER"); properties.put(SaslConfigs.SASL_JAAS_CONFIG, "org.apache.kafka.common.security.oauthbearer.OAuthBearerLoginModule required"); properties.put(SaslConfigs.SASL_LOGIN_CALLBACK_HANDLER_CLASS, "<path-to-your-KafkaOAuth2AuthenticateCallbackHandler>"); return new KafkaProducer<>(properties);
在本機執行應用程式
完成這些程式碼變更之後,請在本機執行您的應用程式。 假設您已登入相容的 IDE 或命令行工具,例如 Azure CLI、Visual Studio 或 IntelliJ,新的組態應該會挑選您的本機認證。 您在 Azure 中指派給本機開發人員使用者的角色,可讓您的應用程式在本機連線到 Azure 服務。
3) 設定 Azure 裝載環境
將應用程式設定為使用無密碼連線並在本機執行之後,相同的程式代碼可以在部署至 Azure 之後向 Azure 服務進行驗證。 例如,部署至已指派受控識別的 Azure Spring Apps 實例的應用程式可以連線到 Kafka 的 Azure 事件中樞。
在本節中,您將執行兩個步驟,讓應用程式以無密碼的方式在 Azure 主控環境中執行:
- 為您的 Azure 裝載環境指派受控識別。
- 將角色指派給受控識別。
注意
Azure 也提供 服務連接器,可協助您將主控服務與事件中樞連線。 使用服務連接器來設定裝載環境,您可以省略將角色指派給受控識別的步驟,因為 Service Connector 會為您執行此作業。 下一節說明如何以兩種方式設定 Azure 主控環境:一種是透過 Service Connector,另一種是直接設定每個裝載環境。
重要
服務連接器的命令需要 Azure CLI 2.41.0 或更高版本。
為您的 Azure 裝載環境指派受控識別
下列步驟說明如何為各種 Web 主機服務指派系統指派的受控識別。 受控識別可以使用您先前設定的應用程式設定,安全地連線至其他 Azure 服務。
在 Azure App 服務 實例的主要概觀頁面上,從瀏覽窗格中選取 [身分識別]。
在 [ 系統指派] 索引 標籤上,請務必將 [ 狀態 ] 字段設定為 [開啟]。 系統指派的身分識別由 Azure 在內部管理,可替您處理管理工作。 身分識別的詳細資料和識別碼絕對不會在程式碼中公開。
您也可以使用 Azure CLI 在 Azure 主控環境中指派受控識別。
您可以使用 az webapp identity assign 命令,將受控識別指派給 Azure App 服務 實例,如下列範例所示。
export AZURE_MANAGED_IDENTITY_ID=$(az webapp identity assign \
--resource-group $AZ_RESOURCE_GROUP \
--name <app-service-name> \
--query principalId \
--output tsv)
將角色指派給受控識別
接下來,將許可權授與您建立的受控識別,以存取事件中樞命名空間。 您可以將角色指派給受控識別來授與許可權,就像您和本機開發用戶一樣。
如果您使用服務連接器連線服務,就不需要完成此步驟。 您已為您處理下列必要的群組態:
如果您在建立連線時選取受控識別,則會為您的應用程式建立系統指派的受控識別,並在事件中樞上指派 Azure 事件中樞 數據傳送者和 Azure 事件中樞 數據接收者角色。
如果您選擇使用 連接字串,連接字串 會新增為應用程式環境變數。
測試應用程式
完成這些程式碼變更之後,在瀏覽器中瀏覽至您的託管應用程式。 您的應用程式應該能夠順利連線到 Kafka 的 Azure 事件中樞。 請記住,角色指派可能需要幾分鐘才會傳播到整個 Azure 環境。 您的應用程式現在已設定為在本機和實際執行環境中執行,而不需要開發人員管理應用程式本身的秘密。
下一步
在本教學課程中,您已了解如何將應用程式移轉為無密碼連線。
您可以閱讀下列資源,以更深入探索本文所討論的概念: