更新框架,增加云库搜索模块
This commit is contained in:
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";
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user