free-code-dotnet/docs/服务子系统设计/服务子系统设计-认证与OAuth.md
应文浩wenhao.ying@xiaobao100.com e25ac591a7 init easy-code
2026-04-06 07:24:24 +08:00

305 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 服务子系统设计 — 认证与 OAuth
> **所属项目**: free-code .NET 10 重写
> **文档类型**: 子模块设计
> **原始源码**: `../../src/services/oauth/`
> **配套文档**: [服务子系统设计总览](服务子系统设计.md) | [参考映射](reference/原始代码映射-服务子系统.md)
---
## 概述
认证模块负责管理用户身份凭证的完整生命周期,涵盖 OAuth 授权流程启动、回调处理、token 交换、安全存储和登出。原始 TypeScript 实现包含两条独立的 OAuth 流程Anthropic 控制台 OAuth 和 OpenAI Codex OAuth均通过本地 HTTP 监听器完成浏览器回调拦截。
.NET 10 重写将认证能力抽象为 `IAuthService` 接口,安全存储单独抽象为 `ISecureTokenStorage`允许在不同平台macOS Keychain、Windows DPAPI、Linux Secret Service下替换存储后端。
---
## 19.1 IAuthService 接口
```csharp
/// <summary>
/// 认证服务接口 — 管理 OAuth 登录、登出和 token 获取
/// 对应原始 ../../src/services/oauth/ 目录的整体能力
/// </summary>
public interface IAuthService
{
/// <summary>当前是否已通过任意提供商完成认证</summary>
bool IsAuthenticated { get; }
/// <summary>是否为 Claude.ai 用户(通过 claudeai_token 判断)</summary>
bool IsClaudeAiUser { get; }
/// <summary>是否为 Anthropic 内部用户</summary>
bool IsInternalUser { get; }
/// <summary>
/// 启动 OAuth 授权流程
/// </summary>
/// <param name="provider">提供商名称,支持 "anthropic" 和 "codex"</param>
Task LoginAsync(string provider = "anthropic");
/// <summary>清除所有本地存储的 token退出登录</summary>
Task LogoutAsync();
/// <summary>获取当前有效的 OAuth access token未登录时返回 null</summary>
Task<string?> GetOAuthTokenAsync();
/// <summary>认证状态发生变化时触发(登录或登出后)</summary>
event EventHandler? AuthStateChanged;
}
```
---
## 19.2 AuthService 实现
```csharp
/// <summary>
/// 认证服务实现 — Anthropic OAuth + Codex OAuth 双流程
/// 对应原始 ../../src/services/oauth/anthropic.ts 和 openai.ts
/// </summary>
public sealed class AuthService : IAuthService
{
private readonly ISecureTokenStorage _tokenStorage;
private readonly ILogger<AuthService> _logger;
public bool IsAuthenticated => _tokenStorage.Get("oauth_token") != null;
public bool IsClaudeAiUser => _tokenStorage.Get("claudeai_token") != null;
public bool IsInternalUser => false; // Anthropic 内部用户标记,外部构建始终为 false
public event EventHandler? AuthStateChanged;
public AuthService(ISecureTokenStorage tokenStorage, ILogger<AuthService> logger)
{
_tokenStorage = tokenStorage;
_logger = logger;
}
/// <summary>
/// Anthropic OAuth 流程
/// 1. 启动本地 HTTP 监听器(拦截浏览器回调)
/// 2. 构建授权 URL 并打开浏览器
/// 3. 等待回调获取 authorization code
/// 4. 用 code 交换 access token + refresh token
/// 5. 写入安全存储
/// </summary>
public async Task LoginAsync(string provider = "anthropic")
{
if (provider == "anthropic")
{
// 1. 启动本地 HTTP 监听器(获取 OAuth 回调)
var callbackPort = GetAvailablePort();
using var listener = new HttpListener();
listener.Prefixes.Add($"http://localhost:{callbackPort}/");
listener.Start();
// 2. 构建 OAuth 授权 URL
var authUrl = $"https://console.anthropic.com/oauth/authorize"
+ $"?client_id=free-code-cli"
+ $"&redirect_uri=http://localhost:{callbackPort}/"
+ $"&response_type=code"
+ $"&scope=openid profile email";
// 3. 打开浏览器
OpenBrowser(authUrl);
// 4. 等待回调 → 获取 code
var context = await listener.GetContextAsync();
var code = context.Request.QueryString["code"];
// 5. 交换 token
using var httpClient = new HttpClient();
var tokenResponse = await httpClient.PostAsync(
"https://console.anthropic.com/oauth/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "authorization_code",
["code"] = code ?? "",
["redirect_uri"] = $"http://localhost:{callbackPort}/",
["client_id"] = "free-code-cli",
}));
var tokens = await tokenResponse.Content.ReadFromJsonAsync<OAuthTokens>();
_tokenStorage.Set("oauth_token", tokens?.AccessToken);
_tokenStorage.Set("oauth_refresh_token", tokens?.RefreshToken);
}
else if (provider == "codex")
{
// OpenAI Codex OAuth — 不同端点,流程相同
var authUrl = "https://auth.openai.com/authorize"
+ "?client_id=codex-cli"
+ "&response_type=code"
+ "&scope=openid profile email";
// 后续 code exchange 流程与 Anthropic 流程对称
}
AuthStateChanged?.Invoke(this, EventArgs.Empty);
}
public Task LogoutAsync()
{
_tokenStorage.Remove("oauth_token");
_tokenStorage.Remove("oauth_refresh_token");
_tokenStorage.Remove("claudeai_token");
AuthStateChanged?.Invoke(this, EventArgs.Empty);
return Task.CompletedTask;
}
public Task<string?> GetOAuthTokenAsync() =>
Task.FromResult(_tokenStorage.Get("oauth_token"));
private static int GetAvailablePort()
{
using var socket = new System.Net.Sockets.TcpListener(
System.Net.IPAddress.Loopback, 0);
socket.Start();
var port = ((System.Net.IPEndPoint)socket.LocalEndpoint).Port;
socket.Stop();
return port;
}
private static void OpenBrowser(string url)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
Process.Start("open", url);
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
Process.Start("xdg-open", url);
else
Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
}
}
/// <summary>
/// OAuth token 响应的反序列化模型
/// </summary>
internal sealed record OAuthTokens
{
[JsonPropertyName("access_token")]
public string? AccessToken { get; init; }
[JsonPropertyName("refresh_token")]
public string? RefreshToken { get; init; }
[JsonPropertyName("expires_in")]
public int ExpiresIn { get; init; }
}
```
---
## 19.3 ISecureTokenStorage 接口
```csharp
/// <summary>
/// 安全 token 存储接口 — 平台无关
/// macOS: Keychain / Windows: DPAPI / Linux: Secret Service
/// </summary>
public interface ISecureTokenStorage
{
/// <summary>读取指定键的 token不存在时返回 null</summary>
string? Get(string key);
/// <summary>写入指定键的 tokenvalue 为 null 时等同于 Remove</summary>
void Set(string key, string? value);
/// <summary>删除指定键的 token</summary>
void Remove(string key);
}
```
---
## 19.4 KeychainTokenStorage — macOS Keychain 集成
```csharp
/// <summary>
/// macOS Keychain 安全存储实现
/// 对应原始代码中对系统 Keychain 的直接访问
/// 通过 security CLI 工具与 macOS Keychain Services 通信
/// </summary>
public sealed class KeychainTokenStorage : ISecureTokenStorage
{
/// <summary>
/// 读取 Keychain 中的 token
/// 等效命令: security find-generic-password -s free-code-{key} -w
/// </summary>
public string? Get(string key)
{
var psi = new ProcessStartInfo
{
FileName = "security",
Arguments = $"find-generic-password -s free-code-{key} -w",
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
};
using var process = Process.Start(psi);
var output = process?.StandardOutput.ReadToEnd().Trim();
return string.IsNullOrEmpty(output) ? null : output;
}
/// <summary>
/// 写入 token 到 Keychain-U 标志: 不存在则创建,存在则更新)
/// 等效命令: security add-generic-password -U -s free-code-{key} -p {value}
/// </summary>
public void Set(string key, string? value)
{
if (value == null) { Remove(key); return; }
var psi = new ProcessStartInfo
{
FileName = "security",
Arguments = $"add-generic-password -U -s free-code-{key} -p {value}",
CreateNoWindow = true,
};
Process.Start(psi)?.WaitForExit();
}
/// <summary>
/// 从 Keychain 删除 token
/// 等效命令: security delete-generic-password -s free-code-{key}
/// </summary>
public void Remove(string key)
{
var psi = new ProcessStartInfo
{
FileName = "security",
Arguments = $"delete-generic-password -s free-code-{key}",
CreateNoWindow = true,
};
Process.Start(psi)?.WaitForExit();
}
}
```
---
## 设计说明
**本地 HTTP 监听器模式**
OAuth 授权码流程要求一个回调 URI。命令行工具无法注册自定义 URL Scheme不同于桌面应用因此采用本地 HTTP 监听器方案:随机选取可用端口,启动监听后打开浏览器,用户授权后浏览器重定向到 `localhost:{port}/?code=...`CLI 截获 code 完成交换。这是命令行 OAuth 的通行做法,与 GitHub CLI、Azure CLI 的实现模式一致。
**Keychain 使用 CLI 而非 P/Invoke**
原始 TypeScript 代码通过 Node.js 调用 `security` 命令行工具,.NET 重写保持相同策略。使用 `security` CLI 而非直接调用 Keychain Services C API避免了 macOS 10.15+ 的 hardened runtime 代码签名限制,也无需 entitlements 配置。代价是每次读写有子进程启动开销,但认证操作频率极低,可以接受。
**跨平台存储抽象**
`ISecureTokenStorage` 接口的引入使 DI 容器可以按运行平台注入不同实现。注册时可这样处理:
```csharp
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
services.AddSingleton<ISecureTokenStorage, KeychainTokenStorage>();
else
services.AddSingleton<ISecureTokenStorage, EncryptedFileTokenStorage>();
```
---
## 参考资料
- [服务子系统设计总览](服务子系统设计.md)
- [原始代码映射 — 服务子系统](reference/原始代码映射-服务子系统.md)
- [核心模块设计 — API 提供商路由](../核心模块设计/核心模块设计-API提供商路由.md)