增加Shop API
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user