Keycloak OAuth2 Idp插件开发手册


基于 Keycloak 12.0.4 版本

前言,为了方便 keycloak 测试,推荐在配置文件中显示行号

以 standalone 模式为例

vim ${keycloak_home}/standalone/configuration/standalone.xml

#修改对应的 pattern
  				<formatter name="PATTERN">
                <pattern-formatter pattern="%d{yyyy-MM-dd HH:mm:ss,SSS} %-5p [%c:%L] (%t) %s%e%n"/>
            </formatter>
            <formatter name="COLOR-PATTERN">
                <pattern-formatter pattern="%K{level}%d{HH:mm:ss,SSS} %-5p [%c:%L] (%t) %s%e%n"/>
            </formatter>
            
            
 # 添加 %L 到自己喜欢的位置,我这边设置为类的后面 %c:%L

钉钉项目地址:https://github.com/RRRRIC/keycloak-service-social-dingtalk

以新版钉钉认证 API 为例,首先展示完整的插件目录结构

.
├── README.md
├── pom.xml
├── src
│   └── main
│       ├── java
│       │   └── org
│       │       └── keycloak
│       │           └── social
│       │               └── dingtalk
│       │                   ├── DingtalkIdentityProvider.java           # 实行 IDP 功能
│       │                   ├── DingtalkIdentityProviderFactory.java    # 创建 IDP 工厂
│       │                   ├── DingtalkProviderConfig.java             # 获取自定义配置文件
│       │                   ├── DingtalkUserAttributeMapper.java        # 用户信息与相应结果字段匹配
│       │                   └── DingtalkUtils.java                      # 自定义的钉钉相关工具类
│       └── resources
│           ├── META-INF
│           │   └── services
│           │       ├── org.keycloak.broker.provider.IdentityProviderMapper
│           │       └── org.keycloak.broker.social.SocialIdentityProviderFactory
│           └── jboss-deployment-structure.xml
└── themes
    └── base
        └── admin
            └── resources
                └── partials
                    ├── realm-identity-provider-dingtalk-ext.html
                    └── realm-identity-provider-dingtalk.html

15 directories, 12 files

其中 resource 目录下为固定格式

org.keycloak.broker.provider.IdentityProviderMapper

文件内容为(只有一行)

org.keycloak.social.dingtalk.DingtalkUserAttributeMapper

对应项目中的自定义 mapper

org.keycloak.broker.social.SocialIdentityProviderFactory

文件内容为(只有一行)

org.keycloak.social.dingtalk.DingtalkIdentityProviderFactory

对应项目中的自定义 Factory

jboss-deployment-structure.xml 为项目结构,因为该项目采用了 infinispan , 所以也需要添加到 dependencies 中。

因为 Jboss 服务器统一采用 infinispan 作为缓存,并且可以在 keycloak 配置中设置成分布式。

所以推荐采用 infinispan 作为统一的缓存处理,在后续搭建集群或者其他分布式时可以实现缓存共享。

<?xml version="1.0" encoding="UTF-8"?>
<jboss-deployment-structure>
    <deployment>
        <dependencies>
            <module name="org.infinispan"/>
            <module name="org.keycloak.keycloak-services" />
        </dependencies>
    </deployment>
</jboss-deployment-structure>

并且在 Keycloak 中添加对应的模块

需要在 ${keycloak_home}/modules/system/layers/keycloak/org/keycloak/keycloak-services/main/module.xml 中添加 infinispan 依赖。

    <dependencies>
        <module name="org.infinispan" services="import"/>
        ....
    </dependencies>

在 src 的 Java 目录下,这四个是必须的

  • DingtalkIdentityProvider.java
  • DingtalkIdentityProviderFactory.java
  • DingtalkProviderConfig.java
  • DingtalkUserAttributeMapper.java

而 DingtalkUtils 是为了简化 Provider 中的代码独立出来的。

对应类的功能

DingtalkIdentityProvider

主要用于创建我们所自定义的 Provider,并且与自定义配置相对应,完整代码如下

public class DingtalkIdentityProviderFactory extends AbstractIdentityProviderFactory<DingtalkIdentityProvider> implements SocialIdentityProviderFactory<DingtalkIdentityProvider> {

    public static final String PROVIDER_ID = "dingtalk";

