Initial commit: Client Doc docs Server Tools
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System;
|
||||
|
||||
namespace XWorld.Server.Auth
|
||||
{
|
||||
public sealed class DuplicateUsernameException : Exception
|
||||
{
|
||||
public DuplicateUsernameException(string username)
|
||||
: base("username already taken: " + username) { }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user