从 Simple-FR 拆出 Platform.Server 并重命名为 MiGu.Server;frontends 源码与构建脚本迁入本仓库。地图监控在 projection/cars 失败或为空时回退 reflection 车辆列表;Simple 仓库已移除旧 Platform.Server。 Co-authored-by: Cursor <cursoragent@cursor.com>
101 lines
4.4 KiB
C#
101 lines
4.4 KiB
C#
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);
|
|
}
|
|
}
|