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));
}
}
}