using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Options; using System.Security.Claims; using System.Security.Cryptography; using System.Text; namespace MAX.Framework.Security; /// Opaque database tokens. All timestamps use UTC. public sealed class TokenService(IFreeSql db, IOptions options, IMemoryCache cache, TimeProvider clock) : ITokenService { 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); internal DateTime CurrentTime => Now; internal DateTime AbsoluteCutoff => Now.AddDays(-settings.AbsoluteMaxDays); public TokenResult CreateToken(IEnumerable 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(); db.Transaction(() => { // Lock the account row so concurrent logins on different instances respect the limit. var account = db.Select().Where(x => x.UserName == name).ForUpdate().First() ?? throw new InvalidOperationException("用户不存在,无法签发令牌"); var cutoff = now.AddDays(-settings.AbsoluteMaxDays); var active = db.Select().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().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 }; } } public Task ValidateTokenAsync(string token) { // Only accept the canonical Base64 representation of 32 bytes. Span 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(null); lock (Gate) { var hash = Hash(token); var now = Now; cache.TryGetValue(Key(hash), out var entry); if (entry == null) { var row = db.Select().Where(x => x.TokenHash == hash).First(); if (row == null || row.Revoked || row.ExpiresAt <= now || row.CreatedAt.AddDays(settings.AbsoluteMaxDays) <= now) return Task.FromResult(null); var account = db.Select().Where(x => x.UserName == row.UserName).First(); if (account == null) return Task.FromResult(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(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().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().Where(x => x.Id == t.Id).First(); if (current == null || current.Revoked || current.ExpiresAt <= now || current.CreatedAt.AddDays(settings.AbsoluteMaxDays) <= now) return Task.FromResult(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(principal); } } /// 清理已撤销或已到期的会话,返回删除数量,供每日后台任务调用。 public int CleanupExpiredTokens() { lock (Gate) { var now = Now; var cutoff = now.AddDays(-settings.AbsoluteMaxDays); // 与认证的到期边界一致;直接条件删除,避免先查询再删除时误删已续期的会话。 return db.Delete() .Where(t => t.Revoked || t.ExpiresAt <= now || t.CreatedAt <= cutoff) .ExecuteAffrows(); } } internal void Revoke(string userName, string? sessionId = null) { lock (Gate) { var user = db.Select().Where(x => x.UserName == userName).First(); if (user == null) return; var rows = db.Select().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().Set(x => x.Revoked, true).Where(x => ids.Contains(x.Id)).ExecuteAffrows(); foreach (var row in rows) cache.Remove(Key(row.TokenHash)); } } }