305 lines
11 KiB
Markdown
305 lines
11 KiB
Markdown
# 服务子系统设计 — 认证与 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>写入指定键的 token,value 为 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)
|