Keycloak实现手机验证码登录

背景说明

使用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统一的浏览器手机验证码登录

原理及实现

这种方式主要的工作原理是:

  1. 定制Keycloak登录主题,点击发送验证码有Keycloak服务进行验证码的发送,缓存和验证;

  2. 新增一个 keycloak-2fa-sms-authenticator 验证器,进行手机号和验证码的校验

具体实现代码可以参考:

在这里插入图片描述

对应的github地址

优缺点

优点是:

- 基于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();
}

指定属性值名称的逻辑

在这里插入图片描述

  1. 配置中的属性名称优先级最高,考虑的原则是管理员设置的安全性最高;

  2. 请求参数中的 “attributeName” 的值;

  3. 默认的 “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,以及客户端绑定校验流程,严格控制相关权限的发放。

  • 4
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 4
    评论
Keycloak是一个开源的身份和访问管理解决方案,它为应用程序和服务提供了安全的用户身份验证和授权功能。它支持多种身份提供者和协议,包括微信登录。 要使用Keycloak实现小程序的微信登录,首先需要在Keycloak中配置微信作为一个身份提供者。这需要获取微信开放平台的App ID和App Secret,将它们添加到Keycloak的身份提供者配置中。 一旦配置完成,开发人员可以在小程序中实现微信登录功能。首先,用户需要点击登录按钮来触发微信登录请求。在后台,开发人员需要将登录请求发送到Keycloak服务器,并将微信的App ID和App Secret作为参数一起发送。 Keycloak服务器会验证这些参数,并将微信的授权登录页面返回给小程序。用户将在微信授权登录页面上进行身份验证,并授权小程序访问其个人信息。 验证成功后,Keycloak服务器将通知小程序,并返回一个授权码。小程序将授权码发送回Keycloak服务器,以获取访问令牌和刷新令牌。 一旦小程序获得访问令牌和刷新令牌,它可以将其存储在本地,并将其用于后续的API调用和身份验证。这样,用户可以使用微信账号登录小程序,并访问其个人信息和其他受保护的资源。 总而言之,使用Keycloak实现小程序的微信登录需要在Keycloak中配置微信作为一个身份提供者,并在小程序中实现Keycloak的交互,以获取访问令牌和刷新令牌。这样,用户可以使用微信账号登录小程序,并进行身份验证和访问受保护的资源。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值