解决因 DataProtection 配置导致的 ASP.NET Core 站点间认证 Cookie 共享失败问题

在现代微服务或分布式架构中,我们经常需要将单一应用拆分为多个独立的 ASP.NET Core 应用。一个常见的需求是,用户在一个应用(例如 www.app.com)登录后,访问另一个相关联的应用(例如 shop.app.com)时,应保持登录状态。这种单点登录(SSO)场景的核心技术就是共享认证 Cookie。

然而,许多开发者在尝试实现此功能时,会遇到一个令人困惑的问题:明明在两个应用中配置了相同的 Cookie 名称和域,但登录状态仍然无法共享。用户在站点 A 登录后,访问站点 B 时依然被重定向到登录页面。这十有八九是 ASP.NET Core Data Protection 系统在“作祟”。

本文将深入剖析 Data Protection 的工作原理,解释为何不正确的配置会导致 Cookie 共享失败,并提供详细的解决方案、最佳实践和代码示例。

目录#

  1. 问题背景:为什么 Cookie 需要被“保护”?
  2. 深入理解 ASP.NET Core Data Protection
  3. 问题根因:密钥环不一致
  4. 解决方案:配置共享的密钥环
    1. 方案一:使用共享的文件系统(适用于本地或同一服务器)
    2. 方案二:使用 Redis(推荐用于分布式环境)
    3. 方案三:使用 Azure Blob Storage 或数据库
  5. 配置统一的应用程序名称
  6. 完整示例:两个站点的协同配置
  7. 最佳实践与常见陷阱
  8. 总结
  9. 参考资料

ASP.NET Core 的认证 Cookie(如 AspNetCore.Cookies)默认不是明文存储的。为了安全起见,它包含了加密的认证票据(Authentication Ticket),其中含有用户的声明(Claims)、过期时间等敏感信息。

如果 Cookie 被恶意截获,攻击者可能会尝试篡改其中的内容(例如,将用户名改为 admin)或直接进行重放攻击。为了防止这种情况,ASP.NET Core 使用 Data Protection 系统对 Cookie 进行:

  • 加密:确保 Cookie 内容不可读。
  • 验证:确保 Cookie 内容未被篡改。

简单来说,Data Protection 是 Cookie 的“签名和加密器”。站点 A 加密的 Cookie,必须由同一个“签名和加密器”才能被站点 B 解密和验证。

深入理解 ASP.NET Core Data Protection#

Data Protection 系统的核心是一个密钥环。这个密钥环包含一个或多个用于加密和验证数据的密钥。

  • 默认行为:当 ASP.NET Core 应用启动时,如果未显式配置 Data Protection,它会尝试根据当前运行环境自动创建一个密钥环。

    • Windows 上,它可能使用 DPAPI(当前用户范围)存储密钥。
    • macOS/Linux 上,它通常将密钥存储在 ~/.aspnet/DataProtection-Keys 目录下。
  • 关键点:每个独立部署的 ASP.NET Core 应用默认都会生成自己独有的、彼此不知晓的密钥环

这就导致了我们的核心问题。

问题根因:密钥环不一致#

想象一下,站点 A 和站点 B 就像两个不同的银行。

  • 站点 A 用自己的印章(密钥环)给客户开具了一张加密的存单(认证 Cookie)。
  • 当客户拿着这张存单去站点 B 取款时,站点 B 拿出自己的印章进行核对,发现印章不匹配。因此,站点 B 会断定这张存单是无效的或伪造的,于是拒绝服务(要求重新登录)。

这就是不同站点的 Data Protection 系统使用不同密钥环所导致的结果。即使 Cookie 的域名(.app.com)设置正确,其内容也无法被另一个站点解密和验证

解决方案:配置共享的密钥环#

要让多个应用共享认证 Cookie,我们必须让它们共享同一个密钥环。这意味着我们需要将一个持久的、可被所有应用访问的存储位置配置给 Data Protection 系统。

方案一:使用共享的文件系统(适用于本地或同一服务器)#

对于部署在同一台物理机或虚拟机上的多个应用,这是最简单的方法。

Program.cs 中配置:

var builder = WebApplication.CreateBuilder(args);
 
builder.Services.AddDataProtection()
    .PersistKeysToFileSystem(new DirectoryInfo(@”\\server\share\Directory–Containing–Keys–For–All–Apps“))
    .SetApplicationName(“MySharedApp”); // 非常重要!见下文解释
 
// ... 其他服务配置,如 AddAuthentication

说明

  • PersistKeysToFileSystem:指定一个所有应用都有读写权限的共享网络路径或本地路径。
  • SetApplicationName:确保所有应用使用相同的应用名称。这用于隔离不同应用组的密钥。必须一致

方案二:使用 Redis(推荐用于分布式环境)#

