基於 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