這一節(jié)我們來看一看Keycloak的Authentication SPI。先來說說我們?yōu)槭裁葱枰?dāng)我們使用Keycloak進(jìn)行登錄注冊(cè)的時(shí)候,默認(rèn)設(shè)置下都是通過web頁(yè)面完成的,流程是相對(duì)固定的,當(dāng)然也有一些可配置項(xiàng),例如OTP。這樣會(huì)帶來什么問題呢?
- 當(dāng)我們想通過Rest請(qǐng)求來完成登錄注冊(cè)過程。登錄,可以通過第二節(jié)中的方式進(jìn)行;注冊(cè)相對(duì)來說就比較麻煩了,需要一個(gè)搭配另一個(gè)server,再配合第五節(jié)中Admin API來進(jìn)行。但這樣的方式過于繁瑣,以至于會(huì)開始懷疑為什么還需要Keycloak。
- 當(dāng)我們想要自定義一些登錄注冊(cè)的流程時(shí),比如想通過短信驗(yàn)證碼進(jìn)行登錄。
Authentication Flow
解決這兩個(gè)問題的方式就是Authentication SPI,它可以用來擴(kuò)展或是替代已有的認(rèn)證流程,通過下圖,看看已有的流程都有哪些:
Browser Flow:使用瀏覽器登錄的流程;
Registration Flow:使用瀏覽器注冊(cè)的流程;
Direct Grant Flow:第二節(jié)介紹的通過Post請(qǐng)求獲取token的流程;
Reset Credentials:使用瀏覽器重置密碼的流程;
Client Authentication:Keycloak保護(hù)的server的認(rèn)證流程。
右側(cè)的下拉列表中可以選擇相應(yīng)的流程,這些流程的定義如下圖所示,我們以Browser為例進(jìn)行解釋:

先來解釋一下圖中的表格,它定義了通過瀏覽器完成登錄操作所經(jīng)歷的步驟/流程。其中又包含了兩個(gè)Column,Auth Type和Requirement,Auth Type中Cookie,Kerberos,Identity Provider Redirector和Forms是同一級(jí)的流程,而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
]
]
]
當(dāng)一個(gè)流程包含子流程時(shí),那么這個(gè)流程就變成了抽象概念了。右側(cè)的Requirement則定義了當(dāng)前流程的狀態(tài),包括 Required,Alternative,Disabled 和 Conditional。對(duì)于同級(jí)流程標(biāo)記為Alternative,則表示在同級(jí)流程中只要有一個(gè)可以完成操作,則不會(huì)再需要其他流程的參與;Require則表示這個(gè)流程是必須的。
接下來,我們來看一下在登錄過程中這個(gè)表格是如何控制整個(gè)流程的。當(dāng)我們從瀏覽器發(fā)起登錄請(qǐng)求時(shí),Keycloak會(huì)首先檢查請(qǐng)求中的cookie,若cookie驗(yàn)證通過,則直接返回登錄成功,而不會(huì)進(jìn)行下面的流程,若cookie驗(yàn)證失敗,則進(jìn)入下一個(gè)流程的驗(yàn)證(Cookie校驗(yàn)是一個(gè)特殊的流程,它無需用戶參與,當(dāng)發(fā)起請(qǐng)求時(shí),即可自發(fā)完成),Kerberos,在Requirement中標(biāo)記該流程為Disabled,將直接跳過。其后的兩個(gè)流程Identity Provider Redirector和Forms(即用戶名密碼登錄),選其一即可,正如之前章節(jié)所展示的demo,用戶可選擇第三方登錄或用戶名密碼登錄。Browser - Conditional OTP 則是一個(gè)可選操作,設(shè)置OTP并通過OTP進(jìn)一步驗(yàn)證用戶身份(Multi-factor)
自定義Authentication SPI
現(xiàn)在,通過一個(gè)demo來演示如何通過自定義Authentication SPI來實(shí)現(xiàn)一個(gè)短信驗(yàn)證碼登錄需求,這里的登錄指的是通過postman發(fā)送一個(gè)post請(qǐng)求來獲取token,如下圖所示:
從代碼層面,需要兩個(gè)類:實(shí)現(xiàn)Authenticator接口的SmsOtpAuthenticator 和 實(shí)現(xiàn)AuthenticatorFactory/ConfigurableAuthenticatorFactory接口的SmsOtpAuthenticatorFactory。
從概念上理解,Authenticator就是上面分析的一個(gè)驗(yàn)證流程/步驟,SmsOtpAuthenticatorFactory為工廠類,這樣的搭配和上一節(jié)中User Storage SPI是相同,而這個(gè)demo也是在上一節(jié)的基礎(chǔ)上進(jìn)行的。
Authenticator & AuthenticatorFactory
先來看一下SmsOtpAuthenticator,它的主要邏輯都集中在authenticate方法中,當(dāng)發(fā)起request token請(qǐng)求時(shí),將通過此方法要校驗(yàn)參數(shù)的合法性。通過context的getHttpRequest便可request對(duì)象,再?gòu)闹蝎@取我們期望的參數(shù)。在這里我們做了一個(gè)mock短信驗(yàn)證碼,假設(shè)合法otpId為123,otpValue為1111,當(dāng)驗(yàn)證通過后,再?gòu)?code>session.users()中通過username獲取UserModel,最后將獲取的userModel賦給當(dāng)前context,并調(diào)用context.success()。期間有任何異常都將調(diào)用context.failure(...)退出當(dāng)前認(rèn)證流程。
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;
}
...
}
主要的邏輯分析完后,我們?cè)賮砜纯雌渌姆椒ā?br>
requiresUser():有些完整流程(例如,Browser)是有多個(gè)步驟/流程共同組成的,其中一步完成后,會(huì)進(jìn)入下一步進(jìn)行驗(yàn)證,而這一步有時(shí)就需要用到上一步中賦值于context中的UserModel,而requiresUser()便表示是否需要上一步中的UserModel。
configuredFor(...):表格中的Requirement標(biāo)記了當(dāng)前流程的狀態(tài),當(dāng)為Conditional時(shí),表示該流程的執(zhí)行與否取決于運(yùn)行時(shí)的判斷,configuredFor便是處理這個(gè)邏輯的。
Factory的實(shí)現(xiàn)相對(duì)就簡(jiǎn)單很多,getId()用于標(biāo)示這個(gè)SPI,getRequirementChoices()用于標(biāo)示這個(gè)流程支持哪些Requirement,create(...)則用于創(chuàng)建SmsOtpAuthenticator,F(xiàn)actory對(duì)于當(dāng)前運(yùn)行的Keycloak是一個(gè)單例,而SmsOtpAuthenticator則在每次請(qǐng)求時(shí),都有機(jī)會(huì)創(chuàng)建一個(gè)新的實(shí)例。
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
部署方式和上一節(jié)中的User Storage相同,需要在src/main/resources/META-INF/services目錄下創(chuàng)建org.keycloak.authentication.AuthenticatorFactory文件,并在其中添加SmsOtpAuthenticatorFactory的包名:
com.iossocket.SmsOtpAuthenticatorFactory
在通過mvn package進(jìn)行打包,放置于standalone/deployments目錄下。再通過admin console配置SmsOtpAuthenticator,步驟如下所示:
-
創(chuàng)建新的流程容器create new flow.png
-
為新創(chuàng)建的流程容器起一個(gè)別名create top level form.png
-
選擇剛創(chuàng)建好的流程容器,并添加一個(gè)executionadd execution.png
-
將原先的Direct Grant Flow改為新的流程容器,并選中Requiredchange existing binding.png
測(cè)試
此時(shí)再通過postman發(fā)起請(qǐng)求時(shí),即可獲得token。http://localhost:8080/auth/realms/demo/protocol/openid-connect/token

源碼可詳見:https://github.com/iossocket/userstorage



