131 lines
3.7 KiB
C#
131 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,
|
|
ExpiresAt = expires,
|
|
};
|
|
}
|
|
|
|
/// <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? userIdValue = principal.FindFirst(JwtClaimTypes.UserId)?.Value;
|
|
string? jti = principal.FindFirst(JwtClaimTypes.Jti)?.Value;
|
|
if (!int.TryParse(userIdValue, out int userId) || string.IsNullOrWhiteSpace(jti) || !_onlineUserStore.IsTokenActive(userId, jti))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return principal;
|
|
}
|
|
catch
|
|
{
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 将令牌声明注册为在线会话。
|
|
/// </summary>
|
|
private void RegisterSession(IEnumerable<Claim> claims, string jti, DateTime loginTime, DateTime expiresAt)
|
|
{
|
|
string? userIdValue = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.UserId)?.Value;
|
|
if (!int.TryParse(userIdValue, out int userId))
|
|
{
|
|
return; // 未携带用户ID的令牌不纳入在线会话管理
|
|
}
|
|
|
|
_onlineUserStore.SignIn(new OnlineUserInfo
|
|
{
|
|
UserId = userId,
|
|
UserName = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.UserName)?.Value ?? string.Empty,
|
|
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;
|
|
}
|
|
}
|
|
|