最近項目中須要實現對接釘釘,並實現單向通信錄同步(釘釘服務器
-> 對接平臺
)本文經過一個簡單的案例快速實現相關的DEMO (本文主要實現與釘釘對接)。html
釘釘API: https://open-doc.dingtalk.com...
在使用回調接口前,須要作如下準備工做:
1) 提供一個接收消息的RESTful接口。
2) 調用釘釘API,主動註冊回調通知。
3) 由於涉及到消息的加密解密,默認的JDK存在一些限制,先要替換相關jar:java
在官方網站下載JCE無限制權限策略文件
JDK6的下載地址: http://www.oracle.com/technet...
JDK7的下載地址: http://www.oracle.com/technet...
JDK8的下載地址: http://www.oracle.com/technet...
下載後解壓,能夠看到local_policy.jar和US_export_policy.jar以及readme.txt。
若是安裝的是JRE,將兩個jar文件放到%JRE_HOME% libsecurity目錄下覆蓋原來的文件,
若是安裝的是JDK,將兩個jar文件放到%JDK_HOME%jrelibsecurity目錄下覆蓋原來文件。
4) 內網穿透映射本地RESTful接口到公網,推薦使用Ngrok
: http://ngrok.ciqiuwl.cn/git
package com.wuwenze.dingtalk.rest; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; import com.mzlion.core.lang.Assert; import com.wuwenze.dingtalk.api.DingTalkConst; import com.wuwenze.dingtalk.encrpty.DingTalkEncryptor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import java.io.Serializable; import java.util.Map; /** * @author wwz * @version 1 (2018/7/26) * @since Java7 */ @Slf4j @RestController public class DingTalkCallbackRest { @PostMapping("/dingtalk/receive") public Map<String, ? extends Serializable> receive(// String signature, String timestamp, String nonce,@RequestBody String requestBody) { Assert.notNull(signature, "signature is null."); Assert.notNull(timestamp, "timestamp is null."); Assert.notNull(nonce, "nonce is null."); Assert.notNull(requestBody, "requestBody is null."); log.info("#receive 接收密文:{}", requestBody); DingTalkEncryptor dingTalkEncryptor = new DingTalkEncryptor(// DingTalkConst.CALLBACK_TOKEN, DingTalkConst.CALLBACK_AES_KEY, DingTalkConst.CORP_ID); JSONObject jsonEncrypt = JSON.parseObject(requestBody); String encryptMessage = dingTalkEncryptor.getDecryptMsg(signature, timestamp, nonce, jsonEncrypt.getString("encrypt")); log.info("#receive 密文解密後:{}", encryptMessage); // TODO: 異步處理報文,解析相關信息 // 返回加密後的success (快速響應) return dingTalkEncryptor.getEncryptedMsg("success", Long.parseLong(timestamp), nonce); } }
接口寫好以後,還須要將接口暴露在公網,如此釘釘服務器才能進行調用,下爲內網穿透示意圖:
github
釘釘爲咱們開發者提供了一個Ngrok服務,在https://github.com/open-dingt...,按照操做文章指引配置便可。web
我在這邊使用的是其餘的Ngrok服務,官網地址是http://ngrok.ciqiuwl.cn/,配置後啓動以下圖所示:
將本地的http://127.0.0.1:8080
映射到http://wuwz.ngrok.xiaomiqiu.cn
,最終提供給釘釘的回調接口地址即爲:http://wuwz.ngrok.xiaomiqiu.cn/dingtalk/receive
spring
以上準備工做完後成,就能夠將接口啓動起來,繼續後續的操做。json
寫一個測試方法,將
http://wuwz.ngrok.xiaomiqiu.cn/dingtalk/receive
註冊到釘釘,後續釘釘相關的消息都會推送到此處。
package com.wuwenze.dingtalk; import com.wuwenze.dingtalk.api.DingTalkApi; import com.wuwenze.dingtalk.api.DingTalkConst; import com.wuwenze.dingtalk.enums.DingTalkCallbackTag; /** * @author wwz * @version 1 (2018/7/27) * @since Java7 */ public class TestRegisterCallback { public static void main(String[] args) { // 獲取Token String accessToken = DingTalkApi.getAccessTokenCache(); // 先刪除以前註冊的回調接口 DingTalkApi.removeCallback(accessToken); // 註冊新的回調接口 String callbackToken = DingTalkConst.CALLBACK_TOKEN; String callbackAesKey = DingTalkConst.CALLBACK_AES_KEY; String callbackUrl = "http://wuwz.ngrok.xiaomiqiu.cn/dingtalk/receive"; DingTalkCallbackTag[] callbackTags = { DingTalkCallbackTag.USER_ADD_ORG, // 增長用戶 DingTalkCallbackTag.USER_MODIFY_ORG, // 修改用戶 DingTalkCallbackTag.USER_LEAVE_ORG // 用戶離職 }; DingTalkApi.registerCallback(accessToken, callbackToken, callbackAesKey, callbackUrl, callbackTags); } }
執行代碼,若是一切不出意外的話,就註冊成功了(註冊的過程當中需保證callbackUrl能夠正常訪問,由於首次會向該接口發送一條check_url事件,驗證其合法性)api
// 獲取Token 13:44:41.342 [main] INFO com.wuwenze.dingtalk.api.DingTalkApi - {"access_token":"9990578f789c3fb1a9d974c268df5029","errcode":0.0,"errmsg":"ok","expires_in":7200.0} // 先刪除以前註冊的回調接口 13:44:41.438 [main] INFO com.wuwenze.dingtalk.api.DingTalkApi - {"errcode":0.0,"errmsg":"ok"} // 註冊新的回調接口 13:44:41.888 [main] INFO com.wuwenze.dingtalk.api.DingTalkApi - {"errcode":0.0,"errmsg":"ok"} 13:44:41.893 [main] INFO com.wuwenze.dingtalk.api.DingTalkApi - #registerCallback 註冊回調接口 -> url: http://wuwz.ngrok.xiaomiqiu.cn/dingtalk/receive, tags: tag: user_add_org, describe: 通信錄用戶增長 + tag: user_modify_org, describe: 通信錄用戶更改 + tag: user_leave_org, describe: 通信錄用戶離職
另外再來觀察一下回調接口是否收到checkUrL消息:數組
2018-07-27 13:44:41.823 INFO 2392 --- [nio-8080-exec-1] c.w.dingtalk.rest.DingTalkCallbackRest : #receive 接收密文:{"encrypt":"JfRo/wn+E1agXgk1uN5UQP/WDv0RvWnw8TgXC/ucatBxYm54OSUcGn5uTGCVMaGIN6Lv24ZOujH/uixB39AKxjXWgzdJQ1Eq4HD0EIJFG+QY8mjcCltvhX0QfhisFlll"} 2018-07-27 13:44:41.823 INFO 2392 --- [nio-8080-exec-1] c.w.dingtalk.rest.DingTalkCallbackRest : #receive 密文解密後:{"EventType":"check_url"}
在上一步中,註冊了USER_ADD_ORG
(增長用戶)、USER_MODIFY_ORG
(修改用戶)、USER_LEAVE_ORG
(用戶離職|刪除)三個事件
打開釘釘後臺管理,在通信錄中新增一個用戶:
保存成功後,在回調接口中則立刻收到了該事件的通知消息:服務器
2018-07-27 13:49:55.985 INFO 2392 --- [nio-8080-exec-3] c.w.dingtalk.rest.DingTalkCallbackRest : #receive 接收密文:{"encrypt":"g6RsagVKTVUS2Gg7B1JSn81uJPgCpPKoaRN4kps4cMpp6CuqW1QahaDP8TcnwDP2fYyG0gwLFvF5cOWbn+lKX2kq4UYe5m08BB/FWw8lALV/4LYu7RI6OARCFDTsllBTs4W6/OUv+9AyYlWGmwK2ZYnXoFyiK4DqFt6jenp45NCXwvSgssjn8RsD/3E7kfw5DL/mfr4L3hkaBysmkU2ohaFFEqBO1r63cj+mONLsD8Dvr2lAsefBoMdZ2JV5sIIePuKhz08G6KnJDvkAqcm59naV6AIbDLouWrBK7upCP7Q="} 2018-07-27 13:49:55.985 INFO 2392 --- [nio-8080-exec-3] c.w.dingtalk.rest.DingTalkCallbackRest : #receive 密文解密後:{"TimeStamp":"1532670599144","CorpId":"dingb9875d6606f892ed35c2f4657eb6378f","UserId":["202844352662984130"],"EventType":"user_add_org"}
在上面的例子中新增用戶後,收到的報文解密後的信息爲只包含事件類型和用戶ID,因此後面還須要主動調用釘釘獲取用戶詳情的接口,再作具體的同步邏輯,這裏就再也不往下寫了,貼一下相關的API接口吧:
https://open-doc.dingtalk.com...
下面羅列了以上示例中用到的工具類封裝,再也不具體講解,直接貼代碼
常量池
public class DingTalkConst { public final static String CORP_ID = "dingb9875d6606f892ed35c2f4657eb6378f"; public final static Object CORP_SECRET = "到釘釘查看"; public final static String CALLBACK_TOKEN = "token"; // 回調Token public final static String CALLBACK_AES_KEY = "xxxxx7p5qnb6zs3xxxxxlkfmxqfkv23d40yd0xxxxxx"; // 回調祕鑰,43個隨機字符 }
可供註冊的回調事件類型枚舉
public enum DingTalkCallbackTag { USER_ADD_ORG("通信錄用戶增長"), USER_MODIFY_ORG("通信錄用戶更改"), USER_LEAVE_ORG("通信錄用戶離職"), ORG_ADMIN_ADD("通信錄用戶被設爲管理員"), ORG_ADMIN_REMOVE("通信錄用戶被取消設置管理員"), ORG_DEPT_CREATE("通信錄企業部門建立"), ORG_DEPT_MODIFY("通信錄企業部門修改"), ORG_DEPT_REMOVE("通信錄企業部門刪除"), ORG_REMOVE("企業被解散"), ORG_CHANGE("企業信息發生變動"), LABEL_USER_CHANGE("員工角色信息發生變動"), LABEL_CONF_ADD("增長角色或者角色組"), LABEL_CONF_DEL("刪除角色或者角色組"), LABEL_CONF_MODIFY("修改角色或者角色組"); private String describe; DingTalkCallbackTag(String describe) { this.describe = describe; } public String getDescribe() { return describe; } public void setDescribe(String describe) { this.describe = describe; } @Override public String toString() { return super.toString().toLowerCase(); } public String toInfoString() { return String.format("tag: %s, describe: %s", this.toString(), this.getDescribe()); } }
釘釘消息加密解密工做類
package com.wuwenze.dingtalk.encrpty; import com.google.common.collect.ImmutableMap; import com.mzlion.core.binary.Base64; import com.mzlion.core.lang.Assert; import javax.crypto.Cipher; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; import java.io.ByteArrayOutputStream; import java.io.Serializable; import java.nio.charset.Charset; import java.security.MessageDigest; import java.util.Arrays; import java.util.Map; import java.util.Random; /** * @author wwz * @version 1 (2018/7/26) * @since Java7 */ public class DingTalkEncryptor { private static final Charset CHARSET = Charset.forName("UTF-8"); private byte[] aesKey; private String token; private String corpId; /** * ask getPaddingBytes key固定長度 **/ private static final Integer AES_ENCODE_KEY_LENGTH = 43; /** * 加密隨機字符串字節長度 **/ private static final Integer RANDOM_LENGTH = 16; /** * 構造函數 * * @param token 釘釘開放平臺上,開發者設置的token * @param encodingAesKey 釘釘開放臺上,開發者設置的EncodingAESKey * @param corpId ISV進行配置的時候應該傳對應套件的SUITE_KEY(第一次建立時傳的是默認的CREATE_SUITE_KEY),普通企業是Corpid */ public DingTalkEncryptor(String token, String encodingAesKey, String corpId) { if (null == encodingAesKey || encodingAesKey.length() != AES_ENCODE_KEY_LENGTH) { throw new IllegalArgumentException("encodingAesKey is null"); } this.token = token; this.corpId = corpId; this.aesKey = Base64.decode(encodingAesKey + "="); } /** * 將和釘釘開放平臺同步的消息體加密,返回加密Map * * @param message 傳遞的消息體明文 * @param timeStamp 時間戳 * @param nonce 隨機字符串 * @return */ public Map<String, ? extends Serializable> getEncryptedMsg(String message, Long timeStamp, String nonce) { Assert.notNull(message, "plaintext is null"); Assert.notNull(timeStamp, "timeStamp is null"); Assert.notNull(nonce, "nonce is null"); String encrypt = encrypt(getRandomStr(RANDOM_LENGTH), message); String signature = getSignature(token, String.valueOf(timeStamp), nonce, encrypt); return ImmutableMap.of( "msg_signature", signature, // "encrypt", encrypt, // "timeStamp", timeStamp,// "nonce", nonce); } /** * 密文解密 * * @param msgSignature 簽名串 * @param timeStamp 時間戳 * @param nonce 隨機串 * @param encryptMsg 密文 * @return 解密後的原文 */ public String getDecryptMsg(String msgSignature, String timeStamp, String nonce, String encryptMsg) { // 校驗簽名 String signature = getSignature(token, timeStamp, nonce, encryptMsg); if (!signature.equals(msgSignature)) { throw new RuntimeException("校驗簽名失敗。"); } // 解密 return decrypt(encryptMsg); } private String encrypt(String random, String plaintext) { try { byte[] randomBytes = random.getBytes(CHARSET); byte[] plainTextBytes = plaintext.getBytes(CHARSET); byte[] lengthByte = int2Bytes(plainTextBytes.length); byte[] corpidBytes = corpId.getBytes(CHARSET); ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); byteStream.write(randomBytes); byteStream.write(lengthByte); byteStream.write(plainTextBytes); byteStream.write(corpidBytes); byte[] padBytes = PKCS7Padding.getPaddingBytes(byteStream.size()); byteStream.write(padBytes); byte[] unencrypted = byteStream.toByteArray(); byteStream.close(); Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding"); SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES"); IvParameterSpec iv = new IvParameterSpec(aesKey, 0, 16); cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv); byte[] encrypted = cipher.doFinal(unencrypted); return Base64.encode(encrypted); } catch (Exception e) { throw new RuntimeException(e); } } /** * 對密文進行解密. * @param text 須要解密的密文 * @return 解密獲得的明文 */ private String decrypt(String text) { byte[] originalArr; try { // 設置解密模式爲AES的CBC模式 Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding"); SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES"); IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16)); cipher.init(Cipher.DECRYPT_MODE, keySpec, iv); // 使用BASE64對密文進行解碼, 解密 originalArr = cipher.doFinal(Base64.decode(text)); } catch (Exception e) { throw new RuntimeException("計算解密文本錯誤"); } String plainText; String fromCorpid; try { // 去除補位字符 byte[] bytes = PKCS7Padding.removePaddingBytes(originalArr); // 分離16位隨機字符串,網絡字節序和corpId byte[] networkOrder = Arrays.copyOfRange(bytes, 16, 20); int plainTextLegth = bytes2int(networkOrder); plainText = new String(Arrays.copyOfRange(bytes, 20, 20 + plainTextLegth), CHARSET); fromCorpid = new String(Arrays.copyOfRange(bytes, 20 + plainTextLegth, bytes.length), CHARSET); } catch (Exception e) { throw new RuntimeException("計算解密文本長度錯誤"); } // corpid不相同的狀況 if (!fromCorpid.equals(corpId)) { throw new RuntimeException("計算文本密碼錯誤"); } return plainText; } /** * 數字簽名 * @param token isv token * @param timestamp 時間戳 * @param nonce 隨機串 * @param encrypt 加密文本 * @return */ public String getSignature(String token, String timestamp, String nonce, String encrypt) { try { String[] array = new String[]{token, timestamp, nonce, encrypt}; Arrays.sort(array); StringBuffer sb = new StringBuffer(); for (int i = 0; i < 4; i++) { sb.append(array[i]); } String str = sb.toString(); MessageDigest md = MessageDigest.getInstance("SHA-1"); md.update(str.getBytes()); byte[] digest = md.digest(); StringBuffer hexstr = new StringBuffer(); String shaHex = ""; for (int i = 0; i < digest.length; i++) { shaHex = Integer.toHexString(digest[i] & 0xFF); if (shaHex.length() < 2) { hexstr.append(0); } hexstr.append(shaHex); } return hexstr.toString(); } catch (Exception e) { throw new RuntimeException(e); } } public static String getRandomStr(int count) { String base = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; Random random = new Random(); StringBuffer sb = new StringBuffer(); for (int i = 0; i < count; i++) { int number = random.nextInt(base.length()); sb.append(base.charAt(number)); } return sb.toString(); } /** * int轉byte數組,高位在前 */ public static byte[] int2Bytes(int count) { byte[] byteArr = new byte[4]; byteArr[3] = (byte) (count & 0xFF); byteArr[2] = (byte) (count >> 8 & 0xFF); byteArr[1] = (byte) (count >> 16 & 0xFF); byteArr[0] = (byte) (count >> 24 & 0xFF); return byteArr; } /** * 高位在前bytes數組轉int * @param byteArr * @return */ public static int bytes2int(byte[] byteArr) { int count = 0; for (int i = 0; i < 4; i++) { count <<= 8; count |= byteArr[i] & 0xff; } return count; } }
package com.wuwenze.dingtalk.encrpty; import java.nio.charset.Charset; import java.util.Arrays; /** * @author wwz * @version 1 (2018/7/10) * @since Java7 */ public class PKCS7Padding { private final static Charset CHARSET = Charset.forName("utf-8"); private final static int BLOCK_SIZE = 32; /** * 填充mode字節 * @param count * @return */ public static byte[] getPaddingBytes(int count) { int amountToPad = BLOCK_SIZE - (count % BLOCK_SIZE); if (amountToPad == 0) { amountToPad = BLOCK_SIZE; } char padChr = chr(amountToPad); String tmp = new String(); for (int index = 0; index < amountToPad; index++) { tmp += padChr; } return tmp.getBytes(CHARSET); } /** * 移除mode填充字節 * @param decrypted * @return */ public static byte[] removePaddingBytes(byte[] decrypted) { int pad = (int) decrypted[decrypted.length - 1]; if (pad < 1 || pad > BLOCK_SIZE) { pad = 0; } return Arrays.copyOfRange(decrypted, 0, decrypted.length - pad); } private static char chr(int a) { byte target = (byte) (a & 0xFF); return (char) target; } }
釘釘開放API簡易封裝 (僅供測試)
package com.wuwenze.dingtalk.api; import com.alibaba.fastjson.JSON; import com.alibaba.fastjson.JSONObject; import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import com.google.common.collect.ImmutableMap; import com.mzlion.core.lang.Assert; import com.mzlion.easyokhttp.HttpClient; import com.wuwenze.dingtalk.enums.DingTalkCallbackTag; import lombok.extern.slf4j.Slf4j; import java.io.Serializable; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; /** * @author wwz * @version 1 (2018/7/26) * @since Java7 */ @Slf4j public class DingTalkApi { private final static LoadingCache<String, String> mTokenCache = // CacheBuilder.newBuilder()// .maximumSize(100)// .expireAfterAccess(7200, TimeUnit.SECONDS)// .build(new CacheLoader<String, String>() { @Override public String load(String key) throws Exception { // key:corpId#corpSecret String[] params = key.split("#"); if (params.length != 2) { throw new RuntimeException("#loadTokenCache error"); } return getAccessToken(params[0], params[1]); } }); public static String getAccessToken(String corpId, String corpSecret) { String url = String.format("https://oapi.dingtalk.com/gettoken?corpid=%s&corpsecret=%s",corpId, corpSecret); JSONObject jsonObject = HttpClient.get(url).asBean(JSONObject.class); assertDingTalkJSONObject(jsonObject); return jsonObject.getString("access_token"); } public static String getAccessTokenCache() { try { return mTokenCache.get(DingTalkConst.CORP_ID + "#" + DingTalkConst.CORP_SECRET); } catch (ExecutionException e) { return null; } } public static void registerCallback(String accessToken, String callbackToken, String callbackAesKey, String url, DingTalkCallbackTag ... tags) { Assert.notNull(accessToken, "accessToken is null"); Assert.notNull(callbackToken, "callbackToken is null"); Assert.notNull(callbackAesKey, "callbackAesKey is null"); Assert.notNull(url, "url is null"); if (tags.length < 1) { throw new IllegalArgumentException("至少指定一個回調事件類型。"); } String[] callbackTagArray = new String[tags.length]; for (int i = 0; i < tags.length; i++) { callbackTagArray[i] = tags[i].toString(); } ImmutableMap<String, Serializable> params = ImmutableMap.of(// "call_back_tag", callbackTagArray,// "token", callbackToken,// "aes_key", callbackAesKey, // "url", url// ); String apiUrl = "https://oapi.dingtalk.com/call_back/register_call_back?access_token=" + accessToken; assertDingTalkJSONObject(// HttpClient.textBody(apiUrl).json(JSON.toJSONString(params)).asBean(JSONObject.class) ); log.info("#registerCallback 註冊回調接口 -> url: {}, tags: {}", url, showTagsInfo(tags)); } private static String showTagsInfo(DingTalkCallbackTag ... tags) { StringBuffer stringBuffer = new StringBuffer(); for (DingTalkCallbackTag tag : tags) { stringBuffer.append(tag.toInfoString()).append(" + "); } return stringBuffer.toString(); } public static void removeCallback(String accessToken) { String apiUrl = "https://oapi.dingtalk.com/call_back/delete_call_back?access_token=" + accessToken; assertDingTalkJSONObject(// HttpClient.get(apiUrl).asBean(JSONObject.class) ); } public static void removeCallback() { removeCallback(getAccessTokenCache()); } public static void registerCallback(String url, DingTalkCallbackTag ... tags) { registerCallback(getAccessTokenCache(), DingTalkConst.CALLBACK_TOKEN, DingTalkConst.CALLBACK_AES_KEY, url, tags); } private static void assertDingTalkJSONObject(JSONObject jsonObject) { log.info(jsonObject.toJSONString()); int errcode = jsonObject.getIntValue("errcode"); if (errcode != 0) { throw new RuntimeException(jsonObject.getString("errmsg")); } } }