0


Keycloak服务开发-认证服务SPI

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以完成用户认证时的操作。

  1. OIDC或SAML协议提供解包相关数据,用于认证客户端以及其他签名。keycloak会创建认证会话模型(AuthenticationSessionModel),查询采用哪一种浏览器认证流程,然后开始执行该流程。
  2. 流程查看cookie认证执行器,并发现它是一种可选方法。于是认证流程加载cookie提供程序,流程检查cookie提供程序是否要求用户已经与身份认证会话关联。Cookie提供程序不需要用户对象。如果它这样做了,认证流程将中止,用户将看到一个错误响应。然后认证流程执行Cookie提供程序。其目的是查看是否存在SSO cookie集合。如果有一个集合,则对其进行验证,并验证UserSessionModel,并将其与AuthenticationSessionModel关联。如果SSO Cookie集合存在并通过认证,Cookie提供程序将返回success()状态。由于cookie提供程序返回了成功,并且此认证流程中其他的执行器都是可选的,因此不会启用其他执行器,这时用户成功登录。如果没有SSO cookie集合,cookie提供程序返回的状态为attempted()。这意味着没有出现错误情况,但也没有成功认证,程序尝试了认证,但请求没有设置为适配这个认证方式。
  3. 接着认证流程检查Kerberos执行器。Kerberos同样是可选执行器,同样不需要创建好的用户对象,也不需要和现成的AuthenticationSessionModel关联,因此这个认证提供程序会被执行。Kerberos使用SPNEGO浏览器协议。这个协议需要服务器和客户端之间的一系列质询与响应以交换协商头。Kerberos认证程序看不到协商头。所以假定当前是服务器与客户端第一次交互。因此认证程序会创建对客户端的质询响应,并设置自己为forceChallenge()状态。forceChallenge()状态表示HTTP响应不能被流程忽略,必须返回给客户端。如果认证程序返回challenge()状态,那么流程可以保存质询响应,知道其他认证程序都切换成attempted状态。因此,在初始状态下,流程会停止并返回质询响应给客户端。如果客户端返回相应的协商头,那么认证程序会把用户和AuthenticationSession关联起来,由于流程中剩余的认证程序都是可选的,所以认证流程会结束。反之,Kerberos认证程序会把自己设置为attempted()状态,而流程会继续执行。
  4. 接下来的认证执行器是表单子流程,子流程执行器会被加载并执行和上面一样的流程。
  5. 表单子流程的第一个执行器是用户名/密码认证程序。这个程序不需要用户和流程关联,并会返回一个质询HTTP响应,同时设置自己为challenge()状态。此执行器是必需的,因此认证流程会接受质询并将HTTP响应发送回浏览器。响应会渲染一份包含用户名、密码输入表单的html页面。用户输入信息并提交后,HTTP请求会把用户名、密码发送给认证程序。如果用户输入的数据错误,程序会生成新的表单响应,并把状态设置为failureChallenge()。这表示用户正在接受认证质询,但是流程中需要记录错误日志。当认证失败次数过多,可以基于日志锁定账户或IP地址。如果用户提交的数据正确,认证程序会把用户模型和认证会话模型关联,并返回success()状态。
  6. 子流程的后续执行器是可选OTP。这个执行器的加载与执行和之前的执行器一样。这个执行器是有前提条件的,因此认证流程会先评估其包含的所有执行条件。可选执行器是实现了ConditionalAuthenticator的认证器,同时必须实现boolean matchCondition(AuthenticationFlowContext context)方法。条件执行流程会调用条件执行器包含的所有matchCondition方法,如果这些条件都评估为true,这个条件执行器会被当做必须执行器执行。如果没有全部响应为true,会被视为禁用的子流程。条件认证器仅用于此目的,不用作认证器。这意味着,即使条件认证器的计算结果为“true”,也不会将认证流程或子流程标记为成功。例如,仅包含条件子流程且仅包含条件认证器的流程将永远不允许用户登录。
  7. 条件OTP子流程的第一个执行器是User Configured,这个程序要求用户和认证流程关联。因为用户名、密码认证程序已经把用户和认证流程关联,所以这个条件是满足的。程序的matchCondition方法会评估当前子流程中所有其他认证器的configuredFor方法。如果子流程包含的Requirement设置为required的执行器,那么只有当所有设置为required的执行器的configuredFor方法评估为true时,matchCondition方法才会返回true。否则,任务认证器返回true时,matchCondition就会返回true
  8. 下一个认证程序时OTP表单,它同样需要用户和认证流程绑定。因为用户名、密码认证程序已经绑定用户,所以这个条件满足。因为这个程序需要用户,因此程序需要用户配置启用。如果用户没有配置,那么这个流程会在用户完成登录后设置一个必须操作。对于OTP而言,这意味着OTP设置页。如果用户配置启用这个认证器,那么用户需要输入OTP码。在我们的场景中,因为这时子流程,除非OTP子流程被设置为必须,否则用户看不到OTP登录页。
  9. 认证流程完成后,认证处理器会创建用户会话模型并将其和认证会话模型关联。接着会检查用户登录前是否需要完成必要操作。
  10. 首先,会调用每个必须操作的evaluateTriggers()方法。改方法使所需的操作提供程序确定是否存在可能触发操作的某些状态。比如,域中配置了密码过期策略,那么可以通过这个方法触发。
  11. 每一个和用户有关的必须操作提供程序的requiredActionChallenge()方法会被调用。这时操作程序会返回可以渲染执行操作页面的HTTP响应。通过设置challenge状态完成此操作。
  12. 当必须操作完成后,必须操作会从用户操作清单上移除。
  13. 当所有操作都完成后,用户成功登录。

