在典型的微服务架构中,依赖中央身份提供者(IdP)进行OpenID Connect (OIDC)流程是标准实践。然而,当服务实例数量扩展到数百甚至数千时,中央IdP会迅速成为性能瓶瓶颈和单点故障。每一个授权码交换、每一次state和nonce的验证,都会转化为一次对IdP的网络调用。当这些调用累积起来,系统的延迟会显著增加,并且对IdP的可用性产生了强依赖。
一个常见的优化思路是引入缓存。但对于OIDC流程中的state、nonce和code这类一次性、短生命周期的数据,选择合适的缓存策略并非易事。内存缓存虽然快速,但服务重启会导致状态丢失,这对于正在进行的认证流程是致命的。分布式缓存如Redis解决了持久化问题,却又引入了新的网络开销、运维复杂性和另一个潜在的故障点。
我们的挑战是:为每个服务实例提供一个本地、持久化、极速且轻量级的状态存储,用于处理OIDC流程中的瞬时数据。它必须在服务重启后依然能恢复状态,同时避免任何外部网络依赖。这驱使我们探索一个不寻常的组合:使用C#和嵌入式键值数据库LevelDB来构建一个专用的OIDC状态存储组件。
技术选型决策
选择LevelDB并非偶然。在真实项目中,我们需要的是一个比SQLite更简单、比完整数据库更轻量的东西。我们的需求仅仅是可靠的键值存储。
LevelDB的优势:
- 嵌入式: 它作为一个库链接到应用程序中,没有独立的服务器进程,没有网络开销。读写操作是本地文件I/O,速度极快。
- 键值存储: OIDC流程中的状态数据(如
nonce,state,code)天然适合以键值对形式存储。key可以是nonce本身,value可以是关联的元数据。 - LSM-Tree结构: LevelDB基于日志结构合并树(LSM-Tree),对写入操作极其友好。这非常适合OIDC流程中状态数据“写入一次,读取一次,然后删除”的模式。
- 持久化: 数据存储在本地磁盘,服务重启后状态不丢失。
C#生态的整合:
- 我们团队的技术栈以.NET为核心。
LevelDB.Standard是一个可靠的C#包装器,它通过P/Invoke调用LevelDB的原生C++库,性能损失极小。 - 利用.NET的
IHostedService可以轻松实现后台清理任务,例如清除过期的状态数据。
- 我们团队的技术栈以.NET为核心。
我们的目标是创建一个IOidcStateStore接口,其实现将所有操作封装在LevelDB之后,对上层业务代码透明。
核心实现:构建持久化状态存储
首先,我们定义存储服务的核心接口。一个清晰的契约是可维护性和可测试性的基础。
// IOidcStateStore.cs
using System.Threading.Tasks;
public interface IOidcStateStore
{
/// <summary>
/// 存储授权码及其关联数据,并设置过期时间。
/// </summary>
/// <param name="code">授权码。</param>
/// <param name="data">需要关联存储的数据,如 code_verifier。</param>
/// <param name="expiresInSeconds">过期时间(秒)。</param>
Task StoreAuthorizationCodeAsync(string code, string data, int expiresInSeconds);
/// <summary>
/// 验证并消费授权码,如果存在且未过期,则返回关联数据并删除该码。
/// </summary>
/// <param name="code">授权码。</param>
/// <returns>关联数据,如果码不存在或已过期则返回null。</returns>
Task<string?> ConsumeAuthorizationCodeAsync(string code);
/// <summary>
/// 存储 Nonce,用于防止重放攻击。
/// </summary>
/// <param name="nonce">Nonce 值。</param>
/// <param name="expiresInSeconds">过期时间(秒)。</param>
Task StoreNonceAsync(string nonce, int expiresInSeconds);
/// <summary>
/// 验证并消费 Nonce,确保其只被使用一次。
/// </summary>
/// <param name="nonce">Nonce 值。</param>
/// <returns>如果 Nonce 有效且是首次使用则返回 true,否则返回 false。</returns>
Task<bool> ValidateAndConsumeNonceAsync(string nonce);
}
接下来,我们需要一个管理LevelDB实例生命周期的上下文类。这通常是一个单例,负责打开和关闭数据库连接。
// LevelDbContext.cs
using LevelDB;
using Microsoft.Extensions.Logging;
using System;
using System.IO;
public class LevelDbContext : IDisposable
{
public DB Db { get; }
private readonly ILogger<LevelDbContext> _logger;
public LevelDbContext(ILogger<LevelDbContext> logger)
{
_logger = logger;
// 在生产环境中,路径应该来自配置。
var dbPath = Path.Combine(AppContext.BaseDirectory, "oidc_state_db");
var options = new Options { CreateIfMissing = true };
try
{
Db = new DB(options, dbPath);
_logger.LogInformation("LevelDB instance opened at {Path}", dbPath);
}
catch (Exception ex)
{
// 这里的坑在于:如果LevelDB文件已存在但被另一个进程锁定,启动会失败。
// 必须确保每个服务实例有自己独立的数据库路径。
_logger.LogCritical(ex, "Failed to open LevelDB instance at {Path}. The directory might be locked by another process.", dbPath);
throw;
}
}
public void Dispose()
{
Db?.Dispose();
_logger.LogInformation("LevelDB instance disposed.");
}
}
设计键名与数据结构
在键值数据库中,键的设计至关重要。为了避免不同类型的数据混淆,我们采用前缀策略:
- 授权码:
code::{the_code} - Nonce:
nonce::{the_nonce}
由于LevelDB本身不支持TTL(Time-To-Live),我们需要手动实现过期逻辑。我们将存储一个包含数据和过期时间戳的JSON对象。
// StoredItem.cs
using System;
using System.Text.Json.Serialization;
public class StoredItem<T>
{
[JsonPropertyName("v")]
public T Value { get; set; }
[JsonPropertyName("e")]
public DateTimeOffset ExpiresAtUtc { get; set; }
}
使用短属性名v和e可以减小存储体积,在高并发写入场景下,这微小的优化是有意义的。
LevelDB状态存储的具体实现
现在我们可以实现IOidcStateStore接口。
// LevelDbOidcStateStore.cs
using LevelDB;
using Microsoft.Extensions.Logging;
using System;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Collections.Concurrent;
public class LevelDbOidcStateStore : IOidcStateStore
{
private readonly DB _db;
private readonly ILogger<LevelDbOidcStateStore> _logger;
private static readonly JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
// LevelDB是单进程写入,但在C#应用中可能有多线程访问。
// 对于“读取再删除”这类需要原子性的操作,需要应用层锁。
// 我们为每个nonce创建一个锁,以实现细粒度的并发控制。
private static readonly ConcurrentDictionary<string, SemaphoreSlim> _nonceLocks = new();
public LevelDbOidcStateStore(LevelDbContext context, ILogger<LevelDbOidcStateStore> logger)
{
_db = context.Db;
_logger = logger;
}
private string GetCodeKey(string code) => $"code::{code}";
private string GetNonceKey(string nonce) => $"nonce::{nonce}";
public Task StoreAuthorizationCodeAsync(string code, string data, int expiresInSeconds)
{
var key = GetCodeKey(code);
var item = new StoredItem<string>
{
Value = data,
ExpiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(expiresInSeconds)
};
var value = JsonSerializer.Serialize(item, _jsonOptions);
_db.Put(key, value);
return Task.CompletedTask;
}
public Task<string?> ConsumeAuthorizationCodeAsync(string code)
{
var key = GetCodeKey(code);
var value = _db.Get(key);
if (value == null)
{
return Task.FromResult<string?>(null);
}
// 找到后立即删除,确保一次性消费
_db.Delete(key);
try
{
var item = JsonSerializer.Deserialize<StoredItem<string>>(value, _jsonOptions);
if (item != null && item.ExpiresAtUtc > DateTimeOffset.UtcNow)
{
return Task.FromResult<string?>(item.Value);
}
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to deserialize stored item for code key: {Key}", key);
}
return Task.FromResult<string?>(null);
}
public Task StoreNonceAsync(string nonce, int expiresInSeconds)
{
var key = GetNonceKey(nonce);
var item = new StoredItem<bool> // 对于nonce,我们只关心其存在性,值不重要
{
Value = true,
ExpiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(expiresInSeconds)
};
var value = JsonSerializer.Serialize(item, _jsonOptions);
_db.Put(key, value);
return Task.CompletedTask;
}
public async Task<bool> ValidateAndConsumeNonceAsync(string nonce)
{
var key = GetNonceKey(nonce);
// 获取或创建一个针对此特定nonce的信号量,初始计数为1
var locker = _nonceLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
await locker.WaitAsync();
try
{
var value = _db.Get(key);
if (value == null)
{
return false; // Nonce不存在
}
// 关键:立即删除以防止重放
_db.Delete(key);
var item = JsonSerializer.Deserialize<StoredItem<bool>>(value, _jsonOptions);
if (item != null && item.ExpiresAtUtc > DateTimeOffset.UtcNow)
{
return true; // Nonce有效且未过期
}
return false; // Nonce已过期
}
catch (Exception ex)
{
_logger.LogError(ex, "Error occurred while consuming nonce: {Nonce}", nonce);
return false;
}
finally
{
locker.Release();
// 在锁释放后,尝试从字典中移除信号量以防止内存泄漏
_nonceLocks.TryRemove(key, out _);
}
}
}
ValidateAndConsumeNonceAsync的实现是整个设计的核心难点。由于Get和Delete不是原子操作,在高并发下,两个线程可能同时Get到同一个nonce,然后都认为自己验证成功。通过使用ConcurrentDictionary和SemaphoreSlim,我们为每个nonce键创建了一个动态的锁,确保在任何时刻只有一个线程可以处理该nonce,从而在应用层面模拟了原子性。
过期数据清理
LevelDB没有自动的TTL机制,这意味着过期的键值对会永远留在数据库中,导致空间膨胀。我们必须实现一个后台清理服务。
// ExpiredStateCleanerService.cs
using LevelDB;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using System;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
public class ExpiredStateCleanerService : BackgroundService
{
private readonly LevelDbContext _context;
private readonly ILogger<ExpiredStateCleanerService> _logger;
private readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(10); // 清理间隔,应来自配置
public ExpiredStateCleanerService(LevelDbContext context, ILogger<ExpiredStateCleanerService> logger)
{
_context = context;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Expired state cleaner service is starting.");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(_checkInterval, stoppingToken);
Cleanup();
}
catch (OperationCanceledException)
{
// Graceful shutdown
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "An error occurred in the expired state cleaner service.");
}
}
_logger.LogInformation("Expired state cleaner service is stopping.");
}
private void Cleanup()
{
_logger.LogInformation("Starting expired state cleanup cycle.");
long cleanedItems = 0;
var now = DateTimeOffset.UtcNow;
var writeBatch = new WriteBatch();
// LevelDB的迭代器是快照,可以在迭代时安全地删除
using (var iterator = _context.Db.CreateIterator())
{
for (iterator.SeekToFirst(); iterator.IsValid(); iterator.Next())
{
try
{
// 仅检查我们关心的前缀
var key = iterator.KeyAsString();
if (!key.StartsWith("code::") && !key.StartsWith("nonce::"))
{
continue;
}
var item = JsonSerializer.Deserialize<StoredItem<object>>(iterator.ValueAsString());
if (item != null && item.ExpiresAtUtc <= now)
{
writeBatch.Delete(iterator.Key());
cleanedItems++;
}
}
catch (JsonException)
{
// 数据格式不正确,可能也需要清理
_logger.LogWarning("Found malformed item during cleanup for key: {Key}. Deleting it.", iterator.KeyAsString());
writeBatch.Delete(iterator.Key());
cleanedItems++;
}
}
}
if (cleanedItems > 0)
{
_context.Db.Write(writeBatch);
_logger.LogInformation("Finished cleanup cycle. Removed {Count} expired items.", cleanedItems);
}
else
{
_logger.LogInformation("Finished cleanup cycle. No expired items found.");
}
}
}
这个BackgroundService会定期扫描整个数据库。一个常见的错误是逐个删除键,这会产生大量的磁盘I/O。这里的优化是使用WriteBatch,它将所有删除操作分组,然后一次性原子写入,性能远高于单个删除。
整合到ASP.NET Core应用
最后一步是将这些组件注入到ASP.NET Core的依赖注入容器中。
// Program.cs or Startup.cs
public void ConfigureServices(IServiceCollection services)
{
// ...
// LevelDbContext 应该是单例,因为它管理着一个持久化的数据库文件句柄。
services.AddSingleton<LevelDbContext>();
// IOidcStateStore 可以是 Scoped 或 Transient,因为它依赖于单例的 DbContext。
services.AddScoped<IOidcStateStore, LevelDbOidcStateStore>();
// 注册后台清理服务
services.AddHostedService<ExpiredStateCleanerService>();
// ...
}
在控制器中,我们可以直接注入IOidcStateStore来处理OIDC回调。
// AuthenticationController.cs
[ApiController]
[Route("[controller]")]
public class AuthenticationController : ControllerBase
{
private readonly IOidcStateStore _oidcStateStore;
public AuthenticationController(IOidcStateStore oidcStateStore)
{
_oidcStateStore = oidcStateStore;
}
[HttpGet("callback")]
public async Task<IActionResult> Callback([FromQuery] string code, [FromQuery] string state)
{
// ... 验证 state ...
// 消费授权码
var storedData = await _oidcStateStore.ConsumeAuthorizationCodeAsync(code);
if (storedData == null)
{
return BadRequest("Invalid or expired authorization code.");
}
// ... 使用 storedData (如 PKCE code_verifier) 与 code 去IdP交换token ...
return Ok();
}
}
通过这种方式,控制器逻辑与底层存储机制完全解耦。
架构图景
整个系统的交互流程如下:
sequenceDiagram
participant UserAgent as User-Agent
participant Service as Service Instance (w/ LevelDB Store)
participant LevelDB as Local LevelDB Store
participant IdP as Identity Provider
UserAgent->>Service: Initiates Login
Service->>IdP: Generates Auth Request (with state, nonce, PKCE)
Service->>LevelDB: StoreNonceAsync(nonce), StoreCodeVerifier(code)
IdP-->>UserAgent: Redirect to Login Page
UserAgent->>IdP: User Authenticates
IdP-->>UserAgent: Redirect back to Service (with code, state)
UserAgent->>Service: /callback?code=...&state=...
Service->>LevelDB: ValidateAndConsumeNonceAsync(nonce from id_token)
LevelDB-->>Service: Nonce is valid
Service->>LevelDB: ConsumeAuthorizationCodeAsync(code)
LevelDB-->>Service: Returns code_verifier
Service->>IdP: Token Endpoint Request (with code, code_verifier)
IdP-->>Service: Returns id_token, access_token
Service-->>UserAgent: Login successful, sets session
这个流程图清晰地展示了本地LevelDB存储如何承接了OIDC流程中对瞬时状态的读写,从而避免了服务实例在处理回调时对IdP或其他外部服务的多次查询。
局限性与未来迭代
这个方案并非万能。它的核心设计是为每个服务实例提供一个隔离的、本地的持久化存储。这意味着它存在一些固有的局限性:
无状态扩展的挑战: 如果一个服务有多个实例运行在负载均衡器后面,并且没有启用粘性会话(Sticky Sessions),那么用户的认证流程可能会被路由到不同的实例。例如,发起认证请求的实例A存储了
state,但回调请求被路由到了实例B,实例B的本地LevelDB中并没有这个state,导致认证失败。因此,此方案最适用于启用了粘性会话的场景,或者整个OIDC流程(从请求到回调)能在单次HTTP请求-响应周期内完成的特定流程。存储容量与管理: 虽然LevelDB很轻量,但它仍然在本地磁盘上写入文件。如果服务长时间运行且清理任务失败,数据库文件可能会无限制增长。必须配置适当的监控和告警来跟踪磁盘使用情况。
单点故障: 虽然我们消除了对外部缓存的依赖,但本地磁盘本身成了新的单点故障。磁盘故障将导致该服务实例的状态完全丢失。在云原生环境中,这通常是可以接受的,因为可以简单地替换掉故障的Pod或VM,但任何正在进行的认证流程都会失败。
未来的一个迭代方向是探索RocksDB。作为LevelDB的超集,RocksDB提供了更丰富的调优选项和更好的多线程写入性能,对于更高负载的场景可能是更好的选择。此外,可以设计一种混合策略:将极短生命周期的数据(如state)存储在本地LevelDB中,而将需要跨实例共享的稍长生命周期数据(如刷新令牌的缓存)存储在分布式缓存中,以平衡性能、复杂性和可靠性。