Files
StoreWeb/StoreHub.Framework/Security/TokenService.cs
T
2026-08-28 15:31:45 +08:00

130 lines
3.7 KiB
C#

using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;
using System.Text;
namespace StoreHub.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;
}
}