diff --git a/Core/MAX.Framework/DependencyInjection/FreeSqlDB.cs b/Core/MAX.Framework/DependencyInjection/FreeSqlDB.cs
index 6a6f1e0..6b2a9c1 100644
--- a/Core/MAX.Framework/DependencyInjection/FreeSqlDB.cs
+++ b/Core/MAX.Framework/DependencyInjection/FreeSqlDB.cs
@@ -1,4 +1,4 @@
-using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
diff --git a/Core/MAX.Framework/DependencyInjection/JwtServiceCollectionExtensions.cs b/Core/MAX.Framework/DependencyInjection/JwtServiceCollectionExtensions.cs
index ae4636a..51d4319 100644
--- a/Core/MAX.Framework/DependencyInjection/JwtServiceCollectionExtensions.cs
+++ b/Core/MAX.Framework/DependencyInjection/JwtServiceCollectionExtensions.cs
@@ -23,7 +23,7 @@ public static class JwtServiceCollectionExtensions
/// - 绑定 Jwt 配置节到 ,并在启动时校验签名密钥长度;
/// - 注册 (令牌生成/校验)与 (当前用户访问器);
/// - 注册 认证,认证失败/授权失败统一返回 格式;
- /// - 注册 (基于 IMemoryCache 的在线会话存储):单点登录、在线人员查询、退出销毁;
+ /// - 注册 (数据库会话存储):多端登录、会话查询、退出撤销;
/// - 为 OpenAPI 文档声明 Bearer(JWT) 安全方案,并对标记了 [Authorize] 的接口声明需要携带令牌。
///
///
@@ -50,8 +50,8 @@ public static class JwtServiceCollectionExtensions
services.AddSingleton();
services.AddScoped();
- // 在线会话存储(单点登录):基于 IMemoryCache 的进程内缓存,用于在线人员管理与退出销毁
- services.AddMemoryCache();
+ // 使用宿主注册的 IFreeSql 持久化会话。
+
services.AddSingleton();
// JwtBearer 认证
@@ -87,7 +87,7 @@ public static class JwtServiceCollectionExtensions
}
return Task.CompletedTask;
},
- // 单点登录校验:会话必须仍有效(未退出、未被新登录顶替)
+ // 会话校验:必须未过期且未撤销
OnTokenValidated = context =>
{
IOnlineUserStore onlineUserStore = context.HttpContext.RequestServices.GetRequiredService();
diff --git a/Core/MAX.Framework/Security/IOnlineUserStore.cs b/Core/MAX.Framework/Security/IOnlineUserStore.cs
index 9334c59..f3ef15d 100644
--- a/Core/MAX.Framework/Security/IOnlineUserStore.cs
+++ b/Core/MAX.Framework/Security/IOnlineUserStore.cs
@@ -1,34 +1,16 @@
namespace MAX.Framework.Security;
-///
-/// 在线用户会话存储:基于 的进程内实现,
-/// 用于单点登录(同一用户仅保留最新会话)、在线人员管理与退出销毁。
-///
+/// 持久化会话存储;同账号可有多个有效会话。
public interface IOnlineUserStore
{
- ///
- /// 用户登录/刷新会话:同用户的新登录会覆盖旧会话,旧令牌立即失效(单点登录)。
- ///
- void SignIn(OnlineUserInfo info);
-
- ///
- /// 判断指定登录账户、指定令牌会话是否仍然有效(未被退出、未被新登录顶替)。
- ///
- bool IsTokenActive(string userName, string jti);
-
- ///
- /// 退出登录:销毁该账户的在线会话,令牌立即失效。
- ///
- void SignOut(string userName);
-
- ///
- /// 获取当前在线人员列表。
- ///
- IReadOnlyList GetOnlineUsers();
-
- ///
- /// 当前在线人数。
- ///
- int Count { get; }
+ void SignIn(OnlineUserInfo info);
+ bool IsTokenActive(string userName, string jti);
+ /// 撤销账号全部会话,用于修改密码、禁用等安全操作。
+ void SignOut(string userName);
+ /// 只撤销指定会话,用于当前客户端退出登录。
+ void SignOutSession(string userName, string jti);
+ /// 未过期且未撤销的会话列表,不代表浏览器仍然打开。
+ IReadOnlyList GetOnlineUsers();
+ /// 有效会话数。
+ int Count { get; }
}
-
diff --git a/Core/MAX.Framework/Security/ITokenService.cs b/Core/MAX.Framework/Security/ITokenService.cs
index 311b76c..e1905e7 100644
--- a/Core/MAX.Framework/Security/ITokenService.cs
+++ b/Core/MAX.Framework/Security/ITokenService.cs
@@ -1,4 +1,4 @@
-using System.Security.Claims;
+using System.Security.Claims;
namespace MAX.Framework.Security;
@@ -31,10 +31,10 @@ public interface ITokenService
///
/// 根据声明集合生成访问令牌
///
- TokenResult CreateToken(IEnumerable claims);
+ TokenResult CreateToken(IEnumerable claims, string? loginIp = null);
///
/// 校验令牌,校验失败返回 null
///
Task ValidateTokenAsync(string token);
-}
\ No newline at end of file
+}
diff --git a/Core/MAX.Framework/Security/OnlineUserInfo.cs b/Core/MAX.Framework/Security/OnlineUserInfo.cs
index 2ce9c39..329440a 100644
--- a/Core/MAX.Framework/Security/OnlineUserInfo.cs
+++ b/Core/MAX.Framework/Security/OnlineUserInfo.cs
@@ -1,23 +1,24 @@
+using FreeSql.DataAnnotations;
+
namespace MAX.Framework.Security;
-///
-/// 在线用户会话信息(单点登录:同一用户仅保留一个有效会话)。
-///
+/// 持久化登录会话;每次登录拥有独立 jti,不保存完整 JWT。
+[Table(Name = "wxxd.auth_login_session")]
public class OnlineUserInfo
{
- /// 登录账户(唯一,作为会话键)
- public string UserName { get; set; } = string.Empty;
-
- /// 用户昵称
- public string NickName { get; set; } = string.Empty;
-
- /// 当前有效会话的令牌唯一标识(jti)
- public string Jti { get; set; } = string.Empty;
-
- /// 登录时间(本地时间)
- public DateTime LoginTime { get; set; }
-
- /// 会话过期时间(本地时间,与令牌 exp 一致)
- public DateTime ExpiresAt { get; set; }
+ [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;
+ /// 服务端观察到的客户端 IP;反向代理需配置受信任的转发头。
+ [Column(StringLength = 45)]
+ public string LoginIp { get; set; } = string.Empty;
+ /// UTC 登录时间。
+ public DateTime LoginTime { get; set; }
+ /// UTC 到期时间,与 JWT exp 对齐。
+ public DateTime ExpiresAt { get; set; }
+ /// UTC 撤销时间;保留记录供后续面板查询。
+ public DateTime? RevokedAt { get; set; }
}
-
diff --git a/Core/MAX.Framework/Security/OnlineUserStore.cs b/Core/MAX.Framework/Security/OnlineUserStore.cs
index bd06432..988c700 100644
--- a/Core/MAX.Framework/Security/OnlineUserStore.cs
+++ b/Core/MAX.Framework/Security/OnlineUserStore.cs
@@ -1,80 +1,47 @@
-using Microsoft.Extensions.Caching.Memory;
-using System.Collections.Concurrent;
-
namespace MAX.Framework.Security;
-///
-/// 基于 的在线用户会话存储。
-///
-public class OnlineUserStore : IOnlineUserStore
+/// 数据库登录会话存储,支持同账号多个独立会话。
+public class OnlineUserStore(IFreeSql db) : IOnlineUserStore
{
- private const string CacheKeyPrefix = "online:user:";
- private readonly IMemoryCache _cache;
- // 在线人员索引:IMemoryCache 不提供公共枚举 API,这里用字典维护便于查询在线列表
- private readonly ConcurrentDictionary _onlineIndex = new(StringComparer.Ordinal);
+ 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 OnlineUserStore(IMemoryCache cache)
- {
- _cache = cache;
- }
+ public bool IsTokenActive(string userName, string jti)
+ {
+ if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(jti)) return false;
+ var now = DateTime.UtcNow;
+ return db.Select().Any(x => x.UserName == userName && x.Jti == jti
+ && x.RevokedAt == null && x.ExpiresAt > now);
+ }
- ///
- public void SignIn(OnlineUserInfo info)
- {
- ArgumentNullException.ThrowIfNull(info);
+ public void SignOut(string userName)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(userName);
+ db.Update().Set(x => x.RevokedAt, DateTime.UtcNow)
+ .Where(x => x.UserName == userName && x.RevokedAt == null).ExecuteAffrows();
+ }
- // 以登录账户为键覆盖写入:旧会话(jti)被顶替,实现单点登录
- _cache.Set(GetKey(info.UserName), info, new MemoryCacheEntryOptions
- {
- // 缓存条目与令牌同时过期,过期后自动失效
- AbsoluteExpiration = info.ExpiresAt,
- });
- _onlineIndex[info.UserName] = info;
- }
+ public void SignOutSession(string userName, string jti)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(userName);
+ ArgumentException.ThrowIfNullOrWhiteSpace(jti);
+ db.Update().Set(x => x.RevokedAt, DateTime.UtcNow)
+ .Where(x => x.UserName == userName && x.Jti == jti && x.RevokedAt == null).ExecuteAffrows();
+ }
- ///
- public bool IsTokenActive(string userName, string jti)
- {
- if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(jti))
- {
- return false;
- }
+ public IReadOnlyList GetOnlineUsers()
+ {
+ var now = DateTime.UtcNow;
+ return db.Select().Where(x => x.RevokedAt == null && x.ExpiresAt > now)
+ .OrderByDescending(x => x.LoginTime).ToList();
+ }
- return _cache.TryGetValue(GetKey(userName), out OnlineUserInfo? info)
- && info is not null
- && string.Equals(info.Jti, jti, StringComparison.Ordinal);
- }
-
- ///
- public void SignOut(string userName)
- {
- _cache.Remove(GetKey(userName));
- _onlineIndex.TryRemove(userName, out _);
- }
-
- ///
- public IReadOnlyList GetOnlineUsers()
- {
- DateTime now = DateTime.Now;
- List result = [];
- foreach (KeyValuePair pair in _onlineIndex)
- {
- if (pair.Value.ExpiresAt > now)
- {
- result.Add(pair.Value);
- }
- else
- {
- _onlineIndex.TryRemove(pair.Key, out _);
- }
- }
-
- return result;
- }
-
- ///
- public int Count => GetOnlineUsers().Count;
-
- private static string GetKey(string userName) => $"{CacheKeyPrefix}{userName}";
+ public int Count => GetOnlineUsers().Count;
}
-
diff --git a/Core/MAX.Framework/Security/README.md b/Core/MAX.Framework/Security/README.md
new file mode 100644
index 0000000..052e1f3
--- /dev/null
+++ b/Core/MAX.Framework/Security/README.md
@@ -0,0 +1,34 @@
+# 登录会话数据库存储
+
+会话使用 `ConnectionStrings:DefaultConnection` 指定的 MySQL 数据库,表名 `auth_login_session`。
+FreeSql 初始化时仅同步此表结构;首次使用需要建表/索引权限。没有运行针对业务数据库的迁移或测试。
+
+## 行为
+
+- 每次登录插入独立的 `Jti` 主键,保存用户名、昵称、登录 IP、UTC 登录时间、UTC 到期时间和撤销时间。不存储完整 JWT。
+- 同账号可以同时登录。Logout 仅撤销当前 JWT 对应的会话;修改密码、禁用和删除账号等现有安全操作仍撤销该账号全部会话。
+- 每次认证查询数据库,只有会话未过期且未撤销才接受 JWT。保留现有有效期配置(当前 2880 分钟),不新增自动刷新。
+- 应用重启后,只要数据库记录、签名密钥、Issuer 和 Audience 保持一致,未过期且未撤销的 Token 可继续使用。
+- 从旧内存版本升级时,已有会话不能自动迁移,用户需要重新登录一次。
+- OnlineUsers 返回有效会话,包含 LoginIp;Count 是会话数,同账号可能有多条。它不代表浏览器仍然打开。现有授权规则保持不变;正式管理面板应按管理权限提供分页查询。
+- 退出和过期不删除记录,以便后续查询历史。后续可根据实际留存周期添加清理任务;本次没有无限查询历史的管理接口或面板。
+
+## IP 与代理
+
+IP 来自 HttpContext.Connection.RemoteIpAddress,不直接信任客户端提交的 X-Forwarded-For。
+若使用 Nginx、IIS 等代理,应按部署拓扑配置 ASP.NET Core ForwardedHeaders 和可信代理,在认证前运行;否则记录的可能是代理地址。
+
+## 数据库与 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 仍需部署环境验证。可使用代码审查进一步检查。
diff --git a/Core/MAX.Framework/Security/TokenService.cs b/Core/MAX.Framework/Security/TokenService.cs
index 4640976..515dcf6 100644
--- a/Core/MAX.Framework/Security/TokenService.cs
+++ b/Core/MAX.Framework/Security/TokenService.cs
@@ -21,11 +21,11 @@ public class TokenService : ITokenService
}
///
- public TokenResult CreateToken(IEnumerable claims)
+ public TokenResult CreateToken(IEnumerable claims, string? loginIp = null)
{
ArgumentNullException.ThrowIfNull(claims);
- DateTime now = DateTime.Now;
+ DateTime now = DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.ToUnixTimeSeconds()).UtcDateTime;
DateTime expires = now.AddMinutes(_options.ExpiresMinutes);
string jti = Guid.NewGuid().ToString("N");
@@ -44,8 +44,8 @@ public class TokenService : ITokenService
SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256),
});
- // 注册在线会话(单点登录:同用户的新登录会顶替旧会话,旧令牌立即失效)
- RegisterSession(allClaims, jti, now, expires);
+ // 持久化独立登录会话
+ RegisterSession(allClaims, jti, now, expires, loginIp);
return new TokenResult
{
@@ -73,7 +73,7 @@ public class TokenService : ITokenService
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))
@@ -92,7 +92,7 @@ public class TokenService : ITokenService
///
/// 将令牌声明注册为在线会话。
///
- private void RegisterSession(IEnumerable claims, string jti, DateTime loginTime, DateTime expiresAt)
+ private void RegisterSession(IEnumerable claims, string jti, DateTime loginTime, DateTime expiresAt, string? loginIp)
{
string? userName = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.UserName)?.Value;
if (string.IsNullOrWhiteSpace(userName))
@@ -105,6 +105,7 @@ public class TokenService : ITokenService
UserName = userName,
NickName = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.NickName)?.Value ?? string.Empty,
Jti = jti,
+ LoginIp = loginIp ?? string.Empty,
LoginTime = loginTime,
ExpiresAt = expiresAt,
});
@@ -126,4 +127,3 @@ public class TokenService : ITokenService
return result;
}
}
-
diff --git a/Store.API/Controllers/AuthController.cs b/Store.API/Controllers/AuthController.cs
index 5541079..4534150 100644
--- a/Store.API/Controllers/AuthController.cs
+++ b/Store.API/Controllers/AuthController.cs
@@ -29,7 +29,7 @@ namespace Store.Api.Controllers
}
///
- /// 登录:校验用户名/密码,签发 JWT(单点登录:同用户的新登录会使旧令牌失效)
+ /// 登录:校验用户名/密码,签发 JWT(同账号支持多个独立会话)
///
[HttpPost]
[EndpointSummary("用户登录")]
@@ -51,21 +51,21 @@ namespace Store.Api.Controllers
new(JwtClaimTypes.UserName, user.UserName),
};
- TokenResult token = _tokenService.CreateToken(claims);
+ TokenResult token = _tokenService.CreateToken(claims, HttpContext.Connection.RemoteIpAddress?.ToString());
return ApiResult.Ok(token);
}
///
- /// 退出登录:销毁当前用户的服务端在线会话,令牌立即失效
+ /// 退出登录:撤销当前客户端的服务端登录会话,令牌立即失效
///
[HttpPost]
[EndpointSummary("退出登录")]
[Authorize]
public ApiResult