使用.NET实现企业微信应用接入:域名验证与消息处理

本文将介绍如何在不依赖任何第三方库的情况下,使用纯.NET实现企业微信应用的快速接入,涵盖域名验证和消息处理两个核心功能。

1. 背景

作为.NET开发者,在开发企业内部应用的时候,我们经常需要与企业微信进行集成,实现快捷的内部登录和一些便捷的消息交互,简化一些功能的开发和重复建设。虽然市面上有一些第三方库可以帮助我们完成这些集成工作,但有时候我们希望能够直接使用.NET自带的功能来实现这些需求,以减少对外部依赖的依赖,提升代码的可维护性和安全性。

下面我们就开始使用纯.NET实现企业微信应用的快速接入,企业微信应用接入主要包含两个关键环节:

  • 域名归属认证:验证域名所有权
  • 消息接收处理:处理企业微信推送的事件和消息

2. 项目结构与配置

2.1 配置文件设置

首先在appsettings.json中配置企业微信相关参数:

json 复制代码
{
  "DomainVerification": {
    "xxxx": "xxxxxx"
  },
  "WeCom": {
    "Token": "企业微信后台设置的Token",
    "EncodingAesKey": "企业微信后台设置的EncodingAesKey",
    "CorpId": "企业ID",
    "CorpSecret": "应用凭证密钥"
  }
}

2.2 配置模型定义

使用 record 类型定义配置模型:

cs 复制代码
public record WeCom(string CorpId, string CorpSecret, string Token, string EncodingAesKey);

2.3 依赖注入注册

Program.cs 中注册配置:

cs 复制代码
var builder = WebApplication.CreateBuilder(args);
builder.Services.Configure<WeCom>(builder.Configuration.GetSection(nameof(WeCom)));

3. 域名归属认证实现

企业微信要求通过特定格式的URL验证域名归属,我们可以使用Minimal API实现:

cs 复制代码
app.MapGet("/WW_verify_{name}.txt", (string name) =>
{
    var cfg = app.Configuration;
    var value = cfg.GetValue<string>($"DomainVerification:{name}");
    if (string.IsNullOrEmpty(value))
    {
        return Results.NotFound();
    }

    return Results.Text(value, "text/plain");
});

实现要点

  • 使用路由模板WW_verify_{name}.txt匹配企业微信的验证请求
  • 从配置中读取对应的验证码内容
  • 返回纯文本响应

4. 消息接收处理控制器

4.1 控制器基础结构

cs 复制代码
[ApiController]
[Route("wecom/callback")]
public class WeComController : ControllerBase
{
    private readonly ILogger<WeComController> _logger;
    private readonly WeCom _weCom;

    public WeComController(ILogger<WeComController> logger, IOptionsSnapshot<WeCom> weCom)
    {
        _logger = logger;
        _weCom = weCom.Value;
    }
    
    // 后续方法实现...
}

4.2 URL验证接口(GET请求)

企业微信在配置回调URL时会发送GET请求进行验证:

cs 复制代码
[HttpGet]
public IActionResult Get([FromQuery] string msg_signature, [FromQuery] string timestamp, 
    [FromQuery] string nonce, [FromQuery] string echostr)
{
    var token = _weCom.Token;
    var encodingAesKey = _weCom.EncodingAesKey;
    var corpId = _weCom.CorpId;

    // 配置验证
    if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(encodingAesKey) || string.IsNullOrEmpty(corpId))
    {
        _logger.LogWarning("WeCom config missing");
        return BadRequest("WeCom config missing");
    }

    if (string.IsNullOrEmpty(echostr))
        return BadRequest();

    // 签名验证
    if (!VerifySignature(token, timestamp, nonce, echostr, msg_signature))
    {
        _logger.LogWarning("WeCom signature invalid (GET)");
        return BadRequest("signature invalid");
    }

    try
    {
        var plain = DecryptMessage(encodingAesKey, echostr, corpId);
        // 返回明文完成验证
        return Content(plain, "text/plain", Encoding.UTF8);
    }
    catch (Exception ex)
    {
        _logger.LogWarning(ex, "WeCom decrypt failed (GET)");
        return BadRequest("decrypt failed");
    }
}

4.3 消息处理接口(POST请求)

处理企业微信推送的各种事件和消息:

cs 复制代码
[HttpPost]
public async Task<IActionResult> Post([FromQuery] string msg_signature, 
    [FromQuery] string timestamp, [FromQuery] string nonce)
{
    var token = _weCom.Token;
    var encodingAesKey = _weCom.EncodingAesKey;
    var corpId = _weCom.CorpId;

    // 配置验证
    if (string.IsNullOrEmpty(token) || string.IsNullOrEmpty(encodingAesKey) || string.IsNullOrEmpty(corpId))
    {
        _logger.LogWarning("WeCom config missing");
        return BadRequest();
    }

    // 读取请求体
    using var reader = new System.IO.StreamReader(Request.Body, Encoding.UTF8);
    var body = await reader.ReadToEndAsync();

    var encrypt = ExtractXmlNode(body, "Encrypt");
    if (string.IsNullOrEmpty(encrypt))
    {
        // 非加密消息或格式不对,按企业微信要求返回 success
        _logger.LogDebug("WeCom POST without Encrypt node, return success");
        return Content("success", "text/plain");
    }

    // 签名验证
    if (!VerifySignature(token, timestamp, nonce, encrypt, msg_signature))
    {
        _logger.LogWarning("WeCom signature invalid (POST)");
        return BadRequest("signature invalid");
    }

    string xml;
    try
    {
        xml = DecryptMessage(encodingAesKey, encrypt, corpId);
    }
    catch (Exception ex)
    {
        _logger.LogWarning(ex, "WeCom decrypt failed (POST)");
        return BadRequest("decrypt failed");
    }

    // 处理XML消息内容
    // 这里可以添加具体的业务逻辑处理
    
    // 企业微信要求返回固定字符串 "success"
    return Content("success", "text/plain");
}

