前言
在某些示例列表中,在一行上显示的内容无法容纳在可用的页面宽度内。这些行已断开。行末的“\”表示已引入断点以适应页面,接下来的行将缩进。所以
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 的示例
使用用户名和密码进行身份验证
以下示例假设您在入门指南教程中创建了用户名为 admin 、密码为 password 的用户,该用户属于 master 领域。 |
-
获取用户名为
admin
、密码为password
的用户的master
领域的访问令牌curl \ -d "client_id=admin-cli" \ -d "username=admin" \ -d "password=password" \ -d "grant_type=password" \ "http://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..." \ "http://127.0.0.1:8080/admin/realms/master"
使用服务帐户进行身份验证
要使用 client_id
和 client_secret
对管理员 REST API 进行身份验证,请执行以下步骤。
-
确保客户端配置如下
-
client_id
是一个属于 master 领域的 机密 客户端 -
client_id
已启用服务帐户已启用
选项 -
client_id
具有自定义“受众”映射器-
包含的客户端受众:
security-admin-console
-
-
-
检查
client_id
是否在“服务帐户角色”选项卡中被分配了“管理员”角色。
curl \
-d "client_id=<YOUR_CLIENT_ID>" \
-d "client_secret=<YOUR_CLIENT_SECRET>" \
-d "grant_type=client_credentials" \
"http://127.0.0.1:8080/realms/master/protocol/openid-connect/token"
主题
Keycloak 为网页和电子邮件提供主题支持。这允许您自定义面向最终用户的页面的外观和感觉,以便它们可以与您的应用程序集成。
主题类型
主题可以提供一种或多种类型来自定义 Keycloak 的不同方面。可用的类型包括
-
帐户 - 帐户控制台
-
管理员 - 管理员控制台
-
电子邮件 - 电子邮件
-
登录 - 登录表单
-
欢迎 - 欢迎页面
配置主题
所有主题类型(欢迎页面除外)都通过管理员控制台进行配置。
-
登录管理员控制台。
-
从左上角的下拉框中选择您的领域。
-
从菜单中单击 领域设置。
-
单击 主题 选项卡。
要设置 master
管理员控制台的主题,您需要设置master
领域的管理员控制台主题。 -
要查看对管理员控制台的更改,请刷新页面。
-
使用
spi-theme-welcome-theme
选项更改欢迎主题。 -
例如
bin/kc.[sh|bat] start --spi-theme-welcome-theme=custom-theme
默认主题
Keycloak 在服务器发行版内的 JAR 文件 keycloak-themes-26.0.5.jar
中捆绑了默认主题。服务器的根 themes
目录默认情况下不包含任何主题,但它包含一个包含有关默认主题的一些其他详细信息的 README 文件。为了简化升级,请不要直接编辑捆绑的主题。相反,请创建您自己的主题,该主题扩展了捆绑的主题之一。
创建主题
主题包含
-
HTML 模板 (Freemarker 模板)
-
图像
-
消息捆绑包
-
样式表
-
脚本
-
主题属性
除非您打算替换每个页面,否则您应该扩展另一个主题。您最有可能希望扩展一些现有主题。或者,如果您打算提供您自己的管理员或帐户控制台的实现,请考虑扩展 base
主题。base
主题包含一个消息捆绑包,因此此类实现需要从头开始,包括主 index.ftl
Freemarker 模板的实现,但它可以利用消息捆绑包中的现有翻译。
扩展主题时,您可以覆盖单个资源(模板、样式表等)。如果您决定覆盖 HTML 模板,请记住,在升级到新版本时,您可能需要更新自定义模板。
创建主题时,最好禁用缓存,因为这样可以在不重新启动 Keycloak 的情况下直接从 themes
目录编辑主题资源。
-
使用以下选项运行 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
您现在已经创建了一个支持登录类型的主题。
-
登录管理控制台以查看您的新主题
-
选择您的 realm
-
从菜单中单击 领域设置。
-
点击主题选项卡。
-
在登录主题中选择mytheme,然后点击保存。
-
打开 realm 的登录页面。
您可以通过应用程序登录或打开帐户控制台 (
/realms/{realm name}/account
) 来执行此操作。 -
要查看更改父主题的效果,请在
theme.properties
中设置parent=keycloak
并刷新登录页面。
请确保在生产环境中重新启用缓存,因为它会严重影响性能。 |
如果您想手动删除主题缓存的内容,您可以删除服务器发行版的 |
主题属性
主题属性在主题目录下的 <THEME TYPE>/theme.properties
文件中设置。
-
parent - 要扩展的父主题
-
import - 从另一个主题导入资源
-
common - 覆盖公共资源路径。如果未指定,则默认值为
common/keycloak
。此值用作${url.resourcesCommonPath}
后缀的值,该值通常用于 freemarker 模板(${url.resoucesCommonPath}
值的前缀是主题根 URI)。 -
styles - 要包含的样式的空格分隔列表
-
locales - 支持的语言环境的逗号分隔列表
有一系列属性可用于更改用于特定元素类型的 css 类。有关这些属性的列表,请查看 keycloak 主题相应类型中的 theme.properties
文件 (themes/keycloak/<THEME TYPE>/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}
向主题添加样式表
您可以向主题添加一个或多个样式表。
-
在您主题的
<THEME TYPE>/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
-
要查看更改,请打开 realm 的登录页面。
您会注意到,唯一应用的样式来自您的自定义样式表。
-
要包含父主题中的样式,请加载该主题中的样式。编辑
themes/mytheme/login/theme.properties
并将styles
更改为styles=css/login.css css/styles.css
要覆盖父样式表中的样式,请确保您的样式表位于最后。
向主题添加脚本
您可以向主题添加一个或多个脚本。
-
在您主题的
<THEME TYPE>/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
向主题添加图片
要使主题可以使用图片,请将它们添加到主题的 <THEME TYPE>/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>
向电子邮件主题添加图片
要使主题可以使用图片,请将它们添加到主题的 <THEME TYPE>/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">
消息
模板中的文本从消息捆绑包中加载。扩展另一个主题的主题将继承父消息捆绑包中的所有消息,您可以通过在主题中添加 <THEME TYPE>/messages/messages_en.properties
来覆盖单个消息。
例如,要将 mytheme
主题的登录表单上的 Username
替换为 Your Username
,请在 themes/mytheme/login
目录下创建 messages_en.properties
文件,其内容如下:
usernameOrEmail=Your Username
在消息中,{0}
和 {1}
等值会在使用消息时被替换为参数。例如,Log in to {0}
中的 {0}
将被替换为 realm 的名称。
这些消息捆绑包的文本可以通过 realm 特定的值覆盖。realm 特定的值可以通过 UI 和 API 进行管理。
向 realm 添加语言
-
要为 realm 启用国际化,请参阅服务器管理指南。
-
在主题目录下创建
<THEME TYPE>/messages/messages_<LOCALE>.properties
文件。 -
将此文件添加到
<THEME TYPE>/theme.properties
文件中的locales
属性中。要使语言对用户可用,realm 的login
、account
和email
主题必须支持该语言,因此您需要为这些主题类型添加您的语言。例如,要将挪威语翻译添加到
mytheme
主题,请在themes/mytheme/login
目录下创建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 支持为自定义身份提供程序添加图标,这些图标将显示在登录屏幕上。
-
在您的登录
theme.properties
文件(例如,themes/mytheme/login/theme.properties
)中定义图标类,键模式为kcLogoIdP-<alias>
。 -
对于别名为
myProvider
的身份提供程序,您可以向自定义主题的theme.properties
文件添加一行。例如kcLogoIdP-myProvider = fa fa-lock
所有图标都可以在 PatternFly4 的官方网站上找到。社交提供程序的图标已经在 base
登录主题属性 (themes/keycloak/login/theme.properties
) 中定义,您可以参考这些属性。
创建自定义 HTML 模板
Keycloak 使用 Apache Freemarker 模板来生成 HTML 和渲染页面。
虽然可以创建自定义模板来完全更改页面渲染方式,但建议尽可能地利用内置模板。原因如下:
在大多数情况下,您不需要更改模板来调整 Keycloak 以满足您的需求,但您可以通过创建 <THEME TYPE>/<TEMPLATE>.ftl
来覆盖您自己主题中的单个模板。管理和帐户控制台使用单个模板 index.ftl
来渲染应用程序。
要查看其他主题类型中的模板列表,请查看 JAR 文件 $KEYCLOAK_HOME/lib/lib/main/org.keycloak.keycloak-themes-<VERSION>.jar
中的 theme/base/<THEME_TYPE>
目录。
-
将模板从基本主题复制到您自己的主题中。
-
应用您需要的修改。
例如,要为
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
,您可以准确地决定如何加载模板和资源。
语言环境选择器
默认情况下,语言环境使用DefaultLocaleSelectorProvider
选择,它实现了LocaleSelectorProvider
接口。当禁用国际化时,英语是默认语言。
启用国际化后,语言环境将根据服务器管理指南 中描述的逻辑解析。
这种行为可以通过LocaleSelectorSPI
进行更改,方法是实现LocaleSelectorProvider
和LocaleSelectorProviderFactory
。
LocaleSelectorProvider
接口只有一个方法resolveLocale
,该方法必须根据RealmModel
和可为空的UserModel
返回一个语言环境。实际请求可从KeycloakSession#getContext
方法获取。
自定义实现可以扩展DefaultLocaleSelectorProvider
以重用默认行为的部分。例如,要忽略Accept-Language
请求标头,自定义实现可以扩展默认提供程序,覆盖其getAcceptLanguageHeaderLocale
并返回一个空值。这样,语言环境选择将回退到领域的默认语言。
语言环境选择器的附加资源
-
有关创建和部署自定义提供程序的更多详细信息,请参阅服务提供程序接口。
身份代理 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)。服务器启动与外部提供商的登录。浏览器在外部提供商处登录,然后重定向回服务器。服务器建立链接并重定向回应用程序,并进行确认。
在客户端应用程序可以启动此协议之前,必须满足某些先决条件。
-
在管理控制台中,必须为用户的 realm 配置并启用所需的标识提供商。
-
用户帐户必须已经通过 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 和要访问的标识提供商别名进行哈希运算。
以下是一个生成用于建立帐户链接的 URL 的 Java Servlet 代码示例。
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;
}
}
如果有多个具有相同 provider 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
检索现有提供商,如实现 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
-
org.jboss.logging.Logger
,作用域为ScriptBasedAuthenticator
您可以从传递给 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 协议映射器脚本是允许您更改 ID 令牌和/或访问令牌内容的 javascript 脚本。
您可以在代码中使用 javax.script.Bindings
脚本。
用户
-
当前
UserModel
领域
-
RealmModel
令牌
-
当前
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
。这将可用于映射到在映射器中配置的 Token Claim Name
中。
创建包含要部署的脚本的 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-映射器
适用于 SAML 脚本协议映射器。您可以在同一个 JAR 文件中拥有一个或多个映射器
对于 JAR
文件中的每个脚本文件,您都需要在 META-INF/keycloak-scripts.json
中有一个相应的条目,将您的脚本文件映射到特定的提供者类型。为此,您应该为每个条目提供以下属性
-
名称
一个友好的名称,将用于通过 Keycloak 管理控制台显示脚本。如果未提供,将使用脚本文件的名称
-
描述
一个可选文本,更好地描述脚本文件的意图
-
fileName
脚本文件的名称。此属性是 **必需的**,应映射到 JAR 中的文件。
可用的 SPI
如果您想查看运行时所有可用 SPI 的列表,您可以查看管理控制台中的 服务器信息
页面,如 管理控制台 部分所述。ExampleSpi
扩展服务器
Keycloak SPI 框架提供了实现或覆盖特定内置提供者的可能性。但是,Keycloak 还提供了扩展其核心功能和域的功能。这包括以下可能性
-
向 Keycloak 服务器添加自定义 REST 端点
-
添加您自己的自定义 SPI
-
向 Keycloak 数据模型添加自定义 JPA 实体
添加自定义 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
生命周期)。 -
最后,您需要以与 服务提供者接口 章节中描述的方式相同的方式实现您的提供者。
向 Keycloak 数据模型添加自定义 JPA 实体
如果 Keycloak 数据模型与您想要的解决方案不完全匹配,或者您想向 Keycloak 添加一些核心功能,或者当您有自己的 REST 端点时,您可能想要扩展 Keycloak 数据模型。我们允许您向 Keycloak JPA EntityManager
添加您自己的 JPA 实体。
要添加您自己的 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
并对其进行 DB 操作。
EntityManager em = session.getProvider(JpaConnectionProvider.class).getEntityManager();
Company myCompany = em.find(Company.class, "123");
方法 getChangelogLocation
和 getFactoryId
对通过 Liquibase 自动更新实体非常重要。 Liquibase 是一个用于更新数据库模式的框架,Keycloak 在内部使用它来创建 DB 模式并在版本之间更新 DB 模式。您可能也需要使用它并为您的实体创建一个变更日志。请注意,您自己的 Liquibase 变更日志的版本控制与 Keycloak 版本无关。换句话说,当您更新到新的 Keycloak 版本时,您不需要同时更新您的模式。反之亦然,您可以在不更新 Keycloak 版本的情况下更新您的模式。Liquibase 更新始终在服务器启动时完成,因此要触发您的模式的 DB 更新,您只需将新的变更集添加到您的 Liquibase 变更日志文件(在上面的示例中,它是文件 META-INF/example-changelog.xml
,它必须与 JPA 实体和 ExampleJpaEntityProvider
打包在同一个 JAR 中),然后重新启动服务器。DB 模式将在启动时自动更新。
在对 Liquibase 变更日志进行任何更改并触发 DB 更新之前,请务必始终备份您的数据库。 |
身份验证 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 提供程序返回成功()状态。由于 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 地址,这些帐户或 IP 地址的登录失败次数过多。如果用户名和密码有效,则提供程序将 UserModel 与 AuthenticationSessionModel 关联,并返回成功()状态。
-
下一个执行是一个名为 Conditional OTP 的子流程。加载此子流程的执行,并执行相同的处理逻辑。它的要求是条件的。这意味着流程将首先评估它包含的所有条件执行程序。条件执行程序是实现
ConditionalAuthenticator
的身份验证器,并且必须实现boolean matchCondition(AuthenticationFlowContext context)
方法。条件子流程将调用它包含的所有条件执行的matchCondition
方法,如果它们都评估为 true,则它将充当所需子流程。如果没有,它将充当禁用子流程。条件身份验证器仅用于此目的,不作为身份验证器使用。这意味着,即使条件身份验证器评估为“true”,这也不会将流程或子流程标记为成功。例如,仅包含一个条件子流程(其中仅包含一个条件身份验证器)的流程永远不会允许用户登录。 -
Conditional OTP 子流程的第一个执行是 Condition - User Configured。此提供程序要求用户已与流程关联。此要求已满足,因为 UsernamePassword 提供程序已将用户与流程关联。此提供程序的
matchCondition
方法将评估其当前子流程中所有其他身份验证器的configuredFor
方法。如果子流程包含其要求设置为必需的执行程序,则matchCondition
方法仅在所有必需身份验证器的configuredFor
方法都评估为 true 时才评估为 true。否则,如果任何备选身份验证器评估为 true,则matchCondition
方法将评估为 true。 -
下一个执行是 OTP Form。此提供程序也要求用户已与流程关联。此要求已满足,因为 UsernamePassword 提供程序已将用户与流程关联。由于用户是此提供程序所必需的,因此也会询问该提供程序用户是否已配置为使用此提供程序。如果用户未配置,则流程将设置一个必备操作,用户必须在身份验证完成后执行该操作。对于 OTP,这意味着 OTP 设置页面。如果用户已配置,则会要求他输入 OTP 代码。在我们的场景中,由于条件子流程,用户永远不会看到 OTP 登录页面,除非 Conditional OTP 子流程设置为 Required。
-
流程完成后,身份验证处理器将创建一个 UserSessionModel 并将其与 AuthenticationSessionModel 关联。然后,它会检查用户是否需要在登录前完成任何必备操作。
-
首先,将调用每个必备操作的 evaluateTriggers()方法。这允许必备操作提供程序确定是否存在可能触发操作触发的状态。例如,如果您的领域具有密码过期策略,则可能由此方法触发。
-
将调用与具有 requiredActionChallenge()方法的用户关联的每个必备操作。在这里,提供程序设置一个 HTTP 响应,它会渲染必备操作的页面。这是通过设置质询状态来完成的。
-
如果必备操作最终成功,则该必备操作将从用户的必备操作列表中删除。
-
在所有必备操作都解决后,用户最终将登录。
身份验证器 SPI 演练
在本节中,我们将介绍身份验证器接口。为此,我们将实现一个身份验证器,要求用户输入一个秘密问题的答案,例如“你母亲的娘家姓是什么?”。此示例已完全实现,并包含在 Keycloak 快速入门仓库 仓库下的 extension/authenticator
中。
要创建身份验证器,您至少需要实现 org.keycloak.authentication.AuthenticatorFactory 和 Authenticator 接口。Authenticator 接口定义了逻辑。AuthenticatorFactory 负责创建身份验证器的实例。它们都扩展了更通用的 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
此 services/ 文件用于 Keycloak 扫描它必须加载到系统中的提供程序。
要部署此 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
与所有提供程序一样,为了允许 Keycloak 生成 CredentialProvider,我们需要一个 CredentialProviderFactory。为此,我们创建了 SecretQuestionCredentialProviderFactory,它的 create
方法将在需要 SecretQuestionCredentialProvider 时被调用。
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");
}
现在我们开始进入身份验证器实现的核心部分。下一个需要实现的方法是 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,该 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 响应对象。然后,我们调用 AuthenticationFlowContext.challenge(),并将此响应作为参数传递。这会将执行的状态设置为 CHALLENGE,如果执行是必需的,则此 JAX-RS 响应对象将被发送到浏览器。
因此,询问秘密问题答案的 HTML 页面会显示给用户,用户输入答案并单击提交。HTML 表单的操作 URL 将向流程发送一个 HTTP 请求。流程最终将调用我们身份验证器实现的 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。如果存在配置,我们将从其中提取最大生存时间配置。在讨论身份验证器工厂实现时,我们将看到如何定义配置内容。如果在身份验证器工厂实现中设置了配置定义,则可以在管理控制台中定义配置值。
@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 相同,但不是创建新的凭据,而是更新凭据)。
实现身份验证器工厂
此过程的下一步是实现一个身份验证器工厂。该工厂负责实例化一个身份验证器。它还提供有关身份验证器的部署和配置元数据。
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,但身份验证器工厂实现可以限制在定义流程时在管理控制台中显示哪些需求选项。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>
包含在 ${}
中的任何文本都对应于一个属性或模板函数。如果您看到表单的操作,您会看到它指向 ${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
此 services/ 文件用于 Keycloak 扫描它必须加载到系统中的提供程序。
要部署此 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();
}
答案是从表单提交中提取的。创建一个 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 需要从配置中获取的某些特定设置。FormActions 的配置方式与 Authenticators 完全相同。在这个例子中,我们从 Recaptcha 配置中提取 Google Recaptcha 站点密钥和其他选项,并将它们作为属性添加到表单提供程序中。现在,我们的注册模板文件 register.ftl 可以访问这些属性。
Recaptcha 还需要加载 JavaScript 脚本。您可以通过调用 LoginFormsProvider.addScript(),传入 URL 来实现。
对于用户资料处理,它不需要向表单添加任何其他信息,因此它的 buildPage() 方法为空。
该接口的下一个重要部分是 validate() 方法。它在收到表单提交后立即被调用。让我们先看看 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 或注册字段,但您可以只适当地配置用户资料以反映这一点。有关更多详细信息,请参见 用户资料文档。一般来说,新的 FormAction 可能在您想要向注册表单添加新的凭据(例如,此处提到的 ReCaptcha 支持)而不是新的用户资料字段时很有用。 |
在处理完所有验证后,表单流程将调用 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
此 services/ 文件用于 Keycloak 扫描它必须加载到系统中的提供程序。
要部署此 jar,请将其复制到 providers/
目录,然后运行 bin/kc.[sh|bat] build
。
将 FormAction 添加到注册流程
必须在管理控制台中将 FormAction 添加到注册页面流程。如果您转到“身份验证”菜单项并转到“流程”选项卡,您将能够查看当前定义的流程。您无法修改内置流程,因此,要添加我们创建的 Authenticator,您必须复制现有流程或创建自己的流程。我希望 UI 足够直观,以便您可以自行弄清楚如何创建流程并添加 FormAction。
基本上,您必须复制注册流程。然后单击注册表单右侧的操作菜单,并选择“添加执行”以添加新的执行。您将从选择列表中选择 FormAction。确保您的 FormAction 位于“注册用户创建”之后,通过使用向下按钮移动它(如果您的 FormAction 尚未在“注册用户创建”之后列出)。您希望您的 FormAction 出现在用户创建之后,因为“注册用户创建”的 success() 方法负责创建新的 UserModel。
创建流程后,您必须将其绑定到注册。如果您转到“身份验证”菜单并转到“绑定”选项卡,您将看到将流程绑定到浏览器、注册或直接授予流程的选项。
修改忘记密码/凭据流程
Keycloak 还为忘记密码或用户发起的凭据重置提供了一个特定身份验证流程。如果您转到管理控制台流程页面,则有一个“重置凭据”流程。默认情况下,Keycloak 会询问用户的电子邮件或用户名,并将电子邮件发送给他们。如果用户单击链接,则他们可以重置其密码和 OTP(如果已设置 OTP)。您可以通过禁用流程中的“重置 OTP”身份验证器来禁用自动 OTP 重置。
您也可以向此流程添加其他功能。例如,许多部署希望用户在发送包含链接的电子邮件时,除了回答一个或多个秘密问题之外。您可以扩展发行版附带的秘密问题示例,并将其整合到重置凭据流程中。
如果您正在扩展重置凭据流程,需要注意的一点。第一个“身份验证器”只是一个页面,用于获取用户名或电子邮件。如果用户名或电子邮件存在,则 AuthenticationFlowContext.getUser() 将返回找到的用户。否则,这将为空。如果以前的电子邮件或用户名不存在,此表单 **将不会** 再次要求用户输入电子邮件或用户名。您需要防止攻击者猜测有效的用户。因此,如果 AuthenticationFlowContext.getUser() 返回空值,则应继续进行流程,使其看起来像是选择了有效用户。建议如果要在此流程中添加秘密问题,则应在发送电子邮件后询问这些问题。换句话说,在“发送重置电子邮件”身份验证器之后添加您的自定义身份验证器。
修改第一个代理登录流程
第一个代理登录流程在首次使用某些身份提供者登录时使用。术语“首次登录”意味着尚未存在与特定已验证身份提供者帐户关联的 Keycloak 帐户。
-
请参见 服务器管理指南 中的“身份代理”一章。
客户端身份验证
Keycloak 实际上支持针对 OpenID Connect 客户端应用程序的可插拔身份验证。客户端(应用程序)的身份验证在 Keycloak 适配器向 Keycloak 服务器发送任何后端请求时被使用(例如,在成功身份验证后将代码交换为访问令牌的请求或刷新令牌的请求)。但是,您也可以在 `直接访问授予`(由 OAuth2 `资源所有者密码凭据流程` 表示)或在 `服务帐户` 身份验证(由 OAuth2 `客户端凭据流程` 表示)期间直接使用客户端身份验证。
-
有关 Keycloak 适配器和 OAuth2 流程的更多详细信息,请参见 保护应用程序指南。
默认实现
实际上,Keycloak 有 2 个默认的客户端身份验证实现
- 使用 client_id 和 client_secret 的传统身份验证
-
这是 OpenID Connect 或 OAuth2 规范中提到的默认机制,Keycloak 自其早期就支持它。公共客户端需要在 POST 请求中包含带有其 ID 的 `client_id` 参数(因此它实际上未经身份验证),而机密客户端需要包含带有 clientId 和 clientSecret 的 `Authorization: Basic` 标头,用作用户名和密码。
- 使用签名 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 Token (JWT) 的一个特殊实例,它允许其持有者执行某些操作,例如重置密码或验证电子邮件地址。它们通常以指向处理特定领域的操作令牌的端点的链接形式发送给用户。
Keycloak 提供了四种基本令牌类型,允许持有者
-
重置凭据
-
确认电子邮件地址
-
执行必需的操作
-
确认将帐户与外部身份提供者中的帐户链接
除此之外,可以使用操作令牌处理程序 SPI 实现任何启动或修改身份验证会话的功能,其详细信息将在下面的文本中介绍。
操作令牌的结构
操作令牌是一个使用活动领域密钥签名的标准 JSON Web Token,其有效载荷包含多个字段。
-
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。
以下示例将前一个示例中的 DemoActionToken
扩展了字段 demo-id
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 中;
-
将被添加到 SP 应用程序 WAR 的 WEB-INF/lib 中的自定义 JAR 文件;
-
(仅限 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 的数据模型。对于这些情况,应用程序开发人员可以编写 User Storage SPI 的实现,以桥接外部用户存储和 Keycloak 用于登录和管理用户的内部用户对象模型。
当 Keycloak 运行时需要查找用户(例如,当用户登录时),它会执行多个步骤来定位用户。它首先查看用户是否在用户缓存中;如果找到用户,它将使用该内存中的表示。然后,它在 Keycloak 本地数据库中查找用户。如果未找到用户,它将循环遍历 User Storage SPI 提供程序实现以执行用户查询,直到其中一个返回运行时正在查找的用户。提供程序查询外部用户存储以获取用户,并将用户的外部数据表示映射到 Keycloak 的用户元模型。
User Storage SPI 提供程序实现还可以执行复杂的条件查询,对用户执行 CRUD 操作,验证和管理凭据,或一次性对许多用户执行批量更新。这取决于外部存储的功能。
User Storage SPI 提供程序实现的打包和部署方式与(通常是)Jakarta EE 组件类似。它们默认情况下不会启用,而是必须在管理控制台的“用户联合”选项卡下为每个领域启用和配置。
如果您的用户提供程序实现使用某些用户属性作为链接/建立用户身份的元数据属性,那么请确保用户无法编辑这些属性,并且相应的属性是只读的。例如,LDAP_ID 属性,内置的 Keycloak LDAP 提供程序使用它来存储 LDAP 服务器端的用户 ID。有关详细信息,请参阅威胁模型缓解章节。 |
在Keycloak 快速入门存储库中,有两个示例项目。每个快速入门都有一个README
文件,其中包含有关如何构建、部署和测试示例项目的说明。下表简要介绍了可用的 User Storage SPI 快速入门
名称 | 描述 |
---|---|
演示使用 JPA 实现用户存储提供程序。 |
|
演示使用包含用户名/密码键值对的简单属性文件实现用户存储提供程序。 |
提供程序接口
构建 User Storage 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:[email protected]">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()
方法返回 User Storage 提供程序的名称。当您要为特定领域启用提供程序时,此 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
。
简单的只读查找示例
为了说明实现 User Storage SPI 的基础知识,让我们逐步介绍一个简单的示例。在本章中,您将看到一个简单的UserStorageProvider
的实现,该实现会在一个简单的属性文件中查找用户。属性文件包含用户名和密码定义,并且硬编码到类路径上的特定位置。该提供程序将能够按 ID 和用户名查找用户,并且还能够验证密码。来自此提供程序的用户将是只读的。
Provider 类
首先,我们将介绍 UserStorageProvider
类。
public class PropertyFileUserStorageProvider implements
UserStorageProvider,
UserLookupProvider,
CredentialInputValidator,
CredentialInputUpdater
{
...
}
我们的 provider 类,PropertyFileUserStorageProvider
,实现了多个接口。它实现了 UserStorageProvider
,因为这是 SPI 的基本要求。它实现了 UserLookupProvider
接口,因为我们希望能够使用此 provider 存储的用户登录。它实现了 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;
}
此 provider 类的构造函数将存储对 KeycloakSession
、ComponentModel
和属性文件的引用。我们将在后面使用所有这些内容。另外请注意,有一个已加载用户的映射。每当我们找到一个用户时,我们都会将其存储在此映射中,以便我们避免在同一个事务中再次重新创建它。这是一个值得遵循的良好做法,因为许多 provider 将需要这样做(也就是说,任何与 JPA 集成的 provider)。还要记住,provider 类实例是在每个事务中创建一次,并在事务完成后关闭。
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;
}
当用户登录时,getUserByUsername()
方法由 Keycloak 登录页面调用。在我们的实现中,我们首先检查 loadedUsers
映射以查看用户是否已在此事务中加载。如果它尚未加载,我们会在属性文件中查找用户名。如果它存在,我们会创建一个 UserModel
的实现,将其存储在 loadedUsers
中以供将来参考,并返回此实例。
createAdapter()
方法使用辅助类 org.keycloak.storage.adapter.AbstractUserAdapter
。这为 UserModel
提供了一个基本实现。它会自动根据所需的存储 ID 格式生成用户 ID,使用用户的用户名作为外部 ID。
"f:" + component id + ":" + username
AbstractUserAdapter
的每个 get 方法都返回 null 或空集合。但是,返回角色和组映射的方法将为每个用户返回为 realm 配置的默认角色和组。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
。
Provider 工厂实现
现在 provider 类已完成,我们现在将注意力转向 provider 工厂类。
public class PropertyFileUserStorageProviderFactory
implements UserStorageProviderFactory<PropertyFileUserStorageProvider> {
public static final String PROVIDER_NAME = "readonly-property-file";
@Override
public String getId() {
return PROVIDER_NAME;
}
首先要注意的是,在实现 UserStorageProviderFactory
类时,必须将具体的 provider 类实现作为模板参数传入。在这里,我们指定了之前定义的 provider 类:PropertyFileUserStorageProvider
。
如果您没有指定模板参数,您的 provider 将无法正常工作。运行时会进行类内省以确定 provider 实现的功能接口。 |
getId()
方法在运行时标识工厂,也是您要在管理控制台中启用 realm 的用户存储 provider 时显示的字符串。
初始化
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 启动时,只创建每个 provider 工厂的一个实例。另外,在启动时,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
示例有点牵强。它被硬编码到嵌入在 provider jar 中的属性文件中,这不是特别有用。我们可能希望使此文件的 location 对于 provider 的每个实例都是可配置的。换句话说,我们可能希望在多个不同的 realm 中多次重用此 provider,并指向完全不同的用户属性文件。我们还希望在管理控制台 UI 中执行此配置。
UserStorageProviderFactory
具有您可以实现的其他方法,这些方法处理 provider 配置。您可以描述要为每个 provider 配置的变量,管理控制台会自动呈现一个通用输入页面以收集此配置。在实现后,回调方法还会在保存配置、首次创建 provider 以及更新 provider 时验证配置。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
实例列表。这些实例声明渲染和存储 provider 的每个配置变量所需的信息。
配置示例
让我们扩展我们的 PropertyFileUserStorageProviderFactory
示例,以便您可以将 provider 实例指向磁盘上的特定文件。
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 类型。在管理控制台的此 provider 配置页面上,此配置变量被标记为 Path
,默认值为 ${jboss.server.config.dir}/example-users.properties
。当您将鼠标悬停在此配置选项的工具提示上时,它会显示帮助文本 File path to properties file
。
接下来我们要做的是验证此文件是否存在于磁盘上。我们不想在 realm 中启用此 provider 的实例,除非它指向有效的用户属性文件。为此,我们实现了 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()
方法。我们这样做是因为用户属性文件将针对每个 provider 实例是唯一的。我们将此逻辑移至 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);
}
此逻辑当然效率低下,因为每个事务都会从磁盘读取整个用户属性文件,但这希望以简单的方式说明如何挂钩配置变量。
添加/删除用户和查询功能接口
我们的示例中没有做的一件事是允许它添加和删除用户或更改密码。在我们的示例中定义的用户也不可查询或在管理控制台中查看。要添加这些增强功能,我们的示例 provider 必须实现 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
。我们这样做是因为属性值中不能有空值。我们还需要修改 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 {
...
}
可以在 UserStorageUtil.userFederatedStorage(KeycloakSession)
方法上获得 UserFederatedStorageProvider
实例。它具有用于存储属性、组和角色映射、不同凭据类型和所需操作的所有不同类型的方法。如果您的外部存储的数据模型不支持完整的 Keycloak 功能集,那么此服务可以弥补这些差距。
Keycloak 带有一个辅助类 org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage
,它将委托除用户名获取/设置之外的每个 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
上的其他方法以执行与外部存储的同步。例如,获取方法可以确保本地存储与外部存储保持同步。设置方法使外部存储与本地存储保持同步。需要注意的一点是,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:[email protected]">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 不是应用程序服务器。有关更多详细信息,请参阅 http://keycloak.java.net.cn/migration/migrating-to-quarkus#_quarkus_is_not_an_application_server。
因此,用户存储提供程序不能打包在任何 Jakarta EE 组件中或使其成为 EJB,正如 Keycloak 在先前版本中运行在 WildFly 上时的情况一样。
提供程序实现需要是实现适当的用户存储 SPI 接口的普通 Java 对象,如前几节所述。它们必须按照本迁移指南中所述进行打包和部署
您仍然可以实现自定义的 UserStorageProvider
类,该类能够通过 JPA 实体管理器集成外部数据库,如本示例所示
不支持 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(
"http://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()
方法。
较晚的 SPI 中不再存在 UserFederationProvider.isValid()
方法。
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 实现的一个示例。一般来说,以下规则适用
-
为了防止机密泄露到不同领域,您可能希望隔离或限制领域可以检索的机密。在这种情况下,您的提供程序在查找机密时应考虑领域名称,例如,在条目前面加上领域名称。例如,表达式
${vault.key}
将根据它是在领域 A 还是领域 B 中使用,通常评估为不同的条目名称。为了区分不同的领域,需要将领域传递给从VaultProviderFactory.create()
方法创建的VaultProvider
实例,其中该领域可从KeycloakSession
参数获得。 -
Vault 提供程序需要实现一个
obtainSecret
方法,该方法为给定的机密名称返回一个VaultRawSecret
。该类以byte[]
或ByteBuffer
的形式保存机密的表示,并期望根据需要在两者之间进行转换。请注意,此缓冲区将在使用后被丢弃,如下所述。
关于领域分离,所有内置的 Vault 提供程序工厂都允许配置一个或多个密钥解析器。VaultKeyResolver
接口代表密钥解析器,它本质上实现了将领域名称与密钥(从 ${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().getRawSecret()
方法)之外,还可以将原始机密(通常是字节数组)解释为字符数组(通过 vault().getCharSecret()
)或 String
(通过 vault().getStringSecret()
)。
请注意,由于 String
对象是不可变的,因此它们的内容无法通过用随机垃圾覆盖来丢弃。即使在默认的 VaultStringSecret
实现中采取了措施来防止内部化 String
,存储在 String
对象中的机密至少也要到下一轮 GC 才会消失。因此,最好使用纯字节和字符数组以及缓冲区。