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:
@@ -0,0 +1,82 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace MiGu.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// MiGu.Server ↔ SimpleLite 8222 之间的 **内部共享 token**。
|
||||
///
|
||||
/// 设计动机:SimpleLite 8222 的 EmbedIO WebApi(ReflectionApi / PersistenceApi / MapEditApi)
|
||||
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 SimpleLite 侧实现完整 JWT 验签
|
||||
/// (减少 SimpleLite 复杂度),采用一个轻量约定:
|
||||
///
|
||||
/// - <b>本机回环</b>(127.0.0.1 / ::1)SimpleLite 直接放行(开发机直连不受影响);
|
||||
/// - <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 都不同)。");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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__<USERNAME>__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);
|
||||
}
|
||||
Reference in New Issue
Block a user