前言
在一些示例列表中,原本应该显示在一行的内容,由于页面宽度限制而无法完全显示。这些行已被断开。行尾的 '\' 表示为了适应页面而引入了换行,后续行会缩进。所以
Let's pretend to have an extremely \
long line that \
does not fit
This one is short
实际上是
Let's pretend to have an extremely long line that does not fit
This one is short
管理 REST API
Keycloak 配备了功能齐全的管理 REST API,它提供了管理控制台的所有功能。
要调用 API,您需要获得具有适当权限的访问令牌。所需的权限在服务器管理指南中进行了描述。
您可以通过使用 Keycloak 为您的应用程序启用身份验证来获取令牌;请参阅《保护应用程序和服务指南》。您也可以使用直接访问授权来获取访问令牌。
使用 CURL 的示例
使用用户名和密码进行身份验证
以下示例假设您在 master realm 中创建了用户 admin ,密码为 password ,如入门指南教程所示。 |
-
在 realm
master
中为用户获取访问令牌,用户名为admin
,密码为password
curl \ -d "client_id=admin-cli" \ -d "username=admin" \ -d "password=password" \ -d "grant_type=password" \ "https://127.0.0.1:8080/realms/master/protocol/openid-connect/token"
默认情况下,此令牌在 1 分钟后过期 结果将是一个 JSON 文档。
-
要调用 API,您需要提取
access_token
属性的值。 -
通过在 API 请求的
Authorization
标头中包含该值来调用 API。以下示例演示如何获取 master realm 的详细信息
curl \ -H "Authorization: bearer eyJhbGciOiJSUz..." \ "https://127.0.0.1:8080/admin/realms/master"
使用服务帐户进行身份验证
要使用 client_id
和 client_secret
对管理 REST API 进行身份验证,请执行以下步骤。
-
确保客户端配置如下
-
client_id
是属于 realm master 的 保密 客户端 -
client_id
启用了Service Accounts Enabled
选项 -
client_id
具有自定义“受众”映射器-
包含的客户端受众:
security-admin-console
-
-
-
检查
client_id
是否在“服务帐户角色”选项卡中分配了角色“admin”。
curl \
-d "client_id=<YOUR_CLIENT_ID>" \
-d "client_secret=<YOUR_CLIENT_SECRET>" \
-d "grant_type=client_credentials" \
"https://127.0.0.1:8080/realms/master/protocol/openid-connect/token"
主题
Keycloak 为网页和电子邮件提供主题支持。这允许自定义面向最终用户的页面的外观和风格,以便它们可以与您的应用程序集成。

