更新框架,增加云库搜索模块

This commit is contained in:
chfei committed 2026-09-18 18:40:10 +08:00
1 parent 8526e5e0d2
commit f2caec6083
192 files changed
+3626 -804

No files matched your search

@@ -0,0 +1,66 @@
using Microsoft.AspNetCore.Http;
using System.Globalization;
using System.Security.Claims;
namespace MAX.Framework.Security;
/// <summary>
/// 从 <see cref="IHttpContextAccessor"/> 读取当前请求认证票据的默认实现。
/// </summary>
public class CurrentUser : ICurrentUser
{
private readonly IHttpContextAccessor _httpContextAccessor;
public CurrentUser(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
}
/// <summary>
/// 当前请求的 <see cref="ClaimsPrincipal"/>
/// </summary>
private ClaimsPrincipal? Principal => _httpContextAccessor.HttpContext?.User;
/// <inheritdoc />
public bool IsAuthenticated => Principal?.Identity?.IsAuthenticated == true;
/// <inheritdoc />
public string? UserName => GetClaimValue(JwtClaimTypes.UserName);
/// <inheritdoc />
public string? NickName => GetClaimValue(JwtClaimTypes.NickName);
/// <inheritdoc />
public IEnumerable<Claim> Claims => Principal?.Claims ?? [];
/// <inheritdoc />
public string? GetClaimValue(string claimType)
{
if (string.IsNullOrWhiteSpace(claimType))
{
return null;
}
return Principal?.FindFirst(claimType)?.Value;
}
/// <inheritdoc />
public T? GetClaimValue<T>(string claimType)
{
string? value = GetClaimValue(claimType);
if (value is null)
{
return default;
}
try
{
return (T)Convert.ChangeType(value, typeof(T), CultureInfo.InvariantCulture);
}
catch
{
return default;
}
}
}
@@ -0,0 +1,39 @@
using System.Security.Claims;
namespace MAX.Framework.Security;
/// <summary>
/// 当前登录用户访问器:从当前请求的认证票据中读取用户信息。
/// </summary>
public interface ICurrentUser
{
/// <summary>
/// 当前请求是否已认证
/// </summary>
bool IsAuthenticated { get; }
/// <summary>
/// 当前登录账户名(未登录为 null)
/// </summary>
string? UserName { get; }
/// <summary>
/// 当前用户昵称(未登录为 null)
/// </summary>
string? NickName { get; }
/// <summary>
/// 当前用户的全部声明
/// </summary>
IEnumerable<Claim> Claims { get; }
/// <summary>
/// 获取指定类型的声明值
/// </summary>
string? GetClaimValue(string claimType);
/// <summary>
/// 获取指定类型的声明值并转换为目标类型;不存在或转换失败返回默认值
/// </summary>
T? GetClaimValue<T>(string claimType);
}
@@ -0,0 +1,34 @@
namespace MAX.Framework.Security;
/// <summary>
/// 在线用户会话存储:基于 <see cref="Microsoft.Extensions.Caching.Memory.IMemoryCache"/> 的进程内实现,
/// 用于单点登录(同一用户仅保留最新会话)、在线人员管理与退出销毁。
/// </summary>
public interface IOnlineUserStore
{
/// <summary>
/// 用户登录/刷新会话:同用户的新登录会覆盖旧会话,旧令牌立即失效(单点登录)。
/// </summary>
void SignIn(OnlineUserInfo info);
/// <summary>
/// 判断指定登录账户、指定令牌会话是否仍然有效(未被退出、未被新登录顶替)。
/// </summary>
bool IsTokenActive(string userName, string jti);
/// <summary>
/// 退出登录:销毁该账户的在线会话,令牌立即失效。
/// </summary>
void SignOut(string userName);
/// <summary>
/// 获取当前在线人员列表。
/// </summary>
IReadOnlyList<OnlineUserInfo> GetOnlineUsers();
/// <summary>
/// 当前在线人数。
/// </summary>
int Count { get; }
}
@@ -0,0 +1,40 @@
using System.Security.Claims;
namespace MAX.Framework.Security;
/// <summary>
/// 令牌生成结果
/// </summary>
public class TokenResult
{
/// <summary>
/// 令牌类型(Bearer)
/// </summary>
public string TokenType { get; set; } = "Bearer";
/// <summary>
/// 访问令牌
/// </summary>
public string Token { get; set; } = string.Empty;
/// <summary>
/// 有效时间(分钟)
/// </summary>
public uint ExpiresInMinutes { get; set; }
}
/// <summary>
/// JWT 令牌服务:生成与校验访问令牌。
/// </summary>
public interface ITokenService
{
/// <summary>
/// 根据声明集合生成访问令牌
/// </summary>
TokenResult CreateToken(IEnumerable<Claim> claims);
/// <summary>
/// 校验令牌,校验失败返回 null
/// </summary>
Task<ClaimsPrincipal?> ValidateTokenAsync(string token);
}
@@ -0,0 +1,45 @@
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);
}
}
@@ -0,0 +1,16 @@
namespace MAX.Framework.Security;
/// <summary>
/// JWT 自定义声明类型常量。
/// </summary>
public static class JwtClaimTypes
{
/// <summary>登录账户(唯一)</summary>
public const string UserName = "user_name";
/// <summary>用户昵称</summary>
public const string NickName = "nick_name";
/// <summary>令牌唯一标识(jti,用于单点登录/退出校验)</summary>
public const string Jti = "jti";
}
+74
View File
@@ -0,0 +1,74 @@
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),
};
}
}
@@ -0,0 +1,23 @@
namespace MAX.Framework.Security;
/// <summary>
/// 在线用户会话信息(单点登录:同一用户仅保留一个有效会话)。
/// </summary>
public class OnlineUserInfo
{
/// <summary>登录账户(唯一,作为会话键)</summary>
public string UserName { get; set; } = string.Empty;
/// <summary>用户昵称</summary>
public string NickName { get; set; } = string.Empty;
/// <summary>当前有效会话的令牌唯一标识(jti)</summary>
public string Jti { get; set; } = string.Empty;
/// <summary>登录时间(本地时间)</summary>
public DateTime LoginTime { get; set; }
/// <summary>会话过期时间(本地时间,与令牌 exp 一致)</summary>
public DateTime ExpiresAt { get; set; }
}
@@ -0,0 +1,80 @@
using Microsoft.Extensions.Caching.Memory;
using System.Collections.Concurrent;
namespace MAX.Framework.Security;
/// <summary>
/// 基于 <see cref="IMemoryCache"/> 的在线用户会话存储。
/// </summary>
public class OnlineUserStore : IOnlineUserStore
{
private const string CacheKeyPrefix = "online:user:";
private readonly IMemoryCache _cache;
// 在线人员索引:IMemoryCache 不提供公共枚举 API,这里用字典维护便于查询在线列表
private readonly ConcurrentDictionary<string, OnlineUserInfo> _onlineIndex = new(StringComparer.Ordinal);
public OnlineUserStore(IMemoryCache cache)
{
_cache = cache;
}
/// <inheritdoc />
public void SignIn(OnlineUserInfo info)
{
ArgumentNullException.ThrowIfNull(info);
// 以登录账户为键覆盖写入:旧会话(jti)被顶替,实现单点登录
_cache.Set(GetKey(info.UserName), info, new MemoryCacheEntryOptions
{
// 缓存条目与令牌同时过期,过期后自动失效
AbsoluteExpiration = info.ExpiresAt,
});
_onlineIndex[info.UserName] = info;
}
/// <inheritdoc />
public bool IsTokenActive(string userName, string jti)
{
if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(jti))
{
return false;
}
return _cache.TryGetValue(GetKey(userName), out OnlineUserInfo? info)
&& info is not null
&& string.Equals(info.Jti, jti, StringComparison.Ordinal);
}
/// <inheritdoc />
public void SignOut(string userName)
{
_cache.Remove(GetKey(userName));
_onlineIndex.TryRemove(userName, out _);
}
/// <inheritdoc />
public IReadOnlyList<OnlineUserInfo> GetOnlineUsers()
{
DateTime now = DateTime.Now;
List<OnlineUserInfo> result = [];
foreach (KeyValuePair<string, OnlineUserInfo> pair in _onlineIndex)
{
if (pair.Value.ExpiresAt > now)
{
result.Add(pair.Value);
}
else
{
_onlineIndex.TryRemove(pair.Key, out _);
}
}
return result;
}
/// <inheritdoc />
public int Count => GetOnlineUsers().Count;
private static string GetKey(string userName) => $"{CacheKeyPrefix}{userName}";
}
+129
View File
@@ -0,0 +1,129 @@
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;
using System.Text;
namespace MAX.Framework.Security;
/// <summary>
/// 基于 <see cref="JwtOptions"/> 的 JWT 令牌服务。
/// </summary>
public class TokenService : ITokenService
{
private readonly JwtOptions _options;
private readonly IOnlineUserStore _onlineUserStore;
public TokenService(IOptions<JwtOptions> options, IOnlineUserStore onlineUserStore)
{
_options = options.Value;
_onlineUserStore = onlineUserStore;
}
/// <inheritdoc />
public TokenResult CreateToken(IEnumerable<Claim> claims)
{
ArgumentNullException.ThrowIfNull(claims);
DateTime now = DateTime.Now;
DateTime expires = now.AddMinutes(_options.ExpiresMinutes);
string jti = Guid.NewGuid().ToString("N");
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);
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? 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,
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;
}
}