增加Shop API

This commit is contained in:
chfei committed 2026-10-29 11:34:35 +08:00
1 parent 3769193696
commit 5d017740c6
83 files changed
+4451 -696

No files matched your search

@@ -1,151 +0,0 @@
using Asp.Versioning.OpenApi;
using MAX.Framework.Security;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.IdentityModel.Tokens;
using Microsoft.OpenApi;
using System.Security.Claims;
using System.Text;
namespace MAX.Framework.DependencyInjection;
/// <summary>
/// JWT 认证服务注册入口(由宿主 Program.cs 调用)。
/// </summary>
public static class JwtServiceCollectionExtensions
{
/// <summary>
/// 注册 JWT 认证:
/// <list type="number">
/// <item>绑定 <c>Jwt</c> 配置节到 <see cref="JwtOptions"/>,并在启动时校验签名密钥长度;</item>
/// <item>注册 <see cref="ITokenService"/>(令牌生成/校验)与 <see cref="ICurrentUser"/>(当前用户访问器);</item>
/// <item>注册 <see cref="JwtBearerDefaults"/> 认证,认证失败/授权失败统一返回 <see cref="Dtos.ApiResult{T}"/> 格式;</item>
/// <item>注册 <see cref="IOnlineUserStore"/>(数据库会话存储):多端登录、会话查询、退出撤销;</item>
/// <item>为 OpenAPI 文档声明 Bearer(JWT) 安全方案,并对标记了 [Authorize] 的接口声明需要携带令牌。</item>
/// </list>
/// </summary>
/// <remarks>
/// 需要在请求管道中调用 <c>app.UseAuthentication()</c>(位于 <c>app.UseAuthorization()</c> 之前)。
/// </remarks>
public static IServiceCollection AddStoreHubJwt(this IServiceCollection services, IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
// JwtOptions:绑定 Jwt 配置节,启动时校验 HS256 签名密钥长度
services.AddOptions<JwtOptions>()
.Bind(configuration.GetSection(JwtOptions.SectionName))
.Validate(
o => !string.IsNullOrWhiteSpace(o.SigningKey)
&& Encoding.UTF8.GetByteCount(o.SigningKey) >= 32,
"Jwt:SigningKey 长度不足,HS256 签名密钥至少需要 32 字节(256位)。")
.ValidateOnStart();
services.AddHttpContextAccessor();
// 令牌服务与当前用户访问器
services.AddSingleton<ITokenService, TokenService>();
services.AddScoped<ICurrentUser, CurrentUser>();
// 使用宿主注册的 IFreeSql 持久化会话。
services.AddSingleton<IOnlineUserStore, OnlineUserStore>();
// JwtBearer 认证
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
JwtOptions jwt = configuration.GetSection(JwtOptions.SectionName).Get<JwtOptions>() ?? new JwtOptions();
options.TokenValidationParameters = jwt.ToTokenValidationParameters();
options.Events = new JwtBearerEvents
{
OnChallenge = JwtBearerResponseHandler.OnChallenge,
OnForbidden = JwtBearerResponseHandler.OnForbidden,
OnMessageReceived = context =>
{
// 固定测试凭据只在 Development 环境生效,不依赖 JWT 过期时间或在线会话。
var environment = context.HttpContext.RequestServices.GetRequiredService<IHostEnvironment>();
var development = configuration.GetSection("Jwt:DevelopmentToken");
var token = development["Token"];
var userName = development["UserName"];
var authorization = context.Request.Headers.Authorization.ToString();
if (environment.IsDevelopment()
&& development.GetValue<bool>("Enabled")
&& !string.IsNullOrWhiteSpace(token)
&& !string.IsNullOrWhiteSpace(userName)
&& authorization.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
&& string.Equals(authorization[7..].Trim(), token, StringComparison.Ordinal))
{
context.Principal = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(JwtClaimTypes.UserName, userName),
new Claim(JwtClaimTypes.NickName, "开发测试用户")],
JwtBearerDefaults.AuthenticationScheme));
context.Success();
}
return Task.CompletedTask;
},
// 会话校验:必须未过期且未撤销
OnTokenValidated = context =>
{
IOnlineUserStore onlineUserStore = context.HttpContext.RequestServices.GetRequiredService<IOnlineUserStore>();
ClaimsPrincipal? principal = context.Principal;
string? userName = principal?.FindFirst(JwtClaimTypes.UserName)?.Value;
string? jti = principal?.FindFirst(JwtClaimTypes.Jti)?.Value;
if (string.IsNullOrWhiteSpace(userName)
|| string.IsNullOrWhiteSpace(jti)
|| !onlineUserStore.IsTokenActive(userName, jti))
{
context.Fail(new SecurityTokenException("登录已失效,请重新登录"));
}
return Task.CompletedTask;
},
};
});
// OpenAPI:声明 Bearer(JWT) 安全方案(Scalar 页面即可显示 Authorize/输入 token),
// 并仅对标记了 [Authorize] 的接口声明需要携带令牌(与宿主的 AddOpenApi 配置合并生效)
services.Configure<VersionedOpenApiOptions>(options =>
{
// 在 OpenAPI 文档中声明 Bearer(JWT) 安全方案
options.Document.AddDocumentTransformer((document, _, _) =>
{
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes.TryAdd("Bearer", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "JWT",
Description = "登录接口返回的 accessToken,直接粘贴令牌内容即可(无需手动加 Bearer 前缀)。",
});
return Task.CompletedTask;
});
// 仅对标记了 [Authorize] 的接口声明需要携带令牌
options.Document.AddOperationTransformer((operation, context, _) =>
{
IList<object> metadata = context.Description.ActionDescriptor.EndpointMetadata;
bool requiresAuth = metadata.OfType<IAuthorizeData>().Any()
&& !metadata.OfType<IAllowAnonymous>().Any();
if (requiresAuth)
{
operation.Security =
[
new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference("Bearer", context.Document, "")] = [],
},
];
}
return Task.CompletedTask;
});
});
return services;
}
}
@@ -0,0 +1,72 @@
using Asp.Versioning.OpenApi;
using MAX.Framework.Security;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.OpenApi;
namespace MAX.Framework.DependencyInjection;
public static class TokenAuthServiceCollectionExtensions
{
// Compatibility entry point; authentication no longer uses JWT.
public static IServiceCollection AddStoreHubJwt(this IServiceCollection services, IConfiguration configuration)
=> services.AddStoreHubTokenAuth(configuration);
public static IServiceCollection AddStoreHubTokenAuth(this IServiceCollection services, IConfiguration configuration)
{
services.AddOptions<TokenAuthOptions>().Bind(configuration.GetSection("TokenAuth"))
.Validate(o => o.IdleDays > 0 && o.IdleDays <= 36500 && o.AbsoluteMaxDays > 0 && o.AbsoluteMaxDays <= 36500
&& o.RenewIntervalMinutes > 0 && o.RenewIntervalMinutes < o.IdleDays * 1440
&& o.CacheSeconds >= 0 && o.CacheSeconds <= 86400 && o.MaxDevicesPerUser >= 0,
"TokenAuth 配置无效:有效期必须为正,续期间隔必须小于闲置有效期,缓存和设备数不能为负。")
.ValidateOnStart();
services.AddMemoryCache();
services.AddSingleton(TimeProvider.System);
services.AddHttpContextAccessor();
services.AddSingleton<TokenService>();
services.AddSingleton<ITokenService>(sp => sp.GetRequiredService<TokenService>());
services.AddSingleton<IOnlineUserStore, OnlineUserStore>();
services.AddScoped<ICurrentUser, CurrentUser>();
services.AddAuthentication("Bearer").AddScheme<AuthenticationSchemeOptions, TokenAuthenticationHandler>("Bearer", _ => { });
services.Configure<VersionedOpenApiOptions>(options =>
{
// 在 OpenAPI 文档中声明 Bearer 安全方案
options.Document.AddDocumentTransformer((document, _, _) =>
{
document.Components ??= new OpenApiComponents();
document.Components.SecuritySchemes ??= new Dictionary<string, IOpenApiSecurityScheme>();
document.Components.SecuritySchemes.TryAdd("Bearer", new OpenApiSecurityScheme
{
Type = SecuritySchemeType.Http,
Scheme = "bearer",
BearerFormat = "Opaque",
Description = "登录接口返回的 accessToken,直接粘贴令牌内容即可(无需手动加 Bearer 前缀)。",
});
return Task.CompletedTask;
});
// 仅对标记了 [Authorize] 的接口声明需要携带令牌
options.Document.AddOperationTransformer((operation, context, _) =>
{
IList<object> metadata = context.Description.ActionDescriptor.EndpointMetadata;
bool requiresAuth = metadata.OfType<IAuthorizeData>().Any()
&& !metadata.OfType<IAllowAnonymous>().Any();
if (requiresAuth)
{
operation.Security =
[
new OpenApiSecurityRequirement
{
[new OpenApiSecuritySchemeReference("Bearer", context.Document, "")] = [],
},
];
}
return Task.CompletedTask;
});
});
return services;
}
}
-1
View File
@@ -14,7 +14,6 @@
<PackageReference Include="FreeSql" Version="3.5.311" />
<PackageReference Include="FreeSql.Provider.MySql" Version="3.5.311" />
<PackageReference Include="FreeSql.Repository" Version="3.5.311" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.1" />
<PackageReference Include="Riok.Mapperly" Version="4.3.1" />
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
+2 -2
View File
@@ -24,14 +24,14 @@ public class TokenResult
}
/// <summary>
/// JWT 令牌服务:生成与校验访问令牌。
/// 数据库令牌服务:生成与校验访问令牌。
/// </summary>
public interface ITokenService
{
/// <summary>
/// 根据声明集合生成访问令牌
/// </summary>
TokenResult CreateToken(IEnumerable<Claim> claims, string? loginIp = null);
TokenResult CreateToken(IEnumerable<Claim> claims, string? loginIp = null, string? deviceId = null, string? deviceName = null);
/// <summary>
/// 校验令牌,校验失败返回 null
@@ -1,45 +0,0 @@
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http;
using System.Text;
using System.Text.Json;
namespace MAX.Framework.Security;
/// <summary>
/// JwtBearer 认证失败/授权失败时,统一返回 <see cref="ApiResult{T}"/> 格式的 JSON 响应。
/// </summary>
internal static class JwtBearerResponseHandler
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
/// <summary>
/// 未携带令牌或令牌无效(401)
/// </summary>
public static Task OnChallenge(JwtBearerChallengeContext context)
{
context.HandleResponse();
return WriteAsync(context.HttpContext, StatusCodes.Status401Unauthorized, "未授权:请先登录或携带有效的访问令牌");
}
/// <summary>
/// 已认证但无权限(403)
/// </summary>
public static Task OnForbidden(ForbiddenContext context)
{
return WriteAsync(context.HttpContext, StatusCodes.Status403Forbidden, "禁止访问:当前账号没有权限执行该操作");
}
private static async Task WriteAsync(HttpContext httpContext, int statusCode, string message)
{
httpContext.Response.StatusCode = statusCode;
httpContext.Response.ContentType = "application/json; charset=utf-8";
string body = JsonSerializer.Serialize(ApiResult.Fail(message, statusCode), JsonOptions);
await httpContext.Response.WriteAsync(body, Encoding.UTF8);
}
}
-74
View File
@@ -1,74 +0,0 @@
using Microsoft.IdentityModel.Tokens;
using System.Text;
namespace MAX.Framework.Security;
/// <summary>
/// JWT 配置选项,默认绑定配置节 <c>Jwt</c>。
/// </summary>
public class JwtOptions
{
/// <summary>
/// 配置节名称
/// </summary>
public const string SectionName = "Jwt";
/// <summary>
/// 签发者
/// </summary>
public string Issuer { get; set; } = "StoreHub";
/// <summary>
/// 受众
/// </summary>
public string Audience { get; set; } = "StoreHub.Client";
/// <summary>
/// 签名密钥(HS256,至少 32 字节)
/// </summary>
public string SigningKey { get; set; } = "StoreHub-Development-SigningKey-0123456789abcdef0123456789abcdef";
/// <summary>
/// 令牌有效期(分钟)
/// </summary>
public int ExpiresMinutes { get; set; } = 120;
/// <summary>
/// 时钟偏移(分钟),容忍客户端与服务器的时间偏差
/// </summary>
public int ClockSkewMinutes { get; set; } = 5;
/// <summary>
/// 是否校验签发者
/// </summary>
public bool ValidateIssuer { get; set; } = true;
/// <summary>
/// 是否校验受众
/// </summary>
public bool ValidateAudience { get; set; } = true;
/// <summary>
/// 是否校验有效期
/// </summary>
public bool ValidateLifetime { get; set; } = true;
/// <summary>
/// 根据当前选项构建 <see cref="TokenValidationParameters"/>,用于 JwtBearer 中间件与令牌校验。
/// </summary>
public TokenValidationParameters ToTokenValidationParameters()
{
byte[] keyBytes = Encoding.UTF8.GetBytes(SigningKey);
return new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(keyBytes),
ValidateIssuer = ValidateIssuer,
ValidIssuer = Issuer,
ValidateAudience = ValidateAudience,
ValidAudience = Audience,
ValidateLifetime = ValidateLifetime,
ClockSkew = TimeSpan.FromMinutes(ClockSkewMinutes),
};
}
}
@@ -1,23 +1,17 @@
using FreeSql.DataAnnotations;
namespace MAX.Framework.Security;
/// <summary>持久化登录会话;每次登录拥有独立 jti,不保存完整 JWT。</summary>
[Table(Name = "wxxd.auth_login_session")]
/// <summary>登录会话展示 DTO;Jti 为数据库 Token 记录 ID。</summary>
public class OnlineUserInfo
{
[Column(IsPrimary = true, StringLength = 32)]
public string Jti { get; set; } = string.Empty;
[Column(StringLength = 255)]
public string UserName { get; set; } = string.Empty;
[Column(StringLength = 255)]
public string NickName { get; set; } = string.Empty;
/// <summary>服务端观察到的客户端 IP;反向代理需配置受信任的转发头。</summary>
[Column(StringLength = 45)]
public string LoginIp { get; set; } = string.Empty;
/// <summary>UTC 登录时间。</summary>
public DateTime LoginTime { get; set; }
/// <summary>UTC 到期时间,与 JWT exp 对齐。</summary>
/// <summary>UTC 当前滑动到期时间。</summary>
public DateTime ExpiresAt { get; set; }
/// <summary>UTC 撤销时间;保留记录供后续面板查询。</summary>
public DateTime? RevokedAt { get; set; }
+14 -39
View File
@@ -1,47 +1,22 @@
namespace MAX.Framework.Security;
/// <summary>数据库登录会话存储,支持同账号多个独立会话。</summary>
public class OnlineUserStore(IFreeSql db) : IOnlineUserStore
/// <summary>Compatibility facade for existing session management callers.</summary>
public sealed class OnlineUserStore(IFreeSql db, TokenService tokens) : IOnlineUserStore
{
public void SignIn(OnlineUserInfo info)
{
ArgumentNullException.ThrowIfNull(info);
ArgumentException.ThrowIfNullOrWhiteSpace(info.UserName);
ArgumentException.ThrowIfNullOrWhiteSpace(info.Jti);
if (info.ExpiresAt <= info.LoginTime || info.RevokedAt is not null)
throw new ArgumentException("登录会话的有效期或撤销状态无效", nameof(info));
db.Insert(info).ExecuteAffrows();
}
public bool IsTokenActive(string userName, string jti)
{
if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(jti)) return false;
var now = DateTime.UtcNow;
return db.Select<OnlineUserInfo>().Any(x => x.UserName == userName && x.Jti == jti
&& x.RevokedAt == null && x.ExpiresAt > now);
}
public void SignOut(string userName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userName);
db.Update<OnlineUserInfo>().Set(x => x.RevokedAt, DateTime.UtcNow)
.Where(x => x.UserName == userName && x.RevokedAt == null).ExecuteAffrows();
}
public void SignOutSession(string userName, string jti)
{
ArgumentException.ThrowIfNullOrWhiteSpace(userName);
ArgumentException.ThrowIfNullOrWhiteSpace(jti);
db.Update<OnlineUserInfo>().Set(x => x.RevokedAt, DateTime.UtcNow)
.Where(x => x.UserName == userName && x.Jti == jti && x.RevokedAt == null).ExecuteAffrows();
}
public void SignIn(OnlineUserInfo info) => throw new NotSupportedException("请通过 ITokenService 签发数据库令牌");
public void SignOut(string userName) => tokens.Revoke(userName);
public void SignOutSession(string userName, string jti) => tokens.Revoke(userName, jti);
public bool IsTokenActive(string userName, string jti) => GetOnlineUsers().Any(x => x.UserName == userName && x.Jti == jti);
public IReadOnlyList<OnlineUserInfo> GetOnlineUsers()
{
var now = DateTime.UtcNow;
return db.Select<OnlineUserInfo>().Where(x => x.RevokedAt == null && x.ExpiresAt > now)
.OrderByDescending(x => x.LoginTime).ToList();
var now = tokens.CurrentTime;
var cutoff = tokens.AbsoluteCutoff;
return db.Select<UserToken, TokenAccount>()
.InnerJoin((t, u) => t.UserName == u.UserName)
.Where((t, u) => !t.Revoked && t.ExpiresAt > now && t.CreatedAt > cutoff)
.OrderByDescending((t, u) => t.CreatedAt)
.ToList((t, u) => new OnlineUserInfo { Jti = t.Id.ToString(), UserName = u.UserName,
NickName = u.NickName, LoginIp = t.Ip ?? "", LoginTime = t.CreatedAt, ExpiresAt = t.ExpiresAt });
}
public int Count => GetOnlineUsers().Count;
}
+22 -28
View File
@@ -1,34 +1,28 @@
# 登录会话数据库存储
# 数据库 Token 认证
会话使用 `ConnectionStrings:DefaultConnection` 指定的 MySQL 数据库,表名 `auth_login_session`。
FreeSql 初始化时仅同步此表结构;首次使用需要建表/索引权限。没有运行针对业务数据库的迁移或测试。
部署前执行同目录 `user_tokens.sql`,在现有业务库 `wxxd` 中建立唯一的新表;应用不自动修改表结构。
启动:`dotnet run --project Store.API/Store.Api.csproj`。宿主使用 `AddStoreHubTokenAuth`,管道继续使用 `UseAuthentication` / `UseAuthorization`。
## 行为
登录接口仍返回 `tokenType`、`token`、`expiresInMinutes`,可选传入 `deviceId`、`deviceName`(各最多 100 字符)。
客户端按 `Authorization: Bearer {token}` 发送;Token 是 32 字节安全随机数的标准 Base64(44 字符),数据库仅存 SHA256 十六进制哈希。
`expiresInMinutes` 表示签发时的闲置有效期;服务端使用滑动过期,客户端无需刷新令牌。
- 每次登录插入独立的 `Jti` 主键,保存用户名、昵称、登录 IP、UTC 登录时间、UTC 到期时间和撤销时间。不存储完整 JWT。
- 同账号可以同时登录。Logout 仅撤销当前 JWT 对应的会话;修改密码、禁用和删除账号等现有安全操作仍撤销该账号全部会话。
- 每次认证查询数据库,只有会话未过期且未撤销才接受 JWT。保留现有有效期配置(当前 2880 分钟),不新增自动刷新。
- 应用重启后,只要数据库记录、签名密钥、Issuer 和 Audience 保持一致,未过期且未撤销的 Token 可继续使用。
- 从旧内存版本升级时,已有会话不能自动迁移,用户需要重新登录一次。
- OnlineUsers 返回有效会话,包含 LoginIp;Count 是会话数,同账号可能有多条。它不代表浏览器仍然打开。现有授权规则保持不变;正式管理面板应按管理权限提供分页查询。
- 退出和过期不删除记录,以便后续查询历史。后续可根据实际留存周期添加清理任务;本次没有无限查询历史的管理接口或面板。
`TokenAuth` 默认配置:IdleDays=7、AbsoluteMaxDays=30、RenewIntervalMinutes=10、CacheSeconds=30、MaxDevicesPerUser=0。
全部时间按 UTC 保存(DATETIME 不带时区);每次使用达到续期间隔才条件更新到期时间,绝不超过创建时间加绝对期限。
内存缓存使用固定到期时间,命中不会延长缓存寿命。缓存期间仍检查闲置及绝对期限。
## IP 与代理
每次登录创建新记录;相同非空 deviceId 会替换其旧记录。未提供 deviceId 的登录按独立设备计数。
设备数为 0 时无限制,为 1 时单点登录,为 N 时保留最多 N 个有效会话,超限撤销最早的会话。
签发在事务中锁定现有用户行,使多实例的并发登录也遵守数量限制。MySQL 业务用户表需使用 InnoDB。
退出当前会话、修改密码后的全部会话撤销以及设备替换统一更新 Revoked 并清除当前实例缓存。
IP 来自 HttpContext.Connection.RemoteIpAddress,不直接信任客户端提交的 X-Forwarded-For。
若使用 Nginx、IIS 等代理,应按部署拓扑配置 ASP.NET Core ForwardedHeaders 和可信代理,在认证前运行;否则记录的可能是代理地址。
多实例下其他实例最多延迟 CacheSeconds 秒获知撤销;要求每次查库时设置 CacheSeconds=0。
用户信息在缓存未命中时从实际 `wxxd.user` 表读取 id/user_name/nick_name;当前用户表没有 IsEnabled 或 Role 字段,因此未假设这些字段或新增角色策略。
数据库故障向上抛出,由宿主处理错误,认证不会在数据库不可用时绕过校验。
## 数据库与 Redis
当前仅需 MySQL,不需要 Redis。每次受保护请求会执行按主键索引的会话查询。
未来实测认证查询成为瓶颈时可评估 Redis,但必须处理退出/禁用时的缓存失效,不能让缓存继续接受已经撤销的会话。
Redis 自身重启的数据保留取决于 RDB/AOF 持久化与故障恢复配置;不能把默认内存缓存等同于可靠数据库记录。
## 验证
运行 `dotnet test Tests/Store.Security.Tests/Store.Security.Tests.csproj`。
独立 SQLite 测试覆盖关闭/重建数据库连接后的令牌有效性、同账号多会话、IPv4/IPv6 留存、定向退出、撤销后的重启、全部撤销、会话过期和开发测试凭据。
运行 `dotnet build Store.API/Store.Api.csproj` 构建 API。
原 Store.Users.Tests 当前存在既有枚举类型及方法签名不匹配,不能整体编译;新增认证测试独立运行。
生产 MySQL 建表及真实代理 IP 仍需部署环境验证。可使用代码审查进一步检查。
切换后旧 JWT 失效,用户需要重新登录。旧 auth_login_session 表不再使用,保留供人工归档。
开发固定凭据配置位于 `TokenAuth:DevelopmentToken`(Enabled、Token、UserName):基础配置禁用,`appsettings.Development.json` 开启,仅 Development 环境生效。
使用相同的 Bearer 请求头即可测试;固定凭据不查询数据库、不创建会话、不参与滑动过期和退出吊销。关闭 Enabled 或修改 Token 可使其失效。
保留 AddStoreHubJwt 名称作为注册入口的兼容别名;JwtClaimTypes 仅保留现有声明字段名称。
校验:`dotnet test Tests/Store.Security.Tests/Store.Security.Tests.csproj`,使用独立 SQLite 临时库,不连接业务 MySQL。
测试涵盖重启持久化、滑动/绝对过期、设备限制、缓存撤销与开发凭据的环境限制;MySQL 行锁行为仍需部署环境验证。
@@ -0,0 +1,10 @@
namespace MAX.Framework.Security;
public sealed class TokenAuthOptions
{
public int IdleDays { get; set; } = 7;
public int AbsoluteMaxDays { get; set; } = 30;
public int RenewIntervalMinutes { get; set; } = 10;
public int CacheSeconds { get; set; } = 30;
public int MaxDevicesPerUser { get; set; }
}
@@ -0,0 +1,50 @@
using System.Text.Encodings.Web;
using MAX.Framework.Dtos;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using System.Security.Claims;
namespace MAX.Framework.Security;
public sealed class TokenAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder, ITokenService tokens,
IConfiguration configuration, IHostEnvironment environment)
: AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var header = Request.Headers.Authorization.ToString();
if (!header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) return AuthenticateResult.NoResult();
var token = header[7..].Trim();
// 固定凭据仅供开发调试,生产环境即使误开启配置也不能使用。
var development = configuration.GetSection("TokenAuth:DevelopmentToken");
if (environment.IsDevelopment() && development.GetValue<bool>("Enabled")
&& !string.IsNullOrWhiteSpace(development["Token"])
&& !string.IsNullOrWhiteSpace(development["UserName"])
&& string.Equals(token, development["Token"], StringComparison.Ordinal))
{
var identity = new ClaimsIdentity([
new Claim(JwtClaimTypes.UserName, development["UserName"]!),
new Claim(JwtClaimTypes.NickName, "开发测试用户")], Scheme.Name);
return AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(identity), Scheme.Name));
}
var principal = await tokens.ValidateTokenAsync(token);
return principal == null ? AuthenticateResult.Fail("登录已失效,请重新登录")
: AuthenticateResult.Success(new AuthenticationTicket(principal, Scheme.Name));
}
protected override Task HandleChallengeAsync(AuthenticationProperties properties)
{
Response.StatusCode = 401;
Response.Headers.WWWAuthenticate = "Bearer";
return Response.WriteAsJsonAsync(ApiResult.Fail("未授权:请先登录或携带有效的访问令牌", 401));
}
protected override Task HandleForbiddenAsync(AuthenticationProperties properties)
{
Response.StatusCode = 403;
return Response.WriteAsJsonAsync(ApiResult.Fail("禁止访问:当前账号没有权限执行该操作", 403));
}
}
+136 -117
View File
@@ -1,129 +1,148 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
namespace MAX.Framework.Security;
/// <summary>
/// 基于 <see cref="JwtOptions"/> 的 JWT 令牌服务。
/// </summary>
public class TokenService : ITokenService
/// <summary>Opaque database tokens. All timestamps use UTC.</summary>
public sealed class TokenService(IFreeSql db, IOptions<TokenAuthOptions> options, IMemoryCache cache,
TimeProvider clock) : ITokenService
{
private readonly JwtOptions _options;
private readonly IOnlineUserStore _onlineUserStore;
private readonly TokenAuthOptions settings = options.Value;
// Serialize local cache reads/writes and revocations to prevent stale cache resurrection.
internal object Gate { get; } = new();
private sealed record CachedToken(UserToken Token, TokenAccount Account);
private static string Key(string hash) => "token-auth:" + hash;
private static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
// MySQL DATETIME is timezone-free; preserve UTC wall time without provider-local conversion.
private DateTime Now => DateTime.SpecifyKind(DateTimeOffset.FromUnixTimeSeconds(clock.GetUtcNow().ToUnixTimeSeconds()).UtcDateTime, DateTimeKind.Unspecified);
public TokenService(IOptions<JwtOptions> options, IOnlineUserStore onlineUserStore)
{
_options = options.Value;
_onlineUserStore = onlineUserStore;
}
internal DateTime CurrentTime => Now;
internal DateTime AbsoluteCutoff => Now.AddDays(-settings.AbsoluteMaxDays);
/// <inheritdoc />
public TokenResult CreateToken(IEnumerable<Claim> claims, string? loginIp = null)
{
ArgumentNullException.ThrowIfNull(claims);
public TokenResult CreateToken(IEnumerable<Claim> claims, string? loginIp = null,
string? deviceId = null, string? deviceName = null)
{
ArgumentNullException.ThrowIfNull(claims);
var name = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.UserName)?.Value;
ArgumentException.ThrowIfNullOrWhiteSpace(name);
if (deviceId?.Length > 100 || deviceName?.Length > 100 || loginIp?.Length > 64)
throw new ArgumentException("设备信息或 IP 超出允许长度");
deviceId = string.IsNullOrWhiteSpace(deviceId) ? null : deviceId;
lock (Gate)
{
var now = Now;
var plain = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
var expires = now.AddDays(Math.Min(settings.IdleDays, settings.AbsoluteMaxDays));
var revokedHashes = new List<string>();
db.Transaction(() =>
{
// Lock the account row so concurrent logins on different instances respect the limit.
var account = db.Select<TokenAccount>().Where(x => x.UserName == name).ForUpdate().First()
?? throw new InvalidOperationException("用户不存在,无法签发令牌");
var cutoff = now.AddDays(-settings.AbsoluteMaxDays);
var active = db.Select<UserToken>().Where(x => x.UserName == account.UserName && !x.Revoked
&& x.ExpiresAt > now && x.CreatedAt > cutoff).OrderBy(x => x.CreatedAt).OrderBy(x => x.Id).ToList();
var remove = active.Where(x => deviceId != null && x.DeviceId == deviceId).ToList();
var remaining = active.Except(remove).ToList();
if (settings.MaxDevicesPerUser > 0)
remove.AddRange(remaining.Take(Math.Max(0, remaining.Count - settings.MaxDevicesPerUser + 1)));
foreach (var old in remove)
{
db.Update<UserToken>().Set(x => x.Revoked, true).Where(x => x.Id == old.Id).ExecuteAffrows();
revokedHashes.Add(old.TokenHash);
}
db.Insert(new UserToken { TokenHash = Hash(plain), UserName = account.UserName, DeviceId = deviceId,
DeviceName = deviceName, Ip = loginIp, CreatedAt = now, LastUsedAt = now, ExpiresAt = expires })
.ExecuteAffrows();
});
foreach (var hash in revokedHashes) cache.Remove(Key(hash));
return new TokenResult { Token = plain, ExpiresInMinutes = (uint)(expires - now).TotalMinutes };
}
}
DateTime now = DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.ToUnixTimeSeconds()).UtcDateTime;
DateTime expires = now.AddMinutes(_options.ExpiresMinutes);
string jti = Guid.NewGuid().ToString("N");
public Task<ClaimsPrincipal?> ValidateTokenAsync(string token)
{
// Only accept the canonical Base64 representation of 32 bytes.
Span<byte> bytes = stackalloc byte[32];
if (token is null || token.Length != 44 || !Convert.TryFromBase64String(token, bytes, out var length)
|| length != 32 || Convert.ToBase64String(bytes) != token)
return Task.FromResult<ClaimsPrincipal?>(null);
lock (Gate)
{
var hash = Hash(token);
var now = Now;
cache.TryGetValue<CachedToken>(Key(hash), out var entry);
if (entry == null)
{
var row = db.Select<UserToken>().Where(x => x.TokenHash == hash).First();
if (row == null || row.Revoked || row.ExpiresAt <= now || row.CreatedAt.AddDays(settings.AbsoluteMaxDays) <= now)
return Task.FromResult<ClaimsPrincipal?>(null);
var account = db.Select<TokenAccount>().Where(x => x.UserName == row.UserName).First();
if (account == null) return Task.FromResult<ClaimsPrincipal?>(null);
entry = new(row, account);
}
var t = entry.Token;
var absolute = t.CreatedAt.AddDays(settings.AbsoluteMaxDays);
if (t.Revoked || t.ExpiresAt <= now || absolute <= now)
{
cache.Remove(Key(hash));
return Task.FromResult<ClaimsPrincipal?>(null);
}
if (now - t.LastUsedAt >= TimeSpan.FromMinutes(settings.RenewIntervalMinutes))
{
var next = now.AddDays(settings.IdleDays);
if (next > absolute) next = absolute;
// Conditional update never renews expired/revoked tokens or overwrites a newer renewal.
var changed = db.Update<UserToken>().Set(x => x.LastUsedAt, now).Set(x => x.ExpiresAt, next)
.Where(x => x.Id == t.Id && !x.Revoked && x.ExpiresAt > now && x.LastUsedAt == t.LastUsedAt)
.ExecuteAffrows();
if (changed == 0)
{
cache.Remove(Key(hash));
var current = db.Select<UserToken>().Where(x => x.Id == t.Id).First();
if (current == null || current.Revoked || current.ExpiresAt <= now
|| current.CreatedAt.AddDays(settings.AbsoluteMaxDays) <= now)
return Task.FromResult<ClaimsPrincipal?>(null);
t = current;
entry = new(current, entry.Account);
}
else
{
t.LastUsedAt = now;
t.ExpiresAt = next;
}
}
if (settings.CacheSeconds > 0 && !cache.TryGetValue(Key(hash), out object? _))
{
var ttl = TimeSpan.FromSeconds(settings.CacheSeconds);
if (ttl > t.ExpiresAt - now) ttl = t.ExpiresAt - now;
if (ttl > absolute - now) ttl = absolute - now;
cache.Set(Key(hash), entry, ttl);
}
ClaimsPrincipal principal = new(new ClaimsIdentity([
new Claim(ClaimTypes.NameIdentifier, t.UserName.ToString()),
new Claim(JwtClaimTypes.UserName, entry.Account.UserName),
new Claim(JwtClaimTypes.NickName, entry.Account.NickName),
new Claim(JwtClaimTypes.Jti, t.Id.ToString())], "Bearer"));
return Task.FromResult<ClaimsPrincipal?>(principal);
}
}
List<Claim> allClaims = [.. claims, new Claim(JwtClaimTypes.Jti, jti)];
SymmetricSecurityKey key = new(Encoding.UTF8.GetBytes(_options.SigningKey));
JsonWebTokenHandler handler = new();
string token = handler.CreateToken(new SecurityTokenDescriptor
{
Issuer = _options.Issuer,
Audience = _options.Audience,
Claims = ToClaimDictionary(allClaims),
NotBefore = now,
Expires = expires,
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
});
// 持久化独立登录会话
RegisterSession(allClaims, jti, now, expires, loginIp);
return new TokenResult
{
Token = token,
ExpiresInMinutes = (uint)_options.ExpiresMinutes,
};
}
/// <inheritdoc />
public async Task<ClaimsPrincipal?> ValidateTokenAsync(string token)
{
if (string.IsNullOrWhiteSpace(token))
{
return null;
}
try
{
JsonWebTokenHandler handler = new();
TokenValidationResult result = await handler.ValidateTokenAsync(token, _options.ToTokenValidationParameters());
if (!result.IsValid)
{
return null;
}
ClaimsPrincipal principal = new(result.ClaimsIdentity);
// 会话必须仍有效(未到期、未被撤销)
string? userName = principal.FindFirst(JwtClaimTypes.UserName)?.Value;
string? jti = principal.FindFirst(JwtClaimTypes.Jti)?.Value;
if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(jti) || !_onlineUserStore.IsTokenActive(userName, jti))
{
return null;
}
return principal;
}
catch
{
return null;
}
}
/// <summary>
/// 将令牌声明注册为在线会话。
/// </summary>
private void RegisterSession(IEnumerable<Claim> claims, string jti, DateTime loginTime, DateTime expiresAt, string? loginIp)
{
string? userName = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.UserName)?.Value;
if (string.IsNullOrWhiteSpace(userName))
{
return; // 未携带登录账户的令牌不纳入在线会话管理
}
_onlineUserStore.SignIn(new OnlineUserInfo
{
UserName = userName,
NickName = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.NickName)?.Value ?? string.Empty,
Jti = jti,
LoginIp = loginIp ?? string.Empty,
LoginTime = loginTime,
ExpiresAt = expiresAt,
});
}
/// <summary>
/// 将声明集合转换为 <see cref="SecurityTokenDescriptor.Claims"/> 所需的字典;
/// 相同类型的多个声明合并为数组。
/// </summary>
private static Dictionary<string, object> ToClaimDictionary(IEnumerable<Claim> claims)
{
Dictionary<string, object> result = new(StringComparer.Ordinal);
foreach (IGrouping<string, Claim> group in claims.GroupBy(c => c.Type, StringComparer.Ordinal))
{
object[] values = group.Select(c => (object)c.Value).ToArray();
result[group.Key] = values.Length == 1 ? values[0] : values;
}
return result;
}
internal void Revoke(string userName, string? sessionId = null)
{
lock (Gate)
{
var user = db.Select<TokenAccount>().Where(x => x.UserName == userName).First();
if (user == null) return;
var rows = db.Select<UserToken>().Where(x => x.UserName == user.UserName && !x.Revoked).ToList();
if (sessionId != null) rows = rows.Where(x => x.Id.ToString() == sessionId).ToList();
var ids = rows.Select(x => x.Id).ToArray();
if (ids.Length == 0) return;
db.Update<UserToken>().Set(x => x.Revoked, true).Where(x => ids.Contains(x.Id)).ExecuteAffrows();
foreach (var row in rows) cache.Remove(Key(row.TokenHash));
}
}
}
+35
View File
@@ -0,0 +1,35 @@
using FreeSql.DataAnnotations;
namespace MAX.Framework.Security;
[Table(Name = "wxxd.user_tokens")]
public sealed class UserToken
{
[Column(IsPrimary = true, IsIdentity = true)]
public long Id { get; set; }
[Column(StringLength = 64)]
public string TokenHash { get; set; } = "";
[Column(StringLength = 100)]
public string UserName { get; set; }
[Column(StringLength = 100)]
public string? DeviceId { get; set; }
[Column(StringLength = 100)]
public string? DeviceName { get; set; }
[Column(StringLength = 64)]
public string? Ip { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime LastUsedAt { get; set; }
public DateTime ExpiresAt { get; set; }
public bool Revoked { get; set; }
}
// Read-only mapping of the existing user table; never synchronize this projection.
[Table(Name = "wxxd.user", DisableSyncStructure = true)]
public sealed class TokenAccount
{
[Column(Name = "id", IsPrimary = true)]
public int Id { get; set; }
[Column(Name = "user_name")]
public string UserName { get; set; } = "";
[Column(Name = "nick_name")]
public string NickName { get; set; } = "";
}
@@ -0,0 +1,17 @@
-- Run once before deploying the new authentication service. No existing business tables are modified.
CREATE TABLE IF NOT EXISTS wxxd.user_tokens (
Id BIGINT NOT NULL AUTO_INCREMENT,
TokenHash VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
UserId INT NOT NULL,
DeviceId VARCHAR(100) NULL,
DeviceName VARCHAR(100) NULL,
Ip VARCHAR(64) NULL,
CreatedAt DATETIME NOT NULL,
LastUsedAt DATETIME NOT NULL,
ExpiresAt DATETIME NOT NULL,
Revoked TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (Id),
UNIQUE KEY IX_UserTokens_TokenHash (TokenHash),
KEY IX_UserTokens_UserId (UserId),
KEY IX_UserTokens_ExpiresAt (ExpiresAt)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;