前言
在某些範例清單中,原本應該顯示在一行的內容,無法符合可用的頁面寬度。這些行已經被分開。行尾的 '\' 表示為了符合頁面而導入了分行,後續行會縮排。所以
Let's pretend to have an extremely \
long line that \
does not fit
This one is short
實際上是
Let's pretend to have an extremely long line that does not fit
This one is short
管理 REST API
Keycloak 隨附功能完整的管理 REST API,其中包含管理控制台提供的所有功能。
若要呼叫 API,您需要取得具有適當權限的存取權杖。所需的權限在伺服器管理指南中說明。
您可以使用 Keycloak 為您的應用程式啟用身份驗證來取得權杖;請參閱保護應用程式和服務指南。您也可以使用直接存取授與來取得存取權杖。
使用 CURL 的範例
使用使用者名稱和密碼進行身份驗證
以下範例假設您在 master 領域中建立了使用者 admin ,密碼為 password ,如入門指南教學課程所示。 |
-
使用使用者名稱
admin
和密碼password
,在master
領域中取得使用者的存取權杖curl \ -d "client_id=admin-cli" \ -d "username=admin" \ -d "password=password" \ -d "grant_type=password" \ "https://127.0.0.1:8080/realms/master/protocol/openid-connect/token"
依預設,此權杖會在 1 分鐘後過期 結果將會是一個 JSON 文件。
-
擷取
access_token
屬性的值來呼叫您需要的 API。 -
將該值包含在傳送至 API 的請求的
Authorization
標頭中,來呼叫 API。以下範例示範如何取得 master 領域的詳細資訊
curl \ -H "Authorization: bearer eyJhbGciOiJSUz..." \ "https://127.0.0.1:8080/admin/realms/master"
使用服務帳戶進行身份驗證
若要使用 client_id
和 client_secret
向管理 REST API 進行身份驗證,請執行此程序。
-
請確保用戶端設定如下
-
client_id
是屬於 master 領域的 機密 用戶端 -
client_id
已啟用Service Accounts Enabled
選項 -
client_id
具有自訂的「Audience」對應器-
包含的用戶端目標對象:
security-admin-console
-
-
-
檢查
client_id
是否在「服務帳戶角色」標籤中已獲指派「admin」角色。
curl \
-d "client_id=<YOUR_CLIENT_ID>" \
-d "client_secret=<YOUR_CLIENT_SECRET>" \
-d "grant_type=client_credentials" \
"https://127.0.0.1:8080/realms/master/protocol/openid-connect/token"
主題
Keycloak 提供網頁和電子郵件的主題支援。這允許自訂面向終端使用者的頁面的外觀和風格,使其能夠與您的應用程式整合。

