Keycloak提供了一系列不同的认证机制:kerberos、密码、otp等。这些机制可能不适合你的需求,而你希望实现自定义的机制。keycloak提供了认证SPI帮助用户自定义插件。并且用户可以在控制台应用、排序和配置这些新的机制。
keycloak也支持简单的注册表单。表单的各个项目都可以启用或禁用。相同的认证SPI可以向注册流添加一个新的页面或完全重新实现。Keycloak中也有其他细粒度的SPI可以用于添加特殊认证或拓展注册表单中的用户属性。
在keycloak中必须操作是指用户完成认证后必须执行的操作。这种操作只需要成功执行一次。keycloak有一些内置的操作,比如重置密码。你也可以定义自己的必须操作。
术语
- 认证流程
流程是指在认证或登录期间必须完成的所有事件。如果你查看管理控制台的认证页,可以看到系统定义的所有流程,以这些流程使用的认证器。流程可以包含别的流程。 - 认证器
认证器是一个可插拔的组件,其中包含了认证的逻辑以及操作,通常是单例。 - 执行器
执行器是一个对象,可以把认证器和认证流程绑定,并把认证器的配置绑定给认证器。认证流程包含完整的执行器。 - 执行条件
执行器定义了认证器在认证流程中的行为。执行条件定义了认证器是启用的、禁用的、有条件的、必需的还是替代的。可选条件是指,认证器足以认证其所在的流,因此执行条件不是必需的。例如,在内置浏览器认证流中,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以完成用户认证时的操作。
- OIDC或SAML协议提供解包相关数据,用于认证客户端以及其他签名。keycloak会创建认证会话模型(AuthenticationSessionModel),查询采用哪一种浏览器认证流程,然后开始执行该流程。
- 流程查看cookie认证执行器,并发现它是一种可选方法。于是认证流程加载cookie提供程序,流程检查cookie提供程序是否要求用户已经与身份认证会话关联。Cookie提供程序不需要用户对象。如果它这样做了,认证流程将中止,用户将看到一个错误响应。然后认证流程执行Cookie提供程序。其目的是查看是否存在SSO cookie集合。如果有一个集合,则对其进行验证,并验证UserSessionModel,并将其与AuthenticationSessionModel关联。如果SSO Cookie集合存在并通过认证,Cookie提供程序将返回
success()
状态。由于cookie提供程序返回了成功,并且此认证流程中其他的执行器都是可选的,因此不会启用其他执行器,这时用户成功登录。如果没有SSO cookie集合,cookie提供程序返回的状态为attempted()
。这意味着没有出现错误情况,但也没有成功认证,程序尝试了认证,但请求没有设置为适配这个认证方式。 - 接着认证流程检查Kerberos执行器。Kerberos同样是可选执行器,同样不需要创建好的用户对象,也不需要和现成的AuthenticationSessionModel关联,因此这个认证提供程序会被执行。Kerberos使用SPNEGO浏览器协议。这个协议需要服务器和客户端之间的一系列质询与响应以交换协商头。Kerberos认证程序看不到协商头。所以假定当前是服务器与客户端第一次交互。因此认证程序会创建对客户端的质询响应,并设置自己为
forceChallenge()
状态。forceChallenge()
状态表示HTTP响应不能被流程忽略,必须返回给客户端。如果认证程序返回challenge()
状态,那么流程可以保存质询响应,知道其他认证程序都切换成attempted
状态。因此,在初始状态下,流程会停止并返回质询响应给客户端。如果客户端返回相应的协商头,那么认证程序会把用户和AuthenticationSession
关联起来,由于流程中剩余的认证程序都是可选的,所以认证流程会结束。反之,Kerberos认证程序会把自己设置为attempted()
状态,而流程会继续执行。 - 接下来的认证执行器是表单子流程,子流程执行器会被加载并执行和上面一样的流程。
- 表单子流程的第一个执行器是用户名/密码认证程序。这个程序不需要用户和流程关联,并会返回一个质询HTTP响应,同时设置自己为
challenge()
状态。此执行器是必需的,因此认证流程会接受质询并将HTTP响应发送回浏览器。响应会渲染一份包含用户名、密码输入表单的html页面。用户输入信息并提交后,HTTP请求会把用户名、密码发送给认证程序。如果用户输入的数据错误,程序会生成新的表单响应,并把状态设置为failureChallenge()
。这表示用户正在接受认证质询,但是流程中需要记录错误日志。当认证失败次数过多,可以基于日志锁定账户或IP地址。如果用户提交的数据正确,认证程序会把用户模型和认证会话模型关联,并返回success()
状态。 - 子流程的后续执行器是可选OTP。这个执行器的加载与执行和之前的执行器一样。这个执行器是有前提条件的,因此认证流程会先评估其包含的所有执行条件。可选执行器是实现了
ConditionalAuthenticator
的认证器,同时必须实现boolean matchCondition(AuthenticationFlowContext context)
方法。条件执行流程会调用条件执行器包含的所有matchCondition
方法,如果这些条件都评估为true
,这个条件执行器会被当做必须执行器执行。如果没有全部响应为true
,会被视为禁用的子流程。条件认证器仅用于此目的,不用作认证器。这意味着,即使条件认证器的计算结果为“true”,也不会将认证流程或子流程标记为成功。例如,仅包含条件子流程且仅包含条件认证器的流程将永远不允许用户登录。 - 条件OTP子流程的第一个执行器是
User Configured
,这个程序要求用户和认证流程关联。因为用户名、密码认证程序已经把用户和认证流程关联,所以这个条件是满足的。程序的matchCondition
方法会评估当前子流程中所有其他认证器的configuredFor
方法。如果子流程包含的Requirement
设置为required的执行器,那么只有当所有设置为required
的执行器的configuredFor
方法评估为true
时,matchCondition
方法才会返回true。否则,任务认证器返回true
时,matchCondition
就会返回true
。 - 下一个认证程序时OTP表单,它同样需要用户和认证流程绑定。因为用户名、密码认证程序已经绑定用户,所以这个条件满足。因为这个程序需要用户,因此程序需要用户配置启用。如果用户没有配置,那么这个流程会在用户完成登录后设置一个必须操作。对于OTP而言,这意味着OTP设置页。如果用户配置启用这个认证器,那么用户需要输入OTP码。在我们的场景中,因为这时子流程,除非OTP子流程被设置为必须,否则用户看不到OTP登录页。
- 认证流程完成后,认证处理器会创建用户会话模型并将其和认证会话模型关联。接着会检查用户登录前是否需要完成必要操作。
- 首先,会调用每个必须操作的
evaluateTriggers()
方法。改方法使所需的操作提供程序确定是否存在可能触发操作的某些状态。比如,域中配置了密码过期策略,那么可以通过这个方法触发。 - 每一个和用户有关的必须操作提供程序的
requiredActionChallenge()
方法会被调用。这时操作程序会返回可以渲染执行操作页面的HTTP响应。通过设置challenge
状态完成此操作。 - 当必须操作完成后,必须操作会从用户操作清单上移除。
- 当所有操作都完成后,用户成功登录。
认证服务提供接口介绍
要创建一个认证器,必须至少实现org.ekycloak.authentication.AuthenticatorFactory
和Authenticator
接口。Authenticator
中定义认证逻辑,而AuthenticatorFactory
负责创建Authenticator
实例。它们都扩展了一组更通用的认证程序和认证程序工厂(ProviderFactory)接口,其他Keycloak组件(如用户联合)也是采用相同的方式实现的。
有些认证器,像CookieAuthentor,并不依赖于用户的凭证。而有些认证器,比如密码表单或OTP表单认证器则依赖于用户输入的信息并需要和数据库中的信息做验证。以密码表单为例,认证器会校验密码的hash值并和数据库中的记录做比对,而OTP表单认证器会将收到的OTP和从存储在数据库中的共享密钥生成的值作比对。
这些认证器称为凭证校验器,实现这类认证器需要实现下面这些类:
- 继承
org.keycloak.credential.CredentialModel
的类,这个类需要生成数据库中正确的凭证格式。 - 继承
org.keycloak.credential.CredentialProvider
接口的类,这个类需要实现CredentialProviderFactory
工厂接口。
在本章节中我们会介绍一个名为SecretQuestionAuthenticato
的凭证校验器。
类的打包与部属
你需要把实现的类打包在一个jar文件中。这个jar文件必须包含名为org.keycloak.authentication.AuthenticatorFactory
的文件并且必须包含META-INF/services
路径。这个文件必须列出jar中每个AuthenticatorFactory实现的完全限定类名。比如:
org.keycloak.examples.authenticator.SecretQuestionAuthenticatorFactory
org.keycloak.examples.authenticator.AnotherProviderFactory
keycloak这个services/
文件扫描并加载认证程序。
把jar文件复制到程序路径即可完成部署。
拓展CredentialModel类
在keycloak中,凭证存在数据库的Credential
表中,包含以下结构:
-----------------------------
| 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接口共享priority
定义如何用户对凭证的偏好,用于决定如果呈现用户的多种选择
因为secret_data
和credential_data
包含json数据,你可以自定义如何构建、读取和写入这些数据,提高了灵活性。
比如,我们打算使用一套简单的凭证数据,仅包含一下问题:
{
"question":"aQuestion"
}
使用同样简单的加密数据,仅包含加密答案:
{
"answer":"anAnswer"
}
尽管问题使用让人震惊的纯文本格式存在数据库中,但是问题的答案可以使用hash值存储,就像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
。为了一致性,我们确保在获取此凭据的类型时,此字符串始终是引用的字符串。SecretQuestionCredentailData
类以及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