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 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.Ok(null); diff --git a/Store.API/StoreHub.http b/Store.API/StoreHub.http index a90691f..0a4f683 100644 --- a/Store.API/StoreHub.http +++ b/Store.API/StoreHub.http @@ -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}} diff --git a/Store.Api.slnx b/Store.Api.slnx index 5f2db34..cf547fe 100644 --- a/Store.Api.slnx +++ b/Store.Api.slnx @@ -23,6 +23,9 @@ + + + diff --git a/Tests/Store.Security.Tests/PersistentSessionTests.cs b/Tests/Store.Security.Tests/PersistentSessionTests.cs new file mode 100644 index 0000000..c2cd264 --- /dev/null +++ b/Tests/Store.Security.Tests/PersistentSessionTests.cs @@ -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(); + 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().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(); + 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().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().Count()); + } + + private static IFreeSql Open(string path) => new FreeSql.FreeSqlBuilder() + .UseConnectionString(FreeSql.DataType.Sqlite, $"Data Source={path};Pooling=false") + .Build(); +} diff --git a/Tests/Store.Security.Tests/Store.Security.Tests.csproj b/Tests/Store.Security.Tests/Store.Security.Tests.csproj new file mode 100644 index 0000000..ea3342d --- /dev/null +++ b/Tests/Store.Security.Tests/Store.Security.Tests.csproj @@ -0,0 +1,13 @@ + + truefalse + + + + + + all + + + + + diff --git a/Tests/Store.Users.Tests/DevelopmentTokenTests.cs b/Tests/Store.Users.Tests/DevelopmentTokenTests.cs index 5fdc5b2..e88a4e0 100644 --- a/Tests/Store.Users.Tests/DevelopmentTokenTests.cs +++ b/Tests/Store.Users.Tests/DevelopmentTokenTests.cs @@ -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(); + builder.Services.AddSingleton(db); builder.Services.AddStoreHubJwt(builder.Configuration); await using var app = builder.Build(); app.UseAuthentication(); diff --git a/Tests/Store.Users.Tests/UserTestContext.cs b/Tests/Store.Users.Tests/UserTestContext.cs index 611ad6b..f857fc4 100644 --- a/Tests/Store.Users.Tests/UserTestContext.cs +++ b/Tests/Store.Users.Tests/UserTestContext.cs @@ -95,6 +95,7 @@ internal sealed class TestCurrentUser : ICurrentUser internal sealed class TestOnlineUsers : IOnlineUserStore { public HashSet 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; diff --git a/Tests/UserManagementSmoke/Program.cs b/Tests/UserManagementSmoke/Program.cs index 3405039..b595618 100644 --- a/Tests/UserManagementSmoke/Program.cs +++ b/Tests/UserManagementSmoke/Program.cs @@ -159,6 +159,7 @@ sealed class Actor : ICurrentUser sealed class Sessions : IOnlineUserStore { public HashSet 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;