設定主題
除了歡迎頁面以外,所有主題類型都是透過管理控制台設定的。
-
登入管理控制台。
-
從左上角的下拉式方塊中選取您的領域。
-
按一下選單中的 領域設定。
-
按一下 主題 索引標籤。
若要設定 master
管理控制台的主題,您需要為master
領域設定管理控制台主題。 -
若要查看對管理控制台的變更,請重新整理頁面。
-
使用
spi-theme-welcome-theme
選項來變更歡迎主題。 -
例如
bin/kc.[sh|bat] start --spi-theme-welcome-theme=custom-theme
預設主題
Keycloak 隨附伺服器發佈內 keycloak-themes-26.0.5.jar
JAR 檔案中的預設主題。伺服器的根目錄 themes
預設不包含任何主題,但它包含一個 README 檔案,其中包含關於預設主題的其他詳細資訊。為了簡化升級,請勿直接編輯隨附的主題。請改為建立您自己的主題,擴充其中一個隨附的主題。
建立主題
主題包含
-
HTML 範本 (Freemarker 範本)
-
影像
-
訊息套件
-
樣式表
-
指令碼
-
主題屬性
除非您計劃替換每一個頁面,否則您應該擴充另一個佈景主題。最有可能的情況是您會想要擴充某些現有的佈景主題。或者,如果您打算提供自己的管理或帳戶控制台實作,請考慮擴充 base
佈景主題。base
佈景主題包含一個訊息包,因此這類實作需要從頭開始,包括實作主要的 index.ftl
Freemarker 範本,但它可以利用訊息包中現有的翻譯。
擴充佈景主題時,您可以覆寫個別資源(範本、樣式表等)。如果您決定覆寫 HTML 範本,請記住,當您升級到新版本時,您可能需要更新您的自訂範本。
在建立佈景主題時,最好停用快取,這樣就可以直接從 themes
目錄編輯佈景主題資源,而無需重新啟動 Keycloak。
-
使用以下選項執行 Keycloak
bin/kc.[sh|bat] start --spi-theme-static-max-age=-1 --spi-theme-cache-themes=false --spi-theme-cache-templates=false
-
在
themes
目錄中建立一個目錄。目錄的名稱會成為佈景主題的名稱。例如,若要建立名為
mytheme
的佈景主題,請建立目錄themes/mytheme
。 -
在佈景主題目錄內,為您的佈景主題將要提供的每種類型建立一個目錄。
例如,若要將登入類型新增至
mytheme
佈景主題,請建立目錄themes/mytheme/login
。 -
對於每種類型,建立一個檔案
theme.properties
,允許設定佈景主題的一些組態。例如,若要將佈景主題
themes/mytheme/login
設定為擴充base
佈景主題並匯入一些常見資源,請使用以下內容建立檔案themes/mytheme/login/theme.properties
parent=base import=common/keycloak
您現在已建立一個支援登入類型的佈景主題。
-
登入管理控制台以查看您的新佈景主題
-
選取您的領域
-
按一下選單中的 領域設定。
-
按一下 佈景主題 標籤。
-
針對 登入佈景主題 選取 mytheme 並按一下 儲存。
-
開啟領域的登入頁面。
您可以透過您的應用程式登入,或開啟帳戶控制台 (
/realms/{realm name}/account
) 來執行此操作。 -
若要查看變更父佈景主題的效果,請在
theme.properties
中設定parent=keycloak
並重新整理登入頁面。
請務必在生產環境中重新啟用快取,因為它會顯著影響效能。 |
如果您想手動刪除佈景主題快取的內容,您可以刪除伺服器發行版的 |
佈景主題屬性
佈景主題屬性設定在佈景主題目錄中的檔案 <佈景主題類型>/theme.properties
中。
-
parent - 要擴充的父佈景主題
-
import - 從另一個佈景主題匯入資源
-
common - 覆寫常見資源路徑。預設值為未指定時的
common/keycloak
。此值會用作${url.resourcesCommonPath}
後綴的值,該值通常用於 Freemarker 範本中 (${url.resoucesCommonPath}
值的前綴是佈景主題根 URI)。 -
styles - 要包含的樣式(以空格分隔)清單
-
locales - 支援的地區設定(以逗號分隔)清單
有一些屬性可以用來變更用於某些元素類型的 CSS 類別。如需這些屬性的清單,請查看 Keycloak 佈景主題的對應類型中的 theme.properties 檔案 (themes/keycloak/<佈景主題類型>/theme.properties
)。
您也可以新增自己的自訂屬性,並從自訂範本中使用它們。
執行此操作時,您可以使用以下格式來取代系統屬性或環境變數
-
${some.system.property}
- 用於系統屬性 -
${env.ENV_VAR}
- 用於環境變數。
如果找不到系統屬性或環境變數,也可以使用 ${foo:defaultValue}
提供預設值。
如果未提供預設值,且沒有對應的系統屬性或環境變數,則不會取代任何內容,且您最終會在範本中使用該格式。 |
以下是可以實現的範例
javaVersion=${java.version}
unixHome=${env.HOME:Unix home not found}
windowsHome=${env.HOMEPATH:Windows home not found}
將樣式表新增至佈景主題
您可以將一個或多個樣式表新增至佈景主題。
-
在您的佈景主題的
<佈景主題類型>/resources/css
目錄中建立一個檔案。 -
將此檔案新增至
theme.properties
中的styles
屬性。例如,若要將
styles.css
新增至mytheme
,請使用以下內容建立themes/mytheme/login/resources/css/styles.css
.login-pf body { background: DimGrey none; }
-
編輯
themes/mytheme/login/theme.properties
並新增styles=css/styles.css
-
若要查看變更,請開啟您領域的登入頁面。
您會注意到,唯一套用的樣式來自您的自訂樣式表。
-
若要包含父佈景主題的樣式,請從該佈景主題載入樣式。編輯
themes/mytheme/login/theme.properties
並將styles
變更為styles=css/login.css css/styles.css
若要覆寫父樣式表的樣式,請確保您的樣式表列在最後。
將指令碼新增至佈景主題
您可以將一個或多個指令碼新增至佈景主題。
-
在您的佈景主題的
<佈景主題類型>/resources/js
目錄中建立一個檔案。 -
將該檔案新增至
theme.properties
中的scripts
屬性。例如,若要將
script.js
新增至mytheme
,請使用以下內容建立themes/mytheme/login/resources/js/script.js
alert('Hello');
然後編輯
themes/mytheme/login/theme.properties
並新增scripts=js/script.js
將影像新增至佈景主題
若要讓影像可用於佈景主題,請將它們新增至您佈景主題的 <佈景主題類型>/resources/img
目錄。這些影像可以從樣式表內使用,也可以直接在 HTML 範本中使用。
例如,若要將影像新增至 mytheme
,請將影像複製到 themes/mytheme/login/resources/img/image.jpg
。
然後您可以使用以下內容從自訂樣式表中使用此影像
body {
background-image: url('../img/image.jpg');
background-size: cover;
}
或者,若要直接在 HTML 範本中使用,請將以下內容新增至自訂 HTML 範本
<img src="${url.resourcesPath}/img/image.jpg" alt="My image description">
將自訂頁尾新增至登入佈景主題
若要使用自訂頁尾,請在您的自訂登入佈景主題中建立一個包含所需內容的 footer.ftl
檔案。
自訂 footer.ftl
的範例可能如下所示
<#macro content>
<#-- footer at the end of the login box -->
<div>
<ul id="kc-login-footer-links">
<li><a href="#home">Home</a></li>
<li><a href="#about">About</a></li>
<li><a href="#contact">Contact</a></li>
</ul>
<div>
</#macro>
將影像新增至電子郵件佈景主題
若要讓影像可用於佈景主題,請將它們新增至您佈景主題的 <佈景主題類型>/email/resources/img
目錄。這些影像可以直接從 HTML 範本中使用。
例如,若要將影像新增至 mytheme
,請將影像複製到 themes/mytheme/email/resources/img/logo.jpg
。
若要直接在 HTML 範本中使用,請將以下內容新增至自訂 HTML 範本
<img src="${url.resourcesUrl}/img/image.jpg" alt="My image description">
訊息
範本中的文字會從訊息包載入。擴充另一個佈景主題的佈景主題將繼承父系訊息包中的所有訊息,您可以透過將 <佈景主題類型>/messages/messages_en.properties
新增至您的佈景主題來覆寫個別訊息。
例如,若要將登入表單上的 Username
取代為 Your Username
(適用於 mytheme
),請使用以下內容建立檔案 themes/mytheme/login/messages/messages_en.properties
usernameOrEmail=Your Username
在訊息中,當使用訊息時,會將 {0}
和 {1}
之類的值取代為引數。例如,Log in to {0}
中的 {0} 會取代為領域的名稱。
這些訊息包的文字可以由特定於領域的值覆寫。特定於領域的值可透過 UI 和 API 管理。
將語言新增至領域
-
若要為領域啟用國際化,請參閱 伺服器管理指南。
-
在您佈景主題的目錄中建立檔案
<佈景主題類型>/messages/messages_<地區設定>.properties
。 -
將此檔案新增至
<佈景主題類型>/theme.properties
中的locales
屬性。為了讓使用者可以使用某種語言,領域的login
、account
和email
,佈景主題必須支援該語言,因此您需要為這些佈景主題類型新增您的語言。例如,若要將挪威文翻譯新增至
mytheme
佈景主題,請使用以下內容建立檔案themes/mytheme/login/messages/messages_no.properties
usernameOrEmail=Brukernavn password=Passord
如果您省略訊息的翻譯,它們將會使用英文。
-
編輯
themes/mytheme/login/theme.properties
並新增locales=en,no
-
針對
account
和email
佈景主題類型新增相同的內容。若要執行此操作,請建立themes/mytheme/account/messages/messages_no.properties
和themes/mytheme/email/messages/messages_no.properties
。如果將這些檔案留空,將會使用英文訊息。 -
將
themes/mytheme/login/theme.properties
複製到themes/mytheme/account/theme.properties
和themes/mytheme/email/theme.properties
。 -
新增語言選取器的翻譯。方法是在英文翻譯中新增訊息。若要執行此操作,請將以下內容新增至
themes/mytheme/account/messages/messages_en.properties
和themes/mytheme/login/messages/messages_en.properties
locale_no=Norsk
根據預設,訊息屬性檔案應該使用 UTF-8 編碼。如果 Keycloak 無法以 UTF-8 讀取內容,則會回復為 ISO-8859-1 處理。Unicode 字元可以按照 Java 的 PropertyResourceBundle 文件中的說明進行逸出。先前版本的 Keycloak 支援使用類似 # encoding: UTF-8
的註解在第一行中指定編碼,但這不再受支援。
-
如需關於如何選取目前地區設定的詳細資訊,請參閱地區設定選取器。
新增自訂身分提供者圖示
Keycloak 支援為自訂身分提供者新增圖示,這些圖示會顯示在登入畫面中。
-
使用金鑰模式
kcLogoIdP-<別名>
在您的登入theme.properties
檔案 (例如,themes/mytheme/login/theme.properties
) 中定義圖示類別。 -
對於別名為
myProvider
的身分提供者,您可以在自訂佈景主題的theme.properties
檔案中新增一行。例如kcLogoIdP-myProvider = fa fa-lock
所有圖示都可以在 PatternFly4 的官方網站上找到。社交提供者的圖示已在 base
登入佈景主題屬性 (themes/keycloak/login/theme.properties
) 中定義,您可以在其中尋找靈感。
建立自訂 HTML 範本
Keycloak 使用 Apache Freemarker 範本來產生 HTML 和轉譯頁面。
雖然可以建立自訂範本來完全變更頁面的轉譯方式,但建議儘可能利用內建範本。原因如下
在大多數情況下,您不需要變更範本來調整 Keycloak 以符合您的需求,但您可以透過在自己的主題中建立 <主題類型>/<範本>.ftl
來覆寫個別範本。管理員和帳戶主控台使用單一範本 index.ftl
來呈現應用程式。
如需其他主題類型中的範本清單,請查看 JAR 檔案中 theme/base/<主題類型>
目錄,該檔案位於 $KEYCLOAK_HOME/lib/lib/main/org.keycloak.keycloak-themes-<版本>.jar
。
-
將範本從基本主題複製到您自己的主題。
-
套用您需要的修改。
例如,要為
mytheme
主題建立自訂登入表單,請將themes/base/login/login.ftl
複製到themes/mytheme/login
並在編輯器中開啟它。在第一行 (<#import …>) 之後,加入
<h1>HELLO WORLD!</h1>
,如下所示<#import "template.ftl" as layout> <h1>HELLO WORLD!</h1> ...
-
備份修改後的範本。升級到新版本的 Keycloak 時,您可能需要更新自訂範本,以套用對原始範本的變更(如果有的話)。
-
有關如何編輯範本的詳細資訊,請參閱 FreeMarker 手冊。
電子郵件
若要編輯電子郵件的主旨和內容,例如密碼恢復電子郵件,請將訊息套件新增至您主題的 email
類型。每封電子郵件有三則訊息。一則用於主旨,一則用於純文字內文,另一則用於 html 內文。
若要查看所有可用的電子郵件,請查看 themes/base/email/messages/messages_en.properties
。
例如,要變更 mytheme
主題的密碼恢復電子郵件,請建立 themes/mytheme/email/messages/messages_en.properties
,內容如下
passwordResetSubject=My password recovery
passwordResetBody=Reset password link: {0}
passwordResetBodyHtml=<a href="{0}">Reset password</a>
部署主題
可以透過將主題目錄複製到 themes
來將主題部署到 Keycloak,也可以將其部署為封存檔。在開發期間,您可以將主題複製到 themes
目錄,但在生產環境中,您可能需要考慮使用 archive
。archive
可以更輕鬆地擁有主題的版本化副本,特別是在您有多個 Keycloak 執行個體(例如叢集)時。
-
若要將主題部署為封存檔,請使用主題資源建立 JAR 封存檔。
-
將
META-INF/keycloak-themes.json
檔案新增至封存檔,其中列出封存檔中可用的主題,以及每個主題提供的類型。例如,針對
mytheme
主題,建立包含以下內容的mytheme.jar
-
META-INF/keycloak-themes.json
-
theme/mytheme/login/theme.properties
-
theme/mytheme/login/login.ftl
-
theme/mytheme/login/resources/css/styles.css
-
theme/mytheme/login/resources/img/image.png
-
theme/mytheme/login/messages/messages_en.properties
-
theme/mytheme/email/messages/messages_en.properties
在此情況下,
META-INF/keycloak-themes.json
的內容會是{ "themes": [{ "name" : "mytheme", "types": [ "login", "email" ] }] }
單一封存檔可以包含多個主題,而且每個主題可以支援一個或多個類型。
-
若要將封存檔部署到 Keycloak,請將其新增至 Keycloak 的 providers/
目錄,並在伺服器已在執行時重新啟動伺服器。
主題的其他資源
-
如需靈感,請參閱 Keycloak 內建的預設主題。
-
Keycloak 快速入門儲存庫 - 快速入門儲存庫的
extension
目錄包含一些主題範例,也可以作為靈感來源。
基於 React 的主題
管理員主控台和帳戶主控台是基於 React。若要完全自訂這些,您可以使用基於 React 的 npm 套件。有兩個套件
-
@keycloak/keycloak-admin-ui
:這是管理員主控台的基本主題。 -
@keycloak/keycloak-account-ui
:這是帳戶主控台的基本主題。
這兩個套件都可在 npm 上取得。
使用套件
若要使用這些頁面,您需要在元件階層中新增 KeycloakProvider,以設定要使用的用戶端、領域和 URL。
import { KeycloakProvider } from "@keycloak/keycloak-ui-shared";
//...
<KeycloakProvider environment={{
serverBaseUrl: "http://localhost:8080",
realm: "master",
clientId: "security-admin-console"
}}>
{/* rest of you application */}
</KeycloakProvider>
翻譯頁面
這些頁面使用 i18next
程式庫進行翻譯。您可以依照其[網站](https://react.i18next.com/)所述進行設定。如果您想要使用提供的翻譯,則需要將 i18next-http-backend 新增至您的專案,並新增
backend: {
loadPath: `http://127.0.0.1:8080/resources/master/account/{lng}}`,
parse: (data: string) => {
const messages = JSON.parse(data);
const result: Record<string, string> = {};
messages.forEach((v) => (result[v.key] = v.value)); //need to convert to record
return result;
},
},
使用頁面
所有「頁面」都是可以在您的應用程式中使用的 React 元件。若要查看有哪些可用的元件,請參閱[原始碼](https://github.com/keycloak/keycloak/blob/main/js/apps/account-ui/src/index.ts)。或查看[快速入門](https://github.com/keycloak/keycloak-quickstarts/tree/main/extension/extend-admin-console-node),以了解如何使用它們。
主題選取器
預設情況下,會使用為領域設定的主題,但用戶端可以覆寫登入主題。此行為可以透過主題選取器 SPI 變更。
例如,這可用於透過查看使用者代理程式標頭,為桌上型電腦和行動裝置選取不同的主題。
若要建立自訂主題選取器,您需要實作 ThemeSelectorProviderFactory
和 ThemeSelectorProvider
。
主題資源
在 Keycloak 中實作自訂提供者時,通常可能需要新增其他範本、資源和訊息套件。
一個範例使用案例是需要額外範本和資源的自訂驗證器。
載入其他主題資源的最簡單方法是建立 JAR,其中範本位於 theme-resources/templates
中,資源位於 theme-resources/resources
中,訊息套件位於 theme-resources/messages
中。
如果您想要更彈性地載入範本和資源,可以透過 ThemeResourceSPI 來達成。透過實作 ThemeResourceProviderFactory
和 ThemeResourceProvider
,您可以確切決定如何載入範本和資源。
地區設定選取器
依預設,地區設定是使用實作 LocaleSelectorProvider
介面的 DefaultLocaleSelectorProvider
來選取。在停用國際化時,英文是預設語言。
啟用國際化後,會根據伺服器管理指南中所述的邏輯來解析地區設定。
此行為可以透過實作 LocaleSelectorProvider
和 LocaleSelectorProviderFactory
的 LocaleSelectorSPI
來變更。
LocaleSelectorProvider
介面有一個方法 resolveLocale
,該方法必須在給定 RealmModel
和可為 null 的 UserModel
的情況下傳回地區設定。實際請求可從 KeycloakSession#getContext
方法取得。
自訂實作可以擴充 DefaultLocaleSelectorProvider
,以便重複使用預設行為的部分。例如,若要忽略 Accept-Language
請求標頭,自訂實作可以擴充預設提供者、覆寫其 getAcceptLanguageHeaderLocale
,並傳回 null 值。因此,地區設定選取將會回復為領域的預設語言。
地區設定選取器的其他資源
-
有關建立和部署自訂提供者的詳細資訊,請參閱服務提供者介面。
身分代理 API
Keycloak 可以將驗證委派給父 IDP 進行登入。一個典型的例子是,您希望使用者能夠透過社交提供者(例如 Facebook 或 Google)登入的情況。您也可以將現有帳戶連結到代理的 IDP。本節說明您的應用程式在使用身分代理時可以使用的一些 API。
擷取外部 IDP 權杖
Keycloak 允許您儲存來自與外部 IDP 驗證流程的權杖和回應。為此,您可以使用 IDP 設定頁面上的 [儲存權杖] 設定選項。
應用程式程式碼可以擷取這些權杖和回應,以提取額外的使用者資訊,或安全地叫用外部 IDP 上的請求。例如,應用程式可能想要使用 Google 權杖來叫用其他 Google 服務和 REST API。若要擷取特定身分提供者的權杖,您需要傳送如下的請求
GET /realms/{realm}/broker/{provider_alias}/token HTTP/1.1
Host: localhost:8080
Authorization: Bearer <KEYCLOAK ACCESS TOKEN>
應用程式必須先向 Keycloak 驗證身分並取得存取權杖。這個存取權杖必須設定 broker
用戶端層級角色 read-token
。這表示使用者必須有此角色的角色對應,且用戶端應用程式必須在其範圍內具有該角色。在這種情況下,由於您正在存取 Keycloak 中受保護的服務,因此您需要傳送使用者驗證期間 Keycloak 發出的存取權杖。在代理設定頁面中,您可以開啟「儲存的權杖可讀取」開關,將此角色自動指派給新匯入的使用者。
這些外部權杖可以透過再次透過提供者登入或使用用戶端啟動的帳戶連結 API 重新建立。
用戶端啟動的帳戶連結
有些應用程式想要與 Facebook 等社群提供者整合,但不希望提供透過這些社群提供者登入的選項。Keycloak 提供了一個基於瀏覽器的 API,應用程式可以使用此 API 將現有的使用者帳戶連結到特定的外部 IDP。這稱為用戶端啟動的帳戶連結。帳戶連結只能由 OIDC 應用程式啟動。
運作方式是,應用程式將使用者的瀏覽器轉送到 Keycloak 伺服器上的 URL,要求將使用者的帳戶連結到特定的外部提供者 (例如 Facebook)。伺服器會使用外部提供者啟動登入。瀏覽器會在外部提供者登入,然後重新導向回伺服器。伺服器會建立連結並重新導向回應用程式並進行確認。
用戶端應用程式必須先滿足一些先決條件,才能啟動此協定
-
必須在管理主控台中設定並啟用使用者領域所需的識別提供者。
-
使用者帳戶必須已透過 OIDC 協定以現有使用者的身分登入
-
使用者必須具有
account.manage-account
或account.manage-account-links
角色對應。 -
應用程式必須在其存取權杖中被授予這些角色的範圍
-
應用程式必須可以存取其存取權杖,因為它需要其中的資訊來產生重新導向 URL。
若要啟動登入,應用程式必須建立 URL 並將使用者的瀏覽器重新導向至此 URL。URL 看起來像這樣
/{auth-server-root}/realms/{realm}/broker/{provider}/link?client_id={id}&redirect_uri={uri}&nonce={nonce}&hash={hash}
以下是每個路徑和查詢參數的描述
- provider
-
這是您在管理主控台的「識別提供者」區段中定義的外部 IDP 的提供者別名。
- client_id
-
這是您應用程式的 OIDC 用戶端 ID。當您在管理主控台中將應用程式註冊為用戶端時,您必須指定此用戶端 ID。
- redirect_uri
-
這是您想要在建立帳戶連結後重新導向的應用程式回呼 URL。它必須是有效的使用戶端重新導向 URI 模式。換句話說,它必須與您在管理主控台中註冊用戶端時定義的有效 URL 模式之一相符。
- nonce
-
這是您的應用程式必須產生的隨機字串
- hash
-
這是 Base64 URL 編碼的雜湊。此雜湊是透過 Base64 URL 編碼
nonce
+token.getSessionState()
+token.getIssuedFor()
+provider
的 SHA_256 雜湊來產生的。token 變數是從 OIDC 存取權杖取得的。基本上,您要雜湊隨機 nonce、使用者工作階段 ID、用戶端 ID 以及您想要存取的識別提供者別名。
以下是一個 Java Servlet 程式碼範例,用於產生建立帳戶連結的 URL。
KeycloakSecurityContext session = (KeycloakSecurityContext) httpServletRequest.getAttribute(KeycloakSecurityContext.class.getName());
AccessToken token = session.getToken();
String clientId = token.getIssuedFor();
String nonce = UUID.randomUUID().toString();
MessageDigest md = null;
try {
md = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
String input = nonce + token.getSessionState() + clientId + provider;
byte[] check = md.digest(input.getBytes(StandardCharsets.UTF_8));
String hash = Base64Url.encode(check);
request.getSession().setAttribute("hash", hash);
String redirectUri = ...;
String accountLinkUrl = KeycloakUriBuilder.fromUri(authServerRootUrl)
.path("/realms/{realm}/broker/{provider}/link")
.queryParam("nonce", nonce)
.queryParam("hash", hash)
.queryParam("client_id", clientId)
.queryParam("redirect_uri", redirectUri).build(realm, provider).toString();
為何要包含此雜湊?我們這樣做是為了確保驗證伺服器知道用戶端應用程式啟動了要求,而不是其他惡意應用程式隨機要求將使用者帳戶連結到特定的提供者。驗證伺服器會先檢查是否已透過檢查登入時設定的 SSO Cookie 來登入使用者。然後,它會嘗試根據目前的登入重新產生雜湊,並將其與應用程式傳送的雜湊進行比對。
連結帳戶後,驗證伺服器將重新導向回 redirect_uri
。如果處理連結要求時發生問題,驗證伺服器可能會或可能不會重新導向回 redirect_uri
。瀏覽器可能最終會停留在錯誤頁面上,而不是重新導向回應用程式。如果發生錯誤狀況,且驗證伺服器認為重新導向回用戶端應用程式是夠安全的,則會將額外的 error
查詢參數附加到 redirect_uri
。
雖然此 API 保證應用程式啟動了要求,但它並不能完全防止此操作的 CSRF 攻擊。應用程式仍有責任防禦針對自身的 CSRF 攻擊。 |
服務提供者介面 (SPI)
Keycloak 的設計目標是在不需要自訂程式碼的情況下涵蓋大多數的使用案例,但我們也希望它是可自訂的。為了實現此目的,Keycloak 具有許多服務提供者介面 (SPI),您可以為其實作自己的提供者。
實作 SPI
若要實作 SPI,您需要實作其 ProviderFactory 和 Provider 介面。您還需要建立服務組態檔案。
例如,若要實作主題選取器 SPI,您需要實作 ThemeSelectorProviderFactory 和 ThemeSelectorProvider,並提供檔案 META-INF/services/org.keycloak.theme.ThemeSelectorProviderFactory
。
ThemeSelectorProviderFactory 範例
package org.acme.provider;
import ...
public class MyThemeSelectorProviderFactory implements ThemeSelectorProviderFactory {
@Override
public ThemeSelectorProvider create(KeycloakSession session) {
return new MyThemeSelectorProvider(session);
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
@Override
public String getId() {
return "myThemeSelector";
}
}
建議您的提供者工廠實作透過 getId()
方法傳回唯一 ID。但是,此規則可能有一些例外情況,如下面的覆寫提供者章節所述。
Keycloak 會建立提供者工廠的單一執行個體,這使得可以儲存多個要求的狀態。提供者執行個體是透過在工廠上為每個要求呼叫 create 來建立的,因此這些應該是輕量物件。 |
ThemeSelectorProvider 範例
package org.acme.provider;
import ...
public class MyThemeSelectorProvider implements ThemeSelectorProvider {
public MyThemeSelectorProvider(KeycloakSession session) {
}
@Override
public String getThemeName(Theme.Type type) {
return "my-theme";
}
@Override
public void close() {
}
}
服務組態檔案範例 (META-INF/services/org.keycloak.theme.ThemeSelectorProviderFactory
)
org.acme.provider.MyThemeSelectorProviderFactory
若要設定您的提供者,請參閱設定提供者指南。
例如,若要設定提供者,您可以如下設定選項
bin/kc.[sh|bat] --spi-theme-selector-my-theme-selector-enabled=true --spi-theme-selector-my-theme-selector-theme=my-theme
然後,您可以在 ProviderFactory
init 方法中擷取設定
public void init(Config.Scope config) {
String themeName = config.get("theme");
}
您的提供者也可以在需要時查詢其他提供者。例如
public class MyThemeSelectorProvider implements ThemeSelectorProvider {
private KeycloakSession session;
public MyThemeSelectorProvider(KeycloakSession session) {
this.session = session;
}
@Override
public String getThemeName(Theme.Type type) {
return session.getContext().getRealm().getLoginTheme();
}
}
覆寫內建提供者
如上所述,建議您的 ProviderFactory
實作使用唯一 ID。但是,同時覆寫 Keycloak 的其中一個內建提供者也可能很有用。建議的方法仍然是具有唯一 ID 的 ProviderFactory 實作,然後例如設定設定提供者指南中指定的預設提供者。另一方面,這可能並非總是可行。
例如,當您需要對預設 OpenID Connect 協定行為進行一些自訂,並且想要覆寫 OIDCLoginProtocolFactory
的預設 Keycloak 實作時,您需要保留相同的 providerId。例如,管理主控台、OIDC 協定知名端點和各種其他項目都依賴於協定工廠的 ID 為 openid-connect
。
對於這種情況,強烈建議實作自訂實作的 order()
方法,並確保其順序高於內建實作。
public class CustomOIDCLoginProtocolFactory extends OIDCLoginProtocolFactory {
// Some customizations here
@Override
public int order() {
return 1;
}
}
如果有多個具有相同提供者 ID 的實作,則 Keycloak 執行階段只會使用順序最高的實作。
在管理主控台中顯示 SPI 實作的資訊
有時,向 Keycloak 管理員顯示有關您提供者的其他資訊會很有用。您可以顯示提供者建置時間資訊 (例如,目前安裝的自訂提供者版本)、提供者的目前組態 (例如,您的提供者與之通訊的遠端系統的 URL) 或一些操作資訊 (您的提供者與之通訊的遠端系統的回應平均時間)。Keycloak 管理主控台提供伺服器資訊頁面來顯示這類資訊。
若要顯示您的提供者的資訊,在您的 ProviderFactory
中實作 org.keycloak.provider.ServerInfoAwareProviderFactory
介面就足夠了。
先前範例中 MyThemeSelectorProviderFactory
的實作範例
package org.acme.provider;
import ...
public class MyThemeSelectorProviderFactory implements ThemeSelectorProviderFactory, ServerInfoAwareProviderFactory {
...
@Override
public Map<String, String> getOperationalInfo() {
Map<String, String> ret = new LinkedHashMap<>();
ret.put("theme-name", "my-theme");
return ret;
}
}
使用可用的提供者
在您的提供者實作中,您可以使用 Keycloak 中可用的其他提供者。現有的提供者通常可以使用 KeycloakSession
來擷取,該 KeycloakSession
可供您的提供者使用,如實作 SPI章節中所述。
Keycloak 有兩種提供者類型
-
單一實作提供者類型 - 在 Keycloak 執行階段中,特定提供者類型只能有一個有效的實作。
例如,
HostnameProvider
指定 Keycloak 使用的主機名稱,且該主機名稱為整個 Keycloak 伺服器共用。因此,對於 Keycloak 伺服器,此供應商只能有一個實作處於活動狀態。如果伺服器運行時有多個供應商實作可用,則需要將其中一個指定為預設供應商。
例如像是
bin/kc.[sh|bat] build --spi-hostname-provider=default
作為 default-provider
值的 default
值,必須與特定供應商工廠實作的 ProviderFactory.getId()
所傳回的 ID 相符。在程式碼中,您可以取得供應商,例如 keycloakSession.getProvider(HostnameProvider.class)
-
多重實作供應商類型 - 這些是允許在 Keycloak 運行時有多個實作可用且協同工作的供應商類型。
例如,
EventListener
供應商允許多個實作可用並註冊,這表示特定事件可以發送到所有監聽器(jboss-logging、sysout 等)。在程式碼中,您可以取得供應商的指定實例,例如session.getProvider(EventListener.class, "jboss-logging")
。您需要將供應商的provider_id
作為第二個參數指定,因為如上所述,此供應商類型可以有多個實例。供應商 ID 必須與特定供應商工廠實作的
ProviderFactory.getId()
所傳回的 ID 相符。某些供應商類型可以使用ComponentModel
作為第二個參數來檢索,而某些供應商類型(例如Authenticator
)甚至需要使用KeycloakSessionFactory
來檢索。不建議您以這種方式實作自己的供應商,因為它在未來可能會被棄用。
註冊供應商實作
只需將 JAR 檔案複製到 providers
目錄,即可將供應商註冊到伺服器。
如果您的供應商需要 Keycloak 未提供的額外相依性,請將這些相依性複製到 providers
目錄。
在註冊新的供應商或相依性後,Keycloak 需要以非最佳化啟動或 kc.[sh|bat] build
命令來重建。
供應商 JAR 不會在隔離的類別載入器中載入,因此請勿在供應商 JAR 中包含與內建資源或類別衝突的資源或類別。特別是,如果移除供應商 JAR,包含 application.properties 檔案或覆寫 commons-lang3 相依性將會導致自動建置失敗。如果您包含了衝突的類別,您可能會在伺服器的啟動記錄中看到分割套件警告。不幸的是,並非所有內建的 lib JAR 都會受到分割套件警告邏輯的檢查,因此您需要在捆綁或包含過渡相依性之前檢查 lib 目錄 JAR。如果有衝突,可以透過移除或重新封裝有問題的類別來解決。 如果您有衝突的資源檔案,則不會有警告。您應該確保您的 JAR 的資源檔案具有包含該供應商特有的路徑名稱,或者您可以使用如下程式碼檢查 JAR 內容中
如果您發現您的伺服器由於與移除的供應商 JAR 相關的
這將強制 Quarkus 重建與類別載入相關的索引檔案。從那裡,您應該可以執行非最佳化的啟動或建置,而不會出現例外狀況。 |
JavaScript 供應商
指令碼為預覽功能,不完全支援。此功能預設為停用。 要啟用,請使用 |
Keycloak 能夠在運行時執行指令碼,以允許管理員自訂特定功能
-
驗證器
-
JavaScript 原則
-
OpenID Connect 通訊協定對應器
-
SAML 協定對應器
驗證器
驗證指令碼必須提供以下至少一個函數:authenticate(..)
,從 Authenticator#authenticate(AuthenticationFlowContext)
呼叫;action(..)
,從 Authenticator#action(AuthenticationFlowContext)
呼叫
自訂 Authenticator
至少應提供 authenticate(..)
函數。您可以在程式碼中使用 javax.script.Bindings
指令碼。
指令碼
-
ScriptModel
用於存取指令碼中繼資料 領域
-
RealmModel
使用者
-
目前的
UserModel
。請注意,當您的指令碼驗證器在驗證流程中以在另一個驗證器成功建立使用者身分並將使用者設定到驗證會話後觸發的方式設定時,user
才可用。 會話
-
活動中的
KeycloakSession
驗證會話
-
目前的
AuthenticationSessionModel
httpRequest
-
目前的
org.jboss.resteasy.spi.HttpRequest
LOG
-
作用域為
ScriptBasedAuthenticator
的org.jboss.logging.Logger
您可以從傳遞給 authenticate(context) action(context) 函數的 context 引數中擷取其他上下文資訊。 |
AuthenticationFlowError = Java.type("org.keycloak.authentication.AuthenticationFlowError");
function authenticate(context) {
LOG.info(script.name + " --> trace auth for: " + user.username);
if ( user.username === "tester"
&& user.getAttribute("someAttribute")
&& user.getAttribute("someAttribute").contains("someValue")) {
context.failure(AuthenticationFlowError.INVALID_USER);
return;
}
context.success();
}
在哪裡新增指令碼驗證器
指令碼驗證器的一種可能用途是在驗證結束時執行一些檢查。請注意,如果您希望您的指令碼驗證器始終被觸發(例如,即使在使用身分識別 Cookie 進行 SSO 重新驗證期間),您可能需要在驗證流程的末尾將其新增為 REQUIRED,並將現有的驗證器封裝到單獨的 REQUIRED 驗證子流程中。這是因為 REQUIRED 和 ALTERNATIVE 執行不應處於同一層級。例如,驗證流程設定應如下所示
- User-authentication-subflow REQUIRED
-- Cookie ALTERNATIVE
-- Identity-provider-redirect ALTERNATIVE
...
- Your-Script-Authenticator REQUIRED
OpenID Connect 協定對應器
OpenID Connect 協定對應器指令碼是 JavaScript 指令碼,可讓您變更 ID 權杖和/或存取權杖的內容。
您可以在程式碼中使用 javax.script.Bindings
指令碼。
使用者
-
目前的
UserModel
領域
-
RealmModel
token
-
目前的
IDToken
。僅當對應器針對 ID 權杖設定時才可用。 tokenResponse
-
目前的
AccessTokenResponse
。僅當對應器針對存取權杖設定時才可用。 userSession
-
活動中的
UserSessionModel
keycloakSession
-
活動中的
KeycloakSession
指令碼的匯出將用作權杖宣告的值。
// prints can be used to log information for debug purpose.
print("STARTING CUSTOM MAPPER");
var inputRequest = keycloakSession.getContext().getHttpRequest();
var params = inputRequest.getDecodedFormParameters();
var output = params.getFirst("user_input");
exports = output;
上述指令碼允許從授權請求中擷取 user_input
。這將可用於在對應器中設定的 權杖宣告名稱
中對應。
建立具有要部署的指令碼的 JAR
JAR 檔案是副檔名為 .jar 的一般 ZIP 檔案。 |
為了讓您的指令碼可供 Keycloak 使用,您需要將它們部署到伺服器。為此,您應建立具有以下結構的 JAR
檔案
META-INF/keycloak-scripts.json
my-script-authenticator.js
my-script-policy.js
my-script-mapper.js
META-INF/keycloak-scripts.json
是一個檔案描述符,提供有關您要部署的指令碼的中繼資料資訊。它是具有以下結構的 JSON 檔案
{
"authenticators": [
{
"name": "My Authenticator",
"fileName": "my-script-authenticator.js",
"description": "My Authenticator from a JS file"
}
],
"policies": [
{
"name": "My Policy",
"fileName": "my-script-policy.js",
"description": "My Policy from a JS file"
}
],
"mappers": [
{
"name": "My Mapper",
"fileName": "my-script-mapper.js",
"description": "My Mapper from a JS file"
}
],
"saml-mappers": [
{
"name": "My Mapper",
"fileName": "my-script-mapper.js",
"description": "My Mapper from a JS file"
}
]
}
此檔案應參考您要部署的不同類型的指令碼供應商
-
驗證器
適用於 OpenID Connect 指令碼驗證器。您可以在同一個 JAR 檔案中有多個驗證器
-
原則
在使用 Keycloak 授權服務時適用於 JavaScript 原則。您可以在同一個 JAR 檔案中有多個原則
-
對應器
適用於 OpenID Connect 指令碼協定對應器。您可以在同一個 JAR 檔案中有多個對應器
-
saml-mappers
適用於 SAML 指令碼協定對應器。您可以在同一個 JAR 檔案中有多個對應器
對於 JAR
檔案中的每個指令碼檔案,您需要在 META-INF/keycloak-scripts.json
中有一個對應的項目,將您的指令碼檔案對應到特定的供應商類型。為此,您應該為每個項目提供以下屬性
-
名稱
一個友善的名稱,將用於透過 Keycloak 管理主控台顯示指令碼。如果未提供,則會改為使用指令碼檔案的名稱
-
描述
可選文字,更好地描述指令碼檔案的意圖
-
檔案名稱
指令碼檔案的名稱。此屬性為強制性,並且應對應到 JAR 內的檔案。
可用的 SPI
如果您想在執行時查看所有可用 SPI 的列表,您可以在管理主控台中的 伺服器資訊
頁面中查看,如管理主控台章節中所述。ExampleSpi
擴展伺服器
Keycloak SPI 框架提供了實作或覆寫特定內建供應商的可能性。然而,Keycloak 也提供了擴展其核心功能和網域的能力。這包括以下可能性:
-
向 Keycloak 伺服器新增自訂 REST 端點
-
新增您自己的自訂 SPI
-
將自訂 JPA 實體新增至 Keycloak 資料模型
新增自訂 REST 端點
這是一個非常強大的擴充功能,它允許您將自己的 REST 端點部署到 Keycloak 伺服器。它啟用了各種擴充功能,例如在 Keycloak 伺服器上觸發預設的內建 Keycloak REST 端點無法使用的功能。
要新增自訂 REST 端點,您需要實作 RealmResourceProviderFactory
和 RealmResourceProvider
介面。RealmResourceProvider
有一個重要的方法
Object getResource();
使用此方法來回傳一個物件,該物件充當 JAX-RS 資源。您的 JAX-RS 資源只有在包含以下設定時,才會被伺服器識別並註冊為有效的端點:- 在 META-INF
下新增一個名為 beans.xml
的空檔案 - 使用 jakarta.ws.rs.ext.Provider
註釋 JAX-RS 類別。
有關如何封裝和部署自訂提供者的詳細資訊,請參閱服務提供者介面章節。
雖然可以透過提供者擴充機制安裝其他 JAX-RS 元件(例如篩選器和攔截器),但這些元件並非官方支援。 |
新增您自己的自訂 SPI
自訂 SPI 特別適用於自訂 REST 端點。使用此程序新增您自己的 SPI
-
實作
org.keycloak.provider.Spi
介面,並定義您的 SPI 的 ID 以及ProviderFactory
和Provider
類別。如下所示public class ExampleSpi implements Spi { @Override public boolean isInternal() { return false; } @Override public String getName() { return "example"; } @Override public Class<? extends Provider> getProviderClass() { return ExampleService.class; } @Override @SuppressWarnings("rawtypes") public Class<? extends ProviderFactory> getProviderFactoryClass() { return ExampleServiceProviderFactory.class; } }
-
建立檔案
META-INF/services/org.keycloak.provider.Spi
,並將您的 SPI 類別新增至其中。例如ExampleSpi
-
建立介面
ExampleServiceProviderFactory
(繼承自ProviderFactory
)和ExampleService
(繼承自Provider
)。ExampleService
通常會包含您用例所需的業務方法。請注意,ExampleServiceProviderFactory
實例的範圍始終是每個應用程式,而ExampleService
的範圍是每個請求(或更準確地說,每個KeycloakSession
生命週期)。 -
最後,您需要以服務提供者介面章節中所述的相同方式實作您的提供者。
將自訂 JPA 實體新增至 Keycloak 資料模型
如果 Keycloak 資料模型不完全符合您所需的解決方案,或者您想要將某些核心功能新增至 Keycloak,或者當您擁有自己的 REST 端點時,您可能需要擴充 Keycloak 資料模型。我們允許您將自己的 JPA 實體新增至 Keycloak JPA EntityManager
。
要新增您自己的 JPA 實體,您需要實作 JpaEntityProviderFactory
和 JpaEntityProvider
。JpaEntityProvider
允許您回傳自訂 JPA 實體的清單,並提供 Liquibase 變更記錄的位置和 ID。範例實作如下所示
這是一個不受支援的 API,這表示您可以使用它,但不保證它不會在沒有警告的情況下被移除或變更。 |
public class ExampleJpaEntityProvider implements JpaEntityProvider {
// List of your JPA entities.
@Override
public List<Class<?>> getEntities() {
return Collections.<Class<?>>singletonList(Company.class);
}
// This is used to return the location of the Liquibase changelog file.
// You can return null if you don't want Liquibase to create and update the DB schema.
@Override
public String getChangelogLocation() {
return "META-INF/example-changelog.xml";
}
// Helper method, which will be used internally by Liquibase.
@Override
public String getFactoryId() {
return "sample";
}
...
}
在上面的範例中,我們新增了一個由 Company
類別表示的單一 JPA 實體。在您的 REST 端點程式碼中,您可以使用類似以下的程式碼來擷取 EntityManager
並在其上呼叫資料庫操作。
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
Company myCompany = em.find(Company.class, "123");
getChangelogLocation
和 getFactoryId
方法對於支援 Liquibase 自動更新您的實體非常重要。Liquibase 是一個用於更新資料庫結構描述的框架,Keycloak 內部使用它來建立資料庫結構描述並在版本之間更新資料庫結構描述。您可能也需要使用它,並為您的實體建立一個變更記錄。請注意,您自己的 Liquibase 變更記錄的版本控制與 Keycloak 版本無關。換句話說,當您更新到新的 Keycloak 版本時,您不必同時更新您的結構描述。反之亦然,您甚至可以在不更新 Keycloak 版本的情況下更新您的結構描述。Liquibase 更新總是在伺服器啟動時完成,因此要觸發您結構描述的資料庫更新,您只需將新的變更集新增至您的 Liquibase 變更記錄檔案(在上面的範例中,它是檔案 META-INF/example-changelog.xml
,它必須與 JPA 實體和 ExampleJpaEntityProvider
封裝在同一個 JAR 中),然後重新啟動伺服器。資料庫結構描述將在啟動時自動更新。
在變更 Liquibase 變更記錄並觸發資料庫更新之前,請務必先備份您的資料庫。 |
驗證 SPI
Keycloak 包括一系列不同的驗證機制:Kerberos、密碼、OTP 等。這些機制可能無法滿足您的所有需求,您可能想要插入自己的自訂機制。Keycloak 提供了一個驗證 SPI,您可以使用它來撰寫新的外掛程式。管理控制台支援套用、排序和設定這些新機制。
Keycloak 也支援一個簡單的註冊表單。可以啟用和停用此表單的不同方面,例如,可以關閉和開啟 reCAPTCHA 支援。相同的驗證 SPI 可用於在註冊流程中新增另一個頁面或完全重新實作它。還有一個額外的細粒度 SPI,您可以使用它來將特定的驗證和使用者擴充功能新增至內建的註冊表單。
Keycloak 中的必要動作是使用者在驗證後必須執行的動作。成功執行動作後,使用者不必再次執行該動作。Keycloak 內建了一些必要的動作,例如「重設密碼」。此動作會強制使用者在登入後變更其密碼。您可以撰寫並插入自己的必要動作。
如果您的驗證器或必要動作實作使用某些使用者屬性作為連結/建立使用者身分的中繼資料屬性,請確保使用者無法編輯這些屬性,並且對應的屬性是唯讀的。請參閱威脅模型緩解章節中的詳細資訊。 |
術語
為了首先了解驗證 SPI,讓我們回顧一下用於描述它的一些術語。
- 驗證流程
-
流程是登入或註冊期間必須發生的所有驗證的容器。如果您前往管理控制台驗證頁面,您可以檢視系統中定義的所有流程及其組成的驗證器。流程可以包含其他流程。您也可以為瀏覽器登入、直接授權存取和註冊繫結新的不同流程。
- 驗證器
-
驗證器是一個可插入的元件,它在流程中保存執行驗證或動作的邏輯。它通常是一個單例。
- 執行
-
執行是一個將驗證器繫結到流程,並將驗證器繫結到驗證器設定的物件。流程包含執行條目。
- 執行需求
-
每個執行定義驗證器在流程中的行為方式。需求定義是否啟用、停用、有條件、必要或替代驗證器。替代需求表示驗證器足以驗證它所在的流程,但不是必要的。例如,在內建的瀏覽器流程中,Cookie 驗證、身分提供者重新導向器以及表單子流程中的所有驗證器集合都是替代的。由於它們是按照從上到下的順序依序執行的,如果其中一個成功,則流程成功,並且不評估流程(或子流程)中的任何後續執行。
- 驗證器設定
-
此物件定義驗證器在驗證流程中的特定執行設定。每個執行可以有不同的設定。
- 必要動作
-
驗證完成後,使用者可能必須完成一個或多個一次性動作,然後才能登入。可能需要使用者設定 OTP 權杖產生器或重設過期的密碼,甚至接受條款和條件文件。
演算法概述
讓我們來談談瀏覽器登入是如何運作的。讓我們假設以下流程、執行和子流程。
Cookie - ALTERNATIVE
Kerberos - ALTERNATIVE
Forms subflow - ALTERNATIVE
Username/Password Form - REQUIRED
Conditional OTP subflow - CONDITIONAL
Condition - User Configured - REQUIRED
OTP Form - REQUIRED
在表單的頂層,我們有 3 個執行,它們都是替代必要。這表示如果其中任何一個成功,則其他執行都不必執行。如果設定了 SSO Cookie 或成功的 Kerberos 登入,則不會執行使用者名稱/密碼表單。讓我們逐步了解從用戶端首次重新導向到 Keycloak 以驗證使用者的步驟。
-
OpenID Connect 或 SAML 通訊協定提供者會解壓縮相關資料,驗證用戶端和任何簽章。它會建立 AuthenticationSessionModel。它會查詢瀏覽器流程應該是什麼,然後開始執行流程。
-
流程會檢視 Cookie 執行,並看到它是替代的。它會載入 Cookie 提供者。它會檢查 Cookie 提供者是否需要使用者已經與驗證會話相關聯。Cookie 提供者不需要使用者。如果需要使用者,則流程將中止,並且使用者將看到錯誤畫面。然後 Cookie 提供者會執行。其目的是查看是否設定了 SSO Cookie。如果設定了 SSO Cookie,則會驗證它,並驗證 UserSessionModel 並將其與 AuthenticationSessionModel 關聯。如果 SSO Cookie 存在且已驗證,則 Cookie 提供者會傳回 success() 狀態。由於 Cookie 提供者傳回成功,並且此流程層級的每個執行都是替代的,因此不會執行其他執行,這會導致登入成功。如果沒有 SSO Cookie,則 Cookie 提供者會傳回 attempted() 狀態。這表示沒有錯誤情況,也沒有成功。提供者嘗試過了,但是請求並未設定為處理此驗證器。
-
接下來,流程會查看 Kerberos 的執行。這也是一個替代方案。Kerberos 提供者也不需要使用者已經設定好,並且與 AuthenticationSessionModel 相關聯,因此會執行此提供者。Kerberos 使用 SPNEGO 瀏覽器協定。這需要在伺服器和客戶端之間進行一系列的挑戰/回應,交換協商標頭。Kerberos 提供者沒有看到任何協商標頭,因此假設這是伺服器和客戶端之間的首次互動。因此,它會為客戶端建立一個 HTTP 挑戰回應,並設定 forceChallenge() 狀態。forceChallenge() 表示此 HTTP 回應不能被流程忽略,必須返回給客戶端。如果提供者返回的是 challenge() 狀態,流程會保留挑戰回應,直到嘗試所有其他替代方案。因此,在這個初始階段,流程會停止,挑戰回應會被送回瀏覽器。如果瀏覽器隨後以成功的協商標頭回應,提供者會將使用者與 AuthenticationSession 關聯,流程結束,因為此流程層級上的其餘執行都是替代方案。否則,Kerberos 提供者會再次設定 attempted() 狀態,流程繼續。
-
下一個執行是一個名為 Forms 的子流程。載入此子流程的執行,並執行相同的處理邏輯。
-
Forms 子流程中的第一個執行是 UsernamePassword 提供者。此提供者也不需要使用者已經與流程相關聯。此提供者會建立一個挑戰 HTTP 回應,並將其狀態設定為 challenge()。此執行是必要的,因此流程會接受此挑戰,並將 HTTP 回應送回瀏覽器。此回應是 Username/Password HTML 頁面的呈現。使用者輸入其使用者名稱和密碼,然後按一下「提交」。此 HTTP 請求會被導向 UsernamePassword 提供者。如果使用者輸入了無效的使用者名稱或密碼,則會建立一個新的挑戰回應,並且此執行的狀態會設定為 failureChallenge()。failureChallenge() 表示存在挑戰,但流程應將其作為錯誤記錄在錯誤日誌中。此錯誤日誌可用於鎖定登入失敗次數過多的帳戶或 IP 位址。如果使用者名稱和密碼有效,提供者會將 UserModel 與 AuthenticationSessionModel 相關聯,並返回 status() 狀態。
-
下一個執行是一個名為 Conditional OTP 的子流程。載入此子流程的執行,並執行相同的處理邏輯。其 Requirement 為 Conditional。這表示流程會先評估其中包含的所有條件執行器。條件執行器是實作
ConditionalAuthenticator
的驗證器,且必須實作方法boolean matchCondition(AuthenticationFlowContext context)
。條件子流程會呼叫其包含的所有條件執行的matchCondition
方法,如果它們都評估為 true,則會像它是必要子流程一樣運作。如果不是,則會像它是停用子流程一樣運作。條件驗證器僅用於此目的,不作為驗證器使用。這表示即使條件驗證器評估為「true」,也不會將流程或子流程標記為成功。例如,僅包含一個條件子流程且僅具有一個條件驗證器的流程永遠不會允許使用者登入。 -
Conditional OTP 子流程的第一個執行是 Condition - User Configured。此提供者要求使用者已與流程相關聯。此要求已滿足,因為 UsernamePassword 提供者已將使用者與流程相關聯。此提供者的
matchCondition
方法會評估目前子流程中所有其他驗證器的configuredFor
方法。如果子流程包含 Requirement 設定為必要的執行器,則只有當所有必要驗證器的configuredFor
方法都評估為 true 時,matchCondition
方法才會評估為 true。否則,如果任何替代驗證器評估為 true,則matchCondition
方法將評估為 true。 -
下一個執行是 OTP Form。此提供者也要求使用者已與流程相關聯。此要求已滿足,因為 UsernamePassword 提供者已將使用者與流程相關聯。由於此提供者需要使用者,因此也會詢問是否已設定使用者使用此提供者。如果使用者未設定,則流程會設定一個必要動作,使用者必須在驗證完成後執行。對於 OTP,這表示 OTP 設定頁面。如果已設定使用者,系統會要求他輸入其 OTP 代碼。在我們的案例中,由於條件子流程,除非 Conditional OTP 子流程設定為 Required,否則使用者永遠不會看到 OTP 登入頁面。
-
流程完成後,驗證處理器會建立 UserSessionModel,並將其與 AuthenticationSessionModel 相關聯。然後,它會檢查使用者是否需要在登入前完成任何必要動作。
-
首先,會呼叫每個必要動作的 evaluateTriggers() 方法。這允許必要動作提供者確定是否存在可能觸發該動作的某些狀態。例如,如果您的領域有密碼過期原則,則可能會由這個方法觸發。
-
會呼叫與使用者相關聯的每個必要動作的 requiredActionChallenge() 方法。在這裡,提供者會設定一個 HTTP 回應,該回應會呈現必要動作的頁面。這是透過設定 challenge 狀態來完成的。
-
如果必要動作最終成功,則會從使用者的必要動作清單中移除該動作。
-
在所有必要動作都已解決後,使用者最終會登入。
驗證器 SPI 逐步解說
在本節中,我們將查看 Authenticator 介面。為此,我們將實作一個驗證器,該驗證器要求使用者輸入一個秘密問題的答案,例如「您母親的娘家姓是什麼?」。此範例已完全實作,並包含在 Keycloak Quickstarts Repository 儲存庫的 extension/authenticator
下方。
若要建立驗證器,您必須至少實作 org.keycloak.authentication.AuthenticatorFactory 和 Authenticator 介面。Authenticator 介面定義邏輯。AuthenticatorFactory 負責建立 Authenticator 的執行個體。它們都延伸了更通用的 Provider 和 ProviderFactory 介面集,其他 Keycloak 元件(例如使用者聯盟)也是如此。
某些驗證器(例如 CookieAuthenticator)不依賴使用者擁有或知道的認證來驗證使用者。但是,某些驗證器(例如 PasswordForm 驗證器或 OTPFormAuthenticator)依賴使用者輸入某些資訊,並根據資料庫中的某些資訊驗證該資訊。例如,對於 PasswordForm,驗證器會根據資料庫中儲存的雜湊值驗證密碼的雜湊值,而 OTPFormAuthenticator 會根據從資料庫中儲存的共用秘密產生的 OTP 驗證收到的 OTP。
這些類型的驗證器稱為 CredentialValidators,它們會要求您實作更多類別
-
一個延伸 org.keycloak.credential.CredentialModel 的類別,並且可以在資料庫中產生正確格式的認證
-
一個實作 org.keycloak.credential.CredentialProvider 介面的類別,以及一個實作其 CredentialProviderFactory 工廠介面的類別。
我們將在此逐步解說中看到的 SecretQuestionAuthenticator 是一個 CredentialValidator,因此我們將了解如何實作所有這些類別。
封裝類別和部署
您將在單個 jar 中封裝您的類別。此 jar 必須包含一個名為 org.keycloak.authentication.AuthenticatorFactory
的檔案,並且必須包含在 jar 的 META-INF/services/
目錄中。此檔案必須列出 jar 中每個 AuthenticatorFactory 實作的完整類別名稱。例如
org.keycloak.examples.authenticator.SecretQuestionAuthenticatorFactory
org.keycloak.examples.authenticator.AnotherProviderFactory
Keycloak 使用此 services/ 檔案掃描它必須載入系統的提供者。
若要部署此 jar,只需將其複製到 providers 目錄即可。
延伸 CredentialModel 類別
在 Keycloak 中,認證會儲存在資料庫的 Credentials 表格中。它具有以下結構
----------------------------- | ID | ----------------------------- | user_ID | ----------------------------- | credential_type | ----------------------------- | created_date | ----------------------------- | user_label | ----------------------------- | secret_data | ----------------------------- | credential_data | ----------------------------- | priority | -----------------------------
其中
-
ID
是認證的主鍵。 -
user_ID
是將認證連結到使用者的外鍵。 -
credential_type
是一個在建立期間設定的字串,必須參考現有的認證類型。 -
created_date
是認證的建立時間戳記(長格式)。 -
user_label
是使用者可編輯的認證名稱 -
secret_data
包含一個靜態 json,其中包含無法在 Keycloak 外部傳輸的資訊 -
credential_data
包含一個 json,其中包含可在管理主控台或透過 REST API 共用的認證靜態資訊。 -
priority
定義認證對使用者的「偏好」程度,以確定當使用者有多個選擇時要呈現哪個認證。
由於 secret_data 和 credential_data 欄位旨在包含 json,因此由您決定如何建構、讀取和寫入這些欄位,這讓您擁有很大的彈性。
在此範例中,我們將使用非常簡單的認證資料,其中僅包含詢問使用者的問題
{
"question":"aQuestion"
}
以及同樣簡單的秘密資料,其中僅包含秘密答案
{
"answer":"anAnswer"
}
在這裡,為求簡潔起見,答案將以純文字形式保留在資料庫中,但也可以對答案使用加鹽雜湊,就像 Keycloak 中的密碼一樣。在這種情況下,秘密資料也必須包含一個鹽的欄位,而認證資料則包含有關演算法的資訊,例如所使用的演算法類型和使用的迭代次數。如需更多詳細資訊,您可以參考 org.keycloak.models.credential.PasswordCredentialModel
類別的實作。
在我們的案例中,我們建立 SecretQuestionCredentialModel
類別
public class SecretQuestionCredentialModel extends CredentialModel {
public static final String TYPE = "SECRET_QUESTION";
private final SecretQuestionCredentialData credentialData;
private final SecretQuestionSecretData secretData;
其中 TYPE
是我們在資料庫中寫入的 credential_type。為了保持一致性,我們確保在取得此憑證的類型時,此字串永遠是指向同一個。SecretQuestionCredentialData
和 SecretQuestionSecretData
類別用於編組和解編 JSON。
public class SecretQuestionCredentialData {
private final String question;
@JsonCreator
public SecretQuestionCredentialData(@JsonProperty("question") String question) {
this.question = question;
}
public String getQuestion() {
return question;
}
}
public class SecretQuestionSecretData {
private final String answer;
@JsonCreator
public SecretQuestionSecretData(@JsonProperty("answer") String answer) {
this.answer = answer;
}
public String getAnswer() {
return answer;
}
}
為了完全可用,SecretQuestionCredentialModel
物件必須同時包含來自其父類別的原始 JSON 資料,以及在其自身屬性中解編的物件。這引導我們建立一個方法,該方法從一個簡單的 CredentialModel 讀取(例如從資料庫讀取時建立的),以建立一個 SecretQuestionCredentialModel
。
private SecretQuestionCredentialModel(SecretQuestionCredentialData credentialData, SecretQuestionSecretData secretData) {
this.credentialData = credentialData;
this.secretData = secretData;
}
public static SecretQuestionCredentialModel createFromCredentialModel(CredentialModel credentialModel){
try {
SecretQuestionCredentialData credentialData = JsonSerialization.readValue(credentialModel.getCredentialData(), SecretQuestionCredentialData.class);
SecretQuestionSecretData secretData = JsonSerialization.readValue(credentialModel.getSecretData(), SecretQuestionSecretData.class);
SecretQuestionCredentialModel secretQuestionCredentialModel = new SecretQuestionCredentialModel(credentialData, secretData);
secretQuestionCredentialModel.setUserLabel(credentialModel.getUserLabel());
secretQuestionCredentialModel.setCreatedDate(credentialModel.getCreatedDate());
secretQuestionCredentialModel.setType(TYPE);
secretQuestionCredentialModel.setId(credentialModel.getId());
secretQuestionCredentialModel.setSecretData(credentialModel.getSecretData());
secretQuestionCredentialModel.setCredentialData(credentialModel.getCredentialData());
return secretQuestionCredentialModel;
} catch (IOException e){
throw new RuntimeException(e);
}
}
還有一個方法可以從問題和答案建立 SecretQuestionCredentialModel
。
private SecretQuestionCredentialModel(String question, String answer) {
credentialData = new SecretQuestionCredentialData(question);
secretData = new SecretQuestionSecretData(answer);
}
public static SecretQuestionCredentialModel createSecretQuestion(String question, String answer) {
SecretQuestionCredentialModel credentialModel = new SecretQuestionCredentialModel(question, answer);
credentialModel.fillCredentialModelFields();
return credentialModel;
}
private void fillCredentialModelFields(){
try {
setCredentialData(JsonSerialization.writeValueAsString(credentialData));
setSecretData(JsonSerialization.writeValueAsString(secretData));
setType(TYPE);
setCreatedDate(Time.currentTimeMillis());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
實作 CredentialProvider
如同所有的 Provider,為了讓 Keycloak 生成 CredentialProvider,我們需要一個 CredentialProviderFactory。為滿足此要求,我們建立 SecretQuestionCredentialProviderFactory,當請求 SecretQuestionCredentialProvider 時,將會呼叫其 create
方法。
public class SecretQuestionCredentialProviderFactory implements CredentialProviderFactory<SecretQuestionCredentialProvider> {
public static final String PROVIDER_ID = "secret-question";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public CredentialProvider create(KeycloakSession session) {
return new SecretQuestionCredentialProvider(session);
}
}
CredentialProvider 介面採用一個泛型參數,該參數繼承自 CredentialModel。在我們的案例中,我們使用我們建立的 SecretQuestionCredentialModel。
public class SecretQuestionCredentialProvider implements CredentialProvider<SecretQuestionCredentialModel>, CredentialInputValidator {
private static final Logger logger = Logger.getLogger(SecretQuestionCredentialProvider.class);
protected KeycloakSession session;
public SecretQuestionCredentialProvider(KeycloakSession session) {
this.session = session;
}
我們還想實作 CredentialInputValidator 介面,因為這允許 Keycloak 知道此提供者也可以用於驗證驗證器的憑證。對於 CredentialProvider 介面,需要實作的第一個方法是 getType()
方法。這將簡單地回傳 `SecretQuestionCredentialModel’ 的 TYPE 字串。
@Override
public String getType() {
return SecretQuestionCredentialModel.TYPE;
}
第二個方法是從 CredentialModel
建立一個 SecretQuestionCredentialModel
。對於此方法,我們只需呼叫 SecretQuestionCredentialModel
中現有的靜態方法。
@Override
public SecretQuestionCredentialModel getCredentialFromModel(CredentialModel model) {
return SecretQuestionCredentialModel.createFromCredentialModel(model);
}
最後,我們有建立憑證和刪除憑證的方法。這些方法呼叫 UserModel 的憑證管理器,該管理器負責知道在哪裡讀取或寫入憑證,例如本機儲存或聯合儲存。
@Override
public CredentialModel createCredential(RealmModel realm, UserModel user, SecretQuestionCredentialModel credentialModel) {
if (credentialModel.getCreatedDate() == null) {
credentialModel.setCreatedDate(Time.currentTimeMillis());
}
return user.credentialManager().createStoredCredential(credentialModel);
}
@Override
public boolean deleteCredential(RealmModel realm, UserModel user, String credentialId) {
return user.credentialManager().removeStoredCredentialById(credentialId);
}
對於 CredentialInputValidator,要實作的主要方法是 isValid
,它會測試憑證對於給定領域中的給定使用者是否有效。這是驗證器在嘗試驗證使用者輸入時所呼叫的方法。在這裡,我們只需要檢查輸入的字串是否與憑證中記錄的字串相同。
@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
if (!(input instanceof UserCredentialModel)) {
logger.debug("Expected instance of UserCredentialModel for CredentialInput");
return false;
}
if (!input.getType().equals(getType())) {
return false;
}
String challengeResponse = input.getChallengeResponse();
if (challengeResponse == null) {
return false;
}
CredentialModel credentialModel = getCredentialStore().getStoredCredentialById(realm, user, input.getCredentialId());
SecretQuestionCredentialModel sqcm = getCredentialFromModel(credentialModel);
return sqcm.getSecretQuestionSecretData().getAnswer().equals(challengeResponse);
}
要實作的其他兩個方法是測試 CredentialProvider 是否支援給定的憑證類型,以及檢查給定使用者是否已設定憑證類型。對於我們的案例,後者測試僅表示檢查使用者是否具有 SECRET_QUESTION 類型的憑證。
@Override
public boolean supportsCredentialType(String credentialType) {
return getType().equals(credentialType);
}
@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
if (!supportsCredentialType(credentialType)) return false;
return !getCredentialStore().getStoredCredentialsByType(realm, user, credentialType).isEmpty();
}
實作驗證器
在實作使用憑證來驗證使用者的驗證器時,您應該讓驗證器實作 CredentialValidator 介面。此介面採用一個繼承自 CredentialProvider 的類別作為參數,並允許 Keycloak 直接呼叫 CredentialProvider 中的方法。唯一需要實作的方法是 getCredentialProvider
方法,在我們的範例中,它允許 SecretQuestionAuthenticator 檢索 SecretQuestionCredentialProvider。
public SecretQuestionCredentialProvider getCredentialProvider(KeycloakSession session) {
return (SecretQuestionCredentialProvider)session.getProvider(CredentialProvider.class, SecretQuestionCredentialProviderFactory.PROVIDER_ID);
}
在實作 Authenticator 介面時,需要實作的第一個方法是 requiresUser() 方法。在我們的範例中,此方法必須回傳 true,因為我們需要驗證與使用者相關聯的密碼問題。像 Kerberos 這樣的提供者會從此方法回傳 false,因為它可以從協商標頭解析使用者。但是,此範例是驗證特定使用者的特定憑證。
下一個要實作的方法是 configuredFor() 方法。此方法負責判斷是否已針對此特定驗證器設定使用者。在我們的案例中,我們可以簡單地呼叫在 SecretQuestionCredentialProvider 中實作的方法。
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return getCredentialProvider(session).isConfiguredFor(realm, user, getType(session));
}
Authenticator 上要實作的下一個方法是 setRequiredActions()。如果 configuredFor() 回傳 false 且我們的範例驗證器在流程中是必要的,則將呼叫此方法,但前提是關聯的 AuthenticatorFactory 的 isUserSetupAllowed
方法回傳 true。setRequiredActions() 方法負責註冊使用者必須執行的任何必要動作。在我們的範例中,我們需要註冊一個必要動作,該動作將強制使用者設定密碼問題的答案。我們將在本章稍後實作此必要動作提供者。以下是 setRequiredActions() 方法的實作。
@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
user.addRequiredAction("SECRET_QUESTION_CONFIG");
}
現在我們將深入探討 Authenticator 的實作核心。下一個要實作的方法是 authenticate()。這是流程首次訪問執行時所調用的初始方法。我們想要的是,如果使用者已在其瀏覽器的機器上回答了密碼問題,則使用者不必再次回答該問題,使該機器成為「受信任的」。authenticate() 方法不負責處理密碼問題表單。它的唯一目的是呈現頁面或繼續流程。
@Override
public void authenticate(AuthenticationFlowContext context) {
if (hasCookie(context)) {
context.success();
return;
}
Response challenge = context.form()
.createForm("secret-question.ftl");
context.challenge(challenge);
}
protected boolean hasCookie(AuthenticationFlowContext context) {
Cookie cookie = context.getHttpRequest().getHttpHeaders().getCookies().get("SECRET_QUESTION_ANSWERED");
boolean result = cookie != null;
if (result) {
System.out.println("Bypassing secret question because cookie is set");
}
return result;
}
hasCookie() 方法檢查瀏覽器上是否已設定 cookie,表明已回答密碼問題。如果它回傳 true,我們只需使用 AuthenticationFlowContext.success() 方法將此執行的狀態標記為 SUCCESS,然後從 authentication() 方法回傳。
如果 hasCookie() 方法回傳 false,我們必須回傳一個呈現密碼問題 HTML 表單的回應。AuthenticationFlowContext 有一個 form() 方法,該方法使用建立表單所需的適當基礎資訊來初始化 Freemarker 頁面建立器。此頁面建立器稱為 org.keycloak.login.LoginFormsProvider
。LoginFormsProvider.createForm() 方法從您的登入主題載入 Freemarker 範本檔案。此外,如果您想將其他資訊傳遞給 Freemarker 範本,則可以呼叫 LoginFormsProvider.setAttribute() 方法。我們稍後將討論此問題。
呼叫 LoginFormsProvider.createForm() 會回傳一個 JAX-RS Response 物件。然後,我們呼叫 AuthenticationFlowContext.challenge() 並傳入此回應。這會將執行的狀態設定為 CHALLENGE,如果執行是 Required,則此 JAX-RS Response 物件將會傳送到瀏覽器。
因此,會向使用者顯示要求回答密碼問題的 HTML 頁面,使用者輸入答案並按一下「提交」。HTML 表單的 action URL 會將 HTTP 請求傳送到流程。流程最終會呼叫我們 Authenticator 實作的 action() 方法。
@Override
public void action(AuthenticationFlowContext context) {
boolean validated = validateAnswer(context);
if (!validated) {
Response challenge = context.form()
.setError("badSecret")
.createForm("secret-question.ftl");
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challenge);
return;
}
setCookie(context);
context.success();
}
如果答案無效,我們將使用其他錯誤訊息重建 HTML 表單。然後,我們呼叫 AuthenticationFlowContext.failureChallenge() 並傳入值的原因和 JAX-RS 回應。failureChallenge() 的工作方式與 challenge() 相同,但它也會記錄失敗,以便任何攻擊偵測服務可以分析它。
如果驗證成功,那麼我們會設定一個 cookie 來記住已回答密碼問題,並且我們呼叫 AuthenticationFlowContext.success()。
驗證本身會取得從表單接收到的資料,並呼叫 SecretQuestionCredentialProvider 中的 isValid 方法。您會注意到有一段程式碼是關於取得憑證 ID。這是因為如果 Keycloak 設定為允許多種替代驗證器類型,或者如果使用者可以記錄多個 SECRET_QUESTION 類型的憑證(例如,如果我們允許從幾個問題中選擇,並且我們允許使用者擁有不止一個問題的答案),那麼 Keycloak 需要知道正在使用哪個憑證來登入使用者。如果有多個憑證,Keycloak 允許使用者在登入期間選擇正在使用的憑證,並且該資訊會透過表單傳輸到驗證器。如果表單未顯示此資訊,則使用的憑證 ID 由 CredentialProvider 的 default getDefaultCredential
方法提供,該方法將回傳使用者的正確類型「最優先」憑證。
protected boolean validateAnswer(AuthenticationFlowContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
String secret = formData.getFirst("secret_answer");
String credentialId = formData.getFirst("credentialId");
if (credentialId == null || credentialId.isEmpty()) {
credentialId = getCredentialProvider(context.getSession())
.getDefaultCredential(context.getSession(), context.getRealm(), context.getUser()).getId();
}
UserCredentialModel input = new UserCredentialModel(credentialId, getType(context.getSession()), secret);
return getCredentialProvider(context.getSession()).isValid(context.getRealm(), context.getUser(), input);
}
下一個方法是 setCookie()。這是為驗證器提供設定的範例。在這種情況下,我們希望 cookie 的最大存留期是可設定的。
protected void setCookie(AuthenticationFlowContext context) {
AuthenticatorConfigModel config = context.getAuthenticatorConfig();
int maxCookieAge = 60 * 60 * 24 * 30; // 30 days
if (config != null) {
maxCookieAge = Integer.valueOf(config.getConfig().get("cookie.max.age"));
}
URI uri = context.getUriInfo().getBaseUriBuilder().path("realms").path(context.getRealm().getName()).build();
addCookie(context, "SECRET_QUESTION_ANSWERED", "true",
uri.getRawPath(),
null, null,
maxCookieAge,
false, true);
}
我們從 AuthenticationFlowContext.getAuthenticatorConfig() 方法取得 AuthenticatorConfigModel。如果存在設定,我們會從中取出最大存留期設定。當我們討論 AuthenticatorFactory 實作時,我們將看到如何定義應設定的內容。如果您在 AuthenticatorFactory 實作中設定設定定義,則可以在管理控制台中定義設定值。
@Override
public CredentialTypeMetadata getCredentialTypeMetadata(CredentialTypeMetadataContext metadataContext) {
return CredentialTypeMetadata.builder()
.type(getType())
.category(CredentialTypeMetadata.Category.TWO_FACTOR)
.displayName(SecretQuestionCredentialProviderFactory.PROVIDER_ID)
.helpText("secret-question-text")
.createAction(SecretQuestionAuthenticatorFactory.PROVIDER_ID)
.removeable(false)
.build(session);
}
SecretQuestionCredentialProvider 類別中要實作的最後一個方法是 getCredentialTypeMetadata(CredentialTypeMetadataContext metadataContext),它是 CredentialProvider 介面的抽象方法。每個憑證提供者都必須提供並實作此方法。該方法回傳 CredentialTypeMetadata 的執行個體,該執行個體至少應包括驗證器的類型和類別、顯示名稱和可移除項目。在此範例中,建立器從方法 getType() 取得驗證器的類型,類別是雙因素(驗證器可以用作第二個驗證因素),以及可移除的,該值設定為 false(使用者無法移除一些先前註冊的憑證)。
建立器的其他項目是 helpText(將在各種螢幕上向使用者顯示)、createAction(必要動作的 providerID,使用者可以使用該動作來建立新憑證)或 updateAction(與 createAction 相同,但不會建立新憑證,而是會更新憑證)。
實作 AuthenticatorFactory
此程序中的下一個步驟是實作 AuthenticatorFactory。此工廠負責實例化驗證器。它還提供有關驗證器的部署和設定中繼資料。
getId() 方法只是元件的唯一名稱。執行階段會呼叫 create() 方法來配置和處理驗證器。
public class SecretQuestionAuthenticatorFactory implements AuthenticatorFactory, ConfigurableAuthenticatorFactory {
public static final String PROVIDER_ID = "secret-question-authenticator";
private static final SecretQuestionAuthenticator SINGLETON = new SecretQuestionAuthenticator();
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public Authenticator create(KeycloakSession session) {
return SINGLETON;
}
工廠負責的下一件事是指定允許的需求開關。雖然有四種不同的需求類型:ALTERNATIVE、REQUIRED、CONDITIONAL、DISABLED,但 AuthenticatorFactory 實作可以限制在定義流程時管理控制台中顯示哪些需求選項。CONDITIONAL 應僅始終用於子流程,並且除非有充分的理由,否則驗證器上的需求應為 REQUIRED、ALTERNATIVE 和 DISABLED。
private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.ALTERNATIVE,
AuthenticationExecutionModel.Requirement.DISABLED
};
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
AuthenticatorFactory.isUserSetupAllowed() 是一個旗標,它會告知流程管理器是否會呼叫 Authenticator.setRequiredActions() 方法。如果未針對使用者設定驗證器,則流程管理器會檢查 isUserSetupAllowed()。如果它為 false,則流程會中止並發生錯誤。如果它回傳 true,則流程管理器將會呼叫 Authenticator.setRequiredActions()。
@Override
public boolean isUserSetupAllowed() {
return true;
}
接下來的幾個方法定義如何設定驗證器。isConfigurable() 方法是一個旗標,它會向管理控制台指定是否可以在流程中設定驗證器。getConfigProperties() 方法回傳 ProviderConfigProperty 物件的清單。這些物件定義特定的設定屬性。
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return configProperties;
}
private static final List<ProviderConfigProperty> configProperties = new ArrayList<ProviderConfigProperty>();
static {
ProviderConfigProperty property;
property = new ProviderConfigProperty();
property.setName("cookie.max.age");
property.setLabel("Cookie Max Age");
property.setType(ProviderConfigProperty.STRING_TYPE);
property.setHelpText("Max age in seconds of the SECRET_QUESTION_COOKIE.");
configProperties.add(property);
}
每個 ProviderConfigProperty 都定義設定屬性的名稱。這是儲存在 AuthenticatorConfigModel 中的設定對應中使用的索引鍵。標籤定義設定選項在管理控制台中將如何顯示。類型定義它是字串、布林值或其他類型。管理控制台將根據類型顯示不同的 UI 輸入。說明文字是在管理控制台中設定屬性的工具提示中顯示的內容。請閱讀 ProviderConfigProperty 的 javadoc 以瞭解更多詳細資訊。
其餘方法適用於管理控制台。getHelpText() 是當您選擇要綁定到執行的驗證器時將顯示的工具提示文字。getDisplayType() 是在管理控制台中列出驗證器時將顯示的文字。getReferenceCategory() 只是驗證器所屬的類別。
新增驗證器表單
Keycloak 附帶 Freemarker 主題和範本引擎。您在驗證器類別的 authenticate() 中呼叫的 createForm() 方法,會從您的登入主題中的檔案建立 HTML 頁面:secret-question.ftl
。此檔案應新增至 JAR 中的 theme-resources/templates
,請參閱 主題資源提供者 以瞭解更多詳細資訊。
讓我們更仔細地看一下 secret-question.ftl。以下是一小段程式碼片段
<form id="kc-totp-login-form" class="${properties.kcFormClass!}" action="${url.loginAction}" method="post">
<div class="${properties.kcFormGroupClass!}">
<div class="${properties.kcLabelWrapperClass!}">
<label for="totp" class="${properties.kcLabelClass!}">${msg("loginSecretQuestion")}</label>
</div>
<div class="${properties.kcInputWrapperClass!}">
<input id="totp" name="secret_answer" type="text" class="${properties.kcInputClass!}" />
</div>
</div>
</form>
任何以 ${}
括起來的文字都對應到屬性或範本函式。如果您看到表單的 action,您會看到它指向 ${url.loginAction}
。此值會在您調用 AuthenticationFlowContext.form() 方法時自動產生。您也可以在 Java 程式碼中呼叫 AuthenticationFlowContext.getActionURL() 方法來取得此值。
您也會看到 ${properties.someValue}
。這些對應到您主題的 theme.properties 檔案中定義的屬性。 ${msg("someValue")}
對應到登入主題 messages/ 目錄中包含的國際化訊息套件(.properties 檔案)。如果您只使用英文,您只需新增 loginSecretQuestion
的值即可。這應該是您要詢問使用者的問題。
當您呼叫 AuthenticationFlowContext.form() 時,這會給您一個 LoginFormsProvider 實例。如果您呼叫 LoginFormsProvider.setAttribute("foo", "bar")
,「foo」的值將可在您的表單中以 ${foo}
的形式參照。屬性的值也可以是任何 Java Bean。
如果您查看檔案頂部,您會看到我們正在匯入一個範本
<#import "select.ftl" as layout>
匯入這個範本,而不是標準的 template.ftl
,可以讓 Keycloak 顯示一個下拉式選單,讓使用者選擇不同的憑證或執行。
將驗證器新增至流程
必須在管理控制台中完成將驗證器新增至流程的操作。如果您前往「驗證」選單項目並前往「流程」標籤,您將能夠檢視目前定義的流程。您無法修改內建的流程,因此,若要新增我們建立的驗證器,您必須複製現有的流程或建立自己的流程。我們希望使用者介面夠清楚,讓您可以判斷如何建立流程並新增驗證器。如需更多詳細資訊,請參閱伺服器管理指南中的「驗證流程
」章節。
建立流程後,您必須將它繫結至您要繫結的登入動作。如果您前往「驗證」選單並前往「繫結」標籤,您會看到將流程繫結至瀏覽器、註冊或直接授與流程的選項。
必要動作導覽
在本節中,我們將討論如何定義必要動作。在驗證器章節中,您可能會想:「我們要如何取得使用者輸入到系統中的秘密問題答案?」。正如我們在範例中顯示的,如果未設定答案,則會觸發必要動作。本節討論如何實作秘密問題驗證器的必要動作。
封裝類別和部署
您將把您的類別封裝在單一的 jar 檔案中。此 jar 檔案不必與其他提供者類別分開,但它必須包含一個名為 org.keycloak.authentication.RequiredActionFactory
的檔案,並且必須包含在您 jar 檔案的 META-INF/services/
目錄中。此檔案必須列出 jar 檔案中每個 RequiredActionFactory 實作的完整類別名稱。例如
org.keycloak.examples.authenticator.SecretQuestionRequiredActionFactory
Keycloak 使用此 services/ 檔案掃描它必須載入系統的提供者。
若要部署此 jar 檔案,請將它複製到 providers/
目錄,然後執行 bin/kc.[sh|bat] build
。
實作 RequiredActionProvider
必要動作必須先實作 RequiredActionProvider 介面。RequiredActionProvider.requiredActionChallenge() 是流程管理員對必要動作的初始呼叫。此方法負責呈現將驅動必要動作的 HTML 表單。
@Override
public void requiredActionChallenge(RequiredActionContext context) {
Response challenge = context.form().createForm("secret_question_config.ftl");
context.challenge(challenge);
}
您會看到 RequiredActionContext 具有與 AuthenticationFlowContext 類似的方法。form() 方法允許您從 Freemarker 範本呈現頁面。動作 URL 會預先設定為對這個 form() 方法的呼叫。您只需要在您的 HTML 表單中參考它即可。稍後我會向您展示這一點。
challenge() 方法會通知流程管理員必須執行必要動作。
下一個方法負責處理來自必要動作 HTML 表單的輸入。表單的動作 URL 將會路由到 RequiredActionProvider.processAction() 方法
@Override
public void processAction(RequiredActionContext context) {
String answer = (context.getHttpRequest().getDecodedFormParameters().getFirst("answer"));
UserCredentialValueModel model = new UserCredentialValueModel();
model.setValue(answer);
model.setType(SecretQuestionAuthenticator.CREDENTIAL_TYPE);
context.getUser().updateCredentialDirectly(model);
context.success();
}
答案會從表單 post 中取出。建立 UserCredentialValueModel,並設定憑證的類型和值。然後調用 UserModel.updateCredentialDirectly()。最後,RequiredActionContext.success() 會通知容器必要動作已成功。
實作 RequiredActionFactory
這個類別真的很簡單。它只負責建立必要動作提供者實例。
public class SecretQuestionRequiredActionFactory implements RequiredActionFactory {
private static final SecretQuestionRequiredAction SINGLETON = new SecretQuestionRequiredAction();
@Override
public RequiredActionProvider create(KeycloakSession session) {
return SINGLETON;
}
@Override
public String getId() {
return SecretQuestionRequiredAction.PROVIDER_ID;
}
@Override
public String getDisplayText() {
return "Secret Question";
}
getDisplayText() 方法僅供管理控制台在想要顯示必要動作的友善名稱時使用。
修改或擴充註冊表單
您完全可以使用一組驗證器來實作自己的流程,以完全變更 Keycloak 中註冊的執行方式。但您通常想要做的只是為現成的註冊頁面新增一些驗證。建立了一個額外的 SPI 來執行此操作。它基本上允許您在頁面上新增表單元素的驗證,以及在使用者註冊後初始化 UserModel 屬性和資料。我們將檢視使用者設定檔註冊處理的實作以及註冊 Google reCAPTCHA Enterprise 外掛程式。
實作 FormAction 介面
您必須實作的核心介面是 FormAction 介面。FormAction 負責呈現和處理頁面的一部分。呈現是在 buildPage() 方法中完成,驗證是在 validate() 方法中完成,驗證後操作是在 success() 中完成。讓我們先看看 Recaptcha 外掛程式的 buildPage() 方法。
@Override
public void buildPage(FormContext context, LoginFormsProvider form) {
Map<String, String> config = context.getAuthenticatorConfig().getConfig();
if (config == null
|| Stream.of(PROJECT_ID, SITE_KEY, API_KEY, ACTION)
.anyMatch(key -> Strings.isNullOrEmpty(config.get(key)))
|| parseDoubleFromConfig(config, SCORE_THRESHOLD) == null) {
form.addError(new FormMessage(null, Messages.RECAPTCHA_NOT_CONFIGURED));
return;
}
String userLanguageTag = context.getSession().getContext().resolveLocale(context.getUser())
.toLanguageTag();
boolean invisible = Boolean.parseBoolean(config.getOrDefault(INVISIBLE, "true"));
form.setAttribute("recaptchaRequired", true);
form.setAttribute("recaptchaSiteKey", config.get(SITE_KEY));
form.setAttribute("recaptchaAction", config.get(ACTION));
form.setAttribute("recaptchaVisible", !invisible);
form.addScript("https://www.google.com/recaptcha/enterprise.js?hl=" + userLanguageTag);
}
Recaptcha buildPage() 方法是表單流程的回呼,以協助呈現頁面。它接收一個表單參數,該參數是一個 LoginFormsProvider。您可以將其他屬性新增至表單提供者,以便它們可以顯示在註冊 Freemarker 範本產生的 HTML 頁面中。
上面的程式碼來自註冊 recaptcha 外掛程式。Recaptcha 需要從組態取得的一些特定設定。FormAction 的組態方式與驗證器完全相同。在此範例中,我們從 Recaptcha 組態中提取 Google Recaptcha 網站金鑰和其他選項,並將它們作為屬性新增至表單提供者。我們的註冊範本檔案 register.ftl 現在可以存取這些屬性。
Recaptcha 也需要載入 JavaScript 腳本。您可以呼叫 LoginFormsProvider.addScript(),並傳入 URL 來執行此操作。
對於使用者設定檔處理,沒有其他需要新增至表單的資訊,因此其 buildPage() 方法是空的。
這個介面的下一個重要部分是 validate() 方法。這會在收到表單 post 後立即呼叫。讓我們首先看看 Recaptcha 的外掛程式。
@Override
public void validate(ValidationContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
String captcha = formData.getFirst(G_RECAPTCHA_RESPONSE);
if (!Validation.isBlank(captcha) && validateRecaptcha(context, captcha)) {
context.success();
} else {
List<FormMessage> errors = new ArrayList<>();
errors.add(new FormMessage(null, Messages.RECAPTCHA_FAILED));
formData.remove(G_RECAPTCHA_RESPONSE);
context.validationError(formData, errors);
}
}
在這裡,我們取得 Recaptcha 小工具新增至表單的表單資料。我們從組態取得 Recaptcha 秘密金鑰。然後我們驗證 recaptcha。如果成功,則會呼叫 ValidationContext.success()。我們使用 formData.remove 從表單中清除驗證碼權杖,但保持其他表單資料不變。如果失敗,我們則會調用 ValidationContext.validationError(),並傳入 formData(因此使用者不必重新輸入資料),我們還會指定要顯示的錯誤訊息。錯誤訊息必須指向國際化訊息套件中的訊息套件屬性。對於其他註冊延伸,validate() 可能會驗證表單元素的格式,例如替代電子郵件屬性。
我們也來看看用於驗證註冊時電子郵件地址和其他使用者資訊的使用者設定檔外掛程式。
@Override
public void validate(ValidationContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
context.getEvent().detail(Details.REGISTER_METHOD, "form");
UserProfile profile = getOrCreateUserProfile(context, formData);
try {
profile.validate();
} catch (ValidationException pve) {
List<FormMessage> errors = Validation.getFormErrorsFromValidation(pve.getErrors());
if (pve.hasError(Messages.EMAIL_EXISTS, Messages.INVALID_EMAIL)) {
context.getEvent().detail(Details.EMAIL, profile.getAttributes().getFirstValue(UserModel.EMAIL));
}
if (pve.hasError(Messages.EMAIL_EXISTS)) {
context.error(Errors.EMAIL_IN_USE);
} else if (pve.hasError(Messages.USERNAME_EXISTS)) {
context.error(Errors.USERNAME_IN_USE);
} else {
context.error(Errors.INVALID_REGISTRATION);
}
context.validationError(formData, errors);
return;
}
context.success();
}
如您所見,此使用者設定檔處理的 validate() 方法會確保表單中填寫了電子郵件和所有其他屬性。它委派給使用者設定檔 SPI,這會確保電子郵件格式正確並執行所有其他驗證。如果這些驗證中的任何一個失敗,則會將錯誤訊息排隊以進行呈現。它會包含每個驗證失敗的欄位的訊息。
如您所見,使用者設定檔會確保註冊表單包含所有需要的使用者設定檔欄位。使用者設定檔也會確保使用正確的驗證,屬性會正確地分組在頁面上。每個欄位都會使用正確的類型(例如,如果使用者需要從預定義的值中選擇),並且欄位只會針對某些範圍「有條件地」呈現(漸進式設定檔)等。因此,通常您不需要實作新的 FormAction 或註冊欄位,但您可以正確設定使用者設定檔來反映這一點。如需更多詳細資訊,請參閱使用者設定檔文件。一般而言,如果您想要將新的憑證新增至註冊表單(例如這裡提到的 ReCaptcha 支援),而不是新增使用者設定檔欄位,則新的 FormAction 可能會很有用。 |
在處理完所有驗證後,表單流程會接著調用 FormAction.success() 方法。對於 recaptcha 而言,這是一個無操作,因此我們不會再討論它。對於使用者設定檔處理,此方法會填入已註冊使用者的值。
@Override
public void success(FormContext context) {
checkNotOtherUserAuthenticating(context);
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
String email = formData.getFirst(UserModel.EMAIL);
String username = formData.getFirst(UserModel.USERNAME);
if (context.getRealm().isRegistrationEmailAsUsername()) {
username = email;
}
context.getEvent().detail(Details.USERNAME, username)
.detail(Details.REGISTER_METHOD, "form")
.detail(Details.EMAIL, email);
UserProfile profile = getOrCreateUserProfile(context, formData);
UserModel user = profile.create();
user.setEnabled(true);
// This means that following actions can retrieve user from the context by context.getUser() method
context.setUser(user);
}
建立新的使用者,並將新註冊使用者的 UserModel 新增至 FormContext。會呼叫適當的方法來初始化 UserModel 資料。在您自己的 FormAction 中,您可能會使用類似以下的方法來取得使用者
@Override
public void success(FormContext context) {
UserModel user = context.getUser();
if (user != null) {
// Do something useful with the user here ...
}
}
最後,您還需要定義一個 FormActionFactory 類別。這個類別的實作方式與 AuthenticatorFactory 類似,因此我們不會再討論它。
封裝動作
您將把您的類別封裝在單一的 jar 檔案中。此 jar 檔案必須包含一個名為 org.keycloak.authentication.FormActionFactory
的檔案,並且必須包含在您 jar 檔案的 META-INF/services/
目錄中。此檔案必須列出 jar 檔案中每個 FormActionFactory 實作的完整類別名稱。例如
org.keycloak.authentication.forms.RegistrationUserCreation
org.keycloak.authentication.forms.RegistrationRecaptcha
Keycloak 使用此 services/ 檔案掃描它必須載入系統的提供者。
若要部署此 jar 檔案,請將它複製到 providers/
目錄,然後執行 bin/kc.[sh|bat] build
。
將 FormAction 新增至註冊流程
必須在管理控制台中完成將 FormAction 新增至註冊頁面流程的操作。如果您前往「驗證」選單項目並前往「流程」標籤,您將能夠檢視目前定義的流程。您無法修改內建的流程,因此,若要新增我們建立的驗證器,您必須複製現有的流程或建立自己的流程。我希望 UI 夠直覺,讓您可以自行判斷如何建立流程並新增 FormAction。
基本上,您必須複製註冊流程。然後按一下「註冊表單」右側的「動作」選單,並選擇「新增執行」以新增新的執行。您將從選取清單中選擇 FormAction。請確保您的 FormAction 出現在「註冊使用者建立」之後,方法是使用向下按鈕來移動它(如果您的 FormAction 尚未列在「註冊使用者建立」之後)。您希望您的 FormAction 出現在使用者建立之後,因為「註冊使用者建立」的 success() 方法負責建立新的 UserModel。
建立流程後,您必須將它繫結至註冊。如果您前往「驗證」選單並前往「繫結」標籤,您會看到將流程繫結至瀏覽器、註冊或直接授與流程的選項。
修改忘記密碼/憑證流程
Keycloak 針對忘記密碼,或更準確地說,使用者發起的憑證重設,也有一套特定的驗證流程。如果您前往管理控制台的流程頁面,會看到一個「重設憑證」流程。預設情況下,Keycloak 會要求使用者提供電子郵件或使用者名稱,並向他們發送一封電子郵件。如果使用者點擊連結,他們就能夠重設密碼和 OTP(如果已設定 OTP)。您可以停用流程中的「重設 OTP」驗證器來關閉自動 OTP 重設功能。
您也可以在這個流程中新增其他功能。例如,許多部署環境希望使用者除了收到帶有連結的電子郵件外,還能回答一或多個密碼提示問題。您可以擴展發行版附帶的密碼提示問題範例,並將其整合到重設憑證流程中。
如果您正在擴展重設憑證流程,有一點需要注意。第一個「驗證器」只是一個取得使用者名稱或電子郵件的頁面。如果使用者名稱或電子郵件存在,則 AuthenticationFlowContext.getUser()
會傳回找到的使用者。否則,它將會是 null。如果先前的電子郵件或使用者名稱不存在,此表單不會要求使用者重新輸入電子郵件或使用者名稱。您需要防止攻擊者猜測有效的使用者。因此,如果 AuthenticationFlowContext.getUser()
傳回 null,您應該繼續執行流程,使其看起來像是選取了有效的使用者。我建議如果您想在此流程中新增密碼提示問題,您應該在電子郵件發送後再詢問這些問題。換句話說,在「發送重設電子郵件」驗證器之後新增您的自訂驗證器。
修改首次代理登入流程
首次代理登入流程是在首次使用某個身分提供者登入時使用。術語「首次登入
」表示尚未存在與特定已驗證身分提供者帳戶連結的 Keycloak 帳戶。
-
請參閱伺服器管理指南中的「
身分代理
」章節。
用戶端驗證
Keycloak 實際上支援 OpenID Connect 用戶端應用程式的可插拔驗證。用戶端(應用程式)的驗證由 Keycloak 介面卡在幕後使用,在向 Keycloak 伺服器發送任何後端通道請求時(例如,在成功驗證後請求將程式碼交換為存取權杖或請求重新整理權杖)。但是,在 直接存取授權
(由 OAuth2 資源擁有者密碼憑證流程
表示)或 服務帳戶
驗證(由 OAuth2 用戶端憑證流程
表示)期間,您也可以直接使用用戶端驗證。
-
有關 Keycloak 介面卡和 OAuth2 流程的更多詳細資訊,請參閱保護應用程式指南。
預設實作
實際上,Keycloak 有 2 個用戶端驗證的預設實作
- 使用 client_id 和 client_secret 的傳統驗證
-
這是 OpenID Connect 或 OAuth2 規格中提及的預設機制,Keycloak 自早期就支援它。公開用戶端需要在 POST 請求中包含帶有其 ID 的
client_id
參數(因此它實際上未經過驗證),而機密用戶端需要包含Authorization: Basic
標頭,其中 clientId 和 clientSecret 用作使用者名稱和密碼。 - 使用簽署的 JWT 進行驗證
-
這基於 OAuth 2.0 的 JWT 持有者權杖設定檔規範。用戶端/介面卡會產生 JWT 並使用其私密金鑰簽署。然後,Keycloak 會使用用戶端的公開金鑰驗證簽署的 JWT,並根據它驗證用戶端。
請參閱範例演示,尤其是 examples/preconfigured-demo/product-app
,了解顯示使用簽署的 JWT 進行用戶端驗證的範例應用程式。
實作您自己的用戶端驗證器
若要插入您自己的用戶端驗證器,您需要在用戶端(介面卡)和伺服器端實作幾個介面。
- 用戶端
-
在這裡,您需要實作
org.keycloak.adapters.authentication.ClientCredentialsProvider
,並將實作放入-
您的 WAR 檔案中的 WEB-INF/classes。但在這種情況下,該實作只能用於此單一 WAR 應用程式
-
某些 JAR 檔案,將被新增至您的 WAR 的 WEB-INF/lib 中
-
某些 JAR 檔案,將用作 jboss 模組並在您的 WAR 的 jboss-deployment-structure.xml 中設定。在所有情況下,您還需要在 WAR 或您的 JAR 中建立檔案
META-INF/services/org.keycloak.adapters.authentication.ClientCredentialsProvider
。
-
- 伺服器端
-
在這裡,您需要實作
org.keycloak.authentication.ClientAuthenticatorFactory
和org.keycloak.authentication.ClientAuthenticator
。您還需要新增檔案META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory
,其中包含實作類別的名稱。如需更多詳細資訊,請參閱驗證器。
動作權杖處理程式 SPI
動作權杖是 JSON Web 權杖 (JWT) 的特殊實例,允許其持有者執行某些動作,例如重設密碼或驗證電子郵件地址。它們通常以連結的形式傳送給使用者,該連結指向處理特定領域的動作權杖的端點。
Keycloak 提供四種基本權杖類型,允許持有者執行以下操作:
-
重設憑證
-
確認電子郵件地址
-
執行必要動作
-
確認將帳戶與外部身分提供者中的帳戶連結
此外,可以使用動作權杖處理程式 SPI 實作任何啟動或修改驗證工作階段的功能,詳細資訊如下文所述。
動作權杖的組成
動作權杖是以使用中領域金鑰簽署的標準 JSON Web 權杖,其中酬載包含數個欄位
-
typ
- 動作識別(例如verify-email
) -
iat
和exp
- 權杖有效時間 -
sub
- 使用者 ID -
azp
- 用戶端名稱 -
iss
- 發行者 - 發行領域的 URL -
aud
- 受眾 - 包含發行領域 URL 的列表 -
asid
- 驗證工作階段的 ID(選用) -
nonce
- 隨機 nonce,以保證操作只能執行一次時的唯一性(選用)
此外,動作權杖可以包含任意數量的可序列化為 JSON 的自訂欄位。
動作權杖處理
當動作權杖透過 key
參數傳遞至 Keycloak 端點 KEYCLOAK_ROOT/realms/master/login-actions/action-token
時,它會經過驗證,並執行適當的動作權杖處理程式。處理始終在驗證工作階段的環境中進行,可以是全新的,也可以是動作權杖服務加入現有的驗證工作階段(詳細資訊如下所述)。動作權杖處理程式可以執行權杖規定的動作(通常會更改驗證工作階段),並產生 HTTP 回應(例如,它可以繼續驗證或顯示資訊/錯誤頁面)。以下詳細說明這些步驟。
-
基本動作權杖驗證。 檢查簽名和時間有效性,並根據
typ
欄位判斷動作權杖處理程式。 -
判斷驗證工作階段。 如果動作權杖 URL 在瀏覽器中開啟且有現有的驗證工作階段,且權杖包含與瀏覽器中的驗證工作階段相符的驗證工作階段 ID,則動作權杖驗證和處理將會附加此持續進行的驗證工作階段。否則,動作權杖處理程式會建立一個全新的驗證工作階段,取代當時瀏覽器中存在的任何其他驗證工作階段。
-
特定於權杖類型的權杖驗證。 動作權杖端點邏輯會驗證權杖中的使用者 (
sub
欄位) 和用戶端 (azp
) 是否存在、有效且未停用。然後,它會驗證動作權杖處理程式中定義的所有自訂驗證。此外,權杖處理程式可以要求此權杖為單次使用。動作權杖端點邏輯會拒絕已使用的權杖。 -
執行動作。 在所有這些驗證之後,將呼叫動作權杖處理程式程式碼,該程式碼會根據權杖中的參數執行實際動作。
-
使單次使用權杖失效。 如果權杖設定為單次使用,則一旦驗證流程完成,動作權杖就會失效。
實作您自己的動作權杖及其處理程式
如何建立動作權杖
由於動作權杖只是一個簽署的 JWT,帶有幾個必要欄位(請參閱上方的動作權杖的組成),因此可以使用 Keycloak 的 JWSBuilder
類別進行序列化和簽署。這種方法已在 org.keycloak.authentication.actiontoken.DefaultActionToken
的 serialize(session, realm, uriInfo)
方法中實作,實作者可以藉由對權杖使用該類別而不是純 JsonWebToken
來加以利用。
以下範例顯示了簡單動作權杖的實作。請注意,該類別必須有一個沒有任何引數的私有建構函式。這是從 JWT 還原序列化權杖類別所必需的。
import org.keycloak.authentication.actiontoken.DefaultActionToken;
public class DemoActionToken extends DefaultActionToken {
public static final String TOKEN_TYPE = "my-demo-token";
public DemoActionToken(String userId, int absoluteExpirationInSecs, String compoundAuthenticationSessionId) {
super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null, compoundAuthenticationSessionId);
}
private DemoActionToken() {
// Required to deserialize from JWT
super();
}
}
如果您正在實作的動作權杖包含任何應序列化為 JSON 欄位的自訂欄位,則您應該考慮實作 org.keycloak.representations.JsonWebToken
類別的後代,該類別將實作 org.keycloak.models.ActionTokenKeyModel
介面。在這種情況下,您可以利用現有的 org.keycloak.authentication.actiontoken.DefaultActionToken
類別,因為它已經滿足這兩個條件,並且可以直接使用它或實作其子類別,子類別的欄位可以使用適當的 Jackson 註解(例如 com.fasterxml.jackson.annotation.JsonProperty
)進行註解,以將它們序列化為 JSON。
以下範例使用 demo-id
欄位擴充了先前範例中的 DemoActionToken
import com.fasterxml.jackson.annotation.JsonProperty;
import org.keycloak.authentication.actiontoken.DefaultActionToken;
public class DemoActionToken extends DefaultActionToken {
public static final String TOKEN_TYPE = "my-demo-token";
private static final String JSON_FIELD_DEMO_ID = "demo-id";
@JsonProperty(value = JSON_FIELD_DEMO_ID)
private String demoId;
public DemoActionToken(String userId, int absoluteExpirationInSecs, String compoundAuthenticationSessionId, String demoId) {
super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null, compoundAuthenticationSessionId);
this.demoId = demoId;
}
private DemoActionToken() {
// you must have this private constructor for deserializer
}
public String getDemoId() {
return demoId;
}
}
封裝類別和部署
若要插入您自己的動作權杖及其處理程式,您需要在伺服器端實作幾個介面
-
org.keycloak.authentication.actiontoken.ActionTokenHandler
- 特定動作的動作權杖的實際處理程式(即針對typ
權杖欄位的給定值)。該介面中的核心方法是
handleToken(token, context)
,它定義了接收到動作權杖時執行的實際操作。通常是對驗證工作階段註解的一些更改,但通常可以是任意的。只有在所有驗證器(包括在getVerifiers(context)
中定義的驗證器)都成功時,才會呼叫此方法,並保證token
將屬於getTokenClass()
方法傳回的類別。為了判斷動作令牌是否是為目前驗證會期所發放,如上方第 2 項所述,必須在
getAuthenticationSessionIdFromToken(token, context)
方法中宣告提取驗證會期 ID 的方法。DefaultActionToken
中的實作會從令牌中傳回asid
欄位的值(如果已定義)。請注意,您可以覆寫該方法以傳回目前的驗證會期 ID,無論令牌為何。如此一來,您便可以建立在任何驗證流程開始之前,就步入正在進行的驗證流程的令牌。如果令牌中的驗證會期與目前的驗證會期不符,則會要求動作令牌處理常式透過呼叫
startFreshAuthenticationSession(token, context)
來啟動新的驗證會期。它可以擲回VerificationException
(或更好的其更具描述性的變體ExplainedTokenVerificationException
)來表示這將被禁止。令牌處理常式也會透過方法
canUseTokenRepeatedly(token, context)
判斷令牌在被使用且驗證完成後是否失效。請注意,如果您有一個利用多個動作令牌的流程,則只會使最後一個令牌失效。在這種情況下,您應該在動作令牌處理常式中使用org.keycloak.models.SingleUseObjectProvider
來手動使已使用的令牌失效。大多數
ActionTokenHandler
方法的預設實作是keycloak-services
模組中的org.keycloak.authentication.actiontoken.AbstractActionTokenHandler
抽象類別。唯一需要實作的方法是handleToken(token, context)
,它會執行實際的動作。 -
org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory
- 實例化動作令牌處理常式的工廠。實作必須覆寫getId()
以傳回必須與動作令牌中typ
欄位的值精確相符的值。請注意,您必須如本指南的服務提供者介面章節所述,註冊自訂的
ActionTokenHandlerFactory
實作。
事件監聽器 SPI
撰寫事件監聽器提供者首先要實作 EventListenerProvider
和 EventListenerProviderFactory
介面。請參閱 Javadoc 和範例以了解如何執行此操作的完整詳細資訊。
有關如何封裝和部署自訂提供者的詳細資訊,請參閱服務提供者介面章節。
SAML 角色對應 SPI
Keycloak 定義了一個 SPI,用於將 SAML 角色對應到 SP 環境中存在的角色。第三方 IDP 傳回的角色可能不一定與為 SP 應用程式定義的角色相對應,因此需要一種機制,允許將 SAML 角色對應到不同的角色。SAML 配接器在從 SAML 斷言中提取角色後,會使用此機制來設定容器的安全性內容。
org.keycloak.adapters.saml.RoleMappingsProvider
SPI 沒有對可執行的對應施加任何限制。根據使用案例,實作不僅可以將角色對應到其他角色,還可以新增或移除角色(從而擴增或減少指派給 SAML 主體的角色集合)。
有關 SAML 配接器的角色對應提供者設定以及可用預設實作的說明,請參閱保護應用程式指南。
實作自訂角色對應提供者
若要實作自訂角色對應提供者,首先需要實作 org.keycloak.adapters.saml.RoleMappingsProvider
介面。然後,必須將包含自訂實作完整限定名稱的 META-INF/services/org.keycloak.adapters.saml.RoleMappingsProvider
檔案新增到也包含實作類別的封存檔中。此封存檔可以是
-
SP 應用程式 WAR 檔案,其中提供者類別包含在 WEB-INF/classes 中;
-
自訂 JAR 檔案,將會新增到 SP 應用程式 WAR 的 WEB-INF/lib 中;
-
(僅限 WildFly/JBoss EAP)設定為
jboss module
且在 SP 應用程式 WAR 的jboss-deployment-structure.xml
中參考的自訂 JAR 檔案。
當部署 SP 應用程式時,將使用的角色對應提供者會由 keycloak-saml.xml
或 keycloak-saml
子系統中設定的 ID 選取。因此,若要啟用自訂提供者,只需確保其 ID 已在配接器設定中正確設定即可。
使用者儲存 SPI
您可以使用使用者儲存 SPI 來撰寫 Keycloak 的擴充功能,以連線到外部使用者資料庫和認證儲存區。內建的 LDAP 和 ActiveDirectory 支援是此 SPI 的實際實作。Keycloak 開箱即用,使用其本機資料庫來建立、更新和查閱使用者,並驗證認證。不過,組織通常有現有的外部專有使用者資料庫,他們無法將其移轉到 Keycloak 的資料模型。對於這些情況,應用程式開發人員可以撰寫使用者儲存 SPI 的實作,以橋接外部使用者儲存區和 Keycloak 用於登入使用者並管理使用者的內部使用者物件模型。
當 Keycloak 執行階段需要查閱使用者時(例如,當使用者登入時),它會執行多個步驟來找到使用者。它首先檢查使用者是否在使用者快取中;如果找到使用者,則會使用該記憶體中的表示。然後,它會在 Keycloak 本機資料庫中尋找使用者。如果找不到使用者,則會接著循環執行使用者儲存 SPI 提供者實作,以執行使用者查詢,直到其中一個提供者傳回執行階段正在尋找的使用者。提供者會向外部使用者儲存區查詢使用者,並將使用者的外部資料表示對應到 Keycloak 的使用者元模型。
使用者儲存 SPI 提供者實作還可以執行複雜的條件查詢、對使用者執行 CRUD 作業、驗證和管理認證,或一次執行許多使用者的批次更新。這取決於外部儲存區的功能。
使用者儲存 SPI 提供者實作的封裝和部署方式類似於(並且通常是)Jakarta EE 元件。它們預設不會啟用,而是必須在管理主控台的 使用者聯盟
索引標籤下針對每個領域啟用和設定。
如果您的使用者提供者實作將某些使用者屬性用作連結/建立使用者身分的中繼資料屬性,請確保使用者無法編輯這些屬性,並且對應的屬性是唯讀的。例如 LDAP_ID 屬性,內建的 Keycloak LDAP 提供者使用該屬性來儲存 LDAP 伺服器端的使用者 ID。請參閱威脅模型緩和措施章節中的詳細資訊。 |
Keycloak 快速入門儲存庫中有兩個範例專案。每個快速入門都有一個 README
檔案,其中包含如何建置、部署和測試範例專案的指示。下表簡要說明可用的使用者儲存 SPI 快速入門
名稱 | 說明 |
---|---|
示範如何使用 JPA 實作使用者儲存提供者。 |
|
示範如何使用包含使用者名稱/密碼金鑰配對的簡單屬性檔案實作使用者儲存提供者。 |
提供者介面
當建置使用者儲存 SPI 的實作時,您必須定義提供者類別和提供者工廠。提供者類別實例是由提供者工廠針對每個交易建立。提供者類別執行使用者查閱和其他使用者操作的所有繁重工作。它們必須實作 org.keycloak.storage.UserStorageProvider
介面。
package org.keycloak.storage;
public interface UserStorageProvider extends Provider {
/**
* Callback when a realm is removed. Implement this if, for example, you want to do some
* cleanup in your user storage when a realm is removed
*
* @param realm
*/
default
void preRemove(RealmModel realm) {
}
/**
* Callback when a group is removed. Allows you to do things like remove a user
* group mapping in your external store if appropriate
*
* @param realm
* @param group
*/
default
void preRemove(RealmModel realm, GroupModel group) {
}
/**
* Callback when a role is removed. Allows you to do things like remove a user
* role mapping in your external store if appropriate
* @param realm
* @param role
*/
default
void preRemove(RealmModel realm, RoleModel role) {
}
}
您可能會認為 UserStorageProvider
介面非常稀疏?您將在本章稍後看到,您的提供者類別可以實作其他混合介面,以支援使用者整合的主要部分。
UserStorageProvider
實例是針對每個交易建立一次。當交易完成時,會叫用 UserStorageProvider.close()
方法,然後會對實例進行垃圾回收。實例由提供者工廠建立。提供者工廠實作 org.keycloak.storage.UserStorageProviderFactory
介面。
package org.keycloak.storage;
/**
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public interface UserStorageProviderFactory<T extends UserStorageProvider> extends ComponentFactory<T, UserStorageProvider> {
/**
* This is the name of the provider and will be shown in the admin console as an option.
*
* @return
*/
@Override
String getId();
/**
* called per Keycloak transaction.
*
* @param session
* @param model
* @return
*/
T create(KeycloakSession session, ComponentModel model);
...
}
提供者工廠類別在實作 UserStorageProviderFactory
時,必須將具體的提供者類別指定為樣板參數。這是必須的,因為運行時會內省此類別以掃描其功能(它實作的其他介面)。因此,舉例來說,如果您的提供者類別名為 FileProvider
,則工廠類別應如下所示:
public class FileProviderFactory implements UserStorageProviderFactory<FileProvider> {
public String getId() { return "file-provider"; }
public FileProvider create(KeycloakSession session, ComponentModel model) {
...
}
getId()
方法會傳回使用者儲存提供者的名稱。當您想要為特定領域啟用提供者時,此 ID 會顯示在管理控制台的「使用者聯盟」頁面中。
create()
方法負責配置提供者類別的實例。它接受一個 org.keycloak.models.KeycloakSession
參數。此物件可用於查找其他資訊和元數據,以及提供對運行時內各種其他組件的存取。ComponentModel
參數表示如何在特定領域中啟用和配置提供者。它包含已啟用提供者的實例 ID,以及您在透過管理控制台啟用時可能為其指定的任何配置。
UserStorageProviderFactory
也具有其他功能,我們將在本章稍後討論。
提供者功能介面
如果您仔細檢查過 UserStorageProvider
介面,您可能會注意到它沒有定義任何用於尋找或管理使用者的方法。這些方法實際上是在其他功能介面中定義的,具體取決於您的外部使用者儲存可以提供和執行的功能範圍。例如,某些外部儲存是唯讀的,只能執行簡單的查詢和憑證驗證。您只需要實作您能夠使用的功能之功能介面。您可以實作這些介面
SPI | 說明 |
---|---|
|
如果您想要能夠使用來自此外部儲存的使用者登入,則需要此介面。大多數(全部?)提供者都實作此介面。 |
|
定義用於尋找一個或多個使用者的複雜查詢。如果您想要從管理控制台檢視和管理使用者,則必須實作此介面。 |
|
如果您的提供者支援計數查詢,請實作此介面。 |
|
此介面是 |
|
如果您的提供者支援新增和移除使用者,請實作此介面。 |
|
如果您的提供者支援批量更新一組使用者,請實作此介面。 |
|
如果您的提供者可以驗證一種或多種不同的憑證類型(例如,如果您的提供者可以驗證密碼),請實作此介面。 |
|
如果您的提供者支援更新一種或多種不同的憑證類型,請實作此介面。 |
模型介面
在功能介面中定義的大多數方法都會傳回或傳入使用者的表示法。這些表示法由 org.keycloak.models.UserModel
介面定義。應用程式開發人員需要實作此介面。它提供了外部使用者儲存和 Keycloak 使用的使用者元模型之間的對應。
package org.keycloak.models;
public interface UserModel extends RoleMapperModel {
String getId();
String getUsername();
void setUsername(String username);
String getFirstName();
void setFirstName(String firstName);
String getLastName();
void setLastName(String lastName);
String getEmail();
void setEmail(String email);
...
}
UserModel
實作提供了對使用者元數據的讀取和更新存取,包括使用者名稱、名稱、電子郵件、角色和群組對應,以及其他任意屬性。
org.keycloak.models
套件中還有其他模型類別,代表 Keycloak 元模型的其他部分:RealmModel
、RoleModel
、GroupModel
和 ClientModel
。
儲存 ID
UserModel
的一個重要方法是 getId()
方法。在實作 UserModel
時,開發人員必須了解使用者 ID 格式。格式必須是
"f:" + component id + ":" + external id
Keycloak 執行時通常必須依使用者 ID 查找使用者。使用者 ID 包含足夠的資訊,因此執行時不必查詢系統中的每個 UserStorageProvider
來尋找使用者。
組件 ID 是從 ComponentModel.getId()
傳回的 ID。建立提供者類別時,ComponentModel
會作為參數傳入,因此您可以從那裡取得。外部 ID 是您的提供者類別需要在外部儲存中尋找使用者的資訊。這通常是使用者名稱或 uid。例如,它可能看起來像這樣
f:332a234e31234:wburke
當執行時依 ID 執行查找時,會解析該 ID 以取得組件 ID。組件 ID 用於尋找最初用於載入使用者的 UserStorageProvider
。然後,該提供者會被傳入該 ID。提供者會再次解析該 ID 以取得外部 ID,並使用它來在外部使用者儲存中尋找使用者。
封裝和部署
為了讓 Keycloak 識別提供者,您需要將檔案新增至 JAR:META-INF/services/org.keycloak.storage.UserStorageProviderFactory
。此檔案必須包含 UserStorageProviderFactory
實作的完整類別名稱的行分隔清單
org.keycloak.examples.federation.properties.ClasspathPropertiesStorageFactory org.keycloak.examples.federation.properties.FilePropertiesStorageFactory
若要部署此 jar 檔案,請將它複製到 providers/
目錄,然後執行 bin/kc.[sh|bat] build
。
簡單的唯讀、查找範例
為了說明實作使用者儲存 SPI 的基本知識,讓我們逐步了解一個簡單的範例。在本章中,您將看到一個簡單的 UserStorageProvider
的實作,該提供者會在一個簡單的屬性檔案中查找使用者。屬性檔案包含使用者名稱和密碼定義,並硬式編碼到類路徑上的特定位置。提供者將能夠依 ID 和使用者名稱查找使用者,並且能夠驗證密碼。來自此提供者的使用者將是唯讀的。
提供者類別
我們要逐步了解的第一件事是 UserStorageProvider
類別。
public class PropertyFileUserStorageProvider implements
UserStorageProvider,
UserLookupProvider,
CredentialInputValidator,
CredentialInputUpdater
{
...
}
我們的提供者類別 PropertyFileUserStorageProvider
實作了許多介面。它實作了 UserStorageProvider
,因為這是 SPI 的基本要求。它實作了 UserLookupProvider
介面,因為我們希望能夠使用此提供者儲存的使用者登入。它實作了 CredentialInputValidator
介面,因為我們希望能夠驗證使用登入畫面輸入的密碼。我們的屬性檔案是唯讀的。我們實作了 CredentialInputUpdater
,因為我們希望在使用者嘗試更新其密碼時張貼錯誤條件。
protected KeycloakSession session;
protected Properties properties;
protected ComponentModel model;
// map of loaded users in this transaction
protected Map<String, UserModel> loadedUsers = new HashMap<>();
public PropertyFileUserStorageProvider(KeycloakSession session, ComponentModel model, Properties properties) {
this.session = session;
this.model = model;
this.properties = properties;
}
此提供者類別的建構子將儲存對 KeycloakSession
、ComponentModel
和屬性檔案的參考。我們稍後將使用所有這些。另請注意,有一個已載入使用者的地圖。每當我們找到使用者時,我們會將其儲存在此地圖中,以避免在同一個交易中再次建立它。這是一個值得遵循的好習慣,因為許多提供者都需要這樣做(也就是說,任何與 JPA 整合的提供者)。另請記住,提供者類別實例在每個交易中都會建立一次,並在交易完成後關閉。
UserLookupProvider 實作
@Override
public UserModel getUserByUsername(RealmModel realm, String username) {
UserModel adapter = loadedUsers.get(username);
if (adapter == null) {
String password = properties.getProperty(username);
if (password != null) {
adapter = createAdapter(realm, username);
loadedUsers.put(username, adapter);
}
}
return adapter;
}
protected UserModel createAdapter(RealmModel realm, String username) {
return new AbstractUserAdapter(session, realm, model) {
@Override
public String getUsername() {
return username;
}
};
}
@Override
public UserModel getUserById(RealmModel realm, String id) {
StorageId storageId = new StorageId(id);
String username = storageId.getExternalId();
return getUserByUsername(realm, username);
}
@Override
public UserModel getUserByEmail(RealmModel realm, String email) {
return null;
}
當使用者登入時,Keycloak 登入頁面會叫用 getUserByUsername()
方法。在我們的實作中,我們首先檢查 loadedUsers
地圖,以查看使用者是否已在此交易中載入。如果尚未載入,我們會在屬性檔案中尋找使用者名稱。如果存在,我們會建立 UserModel
的實作,將其儲存在 loadedUsers
中以供將來參考,並傳回此實例。
createAdapter()
方法使用輔助類別 org.keycloak.storage.adapter.AbstractUserAdapter
。這為 UserModel
提供了基本實作。它會使用使用者的使用者名稱作為外部 ID,自動根據所需的儲存 ID 格式產生使用者 ID。
"f:" + component id + ":" + username
AbstractUserAdapter
的每個 get 方法都會傳回 null 或空集合。但是,傳回角色和群組對應的方法會針對每個使用者傳回為領域設定的預設角色和群組。AbstractUserAdapter
的每個 set 方法都會擲回 org.keycloak.storage.ReadOnlyException
。因此,如果您嘗試在管理控制台中修改使用者,則會收到錯誤。
getUserById()
方法使用 org.keycloak.storage.StorageId
輔助類別解析 id
參數。叫用 StorageId.getExternalId()
方法以取得內嵌在 id
參數中的使用者名稱。然後,該方法會委派給 getUserByUsername()
。
未儲存電子郵件,因此 getUserByEmail()
方法會傳回 null。
CredentialInputValidator 實作
接下來讓我們看一下 CredentialInputValidator
的方法實作。
@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
String password = properties.getProperty(user.getUsername());
return credentialType.equals(PasswordCredentialModel.TYPE) && password != null;
}
@Override
public boolean supportsCredentialType(String credentialType) {
return credentialType.equals(PasswordCredentialModel.TYPE);
}
@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
if (!supportsCredentialType(input.getType())) return false;
String password = properties.getProperty(user.getUsername());
if (password == null) return false;
return password.equals(input.getChallengeResponse());
}
執行時會呼叫 isConfiguredFor()
方法,以判斷是否為使用者設定了特定憑證類型。此方法會檢查是否為使用者設定了密碼。
supportsCredentialType()
方法會傳回是否支援特定憑證類型的驗證。我們會檢查憑證類型是否為 password
。
isValid()
方法負責驗證密碼。CredentialInput
參數實際上只是所有憑證類型的抽象介面。我們確保支援憑證類型,並且它也是 UserCredentialModel
的實例。當使用者透過登入頁面登入時,密碼輸入的純文字會放入 UserCredentialModel
的實例中。isValid()
方法會針對屬性檔案中儲存的純文字密碼檢查此值。傳回值 true
表示密碼有效。
CredentialInputUpdater 實作
如先前所述,我們在此範例中實作 CredentialInputUpdater
介面的唯一原因是禁止修改使用者密碼。我們必須這樣做的原因是,否則執行階段會允許在 Keycloak 本機儲存空間中覆寫密碼。我們將在本章稍後詳細討論此問題。
@Override
public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
if (input.getType().equals(PasswordCredentialModel.TYPE)) throw new ReadOnlyException("user is read only for this update");
return false;
}
@Override
public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {
}
@Override
public Stream<String> getDisableableCredentialTypesStream(RealmModel realm, UserModel user) {
return Stream.empty();
}
updateCredential()
方法只會檢查憑證類型是否為密碼。如果是,則會拋出 ReadOnlyException
。
提供者工廠實作
現在提供者類別已完成,我們接著將注意力轉向提供者工廠類別。
public class PropertyFileUserStorageProviderFactory
implements UserStorageProviderFactory<PropertyFileUserStorageProvider> {
public static final String PROVIDER_NAME = "readonly-property-file";
@Override
public String getId() {
return PROVIDER_NAME;
}
首先要注意的是,在實作 UserStorageProviderFactory
類別時,您必須將具體提供者類別實作作為範本參數傳入。在此我們指定先前定義的提供者類別:PropertyFileUserStorageProvider
。
如果您沒有指定範本參數,您的提供者將無法運作。執行階段會執行類別內省,以判斷提供者實作的能力介面。 |
getId()
方法會在執行階段中識別工廠,並且也會是在您想要為領域啟用使用者儲存提供者時,管理主控台中顯示的字串。
初始化
private static final Logger logger = Logger.getLogger(PropertyFileUserStorageProviderFactory.class);
protected Properties properties = new Properties();
@Override
public void init(Config.Scope config) {
InputStream is = getClass().getClassLoader().getResourceAsStream("/users.properties");
if (is == null) {
logger.warn("Could not find users.properties in classpath");
} else {
try {
properties.load(is);
} catch (IOException ex) {
logger.error("Failed to load users.properties file", ex);
}
}
}
@Override
public PropertyFileUserStorageProvider create(KeycloakSession session, ComponentModel model) {
return new PropertyFileUserStorageProvider(session, model, properties);
}
UserStorageProviderFactory
介面有一個可選擇實作的 init()
方法。當 Keycloak 啟動時,只會建立每個提供者工廠的一個實例。此外,在啟動時,會在每個工廠實例上呼叫 init()
方法。您也可以實作 postInit()
方法。在呼叫每個工廠的 init()
方法之後,會呼叫其 postInit()
方法。
在我們的 init()
方法實作中,我們從類別路徑中找到包含使用者宣告的屬性檔案。然後,我們將 properties
欄位載入儲存在該處的使用者名稱和密碼組合。
Config.Scope
參數是透過伺服器設定設定的工廠組態。
例如,透過使用以下引數執行伺服器
kc.[sh|bat] start --spi-storage-readonly-property-file-path=/other-users.properties
我們可以指定使用者屬性檔案的類別路徑,而不是將其硬式編碼。然後,您可以在 PropertyFileUserStorageProviderFactory.init()
中擷取組態
public void init(Config.Scope config) {
String path = config.get("path");
InputStream is = getClass().getClassLoader().getResourceAsStream(path);
...
}
組態技術
我們的 PropertyFileUserStorageProvider
範例有點矯揉造作。它被硬式編碼到內嵌在提供者 jar 中的屬性檔案,這不是很有用。我們可能希望讓此檔案的位置在每個提供者實例中都是可設定的。換句話說,我們可能希望在多個不同的領域中重複使用此提供者多次,並指向完全不同的使用者屬性檔案。我們也希望在管理主控台 UI 中執行此組態。
UserStorageProviderFactory
具有您可以實作的其他方法,這些方法可處理提供者組態。您可以描述您想要針對每個提供者設定的變數,而管理主控台會自動呈現一般輸入頁面來收集此組態。實作後,回呼方法也會在第一次建立提供者時,以及在更新提供者時,在儲存之前驗證組態。UserStorageProviderFactory
從 org.keycloak.component.ComponentFactory
介面繼承這些方法。
List<ProviderConfigProperty> getConfigProperties();
default
void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model)
throws ComponentValidationException
{
}
default
void onCreate(KeycloakSession session, RealmModel realm, ComponentModel model) {
}
default
void onUpdate(KeycloakSession session, RealmModel realm, ComponentModel model) {
}
ComponentFactory.getConfigProperties()
方法會傳回 org.keycloak.provider.ProviderConfigProperty
實例的清單。這些實例宣告呈現和儲存提供者每個組態變數所需的元資料。
組態範例
讓我們擴充 PropertyFileUserStorageProviderFactory
範例,讓您將提供者實例指向磁碟上的特定檔案。
public class PropertyFileUserStorageProviderFactory
implements UserStorageProviderFactory<PropertyFileUserStorageProvider> {
protected static final List<ProviderConfigProperty> configMetadata;
static {
configMetadata = ProviderConfigurationBuilder.create()
.property().name("path")
.type(ProviderConfigProperty.STRING_TYPE)
.label("Path")
.defaultValue("${jboss.server.config.dir}/example-users.properties")
.helpText("File path to properties file")
.add().build();
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return configMetadata;
}
ProviderConfigurationBuilder
類別是用來建立組態屬性清單的絕佳協助程式類別。在此我們指定名為 path
的變數,其類型為 String。在此提供者的管理主控台組態頁面上,此組態變數會標示為 Path
,且預設值為 ${jboss.server.config.dir}/example-users.properties
。當您將滑鼠游標暫留在這個組態選項的工具提示上時,它會顯示說明文字 File path to properties file
(屬性檔案的檔案路徑)。
接下來,我們想要做的就是驗證此檔案是否存在於磁碟上。除非它指向有效的使用者屬性檔案,否則我們不希望在領域中啟用此提供者的實例。若要執行此操作,我們實作 validateConfiguration()
方法。
@Override
public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config)
throws ComponentValidationException {
String fp = config.getConfig().getFirst("path");
if (fp == null) throw new ComponentValidationException("user property file does not exist");
fp = EnvUtil.replace(fp);
File file = new File(fp);
if (!file.exists()) {
throw new ComponentValidationException("user property file does not exist");
}
}
validateConfiguration()
方法提供來自 ComponentModel
的組態變數,以驗證該檔案是否存在於磁碟上。請注意使用 org.keycloak.common.util.EnvUtil.replace()
方法。使用此方法,任何包含 ${}
的字串都會將該值替換為系統屬性值。${jboss.server.config.dir}
字串對應於伺服器的 conf/
目錄,並且對於此範例非常有用。
接下來,我們必須移除舊的 init()
方法。我們這麼做的原因是每個提供者實例的使用者屬性檔案都將是唯一的。我們將此邏輯移至 create()
方法。
@Override
public PropertyFileUserStorageProvider create(KeycloakSession session, ComponentModel model) {
String path = model.getConfig().getFirst("path");
Properties props = new Properties();
try {
InputStream is = new FileInputStream(path);
props.load(is);
is.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
return new PropertyFileUserStorageProvider(session, model, props);
}
當然,此邏輯效率不高,因為每次交易都會從磁碟讀取整個使用者屬性檔案,但希望這能以簡單的方式說明如何掛接組態變數。
新增/移除使用者和查詢能力介面
我們在範例中尚未完成的一件事是允許它新增和移除使用者或變更密碼。範例中定義的使用者也無法在管理主控台中查詢或檢視。若要新增這些增強功能,我們的範例提供者必須實作 UserQueryMethodsProvider
(或 UserQueryProvider
) 和 UserRegistrationProvider
介面。
實作 UserRegistrationProvider
使用此程序來實作從特定儲存區新增和移除使用者,我們首先必須能夠將我們的屬性檔案儲存到磁碟。
public void save() {
String path = model.getConfig().getFirst("path");
path = EnvUtil.replace(path);
try {
FileOutputStream fos = new FileOutputStream(path);
properties.store(fos, "");
fos.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
然後,addUser()
和 removeUser()
方法的實作會變得簡單。
public static final String UNSET_PASSWORD="#$!-UNSET-PASSWORD";
@Override
public UserModel addUser(RealmModel realm, String username) {
synchronized (properties) {
properties.setProperty(username, UNSET_PASSWORD);
save();
}
return createAdapter(realm, username);
}
@Override
public boolean removeUser(RealmModel realm, UserModel user) {
synchronized (properties) {
if (properties.remove(user.getUsername()) == null) return false;
save();
return true;
}
}
請注意,在新增使用者時,我們將屬性對應的密碼值設定為 UNSET_PASSWORD
。我們這麼做的原因是我們不能讓屬性值有 Null 值。我們也必須修改 CredentialInputValidator
方法以反映這一點。
如果提供者實作 UserRegistrationProvider
介面,則會呼叫 addUser()
方法。如果您的提供者有一個組態開關可關閉新增使用者,則從此方法傳回 null
將會略過該提供者並呼叫下一個提供者。
@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
if (!supportsCredentialType(input.getType()) || !(input instanceof UserCredentialModel)) return false;
UserCredentialModel cred = (UserCredentialModel)input;
String password = properties.getProperty(user.getUsername());
if (password == null || UNSET_PASSWORD.equals(password)) return false;
return password.equals(cred.getValue());
}
既然我們現在可以儲存我們的屬性檔案,那麼允許密碼更新也是合理的。
@Override
public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
if (!(input instanceof UserCredentialModel)) return false;
if (!input.getType().equals(PasswordCredentialModel.TYPE)) return false;
UserCredentialModel cred = (UserCredentialModel)input;
synchronized (properties) {
properties.setProperty(user.getUsername(), cred.getValue());
save();
}
return true;
}
我們現在也可以實作停用密碼。
@Override
public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {
if (!credentialType.equals(PasswordCredentialModel.TYPE)) return;
synchronized (properties) {
properties.setProperty(user.getUsername(), UNSET_PASSWORD);
save();
}
}
private static final Set<String> disableableTypes = new HashSet<>();
static {
disableableTypes.add(PasswordCredentialModel.TYPE);
}
@Override
public Stream<String> getDisableableCredentialTypes(RealmModel realm, UserModel user) {
return disableableTypes.stream();
}
實作這些方法後,您現在可以在管理主控台中變更和停用使用者的密碼。
實作 UserQueryProvider
UserQueryProvider
是 UserQueryMethodsProvider
和 UserCountMethodsProvider
的組合。如果沒有實作 UserQueryMethodsProvider
,管理主控台將無法檢視和管理由我們的範例提供者載入的使用者。讓我們看看如何實作此介面。
@Override
public int getUsersCount(RealmModel realm) {
return properties.size();
}
@Override
public Stream<UserModel> searchForUserStream(RealmModel realm, String search, Integer firstResult, Integer maxResults) {
Predicate<String> predicate = "*".equals(search) ? username -> true : username -> username.contains(search);
return properties.keySet().stream()
.map(String.class::cast)
.filter(predicate)
.skip(firstResult)
.map(username -> getUserByUsername(realm, username))
.limit(maxResults);
}
searchForUserStream()
的第一個宣告會採用 String
參數。在此範例中,參數表示您想要依據搜尋的使用者名稱。此字串可以是子字串,這說明在執行搜尋時選擇 String.contains()
方法。請注意使用 *
來表示要求所有使用者的清單。此方法會反覆運算屬性檔案的索引鍵集,並委派給 getUserByUsername()
來載入使用者。請注意,我們會根據 firstResult
和 maxResults
參數來索引此呼叫。如果您的外部儲存區不支援分頁,您將必須執行類似的邏輯。
@Override
public Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> params, Integer firstResult, Integer maxResults) {
// only support searching by username
String usernameSearchString = params.get("username");
if (usernameSearchString != null)
return searchForUserStream(realm, usernameSearchString, firstResult, maxResults);
// if we are not searching by username, return all users
return searchForUserStream(realm, "*", firstResult, maxResults);
}
採用 Map
參數的 searchForUserStream()
方法可以根據名字、姓氏、使用者名稱和電子郵件來搜尋使用者。只會儲存使用者名稱,因此搜尋只會根據使用者名稱進行,除非 Map
參數不包含 username
屬性。在這種情況下,會傳回所有使用者。在這種情況下,會使用 searchForUserStream(realm, search, firstResult, maxResults)
。
@Override
public Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group, Integer firstResult, Integer maxResults) {
return Stream.empty();
}
@Override
public Stream<UserModel> searchForUserByUserAttributeStream(RealmModel realm, String attrName, String attrValue) {
return Stream.empty();
}
不會儲存群組或屬性,因此其他方法會傳回空的串流。
擴充外部儲存空間
PropertyFileUserStorageProvider
範例的功能非常有限。雖然我們可以使用屬性檔中儲存的使用者登入,但我們無法執行其他操作。如果此提供者載入的使用者需要特殊的角色或群組對應才能完全存取特定應用程式,我們就無法為這些使用者新增額外的角色對應。您也無法修改或新增額外的必要屬性,例如電子郵件、名字和姓氏。
對於這些情況,Keycloak 允許您透過在 Keycloak 的資料庫中儲存額外資訊來擴增您的外部儲存。這稱為聯合使用者儲存,並封裝在 org.keycloak.storage.federated.UserFederatedStorageProvider
類別中。
package org.keycloak.storage.federated;
public interface UserFederatedStorageProvider extends Provider,
UserAttributeFederatedStorage,
UserBrokerLinkFederatedStorage,
UserConsentFederatedStorage,
UserNotBeforeFederatedStorage,
UserGroupMembershipFederatedStorage,
UserRequiredActionsFederatedStorage,
UserRoleMappingsFederatedStorage,
UserFederatedUserCredentialStore {
...
}
UserFederatedStorageProvider
實例可在 UserStorageUtil.userFederatedStorage(KeycloakSession)
方法上取得。它具有各種不同的方法,可用於儲存屬性、群組和角色對應、不同的憑證類型和必要操作。如果您的外部儲存的資料模型無法支援完整的 Keycloak 功能集,則此服務可以填補空白。
Keycloak 隨附一個輔助類別 org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage
,該類別會將除了使用者名稱的 get/set 之外的每個 UserModel
方法委派給聯合使用者儲存。覆寫您需要覆寫的方法以委派給您的外部儲存表示法。強烈建議您閱讀此類別的 javadoc,因為它具有您可能想要覆寫的較小受保護方法。特別是關於群組成員資格和角色對應的部分。
擴增範例
在我們的 PropertyFileUserStorageProvider
範例中,我們只需要對提供者進行簡單的變更,以使用 AbstractUserAdapterFederatedStorage
。
protected UserModel createAdapter(RealmModel realm, String username) {
return new AbstractUserAdapterFederatedStorage(session, realm, model) {
@Override
public String getUsername() {
return username;
}
@Override
public void setUsername(String username) {
String pw = (String)properties.remove(username);
if (pw != null) {
properties.put(username, pw);
save();
}
}
};
}
我們改為定義 AbstractUserAdapterFederatedStorage
的匿名類別實作。setUsername()
方法會變更屬性檔並儲存它。
匯入實作策略
在實作使用者儲存提供者時,您可以採取另一種策略。您可以不在 Keycloak 內建使用者資料庫中建立使用者並從外部儲存複製屬性到此本機副本,而是使用聯合使用者儲存。此方法有很多優點。
-
Keycloak 基本上會成為您外部儲存的持久性使用者快取。匯入使用者後,您將不再存取外部儲存,因此可以減輕其負載。
-
如果您要將 Keycloak 作為您的官方使用者儲存並棄用舊的外部儲存,您可以逐步遷移應用程式以使用 Keycloak。當所有應用程式都已遷移後,取消連結匯入的使用者,並停用舊的舊式外部儲存。
但是,使用匯入策略有一些明顯的缺點
-
第一次查詢使用者需要多次更新 Keycloak 資料庫。這可能會在負載下造成很大的效能損失,並對 Keycloak 資料庫造成很大的壓力。聯合使用者儲存方法只會在需要時儲存額外資料,並且可能會根據您的外部儲存的功能而永遠不會使用。
-
使用匯入方法,您必須讓本機 Keycloak 儲存與外部儲存保持同步。使用者儲存 SPI 具有您可以實作以支援同步的介面,但這可能會很快變得痛苦和混亂。
若要實作匯入策略,您只需先檢查是否已在本機匯入使用者。如果是,則傳回本機使用者,如果不是,則在本機建立使用者並從外部儲存匯入資料。您也可以代理本機使用者,以便自動同步大部分變更。
這會有點牽強,但我們可以擴充 PropertyFileUserStorageProvider
以採用此方法。首先,我們從修改 createAdapter()
方法開始。
protected UserModel createAdapter(RealmModel realm, String username) {
UserModel local = UserStoragePrivateUtil.userLocalStorage(session).getUserByUsername(realm, username);
if (local == null) {
local = UserStoragePrivateUtil.userLocalStorage(session).addUser(realm, username);
local.setFederationLink(model.getId());
}
return new UserModelDelegate(local) {
@Override
public void setUsername(String username) {
String pw = (String)properties.remove(username);
if (pw != null) {
properties.put(username, pw);
save();
}
super.setUsername(username);
}
};
}
在此方法中,我們呼叫 UserStoragePrivateUtil.userLocalStorage(session)
方法以取得本機 Keycloak 使用者儲存的參考。我們查看使用者是否已在本機儲存,如果沒有,則在本機新增使用者。請勿設定本機使用者的 id
。讓 Keycloak 自動產生 id
。另請注意,我們呼叫 UserModel.setFederationLink()
並傳入我們提供者的 ComponentModel
的 ID。這會在提供者和匯入的使用者之間設定連結。
當移除使用者儲存提供者時,任何由其匯入的使用者也會被移除。這是呼叫 UserModel.setFederationLink() 的目的之一。 |
另一個要注意的事項是,如果連結了本機使用者,您的儲存提供者仍然會委派給它實作自 CredentialInputValidator
和 CredentialInputUpdater
介面的方法。從驗證或更新傳回 false
只會導致 Keycloak 查看是否可以使用本機儲存進行驗證或更新。
另請注意,我們正在使用 org.keycloak.models.utils.UserModelDelegate
類別代理本機使用者。此類別是 UserModel
的實作。每個方法都只會委派給其實例化的 UserModel
。我們覆寫此委派類別的 setUsername()
方法,以自動與屬性檔同步。對於您的提供者,您可以使用它來攔截本機 UserModel
上的其他方法,以執行與外部儲存的同步。例如,get 方法可以確保本機儲存同步。Set 方法可讓外部儲存與本機儲存保持同步。需要注意的一點是,getId()
方法應始終傳回您在本機建立使用者時自動產生的 id。您不應傳回其他非匯入範例中顯示的聯合 id。
如果您的提供者實作了 UserRegistrationProvider 介面,您的 removeUser() 方法不需要從本機儲存中移除使用者。執行階段會自動執行此操作。另請注意,removeUser() 會在本機儲存移除之前叫用。 |
ImportedUserValidation 介面
如果您還記得本章稍早的內容,我們討論了如何查詢使用者。首先查詢本機儲存,如果在那裡找到使用者,則查詢結束。這對於我們上面的實作來說是一個問題,因為我們想要代理本機 UserModel
以便我們可以讓使用者名稱保持同步。使用者儲存 SPI 具有從本機資料庫載入連結的本機使用者時的回呼。
package org.keycloak.storage.user;
public interface ImportedUserValidation {
/**
* If this method returns null, then the user in local storage will be removed
*
* @param realm
* @param user
* @return null if user no longer valid
*/
UserModel validate(RealmModel realm, UserModel user);
}
每當載入連結的本機使用者時,如果使用者儲存提供者類別實作此介面,則會呼叫 validate()
方法。在這裡,您可以代理作為參數傳入的本機使用者並傳回它。將會使用新的 UserModel
。您也可以選擇性地檢查使用者是否仍然存在於外部儲存中。如果 validate()
傳回 null
,則會從資料庫中移除本機使用者。
ImportSynchronization 介面
使用匯入策略,您可以看到本機使用者副本可能會與外部儲存不同步。例如,使用者可能已從外部儲存中移除。使用者儲存 SPI 具有您可以實作以處理此問題的額外介面:org.keycloak.storage.user.ImportSynchronization
package org.keycloak.storage.user;
public interface ImportSynchronization {
SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model);
SynchronizationResult syncSince(Date lastSync, KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model);
}
此介面由提供者工廠實作。一旦提供者工廠實作此介面,提供者的管理主控台管理頁面就會顯示額外的選項。您可以透過按一下按鈕來手動強制同步。這會叫用 ImportSynchronization.sync()
方法。此外,還會顯示其他設定選項,允許您自動排程同步。自動同步會叫用 syncSince()
方法。
使用者快取
當透過 ID、使用者名稱或電子郵件查詢載入使用者物件時,會快取它。當快取使用者物件時,它會反覆查看整個 UserModel
介面,並將此資訊提取到本機僅限記憶體的快取。在叢集中,此快取仍然是本機的,但它會變成失效快取。當修改使用者物件時,會將其逐出。此逐出事件會傳播到整個叢集,以便其他節點的使用者快取也會失效。
管理使用者快取
您可以透過呼叫 KeycloakSession.getProvider(UserCache.class)
來存取使用者快取。
/**
* All these methods effect an entire cluster of Keycloak instances.
*
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public interface UserCache extends UserProvider {
/**
* Evict user from cache.
*
* @param user
*/
void evict(RealmModel realm, UserModel user);
/**
* Evict users of a specific realm
*
* @param realm
*/
void evict(RealmModel realm);
/**
* Clear cache entirely.
*
*/
void clear();
}
有可用於逐出特定使用者、特定領域中包含的使用者或整個快取的方法。
OnUserCache 回呼介面
您可能想要快取特定於您的提供者實作的額外資訊。使用者儲存 SPI 在每次快取使用者時都有回呼:org.keycloak.models.cache.OnUserCache
。
public interface OnUserCache {
void onCache(RealmModel realm, CachedUserModel user, UserModel delegate);
}
如果您的提供者類別想要此回呼,則應實作此介面。UserModel
委派參數是由您的提供者傳回的 UserModel
實例。CachedUserModel
是擴充的 UserModel
介面。這是本機儲存中本機快取的實例。
public interface CachedUserModel extends UserModel {
/**
* Invalidates the cache for this user and returns a delegate that represents the actual data provider
*
* @return
*/
UserModel getDelegateForUpdate();
boolean isMarkedForEviction();
/**
* Invalidate the cache for this model
*
*/
void invalidate();
/**
* When was the model was loaded from database.
*
* @return
*/
long getCacheTimestamp();
/**
* Returns a map that contains custom things that are cached along with this model. You can write to this map.
*
* @return
*/
ConcurrentHashMap getCachedWith();
}
此 CachedUserModel
介面允許您從快取中逐出使用者並取得提供者 UserModel
實例。getCachedWith()
方法會傳回一個對應,允許您快取與使用者相關的其他資訊。例如,憑證不是 UserModel
介面的一部分。如果您想要在記憶體中快取憑證,您將實作 OnUserCache
並使用 getCachedWith()
方法快取使用者的憑證。
利用 Jakarta EE
自 20 版本起,Keycloak 僅依賴 Quarkus。與 WildFly 不同,Quarkus 不是應用程式伺服器。如需更多詳細資訊,請參閱https://keycloak.dev.org.tw/migration/migrating-to-quarkus#_quarkus_is_not_an_application_server。
因此,使用者儲存供應商無法像先前版本 Keycloak 在 WildFly 上執行時那樣,封裝在任何 Jakarta EE 元件中或將其設為 EJB。
供應商實作需要是實作合適使用者儲存 SPI 介面的純 Java 物件,如前幾節所述。它們必須按照本遷移指南中的說明進行封裝和部署。
您仍然可以實作自訂的 UserStorageProvider
類別,該類別能夠透過 JPA Entity Manager 整合外部資料庫,如本範例所示
不支援 CDI。
REST 管理 API
您可以透過管理員 REST API 建立、移除和更新使用者儲存供應商部署。使用者儲存 SPI 是建立在通用元件介面之上,因此您將使用該通用 API 來管理您的供應商。
REST 元件 API 位於您的領域管理資源下。
/admin/realms/{realm-name}/components
我們只會展示與 Java 用戶端的此 REST API 互動。希望您可以從此 API 的 curl
中提取如何執行此操作。
public interface ComponentsResource {
@GET
@Produces(MediaType.APPLICATION_JSON)
public List<ComponentRepresentation> query();
@GET
@Produces(MediaType.APPLICATION_JSON)
public List<ComponentRepresentation> query(@QueryParam("parent") String parent);
@GET
@Produces(MediaType.APPLICATION_JSON)
public List<ComponentRepresentation> query(@QueryParam("parent") String parent, @QueryParam("type") String type);
@GET
@Produces(MediaType.APPLICATION_JSON)
public List<ComponentRepresentation> query(@QueryParam("parent") String parent,
@QueryParam("type") String type,
@QueryParam("name") String name);
@POST
@Consumes(MediaType.APPLICATION_JSON)
Response add(ComponentRepresentation rep);
@Path("{id}")
ComponentResource component(@PathParam("id") String id);
}
public interface ComponentResource {
@GET
public ComponentRepresentation toRepresentation();
@PUT
@Consumes(MediaType.APPLICATION_JSON)
public void update(ComponentRepresentation rep);
@DELETE
public void remove();
}
若要建立使用者儲存供應商,您必須指定供應商 ID、字串 org.keycloak.storage.UserStorageProvider
的供應商類型,以及組態。
import org.keycloak.admin.client.Keycloak;
import org.keycloak.representations.idm.RealmRepresentation;
...
Keycloak keycloak = Keycloak.getInstance(
"https://127.0.0.1:8080",
"master",
"admin",
"password",
"admin-cli");
RealmResource realmResource = keycloak.realm("master");
RealmRepresentation realm = realmResource.toRepresentation();
ComponentRepresentation component = new ComponentRepresentation();
component.setName("home");
component.setProviderId("readonly-property-file");
component.setProviderType("org.keycloak.storage.UserStorageProvider");
component.setParentId(realm.getId());
component.setConfig(new MultivaluedHashMap());
component.getConfig().putSingle("path", "~/users.properties");
realmResource.components().add(component);
// retrieve a component
List<ComponentRepresentation> components = realmResource.components().query(realm.getId(),
"org.keycloak.storage.UserStorageProvider",
"home");
component = components.get(0);
// Update a component
component.getConfig().putSingle("path", "~/my-users.properties");
realmResource.components().component(component.getId()).update(component);
// Remove a component
realmREsource.components().component(component.getId()).remove();
從先前的使用者聯合 SPI 遷移
如果您使用較早的(現在已移除)使用者聯合 SPI 實作了供應商,則本章才適用。 |
在 Keycloak 2.4.0 和更早版本中,有一個使用者聯合 SPI。Red Hat Single Sign-On 7.0 版本雖然不受支援,但也提供了這個較早的 SPI。這個較早的使用者聯合 SPI 已從 Keycloak 2.5.0 版本和 Red Hat Single Sign-On 7.1 版本中移除。但是,如果您使用這個較早的 SPI 撰寫了供應商,本章將討論一些您可以使用的移植策略。
匯入與非匯入
較早的使用者聯合 SPI 要求您在 Keycloak 的資料庫中建立使用者的本機複本,並將資訊從您的外部儲存匯入到本機複本。但是,這不再是必要條件。您仍然可以按原樣移植較早的供應商,但您應該考慮非匯入策略是否可能是更好的方法。
匯入策略的優點
-
Keycloak 基本上成為您外部儲存的持久性使用者快取。使用者匯入後,您將不再訪問外部儲存,從而減輕其負載。
-
如果您要將 Keycloak 作為您的官方使用者儲存並棄用較早的外部儲存,您可以緩慢地遷移應用程式以使用 Keycloak。當所有應用程式都已遷移完成後,取消連結匯入的使用者,並停用較早的舊版外部儲存。
但是,使用匯入策略有一些明顯的缺點
-
第一次查詢使用者將需要多次更新 Keycloak 資料庫。這在負載下可能會造成很大的效能損失,並對 Keycloak 資料庫造成很大的壓力。使用者聯合儲存方法只會在需要時儲存額外資料,並且可能會因為外部儲存的功能而永遠不會使用。
-
使用匯入方法,您必須讓本機 Keycloak 儲存與外部儲存保持同步。使用者儲存 SPI 具有您可以實作以支援同步的介面,但這可能會很快變得痛苦和混亂。
UserFederationProvider 與 UserStorageProvider
首先要注意的是,UserFederationProvider
是一個完整的介面。您在這個介面中實作了每個方法。但是,UserStorageProvider
卻將這個介面分解為多個功能介面,您可以根據需要實作這些介面。
UserFederationProvider.getUserByUsername()
和 getUserByEmail()
在新的 SPI 中具有完全相同的等效項目。兩者之間的差異在於您如何匯入。如果您要繼續使用匯入策略,您不再呼叫 KeycloakSession.userStorage().addUser()
在本機建立使用者。而是呼叫 KeycloakSession.userLocalStorage().addUser()
。userStorage()
方法已不存在。
UserFederationProvider.validateAndProxy()
方法已移至可選的功能介面 ImportedUserValidation
。如果您要按原樣移植較早的供應商,則要實作此介面。另請注意,在較早的 SPI 中,每次存取使用者時都會呼叫此方法,即使本機使用者位於快取中也是如此。在較新的 SPI 中,只有在本機使用者從本機儲存載入時才會呼叫此方法。如果快取了本機使用者,則根本不會呼叫 ImportedUserValidation.validate()
方法。
UserFederationProvider.isValid()
方法在較新的 SPI 中已不存在。
UserFederationProvider
方法 synchronizeRegistrations()
、registerUser()
和 removeUser()
已移至 UserRegistrationProvider
功能介面。此新介面是可選的實作,因此如果您的供應商不支援建立和移除使用者,則不必實作它。如果您的較早供應商有切換功能以切換支援註冊新使用者,則新的 SPI 支援此功能,如果供應商不支援新增使用者,則從 UserRegistrationProvider.addUser()
傳回 null
。
較早的 UserFederationProvider
方法以憑證為中心,現在封裝在 CredentialInputValidator
和 CredentialInputUpdater
介面中,這兩個介面也是可選的實作,具體取決於您是否支援驗證或更新憑證。憑證管理過去存在於 UserModel
方法中。這些方法也已移至 CredentialInputValidator
和 CredentialInputUpdater
介面。需要注意的一點是,如果您沒有實作 CredentialInputUpdater
介面,則您的供應商提供的任何憑證都可以在 Keycloak 儲存中在本機覆寫。因此,如果您希望您的憑證是唯讀的,請實作 CredentialInputUpdater.updateCredential()
方法並傳回 ReadOnlyException
。
UserFederationProvider
查詢方法,例如 searchByAttributes()
和 getGroupMembers()
,現在封裝在可選的介面 UserQueryProvider
中。如果您沒有實作此介面,則使用者將無法在管理員主控台中檢視。不過,您仍然可以登入。
UserFederationProviderFactory 與 UserStorageProviderFactory
較早 SPI 中的同步方法現在封裝在可選的 ImportSynchronization
介面中。如果您已實作同步邏輯,則讓您的新 UserStorageProviderFactory
實作 ImportSynchronization
介面。
升級到新模型
使用者儲存 SPI 實例儲存在一組不同的關聯式表格中。Keycloak 會自動執行遷移腳本。如果為領域部署了任何較早的使用者聯合供應商,它們會按原樣轉換為較新的儲存模型,包括資料的 id
。只有當使用者儲存供應商與較早的使用者聯合供應商具有相同的供應商 ID(例如,「ldap」、「kerberos」)時,才會發生此遷移。
因此,了解這一點,您可以採用不同的方法。
-
您可以移除較早 Keycloak 部署中的較早供應商。這將移除您匯入的所有使用者的本機連結複本。然後,當您升級 Keycloak 時,只需為您的領域部署和設定新的供應商。
-
第二個選項是撰寫您的新供應商,確保它具有相同的供應商 ID:
UserStorageProviderFactory.getId()
。確保此供應商已部署到伺服器。啟動伺服器,並讓內建的遷移腳本從較早的資料模型轉換為較新的資料模型。在這種情況下,您所有較早連結的匯入使用者都將正常運作且相同。
如果您已決定擺脫匯入策略並重寫您的使用者儲存供應商,我們建議您在升級 Keycloak 之前移除較早的供應商。這將移除您匯入的任何使用者的連結本機匯入複本。
基於串流的介面
Keycloak 中的許多使用者儲存介面都包含可能會傳回大量物件集的查詢方法,這可能會在記憶體消耗和處理時間方面產生重大影響。當查詢方法的邏輯中只使用物件內部狀態的一小部分時,尤其如此。
為了向開發人員提供更有效率的替代方案來處理這些查詢方法中的大型資料集,已在使用者儲存介面中新增了 Streams
子介面。這些 Streams
子介面使用基於串流的變體來取代超介面中原有的基於集合的方法,使基於集合的方法成為預設方法。基於集合的查詢方法的預設實作會叫用其 Stream
對應項,並將結果收集到適當的集合類型中。
Streams
子介面允許實作將重點放在基於串流的方法上,以處理資料集,並從該方法的潛在記憶體和效能最佳化中獲益。提供 Streams
子介面供實作的介面包括一些功能介面,org.keycloak.storage.federated
套件中的所有介面,以及其他一些可能根據自訂儲存實作範圍實作的介面。
請參閱提供 Streams
子介面給開發人員實作的介面清單。
套件 |
類別 |
|
|
|
|
|
|
|
所有介面 |
|
|
(*) 表示介面是功能介面
想要從串流方法中獲益的自訂使用者儲存實作應該只實作 Streams
子介面,而不是原始介面。例如,以下程式碼使用 UserQueryProvider
介面的 Streams
變體
public class CustomQueryProvider extends UserQueryProvider.Streams {
...
@Override
Stream<UserModel> getUsersStream(RealmModel realm, Integer firstResult, Integer maxResults) {
// custom logic here
}
@Override
Stream<UserModel> searchForUserStream(String search, RealmModel realm) {
// custom logic here
}
...
}
Vault SPI
Vault 提供者
您可以使用 org.keycloak.vault
套件中的 vault SPI 來編寫 Keycloak 的自訂擴充功能,以連接到任意的 vault 實作。
內建的 files-plaintext
提供者是此 SPI 實作的一個範例。一般而言,以下規則適用:
-
為了防止密碼洩漏到不同的 realm 中,您可能需要隔離或限制 realm 可以檢索的密碼。在這種情況下,您的提供者在查詢密碼時應考慮 realm 名稱,例如在條目名稱前加上 realm 名稱。例如,表達式
${vault.key}
通常會根據它是在 realm *A* 還是 realm *B* 中使用而評估為不同的條目名稱。為了區分不同的 realm,需要從VaultProviderFactory.create()
方法將 realm 傳遞給建立的VaultProvider
實例,其中 realm 可以從KeycloakSession
參數取得。 -
vault 提供者需要實作一個單一方法
obtainSecret
,該方法會針對給定的密碼名稱傳回一個VaultRawSecret
。該類別以byte[]
或ByteBuffer
的形式保存密碼的表示,並且預期會根據需要在這兩者之間轉換。請注意,如下所述,此緩衝區將在使用後被丟棄。
關於 realm 分隔,所有內建的 vault 提供者工廠都允許設定一個或多個金鑰解析器。金鑰解析器由 VaultKeyResolver
介面表示,它基本上實作了將 realm 名稱與金鑰(從 ${vault.key}
表達式取得)組合到最終條目名稱的演算法或策略,該最終條目名稱將用於從 vault 檢索密碼。處理此設定的程式碼已提取到抽象的 vault 提供者和 vault 提供者工廠類別中,因此想要提供金鑰解析器支援的自訂實作可以擴展這些抽象類別,而不是實作 SPI 介面,以繼承在檢索密碼時設定應嘗試的金鑰解析器的能力。
有關如何封裝和部署自訂提供者的詳細資訊,請參閱服務提供者介面章節。
從 vault 取用值
vault 包含敏感資料,Keycloak 會相應地處理密碼。當存取密碼時,會從 vault 取得密碼,並僅在必要的時間內保留在 JVM 記憶體中。然後會盡一切可能嘗試從 JVM 記憶體中丟棄其內容。這是通過僅在 try
-with-resources 語句中使用 vault 密碼來實現的,如下所述:
char[] c;
try (VaultCharSecret cSecret = session.vault().getCharSecret(SECRET_NAME)) {
// ... use cSecret
c = cSecret.getAsArray().orElse(null);
// if c != null, it now contains password
}
// if c != null, it now contains garbage
此範例使用 KeycloakSession.vault()
作為存取密碼的入口點。直接使用 VaultProvider.obtainSecret
方法也是可行的。然而,vault()
方法的優點是能夠將原始密碼(通常是位元組陣列)解釋為字元陣列(透過 vault().getCharSecret()
)或 String
(透過 vault().getStringSecret()
),此外還可以取得原始未經解釋的值(透過 vault().getRawSecret()
方法)。
請注意,由於 String
物件是不可變的,因此無法透過覆寫隨機垃圾來丟棄其內容。儘管在預設的 VaultStringSecret
實作中已採取措施防止內部化 String
,但儲存在 String
物件中的密碼至少會存活到下一次 GC 回合。因此,最好使用純位元組和字元陣列以及緩衝區。