Initial commit: Client Doc docs Server Tools

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ud18010
2026-07-10 10:24:29 +08:00
co-authored by Cursor
commit 7e35d8da31
3374 changed files with 680813 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
using System;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Data.Sqlite;
namespace XWorld.Server.Auth
{
// SQLite 账号库。保持单条长连接(内存库测试 + 文件库生产均可),低频鉴权用 lock 串行化。
public sealed class AccountStore : IDisposable
{
const int SaltLen = 16;
const int HashLen = 32;
const int Iterations = 100_000;
readonly SqliteConnection _conn;
readonly object _lock = new object();
// dbPath: 文件路径(生产) 或完整连接串(以 "Data Source=" 开头,测试用内存库)
public AccountStore(string dbPath)
{
string cs = dbPath.StartsWith("Data Source=", StringComparison.OrdinalIgnoreCase)
? dbPath
: "Data Source=" + dbPath;
_conn = new SqliteConnection(cs);
_conn.Open();
Exec("PRAGMA journal_mode=WAL;");
Exec(@"CREATE TABLE IF NOT EXISTS accounts(
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL COLLATE NOCASE,
pwd_hash BLOB NOT NULL,
salt BLOB NOT NULL,
iter INTEGER NOT NULL,
created_at INTEGER NOT NULL);");
}
public int CreateAccount(string username, string password)
{
if (string.IsNullOrWhiteSpace(username)) throw new ArgumentException("username");
if (string.IsNullOrEmpty(password)) throw new ArgumentException("password");
byte[] salt = RandomNumberGenerator.GetBytes(SaltLen);
byte[] hash = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, HashLen);
lock (_lock)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = "INSERT INTO accounts(username,pwd_hash,salt,iter,created_at) VALUES($u,$h,$s,$i,$t);";
cmd.Parameters.AddWithValue("$u", username);
cmd.Parameters.AddWithValue("$h", hash);
cmd.Parameters.AddWithValue("$s", salt);
cmd.Parameters.AddWithValue("$i", Iterations);
cmd.Parameters.AddWithValue("$t", DateTimeOffset.UtcNow.ToUnixTimeSeconds());
try { cmd.ExecuteNonQuery(); }
catch (SqliteException e) when (e.SqliteErrorCode == 19) // SQLITE_CONSTRAINT (UNIQUE)
{ throw new DuplicateUsernameException(username); }
using var idCmd = _conn.CreateCommand();
idCmd.CommandText = "SELECT last_insert_rowid();";
return (int)(long)idCmd.ExecuteScalar();
}
}
public int? VerifyCredentials(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) return null;
lock (_lock)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT id,pwd_hash,salt,iter FROM accounts WHERE username=$u COLLATE NOCASE;";
cmd.Parameters.AddWithValue("$u", username);
using var r = cmd.ExecuteReader();
if (!r.Read()) return null;
int id = (int)r.GetInt64(0);
byte[] stored = (byte[])r["pwd_hash"];
byte[] salt = (byte[])r["salt"];
int iter = (int)r.GetInt64(3);
byte[] cand = Rfc2898DeriveBytes.Pbkdf2(
Encoding.UTF8.GetBytes(password), salt, iter, HashAlgorithmName.SHA256, stored.Length);
return CryptographicOperations.FixedTimeEquals(cand, stored) ? id : (int?)null;
}
}
// 测试辅助:原始库字节是否包含某序列(验证明文不入库)
public bool DebugRawContains(byte[] needle)
{
lock (_lock)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = "SELECT pwd_hash,salt FROM accounts;";
using var r = cmd.ExecuteReader();
while (r.Read())
{
if (Contains((byte[])r["pwd_hash"], needle) || Contains((byte[])r["salt"], needle))
return true;
}
return false;
}
}
static bool Contains(byte[] hay, byte[] needle)
{
if (needle.Length == 0 || hay.Length < needle.Length) return false;
for (int i = 0; i <= hay.Length - needle.Length; i++)
{
bool ok = true;
for (int j = 0; j < needle.Length; j++) if (hay[i + j] != needle[j]) { ok = false; break; }
if (ok) return true;
}
return false;
}
void Exec(string sql)
{
using var cmd = _conn.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
public void Dispose() => _conn?.Dispose();
}
}
+16
View File
@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>XWorld.Server.Auth</AssemblyName>
<RootNamespace>XWorld.Server.Auth</RootNamespace>
</PropertyGroup>
<ItemGroup>
<!-- AuthEndpoints 用到 ASP.NET 类型(IEndpointRouteBuilder/Results/MapPost -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<!-- NU1903: 传递依赖 SQLitePCLRaw.lib.e_sqlite3 有已知 SQLite 漏洞(GHSA-2m69-gcr7-jv3q)
2.1.10/2.1.11 均被标记;待 SQLitePCLRaw 发布修复版后 bump 此包即可消除。功能不受影响。 -->
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.0" />
</ItemGroup>
</Project>
+36
View File
@@ -0,0 +1,36 @@
using System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
namespace XWorld.Server.Auth
{
public static class AuthEndpoints
{
public sealed class AuthRequest { public string username { get; set; } public string password { get; set; } }
public sealed class AuthResponse { public string token { get; set; } public int pid { get; set; } }
public static void Map(IEndpointRouteBuilder app, AccountStore store, TokenService tokens, TimeSpan tokenTtl)
{
app.MapPost("/register", (AuthRequest req) =>
{
if (req == null || string.IsNullOrWhiteSpace(req.username) || string.IsNullOrEmpty(req.password))
return Results.BadRequest(new { error = "invalid_input" });
int pid;
try { pid = store.CreateAccount(req.username, req.password); }
catch (DuplicateUsernameException) { return Results.Conflict(new { error = "username_taken" }); }
catch (ArgumentException) { return Results.BadRequest(new { error = "invalid_input" }); }
return Results.Ok(new AuthResponse { token = tokens.Issue(pid, tokenTtl), pid = pid });
});
app.MapPost("/login", (AuthRequest req) =>
{
if (req == null || string.IsNullOrWhiteSpace(req.username) || string.IsNullOrEmpty(req.password))
return Results.BadRequest(new { error = "invalid_input" });
int? pid = store.VerifyCredentials(req.username, req.password);
if (pid == null) return Results.Json(new { error = "invalid_credentials" }, statusCode: 401);
return Results.Ok(new AuthResponse { token = tokens.Issue(pid.Value, tokenTtl), pid = pid.Value });
});
}
}
}
+10
View File
@@ -0,0 +1,10 @@
using System;
namespace XWorld.Server.Auth
{
public sealed class DuplicateUsernameException : Exception
{
public DuplicateUsernameException(string username)
: base("username already taken: " + username) { }
}
}
+64
View File
@@ -0,0 +1,64 @@
using System;
using System.Security.Cryptography;
using System.Text;
namespace XWorld.Server.Auth
{
// 无状态 HMAC-SHA256 token。格式: "v1.{pid}.{expUnix}.{base64url(hmac(payload))}"
// 其中 payload = "v1.{pid}.{expUnix}"。
public sealed class TokenService
{
readonly byte[] _key;
public TokenService(byte[] key)
{
if (key == null || key.Length < 16) throw new ArgumentException("key too short");
_key = key;
}
public string Issue(int pid, TimeSpan ttl)
{
long exp = DateTimeOffset.UtcNow.Add(ttl).ToUnixTimeSeconds();
string payload = "v1." + pid + "." + exp;
return payload + "." + B64Url(Sign(payload));
}
public bool Verify(string token, out int pid)
{
pid = 0;
if (string.IsNullOrEmpty(token)) return false;
int lastDot = token.LastIndexOf('.');
if (lastDot <= 0) return false;
string payload = token.Substring(0, lastDot);
string sig = token.Substring(lastDot + 1);
byte[] expected = Sign(payload);
byte[] got;
try { got = FromB64Url(sig); } catch { return false; }
if (!CryptographicOperations.FixedTimeEquals(expected, got)) return false;
string[] parts = payload.Split('.'); // ["v1", pid, exp]
if (parts.Length != 3 || parts[0] != "v1") return false;
if (!int.TryParse(parts[1], out pid)) return false;
if (!long.TryParse(parts[2], out long exp)) return false;
if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() > exp) return false;
return true;
}
byte[] Sign(string payload)
{
using var h = new HMACSHA256(_key);
return h.ComputeHash(Encoding.UTF8.GetBytes(payload));
}
static string B64Url(byte[] b) =>
Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_');
static byte[] FromB64Url(string s)
{
string b = s.Replace('-', '+').Replace('_', '/');
switch (b.Length % 4) { case 2: b += "=="; break; case 3: b += "="; break; }
return Convert.FromBase64String(b);
}
}
}