增加Shop API
This commit is contained in:
1 parent
3769193696
commit
5d017740c6
83 files changed
+4451
-696
No files matched your search
@@ -1,129 +1,148 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.JsonWebTokens;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MAX.Framework.Security;
|
||||
|
||||
/// <summary>
|
||||
/// 基于 <see cref="JwtOptions"/> 的 JWT 令牌服务。
|
||||
/// </summary>
|
||||
public class TokenService : ITokenService
|
||||
/// <summary>Opaque database tokens. All timestamps use UTC.</summary>
|
||||
public sealed class TokenService(IFreeSql db, IOptions<TokenAuthOptions> options, IMemoryCache cache,
|
||||
TimeProvider clock) : ITokenService
|
||||
{
|
||||
private readonly JwtOptions _options;
|
||||
private readonly IOnlineUserStore _onlineUserStore;
|
||||
private readonly TokenAuthOptions settings = options.Value;
|
||||
// Serialize local cache reads/writes and revocations to prevent stale cache resurrection.
|
||||
internal object Gate { get; } = new();
|
||||
private sealed record CachedToken(UserToken Token, TokenAccount Account);
|
||||
private static string Key(string hash) => "token-auth:" + hash;
|
||||
private static string Hash(string token) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||
// MySQL DATETIME is timezone-free; preserve UTC wall time without provider-local conversion.
|
||||
private DateTime Now => DateTime.SpecifyKind(DateTimeOffset.FromUnixTimeSeconds(clock.GetUtcNow().ToUnixTimeSeconds()).UtcDateTime, DateTimeKind.Unspecified);
|
||||
|
||||
public TokenService(IOptions<JwtOptions> options, IOnlineUserStore onlineUserStore)
|
||||
{
|
||||
_options = options.Value;
|
||||
_onlineUserStore = onlineUserStore;
|
||||
}
|
||||
internal DateTime CurrentTime => Now;
|
||||
internal DateTime AbsoluteCutoff => Now.AddDays(-settings.AbsoluteMaxDays);
|
||||
|
||||
/// <inheritdoc />
|
||||
public TokenResult CreateToken(IEnumerable<Claim> claims, string? loginIp = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(claims);
|
||||
public TokenResult CreateToken(IEnumerable<Claim> claims, string? loginIp = null,
|
||||
string? deviceId = null, string? deviceName = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(claims);
|
||||
var name = claims.FirstOrDefault(c => c.Type == JwtClaimTypes.UserName)?.Value;
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
if (deviceId?.Length > 100 || deviceName?.Length > 100 || loginIp?.Length > 64)
|
||||
throw new ArgumentException("设备信息或 IP 超出允许长度");
|
||||
deviceId = string.IsNullOrWhiteSpace(deviceId) ? null : deviceId;
|
||||
lock (Gate)
|
||||
{
|
||||
var now = Now;
|
||||
var plain = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
var expires = now.AddDays(Math.Min(settings.IdleDays, settings.AbsoluteMaxDays));
|
||||
var revokedHashes = new List<string>();
|
||||
db.Transaction(() =>
|
||||
{
|
||||
// Lock the account row so concurrent logins on different instances respect the limit.
|
||||
var account = db.Select<TokenAccount>().Where(x => x.UserName == name).ForUpdate().First()
|
||||
?? throw new InvalidOperationException("用户不存在,无法签发令牌");
|
||||
var cutoff = now.AddDays(-settings.AbsoluteMaxDays);
|
||||
var active = db.Select<UserToken>().Where(x => x.UserName == account.UserName && !x.Revoked
|
||||
&& x.ExpiresAt > now && x.CreatedAt > cutoff).OrderBy(x => x.CreatedAt).OrderBy(x => x.Id).ToList();
|
||||
var remove = active.Where(x => deviceId != null && x.DeviceId == deviceId).ToList();
|
||||
var remaining = active.Except(remove).ToList();
|
||||
if (settings.MaxDevicesPerUser > 0)
|
||||
remove.AddRange(remaining.Take(Math.Max(0, remaining.Count - settings.MaxDevicesPerUser + 1)));
|
||||
foreach (var old in remove)
|
||||
{
|
||||
db.Update<UserToken>().Set(x => x.Revoked, true).Where(x => x.Id == old.Id).ExecuteAffrows();
|
||||
revokedHashes.Add(old.TokenHash);
|
||||
}
|
||||
db.Insert(new UserToken { TokenHash = Hash(plain), UserName = account.UserName, DeviceId = deviceId,
|
||||
DeviceName = deviceName, Ip = loginIp, CreatedAt = now, LastUsedAt = now, ExpiresAt = expires })
|
||||
.ExecuteAffrows();
|
||||
});
|
||||
foreach (var hash in revokedHashes) cache.Remove(Key(hash));
|
||||
return new TokenResult { Token = plain, ExpiresInMinutes = (uint)(expires - now).TotalMinutes };
|
||||
}
|
||||
}
|
||||
|
||||
DateTime now = DateTimeOffset.FromUnixTimeSeconds(DateTimeOffset.UtcNow.ToUnixTimeSeconds()).UtcDateTime;
|
||||
DateTime expires = now.AddMinutes(_options.ExpiresMinutes);
|
||||
string jti = Guid.NewGuid().ToString("N");
|
||||
public Task<ClaimsPrincipal?> ValidateTokenAsync(string token)
|
||||
{
|
||||
// Only accept the canonical Base64 representation of 32 bytes.
|
||||
Span<byte> bytes = stackalloc byte[32];
|
||||
if (token is null || token.Length != 44 || !Convert.TryFromBase64String(token, bytes, out var length)
|
||||
|| length != 32 || Convert.ToBase64String(bytes) != token)
|
||||
return Task.FromResult<ClaimsPrincipal?>(null);
|
||||
lock (Gate)
|
||||
{
|
||||
var hash = Hash(token);
|
||||
var now = Now;
|
||||
cache.TryGetValue<CachedToken>(Key(hash), out var entry);
|
||||
if (entry == null)
|
||||
{
|
||||
var row = db.Select<UserToken>().Where(x => x.TokenHash == hash).First();
|
||||
if (row == null || row.Revoked || row.ExpiresAt <= now || row.CreatedAt.AddDays(settings.AbsoluteMaxDays) <= now)
|
||||
return Task.FromResult<ClaimsPrincipal?>(null);
|
||||
var account = db.Select<TokenAccount>().Where(x => x.UserName == row.UserName).First();
|
||||
if (account == null) return Task.FromResult<ClaimsPrincipal?>(null);
|
||||
entry = new(row, account);
|
||||
}
|
||||
var t = entry.Token;
|
||||
var absolute = t.CreatedAt.AddDays(settings.AbsoluteMaxDays);
|
||||
if (t.Revoked || t.ExpiresAt <= now || absolute <= now)
|
||||
{
|
||||
cache.Remove(Key(hash));
|
||||
return Task.FromResult<ClaimsPrincipal?>(null);
|
||||
}
|
||||
if (now - t.LastUsedAt >= TimeSpan.FromMinutes(settings.RenewIntervalMinutes))
|
||||
{
|
||||
var next = now.AddDays(settings.IdleDays);
|
||||
if (next > absolute) next = absolute;
|
||||
// Conditional update never renews expired/revoked tokens or overwrites a newer renewal.
|
||||
var changed = db.Update<UserToken>().Set(x => x.LastUsedAt, now).Set(x => x.ExpiresAt, next)
|
||||
.Where(x => x.Id == t.Id && !x.Revoked && x.ExpiresAt > now && x.LastUsedAt == t.LastUsedAt)
|
||||
.ExecuteAffrows();
|
||||
if (changed == 0)
|
||||
{
|
||||
cache.Remove(Key(hash));
|
||||
var current = db.Select<UserToken>().Where(x => x.Id == t.Id).First();
|
||||
if (current == null || current.Revoked || current.ExpiresAt <= now
|
||||
|| current.CreatedAt.AddDays(settings.AbsoluteMaxDays) <= now)
|
||||
return Task.FromResult<ClaimsPrincipal?>(null);
|
||||
t = current;
|
||||
entry = new(current, entry.Account);
|
||||
}
|
||||
else
|
||||
{
|
||||
t.LastUsedAt = now;
|
||||
t.ExpiresAt = next;
|
||||
}
|
||||
}
|
||||
if (settings.CacheSeconds > 0 && !cache.TryGetValue(Key(hash), out object? _))
|
||||
{
|
||||
var ttl = TimeSpan.FromSeconds(settings.CacheSeconds);
|
||||
if (ttl > t.ExpiresAt - now) ttl = t.ExpiresAt - now;
|
||||
if (ttl > absolute - now) ttl = absolute - now;
|
||||
cache.Set(Key(hash), entry, ttl);
|
||||
}
|
||||
ClaimsPrincipal principal = new(new ClaimsIdentity([
|
||||
new Claim(ClaimTypes.NameIdentifier, t.UserName.ToString()),
|
||||
new Claim(JwtClaimTypes.UserName, entry.Account.UserName),
|
||||
new Claim(JwtClaimTypes.NickName, entry.Account.NickName),
|
||||
new Claim(JwtClaimTypes.Jti, t.Id.ToString())], "Bearer"));
|
||||
return Task.FromResult<ClaimsPrincipal?>(principal);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
internal void Revoke(string userName, string? sessionId = null)
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
var user = db.Select<TokenAccount>().Where(x => x.UserName == userName).First();
|
||||
if (user == null) return;
|
||||
var rows = db.Select<UserToken>().Where(x => x.UserName == user.UserName && !x.Revoked).ToList();
|
||||
if (sessionId != null) rows = rows.Where(x => x.Id.ToString() == sessionId).ToList();
|
||||
var ids = rows.Select(x => x.Id).ToArray();
|
||||
if (ids.Length == 0) return;
|
||||
db.Update<UserToken>().Set(x => x.Revoked, true).Where(x => ids.Contains(x.Id)).ExecuteAffrows();
|
||||
foreach (var row in rows) cache.Remove(Key(row.TokenHash));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user