130 lines
3.7 KiB
C#
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 MAX.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, string? loginIp = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(claims);
|
|
|
|
DateTime now = DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.ToUnixTimeSeconds()).UtcDateTime;
|
|
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, loginIp);
|
|
|
|
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? loginIp)
|
|
{
|
|
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,
|
|
LoginIp = loginIp ?? string.Empty,
|
|
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;
|
|
}
|
|
}
|