    @Override
    public String getName() {
        return "Dingtalk";
    }

    @Override
    public DingtalkIdentityProvider create(KeycloakSession session, IdentityProviderModel model) {
        return new DingtalkIdentityProvider(session, new DingtalkProviderConfig(model));
    }

    @SuppressWarnings("unchecked")
	@Override
    public DingtalkProviderConfig createConfig() {
        return new DingtalkProviderConfig();
    }

    @Override
    public String getId() {
        return PROVIDER_ID;
    }
}

DingtalkUserAttributeMapper

主要用于钉钉返回的数据与 Keycloak 中相关数据的对齐,部分代码如下

public class DingtalkUserAttributeMapper extends AbstractJsonUserAttributeMapper {

		public static final String PROVIDER_ID = "dingtalk-user-attribute-mapper";
		private static final String[] cp = new String[] { DingtalkIdentityProviderFactory.PROVIDER_ID };

		@Override
		public String[] getCompatibleProviders() {
			return cp;
		}

		@Override
		public String getId() {
			return PROVIDER_ID;
		}

	
  	  @Override
   	 public void updateBrokeredUser(KeycloakSession session, RealmModel realm, UserModel user, IdentityProviderMapperModel mapperModel, BrokeredIdentityContext context) {
			user.setUsername(context.getUsername());
			user.setEmail(context.getEmail());
			user.setFirstName(context.getFirstName());
			user.setLastName(context.getLastName());

      // 扩展属性
			user.setSingleAttribute(DingtalkIdentityProvider.PROFILE_UNIONID, context.getUserAttribute(DingtalkIdentityProvider.PROFILE_UNIONID));
			user.setSingleAttribute(DingtalkIdentityProvider.PROFILE_BOSS, context.getUserAttribute(DingtalkIdentityProvider.PROFILE_BOSS));
			....

		}
	
}

DingtalkProviderConfig

一定要保证此类执行序列化.

用于获取参数,由前端传递,对应的页面文件为

realm-identity-provider-dingtalk.html

由于钉钉在进行 OAuth2 时还需要携带企业ID, 可以根据页面的格式插入一个格式相同的模块

 <div class="form-group clearfix">
                <label class="col-md-2 control-label" for="corpId"><span class="required">*</span> {{:: 'CorpId' | translate}}</label>
                <div class="col-md-6">
                    <input class="form-control" id="corpId" type="text" placeholder="dingtalk company id" ng-model="identityProvider.config.corpId" required>
                </div>
                <kc-tooltip>{{:: 'social.corp-id.tooltip' | translate}}</kc-tooltip>
            </div>

其中要修改的是 for属性, id 属性和 ng-model 属性。

其中 ng-model 对应到 model-config, 会传递到 DingtalkProviderConfig ,只需要填写 getter 即可,get 的所需要的 key 为 corpId, 区分大小写。

对应 Java 中的部分代码为

public class DingtalkProviderConfig extends OAuth2IdentityProviderConfig implements Serializable {
  
    private static final String corpId = "corpId";
  
    public DingtalkProviderConfig(IdentityProviderModel model) {
        super(model);
    }

    public DingtalkProviderConfig() {

    }

    public String getCorpId() {
        return getConfig().get(corpId);
    }

}

DingtalkIdentityProvider

继承与 AbstractOAuth2IdentityProvider

其中有几个关键方法为:

  • performLogin

    • 用于执行登陆跳转, 由于钉钉在内部可以实现 SNS 免登,所以需要根据 user-agent 来跳转到不同的连接。
  • extractIdentityFromProfile

    • 用于将返回结果的 JSON 映射到用户信息中
  • getDefaultScopes

    • OAuth2 中的 scope, 此方法为抽象方法,必须实现。 但是由于钉钉不同登陆方法 scope 不相同,所以该方法在钉钉中其实未起到作用。
  • getFederatedIdentity***

    • 用于将回调结果中的数据进行处理到 user 中
  • Endpoint 类

    • 为处理回调请求的类,类似于 Java web 中的 @WebServlet

performLogin

首先展示 performLogin 和其对应的 createAuthorizationUrl 方法。

其中各个参数可以在钉钉官网 API 中进行核对.

