更新JWT
This commit is contained in:
1 parent
bb7162aaf6
commit
3769193696
16 files changed
+224
-141
No files matched your search
@@ -1,4 +1,4 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
@@ -23,7 +23,7 @@ public static class JwtServiceCollectionExtensions
|
||||
/// <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"/>(基于 <c>IMemoryCache</c> 的在线会话存储):单点登录、在线人员查询、退出销毁;</item>
|
||||
/// <item>注册 <see cref="IOnlineUserStore"/>(数据库会话存储):多端登录、会话查询、退出撤销;</item>
|
||||
/// <item>为 OpenAPI 文档声明 Bearer(JWT) 安全方案,并对标记了 [Authorize] 的接口声明需要携带令牌。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
@@ -50,8 +50,8 @@ public static class JwtServiceCollectionExtensions
|
||||
services.AddSingleton<ITokenService, TokenService>();
|
||||
services.AddScoped<ICurrentUser, CurrentUser>();
|
||||
|
||||
// 在线会话存储(单点登录):基于 IMemoryCache 的进程内缓存,用于在线人员管理与退出销毁
|
||||
services.AddMemoryCache();
|
||||
// 使用宿主注册的 IFreeSql 持久化会话。
|
||||
|
||||
services.AddSingleton<IOnlineUserStore, OnlineUserStore>();
|
||||
|
||||
// JwtBearer 认证
|
||||
@@ -87,7 +87,7 @@ public static class JwtServiceCollectionExtensions
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
// 单点登录校验:会话必须仍有效(未退出、未被新登录顶替)
|
||||
// 会话校验:必须未过期且未撤销
|
||||
OnTokenValidated = context =>
|
||||
{
|
||||
IOnlineUserStore onlineUserStore = context.HttpContext.RequestServices.GetRequiredService<IOnlineUserStore>();
|
||||
|
||||
@@ -1,34 +1,16 @@
|
||||
namespace MAX.Framework.Security;
|
||||
|
||||
/// <summary>
|
||||
/// 在线用户会话存储:基于 <see cref="Microsoft.Extensions.Caching.Memory.IMemoryCache"/> 的进程内实现,
|
||||
/// 用于单点登录(同一用户仅保留最新会话)、在线人员管理与退出销毁。
|
||||
/// </summary>
|
||||
/// <summary>持久化会话存储;同账号可有多个有效会话。</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; }
|
||||
void SignIn(OnlineUserInfo info);
|
||||
bool IsTokenActive(string userName, string jti);
|
||||
/// <summary>撤销账号全部会话,用于修改密码、禁用等安全操作。</summary>
|
||||
void SignOut(string userName);
|
||||
/// <summary>只撤销指定会话,用于当前客户端退出登录。</summary>
|
||||
void SignOutSession(string userName, string jti);
|
||||
/// <summary>未过期且未撤销的会话列表,不代表浏览器仍然打开。</summary>
|
||||
IReadOnlyList<OnlineUserInfo> GetOnlineUsers();
|
||||
/// <summary>有效会话数。</summary>
|
||||
int Count { get; }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Security.Claims;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace MAX.Framework.Security;
|
||||
|
||||
@@ -31,10 +31,10 @@ public interface ITokenService
|
||||
/// <summary>
|
||||
/// 根据声明集合生成访问令牌
|
||||
/// </summary>
|
||||
TokenResult CreateToken(IEnumerable<Claim> claims);
|
||||
TokenResult CreateToken(IEnumerable<Claim> claims, string? loginIp = null);
|
||||
|
||||
/// <summary>
|
||||
/// 校验令牌,校验失败返回 null
|
||||
/// </summary>
|
||||
Task<ClaimsPrincipal?> ValidateTokenAsync(string token);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,24 @@
|
||||
using FreeSql.DataAnnotations;
|
||||
|
||||
namespace MAX.Framework.Security;
|
||||
|
||||
/// <summary>
|
||||
/// 在线用户会话信息(单点登录:同一用户仅保留一个有效会话)。
|
||||
/// </summary>
|
||||
/// <summary>持久化登录会话;每次登录拥有独立 jti,不保存完整 JWT。</summary>
|
||||
[Table(Name = "wxxd.auth_login_session")]
|
||||
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; }
|
||||
[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>
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
/// <summary>UTC 撤销时间;保留记录供后续面板查询。</summary>
|
||||
public DateTime? RevokedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,80 +1,47 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace MAX.Framework.Security;
|
||||
|
||||
/// <summary>
|
||||
/// 基于 <see cref="IMemoryCache"/> 的在线用户会话存储。
|
||||
/// </summary>
|
||||
public class OnlineUserStore : IOnlineUserStore
|
||||
/// <summary>数据库登录会话存储,支持同账号多个独立会话。</summary>
|
||||
public class OnlineUserStore(IFreeSql db) : IOnlineUserStore
|
||||
{
|
||||
private const string CacheKeyPrefix = "online:user:";
|
||||
private readonly IMemoryCache _cache;
|
||||
// 在线人员索引:IMemoryCache 不提供公共枚举 API,这里用字典维护便于查询在线列表
|
||||
private readonly ConcurrentDictionary<string, OnlineUserInfo> _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<OnlineUserInfo>().Any(x => x.UserName == userName && x.Jti == jti
|
||||
&& x.RevokedAt == null && x.ExpiresAt > now);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void SignIn(OnlineUserInfo info)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
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();
|
||||
}
|
||||
|
||||
// 以登录账户为键覆盖写入:旧会话(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<OnlineUserInfo>().Set(x => x.RevokedAt, DateTime.UtcNow)
|
||||
.Where(x => x.UserName == userName && x.Jti == jti && x.RevokedAt == null).ExecuteAffrows();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool IsTokenActive(string userName, string jti)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(userName) || string.IsNullOrWhiteSpace(jti))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
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}";
|
||||
public int Count => GetOnlineUsers().Count;
|
||||
}
|
||||
|
||||
@@ -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 仍需部署环境验证。可使用代码审查进一步检查。
|
||||
@@ -21,11 +21,11 @@ public class TokenService : ITokenService
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public TokenResult CreateToken(IEnumerable<Claim> claims)
|
||||
public TokenResult CreateToken(IEnumerable<Claim> 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
|
||||
/// <summary>
|
||||
/// 将令牌声明注册为在线会话。
|
||||
/// </summary>
|
||||
private void RegisterSession(IEnumerable<Claim> claims, string jti, DateTime loginTime, DateTime expiresAt)
|
||||
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))
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ namespace Store.Api.Controllers
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 登录:校验用户名/密码,签发 JWT(单点登录:同用户的新登录会使旧令牌失效)
|
||||
/// 登录:校验用户名/密码,签发 JWT(同账号支持多个独立会话)
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 退出登录:销毁当前用户的服务端在线会话,令牌立即失效
|
||||
/// 退出登录:撤销当前客户端的服务端登录会话,令牌立即失效
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
[EndpointSummary("退出登录")]
|
||||
[Authorize]
|
||||
public ApiResult<object?> Logout()
|
||||
{
|
||||
if (_currentUser.UserName is string username)
|
||||
if (_currentUser.UserName is string username && _currentUser.GetClaimValue(JwtClaimTypes.Jti) is string jti)
|
||||
{
|
||||
_onlineUserStore.SignOut(username);
|
||||
_onlineUserStore.SignOutSession(username, jti);
|
||||
}
|
||||
|
||||
return ApiResult<object?>.Ok(null);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
@StoreHub_HostAddress = http://localhost:5295
|
||||
@StoreHub_HostAddress = http://localhost:5295
|
||||
|
||||
# ============ 认证(JWT) ============
|
||||
|
||||
### 登录:返回 accessToken(单点登录,同用户新登录会使旧 token 失效)
|
||||
### 登录:返回 token(同账号可同时登录,独立记录会话和 IP)
|
||||
# @name login
|
||||
POST {{StoreHub_HostAddress}}/api/v1/Auth/Login
|
||||
Content-Type: application/json
|
||||
@@ -16,7 +16,7 @@ Content-Type: application/json
|
||||
GET {{StoreHub_HostAddress}}/api/v1/Auth/Me
|
||||
Authorization: Bearer {{login.response.body.$.data.token}}
|
||||
|
||||
### 退出登录:销毁服务端会话,token 立即失效
|
||||
### 退出登录:仅撤销当前会话,其他客户端不受影响
|
||||
POST {{StoreHub_HostAddress}}/api/v1/Auth/Logout
|
||||
Authorization: Bearer {{login.response.body.$.data.token}}
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
<File Path="Plugins/Directory.Build.props" />
|
||||
<File Path="Plugins/Directory.Build.targets" />
|
||||
</Folder>
|
||||
<Folder Name="/Tests/">
|
||||
<Project Path="Tests/Store.Security.Tests/Store.Security.Tests.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/_SolutionItems/">
|
||||
<File Path="Directory.Build.props" />
|
||||
<File Path="Directory.Build.targets" />
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Security.Claims;
|
||||
using MAX.Framework.Security;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Store.Users.Tests;
|
||||
|
||||
public sealed class PersistentSessionTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TokensSurviveRestart_AndLogoutOnlyRevokesCurrentSession()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"sessions-{Guid.NewGuid():N}.db");
|
||||
var options = Options.Create(new JwtOptions());
|
||||
TokenResult first;
|
||||
TokenResult second;
|
||||
string firstJti;
|
||||
try
|
||||
{
|
||||
using (var db = Open(path))
|
||||
{
|
||||
db.CodeFirst.SyncStructure<OnlineUserInfo>();
|
||||
var store = new OnlineUserStore(db);
|
||||
var tokens = new TokenService(options, store);
|
||||
first = tokens.CreateToken([new Claim(JwtClaimTypes.UserName, "same")], "192.0.2.10");
|
||||
second = tokens.CreateToken([new Claim(JwtClaimTypes.UserName, "same")], "2001:db8::1");
|
||||
firstJti = (await tokens.ValidateTokenAsync(first.Token))!.FindFirst(JwtClaimTypes.Jti)!.Value;
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(second.Token));
|
||||
Assert.Equal(2, store.Count);
|
||||
}
|
||||
// 新 ORM、新存储和 TokenService,模拟进程重启后的数据库恢复。
|
||||
using (var db = Open(path))
|
||||
{
|
||||
var store = new OnlineUserStore(db);
|
||||
var tokens = new TokenService(options, store);
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(first.Token));
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(second.Token));
|
||||
Assert.Contains(store.GetOnlineUsers(), x => x.LoginIp == "2001:db8::1");
|
||||
store.SignOutSession("other", firstJti);
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(first.Token));
|
||||
store.SignOutSession("same", firstJti);
|
||||
Assert.Null(await tokens.ValidateTokenAsync(first.Token));
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(second.Token));
|
||||
Assert.Single(store.GetOnlineUsers());
|
||||
Assert.NotNull(db.Select<OnlineUserInfo>().Where(x => x.Jti == firstJti).First().RevokedAt);
|
||||
}
|
||||
using (var db = Open(path))
|
||||
{
|
||||
var store = new OnlineUserStore(db);
|
||||
var tokens = new TokenService(options, store);
|
||||
Assert.Null(await tokens.ValidateTokenAsync(first.Token));
|
||||
Assert.NotNull(await tokens.ValidateTokenAsync(second.Token));
|
||||
store.SignOut("same");
|
||||
Assert.Null(await tokens.ValidateTokenAsync(second.Token));
|
||||
}
|
||||
}
|
||||
finally { File.Delete(path); }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExpiredSessionsAreRejected_EvenWithinJwtClockTolerance()
|
||||
{
|
||||
using var db = Open(":memory:");
|
||||
db.CodeFirst.SyncStructure<OnlineUserInfo>();
|
||||
var store = new OnlineUserStore(db);
|
||||
var tokens = new TokenService(Options.Create(new JwtOptions()), store);
|
||||
var token = tokens.CreateToken([new Claim(JwtClaimTypes.UserName, "user")]);
|
||||
db.Update<OnlineUserInfo>().Set(x => x.ExpiresAt, DateTime.UtcNow.AddSeconds(-1))
|
||||
.Where(x => x.UserName == "user").ExecuteAffrows();
|
||||
Assert.Null(await tokens.ValidateTokenAsync(token.Token));
|
||||
Assert.Empty(store.GetOnlineUsers());
|
||||
Assert.Equal(1, db.Select<OnlineUserInfo>().Count());
|
||||
}
|
||||
|
||||
private static IFreeSql Open(string path) => new FreeSql.FreeSqlBuilder()
|
||||
.UseConnectionString(FreeSql.DataType.Sqlite, $"Data Source={path};Pooling=false")
|
||||
.Build();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup><IsTestProject>true</IsTestProject><IsPackable>false</IsPackable></PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../../Core/MAX.Framework/MAX.Framework.csproj" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.TestHost" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
|
||||
<PackageReference Include="xunit" Version="2.9.3" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"><PrivateAssets>all</PrivateAssets></PackageReference>
|
||||
<PackageReference Include="FreeSql.Provider.Sqlite" Version="3.5.311" />
|
||||
<Using Include="Xunit" />
|
||||
<Compile Include="../Store.Users.Tests/DevelopmentTokenTests.cs" Link="DevelopmentTokenTests.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -28,6 +28,10 @@ public sealed class DevelopmentTokenTests
|
||||
["Jwt:DevelopmentToken:Token"] = "test-fixed-token",
|
||||
["Jwt:DevelopmentToken:UserName"] = "admin"
|
||||
});
|
||||
using var db = new FreeSql.FreeSqlBuilder()
|
||||
.UseConnectionString(FreeSql.DataType.Sqlite, "Data Source=:memory:").Build();
|
||||
db.CodeFirst.SyncStructure<OnlineUserInfo>();
|
||||
builder.Services.AddSingleton<IFreeSql>(db);
|
||||
builder.Services.AddStoreHubJwt(builder.Configuration);
|
||||
await using var app = builder.Build();
|
||||
app.UseAuthentication();
|
||||
|
||||
@@ -95,6 +95,7 @@ internal sealed class TestCurrentUser : ICurrentUser
|
||||
internal sealed class TestOnlineUsers : IOnlineUserStore
|
||||
{
|
||||
public HashSet<string> SignedOut { get; } = [];
|
||||
public void SignOutSession(string userName, string jti) => SignedOut.Add(userName);
|
||||
public void SignOut(string userName) => SignedOut.Add(userName);
|
||||
public void SignIn(OnlineUserInfo info) { }
|
||||
public bool IsTokenActive(string userName, string jti) => false;
|
||||
|
||||
@@ -159,6 +159,7 @@ sealed class Actor : ICurrentUser
|
||||
sealed class Sessions : IOnlineUserStore
|
||||
{
|
||||
public HashSet<string> Names { get; } = [];
|
||||
public void SignOutSession(string name, string jti) => Names.Add(name);
|
||||
public void SignOut(string name) => Names.Add(name);
|
||||
public void SignIn(OnlineUserInfo info) { }
|
||||
public bool IsTokenActive(string name, string jti) => false;
|
||||
|
||||
Reference in new issue
Block a user