5. 核心工具方法实现

5.1 签名验证

cs 复制代码
private static bool VerifySignature(string token, string timestamp, string nonce, 
    string encrypt, string signature)
{
    var arr = new[] { token ?? string.Empty, timestamp ?? string.Empty, 
                     nonce ?? string.Empty, encrypt ?? string.Empty };
    Array.Sort(arr, StringComparer.Ordinal);
    var raw = string.Join("", arr);
    
    using var sha1 = SHA1.Create();
    var hash = sha1.ComputeHash(Encoding.UTF8.GetBytes(raw));
    var computed = BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
    
    return string.Equals(computed, signature ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}

5.2 消息解密

cs 复制代码
private static string DecryptMessage(string encodingAesKey, string inputEncryptBase64, string corpId)
{
    // 补齐 base64 填充
    var pad = encodingAesKey;
    if (pad.Length % 4 != 0)
        pad += new string('=', 4 - pad.Length % 4);
    var aesKey = Convert.FromBase64String(pad);

    var encrypted = Convert.FromBase64String(inputEncryptBase64);

    using var aes = Aes.Create();
    aes.Key = aesKey;
    aes.IV = aesKey.Take(16).ToArray();
    aes.Mode = CipherMode.CBC;
    aes.Padding = PaddingMode.None;

    using var decryptor = aes.CreateDecryptor();
    var decrypted = decryptor.TransformFinalBlock(encrypted, 0, encrypted.Length);

    // 移除 PKCS#7 填充
    var padLen = decrypted[^1];
    if (padLen < 1 || padLen > 32) padLen = 0;
    var noPad = decrypted[..(decrypted.Length - padLen)];

    // 结构:16 random | 4 msgLen | msg | corpId
    var msgLenBytes = noPad.Skip(16).Take(4).ToArray();
    if (msgLenBytes.Length < 4) throw new Exception("invalid msg len");
    
    // 处理字节序
    if (BitConverter.IsLittleEndian) 
        Array.Reverse(msgLenBytes);
        
    var msgLen = BitConverter.ToInt32(msgLenBytes, 0);
    var msgBytes = noPad.Skip(20).Take(msgLen).ToArray();
    var msg = Encoding.UTF8.GetString(msgBytes);

    var fromCorpIdBytes = noPad.Skip(20 + msgLen).ToArray();
    var fromCorpId = Encoding.UTF8.GetString(fromCorpIdBytes);

    // 验证企业ID
    if (!string.IsNullOrEmpty(corpId) && !string.IsNullOrEmpty(fromCorpId))
    {
        if (!fromCorpId.StartsWith(corpId, StringComparison.Ordinal))
            throw new Exception("corpId mismatch");
    }

    return msg;
}

5.3 XML节点提取

cs 复制代码
private static string ExtractXmlNode(string xml, string nodeName)
{
    try
    {
        var doc = new XmlDocument();
        doc.LoadXml(xml);
        var node = doc.SelectSingleNode($"/xml/{nodeName}");
        return node?.InnerText;
    }
    catch
    {
        return null;
    }
}

6. 测试与部署

在实际部署和测试时,请确保完成以下步骤:

  1. 域名验证测试 :访问https://your-domain.com/WW_verify_xxxx.txt验证是否返回正确内容
  2. URL配置 :在企业微信后台配置回调URL为https://your-domain.com/wecom/callback
  3. 消息测试 :通过企业微信的在线测试工具测试集成效果

7. 最后

本文完整展示了如何使用纯.NET技术栈实现企业微信应用的接入,涵盖了从域名验证到消息处理的完整流程。这种实现方式不依赖任何第三方库,代码简洁清晰,易于理解和维护,适合需要快速接入企业微信的.NET项目使用。您可以根据实际业务需求在此基础上进行扩展,如添加消息类型处理、业务逻辑集成等功能。

相关推荐
电脑小管家18 分钟前
DirectX报错怎么办?快速修复游戏和软件崩溃问题
windows·驱动开发·microsoft·计算机外设·电脑
武藤一雄20 分钟前
C# 关于GC垃圾回收需要注意的问题(持续更新)
后端·微软·c#·.net·.netcore
FreeBuf_35 分钟前
新工具可移除Windows 11中的Copilot、Recall及其他AI组件,反抗微软数据收集
人工智能·microsoft·copilot
武藤一雄38 分钟前
C# 关于应用程序域(AppDomain)需要注意的问题(持续更新)
后端·microsoft·微软·c#·.net·.netcore
码上宝藏1 小时前
微软再次禁用离线Windows激活选项,强制推行在线激活模式
microsoft
HarryXYC1 小时前
【vb.net】实现简单的内网文件分享网站
.net·web·文件共享·vb.net
bugcome_com1 小时前
.NET 核心:Func 与 Action 委托(从入门到实战)
c#·.net
vx-bot5556661 小时前
企业微信协议接口的安全调用与性能优化规范
安全·性能优化·企业微信
天空属于哈夫克31 小时前
通过企业微信二次开发构建外部群主动推送体系
企业微信
萧曵 丶2 小时前
领域驱动设计(DDD)浅谈
数据库·microsoft