feat: 迁入 MiGu.Server、平台前端与车辆列表 reflection 回退

从 Simple-FR 拆出 Platform.Server 并重命名为 MiGu.Server;frontends 源码与构建脚本迁入本仓库。地图监控在 projection/cars 失败或为空时回退 reflection 车辆列表;Simple 仓库已移除旧 Platform.Server。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-05-29 18:16:34 +08:00
co-authored by Cursor
parent 804aa68ade
commit 42978930ca
280 changed files with 30046 additions and 8 deletions
+82
View File
@@ -0,0 +1,82 @@
using System.Security.Cryptography;
namespace MiGu.Server.Auth;
/// <summary>
/// MiGu.Server ↔ SimpleLite 8222 之间的 **内部共享 token**。
///
/// 设计动机:SimpleLite 8222 的 EmbedIO WebApiReflectionApi / PersistenceApi / MapEditApi
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 SimpleLite 侧实现完整 JWT 验签
/// (减少 SimpleLite 复杂度),采用一个轻量约定:
///
/// - <b>本机回环</b>127.0.0.1 / ::1SimpleLite 直接放行(开发机直连不受影响);
/// - <b>其它来源</b>必须携带 <c>X-Platform-Internal-Token</c> header
/// - MiGu.Server YARP 反代 <c>/api/sl/*</c> 到 SimpleLite 8222 时,YARP transform
/// 会自动追加该 header
/// - SimpleLite 端通过 <c>simple.json:platform.internalToken</c> 或环境变量
/// <c>SIMPLELITE__PLATFORM__INTERNALTOKEN</c> 配置同一个 token;两端不一致即拒绝。
///
/// Token 来源优先级:
/// 1) appsettings.json:Internal:Token 显式配置(生产推荐:strong, length ≥ 32
/// 2) 环境变量 PLATFORM__INTERNAL__TOKEN
/// 3) 兜底:进程随机 64 字节 base64,并在日志告警 + 写入 <c>data/.internal-token</c>
/// 文件供本机 SimpleLite 读取(同机部署常见场景)
/// </summary>
public sealed class InternalTokenStore
{
public string Token { get; }
public bool IsEphemeral { get; }
public string? PersistedFilePath { get; }
public InternalTokenStore(IConfiguration config, IWebHostEnvironment env, ILogger<InternalTokenStore> logger)
{
var configured = config["Internal:Token"];
if (!string.IsNullOrWhiteSpace(configured) && configured != "REPLACE_ME")
{
Token = configured;
IsEphemeral = false;
logger.LogInformation("Internal token 从配置读取(长度 {Len})。", Token.Length);
return;
}
// 兜底:进程随机 + 落地到 data/.internal-token 便于同机 SimpleLite 读取
var dataDir = Path.Combine(env.ContentRootPath, "data");
Directory.CreateDirectory(dataDir);
var file = Path.Combine(dataDir, ".internal-token");
if (File.Exists(file))
{
try
{
var existing = File.ReadAllText(file).Trim();
if (existing.Length >= 32)
{
Token = existing;
IsEphemeral = false;
PersistedFilePath = file;
logger.LogInformation("Internal token 复用 {File}(长度 {Len})。", file, Token.Length);
return;
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "读取 {File} 失败,将重新生成 internal token。", file);
}
}
Token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
IsEphemeral = true;
try
{
File.WriteAllText(file, Token);
PersistedFilePath = file;
logger.LogWarning(
"Internal token 未配置 —— 已生成进程随机值并写入 {File}(重启后保留)。" +
"若 MiGu.Server 与 SimpleLite 不在同一台机器,请把同一个值写入 SimpleLite 端 simple.json:platform.internalToken。",
file);
}
catch (Exception ex)
{
logger.LogError(ex, "internal token 落盘失败 —— 远程 SimpleLite 调用将无法通过鉴权(每次重启 token 都不同)。");
}
}
}
+100
View File
@@ -0,0 +1,100 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.Tokens;
namespace MiGu.Server.Auth;
/// <summary>
/// 平台 JWT 颁发与验签。Secret 从 <c>appsettings.json</c> 的 <c>Jwt:Secret</c> 读取;
/// 若为占位值 <c>"REPLACE_ME"</c> 则启动期生成临时随机 secret 并在日志里强制告警,
/// 防止生产部署忘改 secret 导致历史 hardcoded "simple-mock-secret" 风险复现。
/// </summary>
public sealed class JwtIssuer
{
/// <summary>占位 secret;启动期检测到时自动换成进程随机值,并在日志里 critical 告警。</summary>
public const string PlaceholderSecret = "REPLACE_ME";
public string Issuer { get; }
public string Audience { get; }
public TimeSpan Lifetime { get; }
public string SecretInUse { get; }
public bool SecretIsEphemeral { get; }
private readonly SymmetricSecurityKey _key;
private readonly SigningCredentials _signing;
private readonly TokenValidationParameters _validation;
public JwtIssuer(string secret, string issuer, string audience, TimeSpan lifetime, ILogger<JwtIssuer> logger)
{
Issuer = issuer;
Audience = audience;
Lifetime = lifetime;
if (string.IsNullOrWhiteSpace(secret) || secret == PlaceholderSecret)
{
// 兜底:用进程随机 secret,保证签名不被预知,但 token 在 MiGu.Server 重启后失效。
// 这条路径只应出现在「开发机首次启动」/「忘改配置的部署」,**日志里强制告警让运维注意**。
secret = RandomBase64Secret(64);
SecretIsEphemeral = true;
logger.LogCritical(
"Jwt:Secret 是占位值或未配置 —— 已生成进程随机 secret(重启后所有 token 失效)。" +
"生产环境必须在 appsettings.Production.json 或环境变量 PLATFORM__JWT__SECRET 配置一个 ≥ 32 字节的稳定 secret。");
}
else if (Encoding.UTF8.GetByteCount(secret) < 32)
{
logger.LogWarning("Jwt:Secret 长度不足 32 字节,HS256 推荐 ≥ 32 字节 secret 以达到 256bit 强度。");
}
SecretInUse = secret;
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
_signing = new SigningCredentials(_key, SecurityAlgorithms.HmacSha256);
_validation = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateIssuerSigningKey = true,
ValidateLifetime = true,
ValidIssuer = Issuer,
ValidAudience = Audience,
IssuerSigningKey = _key,
ClockSkew = TimeSpan.FromSeconds(30),
};
}
/// <summary>颁发 access token。<paramref name="ops"/> 写入私有 claim <c>ops</c>(空格分隔,便于后端 <c>[Authorize]</c> policy 解析)。</summary>
public string Issue(string userId, string username, string scope, IReadOnlyList<string> roles, IReadOnlyList<string> ops)
{
var now = DateTime.UtcNow;
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, userId),
new(JwtRegisteredClaimNames.UniqueName, username),
new("scope", scope),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
new(JwtRegisteredClaimNames.Iat, new DateTimeOffset(now).ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
new("ops", string.Join(' ', ops ?? Array.Empty<string>())),
};
foreach (var r in roles ?? Array.Empty<string>())
claims.Add(new Claim(ClaimTypes.Role, r));
var token = new JwtSecurityToken(
issuer: Issuer,
audience: Audience,
claims: claims,
notBefore: now,
expires: now.Add(Lifetime),
signingCredentials: _signing);
return new JwtSecurityTokenHandler().WriteToken(token);
}
/// <summary>给框架的 JwtBearer middleware 用的验证参数(启动期注入到 AddJwtBearer)。</summary>
public TokenValidationParameters BuildValidationParameters() => _validation;
private static string RandomBase64Secret(int byteLen)
{
var buf = RandomNumberGenerator.GetBytes(byteLen);
return Convert.ToBase64String(buf);
}
}
+75
View File
@@ -0,0 +1,75 @@
using System.Security.Cryptography;
using System.Text;
namespace MiGu.Server.Auth;
/// <summary>
/// 最简化的用户表(占位实现):内置 admin / ops 两个账号 + PBKDF2 哈希密码校验。
/// 真实生产应替换成 Microsoft.AspNetCore.Identity 或外接 LDAP / OAuth。
///
/// 安全要点(哪怕是占位也要做到):
/// - 密码不明文存储,启动期用 PBKDF2-SHA256(100k iter, 16B salt) 哈希;
/// - 密码 hash 比较走 <see cref="CryptographicOperations.FixedTimeEquals"/> 防时序攻击;
/// - 不允许「空用户名 = 空密码」之类的快捷绕过。
///
/// 默认账号:
/// admin / admin (Platform scope, role-admin)
/// ops / ops (RCSMonitor scope, role-ops)
/// 默认密码同名是为了**开发机一次启动就能登录**;生产部署务必通过环境变量
/// <c>PLATFORM__AUTH__USERS__&lt;USERNAME&gt;__PASSWORD</c> 改写或接入真实身份源。
/// </summary>
public sealed class UserStore
{
public sealed record UserRecord(
string Id,
string Username,
string DisplayName,
string DefaultScope,
IReadOnlyList<string> Roles,
byte[] Salt,
byte[] PasswordHash);
private readonly Dictionary<string, UserRecord> _users;
public UserStore(IConfiguration config, ILogger<UserStore> logger)
{
_users = new Dictionary<string, UserRecord>(StringComparer.OrdinalIgnoreCase);
// 1. 内置 admin / ops(密码可从 appsettings 覆盖)
var adminPwd = config["Auth:Users:admin:Password"] ?? "admin";
var opsPwd = config["Auth:Users:ops:Password"] ?? "ops";
Add("u-admin", "admin", "系统管理员", "Platform", new[] { "role-admin", "role-platform-write" }, adminPwd);
Add("u-ops", "ops", "运营人员", "RCSMonitor", new[] { "role-ops", "role-monitor-read" }, opsPwd);
if (adminPwd == "admin" || opsPwd == "ops")
{
logger.LogWarning(
"UserStore 使用默认弱密码(admin/admin 或 ops/ops)。生产环境务必通过 appsettings.Production.json " +
"或环境变量 PLATFORM__AUTH__USERS__admin__PASSWORD 等覆盖。");
}
}
/// <summary>用户名 / 密码校验。返回 null = 不存在或密码错。<b>不向调用方区分两种失败原因</b>,防用户名枚举。</summary>
public UserRecord? Verify(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) return null;
if (!_users.TryGetValue(username, out var u)) return null;
var hash = Pbkdf2(password, u.Salt);
return CryptographicOperations.FixedTimeEquals(hash, u.PasswordHash) ? u : null;
}
public UserRecord? Find(string username) =>
_users.TryGetValue(username ?? "", out var u) ? u : null;
private void Add(string id, string username, string displayName, string defaultScope, IReadOnlyList<string> roles, string plainPassword)
{
var salt = RandomNumberGenerator.GetBytes(16);
var hash = Pbkdf2(plainPassword, salt);
_users[username] = new UserRecord(id, username, displayName, defaultScope, roles, salt, hash);
}
private static byte[] Pbkdf2(string password, byte[] salt) =>
Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations: 100_000, HashAlgorithmName.SHA256, 32);
}