主题类型
一个主题可以提供一种或多种类型来自定义 Keycloak 的不同方面。可用的类型有
-
Account - 帐户控制台
-
Admin - 管理控制台
-
Email - 电子邮件
-
Login - 登录表单
-
Welcome - 欢迎页面
配置主题
除欢迎页面之外,所有主题类型都通过管理控制台进行配置。
-
登录到管理控制台。
-
从左上角的下拉框中选择您的 realm。
-
从菜单中单击 Realm 设置。
-
单击 主题 选项卡。
要设置 master
管理控制台的主题,您需要为master
realm 设置管理控制台主题。 -
要查看对管理控制台的更改,请刷新页面。
-
使用
spi-theme-welcome-theme
选项更改欢迎主题。 -
例如
bin/kc.[sh|bat] start --spi-theme-welcome-theme=custom-theme
默认主题
Keycloak 捆绑了默认主题,这些主题位于服务器发行版内的 JAR 文件 keycloak-themes-26.2.0.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
-
从菜单中单击 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
来覆盖单个消息。
例如,要将登录表单上的 Username
替换为 Your Username
,对于 mytheme
,请创建文件 themes/mytheme/login/messages/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/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>你好世界!</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 Quickstarts 仓库 - quickstarts 仓库的
extension
目录包含一些主题示例,也可以用作灵感。
基于 React 的主题
管理控制台和帐户控制台都基于 React。要完全自定义这些控制台,您可以使用基于 React 的 npm 软件包。有两个软件包
-
@keycloak/keycloak-admin-ui
:这是管理控制台的基本主题。 -
@keycloak/keycloak-account-ui
:这是帐户控制台的基本主题。
这两个软件包都在 npm 上可用。
使用软件包
要使用这些页面,您需要在组件层次结构中添加 KeycloakProvider,以设置要使用的客户端、realm 和 url。
import { KeycloakProvider } from "@keycloak/keycloak-ui-shared";
//...
<KeycloakProvider environment={{
serverBaseUrl: "http://localhost:8080",
realm: "master",
clientId: "security-admin-console"
}}>
{/* rest of your application */}
</KeycloakProvider>
翻译页面
页面使用 i18next
库进行翻译。您可以按照他们的[网站](https://react.i18next.com/)上描述的方式进行设置。如果您想使用提供的翻译,则需要将 i18next-fetch-backend
添加到您的项目并添加
backend: {
loadPath: `http://127.0.0.1:8080/resources/master/account/{lng}}`,
parse: (data: string) => {
const messages = JSON.parse(data);
return Object.fromEntries(
messages.map(({ key, value }) => [key, value])
);
},
},
使用页面
所有“页面”都是 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-account-console-node)以了解如何使用它们。
主题选择器
默认情况下,使用为 realm 配置的主题,但客户端可以覆盖登录主题。可以通过主题选择器 SPI 更改此行为。
例如,可以通过查看用户代理标头,使用它为桌面和移动设备选择不同的主题。
要创建自定义主题选择器,您需要实现 ThemeSelectorProviderFactory
和 ThemeSelectorProvider
。
主题资源
在 Keycloak 中实现自定义提供商时,通常可能需要添加其他模板、资源和消息包。
一个用例示例是自定义身份验证器,它需要额外的模板和资源。
加载其他主题资源的最简单方法是创建一个 JAR,其中模板位于 theme-resources/templates
中,资源位于 theme-resources/resources
中,消息包位于 theme-resources/messages
中。
如果您想要更灵活的方式来加载模板和资源,可以通过 ThemeResourceSPI 实现。通过实现 ThemeResourceProviderFactory
和 ThemeResourceProvider
,您可以精确地决定如何加载模板和资源。
语言环境选择器
默认情况下,区域设置是使用实现了 LocaleSelectorProvider
接口的 DefaultLocaleSelectorProvider
来选择的。当国际化被禁用时,英语是默认语言。
启用国际化后,区域设置将根据服务器管理指南中描述的逻辑进行解析。
可以通过 LocaleSelectorSPI
,实现 LocaleSelectorProvider
和 LocaleSelectorProviderFactory
来更改此行为。
LocaleSelectorProvider
接口有一个单一的方法,resolveLocale
,它必须返回一个区域设置,给定一个 RealmModel
和一个可为空的 UserModel
。实际的请求可以从 KeycloakSession#getContext
方法中获取。
自定义实现可以扩展 DefaultLocaleSelectorProvider
以重用默认行为的部分内容。例如,要忽略 Accept-Language
请求头,自定义实现可以扩展默认提供程序,覆盖其 getAcceptLanguageHeaderLocale
方法,并返回一个 null 值。这样一来,区域设置选择将回退到 realm 的默认语言。
区域设置选择器的其他资源
-
有关创建和部署自定义提供程序的更多详细信息,请参阅服务提供程序接口。
身份代理 API
Keycloak 可以将身份验证委托给父 IDP 以进行登录。一个典型的例子是您希望用户能够通过社交提供商(如 Facebook 或 Google)登录的情况。您还可以将现有帐户链接到代理的 IDP。本节介绍了一些您的应用程序可以使用的与身份代理相关的 API。
检索外部 IDP 令牌
Keycloak 允许您存储来自与外部 IDP 的身份验证过程的令牌和响应。为此,您可以使用 IDP 设置页面上的 “存储令牌” 配置选项。
应用程序代码可以检索这些令牌和响应,以获取额外的用户信息,或安全地调用外部 IDP 上的请求。例如,应用程序可能希望使用 Google 令牌来调用其他 Google 服务和 REST API。要检索特定身份提供程序的令牌,您需要按如下方式发送请求
GET /realms/{realm-name}/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,应用程序可以使用它将现有用户帐户链接到特定的外部 IDP。这称为客户端发起的帐户链接。帐户链接只能由 OIDC 应用程序发起。
其工作方式是,应用程序将用户的浏览器转发到 Keycloak 服务器上的 URL,请求将其用户帐户链接到特定的外部提供商(即 Facebook)。服务器启动与外部提供商的登录。浏览器在外部提供商处登录,并重定向回服务器。服务器建立链接,并将确认信息重定向回应用程序。
客户端应用程序在启动此协议之前,必须满足一些先决条件
-
必须在管理控制台中为用户的 realm 配置并启用所需的身份提供程序。
-
用户帐户必须已通过 OIDC 协议作为现有用户登录
-
用户必须具有
account.manage-account
或account.manage-account-links
角色映射。 -
应用程序必须在其访问令牌中被授予这些角色的作用域
-
应用程序必须有权访问其访问令牌,因为它需要其中的信息来生成重定向 URL。
要启动登录,应用程序必须构造一个 URL 并将用户的浏览器重定向到此 URL。URL 看起来像这样
/{auth-server-root}/realms/{realm-name}/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 编码的哈希值。此哈希值是通过对
nonce
+token.getSessionState()
+token.getIssuedFor()
+provider
的 SHA_256 哈希进行 Base64 URL 编码生成的。token 变量从 OIDC 访问令牌中获得。基本上,您正在哈希随机数、用户会话 ID、客户端 ID 和您想要访问的身份提供程序别名。
这是一个 Java Servlet 代码示例,用于生成建立帐户链接的 URL。
KeycloakSecurityContext session = (KeycloakSecurityContext) httpServletRequest.getAttribute(KeycloakSecurityContext.class.getName());
AccessToken token = session.getToken();
String clientId = token.getIssuedFor();
String nonce = UUID.randomUUID().toString();
MessageDigest md = null;
try {
md = MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
String input = nonce + token.getSessionState() + clientId + provider;
byte[] check = md.digest(input.getBytes(StandardCharsets.UTF_8));
String hash = Base64Url.encode(check);
request.getSession().setAttribute("hash", hash);
String redirectUri = ...;
String accountLinkUrl = KeycloakUriBuilder.fromUri(authServerRootUrl)
.path("/realms/{realm-name}/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
。浏览器可能最终会停留在错误页面,而不是被重定向回应用程序。如果存在错误情况,并且授权服务器认为重定向回客户端应用程序足够安全,则会在 redirect_uri
中附加一个额外的 error
查询参数。
虽然此 API 保证了应用程序发起了请求,但它并不能完全防止此操作的 CSRF 攻击。应用程序仍然负责防范针对自身的 CSRF 攻击。 |
服务提供程序接口 (SPI)
Keycloak 的设计旨在涵盖大多数用例,而无需自定义代码,但我们也希望它是可定制的。为了实现这一点,Keycloak 具有许多服务提供程序接口 (SPI),您可以使用它们来实现自己的提供程序。
实现 SPI
要实现 SPI,您需要实现其 ProviderFactory 和 Provider 接口。您还需要创建一个服务配置文件。
例如,要实现 Theme Selector 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();
}
}
您的 SPI 的 pom.xml 文件需要一个 dependencyManagement
部分,其中包含对 SPI 预期的 Keycloak 版本的导入引用。在本示例中,将 VERSION
替换为 26.2.0,这是 Keycloak 的当前版本。
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>test-lib</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-parent</artifactId>
<version>VERSION</version> (1)
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-jpa</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
</project>
1 | 将 VERSION 替换为 Keycloak 的当前版本 |
覆盖内置提供程序
如上所述,建议您的 ProviderFactory
实现使用唯一 ID。但与此同时,覆盖 Keycloak 的内置提供程序之一可能很有用。为此推荐的方法仍然是使用唯一 ID 的 ProviderFactory 实现,然后例如按照配置提供程序指南中指定的方式设置默认提供程序。另一方面,这可能并不总是可行的。
例如,当您需要对默认 OpenID Connect 协议行为进行一些自定义,并且想要覆盖 OIDCLoginProtocolFactory
的默认 Keycloak 实现时,您需要保留相同的 providerId。例如,管理控制台、OIDC 协议的 well-known endpoint 和各种其他内容都依赖于协议工厂的 ID 为 openid-connect
。
对于这种情况,强烈建议您实现自定义实现的 order()
方法,并确保它比内置实现具有更高的顺序。
public class CustomOIDCLoginProtocolFactory extends OIDCLoginProtocolFactory {
// Some customizations here
@Override
public int order() {
return 1;
}
}
如果有多个具有相同提供程序 ID 的实现,则 Keycloak 运行时将仅使用顺序最高的实现。
在管理控制台中显示来自您的 SPI 实现的信息
有时,向 Keycloak 管理员显示有关您的提供程序的其他信息很有用。您可以显示提供程序构建时间信息(例如,当前安装的自定义提供程序的版本)、提供程序的当前配置(例如,您的提供程序与之通信的远程系统的 URL)或一些操作信息(您的提供程序与之通信的远程系统的平均响应时间)。Keycloak 管理控制台提供 “服务器信息” 页面来显示此类信息。
要显示来自您的提供程序的信息,只需在您的 ProviderFactory
中实现 org.keycloak.provider.ServerInfoAwareProviderFactory
接口即可。
前一个示例中 MyThemeSelectorProviderFactory
的示例实现
package org.acme.provider;
import ...
public class MyThemeSelectorProviderFactory implements ThemeSelectorProviderFactory, ServerInfoAwareProviderFactory {
...
@Override
public Map<String, String> getOperationalInfo() {
Map<String, String> ret = new LinkedHashMap<>();
ret.put("theme-name", "my-theme");
return ret;
}
}
使用可用的提供程序
在您的提供程序实现中,您可以使用 Keycloak 中可用的其他提供程序。现有提供程序通常可以使用 KeycloakSession
来检索,如实现 SPI 部分所述,您的提供程序可以使用 KeycloakSession
。
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
目录。
注册新的提供程序或依赖项后,需要使用非优化启动或 kc.[sh|bat] build
命令重新构建 Keycloak。
提供程序 JAR 不会在隔离的类加载器中加载,因此请勿在提供程序 JAR 中包含与内置资源或类冲突的资源或类。特别是,如果删除提供程序 JAR,则包含 application.properties 文件或覆盖 commons-lang3 依赖项将导致自动构建失败。如果您包含了冲突的类,您可能会在服务器的启动日志中看到拆分包警告。遗憾的是,并非所有内置 lib jar 都通过拆分包警告逻辑进行检查,因此您需要在捆绑或包含传递依赖项之前检查 lib 目录 JAR。如果存在冲突,可以通过删除或重新打包冲突的类来解决。 如果您有冲突的资源文件,则不会发出警告。您应该确保您的 JAR 的资源文件的路径名包含对该提供程序唯一的内容,或者您可以检查
如果您发现由于与已删除的提供程序 JAR 相关的
这将强制 Quarkus 重建与类加载相关的索引文件。从那里,您应该能够执行非优化启动或构建,而不会出现异常。 |
JavaScript 提供程序
脚本是 预览版,尚未完全支持。此功能默认情况下处于禁用状态。 要启用,请使用 |
Keycloak 能够在运行时执行脚本,以便管理员可以自定义特定功能
-
身份验证器
-
JavaScript 策略
-
OpenID Connect 协议映射器
-
SAML 协议映射器
身份验证器
身份验证脚本必须至少提供以下函数之一:authenticate(..)
,它从 Authenticator#authenticate(AuthenticationFlowContext)
调用;action(..)
,它从 Authenticator#action(AuthenticationFlowContext)
调用
自定义 Authenticator
至少应提供 authenticate(..)
函数。您可以在代码中使用 javax.script.Bindings
脚本。
script
-
ScriptModel
用于访问脚本元数据 realm
-
RealmModel
user
-
当前的
UserModel
。请注意,当您的脚本身份验证器在身份验证流程中配置为在另一个身份验证器成功建立用户身份并将用户设置到身份验证会话后触发时,user
可用。 session
-
活动的
KeycloakSession
authenticationSession
-
当前的
AuthenticationSessionModel
httpRequest
-
当前的
org.jboss.resteasy.spi.HttpRequest
LOG
-
范围限定为
ScriptBasedAuthenticator
的org.jboss.logging.Logger
您可以从传递给 authenticate(context) action(context) 函数的 context 参数中提取额外的上下文信息。 |
AuthenticationFlowError = Java.type("org.keycloak.authentication.AuthenticationFlowError");
function authenticate(context) {
LOG.info(script.name + " --> trace auth for: " + user.username);
if ( user.username === "tester"
&& user.getAttribute("someAttribute")
&& user.getAttribute("someAttribute").contains("someValue")) {
context.failure(AuthenticationFlowError.INVALID_USER);
return;
}
context.success();
}
在哪里添加脚本身份验证器
脚本身份验证器的一个可能用途是在身份验证结束时进行一些检查。请注意,如果您希望您的脚本身份验证器始终被触发(例如,即使在使用身份 cookie 进行 SSO 重新身份验证期间),您可能需要在身份验证流程的末尾将其添加为 REQUIRED,并将现有的身份验证器封装到单独的 REQUIRED 身份验证子流程中。这种需要是因为 REQUIRED 和 ALTERNATIVE 执行不应处于同一级别。例如,身份验证流程配置应如下所示
- User-authentication-subflow REQUIRED
-- Cookie ALTERNATIVE
-- Identity-provider-redirect ALTERNATIVE
...
- Your-Script-Authenticator REQUIRED
OpenID Connect 协议映射器
OpenID Connect 协议映射器脚本是 javascript 脚本,允许您更改 ID 令牌和/或访问令牌的内容。
您可以在代码中使用 javax.script.Bindings
脚本。
user
-
当前的
UserModel
realm
-
RealmModel
token
-
当前的
IDToken
。仅当映射器配置为 ID 令牌时才可用。 tokenResponse
-
当前的
AccessTokenResponse
。仅当映射器配置为访问令牌时才可用。 userSession
-
活动的
UserSessionModel
keycloakSession
-
活动的
KeycloakSession
脚本的导出将用作令牌声明的值。
// prints can be used to log information for debug purpose.
print("STARTING CUSTOM MAPPER");
var inputRequest = keycloakSession.getContext().getHttpRequest();
var params = inputRequest.getDecodedFormParameters();
var output = params.getFirst("user_input");
exports = output;
上面的脚本允许从授权请求中检索 user_input
。这将在映射器中配置的 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"
}
]
}
此文件应引用您要部署的不同类型的脚本提供程序
-
authenticators
用于 OpenID Connect 脚本身份验证器。您可以在同一个 JAR 文件中拥有一个或多个身份验证器
-
policies
用于在使用 Keycloak 授权服务时的 JavaScript 策略。您可以在同一个 JAR 文件中拥有一个或多个策略
-
mappers
用于 OpenID Connect 脚本协议映射器。您可以在同一个 JAR 文件中拥有一个或多个映射器
-
saml-mappers
用于 SAML 脚本协议映射器。您可以在同一个 JAR 文件中拥有一个或多个映射器
对于您的 JAR
文件中的每个脚本文件,您需要在 META-INF/keycloak-scripts.json
中创建一个对应的条目,该条目将您的脚本文件映射到特定的提供程序类型。为此,您应该为每个条目提供以下属性
-
name
一个友好的名称,将用于通过 Keycloak 管理控制台显示脚本。如果未提供,则将使用脚本文件的名称代替
-
description
一段可选文本,更好地描述脚本文件的意图
-
fileName
脚本文件的名称。此属性是强制性的,应映射到 JAR 中的一个文件。
可用的 SPI
如果您想查看运行时所有可用 SPI 的列表,您可以查看管理控制台中的 “提供程序信息” 页面,如管理控制台部分所述。
扩展服务器
Keycloak SPI 框架提供了实现或覆盖特定内置提供程序的可能性。但是,Keycloak 还提供了扩展其核心功能和域的功能。这包括以下可能性
-
向 Keycloak 服务器添加自定义 REST 端点
-
添加您自己的自定义 SPI
-
将自定义 JPA 实体添加到 Keycloak 数据模型
添加自定义 REST 端点
这是一个非常强大的扩展,它允许您将自己的 REST 端点部署到 Keycloak 服务器。它支持各种扩展,例如,可以在 Keycloak 服务器上触发默认的内置 Keycloak REST 端点集中不可用的功能。
要添加自定义 REST 端点,您需要实现 RealmResourceProviderFactory
和 RealmResourceProvider
接口。RealmResourceProvider
有一个重要的方法
Object getResource();
使用此方法返回一个对象,该对象充当 JAX-RS 资源。只有当您的 JAX-RS 资源包含以下配置时,服务器才会识别并将其注册为有效端点:- 在 META-INF
下添加一个名为 beans.xml
的空文件 - 使用注解 jakarta.ws.rs.ext.Provider
注释 JAX-RS 类。
有关如何打包和部署自定义提供程序的详细信息,请参阅服务提供程序接口章节。
虽然可以通过提供程序扩展机制安装其他 JAX-RS 组件,例如过滤器和拦截器,但这些组件未获得官方支持。 |
添加您自己的自定义 SPI
自定义 SPI 在自定义 REST 端点中特别有用。使用以下步骤添加您自己的 SPI
-
实现接口
org.keycloak.provider.Spi
并定义您的 SPI 的 ID 以及ProviderFactory
和Provider
类。看起来像这样public class ExampleSpi implements Spi { @Override public boolean isInternal() { return false; } @Override public String getName() { return "example"; } @Override public Class<? extends Provider> getProviderClass() { return ExampleService.class; } @Override @SuppressWarnings("rawtypes") public Class<? extends ProviderFactory> getProviderFactoryClass() { return ExampleServiceProviderFactory.class; } }
-
创建文件
META-INF/services/org.keycloak.provider.Spi
并将您的 SPI 的类添加到其中。例如ExampleSpi
-
创建接口
ExampleServiceProviderFactory
(它从ProviderFactory
扩展)和ExampleService
(它从Provider
扩展)。ExampleService
通常将包含您的用例所需的业务方法。请注意,ExampleServiceProviderFactory
实例始终按应用程序限定作用域,而ExampleService
按请求限定作用域(或更准确地说,按KeycloakSession
生命周期限定作用域)。 -
最后,您需要以与服务提供程序接口章节中描述的相同方式实现您的提供程序。
向 Keycloak 数据模型添加自定义 JPA 实体
如果 Keycloak 数据模型与您期望的解决方案不完全匹配,或者您想向 Keycloak 添加一些核心功能,或者当您有自己的 REST 端点时,您可能需要扩展 Keycloak 数据模型。我们允许您将自己的 JPA 实体添加到 Keycloak JPA EntityManager
。
要添加您自己的 JPA 实体,您需要实现 JpaEntityProviderFactory
和 JpaEntityProvider
。JpaEntityProvider
允许您返回自定义 JPA 实体列表,并提供 Liquibase changelog 的位置和 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 架构。您可能也需要使用它并为您的实体创建 changelog。请注意,您自己的 Liquibase changelog 的版本控制独立于 Keycloak 版本。换句话说,当您更新到新的 Keycloak 版本时,您不必同时更新您的架构。反之亦然,即使不更新 Keycloak 版本,您也可以更新您的架构。Liquibase 更新始终在服务器启动时完成,因此要触发架构的 DB 更新,您只需将新的 changeset 添加到您的 Liquibase changelog 文件(在上面的示例中,它是文件 META-INF/example-changelog.xml
,它必须与 JPA 实体和 ExampleJpaEntityProvider
打包在同一个 JAR 中),然后重新启动服务器。DB 架构将在启动时自动更新。
在 Liquibase changelog 中进行任何更改并触发 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。如果设置了一个,则对其进行验证,并将 UserSessionModel 验证并与 AuthenticationSessionModel 关联。如果 SSO cookie 存在且已验证,则 Cookie 提供程序返回 success() 状态。由于 cookie 提供程序返回成功,并且此流程级别的每个执行都是 ALTERNATIVE,因此不执行其他执行,这导致登录成功。如果没有 SSO cookie,cookie 提供程序将返回 attempted() 状态。这意味着没有错误情况,也没有成功。提供程序尝试了,但请求只是没有设置为处理此身份验证器。
-
接下来,流程查看 Kerberos 执行。这也是替代的。kerberos 提供程序也不要求用户已经设置并与 AuthenticationSessionModel 关联,因此执行此提供程序。Kerberos 使用 SPNEGO 浏览器协议。这需要在服务器和客户端之间进行一系列挑战/响应,交换协商标头。kerberos 提供程序没有看到任何协商标头,因此它假定这是服务器和客户端之间的第一次交互。因此,它创建对客户端的 HTTP 挑战响应并设置 forceChallenge() 状态。forceChallenge() 意味着此 HTTP 响应不能被流程忽略,并且必须返回给客户端。如果提供程序返回 challenge() 状态,则流程将保持挑战响应,直到尝试所有其他替代方案。因此,在此初始阶段,流程将停止,并且挑战响应将发送回浏览器。如果浏览器然后使用成功的协商标头进行响应,则提供程序将用户与 AuthenticationSession 关联,并且流程结束,因为此流程级别上的其余执行都是替代的。否则,kerberos 提供程序再次设置 attempted() 状态,流程继续。
-
下一个执行是名为 Forms 的子流程。加载此子流程的执行,并发生相同的处理逻辑。
-
Forms 子流程中的第一个执行是 UsernamePassword 提供程序。此提供程序也不要求用户已经与流程关联。此提供程序创建挑战 HTTP 响应,并将其状态设置为 challenge()。此执行是必需的,因此流程接受此挑战并将 HTTP 响应发送回浏览器。此响应是用户名/密码 HTML 页面的呈现。用户输入用户名和密码并单击提交。此 HTTP 请求定向到 UsernamePassword 提供程序。如果用户输入了无效的用户名或密码,则会创建一个新的挑战响应,并为此执行设置 failureChallenge() 状态。failureChallenge() 意味着存在挑战,但流程应将其记录在错误日志中作为错误。此错误日志可用于锁定帐户或 IP 地址,这些帐户或 IP 地址的登录失败次数过多。如果用户名和密码有效,则提供程序将 UserModel 与 AuthenticationSessionModel 关联,并返回 success() 状态。
-
下一个执行是名为 Conditional OTP 的子流程。加载此子流程的执行,并发生相同的处理逻辑。其 Requirement 是 Conditional。这意味着流程将首先评估它包含的所有条件执行器。条件执行器是实现
ConditionalAuthenticator
的身份验证器,并且必须实现方法boolean matchCondition(AuthenticationFlowContext context)
。条件子流程将调用它包含的所有条件执行的matchCondition
方法,如果所有这些方法都评估为 true,它将表现得好像它是必需的子流程。否则,它将表现得好像它是禁用的子流程。条件身份验证器仅用于此目的,而不用于作为身份验证器。这意味着即使条件身份验证器评估为“true”,这也不会将流程或子流程标记为成功。例如,仅包含带有仅条件身份验证器的 Conditional 子流程的流程永远不允许用户登录。 -
Conditional OTP 子流程的第一个执行是 Condition - User Configured。此提供程序要求用户已与流程关联。此要求已满足,因为 UsernamePassword 提供程序已将用户与流程关联。此提供程序的
matchCondition
方法将评估其当前子流程中所有其他身份验证器的configuredFor
方法。如果子流程包含 Requirement 设置为 required 的执行器,则只有当所有必需身份验证器的configuredFor
方法都评估为 true 时,matchCondition
方法才会评估为 true。否则,如果任何替代身份验证器评估为 true,则matchCondition
方法将评估为 true。 -
下一个执行是 OTP Form。此提供程序也要求用户已与流程关联。此要求已满足,因为 UsernamePassword 提供程序已将用户与流程关联。由于此提供程序需要用户,因此还会询问是否配置了用户以使用此提供程序。如果未配置用户,则流程将设置用户在身份验证完成后必须执行的必需操作。对于 OTP,这意味着 OTP 设置页面。如果已配置用户,则将要求他输入其 otp 代码。在我们的场景中,由于条件子流程,除非 Conditional OTP 子流程设置为 Required,否则用户永远不会看到 OTP 登录页面。
-
流程完成后,身份验证处理器将创建一个 UserSessionModel 并将其与 AuthenticationSessionModel 关联。然后,它检查用户是否需要在登录前完成任何必需的操作。
-
首先,调用每个必需操作的 evaluateTriggers() 方法。这允许必需操作提供程序确定是否存在可能触发操作的状态。例如,如果您的域具有密码过期策略,则可能会由此方法触发。
-
调用与用户关联的每个必需操作的 requiredActionChallenge() 方法。在此,提供程序设置一个 HTTP 响应,该响应呈现必需操作的页面。这是通过设置挑战状态来完成的。
-
如果必需操作最终成功,则将从用户的必需操作列表中删除该必需操作。
-
在解决所有必需操作后,用户最终登录。
身份验证器 SPI 演练
在本节中,我们将了解 Authenticator 接口。为此,我们将实现一个身份验证器,该身份验证器要求用户输入一个秘密问题的答案,例如“您母亲的娘家姓是什么?”。此示例已完全实现,并包含在 Keycloak Quickstarts Repository 存储库中的 extension/authenticator
下。
要创建身份验证器,您至少必须实现 org.keycloak.authentication.AuthenticatorFactory 和 Authenticator 接口。Authenticator 接口定义逻辑。AuthenticatorFactory 负责创建 Authenticator 的实例。它们都扩展了更通用的 Provider 和 ProviderFactory 接口集,Keycloak 的其他组件(如用户联合)也这样做。
某些身份验证器(如 CookieAuthenticator)不依赖于用户拥有或知道的 Credential 来验证用户身份。但是,某些身份验证器(例如 PasswordForm 身份验证器或 OTPFormAuthenticator)依赖于用户输入某些信息,并将该信息与数据库中的某些信息进行验证。例如,对于 PasswordForm,身份验证器将验证密码哈希与数据库中存储的哈希,而 OTPFormAuthenticator 将验证接收到的 OTP 与从数据库中存储的共享密钥生成的 OTP。
这些类型的身份验证器称为 CredentialValidators,并且需要您实现更多类
-
一个扩展 org.keycloak.credential.CredentialModel 的类,它可以生成数据库中凭据的正确格式
-
一个实现 org.keycloak.credential.CredentialProvider 和接口的类,以及一个实现其 CredentialProviderFactory 工厂接口的类。
我们将在本演练中看到的 SecretQuestionAuthenticator 是一个 CredentialValidator,因此我们将看到如何实现所有这些类。
打包类和部署
您将在单个 jar 中打包您的类。此 jar 必须包含一个名为 org.keycloak.authentication.AuthenticatorFactory
的文件,并且必须包含在 jar 的 META-INF/services/
目录中。此文件必须列出 jar 中每个 AuthenticatorFactory 实现的完全限定类名。例如
org.keycloak.examples.authenticator.SecretQuestionAuthenticatorFactory
org.keycloak.examples.authenticator.AnotherProviderFactory
Keycloak 使用此 services/ 文件扫描它必须加载到系统中的提供程序。
要部署此 jar,只需将其复制到 providers 目录即可。
扩展 CredentialModel 类
在 Keycloak 中,凭据存储在数据库的 Credentials 表中。它具有以下结构
----------------------------- | ID | ----------------------------- | user_ID | ----------------------------- | credential_type | ----------------------------- | created_date | ----------------------------- | user_label | ----------------------------- | secret_data | ----------------------------- | credential_data | ----------------------------- | priority | -----------------------------
其中
-
ID
是凭据的主键。 -
user_ID
是将凭据链接到用户的外键。 -
credential_type
是创建期间设置的字符串,必须引用现有的凭据类型。 -
created_date
是凭据的创建时间戳(长格式)。 -
user_label
是用户可编辑的凭据名称 -
secret_data
包含一个静态 json,其中包含无法在 Keycloak 外部传输的信息 -
credential_data
包含一个 json,其中包含凭据的静态信息,可以在管理控制台或通过 REST API 共享。 -
priority
定义凭据对用户的“首选”程度,以确定当用户有多个选择时要呈现哪个凭据。
由于 secret_data 和 credential_data 字段旨在包含 json,因此由您决定如何构造、读取和写入这些字段,从而为您提供很大的灵活性。
在此示例中,我们将使用非常简单的凭据数据,仅包含向用户提出的问题
{
"question":"aQuestion"
}
以及同样简单的秘密数据,仅包含秘密答案
{
"answer":"anAnswer"
}
为了简单起见,这里的答案将以纯文本形式保存在数据库中,但是也可以使用加盐哈希作为答案,就像 Keycloak 中的密码一样。在这种情况下,秘密数据还必须包含盐字段,而凭据数据信息则包含有关算法的信息,例如所用算法的类型和使用的迭代次数。有关更多详细信息,您可以查阅 org.keycloak.models.credential.PasswordCredentialModel
类的实现。
在我们的例子中,我们创建了类 SecretQuestionCredentialModel
public class SecretQuestionCredentialModel extends CredentialModel {
public static final String TYPE = "SECRET_QUESTION";
private final SecretQuestionCredentialData credentialData;
private final SecretQuestionSecretData secretData;
其中 TYPE
是我们在数据库中写入的 credential_type。为了保持一致性,我们确保此字符串始终是在获取此凭据类型时引用的字符串。类 SecretQuestionCredentialData
和 SecretQuestionSecretData
用于编组和解组 json
public class SecretQuestionCredentialData {
private final String question;
@JsonCreator
public SecretQuestionCredentialData(@JsonProperty("question") String question) {
this.question = question;
}
public String getQuestion() {
return question;
}
}
public class SecretQuestionSecretData {
private final String answer;
@JsonCreator
public SecretQuestionSecretData(@JsonProperty("answer") String answer) {
this.answer = answer;
}
public String getAnswer() {
return answer;
}
}
为了完全可用,SecretQuestionCredentialModel
对象必须同时包含来自其父类的原始 json 数据以及其自身属性中解组的对象。这引导我们创建一个方法,该方法从简单的 CredentialModel(例如从数据库读取时创建的)读取,以创建一个 SecretQuestionCredentialModel
private SecretQuestionCredentialModel(SecretQuestionCredentialData credentialData, SecretQuestionSecretData secretData) {
this.credentialData = credentialData;
this.secretData = secretData;
}
public static SecretQuestionCredentialModel createFromCredentialModel(CredentialModel credentialModel){
try {
SecretQuestionCredentialData credentialData = JsonSerialization.readValue(credentialModel.getCredentialData(), SecretQuestionCredentialData.class);
SecretQuestionSecretData secretData = JsonSerialization.readValue(credentialModel.getSecretData(), SecretQuestionSecretData.class);
SecretQuestionCredentialModel secretQuestionCredentialModel = new SecretQuestionCredentialModel(credentialData, secretData);
secretQuestionCredentialModel.setUserLabel(credentialModel.getUserLabel());
secretQuestionCredentialModel.setCreatedDate(credentialModel.getCreatedDate());
secretQuestionCredentialModel.setType(TYPE);
secretQuestionCredentialModel.setId(credentialModel.getId());
secretQuestionCredentialModel.setSecretData(credentialModel.getSecretData());
secretQuestionCredentialModel.setCredentialData(credentialModel.getCredentialData());
return secretQuestionCredentialModel;
} catch (IOException e){
throw new RuntimeException(e);
}
}
以及一个从问题和答案创建 SecretQuestionCredentialModel
的方法
private SecretQuestionCredentialModel(String question, String answer) {
credentialData = new SecretQuestionCredentialData(question);
secretData = new SecretQuestionSecretData(answer);
}
public static SecretQuestionCredentialModel createSecretQuestion(String question, String answer) {
SecretQuestionCredentialModel credentialModel = new SecretQuestionCredentialModel(question, answer);
credentialModel.fillCredentialModelFields();
return credentialModel;
}
private void fillCredentialModelFields(){
try {
setCredentialData(JsonSerialization.writeValueAsString(credentialData));
setSecretData(JsonSerialization.writeValueAsString(secretData));
setType(TYPE);
setCreatedDate(Time.currentTimeMillis());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
实现 CredentialProvider
与所有提供程序一样,为了允许 Keycloak 生成 CredentialProvider,我们需要一个 CredentialProviderFactory。对于此要求,我们创建了 SecretQuestionCredentialProviderFactory,当请求 SecretQuestionCredentialProvider 时,将调用其 create
方法
public class SecretQuestionCredentialProviderFactory implements CredentialProviderFactory<SecretQuestionCredentialProvider> {
public static final String PROVIDER_ID = "secret-question";
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public CredentialProvider create(KeycloakSession session) {
return new SecretQuestionCredentialProvider(session);
}
}
CredentialProvider 接口接受一个泛型参数,该参数扩展了 CredentialModel。在我们的例子中,我们使用了我们创建的 SecretQuestionCredentialModel
public class SecretQuestionCredentialProvider implements CredentialProvider<SecretQuestionCredentialModel>, CredentialInputValidator {
private static final Logger logger = Logger.getLogger(SecretQuestionCredentialProvider.class);
protected KeycloakSession session;
public SecretQuestionCredentialProvider(KeycloakSession session) {
this.session = session;
}
我们还想实现 CredentialInputValidator 接口,因为这允许 Keycloak 知道此提供程序也可以用于验证 Authenticator 的凭据。对于 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
,它测试凭据对于给定域中给定用户是否有效。这是 Authenticator 在寻求验证用户输入时调用的方法。在这里,我们只需要检查输入字符串是否与 Credential 中记录的字符串相同
@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();
}
实现身份验证器
当实现使用 Credentials 来验证用户身份的身份验证器时,您应该让身份验证器实现 CredentialValidator 接口。此接口采用扩展 CredentialProvider 的类作为参数,并将允许 Keycloak 直接调用 CredentialProvider 中的方法。唯一需要实现的方法是 getCredentialProvider
方法,在我们的示例中,它允许 SecretQuestionAuthenticator 检索 SecretQuestionCredentialProvider
public SecretQuestionCredentialProvider getCredentialProvider(KeycloakSession session) {
return (SecretQuestionCredentialProvider)session.getProvider(CredentialProvider.class, SecretQuestionCredentialProviderFactory.PROVIDER_ID);
}
当实现 Authenticator 接口时,需要实现的第一个方法是 requiresUser() 方法。对于我们的示例,此方法必须返回 true,因为我们需要验证与用户关联的秘密问题。像 kerberos 这样的提供程序将从此方法返回 false,因为它可以通过协商标头解析用户。但是,此示例正在验证特定用户的特定凭据。
下一个要实现的方法是 configuredFor() 方法。此方法负责确定是否为用户配置了此特定身份验证器。在我们的例子中,我们可以只调用在 SecretQuestionCredentialProvider 中实现的方法
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return getCredentialProvider(session).isConfiguredFor(realm, user, getType(session));
}
Authenticator 上要实现的下一个方法是 setRequiredActions()。如果 configuredFor() 返回 false 并且我们的示例身份验证器在流程中是必需的,则将调用此方法,但前提是关联的 AuthenticatorFactory 的 isUserSetupAllowed
方法返回 true。setRequiredActions() 方法负责注册用户必须执行的任何必需操作。在我们的示例中,我们需要注册一个必需操作,该操作将强制用户设置秘密问题的答案。我们将在本章稍后实现此必需操作提供程序。这是 setRequiredActions() 方法的实现。
@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
user.addRequiredAction("SECRET_QUESTION_CONFIG");
}
现在我们开始深入了解 Authenticator 的实现。下一个要实现的方法是 authenticate()。这是当首次访问执行时流程调用的初始方法。我们想要的是,如果用户已经在其浏览器的计算机上回答了秘密问题,则用户不必再次回答该问题,从而使该计算机“受信任”。authenticate() 方法不负责处理秘密问题表单。其唯一目的是呈现页面或继续流程。
@Override
public void authenticate(AuthenticationFlowContext context) {
if (hasCookie(context)) {
context.success();
return;
}
Response challenge = context.form()
.createForm("secret-question.ftl");
context.challenge(challenge);
}
protected boolean hasCookie(AuthenticationFlowContext context) {
Cookie cookie = context.getHttpRequest().getHttpHeaders().getCookies().get("SECRET_QUESTION_ANSWERED");
boolean result = cookie != null;
if (result) {
System.out.println("Bypassing secret question because cookie is set");
}
return result;
}
hasCookie() 方法检查浏览器上是否已设置 cookie,该 cookie 指示秘密问题已回答。如果返回 true,我们只需使用 AuthenticationFlowContext.success() 方法将此执行的状态标记为 SUCCESS 并从 authentication() 方法返回。
如果 hasCookie() 方法返回 false,我们必须返回一个响应,该响应呈现秘密问题 HTML 表单。AuthenticationFlowContext 具有 form() 方法,该方法使用构建表单所需的适当基本信息初始化 Freemarker 页面构建器。此页面构建器称为 org.keycloak.login.LoginFormsProvider
。LoginFormsProvider.createForm() 方法从您的登录主题加载 Freemarker 模板文件。此外,如果您想将其他信息传递给 Freemarker 模板,则可以调用 LoginFormsProvider.setAttribute() 方法。我们稍后将介绍这一点。
调用 LoginFormsProvider.createForm() 返回 JAX-RS Response 对象。然后,我们调用 AuthenticationFlowContext.challenge() 并传入此响应。这会将执行的状态设置为 CHALLENGE,并且如果执行是 Required,则此 JAX-RS Response 对象将发送到浏览器。
因此,向用户显示询问秘密问题答案的 HTML 页面,用户输入答案并单击提交。HTML 表单的操作 URL 将向流程发送 HTTP 请求。流程最终将调用我们的 Authenticator 实现的 action() 方法。
@Override
public void action(AuthenticationFlowContext context) {
boolean validated = validateAnswer(context);
if (!validated) {
Response challenge = context.form()
.setError("badSecret")
.createForm("secret-question.ftl");
context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challenge);
return;
}
setCookie(context);
context.success();
}
如果答案无效,我们将使用附加的错误消息重建 HTML 表单。然后,我们调用 AuthenticationFlowContext.failureChallenge() 并传入原因值和 JAX-RS 响应。failureChallenge() 的工作方式与 challenge() 相同,但它也会记录失败,以便任何攻击检测服务都可以对其进行分析。
如果验证成功,那么我们设置一个 cookie 以记住秘密问题已回答,并且我们调用 AuthenticationFlowContext.success()。
验证本身获取从表单接收到的数据,并调用 SecretQuestionCredentialProvider 中的 isValid 方法。您会注意到,有一部分代码涉及获取凭据 ID。这是因为,如果 Keycloak 配置为允许多种类型的替代身份验证器,或者如果用户可以记录多种 SECRET_QUESTION 类型的凭据(例如,如果我们允许从几个问题中选择,并且我们允许用户拥有多个问题的答案),那么 Keycloak 需要知道正在使用哪个凭据来记录用户。如果存在多个凭据,Keycloak 允许用户在登录期间选择正在使用哪个凭据,并且该信息通过表单传输到 Authenticator。如果表单未显示此信息,则凭据 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()。这是为 Authenticator 提供配置的示例。在这种情况下,我们希望 cookie 的最大期限是可配置的。
protected void setCookie(AuthenticationFlowContext context) {
AuthenticatorConfigModel config = context.getAuthenticatorConfig();
int maxCookieAge = 60 * 60 * 24 * 30; // 30 days
if (config != null) {
maxCookieAge = Integer.valueOf(config.getConfig().get("cookie.max.age"));
}
URI uri = context.getUriInfo().getBaseUriBuilder().path("realms").path(context.getRealm().getName()).build();
addCookie(context, "SECRET_QUESTION_ANSWERED", "true",
uri.getRawPath(),
null, null,
maxCookieAge,
false, true);
}
我们从 AuthenticationFlowContext.getAuthenticatorConfig() 方法获取 AuthenticatorConfigModel。如果配置存在,我们会从中提取最大期限配置。当我们讨论 AuthenticatorFactory 实现时,我们将看到如何定义应该配置什么。如果您在 AuthenticatorFactory 实现中设置了配置定义,则可以在管理控制台中定义配置值。
@Override
public CredentialTypeMetadata getCredentialTypeMetadata(CredentialTypeMetadataContext metadataContext) {
return CredentialTypeMetadata.builder()
.type(getType())
.category(CredentialTypeMetadata.Category.TWO_FACTOR)
.displayName(SecretQuestionCredentialProviderFactory.PROVIDER_ID)
.helpText("secret-question-text")
.createAction(SecretQuestionAuthenticatorFactory.PROVIDER_ID)
.removeable(false)
.build(session);
}
SecretQuestionCredentialProvider 类中要实现的最后一个方法是 getCredentialTypeMetadata(CredentialTypeMetadataContext metadataContext),它是 CredentialProvider 接口的抽象方法。每个 Credential 提供程序都必须提供和实现此方法。该方法返回 CredentialTypeMetadata 的实例,该实例应至少包括身份验证器的类型和类别、displayName 和 removable 项。在此示例中,构建器从方法 getType() 获取身份验证器的类型,类别是双因素(身份验证器可以用作双因素身份验证),并且 removable 设置为 false(用户无法删除一些先前注册的凭据)。
构建器的其他项包括 helpText(将在各种屏幕上向用户显示)、createAction(必需操作的 providerID,用户可以使用它来创建新凭据)或 updateAction(与 createAction 相同,但不是创建新凭据,而是更新凭据)。
实现 AuthenticatorFactory
此过程的下一步是实现 AuthenticatorFactory。此工厂负责实例化 Authenticator。它还提供有关 Authenticator 的部署和配置元数据。
getId() 方法只是组件的唯一名称。运行时调用 create() 方法来分配和处理 Authenticator。
public class SecretQuestionAuthenticatorFactory implements AuthenticatorFactory, ConfigurableAuthenticatorFactory {
public static final String PROVIDER_ID = "secret-question-authenticator";
private static final SecretQuestionAuthenticator SINGLETON = new SecretQuestionAuthenticator();
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public Authenticator create(KeycloakSession session) {
return SINGLETON;
}
工厂负责的下一件事是指定允许的要求开关。虽然有四种不同的要求类型:ALTERNATIVE、REQUIRED、CONDITIONAL、DISABLED,但 AuthenticatorFactory 实现可以限制在定义流程时在管理控制台中显示哪些要求选项。CONDITIONAL 应该始终仅用于子流程,除非有充分的理由这样做,否则身份验证器的要求应为 REQUIRED、ALTERNATIVE 和 DISABLED
private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.ALTERNATIVE,
AuthenticationExecutionModel.Requirement.DISABLED
};
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
AuthenticatorFactory.isUserSetupAllowed() 是一个标志,它告诉流程管理器是否将调用 Authenticator.setRequiredActions() 方法。如果未为用户配置 Authenticator,则流程管理器将检查 isUserSetupAllowed()。如果为 false,则流程中止并显示错误。如果返回 true,则流程管理器将调用 Authenticator.setRequiredActions()。
@Override
public boolean isUserSetupAllowed() {
return true;
}
接下来的几个方法定义了如何配置 Authenticator。isConfigurable() 方法是一个标志,它向管理控制台指定是否可以在流程中配置 Authenticator。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 中的配置映射中使用的键。标签定义了配置选项在管理控制台中的显示方式。类型定义它是 String、Boolean 还是其他类型。管理控制台将根据类型显示不同的 UI 输入。帮助文本是管理控制台中配置属性的工具提示中将显示的内容。有关更多详细信息,请阅读 ProviderConfigProperty 的 javadoc。
其余方法用于管理控制台。getHelpText() 是当您选择要绑定到执行的 Authenticator 时将显示的工具提示文本。getDisplayType() 是在管理控制台中列出 Authenticator 时将显示的文本。getReferenceCategory() 只是 Authenticator 所属的类别。
添加身份验证器表单
Keycloak 附带 Freemarker 主题和模板引擎。您在 Authenticator 类的 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
Keycloak 使用此 services/ 文件扫描它必须加载到系统中的提供程序。
要部署此 jar 文件,请将其复制到 providers/
目录,然后运行 bin/kc.[sh|bat] build
。
实现 RequiredActionProvider
必需操作必须首先实现 RequiredActionProvider 接口。RequiredActionProvider.requiredActionChallenge() 是流程管理器对必需操作的初始调用。此方法负责呈现将驱动必需操作的 HTML 表单。
@Override
public void requiredActionChallenge(RequiredActionContext context) {
Response challenge = context.form().createForm("secret_question_config.ftl");
context.challenge(challenge);
}
您会看到 RequiredActionContext 具有与 AuthenticationFlowContext 类似的方法。form() 方法允许您从 Freemarker 模板呈现页面。操作 URL 由对 form() 方法的调用预先设置。您只需在您的 HTML 表单中引用它即可。稍后我会向您展示这一点。
challenge() 方法通知流程管理器必须执行必需操作。
下一个方法负责处理来自必需操作的 HTML 表单的输入。表单的操作 URL 将路由到 RequiredActionProvider.processAction() 方法
@Override
public void processAction(RequiredActionContext context) {
String answer = (context.getHttpRequest().getDecodedFormParameters().getFirst("answer"));
UserCredentialValueModel model = new UserCredentialValueModel();
model.setValue(answer);
model.setType(SecretQuestionAuthenticator.CREDENTIAL_TYPE);
context.getUser().updateCredentialDirectly(model);
context.success();
}
答案从表单 post 中提取出来。创建 UserCredentialValueModel,并设置凭据的类型和值。然后调用 UserModel.updateCredentialDirectly()。最后,RequiredActionContext.success() 通知容器必需操作已成功。
实现 RequiredActionFactory
此类非常简单。它仅负责创建必需操作提供程序实例。
public class SecretQuestionRequiredActionFactory implements RequiredActionFactory {
private static final SecretQuestionRequiredAction SINGLETON = new SecretQuestionRequiredAction();
@Override
public RequiredActionProvider create(KeycloakSession session) {
return SINGLETON;
}
@Override
public String getId() {
return SecretQuestionRequiredAction.PROVIDER_ID;
}
@Override
public String getDisplayText() {
return "Secret Question";
}
getDisplayText() 方法仅供管理控制台在想要显示必需操作的友好名称时使用。
修改或扩展注册表单
您完全可以实现您自己的流程,其中包含一组身份验证器,以完全更改 Keycloak 中注册的完成方式。但是,您通常想要做的是仅向开箱即用的注册页面添加一些验证。创建了一个额外的 SPI 以实现此目的。它基本上允许您在页面上添加对表单元素的验证,以及在用户注册后初始化 UserModel 属性和数据。我们将查看用户配置文件注册处理的实现以及注册 Google reCAPTCHA Enterprise 插件。
实现 FormAction 接口
您必须实现的核心接口是 FormAction 接口。FormAction 负责呈现和处理页面的某一部分。呈现在 buildPage() 方法中完成,验证在 validate() 方法中完成,后验证操作在 success() 中完成。让我们首先看一下 Recaptcha 插件的 buildPage() 方法。
@Override
public void buildPage(FormContext context, LoginFormsProvider form) {
Map<String, String> config = context.getAuthenticatorConfig().getConfig();
if (config == null
|| Stream.of(PROJECT_ID, SITE_KEY, API_KEY, ACTION)
.anyMatch(key -> Strings.isNullOrEmpty(config.get(key)))
|| parseDoubleFromConfig(config, SCORE_THRESHOLD) == null) {
form.addError(new FormMessage(null, Messages.RECAPTCHA_NOT_CONFIGURED));
return;
}
String userLanguageTag = context.getSession().getContext().resolveLocale(context.getUser())
.toLanguageTag();
boolean invisible = Boolean.parseBoolean(config.getOrDefault(INVISIBLE, "true"));
form.setAttribute("recaptchaRequired", true);
form.setAttribute("recaptchaSiteKey", config.get(SITE_KEY));
form.setAttribute("recaptchaAction", config.get(ACTION));
form.setAttribute("recaptchaVisible", !invisible);
form.addScript("https://www.google.com/recaptcha/enterprise.js?hl=" + userLanguageTag);
}
Recaptcha buildPage() 方法是表单流程的回调,以帮助呈现页面。它接收一个 form 参数,该参数是 LoginFormsProvider。您可以向表单提供程序添加其他属性,以便可以将它们显示在注册 Freemarker 模板生成的 HTML 页面中。
上面的代码来自注册 recaptcha 插件。Recaptcha 需要一些必须从配置中获得的特定设置。FormAction 的配置方式与身份验证器完全相同。在此示例中,我们从 Recaptcha 配置中提取 Google Recaptcha 站点密钥和其他选项,并将它们作为属性添加到表单提供程序。我们的注册模板文件 register.ftl 现在可以访问这些属性。
Recaptcha 还要求加载 JavaScript 脚本。您可以通过调用 LoginFormsProvider.addScript() 并传入 URL 来执行此操作。
对于用户配置文件处理,无需向表单添加其他信息,因此其 buildPage() 方法为空。
此接口的下一个重要部分是 validate() 方法。在收到表单 post 后立即调用此方法。让我们首先看一下 Recaptcha 的插件。
@Override
public void validate(ValidationContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
String captcha = formData.getFirst(G_RECAPTCHA_RESPONSE);
if (!Validation.isBlank(captcha) && validateRecaptcha(context, captcha)) {
context.success();
} else {
List<FormMessage> errors = new ArrayList<>();
errors.add(new FormMessage(null, Messages.RECAPTCHA_FAILED));
formData.remove(G_RECAPTCHA_RESPONSE);
context.validationError(formData, errors);
}
}
在这里,我们获取 Recaptcha 小部件添加到表单的表单数据。我们从配置中获取 Recaptcha 密钥。然后我们验证 recaptcha。如果成功,则调用 ValidationContext.success()。我们使用 formData.remove 清除表单中的 captcha 令牌,但保持其他表单数据不变。如果失败,我们调用 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,该 SPI 确保电子邮件格式正确并执行所有其他验证。如果任何这些验证失败,则会为呈现排队一条错误消息。它将包含每个验证失败的字段的消息。
正如您所看到的,用户配置文件确保注册表单包含所有需要的用户配置文件字段。用户配置文件还确保使用正确的验证,属性在页面上正确分组。每个字段都使用了正确的类型(例如,如果用户需要从预定义的值中进行选择),字段仅针对某些范围“有条件地”呈现(渐进式配置文件)等等。因此,通常您不需要实现新的 FormAction 或注册字段,但您可以正确配置用户配置文件以反映这一点。有关更多详细信息,请参阅用户配置文件文档。一般来说,例如,如果您想向注册表单添加新凭据(例如此处提到的 ReCaptcha 支持)而不是新的用户配置文件字段,则新的 FormAction 可能很有用。 |
在所有验证都已处理完毕后,表单流程将调用 FormAction.success() 方法。对于 recaptcha,这是一个空操作,因此我们不会赘述。对于用户配置文件处理,此方法将值填充到已注册的用户中。
@Override
public void success(FormContext context) {
checkNotOtherUserAuthenticating(context);
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
String email = formData.getFirst(UserModel.EMAIL);
String username = formData.getFirst(UserModel.USERNAME);
if (context.getRealm().isRegistrationEmailAsUsername()) {
username = email;
}
context.getEvent().detail(Details.USERNAME, username)
.detail(Details.REGISTER_METHOD, "form")
.detail(Details.EMAIL, email);
UserProfile profile = getOrCreateUserProfile(context, formData);
UserModel user = profile.create();
user.setEnabled(true);
// This means that following actions can retrieve user from the context by context.getUser() method
context.setUser(user);
}
创建新用户,并将新注册用户的 UserModel 添加到 FormContext。调用适当的方法来初始化 UserModel 数据。在您自己的 FormAction 中,您可以使用如下代码获取用户:
@Override
public void success(FormContext context) {
UserModel user = context.getUser();
if (user != null) {
// Do something useful with the user here ...
}
}
最后,您还需要定义一个 FormActionFactory 类。此类的实现方式与 AuthenticatorFactory 类似,因此我们不会赘述。
打包操作
您将在单个 jar 文件中打包您的类。此 jar 文件必须包含一个名为 org.keycloak.authentication.FormActionFactory
的文件,并且必须包含在您的 jar 文件的 META-INF/services/
目录中。此文件必须列出 jar 文件中每个 FormActionFactory 实现的完全限定类名。例如
org.keycloak.authentication.forms.RegistrationUserCreation
org.keycloak.authentication.forms.RegistrationRecaptcha
Keycloak 使用此 services/ 文件扫描它必须加载到系统中的提供程序。
要部署此 jar 文件,请将其复制到 providers/
目录,然后运行 bin/kc.[sh|bat] build
。
向注册流程添加 FormAction
向注册页面流程添加 FormAction 必须在管理控制台中完成。如果您转到“身份验证”菜单项并转到“流程”选项卡,您将能够查看当前定义的流程。您无法修改内置流程,因此,要添加我们创建的身份验证器,您必须复制现有流程或创建自己的流程。我希望 UI 足够直观,以便您可以自行弄清楚如何创建流程并添加 FormAction。
基本上,您必须复制注册流程。然后单击“注册表单”右侧的“操作”菜单,然后选择“添加执行”以添加新的执行。您将从选择列表中选择 FormAction。通过使用向下按钮移动 FormAction,确保您的 FormAction 出现在“注册用户创建”之后,如果您的 FormAction 尚未列在“注册用户创建”之后。“注册用户创建”的 success() 方法负责创建新的 UserModel,因此您希望您的 FormAction 出现在用户创建之后。
创建流程后,您必须将其绑定到注册。如果您转到“身份验证”菜单并转到“绑定”选项卡,您将看到将流程绑定到浏览器、注册或直接授权流程的选项。
修改忘记密码/凭据流程
Keycloak 还具有用于忘记密码的特定身份验证流程,或者更确切地说,是用户发起的凭据重置。如果您转到管理控制台流程页面,则有一个“重置凭据”流程。默认情况下,Keycloak 会询问用户的电子邮件或用户名,并将电子邮件发送给他们。如果用户单击链接,则他们可以重置密码和 OTP(如果已设置 OTP)。您可以通过禁用流程中的“重置 OTP”身份验证器来禁用自动 OTP 重置。
您也可以向此流程添加其他功能。例如,许多部署希望用户回答一个或多个秘密问题,以补充发送包含链接的电子邮件。您可以扩展发行版附带的秘密问题示例,并将其合并到重置凭据流程中。
如果您要扩展重置凭据流程,请注意一件事。第一个“身份验证器”只是一个获取用户名或电子邮件的页面。如果用户名或电子邮件存在,则 AuthenticationFlowContext.getUser() 将返回找到的用户。否则,这将为 null。如果之前的电子邮件或用户名不存在,则此表单不会重新要求用户输入电子邮件或用户名。您需要防止攻击者能够猜测有效用户。因此,如果 AuthenticationFlowContext.getUser() 返回 null,您应该继续流程,使其看起来像是选择了有效用户。我建议,如果您想在此流程中添加秘密问题,您应该在发送电子邮件后提出这些问题。换句话说,在“发送重置电子邮件”身份验证器之后添加您的自定义身份验证器。
修改首次代理登录流程
首次代理登录流程用于首次使用某些身份提供程序登录期间。术语 首次登录
表示尚不存在与特定已验证身份提供程序帐户链接的 Keycloak 帐户。
-
请参阅服务器管理指南中的“
身份代理
”章节。
客户端身份验证
Keycloak 实际上支持 OpenID Connect 客户端应用程序的可插拔身份验证。客户端(应用程序)的身份验证在后台由 Keycloak 适配器在向 Keycloak 服务器发送任何后端通道请求期间使用(例如,在成功身份验证后请求将代码交换为访问令牌或请求刷新令牌)。但是,客户端身份验证也可以在 直接访问授权
(由 OAuth2 资源所有者密码凭据流程
表示)期间或在 服务帐户
身份验证(由 OAuth2 客户端凭据流程
表示)期间直接由您使用。
-
有关 Keycloak 适配器和 OAuth2 流程的更多详细信息,请参阅保护应用程序指南。
默认实现
实际上,Keycloak 有 2 个客户端身份验证的默认实现
- 使用 client_id 和 client_secret 的传统身份验证
-
这是 OpenID Connect 或 OAuth2 规范中提到的默认机制,Keycloak 自早期就支持它。公共客户端需要在 POST 请求中包含带有其 ID 的
client_id
参数(因此实际上未经过身份验证),而机密客户端需要包含带有 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。
以下示例使用字段 demo-id
扩展了上一个示例中的 DemoActionToken
import com.fasterxml.jackson.annotation.JsonProperty;
import org.keycloak.authentication.actiontoken.DefaultActionToken;
public class DemoActionToken extends DefaultActionToken {
public static final String TOKEN_TYPE = "my-demo-token";
private static final String JSON_FIELD_DEMO_ID = "demo-id";
@JsonProperty(value = JSON_FIELD_DEMO_ID)
private String demoId;
public DemoActionToken(String userId, int absoluteExpirationInSecs, String compoundAuthenticationSessionId, String demoId) {
super(userId, TOKEN_TYPE, absoluteExpirationInSecs, null, compoundAuthenticationSessionId);
this.demoId = demoId;
}
private DemoActionToken() {
// you must have this private constructor for deserializer
}
public String getDemoId() {
return demoId;
}
}
打包类和部署
要插入您自己的操作令牌及其处理程序,您需要在服务器端实现一些接口
-
org.keycloak.authentication.actiontoken.ActionTokenHandler
- 特定操作的操作令牌的实际处理程序(即,对于给定的typ
令牌字段值)。该接口中的中心方法是
handleToken(token, context)
,它定义了在接收到操作令牌后执行的实际操作。通常它是身份验证会话注释的某些更改,但通常可以是任意的。仅当所有验证器(包括在getVerifiers(context)
中定义的验证器)都成功时,才会调用此方法,并且可以保证token
将是getTokenClass()
方法返回的类。为了能够确定是否为当前身份验证会话颁发了操作令牌,如上面的第 2 项中所述,必须在
getAuthenticationSessionIdFromToken(token, context)
方法中声明用于提取身份验证会话 ID 的方法。DefaultActionToken
中的实现返回令牌中asid
字段的值(如果已定义)。请注意,您可以覆盖该方法以返回当前的身份验证会话 ID,而无需考虑令牌 - 这样,您可以创建令牌,这些令牌将在任何身份验证流程启动之前介入正在进行的身份验证流程。如果来自令牌的身份验证会话与当前会话不匹配,则将要求操作令牌处理程序通过调用
startFreshAuthenticationSession(token, context)
启动新的会话。它可以抛出VerificationException
(或更好的其更具描述性的变体ExplainedTokenVerificationException
)以表示将被禁止。令牌处理程序还通过方法
canUseTokenRepeatedly(token, context)
确定令牌在使用后和身份验证完成时是否会失效。请注意,如果您有一个利用多个操作令牌的流程,则只会使最后一个令牌失效。在这种情况下,您应该在操作令牌处理程序中使用org.keycloak.models.SingleUseObjectProvider
以手动使已使用的令牌失效。大多数
ActionTokenHandler
方法的默认实现是keycloak-services
模块中的org.keycloak.authentication.actiontoken.AbstractActionTokenHandler
抽象类。唯一需要实现的方法是handleToken(token, context)
,它执行实际操作。 -
org.keycloak.authentication.actiontoken.ActionTokenHandlerFactory
- 实例化操作令牌处理程序的工厂。实现必须覆盖getId()
以返回必须与操作令牌中typ
字段的值完全匹配的值。请注意,您必须注册自定义
ActionTokenHandlerFactory
实现,如本指南的服务提供程序接口部分中所述。
事件侦听器 SPI
编写事件侦听器提供程序首先要实现 EventListenerProvider
和 EventListenerProviderFactory
接口。请参阅 Javadoc 和示例,以获取有关如何执行此操作的完整详细信息。
有关如何打包和部署自定义提供程序的详细信息,请参阅服务提供程序接口章节。
SAML 角色映射 SPI
Keycloak 定义了一个 SPI,用于将 SAML 角色映射到 SP 环境中存在的角色。第三方 IDP 返回的角色可能并不总是与为 SP 应用程序定义的角色相对应,因此需要一种机制来允许将 SAML 角色映射到不同的角色。SAML 适配器在从 SAML 断言中提取角色后使用它来设置容器的安全上下文。
org.keycloak.adapters.saml.RoleMappingsProvider
SPI 对可以执行的映射没有任何限制。实现不仅可以将角色映射到其他角色,还可以添加或删除角色(从而增加或减少分配给 SAML 主体的角色集),具体取决于用例。
有关 SAML 适配器的角色映射提供程序的配置以及可用默认实现的描述,请参阅保护应用程序指南。
实现自定义角色映射提供程序
要实现自定义角色映射提供程序,首先需要实现 org.keycloak.adapters.saml.RoleMappingsProvider
接口。然后,必须将包含自定义实现的完全限定名称的 META-INF/services/org.keycloak.adapters.saml.RoleMappingsProvider
文件添加到也包含实现类的存档中。此存档可以是
-
SP 应用程序 WAR 文件,其中提供程序类包含在 WEB-INF/classes 中;
-
将添加到 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 数据模型的现有外部专有用户数据库。对于这些情况,应用程序开发人员可以编写用户存储 SPI 的实现,以桥接外部用户存储和 Keycloak 用于登录和管理用户的内部用户对象模型。
当 Keycloak 运行时需要查找用户时,例如当用户登录时,它会执行多个步骤来定位用户。它首先查看用户是否在用户缓存中;如果找到用户,它将使用该内存表示。然后,它在 Keycloak 本地数据库中查找用户。如果找不到用户,则它会循环遍历用户存储 SPI 提供程序实现以执行用户查询,直到其中一个返回运行时正在查找的用户。提供程序查询外部用户存储以查找用户,并将用户的外部数据表示形式映射到 Keycloak 的用户元模型。
用户存储 SPI 提供程序实现还可以执行复杂的条件查询、对用户执行 CRUD 操作、验证和管理凭据或一次执行许多用户的批量更新。这取决于外部存储的功能。
User Storage SPI 提供程序实现与 Jakarta EE 组件的打包和部署方式类似(通常也是 Jakarta EE 组件)。它们默认情况下未启用,而是必须在管理控制台的 User Federation
选项卡下为每个 realm 启用和配置。
如果您的用户提供程序实现正在使用某些用户属性作为元数据属性来链接/建立用户身份,请确保用户无法编辑这些属性,并且相应的属性是只读的。例如 LDAP_ID 属性,内置的 Keycloak LDAP 提供程序使用该属性来存储用户在 LDAP 服务器端的 ID。有关详细信息,请参阅 威胁模型缓解章节。 |
在 Keycloak Quickstarts 仓库 中有两个示例项目。每个 quickstart 都有一个 README
文件,其中包含有关如何构建、部署和测试示例项目的说明。下表简要描述了可用的 User Storage SPI quickstarts
名称 | 描述 |
---|---|
演示如何使用 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:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public interface UserStorageProviderFactory<T extends UserStorageProvider> extends ComponentFactory<T, UserStorageProvider> {
/**
* This is the name of the provider and will be shown in the admin console as an option.
*
* @return
*/
@Override
String getId();
/**
* called per Keycloak transaction.
*
* @param session
* @param model
* @return
*/
T create(KeycloakSession session, ComponentModel model);
...
}
提供程序工厂类在实现 UserStorageProviderFactory
时,必须将具体的提供程序类指定为模板参数。这是必须的,因为运行时将内省此类以扫描其功能(它实现的其他接口)。因此,例如,如果您的提供程序类名为 FileProvider
,则工厂类应如下所示
public class FileProviderFactory implements UserStorageProviderFactory<FileProvider> {
public String getId() { return "file-provider"; }
public FileProvider create(KeycloakSession session, ComponentModel model) {
...
}
getId()
方法返回 User Storage 提供程序的名称。当您想要为特定 realm 启用提供程序时,此 ID 将显示在管理控制台的 User Federation 页面中。
create()
方法负责分配提供程序类的实例。它接受 org.keycloak.models.KeycloakSession
参数。此对象可用于查找其他信息和元数据,以及访问运行时中的各种其他组件。ComponentModel
参数表示提供程序在特定 realm 中如何启用和配置。它包含已启用提供程序的实例 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,并使用它在外部用户存储中查找用户。
此格式的缺点是它可能为外部存储用户生成长 ID。当与 WebAuthn 身份验证 结合使用时,这一点尤其重要,WebAuthn 身份验证将用户句柄 ID 限制为 64 字节。因此,如果存储用户将使用 WebAuthn 身份验证,则将完整存储 ID 限制为 64 个字符非常重要。方法 validateConfiguration
可用于在创建时为提供程序组件分配一个短 ID,从而为 64 字节限制内的用户 ID 留出一些空间。
@Override
void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model)
throws ComponentValidationException
{
// ...
if (model.getId() == null) {
// On creation use short UUID of 22 chars, 40 chars left for the user ID
model.setId(KeycloakModelUtils.generateShortId());
}
}
打包和部署
为了让 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 和用户名查找用户,并且能够验证密码。源自此提供程序的用户将是只读的。
提供程序类
我们将首先介绍 UserStorageProvider
类。
public class PropertyFileUserStorageProvider implements
UserStorageProvider,
UserLookupProvider,
CredentialInputValidator,
CredentialInputUpdater
{
...
}
我们的提供程序类 PropertyFileUserStorageProvider
实现了许多接口。它实现了 UserStorageProvider
,因为这是 SPI 的基本要求。它实现了 UserLookupProvider
接口,因为我们希望能够使用此提供程序存储的用户登录。它实现了 CredentialInputValidator
接口,因为我们希望能够验证在登录屏幕中输入的密码。我们的属性文件是只读的。我们实现了 CredentialInputUpdater
,因为我们希望在用户尝试更新其密码时发布错误条件。
protected KeycloakSession session;
protected Properties properties;
protected ComponentModel model;
// map of loaded users in this transaction
protected Map<String, UserModel> loadedUsers = new HashMap<>();
public PropertyFileUserStorageProvider(KeycloakSession session, ComponentModel model, Properties properties) {
this.session = session;
this.model = model;
this.properties = properties;
}
此提供程序类的构造函数将存储对 KeycloakSession
、ComponentModel
和属性文件的引用。我们稍后将使用所有这些。另请注意,有一个已加载用户的映射。每当我们找到用户时,我们都会将其存储在此映射中,以便避免在同一事务中再次重新创建它。这是一个很好的实践,因为许多提供程序都需要这样做(即,任何与 JPA 集成的提供程序)。另请记住,提供程序类实例每个事务创建一次,并在事务完成后关闭。
UserLookupProvider 实现
@Override
public UserModel getUserByUsername(RealmModel realm, String username) {
UserModel adapter = loadedUsers.get(username);
if (adapter == null) {
String password = properties.getProperty(username);
if (password != null) {
adapter = createAdapter(realm, username);
loadedUsers.put(username, adapter);
}
}
return adapter;
}
protected UserModel createAdapter(RealmModel realm, String username) {
return new AbstractUserAdapter(session, realm, model) {
@Override
public String getUsername() {
return username;
}
};
}
@Override
public UserModel getUserById(RealmModel realm, String id) {
StorageId storageId = new StorageId(id);
String username = storageId.getExternalId();
return getUserByUsername(realm, username);
}
@Override
public UserModel getUserByEmail(RealmModel realm, String email) {
return null;
}
当用户登录时,Keycloak 登录页面会调用 getUserByUsername()
方法。在我们的实现中,我们首先检查 loadedUsers
映射,以查看是否已在此事务中加载该用户。如果尚未加载,我们在属性文件中查找用户名。如果存在,我们创建一个 UserModel
的实现,将其存储在 loadedUsers
中以供将来参考,并返回此实例。
createAdapter()
方法使用辅助类 org.keycloak.storage.adapter.AbstractUserAdapter
。这为 UserModel
提供了基本实现。它使用用户的用户名作为外部 ID,根据所需的存储 ID 格式自动生成用户 ID。
"f:" + component id + ":" + username
AbstractUserAdapter
的每个 get 方法都返回 null 或空集合。但是,返回角色和组映射的方法将为每个用户返回为 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
。
提供程序工厂实现
现在提供程序类已完成,我们现在将注意力转向提供程序工厂类。
public class PropertyFileUserStorageProviderFactory
implements UserStorageProviderFactory<PropertyFileUserStorageProvider> {
public static final String PROVIDER_NAME = "readonly-property-file";
@Override
public String getId() {
return PROVIDER_NAME;
}
首先要注意的是,在实现 UserStorageProviderFactory
类时,您必须将具体的提供程序类实现作为模板参数传入。在这里,我们指定之前定义的提供程序类:PropertyFileUserStorageProvider
。
如果您未指定模板参数,您的提供程序将无法运行。运行时执行类内省以确定提供程序实现的能力接口。 |
getId()
方法标识运行时中的工厂,并且当您想要为 realm 启用用户存储提供程序时,它也将是管理控制台中显示的字符串。
初始化
private static final Logger logger = Logger.getLogger(PropertyFileUserStorageProviderFactory.class);
protected Properties properties = new Properties();
@Override
public void init(Config.Scope config) {
InputStream is = getClass().getClassLoader().getResourceAsStream("/users.properties");
if (is == null) {
logger.warn("Could not find users.properties in classpath");
} else {
try {
properties.load(is);
} catch (IOException ex) {
logger.error("Failed to load users.properties file", ex);
}
}
}
@Override
public PropertyFileUserStorageProvider create(KeycloakSession session, ComponentModel model) {
return new PropertyFileUserStorageProvider(session, model, properties);
}
UserStorageProviderFactory
接口有一个可选的 init()
方法,您可以实现该方法。当 Keycloak 启动时,仅创建每个提供程序工厂的一个实例。同样在启动时,在每个工厂实例上调用 init()
方法。还有一个 postInit()
方法,您也可以实现该方法。在调用每个工厂的 init()
方法后,将调用它们的 postInit()
方法。
在我们的 init()
方法实现中,我们从类路径中找到包含用户声明的属性文件。然后,我们加载 properties
字段,其中存储了用户名和密码组合。
Config.Scope
参数是通过服务器配置配置的工厂配置。
例如,通过使用以下参数运行服务器
kc.[sh|bat] start --spi-storage-readonly-property-file-path=/other-users.properties
我们可以指定用户属性文件的类路径,而不是对其进行硬编码。然后,您可以在 PropertyFileUserStorageProviderFactory.init()
中检索配置
public void init(Config.Scope config) {
String path = config.get("path");
InputStream is = getClass().getClassLoader().getResourceAsStream(path);
...
}
配置技巧
我们的 PropertyFileUserStorageProvider
示例有点牵强。它硬编码到提供程序的 jar 文件中嵌入的属性文件,这并不是非常有用。我们可能希望使此文件的位置可为提供程序的每个实例配置。换句话说,我们可能希望在多个不同的 realm 中多次重用此提供程序,并指向完全不同的用户属性文件。我们还需要在管理控制台 UI 中执行此配置。
UserStorageProviderFactory
具有您可以实现的附加方法,这些方法处理提供程序配置。您描述要为每个提供程序配置的变量,管理控制台会自动呈现一个通用输入页面来收集此配置。实现后,回调方法还会在保存配置、首次创建提供程序以及更新配置时验证配置。UserStorageProviderFactory
从 org.keycloak.component.ComponentFactory
接口继承这些方法。
List<ProviderConfigProperty> getConfigProperties();
default
void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model)
throws ComponentValidationException
{
}
default
void onCreate(KeycloakSession session, RealmModel realm, ComponentModel model) {
}
default
void onUpdate(KeycloakSession session, RealmModel realm, ComponentModel model) {
}
ComponentFactory.getConfigProperties()
方法返回 org.keycloak.provider.ProviderConfigProperty
实例的列表。这些实例声明了呈现和存储提供程序的每个配置变量所需的元数据。
配置示例
让我们扩展我们的 PropertyFileUserStorageProviderFactory
示例,以允许您将提供程序实例指向磁盘上的特定文件。
public class PropertyFileUserStorageProviderFactory
implements UserStorageProviderFactory<PropertyFileUserStorageProvider> {
protected static final List<ProviderConfigProperty> configMetadata;
static {
configMetadata = ProviderConfigurationBuilder.create()
.property().name("path")
.type(ProviderConfigProperty.STRING_TYPE)
.label("Path")
.defaultValue("${jboss.server.config.dir}/example-users.properties")
.helpText("File path to properties file")
.add().build();
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return configMetadata;
}
ProviderConfigurationBuilder
类是一个很好的辅助类,用于创建配置属性列表。在这里,我们指定一个名为 path
的变量,其类型为 String。在此提供程序的管理控制台配置页面上,此配置变量标记为 Path
,默认值为 ${jboss.server.config.dir}/example-users.properties
。当您将鼠标悬停在此配置选项的工具提示上时,它会显示帮助文本“File path to properties file
”。
我们要做的下一件事是验证此文件是否存在于磁盘上。除非它指向有效的用户属性文件,否则我们不想在 realm 中启用此提供程序的实例。为此,我们实现了 validateConfiguration()
方法。
@Override
public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config)
throws ComponentValidationException {
String fp = config.getConfig().getFirst("path");
if (fp == null) throw new ComponentValidationException("user property file does not exist");
fp = EnvUtil.replace(fp);
File file = new File(fp);
if (!file.exists()) {
throw new ComponentValidationException("user property file does not exist");
}
}
validateConfiguration()
方法提供来自 ComponentModel
的配置变量,以验证该文件是否存在于磁盘上。请注意 org.keycloak.common.util.EnvUtil.replace()
方法的使用。使用此方法,任何包含 ${}
的字符串都将用系统属性值替换该值。${jboss.server.config.dir}
字符串对应于我们服务器的 conf/
目录,并且对此示例非常有用。
接下来我们要做的就是删除旧的 init()
方法。我们这样做是因为用户属性文件将是每个提供程序实例唯一的。我们将此逻辑移动到 create()
方法。
@Override
public PropertyFileUserStorageProvider create(KeycloakSession session, ComponentModel model) {
String path = model.getConfig().getFirst("path");
Properties props = new Properties();
try {
InputStream is = new FileInputStream(path);
props.load(is);
is.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
return new PropertyFileUserStorageProvider(session, model, props);
}
当然,此逻辑效率低下,因为每个事务都从磁盘读取整个用户属性文件,但希望这以一种简单的方式说明了如何挂钩配置变量。
添加/删除用户和查询能力接口
我们的示例尚未完成的一件事是允许它添加和删除用户或更改密码。我们的示例中定义的用户也无法在管理控制台中查询或查看。为了添加这些增强功能,我们的示例提供程序必须实现 UserQueryMethodsProvider
(或 UserQueryProvider
)和 UserRegistrationProvider
接口。
实现 UserRegistrationProvider
使用此过程来实现从特定存储添加和删除用户,我们首先必须能够将属性文件保存到磁盘。
public void save() {
String path = model.getConfig().getFirst("path");
path = EnvUtil.replace(path);
try {
FileOutputStream fos = new FileOutputStream(path);
properties.store(fos, "");
fos.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
然后,addUser()
和 removeUser()
方法的实现变得简单。
public static final String UNSET_PASSWORD="#$!-UNSET-PASSWORD";
@Override
public UserModel addUser(RealmModel realm, String username) {
synchronized (properties) {
properties.setProperty(username, UNSET_PASSWORD);
save();
}
return createAdapter(realm, username);
}
@Override
public boolean removeUser(RealmModel realm, UserModel user) {
synchronized (properties) {
if (properties.remove(user.getUsername()) == null) return false;
save();
return true;
}
}
请注意,在添加用户时,我们将属性映射的密码值设置为 UNSET_PASSWORD
。我们这样做是因为我们不能在属性值中包含空值。我们还必须修改 CredentialInputValidator
方法以反映这一点。
如果提供程序实现了 UserRegistrationProvider
接口,则将调用 addUser()
方法。如果您的提供程序具有用于关闭添加用户的配置开关,则从此方法返回 null
将跳过提供程序并调用下一个提供程序。
@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
if (!supportsCredentialType(input.getType()) || !(input instanceof UserCredentialModel)) return false;
UserCredentialModel cred = (UserCredentialModel)input;
String password = properties.getProperty(user.getUsername());
if (password == null || UNSET_PASSWORD.equals(password)) return false;
return password.equals(cred.getValue());
}
由于我们现在可以保存我们的属性文件,因此允许密码更新也很有意义。
@Override
public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
if (!(input instanceof UserCredentialModel)) return false;
if (!input.getType().equals(PasswordCredentialModel.TYPE)) return false;
UserCredentialModel cred = (UserCredentialModel)input;
synchronized (properties) {
properties.setProperty(user.getUsername(), cred.getValue());
save();
}
return true;
}
我们现在还可以实现禁用密码。
@Override
public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {
if (!credentialType.equals(PasswordCredentialModel.TYPE)) return;
synchronized (properties) {
properties.setProperty(user.getUsername(), UNSET_PASSWORD);
save();
}
}
private static final Set<String> disableableTypes = new HashSet<>();
static {
disableableTypes.add(PasswordCredentialModel.TYPE);
}
@Override
public Stream<String> getDisableableCredentialTypes(RealmModel realm, UserModel user) {
return disableableTypes.stream();
}
通过实现这些方法,您现在可以在管理控制台中更改和禁用用户的密码。
实现 UserQueryProvider
UserQueryProvider
是 UserQueryMethodsProvider
和 UserCountMethodsProvider
的组合。如果不实现 UserQueryMethodsProvider
,管理控制台将无法查看和管理由我们的示例提供程序加载的用户。让我们看一下实现此接口。
@Override
public int getUsersCount(RealmModel realm) {
return properties.size();
}
@Override
public Stream<UserModel> searchForUserStream(RealmModel realm, String search, Integer firstResult, Integer maxResults) {
Predicate<String> predicate = "*".equals(search) ? username -> true : username -> username.contains(search);
return properties.keySet().stream()
.map(String.class::cast)
.filter(predicate)
.skip(firstResult)
.map(username -> getUserByUsername(realm, username))
.limit(maxResults);
}
searchForUserStream()
的第一个声明采用 String
参数。在此示例中,该参数表示您要搜索的用户名。此字符串可以是子字符串,这解释了在执行搜索时选择 String.contains()
方法的原因。请注意使用 *
来指示请求所有用户的列表。该方法迭代属性文件的键集,委托给 getUserByUsername()
以加载用户。请注意,我们正在根据 firstResult
和 maxResults
参数索引此调用。如果您的外部存储不支持分页,您将必须执行类似的逻辑。
@Override
public Stream<UserModel> searchForUserStream(RealmModel realm, Map<String, String> params, Integer firstResult, Integer maxResults) {
// only support searching by username
String usernameSearchString = params.get("username");
if (usernameSearchString != null)
return searchForUserStream(realm, usernameSearchString, firstResult, maxResults);
// if we are not searching by username, return all users
return searchForUserStream(realm, "*", firstResult, maxResults);
}
采用 Map
参数的 searchForUserStream()
方法可以根据名字、姓氏、用户名和电子邮件搜索用户。仅存储用户名,因此搜索仅基于用户名,除非 Map
参数不包含 username
属性。在这种情况下,将返回所有用户。在这种情况下,将使用 searchForUserStream(realm, search, firstResult, maxResults)
。
@Override
public Stream<UserModel> getGroupMembersStream(RealmModel realm, GroupModel group, Integer firstResult, Integer maxResults) {
return Stream.empty();
}
@Override
public Stream<UserModel> searchForUserByUserAttributeStream(RealmModel realm, String attrName, String attrValue) {
return Stream.empty();
}
组或属性未存储,因此其他方法返回空流。
增强外部存储
PropertyFileUserStorageProvider
示例非常有限。虽然我们将能够使用存储在属性文件中的用户登录,但我们无法做太多其他事情。如果由此提供程序加载的用户需要特殊的角色或组映射才能完全访问特定应用程序,则我们无法向这些用户添加其他角色映射。您也无法修改或添加其他重要属性,例如电子邮件、名字和姓氏。
对于这些类型的情况,Keycloak 允许您通过在 Keycloak 数据库中存储额外信息来增强外部存储。这称为联合用户存储,并封装在 org.keycloak.storage.federated.UserFederatedStorageProvider
类中。
package org.keycloak.storage.federated;
public interface UserFederatedStorageProvider extends Provider,
UserAttributeFederatedStorage,
UserBrokerLinkFederatedStorage,
UserConsentFederatedStorage,
UserNotBeforeFederatedStorage,
UserGroupMembershipFederatedStorage,
UserRequiredActionsFederatedStorage,
UserRoleMappingsFederatedStorage,
UserFederatedUserCredentialStore {
...
}
UserFederatedStorageProvider
实例在 UserStorageUtil.userFederatedStorage(KeycloakSession)
方法上可用。它具有各种不同的方法,用于存储属性、组和角色映射、不同的凭据类型和必需的操作。如果您的外部存储的数据模型无法支持完整的 Keycloak 功能集,则此服务可以填补空白。
Keycloak 附带了一个辅助类 org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage
,它会将除用户名 get/set 之外的每个 UserModel
方法委托给联合用户存储。覆盖您需要覆盖的方法以委托给您的外部存储表示。强烈建议您阅读此类的 javadoc,因为它具有您可能想要覆盖的较小的受保护方法。特别是围绕组成员身份和角色映射。
增强示例
在我们的 PropertyFileUserStorageProvider
示例中,我们只需要对我们的提供程序进行简单的更改即可使用 AbstractUserAdapterFederatedStorage
。
protected UserModel createAdapter(RealmModel realm, String username) {
return new AbstractUserAdapterFederatedStorage(session, realm, model) {
@Override
public String getUsername() {
return username;
}
@Override
public void setUsername(String username) {
String pw = (String)properties.remove(username);
if (pw != null) {
properties.put(username, pw);
save();
}
}
};
}
我们改为定义 AbstractUserAdapterFederatedStorage
的匿名类实现。setUsername()
方法会更改属性文件并保存它。
导入实现策略
在实现用户存储提供程序时,您可以采用另一种策略。您可以不使用联合用户存储,而是在 Keycloak 内置用户数据库中本地创建一个用户,并将属性从您的外部存储复制到此本地副本中。这种方法有很多优点。
-
Keycloak 基本上成为您外部存储的持久用户缓存。导入用户后,您将不再访问外部存储,从而减轻其负载。
-
如果您要迁移到 Keycloak 作为您的官方用户存储并弃用旧的外部存储,您可以缓慢地迁移应用程序以使用 Keycloak。当所有应用程序都已迁移后,取消链接导入的用户,并停用旧的旧版外部存储。
但是,使用导入策略有一些明显的缺点
-
首次查找用户将需要多次更新 Keycloak 数据库。在高负载下,这可能会导致严重的性能损失,并给 Keycloak 数据库带来很大压力。联合用户存储方法只会根据需要存储额外数据,并且可能永远不会使用,具体取决于您的外部存储的功能。
-
使用导入方法,您必须使本地 Keycloak 存储和外部存储保持同步。User Storage SPI 具有您可以实现的用于支持同步的能力接口,但这很快就会变得痛苦和混乱。
要实现导入策略,您只需首先检查用户是否已在本地导入。如果是,则返回本地用户,否则在本地创建用户并从外部存储导入数据。您还可以代理本地用户,以便大多数更改自动同步。
这将有点牵强,但我们可以扩展我们的 PropertyFileUserStorageProvider
以采用这种方法。我们首先修改 createAdapter()
方法。
protected UserModel createAdapter(RealmModel realm, String username) {
UserModel local = UserStoragePrivateUtil.userLocalStorage(session).getUserByUsername(realm, username);
if (local == null) {
local = UserStoragePrivateUtil.userLocalStorage(session).addUser(realm, username);
local.setFederationLink(model.getId());
}
return new UserModelDelegate(local) {
@Override
public void setUsername(String username) {
String pw = (String)properties.remove(username);
if (pw != null) {
properties.put(username, pw);
save();
}
super.setUsername(username);
}
};
}
在此方法中,我们调用 UserStoragePrivateUtil.userLocalStorage(session)
方法以获取对本地 Keycloak 用户存储的引用。我们查看用户是否在本地存储,如果不在,我们在本地添加它。不要设置本地用户的 id
。让 Keycloak 自动生成 id
。另请注意,我们调用 UserModel.setFederationLink()
并传入我们提供程序的 ComponentModel
的 ID。这在提供程序和导入的用户之间设置了一个链接。
当用户存储提供程序被删除时,由其导入的任何用户也将被删除。这是调用 UserModel.setFederationLink() 的目的之一。 |
另一个需要注意的是,如果本地用户已链接,您的存储提供程序仍将委托给它,以处理它从 CredentialInputValidator
和 CredentialInputUpdater
接口实现的方法。从验证或更新返回 false
只会导致 Keycloak 查看它是否可以使用本地存储进行验证或更新。
另请注意,我们正在使用 org.keycloak.models.utils.UserModelDelegate
类代理本地用户。此类是 UserModel
的实现。每个方法都只是委托给实例化它的 UserModel
。我们覆盖此委托类的 setUsername()
方法以自动与属性文件同步。对于您的提供程序,您可以使用它来拦截本地 UserModel
上的其他方法,以执行与您的外部存储的同步。例如,get 方法可以确保本地存储同步。Set 方法使外部存储与本地存储保持同步。需要注意的一件事是,getId()
方法应始终返回您在本地创建用户时自动生成的 ID。您不应返回其他非导入示例中显示的联合 ID。
如果您的提供程序正在实现 UserRegistrationProvider 接口,则您的 removeUser() 方法无需从本地存储中删除用户。运行时将自动执行此操作。另请注意,removeUser() 将在从本地存储中删除用户之前被调用。 |
ImportedUserValidation 接口
如果您还记得本章前面讨论的用户查询工作原理,本地存储将首先被查询,如果在本地存储中找到用户,则查询结束。对于我们上面的实现来说,这是一个问题,因为我们希望代理本地 UserModel
,以便我们可以保持用户名同步。用户存储 SPI 有一个回调,用于在从本地数据库加载链接的本地用户时触发。
package org.keycloak.storage.user;
public interface ImportedUserValidation {
/**
* If this method returns null, then the user in local storage will be removed
*
* @param realm
* @param user
* @return null if user no longer valid
*/
UserModel validate(RealmModel realm, UserModel user);
}
每当加载链接的本地用户时,如果用户存储提供程序类实现了此接口,则会调用 validate()
方法。在这里,您可以代理作为参数传入的本地用户并返回它。将使用这个新的 UserModel
。您也可以选择检查用户是否仍然存在于外部存储中。如果 validate()
返回 null
,则本地用户将从数据库中删除。
ImportSynchronization 接口
通过导入策略,您可以看到本地用户副本可能与外部存储失去同步。例如,可能已从外部存储中删除了用户。用户存储 SPI 还有一个额外的接口可以用来处理这个问题,org.keycloak.storage.user.ImportSynchronization
package org.keycloak.storage.user;
public interface ImportSynchronization {
SynchronizationResult sync(KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model);
SynchronizationResult syncSince(Date lastSync, KeycloakSessionFactory sessionFactory, String realmId, UserStorageProviderModel model);
}
此接口由提供程序工厂实现。一旦提供程序工厂实现了此接口,提供程序的管理控制台管理页面将显示其他选项。您可以通过单击按钮手动强制同步。这将调用 ImportSynchronization.sync()
方法。此外,还会显示其他配置选项,允许您自动安排同步。自动同步会调用 syncSince()
方法。
用户缓存
当通过 ID、用户名或电子邮件查询加载用户对象时,它会被缓存。当用户对象被缓存时,它会遍历整个 UserModel
接口,并将此信息提取到本地仅内存缓存中。在集群中,此缓存仍然是本地的,但它会变成失效缓存。当用户对象被修改时,它会被逐出。此逐出事件会传播到整个集群,以便其他节点的用戶缓存也失效。
管理用户缓存
您可以通过调用 KeycloakSession.getProvider(UserCache.class)
来访问用户缓存。
/**
* All these methods effect an entire cluster of Keycloak instances.
*
* @author <a href="mailto:bill@burkecentral.com">Bill Burke</a>
* @version $Revision: 1 $
*/
public interface UserCache extends UserProvider {
/**
* Evict user from cache.
*
* @param user
*/
void evict(RealmModel realm, UserModel user);
/**
* Evict users of a specific realm
*
* @param realm
*/
void evict(RealmModel realm);
/**
* Clear cache entirely.
*
*/
void clear();
}
有用于逐出特定用户、特定 realm 中包含的用户或整个缓存的方法。
OnUserCache 回调接口
您可能希望缓存特定于您的提供程序实现的其他信息。用户存储 SPI 在用户被缓存时有一个回调:org.keycloak.models.cache.OnUserCache
。
public interface OnUserCache {
void onCache(RealmModel realm, CachedUserModel user, UserModel delegate);
}
如果您的提供程序类想要此回调,则应实现此接口。UserModel
委托参数是由您的提供程序返回的 UserModel
实例。CachedUserModel
是扩展的 UserModel
接口。这是在本地存储中本地缓存的实例。
public interface CachedUserModel extends UserModel {
/**
* Invalidates the cache for this user and returns a delegate that represents the actual data provider
*
* @return
*/
UserModel getDelegateForUpdate();
boolean isMarkedForEviction();
/**
* Invalidate the cache for this model
*
*/
void invalidate();
/**
* When was the model was loaded from database.
*
* @return
*/
long getCacheTimestamp();
/**
* Returns a map that contains custom things that are cached along with this model. You can write to this map.
*
* @return
*/
ConcurrentHashMap getCachedWith();
}
此 CachedUserModel
接口允许您从缓存中逐出用户并获取提供程序 UserModel
实例。getCachedWith()
方法返回一个映射,允许您缓存与用户相关的其他信息。例如,凭据不是 UserModel
接口的一部分。如果您想在内存中缓存凭据,您将实现 OnUserCache
并使用 getCachedWith()
方法缓存用户的凭据。
利用 Jakarta EE
自版本 20 起,Keycloak 仅依赖于 Quarkus。与 WildFly 不同,Quarkus 不是应用程序服务器。有关更多详细信息,请参阅 https://keycloak.java.net.cn/migration/migrating-to-quarkus#_quarkus_is_not_an_application_server。
因此,用户存储提供程序不能像以前在 WildFly 上运行 Keycloak 时那样,在任何 Jakarta EE 组件中打包或使其成为 EJB。
提供程序实现需要是普通的 Java 对象,这些对象实现合适的用户存储 SPI 接口,如前几节所述。它们必须按照迁移指南中所述进行打包和部署。请参阅 迁移自定义提供程序。
您仍然可以实现您的自定义 UserStorageProvider
类,它能够通过 JPA 实体管理器集成外部数据库,如本示例所示
不支持 CDI。
REST 管理 API
您可以通过管理员 REST API 创建、删除和更新用户存储提供程序部署。用户存储 SPI 构建在通用组件接口之上,因此您将使用该通用 API 来管理您的提供程序。
REST 组件 API 位于您的 realm 管理资源下。
/admin/realms/{realm-name}/components
我们将仅展示使用 Java 客户端的 REST API 交互。希望您可以从此 API 中提取如何从 curl
执行此操作。
public interface ComponentsResource {
@GET
@Produces(MediaType.APPLICATION_JSON)
public List<ComponentRepresentation> query();
@GET
@Produces(MediaType.APPLICATION_JSON)
public List<ComponentRepresentation> query(@QueryParam("parent") String parent);
@GET
@Produces(MediaType.APPLICATION_JSON)
public List<ComponentRepresentation> query(@QueryParam("parent") String parent, @QueryParam("type") String type);
@GET
@Produces(MediaType.APPLICATION_JSON)
public List<ComponentRepresentation> query(@QueryParam("parent") String parent,
@QueryParam("type") String type,
@QueryParam("name") String name);
@POST
@Consumes(MediaType.APPLICATION_JSON)
Response add(ComponentRepresentation rep);
@Path("{id}")
ComponentResource component(@PathParam("id") String id);
}
public interface ComponentResource {
@GET
public ComponentRepresentation toRepresentation();
@PUT
@Consumes(MediaType.APPLICATION_JSON)
public void update(ComponentRepresentation rep);
@DELETE
public void remove();
}
要创建用户存储提供程序,您必须指定提供程序 ID、字符串 org.keycloak.storage.UserStorageProvider
的提供程序类型以及配置。
import org.keycloak.admin.client.Keycloak;
import org.keycloak.representations.idm.RealmRepresentation;
...
Keycloak keycloak = Keycloak.getInstance(
"https://127.0.0.1:8080",
"master",
"admin",
"password",
"admin-cli");
RealmResource realmResource = keycloak.realm("master");
RealmRepresentation realm = realmResource.toRepresentation();
ComponentRepresentation component = new ComponentRepresentation();
component.setName("home");
component.setProviderId("readonly-property-file");
component.setProviderType("org.keycloak.storage.UserStorageProvider");
component.setParentId(realm.getId());
component.setConfig(new MultivaluedHashMap());
component.getConfig().putSingle("path", "~/users.properties");
realmResource.components().add(component);
// retrieve a component
List<ComponentRepresentation> components = realmResource.components().query(realm.getId(),
"org.keycloak.storage.UserStorageProvider",
"home");
component = components.get(0);
// Update a component
component.getConfig().putSingle("path", "~/my-users.properties");
realmResource.components().component(component.getId()).update(component);
// Remove a component
realmREsource.components().component(component.getId()).remove();
从早期的用户联邦 SPI 迁移
如果您使用早期的(现在已删除的)用户联邦 SPI 实现了提供程序,则本章仅适用。 |
在 Keycloak 2.4.0 及更早版本中,有一个用户联邦 SPI。Red Hat Single Sign-On 7.0 版本(虽然不受支持)也提供了这个早期的 SPI。此早期用户联邦 SPI 已从 Keycloak 2.5.0 版本和 Red Hat Single Sign-On 7.1 版本中删除。但是,如果您使用此早期 SPI 编写了提供程序,本章将讨论您可以用来移植它的一些策略。
导入与非导入
早期的用户联邦 SPI 要求您在 Keycloak 的数据库中创建用户的本地副本,并将信息从您的外部存储导入到本地副本。但是,这不再是必需的。您仍然可以按原样移植您早期的提供程序,但您应该考虑非导入策略是否可能是更好的方法。
导入策略的优势
-
Keycloak 基本上成为您外部存储的持久用户缓存。一旦用户被导入,您将不再访问外部存储,从而减轻其负载。
-
如果您正在迁移到 Keycloak 作为您的官方用户存储并弃用早期的外部存储,您可以缓慢地迁移应用程序以使用 Keycloak。当所有应用程序都已迁移后,取消链接导入的用户,并停用早期的旧外部存储。
但是,使用导入策略有一些明显的缺点
-
首次查找用户将需要多次更新 Keycloak 数据库。这可能会导致负载下的巨大性能损失,并给 Keycloak 数据库带来很大压力。用户联邦存储方法只会根据需要存储额外数据,并且可能永远不会使用,具体取决于您的外部存储的功能。
-
使用导入方法,您必须使本地 Keycloak 存储和外部存储保持同步。User Storage SPI 具有您可以实现的用于支持同步的能力接口,但这很快就会变得痛苦和混乱。
UserFederationProvider 与 UserStorageProvider
首先要注意的是,UserFederationProvider
是一个完整的接口。您实现了此接口中的每个方法。但是,UserStorageProvider
反而将此接口分解为多个功能接口,您可以根据需要实现这些接口。
UserFederationProvider.getUserByUsername()
和 getUserByEmail()
在新的 SPI 中具有完全相同的等效项。两者之间的区别在于导入方式。如果您要继续使用导入策略,您不再调用 KeycloakSession.userStorage().addUser()
在本地创建用户。而是调用 KeycloakSession.userLocalStorage().addUser()
。userStorage()
方法不再存在。
UserFederationProvider.validateAndProxy()
方法已移至可选的功能接口 ImportedUserValidation
。如果您要按原样移植您早期的提供程序,则需要实现此接口。另请注意,在早期的 SPI 中,每次访问用户时都会调用此方法,即使本地用户在缓存中也是如此。在后来的 SPI 中,仅当从本地存储加载本地用户时才调用此方法。如果本地用户被缓存,则根本不会调用 ImportedUserValidation.validate()
方法。
UserFederationProvider.isValid()
方法在后来的 SPI 中不再存在。
UserFederationProvider
方法 synchronizeRegistrations()
、registerUser()
和 removeUser()
已移至 UserRegistrationProvider
功能接口。这个新接口是可选实现的,因此如果您的提供程序不支持创建和删除用户,则不必实现它。如果您的早期提供程序有切换开关来切换对注册新用户的支持,则新的 SPI 中支持此功能,如果提供程序不支持添加用户,则从 UserRegistrationProvider.addUser()
返回 null
。
早期 UserFederationProvider
以凭据为中心的方法现在封装在 CredentialInputValidator
和 CredentialInputUpdater
接口中,这些接口也是可选实现的,具体取决于您是否支持验证或更新凭据。凭据管理过去存在于 UserModel
方法中。这些方法也已移至 CredentialInputValidator
和 CredentialInputUpdater
接口。需要注意的一点是,如果您不实现 CredentialInputUpdater
接口,那么您的提供程序提供的任何凭据都可以在 Keycloak 存储中本地覆盖。因此,如果您希望凭据是只读的,请实现 CredentialInputUpdater.updateCredential()
方法并返回 ReadOnlyException
。
UserFederationProvider
查询方法(例如 searchByAttributes()
和 getGroupMembers()
)现在封装在可选接口 UserQueryProvider
中。如果您不实现此接口,则用户将无法在管理控制台中查看。但您仍然可以登录。
UserFederationProviderFactory 与 UserStorageProviderFactory
早期 SPI 中的同步方法现在封装在可选的 ImportSynchronization
接口中。如果您实现了同步逻辑,那么让您的新 UserStorageProviderFactory
实现 ImportSynchronization
接口。
升级到新模型
用户存储 SPI 实例存储在不同的关系表集中。Keycloak 会自动运行迁移脚本。如果为 realm 部署了任何早期的用户联邦提供程序,它们将按原样转换为后来的存储模型,包括数据的 id
。只有当用户存储提供程序具有与早期用户联邦提供程序相同的提供程序 ID(即“ldap”、“kerberos”)时,才会发生此迁移。
因此,了解这一点后,您可以采取不同的方法。
-
您可以删除早期 Keycloak 部署中的早期提供程序。这将删除您导入的所有用户的本地链接副本。然后,当您升级 Keycloak 时,只需为您的 realm 部署和配置您的新提供程序即可。
-
第二种选择是编写您的新提供程序,确保它具有相同的提供程序 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 实现的一个示例。一般来说,以下规则适用
-
为了防止 secret 跨 realm 泄漏,您可能希望隔离或限制 realm 可以检索的 secret。在这种情况下,您的提供程序在查找 secret 时应考虑 realm 名称,例如通过在条目前缀 realm 名称。例如,表达式
${vault.key}
通常会评估为不同的条目名称,具体取决于它是在 realm A 还是 realm B 中使用。为了区分 realm,需要将 realm 传递给从VaultProviderFactory.create()
方法创建的VaultProvider
实例,该实例可以从KeycloakSession
参数中获得。 -
vault 提供程序需要实现一个方法
obtainSecret
,该方法为给定的 secret 名称返回VaultRawSecret
。该类保存 secret 的byte[]
或ByteBuffer
表示形式,并应根据需要在两者之间进行转换。请注意,此缓冲区将在使用后被丢弃,如下所述。
关于 realm 分隔,所有内置的 vault 提供程序工厂都允许配置一个或多个密钥解析器。由 VaultKeyResolver
接口表示,密钥解析器本质上实现了将 realm 名称与密钥(从 ${vault.key}
表达式获得)组合到最终条目名称的算法或策略,该最终条目名称将用于从 vault 检索 secret。处理此配置的代码已提取到抽象 vault 提供程序和 vault 提供程序工厂类中,因此希望提供密钥解析器支持的自定义实现可以扩展这些抽象类,而不是实现 SPI 接口,以继承配置在检索 secret 时应尝试的密钥解析器的能力。
有关如何打包和部署自定义提供程序的详细信息,请参阅服务提供程序接口章节。
从 vault 使用值
vault 包含敏感数据,Keycloak 对 secret 进行相应处理。访问 secret 时,会从 vault 获取 secret,并仅在必要的时间内将其保留在 JVM 内存中。然后会尽一切可能尝试从 JVM 内存中丢弃其内容。这是通过仅在 try
-with-resources 语句中使用 vault secret 来实现的,如下所述
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()
作为访问 secret 的入口点。直接使用 VaultProvider.obtainSecret
方法实际上也是可能的。但是,vault()
方法的好处是除了获取原始未解释的值(通过 vault().getRawSecret()
方法)之外,还能够将原始 secret(通常是字节数组)解释为字符数组(通过 vault().getCharSecret()
)或 String
(通过 vault().getStringSecret()
)。
请注意,由于 String
对象是不可变的,因此无法通过用随机垃圾覆盖来丢弃其内容。即使在默认的 VaultStringSecret
实现中已采取措施来防止内部化 String
,存储在 String
对象中的 secret 也至少会存活到下一个 GC 轮次。因此,使用纯字节和字符数组以及缓冲区是更可取的。