这一节我们来看一看Keycloak的Authentication SPI。先来说说我们为什么需要它,当我们使用Keycloak进行登录注册的时候,默认设置下都是通过web页面完成的,流程是相对固定的,当然也有一些可配置项,例如OTP。这样会带来什么问题呢?
- 当我们想通过Rest请求来完成登录注册过程。登录,可以通过第二节中的方式进行;注册相对来说就比较麻烦了,需要一个搭配另一个server,再配合第五节中Admin API来进行。但这样的方式过于繁琐,以至于会开始怀疑为什么还需要Keycloak。
- 当我们想要自定义一些登录注册的流程时,比如想通过短信验证码进行登录。
Authentication Flow
解决这两个问题的方式就是Authentication SPI,它可以用来扩展或是替代已有的认证流程,通过下图,看看已有的流程都有哪些:Browser Flow
:使用浏览器登录的流程;
Registration Flow
:使用浏览器注册的流程;
Direct Grant Flow
:第二节介绍的通过Post请求获取token的流程;
Reset Credentials
:使用浏览器重置密码的流程;
Client Authentication
:Keycloak保护的server的认证流程。
右侧的下拉列表中可以选择相应的流程,这些流程的定义如下图所示,我们以Browser
为例进行解释:
先来解释一下图中的表格,它定义了通过浏览器完成登录操作所经历的步骤/流程。其中又包含了两个Column,Auth Type和Requirement,Auth Type中Cookie,Kerberos,Identity Provider Redirector和Forms是同一级的流程,而Username Password Form和Browser - Conditional OTP是Forms的子流程,同理,Condition - User Configured 和 OTP Form又是Browser - Conditional OTP的子流程。换一种方式来理解一下:
[
Cookie,
Kerberos,
Identity Provider Redirector,
[ // Forms
Username Password Form,
[ // Browser - Conditional OTP
Condition - User Configured,
OTP Form
]
]
]
当一个流程包含子流程时,那么这个流程就变成了抽象概念了。右侧的Requirement则定义了当前流程的状态,包括 Required,Alternative,Disabled 和 Conditional。对于同级流程标记为Alternative,则表示在同级流程中只要有一个可以完成操作,则不会再需要其他流程的参与;Require则表示这个流程是必须的。
接下来,我们来看一下在登录过程中这个表格是如何控制整个流程的。当我们从浏览器发起登录请求时,Keycloak会首先检查请求中的cookie,若cookie验证通过,则直接返回登录成功,而不会进行下面的流程,若cookie验证失败,则进入下一个流程的验证(Cookie校验是一个特殊的流程,它无需用户参与,当发起请求时,即可自发完成),Kerberos,在Requirement中标记该流程为Disabled,将直接跳过。其后的两个流程Identity Provider Redirector和Forms(即用户名密码登录),选其一即可,正如之前章节所展示的demo,用户可选择第三方登录或用户名密码登录。Browser - Conditional OTP 则是一个可选操作,设置OTP并通过OTP进一步验证用户身份(Multi-factor)
自定义Authentication SPI
现在,通过一个demo来演示如何通过自定义Authentication SPI来实现一个短信验证码登录需求,这里的登录指的是通过postman发送一个post请求来获取token,如下图所示:从代码层面,需要两个类:实现Authenticator
接口的SmsOtpAuthenticator
和 实现AuthenticatorFactory
/ConfigurableAuthenticatorFactory
接口的SmsOtpAuthenticatorFactory
。
从概念上理解,Authenticator
就是上面分析的一个验证流程/步骤,SmsOtpAuthenticatorFactory
为工厂类,这样的搭配和上一节中User Storage SPI是相同,而这个demo也是在上一节的基础上进行的。
Authenticator & AuthenticatorFactory
先来看一下SmsOtpAuthenticator
,它的主要逻辑都集中在authenticate
方法中,当发起request token请求时,将通过此方法要校验参数的合法性。通过context的getHttpRequest便可request对象,再从中获取我们期望的参数。在这里我们做了一个mock短信验证码,假设合法otpId
为123
,otpValue
为1111
,当验证通过后,再从session.users()
中通过username
获取UserModel
,最后将获取的userModel
赋给当前context
,并调用context.success()。期间有任何异常都将调用context.failure(...)
退出当前认证流程。
public class SmsOtpAuthenticator implements Authenticator {
...
public void authenticate(AuthenticationFlowContext context) {
logger.info("SmsOtpAuthenticator authenticate");
MultivaluedMap<String, String> params = context.getHttpRequest().getDecodedFormParameters();
String otpId = params.getFirst("otpId");
String otpValue = params.getFirst("otpValue");
String username = params.getFirst("username");
if (otpId == null || otpValue == null || username == null) {
logger.error("invalid params");
context.failure(AuthenticationFlowError.INTERNAL_ERROR);
return;
}
// some mock validation, to validate the username is bind to the otpId and otpValue
if (!otpId.equals("123") || !otpValue.equals("1111")) {
context.failure(AuthenticationFlowError.INVALID_CREDENTIALS);
return;
}
UserModel userModel = session.users().getUserByUsername(username, context.getRealm());
if (userModel == null) {
context.failure(AuthenticationFlowError.INVALID_USER);
return;
}
context.setUser(userModel);
context.success();
}
public boolean requiresUser() {
return false;
}
public boolean configuredFor(KeycloakSession keycloakSession, RealmModel realmModel, UserModel userModel) {
return true;
}
...
}
主要的逻辑分析完后,我们再来看看其他的方法。
requiresUser()
:有些完整流程(例如,Browser
)是有多个步骤/流程共同组成的,其中一步完成后,会进入下一步进行验证,而这一步有时就需要用到上一步中赋值于context中的UserModel
,而requiresUser()
便表示是否需要上一步中的UserModel
。
configuredFor(...)
:表格中的Requirement标记了当前流程的状态,当为Conditional时,表示该流程的执行与否取决于运行时的判断,configuredFor
便是处理这个逻辑的。
Factory的实现相对就简单很多,getId()
用于标示这个SPI,getRequirementChoices()
用于标示这个流程支持哪些Requirement,create(...)
则用于创建SmsOtpAuthenticator
,Factory对于当前运行的Keycloak是一个单例,而SmsOtpAuthenticator
则在每次请求时,都有机会创建一个新的实例。
public class SmsOtpAuthenticatorFactory implements AuthenticatorFactory, ConfigurableAuthenticatorFactory {
...
private static final String ID = "sms-otp-auth";
public String getDisplayType() {
return "SMS OTP Authentication";
}
public String getReferenceCategory() {
return ID;
}
public boolean isConfigurable() {
return true;
}
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return new AuthenticationExecutionModel.Requirement[] {
AuthenticationExecutionModel.Requirement.REQUIRED
};
}
public boolean isUserSetupAllowed() {
return true;
}
public String getHelpText() {
return "Validates SMS OTP";
}
public String getId() {
return ID;
}
public Authenticator create(KeycloakSession session) {
logger.info("SmsOtpAuthenticatorFactory create");
return new SmsOtpAuthenticator(session);
}
...
}
Deployment
部署方式和上一节中的User Storage相同,需要在src/main/resources/META-INF/services
目录下创建org.keycloak.authentication.AuthenticatorFactory
文件,并在其中添加SmsOtpAuthenticatorFactory
的包名:
com.iossocket.SmsOtpAuthenticatorFactory
在通过mvn package
进行打包,放置于standalone/deployments
目录下。再通过admin console配置SmsOtpAuthenticator,步骤如下所示:
-
创建新的流程容器
-
为新创建的流程容器起一个别名
-
选择刚创建好的流程容器,并添加一个execution
-
将原先的Direct Grant Flow改为新的流程容器,并选中Required
测试
此时再通过postman发起请求时,即可获得token。http://localhost:8080/auth/realms/demo/protocol/openid-connect/token
源码可详见:https://github.com/iossocket/userstorage