基于 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
部分代码如下
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