最后返回的 Response.seeOther 方法相当于 设置 response 的 code 为 302, localtion 为 Url 的地址。

    @Override
    public Response performLogin(AuthenticationRequest request) {
        try {
            URI authorizationUrl = createAuthorizationUrl(request).build();
            logger.info("auth url " + authorizationUrl.toString());
            return Response.seeOther(authorizationUrl).build();
        } catch (Exception e) {
            e.printStackTrace(System.out);
            throw new IdentityBrokerException("Could not create authentication request. ", e);
        }
    }

		protected UriBuilder createAuthorizationUrl(AuthenticationRequest request) {
        String userAgent = request.getHttpRequest().getHttpHeaders().getHeaderString("user-agent").toLowerCase(Locale.ROOT);
        if (userAgent.contains("dingtalk")) {
            // 在钉钉内部调用自动登录模块
            return UriBuilder.fromUri(getConfig().getSnsUrl())
                    .queryParam(OAUTH2_PARAMETER_SCOPE, getConfig().getSnsScope())
                    .queryParam(OAUTH2_PARAMETER_STATE, request.getState().getEncoded())
                    .queryParam(OAUTH2_PARAMETER_RESPONSE_TYPE, "code")
                    .queryParam(DINGTALK_PARAMETER_APP_ID, getConfig().getClientId())
                    .queryParam(OAUTH2_PARAMETER_REDIRECT_URI, request.getRedirectUri());
        } else {
            // 在非钉钉内部,调用 OAuth2 模块
            return UriBuilder.fromUri(getConfig().getOAuth2Url())
                    .queryParam(OAUTH2_PARAMETER_SCOPE, getConfig().getOauth2Scope())
                    .queryParam(OAUTH2_PARAMETER_STATE, request.getState().getEncoded())
                    .queryParam(OAUTH2_PARAMETER_RESPONSE_TYPE, "code")
                    .queryParam(OAUTH2_PARAMETER_CLIENT_ID, getConfig().getClientId())
                    .queryParam(OAUTH2_PARAMETER_REDIRECT_URI, request.getRedirectUri())
                    .queryParam(DINGTALK_PARAMETER_CORP_ID, getConfig().getCorpId())
                    .queryParam("prompt", "consent");
        }
    }

Endpoint 内部类

类似于 Java web 中的默认的 @WebServlet ,其中有一个函数为 authResponse. 为登陆成功后的回调请求

类似于 Spring , 可以在接受参数中设置自定义返回值。 因为通过钉钉 SNS 和 OAuth2 返回的参数名不同,所以可以自定义为以下方法

        @GET  // 首先声明这个 servlet 可以接受 Get 请求
        public Response authResponse(@QueryParam(OAUTH2_PARAMETER_STATE) String state,
                                     @QueryParam(OAUTH2_PARAMETER_CODE) String snsCode,
                                     @QueryParam(DINGTALK_OAUTH2_CODE) String oauth2Code,
                                     @QueryParam(OAuth2Constants.ERROR) String error) {
        }

通过 SNS 方法登陆钉钉回调的参数为 code=xxxx ,而通过 OAuth2 登陆返回的参数为 oauthCode=xxxx

所以我们也可以通过此函数名来定义后续请求用户信息所使用的方法。

部分代码如下, 其中 event 为记录流程, federatedIdentity 为返回到 Keycloak 的属性,必须将 state 设置到 federatedIdentity 的 code 属性中,用于 Keycloak 内部标记用户。

   			BrokeredIdentityContext federatedIdentity;
        if (!DingtalkUtils.isBlank(snsCode)) {
            federatedIdentity = getFederatedIdentityBySNS(snsCode);
        } else {
            if (!DingtalkUtils.isBlank(oauth2Code)) {
                federatedIdentity = getFederatedIdentityByOAuth2(oauth2Code);
             } else {
                 throw new Exception("Receive empty code");
             }
        }

         // 必须将 state 设置到 code 中 ,否则会报错, 切记!!!!
         // 此 code 非请求返回的 code, 而是 state
         federatedIdentity.setCode(state);
         federatedIdentity.setIdpConfig(getConfig());
         federatedIdentity.setIdp(DingtalkIdentityProvider.this);
         event.user(federatedIdentity.getBrokerUserId());
         event.client(getConfig().getClientId());
         return callback.authenticated(federatedIdentity);