在云原生或分布式环境中,Redis 是共享密钥环的理想选择,因为它高性能且为分布式而设计。

  1. 安装 NuGet 包

    Install-Package Microsoft.AspNetCore.DataProtection.StackExchangeRedis
  2. Program.cs 中配置

    var builder = WebApplication.CreateBuilder(args);
     
    // 配置 Redis 连接
    var redisConnection = “your_redis_connection_string”;
    builder.Services.AddStackExchangeRedisCache(options => // 如果也用 Redis 做缓存,可选项
    {
        options.Configuration = redisConnection;
    });
     
    // 配置 Data Protection 使用 Redis
    builder.Services.AddDataProtection()
        .PersistKeysToStackExchangeRedis(ConnectionMultiplexer.Connect(redisConnection), “DataProtection-Keys”) // “DataProtection-Keys” 是 Redis 中存储密钥的 Key 名
        .SetApplicationName(“MySharedApp”);
     
    // ... 其他配置

方案三:使用 Azure Blob Storage 或数据库#

你也可以使用其他持久化存储,例如 Azure Blob Storage(通过 Microsoft.AspNetCore.DataProtection.AzureStorage 包)或 EF Core(通过 Microsoft.AspNetCore.DataProtection.EntityFrameworkCore 包)。配置模式类似,都是调用相应的 PersistKeysTo[Store] 方法。

配置统一的应用程序名称#

SetApplicationName("MySharedApp") 这一步至关重要,但常常被忽略。

  • 作用:Data Protection 使用应用程序名称来隔离不同应用的密钥。即使共享同一个物理存储(如同一个 Redis 实例),名为 AppA 的应用也不会去使用名为 AppB 的应用创建的密钥。
  • 要求:为了共享 Cookie,所有需要互信的应用必须设置完全相同的 ApplicationName
// 在所有需要共享 Cookie 的应用中,使用相同的字符串
.SetApplicationName(“MySharedApp”);

完整示例:两个站点的协同配置#

假设我们有两个应用:WebApp (www.app.com) 和 ShopApp (shop.app.com)。我们希望实现 SSO。

1. 配置认证方案(两个应用相同)

Program.cs 中,配置一个统一的认证 Cookie 方案。

builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddCookie(options =>
    {
        options.Cookie.Name = “.AspNetCore.SharedCookie”; // Cookie 名称可以自定义,但要一致
        options.Cookie.Domain = “.app.com”; // 设置父级域名,确保子域名可接收
        options.Cookie.SameSite = SameSiteMode.Lax; // 或 None (如果使用 HTTPS)
        options.Cookie.SecurePolicy = CookieSecurePolicy.Always; // 生产环境务必使用 HTTPS
        options.LoginPath =/Account/Login”;
        // ... 其他配置
    });

2. 配置共享的 Data Protection(两个应用相同)

以 Redis 为例:

// 假设 Redis 连接字符串从配置中读取
var redisConn = builder.Configuration.GetConnectionString(“Redis”);
 
builder.Services.AddDataProtection()
    .PersistKeysToStackExchangeRedis(ConnectionMultiplexer.Connect(redisConn), “DataProtection-Keys”)
    .SetApplicationName(“ECommercePlatform”); // 两个应用都使用这个名称

现在,当用户在 www.app.com 登录时,该站点会使用共享密钥环加密一个 Cookie 并写入 .app.com 域。当用户访问 shop.app.com 时,浏览器会携带这个 Cookie。shop.app.com 应用使用相同的共享密钥环来解密和验证该 Cookie,从而识别出已登录的用户。

最佳实践与常见陷阱#

  1. 始终使用 HTTPS:共享认证 Cookie 涉及敏感信息在子域名间传输,必须使用 HTTPS 并设置 Cookie.SecurePolicy = CookieSecurePolicy.Always
  2. 统一的 ApplicationName:这是最常见的错误来源。请仔细检查所有应用的 SetApplicationName 值是否完全一致(区分大小写)。
  3. 密钥环存储的权限:确保所有应用实例对共享的存储(文件共享、Redis、数据库)都有读写权限
  4. 密钥生命周期管理:Data Protection 密钥会定期自动轮换。共享存储确保了新旧应用都能访问当前和历史密钥,从而平滑处理已发出的 Cookie。
  5. 环境隔离:为开发、测试、生产环境使用不同的共享存储位置或不同的 ApplicationName。切勿让生产环境应用使用开发环境的密钥环。
  6. 测试:部署后,在一个站点登录,然后直接访问另一个站点的受保护页面,而不是登录页面,以验证 SSO 是否真正生效。

总结#

ASP.NET Core 强大的 Data Protection 系统是安全性的基石,但在跨应用共享认证 Cookie 的场景下,其默认的、隔离的密钥环配置会成为障碍。解决此问题的关键在于:

  1. 理解根源:认识到是密钥环的不一致导致 Cookie 无法被互相验证。
  2. 配置共享存储:使用文件系统、Redis 等方案,让所有应用访问同一个密钥环。
  3. 设置统一应用名:使用 SetApplicationName 确保应用在共享存储中属于同一个“信任组”。

通过正确配置 Data Protection,你可以轻松地在微服务架构中构建安全、无缝的单点登录体验。

参考资料#

  1. ASP.NET Core Data Protection 官方文档
  2. 在 ASP.NET Core 中配置标识
  3. ASP.NET Core 中的密钥存储提供程序
  4. 在 ASP.NET Core 中使用分布式缓存