背景说明
使用Keycloak作为账号体系的项目中,经常会被问到Keycloak如何实现手机验证码登录,Keycloak有没有内置的基于短信的登录实现SMS-based two-/multi-factor-authentication (2FA/MFA) ;
Keycloak目前只内置一种基于 Google Authenticator 的 2FA 选项。
这篇主要讨论实现上述的需求的几种方案,以及相应的优缺点:
-
定制 Authentication SPI,实现Keycloak统一的浏览器手机验证码登录;
-
定制 Authentication SPI,实现基于 Resource Owner Password Credentials (Direct Access Grants)的手机验证码登录;
-
使用 token-exchange
-
使用 Identity Providers 实现手机验证码登录
定制 Authentication SPI,实现Keycloak统一的浏览器手机验证码登录
原理及实现
这种方式主要的工作原理是:
-
定制Keycloak登录主题,点击发送验证码有Keycloak服务进行验证码的发送,缓存和验证;
-
新增一个 keycloak-2fa-sms-authenticator 验证器,进行手机号和验证码的校验
具体实现代码可以参考:
优缺点
优点是:
- 基于Keycloak标准扩展实现,安全风险可控
- 基于浏览器登录,各业务统一的登录逻辑
缺点是:
- 浏览器登录在某些端,比如APP,并不适合
- 短信发送集成到Keycloak,各个业务无法再支持自定义模板及信息定义;
基于 Resource Owner Password Credentials (Direct Access Grants)的手机验证码登录
Direct Access Grants概念
Resource Owner Password Credentials Grant (Direct Access Grants)
This is referred to in the Admin Console as Direct Access Grants. This is used by REST clients that want to obtain a token on behalf of a user. It is one HTTP POST request that contains the credentials of the user as well as the id of the client and the client’s secret (if it is a confidential client). The user’s credentials are sent within form parameters. The HTTP response contains identity, access, and refresh tokens.
相关的 RESTApi请求
curl --location --request POST 'http://localhost:8080/auth/realms/austintest/protocol/openid-connect/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'client_id=server-admin' \
--data-urlencode 'client_secret=ee0c1f08-775d-4195-b5ca-19eb9b923822' \
--data-urlencode 'username=admin1' \
--data-urlencode 'password=123456' \
--data-urlencode 'grant_type=password'
在后台的 Keycloak 的Flow中:
去掉Password 的Requirement 为 ALTERNATIVE即可
curl --location --request POST 'http://localhost:8018/auth/realms/austin-local/protocol/openid-connect/token' --header 'Content-Type: application/x-www-form-urlencoded' --data-urlencode 'client_id=austin-app1' --data-urlencode 'client_secret=d622f435-4aad-48b7-bf18-e3cba0e4d76a' --data-urlencode 'username=admin1' --data-urlencode 'grant_type=password'
可以在client中单独设置Flow
优缺点
优点:
-
验证码的发送和校验完全由业务方控制
-
业务方可以很方便的进行拓展,不管是基于手机号验证码,还是邮箱验证码;
缺点:
-
由于去掉了用户的密码校验,所以client获取用户令牌的安全级别下降,需要很小心的控制 client 是否开启 Direct Access Grants,以及client对应的scope;
-
手机号对应的用户名,需要业务方自行保存,对应于手机号保存在业务方数据库的实现是方便,但是如果把手机号放在Keycloak的User的attribute中则还需要额外的定制修改。
实现手机号验证登录
定制ValidateUserAttributeAuthenticator
该校验器主要实现 Direct Access Grant 校验用户 的几种方式
-
指定用户名校验用户
-
指定用户邮箱校验用户
-
根据全局设定的 属性名校验用户
-
根据请求参数指定的属性名校验用户
-
根据默认的属性名 phone 校验用户
可能出现的报错:
- 未指定用户名,用户邮箱,以及属性值,会提示"Missing parameter: username"
String attributeValue = retrieveAttributeValue(context, attributeName);
if (username == null && attributeValue == null) {
context.getEvent().error(Errors.USER_NOT_FOUND);
Response challengeResponse = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_request", "Missing parameter: username");
context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse);
return;
}
- 指定的用户名或者邮箱,找到多个用户,提示: “Invalid user credentials”
UserModel user = null;
if (username != null) {
try {
user = KeycloakModelUtils.findUserByNameOrEmail(context.getSession(), context.getRealm(), username);
} catch (ModelDuplicateException mde) {
logger.error(mde);
Response challengeResponse = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_request", "Invalid user credentials");
context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse);
return;
}
}
- 提供的属性名对应的属性值找到多个用户,提示: “Invalid user credentials is not unique”
// find User By attribute
if (user == null) {
List<UserModel> users = context.getSession().users()
.searchForUserByUserAttributeStream(context.getRealm(), attributeName, attributeValue)
.collect(Collectors.toList());
if (users.size() > 1) {
logger.error(new ModelDuplicateException("User with " + attributeName + "=" + attributeValue + " is not unique!"));
Response challengeResponse = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_request", "Invalid user credentials is not unique");
context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse);
return;
}
if (users.size() == 1) {
user = users.get(0);
}
}
- 提供的信息,找不到对应的用户,提示: “Invalid user credentials”
if (user == null) {
context.getEvent().error(Errors.USER_NOT_FOUND);
Response challengeResponse = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_grant", "Invalid user credentials");
context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse);
return;
}
完整的authenticate
函数如下
@Override
public void authenticate(AuthenticationFlowContext context) {
String username = retrieveUsername(context);
AuthenticatorConfigModel config = context.getAuthenticatorConfig();
String attributeName = null;
if (config.getConfig() != null) {
attributeName = config.getConfig().get("attributeName");
} else {
attributeName = retrieveAttributeValue(context, "attributeName");
}
if (attributeName == null) {
attributeName = "phone";
}
String attributeValue = retrieveAttributeValue(context, attributeName);
if (username == null && attributeValue == null) {
context.getEvent().error(Errors.USER_NOT_FOUND);
Response challengeResponse = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_request", "Missing parameter: username");
context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse);
return;
}
UserModel user = null;
if (username != null) {
try {
user = KeycloakModelUtils.findUserByNameOrEmail(context.getSession(), context.getRealm(), username);
} catch (ModelDuplicateException mde) {
logger.error(mde);
Response challengeResponse = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_request", "Invalid user credentials");
context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse);
return;
}
}
// find User By attribute
if (user == null) {
List<UserModel> users = context.getSession().users()
.searchForUserByUserAttributeStream(context.getRealm(), attributeName, attributeValue)
.collect(Collectors.toList());
if (users.size() > 1) {
logger.error(new ModelDuplicateException("User with " + attributeName + "=" + attributeValue + " is not unique!"));
Response challengeResponse = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_request", "Invalid user credentials is not unique");
context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse);
return;
}
if (users.size() == 1) {
user = users.get(0);
}
}
if (user == null) {
context.getEvent().error(Errors.USER_NOT_FOUND);
Response challengeResponse = errorResponse(Response.Status.UNAUTHORIZED.getStatusCode(), "invalid_grant", "Invalid user credentials");
context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse);
return;
}
context.getEvent().detail(Details.USERNAME, user.getUsername());
context.getAuthenticationSession().setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, user.getUsername());
context.setUser(user);
context.success();
enticator.ATTEMPTED_USERNAME, user.getUsername());
context.setUser(user);
context.success();
}
指定属性值名称的逻辑
-
配置中的属性名称优先级最高,考虑的原则是管理员设置的安全性最高;
-
请求参数中的 “attributeName” 的值;
-
默认的 “phone”;
打包部署Provider
- META-INF.services 添加提供器信息
- maven 编译
mvn clean install
- 部署Jar包
把生成target下的jar包,拷贝到 $KC_HOME/standalone/deployments 目录下
keycloak启动时会自动解析加载该提供器
- 确认安装成功
登录Keycloak 管理控制台,在右上角下拉菜单中,选择 Server Info
查看Providers, 搜索我们的提供器ID,查看是否存在
定制校验流程并绑定
- 在Authentication的Flows中选择Direct Grant,并进行拷贝,命名为Direct Grant User Attribute
-
删除原有的Username Validation
-
添加执行器,选择我们定制的
添加后点击左侧箭头,移动到最顶部
-
配置属性名
-
全局绑定
-
客户端作用域绑定
测试验证
创建好两个测试用户,对应的属性值如下
- admin1
其中 两个 mobile的属性值一样;
phone 正确
phone 找不到
修改配置,指定属性名为openid
删除配置,就可以通过 attributeName 指定业务自己的属性名
小结
到这里基本上就实现了手机验证码登录的需求,这里的发送验证码,校验验证由各个可信业务方进行处理。
我们根据客户端的是否开启direct Grant,客户端scope,以及客户端绑定校验流程,严格控制相关权限的发放。