getFederatedIdentityBy***

其实在 AbstractOAuth2IdentityProvider 并没有 By后缀,但是因为钉钉支持不同的登陆方法,所以特意将其区分开。

有两个方法,分别为:

  • getFederatedIdentityBySNS

  • getFederatedIdentityByOAuth2

这两个方法主要根据钉钉业务内部来实现,代码如下

    public BrokeredIdentityContext getFederatedIdentityBySNS(String code) {
        try {
            JsonNode userinfoJson;
            String clientId = getConfig().getClientId();
            String clientSecret = getConfig().getClientSecret();


            String unionId = DingtalkUtils.getUserUnionIdBySns(session, clientId, clientSecret, code);
            userinfoJson = DingtalkUtils.getUserInfoByUnionId(session, clientId, clientSecret, unionId);

            return extractIdentityFromProfile(null, userinfoJson);

        } catch (Exception e) {
            throw new IdentityBrokerException("Could not obtain user profile from dingtalk." + e.getMessage(), e);
        }
    }

    public BrokeredIdentityContext getFederatedIdentityByOAuth2(String code) {
        try {
            JsonNode userinfoJson;
            String clientId = getConfig().getClientId();
            String clientSecret = getConfig().getClientSecret();

            // 采用 OAuth2 登陆流程
            String accessToken = DingtalkUtils.getUserAccessTokenByOAuth2Code(session, clientId, clientSecret, code);
            userinfoJson = DingtalkUtils.getUserInfoByUserAccessToken(session, clientId, clientSecret, accessToken);

            return extractIdentityFromProfile(null, userinfoJson);
        } catch (Exception e) {
            throw new IdentityBrokerException("Could not obtain user profile from dingtalk." + e.getMessage(), e);
        }
    }

extractIdentityFromProfile

用于将用户信息映射到 BrokeredIdentityContext

必须要填写他提供的这些属性,否则可能需要用户自身来添加信息:

  • id
  • username
  • modelUsername
  • firestName
  • lastName
  • email

部分代码如下

    protected BrokeredIdentityContext extractIdentityFromProfile(EventBuilder event, JsonNode profile) {
        user.setEmail(email);

        // 用户唯一 ID Union ID
        user.setUserAttribute(PROFILE_UNIONID, getJsonProperty(profile, PROFILE_UNIONID));

        // 是否为老板
        user.setUserAttribute(PROFILE_BOSS, getJsonProperty(profile, PROFILE_BOSS));
      
        .....
    }

DingtalkUtils

主要是一些业务请求的抽出

部分代码如下

    /**
     * 官方连接 https://open.dingtalk.com/document/orgapp-server/obtain-orgapp-token
     *
     * @param session      当前的 Keycloak Session
     * @param clientId     企业的 appID
     * @param clientSecret 企业的 appSecret
     * @return 企业的 access token
     */
    public static String getCorAccessToken(KeycloakSession session,
                                           String clientId,
                                           String clientSecret) throws Exception {

        String corAccessToken = DingtalkCache.get(DINGTALK_CORP_ACCESS_TOKEN + clientId);
        if (!isBlank(corAccessToken)) {
            return corAccessToken;
        }
        String accessTokenUrl = corAccessTokenUrl + "?" +
                "appkey=" + clientId +
                "&appsecret=" + clientSecret;
        JsonNode responseJson = SimpleHttp.doGet(accessTokenUrl, session).asJson();
        if (responseJson.get("errcode") != null && responseJson.get("errcode").asInt(-1) != 0) {
            logger.warn("Failed to get cor id, response : " + responseJson);
            throw new Exception("Failed to get cor id");
        }
        logger.info(responseJson);
        corAccessToken = responseJson.get("access_token").asText();
        int expireIn = responseJson.get("expires_in").asInt(0);
        expireIn = (int) (expireIn * 0.9);
        DingtalkCache.put(DINGTALK_CORP_ACCESS_TOKEN + clientId, corAccessToken, expireIn, TimeUnit.SECONDS);
        return corAccessToken;
    }

如果需要详细代码,可以直接访问 github 寻找源码
https://github.com/RRRRIC/keycloak-service-social-dingtalk


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM