背景
在对接钉钉开放平台时, 会出现需要钉钉开放平台回调我们项目的情况. 而一般项目都被部署在公司内网. 因此, 我们需要进行内网穿透. 常用内网穿透工具对比如下表. 可以看到钉钉是在对接钉钉开放平台时, 最优的选择… 本文将详细介绍自己和钉钉企业应用网关对接和搭建的整体流程
常用内网穿工具透比较
企业应用网关
其实在之前, 钉钉还提供一种内网穿透. 但因为因安全合规、服务资源和维护成本等原因,钉钉于2022年7月21日起,不再提供内网穿透的工具服务,若需要在本地或开发测试环境调试中有内网穿透的需求,请参考文档自行搭建的frp内网穿透服务. 因此, 如果企业有这方面需求, 本着安全性, 稳定性, 简易性来说, 钉钉企业应用网关也是一个不错的选择. 而且可以联系商务搭建试用版平台. 点击进入官网地址
是什么
企业应用网关, 又称为钉钉零信任网关, 作用是为企业提供了内网应用在外网安全访问的能力,可以替代传统的V**方案,基于网络加速能力提升应用访问速度. 该产品以零信任为理念,提供持续动态的访问准入校验,最大程度上保障企业数字信息安全
用户痛点
企业通常会将核心应用系统放入企业内网或DMZ区,通过防火墙建立网络边界隔离. 如果企业员工在互联网侧通过移动设备或PC设备访问企业内网应用时,通过使用2种方式:V**拨号和端口映射. 但是由于V**存在设备漏洞或端口映射到外网IP或URL,黑客可以直接访问或攻击企业应用,导致企业核心数据泄露
企业原有内网应用访问方式,如下图所示
钉钉企业应用网关总体架构,如下图所示
企业用户内网应用访问推荐以下五种解决方案
怎么用
如果习惯看官网做的话, 可以参照企业应用网关配置流程 进行搭建.
搭建和踩坑过程
搭建过程
准备工作
- 已开通企业应用网关。如果未开通,需要使用移动端钉钉扫描下方二维码或进入此页面扫码二维码, 并安装钉钉企业应用网关应用
- 扫描后会出现下图页面 如果企业开通该功能, 需要该账户级别为管理员(子管理员也不行)
查看企业网关应用
- 当与客户侧沟通成功扫描上方二维码后,客户侧可以通过下图获取到开通成功的提示信息:
- 在钉钉的管理后台 -> 工作台 -> 三方应用里面看到钉钉安全网关的入口,点击即可进入安全网关后台
进入企业网关首页
企业网关首页
配置连通器
- 前提准备 需要注意, 如果需要部署在linux服务器, 则需要满足下面条件(Windows也需保证满足如下条件)
- 创建连通器 选择新建连通器, 选择部署类型之后, 点击继续, 生成以下命令, 然后复制下面linux命令
- 在linux服务器上进行执行复制的命令后, 可以看到连通器已经启动成功
- 新建连通器组 将新建的连通器加入连通器组, 连通器组的作用: 批量管理连通器, 在后面应用管理时使用
配置应用管理
准备前提
- 新建应用 方式一: 在管理后台-工作台新建第三方应用
方式二: 在钉钉开放平台根据使用场景去创建应用
- 根据需要填写应用信息
- 配置成功后, 自动生成AgentId, AppKey, AppSecret, 用于接口对接时使用
- 发布应用(开发程序完成后使用) 应用发布后, 才能被其他用户看到(这里指在自己的工作台上看到, 而不是管理后台的工作台上看到). 体验版本发布属于灰度发布.点击查看应用发布介绍
配置应用
- 创建好应用之后, 在应用管理里面配置测试应用, 点击未配置的应用,进行相关配置
- 应用管理 - 基础配置 点击连通器选项的“ ”,选择部署的连通器(或连通器组),与测试应用建立连接. 选择域名进行配置, 这里的域名值得是内网地址 端口(可以配置多条映射). 配置好保存之后即可实现内网穿透!!!
高级配置
注意:
- 下面介绍下高级配置. 如无特殊需求可以不进行高级配置(直接进行下一步).
- 这里配置需的前提是 要我们在下一步访问策略配置并获取外网访问域名后 再进行配置
因为背景介绍的原因, 需要钉钉开放平台在创建的应用中进行事件回调, 然而在我们配置好访问策略时, 钉钉回调接口时仍出现:
url 地址访问异常, 不允许3xxx跳转
故需要我们管理平台-应用管理中进行高级配置
- 高级配置
添加允许匿名访问url: 格式为
https://生成的外网访问域名/事件回调sdk接口地址.*
2. 配置成功后, 进行测试 需要先将事件回调sdk部署在内网服务器上才能够进行测试
配置访问策略
策略管理由企业管理员操作,包括注册策略、修改策略、删除策略、停用策略、启用策略、优先级排序等
- 然后点击“创建策略”按钮,进入创建策略界面
- 在策略页面,填写策略信息,配置项如下表
- 点击完成,创建策略
获取外网访问域名(踩坑)
- 在配置好访问策略后, 可以通过应用管理里面获取新域名, 而不是通过连通器显示的公网ip进行访问
- 可以看到我们每次配置的内网ip 端口都被映射成外网对应可以访问的唯一域名, 根据需要复制新域名即可
- 测试企业网关内网穿透效果 内网访问
公网访问
附: 钉钉事件回调sdk
事件回调介绍地址: https://open.dingtalk.com/document/org/configure-event-subcription
- 钉钉开放平台加解密方法
import java.io.ByteArrayOutputStream;
import java.nio.charset.Charset;
import java.security.MessageDigest;
import java.security.Permission;
import java.security.PermissionCollection;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.security.Security;
import java.lang.reflect.Field;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import com.alibaba.fastjson.JSON;
import org.apache.commons.codec.binary.Base64;
/**
* 钉钉开放平台加解密方法
* 在ORACLE官方网站下载JCE无限制权限策略文件
* JDK6的下载地址:http://www.oracle.com/technetwork/java/javase/downloads/jce-6-download-429243.html
* JDK7的下载地址: http://www.oracle.com/technetwork/java/javase/downloads/jce-7-download-432124.html
* JDK8的下载地址 https://www.oracle.com/java/technologies/javase-jce8-downloads.html
* @Author caoHaiYang
* @Date 2022/8/18 10:15
*/
public class DingCallbackCrypto {
private static final Charset CHARSET = Charset.forName("utf-8");
private static final Base64 base64 = new Base64();
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 企业自建应用-事件订阅, 使用appKey
* 企业自建应用-注册回调地址, 使用corpId
* 第三方企业应用, 使用suiteKey
*
* @throws DingTalkEncryptException 执行失败,请查看该异常的错误码和具体的错误信息
*/
public DingCallbackCrypto(String token, String encodingAesKey, String corpId) throws DingTalkEncryptException {
if (null == encodingAesKey || encodingAesKey.length() != AES_ENCODE_KEY_LENGTH) {
throw new DingTalkEncryptException(DingTalkEncryptException.AES_KEY_ILLEGAL);
}
this.token = token;
this.corpId = corpId;
aesKey = Base64.decodeBase64(encodingAesKey "=");
}
public Map<String, String> getEncryptedMap(String plaintext) throws DingTalkEncryptException {
return getEncryptedMap(plaintext, System.currentTimeMillis(), Utils.getRandomStr(16));
}
/**
* 将和钉钉开放平台同步的消息体加密,返回加密Map
*
* @param plaintext 传递的消息体明文
* @param timeStamp 时间戳
* @param nonce 随机字符串
* @return
* @throws DingTalkEncryptException
*/
public Map<String, String> getEncryptedMap(String plaintext, Long timeStamp, String nonce)
throws DingTalkEncryptException {
if (null == plaintext) {
throw new DingTalkEncryptException(DingTalkEncryptException.ENCRYPTION_PLAINTEXT_ILLEGAL);
}
if (null == timeStamp) {
throw new DingTalkEncryptException(DingTalkEncryptException.ENCRYPTION_TIMESTAMP_ILLEGAL);
}
if (null == nonce) {
throw new DingTalkEncryptException(DingTalkEncryptException.ENCRYPTION_NONCE_ILLEGAL);
}
// 加密
String encrypt = encrypt(Utils.getRandomStr(RANDOM_LENGTH), plaintext);
String signature = getSignature(token, String.valueOf(timeStamp), nonce, encrypt);
Map<String, String> resultMap = new HashMap<String, String>();
resultMap.put("msg_signature", signature);
resultMap.put("encrypt", encrypt);
resultMap.put("timeStamp", String.valueOf(timeStamp));
resultMap.put("nonce", nonce);
return resultMap;
}
/**
* 密文解密
*
* @param msgSignature 签名串
* @param timeStamp 时间戳
* @param nonce 随机串
* @param encryptMsg 密文
* @return 解密后的原文
* @throws DingTalkEncryptException
*/
public String getDecryptMsg(String msgSignature, String timeStamp, String nonce, String encryptMsg)
throws DingTalkEncryptException {
//校验签名
String signature = getSignature(token, timeStamp, nonce, encryptMsg);
if (!signature.equals(msgSignature)) {
throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_SIGNATURE_ERROR);
}
// 解密
String result = decrypt(encryptMsg);
return result;
}
/*
* 对明文加密.
* @param text 需要加密的明文
* @return 加密后base64编码的字符串
*/
private String encrypt(String random, String plaintext) throws DingTalkEncryptException {
try {
byte[] randomBytes = random.getBytes(CHARSET);
byte[] plainTextBytes = plaintext.getBytes(CHARSET);
byte[] lengthByte = Utils.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);
String result = base64.encodeToString(encrypted);
return result;
} catch (Exception e) {
throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_ENCRYPT_TEXT_ERROR);
}
}
/*
* 对密文进行解密.
* @param text 需要解密的密文
* @return 解密得到的明文
*/
private String decrypt(String text) throws DingTalkEncryptException {
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对密文进行解码
byte[] encrypted = Base64.decodeBase64(text);
// 解密
originalArr = cipher.doFinal(encrypted);
} catch (Exception e) {
throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_DECRYPT_TEXT_ERROR);
}
String plainText;
String fromCorpid;
try {
// 去除补位字符
byte[] bytes = PKCS7Padding.removePaddingBytes(originalArr);
// 分离16位随机字符串,网络字节序和corpId
byte[] networkOrder = Arrays.copyOfRange(bytes, 16, 20);
int plainTextLegth = Utils.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 DingTalkEncryptException(DingTalkEncryptException.COMPUTE_DECRYPT_TEXT_LENGTH_ERROR);
}
// corpid不相同的情况
if (!fromCorpid.equals(corpId)) {
throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_DECRYPT_TEXT_CORPID_ERROR);
}
return plainText;
}
/**
* 数字签名
*
* @param token isv token
* @param timestamp 时间戳
* @param nonce 随机串
* @param encrypt 加密文本
* @return
* @throws DingTalkEncryptException
*/
public String getSignature(String token, String timestamp, String nonce, String encrypt)
throws DingTalkEncryptException {
try {
String[] array = new String[] {token, timestamp, nonce, encrypt};
Arrays.sort(array);
//System.out.println(JSON.toJSONString(array));
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 4; i ) {
sb.append(array[i]);
}
String str = sb.toString();
System.out.println(str);
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 DingTalkEncryptException(DingTalkEncryptException.COMPUTE_SIGNATURE_ERROR);
}
}
public static class Utils {
public Utils() {
}
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();
}
public static byte[] int2Bytes(int count) {
byte[] byteArr = new byte[] {(byte)(count >> 24 & 255), (byte)(count >> 16 & 255), (byte)(count >> 8 & 255),
(byte)(count & 255)};
return byteArr;
}
public static int bytes2int(byte[] byteArr) {
int count = 0;
for (int i = 0; i < 4; i) {
count <<= 8;
count |= byteArr[i] & 255;
}
return count;
}
}
public static class PKCS7Padding {
private static final Charset CHARSET = Charset.forName("utf-8");
private static final int BLOCK_SIZE = 32;
public PKCS7Padding() {
}
public static byte[] getPaddingBytes(int count) {
int amountToPad = 32 - count % 32;
if (amountToPad == 0) {
amountToPad = 32;
}
char padChr = chr(amountToPad);
String tmp = new String();
for (int index = 0; index < amountToPad; index) {
tmp = tmp padChr;
}
return tmp.getBytes(CHARSET);
}
public static byte[] removePaddingBytes(byte[] decrypted) {
int pad = decrypted[decrypted.length - 1];
if (pad < 1 || pad > 32) {
pad = 0;
}
return Arrays.copyOfRange(decrypted, 0, decrypted.length - pad);
}
private static char chr(int a) {
byte target = (byte)(a & 255);
return (char)target;
}
}
public static class DingTalkEncryptException extends Exception {
public static final int SUCCESS = 0;
public static final int ENCRYPTION_PLAINTEXT_ILLEGAL = 900001;
public static final int ENCRYPTION_TIMESTAMP_ILLEGAL = 900002;
public static final int ENCRYPTION_NONCE_ILLEGAL = 900003;
public static final int AES_KEY_ILLEGAL = 900004;
public static final int SIGNATURE_NOT_MATCH = 900005;
public static final int COMPUTE_SIGNATURE_ERROR = 900006;
public static final int COMPUTE_ENCRYPT_TEXT_ERROR = 900007;
public static final int COMPUTE_DECRYPT_TEXT_ERROR = 900008;
public static final int COMPUTE_DECRYPT_TEXT_LENGTH_ERROR = 900009;
public static final int COMPUTE_DECRYPT_TEXT_CORPID_ERROR = 900010;
private static Map<Integer, String> msgMap = new HashMap();
private Integer code;
static {
msgMap.put(0, "成功");
msgMap.put(900001, "加密明文文本非法");
msgMap.put(900002, "加密时间戳参数非法");
msgMap.put(900003, "加密随机字符串参数非法");
msgMap.put(900005, "签名不匹配");
msgMap.put(900006, "签名计算失败");
msgMap.put(900004, "不合法的aes key");
msgMap.put(900007, "计算加密文字错误");
msgMap.put(900008, "计算解密文字错误");
msgMap.put(900009, "计算解密文字长度不匹配");
msgMap.put(900010, "计算解密文字corpid不匹配");
}
public Integer getCode() {
return this.code;
}
public DingTalkEncryptException(Integer exceptionCode) {
super((String)msgMap.get(exceptionCode));
this.code = exceptionCode;
}
}
static {
try {
Security.setProperty("crypto.policy", "limited");
RemoveCryptographyRestrictions();
} catch (Exception var1) {
}
}
private static void RemoveCryptographyRestrictions() throws Exception {
Class<?> jceSecurity = getClazz("javax.crypto.JceSecurity");
Class<?> cryptoPermissions = getClazz("javax.crypto.CryptoPermissions");
Class<?> cryptoAllPermission = getClazz("javax.crypto.CryptoAllPermission");
if (jceSecurity != null) {
setFinalStaticValue(jceSecurity, "isRestricted", false);
PermissionCollection defaultPolicy = (PermissionCollection)getFieldValue(jceSecurity, "defaultPolicy", (Object)null, PermissionCollection.class);
if (cryptoPermissions != null) {
Map<?, ?> map = (Map)getFieldValue(cryptoPermissions, "perms", defaultPolicy, Map.class);
map.clear();
}
if (cryptoAllPermission != null) {
Permission permission = (Permission)getFieldValue(cryptoAllPermission, "INSTANCE", (Object)null, Permission.class);
defaultPolicy.add(permission);
}
}
}
private static Class<?> getClazz(String className) {
Class clazz = null;
try {
clazz = Class.forName(className);
} catch (Exception var3) {
}
return clazz;
}
private static void setFinalStaticValue(Class<?> srcClazz, String fieldName, Object newValue) throws Exception {
Field field = srcClazz.getDeclaredField(fieldName);
field.setAccessible(true);
Field modifiersField = Field.class.getDeclaredField("modifiers");
modifiersField.setAccessible(true);
modifiersField.setInt(field, field.getModifiers() & -17);
field.set((Object)null, newValue);
}
private static <T> T getFieldValue(Class<?> srcClazz, String fieldName, Object owner, Class<T> dstClazz) throws Exception {
Field field = srcClazz.getDeclaredField(fieldName);
field.setAccessible(true);
return dstClazz.cast(field.get(owner));
}
}
- Controller层接口
/**
* 事件回调方法
*
* @param msg_signature
* @param timeStamp
* @param nonce
* @param json
* @return
*/
@RequestMapping("/callback")
public Map<String, String> callBack(
@RequestParam(value = "msg_signature", required = false) String msg_signature,
@RequestParam(value = "timestamp", required = false) String timeStamp,
@RequestParam(value = "nonce", required = false) String nonce,
@RequestBody(required = false) JSONObject json) {
try {
// 1. 从http请求中获取加解密参数
// 2. 使用加解密类型
// Constant.OWNER_KEY 说明:
// 1、开发者后台配置的订阅事件为应用级事件推送,此时OWNER_KEY为应用的APP_KEY。
// 2、调用订阅事件接口订阅的事件为企业级事件推送,
// 此时OWNER_KEY为:企业的appkey(企业内部应用)或SUITE_KEY(三方应用)
DingCallbackCrypto callbackCrypto = new DingCallbackCrypto(dingTalkConfig.getToken(), dingTalkConfig.getAesKey(), dingTalkConfig.getAppkey());
String encryptMsg = json.getString("encrypt");
String decryptMsg = callbackCrypto.getDecryptMsg(msg_signature, timeStamp, nonce, encryptMsg);
// 3. 反序列化回调事件json数据
JSONObject eventJson = JSON.parseObject(decryptMsg);
log.info("反序列化回调事件json数据:" eventJson);
String eventType = eventJson.getString("EventType");
// 4. 根据EventType分类处理
if ("check_url".equals(eventType)) {
// 测试回调url的正确性
log.info("测试回调url的正确性");
} else if ("user_add_org".equals(eventType)) {
// 处理通讯录用户增加事件
log.info("发生了:" eventType "事件");
} else {
// 添加其他已注册的
log.info("发生了:" eventType "事件");
}
// 5. 返回success的加密数据
Map<String, String> successMap = callbackCrypto.getEncryptedMap("success");
return successMap;
} catch (DingCallbackCrypto.DingTalkEncryptException e) {
e.printStackTrace();
}
return null;
}