更新JWT

This commit is contained in:
chfei committed 2026-10-23 19:28:17 +08:00
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>();
+11 -29
View File
@@ -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; }
}
+3 -3
View File
@@ -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);
}
}
+19 -18
View File
@@ -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; }
}
+38 -71
View File
@@ -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;
}
+34
View File
@@ -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 仍需部署环境验证。可使用代码审查进一步检查。
+7 -7
View File
@@ -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;
}
}
+5 -5
View File
@@ -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);
+3 -3
View File
@@ -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}}
+3
View File
@@ -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;
+1
View File
@@ -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;