keycloak实现手机验证码登录(兼容账号密码登录)
keycloak自身只能通过账号密码登录,为了实现手机验证码登录,需要实现自定义的认证SPI+自定义登录页。废话不多说,直接上方法
自定义认证SPI
1.添加依赖
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>19.0.2</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>19.0.2</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-services</artifactId>
<version>19.0.2</version>
</dependency>
2.实现自定义认证SPI
需要实现两个类,可以用一个新的java项目来实现
1)实现AuthenticatorFactory
@AutoService(AuthenticatorFactory.class)
public class SmsAuthenticatorFactory implements AuthenticatorFactory {
// 这个属性用于注册SPI用,需要定义好
public static final String PROVIDER_ID = "sms-authenticator";
private static final SmsAuthenticator SINGLETON = new SmsAuthenticator();
@Override
public String getId() {
return PROVIDER_ID;
}
@Override
public String getDisplayType() {
return "SMS Authentication";
}
@Override
public String getHelpText() {
return "Validates an OTP sent via SMS to the users mobile phone.";
}
@Override
public String getReferenceCategory() {
return "otp";
}
@Override
public boolean isConfigurable() {
return true;
}
@Override
public boolean isUserSetupAllowed() {
return false;
}
@Override
public AuthenticationExecutionModel.Requirement[] getRequirementChoices() {
return REQUIREMENT_CHOICES;
}
@Override
public List<ProviderConfigProperty> getConfigProperties() {
// 可以在这里自定义认证属性,在这里添加了测试用的一些按钮
return List.of(
new ProviderConfigProperty(SmsConstants.CODE_LOGIN_ENABLED, "是否启用手机验证码登录", "是否启用手机验证码登录,不启用将以用户名密码登录", ProviderConfigProperty.BOOLEAN_TYPE, true),
new ProviderConfigProperty(SmsConstants.SIMULATION_MODE, "测试模式", "在测试模式中,不会发送手机验证码,验证码将以日志输出方式显示", ProviderConfigProperty.BOOLEAN_TYPE, false),
new ProviderConfigProperty(SmsConstants.OMNIPOTENT_CODE_ENABLED, "是否启用万能验证码", "是否启用万能验证码,生产上不可开启!!", ProviderConfigProperty.BOOLEAN_TYPE, false),
new ProviderConfigProperty(SmsConstants.OMNIPOTENT_CODE, "万能验证码", "需要启用万能验证码后才生效!", ProviderConfigProperty.STRING_TYPE, "887154")
);
}
@Override
public Authenticator create(KeycloakSession session) {
return SINGLETON;
}
@Override
public void init(Config.Scope config) {
}
@Override
public void postInit(KeycloakSessionFactory factory) {
}
@Override
public void close() {
}
}
2)因为需要用到前端表达,所以需要继承AbstractUsernameFormAuthenticator并且实现Authenticator。
public class SmsAuthenticator extends AbstractUsernameFormAuthenticator implements Authenticator {
private static final String MOBILE_NUMBER_FIELD = "mobile_number";
@Override
public void authenticate(AuthenticationFlowContext context) {
MultivaluedMap<String, String> formData = new MultivaluedMapImpl<>();
String loginHint = context.getAuthenticationSession().getClientNote(OIDCLoginProtocol.LOGIN_HINT_PARAM);
String rememberMeUsername = AuthenticationManager.getRememberMeUsername(context.getRealm(), context.getHttpRequest().getHttpHeaders());
if (context.getUser() != null) {
LoginFormsProvider form = context.form();
form.setAttribute(LoginFormsProvider.USERNAME_HIDDEN, true);
form.setAttribute(LoginFormsProvider.REGISTRATION_DISABLED, true);
context.getAuthenticationSession().setAuthNote(USER_SET_BEFORE_USERNAME_PASSWORD_AUTH, "true");
} else {
context.getAuthenticationSession().removeAuthNote(USER_SET_BEFORE_USERNAME_PASSWORD_AUTH);
if (loginHint != null || rememberMeUsername != null) {
if (loginHint != null) {
formData.add(AuthenticationManager.FORM_USERNAME, loginHint);
} else {
formData.add(AuthenticationManager.FORM_USERNAME, rememberMeUsername);
formData.add("rememberMe", "on");
}
}
}
Response challengeResponse = challenge(context, formData);
context.challenge(challengeResponse);
}
@Override
public void action(AuthenticationFlowContext context) {
AuthenticatorConfigModel config = context.getAuthenticatorConfig();
Boolean codeEnabled = BooleanUtil.toBoolean(config.getConfig().getOrDefault(SmsConstants.CODE_LOGIN_ENABLED, "true"));
Boolean omnipotentCodeEnabled = BooleanUtil.toBoolean(config.getConfig().getOrDefault(SmsConstants.OMNIPOTENT_CODE_ENABLED, "false"));
String omnipotentCode = config.getConfig().get(SmsConstants.OMNIPOTENT_CODE);
MultivaluedMap<String, String> formData = context.getHttpRequest().getDecodedFormParameters();
RealmModel realmModel = context.getRealm();
if (formData.containsKey("cancel")) {
context.cancelLogin();
return;
}
if(formData.containsKey("grant_type") && formData.get("grant_type").get(0).equalsIgnoreCase("code") && BooleanUtil.isTrue(codeEnabled)) {
// 手机验证码登录
if(!validatePhoneForm(context, formData, realmModel,omnipotentCodeEnabled,omnipotentCode)){
return;
}
} else if (!validateForm(context, formData)) {
// 账号密码登录
return;
}
context.success();
}
@Override
public boolean requiresUser() {
return false;
}
@Override
public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
return user.getFirstAttribute(MOBILE_NUMBER_FIELD) != null;
}
@Override
public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
// this will only work if you have the required action from here configured:
// https://github.com/dasniko/keycloak-extensions-demo/tree/main/requiredaction
user.addRequiredAction("mobile-number-ra");
}
@Override
public void close() {
}
protected Response challenge(AuthenticationFlowContext context, MultivaluedMap<String, String> formData) {
LoginFormsProvider forms = context.form();
if (formData.size() > 0) forms.setFormData(formData);
return forms.createLoginUsernamePassword();
}
protected boolean validateForm(AuthenticationFlowContext context, MultivaluedMap<String, String> formData) {
return validateUserAndPassword(context, formData);
}
protected boolean validatePhoneForm(AuthenticationFlowContext context, MultivaluedMap<String, String> formData, RealmModel realmModel,Boolean omnipotentCodeEnabled,String omnipotentCode) {
// 通过手机号获取用户名;
return true;
}
protected Response handleError(AuthenticationFlowContext context, String error, String fieId){
Response challengeResponse = this.challenge(context, error, fieId);
return challengeResponse;
}
}
附上一些用到的常量
public class SmsConstants {
public String SIMULATION_MODE = "simulation";
public String CODE_LOGIN_ENABLED = "code_login_enabled";
public String OMNIPOTENT_CODE = "omnipotent_code";
public String OMNIPOTENT_CODE_ENABLED = "omnipotent_code_enabled";
}
3)实现完这两个类之后,打包成jar
4)因为我用的是官网下载的容器版keycloak,所以需要把这个jar放进去
用了docker-compose部署的示例:
将jar放入providers目录下后挂入容器中
然后进入容器中执行以下命令
bin/kc.sh build --spi-hostname-provider=sms-authenticator
验证是否成功:进入master管理页面,搜索一下是否存在sms-authenticator
如果存在,那已经成功了一半。
3.自定义认证流程
1)在认证页面新建流程
2)按以下流程图添加
3)开启测试用的验证码
4)启用流程
到此,已经完成了自定义认证SPI,并自定义认证流程!
自定义登录主题
要实现手机验证码登录,必须自定义登录主题
大概思路是:保留原有的登录表单,新增一个手机验证码的表单,通过按钮进行切换。使用原本的认证接口,用grant_typel来区分是账号密码登录还是手机号码登录。
大家有兴趣可以看下官方文档keycloak主题
demo演示