认证服务提供接口介绍

要创建一个认证器,必须至少实现

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.SecretQuestionAuthenticatorFactoryorg.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

类:

publicclassSecretQuestionCredentialModelextendsCredentialModel{publicstaticfinalString TYPE ="SECRET_QUESTION";privatefinalSecretQuestionCredentialData credentialData;privatefinalSecretQuestionSecretData secretData;

其中

TYPE

是写入数据库中的

credential_type

。为了一致性,我们确保在获取此凭据的类型时,此字符串始终是引用的字符串。

SecretQuestionCredentailData

类以及

SecretQuestionSecretData

类用于序列化和反序列化json:

publicclassSecretQuestionCredentialData{privatefinalString question;@JsonCreatorpublicSecretQuestionCredentialData(@JsonProperty("question")String question){this.question = question;}publicStringgetQuestion(){return question;}}
publicclassSecretQuestionSecretData{privatefinalString answer;@JsonCreatorpublicSecretQuestionSecretData(@JsonProperty("answer")String answer){this.answer = answer;}publicStringgetAnswer(){return answer;}}

为了适用性,

SecretQuestionCredentialModel

对象的属性中必须包含从父类继承的原始的json数据以及反序列化之后的对象。这导致我们创建了一个从简单的CredentialModel读取的方法,例如从数据库读取数据创建的SecretQuestionCredentialModel:

privateSecretQuestionCredentialModel(SecretQuestionCredentialData credentialData,SecretQuestionSecretData secretData){this.credentialData = credentialData;this.secretData = secretData;}publicstaticSecretQuestionCredentialModelcreateFromCredentialModel(CredentialModel credentialModel){try{SecretQuestionCredentialData credentialData =JsonSerialization.readValue(credentialModel.getCredentialData(),SecretQuestionCredentialData.class);SecretQuestionSecretData secretData =JsonSerialization.readValue(credentialModel.getSecretData(),SecretQuestionSecretData.class);SecretQuestionCredentialModel secretQuestionCredentialModel =newSecretQuestionCredentialModel(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){thrownewRuntimeException(e);}}

以及通过问题和答案创建

SecretQuestionCredentialModel

的方法:

privateSecretQuestionCredentialModel(String question,String answer){
    credentialData =newSecretQuestionCredentialData(question);
    secretData =newSecretQuestionSecretData(answer);}publicstaticSecretQuestionCredentialModelcreateSecretQuestion(String question,String answer){SecretQuestionCredentialModel credentialModel =newSecretQuestionCredentialModel(question, answer);
    credentialModel.fillCredentialModelFields();return credentialModel;}privatevoidfillCredentialModelFields(){try{setCredentialData(JsonSerialization.writeValueAsString(credentialData));setSecretData(JsonSerialization.writeValueAsString(secretData));setType(TYPE);setCreatedDate(Time.currentTimeMillis());}catch(IOException e){thrownewRuntimeException(e);}}

实现CredentialProvider

和别的认证程序一样,我们需要实现

CredentialProviderFacrtory

方法用于生成

CredentialProvider

。因此我们需要创建

SecretCredentialProviderFactory

类,当需要

SecretQuestionCredentialProvider

时可以调用它的

create

方法:

publicclassSecretQuestionCredentialProviderFactoryimplementsCredentialProviderFactory<SecretQuestionCredentialProvider>{publicstaticfinalString PROVIDER_ID ="secret-question";@OverridepublicStringgetId(){return PROVIDER_ID;}@OverridepublicCredentialProvidercreate(KeycloakSession session){returnnewSecretQuestionCredentialProvider(session);}}
CredentialProvider

接口接受扩展CredentialModel的泛型参数。这里我们使用我们创建的

SecretQuestionCredentialModel

publicclassSecretQuestionCredentialProviderimplementsCredentialProvider<SecretQuestionCredentialModel>,CredentialInputValidator{privatestaticfinalLogger logger =Logger.getLogger(SecretQuestionCredentialProvider.class);protectedKeycloakSession session;publicSecretQuestionCredentialProvider(KeycloakSession session){this.session = session;}privateUserCredentialStoregetCredentialStore(){return session.userCredentialManager();}

同时,我们需要实现

CredentialInputValidator

接口,这样keycloak就会知道这个程序可以用于校验认证器的凭证。实现

CredentialProvider

接口首先需要实现

getType()

方法,这个方法只需要返回

SecretQuestionCredentialModels

的TYPE属性字符串:

@OverridepublicStringgetType(){returnSecretQuestionCredentialModel.TYPE;}

第二个方法需要从

CredentialModel

中创建

SecretQuestionCredentialModel

实例。我们只需要调用已有的静态方法即可:

@OverridepublicSecretQuestionCredentialModelgetCredentialFromModel(CredentialModel model){returnSecretQuestionCredentialModel.createFromCredentialModel(model);}

最终我们需要创建和删除凭证的方法,这些方法会调用

KeycloakSession

userCredentialManager

对象,这个对象知道如何读取或编辑凭证,比如通过本地存储或联合存储

@OverridepublicCredentialModelcreateCredential(RealmModel realm,UserModel user,SecretQuestionCredentialModel credentialModel){if(credentialModel.getCreatedDate()==null){
        credentialModel.setCreatedDate(Time.currentTimeMillis());}returngetCredentialStore().createCredential(realm, user, credentialModel);}@OverridepublicbooleandeleteCredential(RealmModel realm,UserModel user,String credentialId){returngetCredentialStore().removeStoredCredential(realm, user, credentialId);}

实现

CredentialInputValidator

接口首先需要实现

isValid

方法,这个方法检测指定域下的指定用户的凭证是否有效。认证器需要校验用户输入数据时调用此方法。这里我们只需要简单地检查输入的字符串是否是凭证中的记录:

@OverridepublicbooleanisValid(RealmModel realm,UserModel user,CredentialInput input){if(!(input instanceofUserCredentialModel)){
        logger.debug("Expected instance of UserCredentialModel for CredentialInput");returnfalse;}if(!input.getType().equals(getType())){returnfalse;}String challengeResponse = input.getChallengeResponse();if(challengeResponse ==null){returnfalse;}CredentialModel credentialModel =getCredentialStore().getStoredCredentialById(realm, user, input.getCredentialId());SecretQuestionCredentialModel sqcm =getCredentialFromModel(credentialModel);return sqcm.getSecretQuestionSecretData().getAnswer().equals(challengeResponse);}

另外两个需要实现的方法分别用于检测

CredentialProvider

是否支持给定的凭证类型以及用户是否配置了该凭证类型。这里对于后一种检测我们只需要检查用户有没有

SECRET_QUESTION

类型的凭证:

@OverridepublicbooleansupportsCredentialType(String credentialType){returngetType().equals(credentialType);}@OverridepublicbooleanisConfiguredFor(RealmModel realm,UserModel user,String credentialType){if(!supportsCredentialType(credentialType))returnfalse;return!getCredentialStore().getStoredCredentialsByType(realm, user, credentialType).isEmpty();}

实现认证器

当实现使用凭证认证用户的认证器时,你需要有一个实现

CredentialValidator

接口的认证器。这个接口接受一个继承

CredentialProvider

的类作为参数,并且会允许keycloak在CredentialProvider中直接调用其方法。唯一需要实现的方法是

getCredentialProvider

。在我们的例子中,

SecretQuestionAuthenticator

使用此方法获取

SecretQuestionProvider

publicSecretQuestionCredentialProvidergetCredentialProvider(KeycloakSession session){return(SecretQuestionCredentialProvider)session.getProvider(CredentialProvider.class,SecretQuestionCredentialProviderFactory.PROVIDER_ID);}

当实现

Authentiactor

接口时,首先要实现的方法是

requiresUser()

方法。在我们的例子中,这个方法必须返回true,因为我们需要校验用户的密钥问题。像kerberos那样的认证器会返回false,因为它可以通过协商头数据解析用户身份。本例中校验指定用户的指定凭证。
另一个要实现的方法是

configuredFor()

方法。这个方法用于判断用户是否配置特定的认证器。在我们的例子中,我们只需要调用在

SecretQuestionCredentialProvider

实现的方法:

@OverridepublicbooleanconfiguredFor(KeycloakSession session,RealmModel realm,UserModel user){returngetCredentialProvider(session).isConfiguredFor(realm, user,getType(session));}

下一个要实现的认证器方法是

setRequiredActions()

。如果

configuredFor()

返回false,并且认证流程中需要我们的验证器,并且仅当关联的

AuthenticatorFactory

isUserSetupAllowed

方法返回true时,则将调用此方法。

setRequiredActions()

方法负责注册必须由用户完成的操作。在我们的例子中,我们注册一个用户必须设置问题答案的操作。这个操作会在收到实现。首先我们先实现

setRequiredActions()

方法:

@OverridepublicvoidsetRequiredActions(KeycloakSession session,RealmModel realm,UserModel user){
        user.addRequiredAction("SECRET_QUESTION_CONFIG");}

现在我们可以实现认证器的核心内容。下一个要实现的方法是

authenticate()

,这是认证流程在第一次访问执行时调用的初始方法。我们希望如果用户响应的答案已经在浏览器的机器上,那么用户不需要再次回答问题,而是把该机器设置为受信任的机器。

authenticate

方法并不处理问题表达,其主要目的是渲染页面以及继续认证流程:

@Overridepublicvoidauthenticate(AuthenticationFlowContext context){if(hasCookie(context)){
        context.success();return;}Response challenge = context.form().createForm("secret-question.ftl");
    context.challenge(challenge);}protectedbooleanhasCookie(AuthenticationFlowContext context){Cookie cookie = context.getHttpRequest().getHttpHeaders().getCookies().get("SECRET_QUESTION_ANSWERED");boolean result = cookie !=null;if(result){System.out.println("Bypassing secret question because cookie is set");}return result;}
hasCookie()

方法检查当前使用的浏览器中是否包含有效的cookie,如果有则表明问题已经被回答过。如果方法返回true,我们只需要使用

AuthenticationFlowContext.success()

方法把执行器的状态设置为

SUCCESS

,并且从

authentication()

方法返回。
如果

hasCoolie()

方法返回false,那么我们需要返回渲染问题表单的响应。

AuthenticationFlowContext

提供

form()

方法用于初始化一个

Freemarker

页面构造器,该构造器拥有构建表单所需要的基础信息。这个构造器称为

org.keycloak.login.LoginFormsProvider

LoginFormsProvider.createForm()

方法会从登录主题中加载Freemarker模板文件。如果你还想通过Freemarker模板传递额外的信息,那么可以使用

LoginFormsProvider.setAttribute()

方法。
调用

LoginFormsProvider.createForm()

方法会返回

JAX-RS

响应对象。接着我们可以调用

AuthenticationFlowContext.challenge()

传递这个对象。这回把执行器的状态设置为

CHALLENGE

,而且如果执行器是必须的,那么JAX-RS响应对象会被发送给浏览器。
因此,需要用户输入答案的HTML页面会被呈现给用户。当用户输入并提交后,HTML表单的的action URL会发送一个HTTP请求给认证流程。认证流程会结束与我们实现的认证器的

action()

方法的交互。

@Overridepublicvoidaction(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();}

如果答案不正确,我们会重新构建表单并且展示错误信息。我们可以调用

AuthenticationFlowContext.failureChallenge()

传递原因和JAX-RS响应。

failuerChallenge()

方法和

challenge()

方法一样,但是会记录失败事件用于攻击分析服务。
如果检验成功,那么我们设置cookie,记录问题已经回答过,接着我们调用

AuthenticationFlowContext.success()

方法。
校验会受到表单传入的数据,并在

SecretQuestionCredentialProvider

中调用

isValid

方法。你会注意到,代码中有一部分与获取凭据Id有关。这是因为如果将Keycloak配置为允许多种类型的替代身份验证器,或者,如果用户可以记录SECRET_QUESTION类型的多个凭据(例如,如果我们允许从多个问题中进行选择,并且我们允许用户对这些问题中的多个问题进行回答),那么Keycloak需要知道使用哪个凭据记录用户。为了防止有超过单个凭证,keycloak允许用户选择使用哪个凭证,表单会把信息传递给认证器。如果表单没有显示此信息,则使用的凭据id由CredentialProvider的默认

getDefaultCredential

方法提供,该方法将返回用户首选的正确类型的凭据。

protectedbooleanvalidateAnswer(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 =newUserCredentialModel(credentialId,getType(context.getSession()), secret);returngetCredentialProvider(context.getSession()).isValid(context.getRealm(), context.getUser(), input);}

下一个方式是

setCookie()

,这是为验证器提供配置的示例。在这种情况下,我们希望cookie的最大存活时间可以配置:

protectedvoidsetCookie(AuthenticationFlowContext context){AuthenticatorConfigModel config = context.getAuthenticatorConfig();int maxCookieAge =60*60*24*30;// 30 daysif(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);}
SecretQuestionCredentialProvider

类中最后要实现的一个方法是

getCredentialTypeMetadata(CredentialTypeMetadataContext metadataContext)

,这是

CredentialProvider

接口中的抽象方法。每个凭证提供程序都需要实现这个方法。这个方法返回

CredentialTypeMetadata

实例,至少需要包类型、认证其类别、展示名称以及可移除项目。在本例中,构建器从

getType()

方法接受认证器类型,类别是双因素(认证器可以用作另一种认证因素),而可移除属性设置为false(用户不能移除已经注册的凭证)。
构建器还包括帮助文档(在不同的页面展示给用户)、创建操作(需要操作的providerID,用户可以使用这个id创建新的凭证)或更新操作(和创建操作一样,但是不创建而是更新凭证)。

实现AuthenticatorFactory

接下来需要实现一个

AuthenticatorFactory

。工厂负责创建认证器实例。同时提供认证器的部署与配置元数据。

getId()

方法返回组件的唯一名称。

create()

方法被运行时调用,用于分配和处理认证器。

publicclassSecretQuestionAuthenticatorFactoryimplementsAuthenticatorFactory,ConfigurableAuthenticatorFactory{publicstaticfinalString PROVIDER_ID ="secret-question-authenticator";privatestaticfinalSecretQuestionAuthenticator SINGLETON =newSecretQuestionAuthenticator();@OverridepublicStringgetId(){return PROVIDER_ID;}@OverridepublicAuthenticatorcreate(KeycloakSession session){return SINGLETON;}

接下来工厂需要指定允许要求的开关。要求有四种:

ALTERNATIVE

REQUIRED

CONDITIONAL

DISABLED

AuthenticatorFactory

实现可以限制在管理控制台定义流程时展示的要求选项。子流程必须使用

CONDITIONAL

,而认证器的需求应该是

REQUIRED

CONDITIONAL

DISABLED

中任意一种,除非有特殊需求:

privatestaticAuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES ={AuthenticationExecutionModel.Requirement.REQUIRED,AuthenticationExecutionModel.Requirement.ALTERNATIVE,AuthenticationExecutionModel.Requirement.DISABLED
    };@OverridepublicAuthenticationExecutionModel.Requirement[]getRequirementChoices(){return REQUIREMENT_CHOICES;}
AuthenticatorFactory.isUserSetupAllowed()

是一个标识,可以通知认证流程管理器是否需要调用

Authenticator.setRequiredActions()

方法。如果某个认证器没有给用户配置,流程管理器会检查

isUserSetupAllowed()

,如果结果是false,那么会流程会抛出异常并终止,如果返回true,那么流程管理起会调用

Authenticator.setRequiredActions()

@OverridepublicbooleanisUserSetupAllowed(){returntrue;}

之后的一些方法定义了如何配置认证器。

isConfigurable()

方法是一个标识,告诉管理控制台认证器是否可以在认证流程中配置。

getConfigProperties()

方法返回

ProviderConfigProperty

对象数组。这些对象定义不同的配置属性。

@OverridepublicList<ProviderConfigProperty>getConfigProperties(){return configProperties;}privatestaticfinalList<ProviderConfigProperty> configProperties =newArrayList<ProviderConfigProperty>();static{ProviderConfigProperty property;
        property =newProviderConfigProperty();
        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

对象都定义了配置的名称(name)。这是

AuthenticatorConfigModel

中存储的配置表的键。标签(label)定义了配置项在管理控制台中如何展示。类型(type)定义了配置项是字符串、布尔值或是其他类型。管理控制台会根据不同的类型展示不同的UI输入组件。帮助文档(help text)会在管理控制台的配置属性的工具提示中展示。
其他方法都是和管理控制台有关。

getHelpText()

是在选择要绑定到执行的认证器时显示的工具提示文本。

getDisplayType()

是在监听认证器时 管理控制台展示的文本。

getReferenceCategory()

标记认证器属于的类别。

添加认证器表单

keycloak包含一个Freemarker的主题与模板引擎。在Authenticator类的

authenticate()

方法中调用的

createForm()

方法会通过登陆主题

secret-question.ftl

文件构建一个HTML页面。这个文件需要添加到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 files)相关。如果你使用英语,你可以添加

loginSecretQuestion

的值,添加的值是展示给用户的问题。
当调用

AuthenticationFlowContext.form()

时可以得到

LoginFormProvider

实例。如果调用

LoginFormProvider.setAttribute("foo", "bar")

,那么在表单中可以通过

${foo}

获得foo的值。属性的值可以是任意java bean对象。
如果你查看文件顶部,可以看到我们引入这样的模板:

<#import"select.ftl" as layout>

引入这个模板取代标准的

template.ftl

可以让keycloak展示下拉框以供用户选择不同的凭证。

把认证器添加到认证流程中

向认证流程添加认证器必须在管理控制台中完成。如果转到“Authentication”菜单项并转到“Flow”选项卡,你将能够查看当前定义的认证流程。内置的认证流程不能更新,因此,要添加我们创建的认证器,必须复制现有流程或新建自己的流程。Keycloak希望用户界面足够清晰,以便你可以确定如何创建流程和添加验证器。
创建流程后,必须将其绑定到登录操作。如果转到“Authentication”菜单并转到“Bindings”选项卡,你将看到将流程绑定到浏览器、注册或直接授权流程的选项。

必须操作介绍

本节讨论如何定义必须操作。在认证器的介绍中,你可能会疑惑:“我们将如何获得用户对输入系统的认证问题的答案”。在实例中,如果用户没有设置答案,会触发一个必须操作。本节展示如何实现认证问题认证器的必须操作。

类的打包与部署

类需要打包成单独的jar文件。这个jar文件不需要和其他程序类分开,但是里面必须要有名为

org.keycloak.authentication.RequiredActionFactory

的文件并且必须包含在

META-INF/services

路径下。文件中必须列出每个实现

RequiredActionFactory

的玩权限等类名,比如:

org.keycloak.examples.authenticator.SecretQuestionRequiredActionFactory
services/

文件用于扫描keycloak需要加载到系统中的程序。
要部署jar包,把jar包复制到

providers/

路径下,然后运行

bin/kc.[sh/bat] build

实现

RequiredActionProvider

定义必须操作首先要实现

RequiredActionProvider

接口。认证流程管理器在启用必须操作时会首先调用

RequiredActionProvider.requiredActionChallenge()

,这个方法用于渲染HTML表单。

@OverridepublicvoidrequiredActionChallenge(RequiredActionContext context){Response challenge = context.form().createForm("secret_question_config.ftl");
        context.challenge(challenge);}

可以看到

RequiredActionContext

AuthenticationFlowContext

有相同的方法。

form()

方法用于从Freemarker模板渲染页面。action URL是通过调用此form()方法预设的。你只需要在HTML表单中引用它。我稍后会向你展示。

challenge()

方法通知流程管理器必须操作必须被执行。
下一个方法用于处理来必须需操作的HTML表单的输入,action URL会被路由给

RequiredActionProvider.processAction()

方法

@OverridepublicvoidprocessAction(RequiredActionContext context){String answer =(context.getHttpRequest().getDecodedFormParameters().getFirst("answer"));UserCredentialValueModel model =newUserCredentialValueModel();
        model.setValue(answer);
        model.setType(SecretQuestionAuthenticator.CREDENTIAL_TYPE);
        context.getUser().updateCredentialDirectly(model);
        context.success();}

从post表单中提取答案,创建一个UserCredentialValueModel并且设置type和value的值。接着调用

UserModel.updateCredentialDirectly()

。最后调用

RequiredActionContext.success()

,通知容器必要操作已经成功完成。

实现

RequiredActionFactory

这个类很简单。用于创建必须操作程序实例。

publicclassSecretQuestionRequiredActionFactoryimplementsRequiredActionFactory{privatestaticfinalSecretQuestionRequiredAction SINGLETON =newSecretQuestionRequiredAction();@OverridepublicRequiredActionProvidercreate(KeycloakSession session){return SINGLETON;}@OverridepublicStringgetId(){returnSecretQuestionRequiredAction.PROVIDER_ID;}@OverridepublicStringgetDisplayText(){return"Secret Question";}

其中

getDisplayName()

方法仅用于在管理控制台展示用于友好的名称。

启用必要操作

最后要做的一件事是进入管理控制台。单击认证

Authentication

菜单。单击

Required Actions

选项卡。单击

Register

按钮并选择新的

Required Action

。新的必须操作现在应显示并在必须操作列表中并且已经被启用。

更新和拓展注册表单

keycloak允许你自定义一组认证器并完全替换keycloak的注册流程。但是,通常只需要在现成的注册页面中添加一点验证。有一个专门的SPI可以用于此目的。它基本上允许你在页面上添加表单元素的验证,以及在用户注册后初始化UserModel的属性和数据。我们将展示用户配置文件注册处理的实现以及注册Google Recaptcha插件。

实现

FormAction

接口

需要实现的核心接口是

FormAction

FormAction

用于渲染和处理一部分页面。

buildPage()

方法中会完成渲染,

validate()

方法会完成验证,

success()

方法会完成验证后的操作。我们首先查看Recaptcha插件的

buildPage()

方法:

@OverridepublicvoidbuildPage(FormContext context,LoginFormsProvider form){AuthenticatorConfigModel captchaConfig = context.getAuthenticatorConfig();if(captchaConfig ==null|| captchaConfig.getConfig()==null|| captchaConfig.getConfig().get(SITE_KEY)==null|| captchaConfig.getConfig().get(SITE_SECRET)==null){
            form.addError(newFormMessage(null,Messages.RECAPTCHA_NOT_CONFIGURED));return;}String siteKey = captchaConfig.getConfig().get(SITE_KEY);
        form.setAttribute("recaptchaRequired",true);
        form.setAttribute("recaptchaSiteKey", siteKey);
        form.addScript("https://www.google.com/recaptcha/api.js");}

Recaptcha的

buildPage()

方法由表单流程调用,用于帮助渲染页面。这个方法接受一个表单参数

LoginFormsProvider

。你可以给表单提供程序添加额外的属性,这样Freemarker的注册模板在生成HTML页面时可以展示这些属性。
上面展示的是Recaptcha插件的注册代码。Recaptcha需要热属的设置,这些设置要从配置中获取。

FormActions

的配置方式和

Authenticators

一样。在本例中,我们从配置中拉取谷歌Recaptcha site key,并把它作为表单提供程序的属性。注册模板可以读取到这个属性。Recaptcha还需要加载JavaScript脚本。您可以通过调用

LoginFormsProvider.addScript()

传递URL来实现。
用户形象处理中不需要给表单添加额外的信息,所以

buildPage()

方法留空。
接口的下一个核心部分是

validate()

方法。当系统收到表单后会首先调用此方法。
我们先看一下Recaptcha插件中的实现:

@Overridepublicvoidvalidate(ValidationContext context){MultivaluedMap<String,String> formData = context.getHttpRequest().getDecodedFormParameters();List<FormMessage> errors =newArrayList<>();boolean success =false;String captcha = formData.getFirst(G_RECAPTCHA_RESPONSE);if(!Validation.isBlank(captcha)){AuthenticatorConfigModel captchaConfig = context.getAuthenticatorConfig();String secret = captchaConfig.getConfig().get(SITE_SECRET);

            success =validateRecaptcha(context, success, captcha, secret);}if(success){
            context.success();}else{
            errors.add(newFormMessage(null,Messages.RECAPTCHA_FAILED));
            formData.remove(G_RECAPTCHA_RESPONSE);
            context.validationError(formData, errors);return;}}

我们首先获取了Racaptcha组件添加给表单的数据,并从配置中拉取Recaptcha的密钥。接着我们校验了recaptcha。如果校验成功,会调用

ValidationContext.success()

;如果失败,则把

formData

传递给

ValidationContext.validationError()

方法,同时需要定义需要展示的错误信息。错误消息必须指向国际化消息中的消息属性。对于其他注册扩展,

validate()

可能需要验证表单元素的格式,例如电子邮件属性。
下例是校验用户邮箱地址以及其他信息的用户信息插件:

@Overridepublicvoidvalidate(ValidationContext context){MultivaluedMap<String,String> formData = context.getHttpRequest().getDecodedFormParameters();List<FormMessage> errors =newArrayList<>();String eventError =Errors.INVALID_REGISTRATION;if(Validation.isBlank(formData.getFirst((RegistrationPage.FIELD_FIRST_NAME)))){
            errors.add(newFormMessage(RegistrationPage.FIELD_FIRST_NAME,Messages.MISSING_FIRST_NAME));}if(Validation.isBlank(formData.getFirst((RegistrationPage.FIELD_LAST_NAME)))){
            errors.add(newFormMessage(RegistrationPage.FIELD_LAST_NAME,Messages.MISSING_LAST_NAME));}String email = formData.getFirst(Validation.FIELD_EMAIL);if(Validation.isBlank(email)){
            errors.add(newFormMessage(RegistrationPage.FIELD_EMAIL,Messages.MISSING_EMAIL));}elseif(!Validation.isEmailValid(email)){
            formData.remove(Validation.FIELD_EMAIL);
            errors.add(newFormMessage(RegistrationPage.FIELD_EMAIL,Messages.INVALID_EMAIL));}if(context.getSession().users().getUserByEmail(email, context.getRealm())!=null){
            formData.remove(Validation.FIELD_EMAIL);
            errors.add(newFormMessage(RegistrationPage.FIELD_EMAIL,Messages.EMAIL_EXISTS));}if(errors.size()>0){
            context.validationError(formData, errors);return;}else{
            context.success();}}

用户信息插件的

validate()

方法确保在表单中填写电子邮件、名字和姓氏。它还确保电子邮件的格式正确。如果这些验证中的任何一个失败,则会发送一条等待渲染错误消息。任何出错的字段都将从表单数据中删除。错误消息由FormMessage类表示,类的构造器接受的第一个参数是表单元素id,当表单重新渲染时,相应输入项的异常会高亮。第二个参数是消息引用id,该id必须对应于主题中通过本地化消息文件中的属性。
当所有的验证通过后,表单流会调用

FormAction.success()

方法。对于Recaptcha插件,这一步不需要操作,所以这里略过。在用户信息处理中,这个方法填充注册用户的相关数据。

@Overridepublicvoidsuccess(FormContext context){UserModel user = context.getUser();MultivaluedMap<String,String> formData = context.getHttpRequest().getDecodedFormParameters();
        user.setFirstName(formData.getFirst(RegistrationPage.FIELD_FIRST_NAME));
        user.setLastName(formData.getFirst(RegistrationPage.FIELD_LAST_NAME));
        user.setEmail(formData.getFirst(RegistrationPage.FIELD_EMAIL));}

整体实现很简单,新用户的

UserModel

可以从

FormContext

中获取。调用适当的方法可以初始化

UserModel

的数据。
最后,你需要dingyi

FormActionFactory

类,这个类的实现和

AuthenticatorFactory

类似,这里不赘述。

打包操作

所有的类都要打包在一个jar包中,jar包中必须包含一个

org.keycloak.authentication.FormActionFactory

类和一个

META-INF/services/

路径,这个文件必须包含所有实现的FormActionFactory的完全限定类名。比如:

org.keycloak.authentication.forms.RegistrationProfileorg.keycloak.authentication.forms.RegistrationRecaptcha

keycloak通过

 services/

文件扫描需要加载到系统中的程序。
把jar包拷贝到

providers/

路径,运行

bin/kc.[sh|bat] build

,部署jar包。

FormAction

添加到注册流中

向注册页面流中添加

FormAction

只能在在管理控制台中完成。如果转到“Authentication”菜单项并转到“Flow”选项卡,将能够查看当前定义好的流程。内置流程不能修改,因此,要添加我们创建的认证器,必须复制现有流程或新建自己的流程。keycloak希望UI足够简洁,这样你就可以自己弄清楚如何创建流程和添加FormAction。
通常你只需要复制一份注册流程。然后点击注册表单右侧

Actions

菜单,选择

Add execution

添加新的执行器。你可以从选择列表中选择

FormAction

。如果你定义的Action在“Registration User Creation”之后尚未列出,请使用下滑按钮寻找,确保你定义的Action在“Registration User Creation”之后。您希望FormAction在用户创建之后进行,因为注册用户创建的

success()

方法负责创建新的UserModel。
创建流程后,你需要绑定到注册器。在

Authentication

菜单选择

Bindings

标签页可以看到浏览器、注册以及直接获取的选项。

修改忘记密码/凭证流程

Keycloak还具有特定的身份验证流程,用于忘记密码,或者更确切地说是由用户启动的凭据重置。如果转到管理控制台流程页面,则会出现“reset credential”流程。默认情况下,Keycloak会询问用户的电子邮件或用户名,并向他们发送电子邮件。如果用户单击链接,则可以重置密码和OTP(如果已设置OTP)。您可以通过禁用流中的“重置OTP”验证器来禁用自动OTP重置。
你也可以向该流程添加其他功能。例如,许多部署除了发送带有链接的电子邮件外,还希望用户回答一个或多个秘密问题。可以扩展发行版附带的机密问题示例,并将其合并到重置凭证流程中。
如果要扩展重置凭据流程,需要注意一件事。第一个“认证器”只是一个获取用户名或电子邮件的页面。如果用户名或电子邮件存在,则

AuthenticationFlowContext.getUser()

将返回定位的用户,否则将为空。如果之前的电子邮件或用户名不存在,则此表单不会重新要求用户输入电子邮件或用户名。你需要防止攻击者猜测有效用户。因此,如果

AuthenticationFlowContext.getUser()

返回null,您应该继续执行流程,使其看起来像是选择了有效用户。我们建议,如果你想在此流程中添加秘密问题,你应该在发送电子邮件后提出这些问题。换句话说,在“Send Reset Email”认证器之后添加自定义认证器。

修改首次代理登录流程

首次代理登录流程在某些身份提供服务首次登录时使用。

First Login

是指尚未存在与特定认证身份提供程序帐户链接的Keycloak帐户。
详情查看服务管理章节中的

Identity Brokering

客户端认证

Keycloak实际上支持OpenID Connect客户端应用程序的可插入身份验证。Keycloak适配器向Keycloak服务器发送后台通道请求(例如在成功认证后请求交换用于获取访问令牌或刷新令牌的验证码)期间,在后台使用客户端(应用程序)的身份验证。但是客户端身份验证也可以在

Direct Access grants

(由 OAuth2 提供的

Resource Owner Password Credentials Flow

)或

Service account

身份验证期间(由 OAuth2 提供)直接使用Client Credentials Flow。
有关 Keycloak 适配器和 OAuth2 流程的更多详细信息,请参阅保护应用程序和服务指南。

默认实现

Keycloak中有2中客户端认证的默认实现。

  • 使用client_id和client_secret的默认实现 这是OpenID Connect 或OAuth2规范中提到的默认机制,Keycloak 从早期就支持它。公共客户端需要 POST 请求中通过client_id 携带ID 的参数(因此它实际上没有经过身份验证),而受信客户端需要在Authorization: Basic请求头带有 clientId 和 clientSecret 用作用户名和密码。
  • 使用JWT的实现 这基于OAuth 2.0规范的JWT 不记名令牌配置规范。客户端/适配器生成JWT并使用他的私钥对其进行签名。然后 Keycloak 使用客户端的公钥验证签名的 JWT,并根据它对客户端进行身份验证

examples/preconfigured-demo/product-app

路径中有实例代码。

实现自定义的客户端认证

如果要实现自定义的客户端认证器,需要实现一些客户端和服务端的接口。

  • 客户端 实现org.keycloak.adapters.authentication.ClientCredentialsProvider接口并把具体的实现做以下封装: - 打包成WAR文件,放到WEB-INF/classes中。这种方案下,该实现仅可用于此单一WAR应用程序。- 打包成JAR文件,添加到WAR问价的呢WEB-INF/lib中- 打包成JAR文件,用于jboss模块并配置到WAR文件的jboss-deployment-structure.xml中。 无论使用哪种方案,都需要在JAR或WAR中创建META-INF/services/org.keycloak.adapters.authentication.ClientCredentialsProvider文件。
  • 服务端 实现org.keycloak.authentication.ClientAuthenticatorFactoryorg.keycloak.authentication.ClientAuthenticator。同时需要创建META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory文件,其中包含实现的类的名称。
标签: 安全 认证

本文转载自: https://blog.csdn.net/JosephThatwho/article/details/125971179
版权归原作者 JosephThatwho 所有, 如有侵权,请联系我们删除。

“Keycloak服务开发-认证服务SPI”的评论:

还没有评论