Keycloak提供了一系列不同的认证机制:kerberos、密码、otp等。这些机制可能不适合你的需求,而你希望实现自定义的机制。keycloak提供了认证SPI帮助用户自定义插件。并且用户可以在控制台应用、排序和配置这些新的机制。
keycloak也支持简单的注册表单。表单的各个项目都可以启用或禁用。相同的认证SPI可以向注册流添加一个新的页面或完全重新实现。Keycloak中也有其他细粒度的SPI可以用于添加特殊认证或拓展注册表单中的用户属性。
在keycloak中必须操作是指用户完成认证后必须执行的操作。这种操作只需要成功执行一次。keycloak有一些内置的操作,比如重置密码。你也可以定义自己的必须操作。
假设采用如下的流程、执行器与子流程。
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以完成用户认证时的操作。
success()状态。由于cookie提供程序返回了成功,并且此认证流程中其他的执行器都是可选的,因此不会启用其他执行器,这时用户成功登录。如果没有SSO cookie集合,cookie提供程序返回的状态为attempted()。这意味着没有出现错误情况,但也没有成功认证,程序尝试了认证,但请求没有设置为适配这个认证方式。forceChallenge()状态。forceChallenge()状态表示HTTP响应不能被流程忽略,必须返回给客户端。如果认证程序返回challenge()状态,那么流程可以保存质询响应,知道其他认证程序都切换成attempted状态。因此,在初始状态下,流程会停止并返回质询响应给客户端。如果客户端返回相应的协商头,那么认证程序会把用户和AuthenticationSession关联起来,由于流程中剩余的认证程序都是可选的,所以认证流程会结束。反之,Kerberos认证程序会把自己设置为attempted()状态,而流程会继续执行。challenge()状态。此执行器是必需的,因此认证流程会接受质询并将HTTP响应发送回浏览器。响应会渲染一份包含用户名、密码输入表单的html页面。用户输入信息并提交后,HTTP请求会把用户名、密码发送给认证程序。如果用户输入的数据错误,程序会生成新的表单响应,并把状态设置为failureChallenge()。这表示用户正在接受认证质询,但是流程中需要记录错误日志。当认证失败次数过多,可以基于日志锁定账户或IP地址。如果用户提交的数据正确,认证程序会把用户模型和认证会话模型关联,并返回success()状态。ConditionalAuthenticator的认证器,同时必须实现boolean matchCondition(AuthenticationFlowContext context)方法。条件执行流程会调用条件执行器包含的所有matchCondition方法,如果这些条件都评估为true,这个条件执行器会被当做必须执行器执行。如果没有全部响应为true,会被视为禁用的子流程。条件认证器仅用于此目的,不用作认证器。这意味着,即使条件认证器的计算结果为“true”,也不会将认证流程或子流程标记为成功。例如,仅包含条件子流程且仅包含条件认证器的流程将永远不允许用户登录。User Configured,这个程序要求用户和认证流程关联。因为用户名、密码认证程序已经把用户和认证流程关联,所以这个条件是满足的。程序的matchCondition方法会评估当前子流程中所有其他认证器的configuredFor方法。如果子流程包含的Requirement设置为required的执行器,那么只有当所有设置为required的执行器的configuredFor方法评估为true时,matchCondition方法才会返回true。否则,任务认证器返回true时,matchCondition就会返回true。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文件复制到程序路径即可完成部署。
在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(Time.currentTimeMillis());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
和别的认证程序一样,我们需要实现CredentialProviderFacrtory方法用于生成CredentialProvider。因此我们需要创建SecretCredentialProviderFactory类,当需要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;
}
private UserCredentialStore getCredentialStore() {
return session.userCredentialManager();
}
同时,我们需要实现CredentialInputValidator接口,这样keycloak就会知道这个程序可以用于校验认证器的凭证。实现CredentialProvider接口首先需要实现getType()方法,这个方法只需要返回SecretQuestionCredentialModels的TYPE属性字符串:
@Override
public String getType() {
return SecretQuestionCredentialModel.TYPE;
}
第二个方法需要从CredentialModel中创建SecretQuestionCredentialModel实例。我们只需要调用已有的静态方法即可:
@Override
public SecretQuestionCredentialModel getCredentialFromModel(CredentialModel model) {
return SecretQuestionCredentialModel.createFromCredentialModel(model);
}
最终我们需要创建和删除凭证的方法,这些方法会调用KeycloakSession的 userCredentialManager对象,这个对象知道如何读取或编辑凭证,比如通过本地存储或联合存储
@Override
public CredentialModel createCredential(RealmModel realm, UserModel user, SecretQuestionCredentialModel credentialModel) {
if (credentialModel.getCreatedDate() == null) {
credentialModel.setCreatedDate(Time.currentTimeMillis());
}
return getCredentialStore().createCredential(realm, user, credentialModel);
}
@Override
public boolean deleteCredential(RealmModel realm, UserModel user, String credentialId) {
return getCredentialStore().removeStoredCredential(realm, user, credentialId);
}
实现CredentialInputValidator接口首先需要实现isValid方法,这个方法检测指定域下的指定用户的凭证是否有效。认证器需要校验用户输入数据时调用此方法。这里我们只需要简单地检查输入的字符串是否是凭证中的记录:
@Override
public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
if (!(input instanceof UserCredentialModel)) {
logger.debug("Expected instance of UserCredentialModel for CredentialInput");
return false;
}
if (!input.getType().equals(getType())) {
return false;
}
String challengeResponse = input.getChallengeResponse();
if (challengeResponse == null) {
return false;
}
CredentialModel credentialModel = getCredentialStore().getStoredCredentialById(realm, user, input.getCredentialId());
SecretQuestionCredentialModel sqcm = getCredentialFromModel(credentialModel);
return sqcm.getSecretQuestionSecretData().getAnswer().equals(challengeResponse);
}
另外两个需要实现的方法分别用于检测CredentialProvider是否支持给定的凭证类型以及用户是否配置了该凭证类型。这里对于后一种检测我们只需要检查用户有没有SECRET_QUESTION类型的凭证:
@Override
public boolean supportsCredentialType(String credentialType) {
return getType().equals(credentialType);
}
@Override
public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
if (!supportsCredentialType(credentialType)) return false;
return !getCredentialStore().getStoredCredentialsByType(realm, user, credentialType).isEmpty();
}
当实现使用凭证认证用户的认证器时,你需要有一个实现CredentialValidator接口的认证器。这个接口接受一个继承CredentialProvider的类作为参数,并且会允许keycloak在CredentialProvider中直接调用其方法。唯一需要实现的方法是getCredentialProvider。在我们的例子中,SecretQuestionAuthenticator使用此方法获取SecretQuestionProvider:
public SecretQuestionCredentialProvider getCredentialProvider(KeycloakSession session) {
return (SecretQuestionCredentialProvider)session.getProvider(CredentialProvider.class, SecretQuestionCredentialProviderFactory.PROVIDER_ID);
}
当实现Authentiactor接口时,首先要实现的方法是requiresUser()方法。在我们的例子中,这个方法必须返回true,因为我们需要校验用户的密钥问题。像kerberos那样的认证器会返回false,因为它可以通过协商头数据解析用户身份。本例中校验指定用户的指定凭证。
另一个要实现的方法是configuredFor()方法。这个方法用于判断用户是否配置特定的认证器。在我们的例子中,我们只需要调用在SecretQuestionCredentialProvider实现的方法:
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return getCredentialProvider(session).isConfiguredFor(realm, user, getType(session));
}
下一个要实现的认证器方法是setRequiredActions()。如果configuredFor()返回false,并且认证流程中需要我们的验证器,并且仅当关联的AuthenticatorFactory的isUserSetupAllowed方法返回true时,则将调用此方法。setRequiredActions()方法负责注册必须由用户完成的操作。在我们的例子中,我们注册一个用户必须设置问题答案的操作。这个操作会在收到实现。首先我们先实现setRequiredActions()方法:
@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
user.addRequiredAction("SECRET_QUESTION_CONFIG");
}
现在我们可以实现认证器的核心内容。下一个要实现的方法是authenticate(),这是认证流程在第一次访问执行时调用的初始方法。我们希望如果用户响应的答案已经在浏览器的机器上,那么用户不需要再次回答问题,而是把该机器设置为受信任的机器。authenticate方法并不处理问题表达,其主要目的是渲染页面以及继续认证流程:
@Override
public void authenticate(AuthenticationFlowContext context) {
if (hasCookie(context)) {
context.success();
return;
}
Response challenge = context.form()
.createForm("secret-question.ftl");
context.challenge(challenge);
}
protected boolean hasCookie(AuthenticationFlowContext context) {
Cookie cookie = context.getHttpRequest().getHttpHeaders().getCookies().get("SECRET_QUESTION_ANSWERED");
boolean result = cookie != null;
if (result) {
System.out.println("Bypassing secret question because cookie is set");
}
return result;
}
hasCookie()方法检查当前使用的浏览器中是否包含有效的cookie,如果有则表明问题已经被回答过。如果方法返回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()方法的交互。
@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();
}
如果答案不正确,我们会重新构建表单并且展示错误信息。我们可以调用AuthenticationFlowContext.failureChallenge()传递原因和JAX-RS响应。failuerChallenge()方法和challenge()方法一样,但是会记录失败事件用于攻击分析服务。
如果检验成功,那么我们设置cookie,记录问题已经回答过,接着我们调用AuthenticationFlowContext.success()方法。
校验会受到表单传入的数据,并在SecretQuestionCredentialProvider中调用isValid方法。你会注意到,代码中有一部分与获取凭据Id有关。这是因为如果将Keycloak配置为允许多种类型的替代身份验证器,或者,如果用户可以记录SECRET_QUESTION类型的多个凭据(例如,如果我们允许从多个问题中进行选择,并且我们允许用户对这些问题中的多个问题进行回答),那么Keycloak需要知道使用哪个凭据记录用户。为了防止有超过单个凭证,keycloak允许用户选择使用哪个凭证,表单会把信息传递给认证器。如果表单没有显示此信息,则使用的凭据id由CredentialProvider的默认getDefaultCredential方法提供,该方法将返回用户首选的正确类型的凭据。
protected boolean validateAnswer(AuthenticationFlowContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
String secret = formData.getFirst("secret_answer");
String credentialId = formData.getFirst("credentialId");
if (credentialId == null || credentialId.isEmpty()) {
credentialId = getCredentialProvider(context.getSession())
.getDefaultCredential(context.getSession(), context.getRealm(), context.getUser()).getId();
}
UserCredentialModel input = new UserCredentialModel(credentialId, getType(context.getSession()), secret);
return getCredentialProvider(context.getSession()).isValid(context.getRealm(), context.getUser(), input);
}
下一个方式是setCookie(),这是为验证器提供配置的示例。在这种情况下,我们希望cookie的最大存活时间可以配置:
protected void setCookie(AuthenticationFlowContext context) {
AuthenticatorConfigModel config = context.getAuthenticatorConfig();
int maxCookieAge = 60 * 60 * 24 * 30; // 30 days
if (config != null) {
maxCookieAge = Integer.valueOf(config.getConfig().get("cookie.max.age"));
}
URI uri = context.getUriInfo().getBaseUriBuilder().path("realms").path(context.getRealm().getName()).build();
addCookie(context, "SECRET_QUESTION_ANSWERED", "true",
uri.getRawPath(),
null, null,
maxCookieAge,
false, true);
}
SecretQuestionCredentialProvider类中最后要实现的一个方法是getCredentialTypeMetadata(CredentialTypeMetadataContext metadataContext),这是CredentialProvider接口中的抽象方法。每个凭证提供程序都需要实现这个方法。这个方法返回CredentialTypeMetadata实例,至少需要包类型、认证其类别、展示名称以及可移除项目。在本例中,构建器从getType()方法接受认证器类型,类别是双因素(认证器可以用作另一种认证因素),而可移除属性设置为false(用户不能移除已经注册的凭证)。
构建器还包括帮助文档(在不同的页面展示给用户)、创建操作(需要操作的providerID,用户可以使用这个id创建新的凭证)或更新操作(和创建操作一样,但是不创建而是更新凭证)。
接下来需要实现一个AuthenticatorFactory。工厂负责创建认证器实例。同时提供认证器的部署与配置元数据。
getId()方法返回组件的唯一名称。create()方法被运行时调用,用于分配和处理认证器。
public class SecretQuestionAuthenticatorFactory implements AuthenticatorFactory, ConfigurableAuthenticatorFactory {
public static final String PROVIDER_ID = "secret-question-authenticator";
private static final SecretQuestionAuthenticator SINGLETON = new SecretQuestionAuthenticator();
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public Authenticator create(KeycloakSession session) {
return SINGLETON;
}
接下来工厂需要指定允许要求的开关。要求有四种:ALTERNATIVE、REQUIRED、CONDITIONAL和DISABLED,AuthenticatorFactory实现可以限制在管理控制台定义流程时展示的要求选项。子流程必须使用CONDITIONAL,而认证器的需求应该是REQUIRED、CONDITIONAL和DISABLED中任意一种,除非有特殊需求:
private static AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
AuthenticationExecutionModel.Requirement.REQUIRED,
AuthenticationExecutionModel.Requirement.ALTERNATIVE,
AuthenticationExecutionModel.Requirement.DISABLED
};
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
AuthenticatorFactory.isUserSetupAllowed()是一个标识,可以通知认证流程管理器是否需要调用Authenticator.setRequiredActions()方法。如果某个认证器没有给用户配置,流程管理器会检查isUserSetupAllowed(),如果结果是false,那么会流程会抛出异常并终止,如果返回true,那么流程管理起会调用Authenticator.setRequiredActions()。
@Override
public boolean isUserSetupAllowed() {
return true;
}
之后的一些方法定义了如何配置认证器。isConfigurable()方法是一个标识,告诉管理控制台认证器是否可以在认证流程中配置。getConfigProperties()方法返回ProviderConfigProperty对象数组。这些对象定义不同的配置属性。
@Override
public List<ProviderConfigProperty> getConfigProperties() {
return configProperties;
}
private static final List<ProviderConfigProperty> configProperties = new ArrayList<ProviderConfigProperty>();
static {
ProviderConfigProperty property;
property = new ProviderConfigProperty();
property.setName("cookie.max.age");
property.setLabel("Cookie Max Age");
property.setType(ProviderConfigProperty.STRING_TYPE);
property.setHelpText("Max age in seconds of the SECRET_QUESTION_COOKIE.");
configProperties.add(property);
}
每一个ProviderConfigProperty对象都定义了配置的名称(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表单。
@Override
public void requiredActionChallenge(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()方法
@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并且设置type和value的值。接着调用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";
}
其中getDisplayName()方法仅用于在管理控制台展示用于友好的名称。
最后要做的一件事是进入管理控制台。单击认证Authentication菜单。单击Required Actions选项卡。单击Register按钮并选择新的Required Action。新的必须操作现在应显示并在必须操作列表中并且已经被启用。
keycloak允许你自定义一组认证器并完全替换keycloak的注册流程。但是,通常只需要在现成的注册页面中添加一点验证。有一个专门的SPI可以用于此目的。它基本上允许你在页面上添加表单元素的验证,以及在用户注册后初始化UserModel的属性和数据。我们将展示用户配置文件注册处理的实现以及注册Google Recaptcha插件。
FormAction接口需要实现的核心接口是FormAction,FormAction用于渲染和处理一部分页面。buildPage()方法中会完成渲染,validate()方法会完成验证,success()方法会完成验证后的操作。我们首先查看Recaptcha插件的buildPage()方法:
@Override
public void buildPage(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(new FormMessage(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插件中的实现:
@Override
public void validate(ValidationContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
List<FormMessage> errors = new ArrayList<>();
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(new FormMessage(null, Messages.RECAPTCHA_FAILED));
formData.remove(G_RECAPTCHA_RESPONSE);
context.validationError(formData, errors);
return;
}
}
我们首先获取了Racaptcha组件添加给表单的数据,并从配置中拉取Recaptcha的密钥。接着我们校验了recaptcha。如果校验成功,会调用ValidationContext.success();如果失败,则把formData传递给ValidationContext.validationError()方法,同时需要定义需要展示的错误信息。错误消息必须指向国际化消息中的消息属性。对于其他注册扩展,validate()可能需要验证表单元素的格式,例如电子邮件属性。
下例是校验用户邮箱地址以及其他信息的用户信息插件:
@Override
public void validate(ValidationContext context) {
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
List<FormMessage> errors = new ArrayList<>();
String eventError = Errors.INVALID_REGISTRATION;
if (Validation.isBlank(formData.getFirst((RegistrationPage.FIELD_FIRST_NAME)))) {
errors.add(new FormMessage(RegistrationPage.FIELD_FIRST_NAME, Messages.MISSING_FIRST_NAME));
}
if (Validation.isBlank(formData.getFirst((RegistrationPage.FIELD_LAST_NAME)))) {
errors.add(new FormMessage(RegistrationPage.FIELD_LAST_NAME, Messages.MISSING_LAST_NAME));
}
String email = formData.getFirst(Validation.FIELD_EMAIL);
if (Validation.isBlank(email)) {
errors.add(new FormMessage(RegistrationPage.FIELD_EMAIL, Messages.MISSING_EMAIL));
} else if (!Validation.isEmailValid(email)) {
formData.remove(Validation.FIELD_EMAIL);
errors.add(new FormMessage(RegistrationPage.FIELD_EMAIL, Messages.INVALID_EMAIL));
}
if (context.getSession().users().getUserByEmail(email, context.getRealm()) != null) {
formData.remove(Validation.FIELD_EMAIL);
errors.add(new FormMessage(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插件,这一步不需要操作,所以这里略过。在用户信息处理中,这个方法填充注册用户的相关数据。
@Override
public void success(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的数据。
最后,你需要dingyiFormActionFactory类,这个类的实现和AuthenticatorFactory类似,这里不赘述。
所有的类都要打包在一个jar包中,jar包中必须包含一个org.keycloak.authentication.FormActionFactory类和一个META-INF/services/路径,这个文件必须包含所有实现的FormActionFactory的完全限定类名。比如:
org.keycloak.authentication.forms.RegistrationProfile
org.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中客户端认证的默认实现。
Authorization: Basic请求头带有 clientId 和 clientSecret 用作用户名和密码。在examples/preconfigured-demo/product-app路径中有实例代码。
如果要实现自定义的客户端认证器,需要实现一些客户端和服务端的接口。
org.keycloak.adapters.authentication.ClientCredentialsProvider接口并把具体的实现做以下封装:
WEB-INF/classes中。这种方案下,该实现仅可用于此单一WAR应用程序。jboss-deployment-structure.xml中。META-INF/services/org.keycloak.adapters.authentication.ClientCredentialsProvider文件。org.keycloak.authentication.ClientAuthenticatorFactory和org.keycloak.authentication.ClientAuthenticator。同时需要创建META-INF/services/org.keycloak.authentication.ClientAuthenticatorFactory文件,其中包含实现的类的名称。