使用.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项目使用。您可以根据实际业务需求在此基础上进行扩展,如添加消息类型处理、业务逻辑集成等功能。

相关推荐
山岚的运维笔记1 天前
SQL Server笔记 -- 第14章:CASE语句
数据库·笔记·sql·microsoft·sqlserver
Sharewinfo_BJ1 天前
PowerBI 2026年1月功能更新|效率升级,体验再优化
windows·microsoft·powerbi
八月瓜科技1 天前
2026春晚机器人专利战:从舞台秀到资本竞逐的产业突围
大数据·人工智能·microsoft·机器人·娱乐
鲨辣椒100861 天前
Linux软件编程基石——基础指令使用
linux·windows·microsoft
vx-bot5556661 天前
企业微信接口在数据工程与分析场景中的架构应用
架构·企业微信
波波0071 天前
每日一题:中间件是如何工作的?
中间件·.net·面试题
无风听海1 天前
.NET 10之可空引用类型
数据结构·.net
2501_941982051 天前
AI + 企微:使用 Python 接入 DeepSeek/GPT 实现外部群自动技术答疑
人工智能·python·企业微信
梦想的旅途21 天前
Java/Python/Go 实现企微外部群自动化消息推送
运维·自动化·企业微信
码云数智-园园1 天前
基于 JSON 配置的 .NET 桌面应用自动更新实现指南
.net