using System.Collections.Concurrent; using System.Net; using System.Text.Json; using XFEExtension.NetCore.ServerInteractive.Attributes; using XFEExtension.NetCore.ServerInteractive.Models.UserModels; using XFEExtension.NetCore.ServerInteractive.Utilities.Helpers; using XFEExtension.NetCore.StringExtension; namespace XFEExtension.NetCore.ServerInteractive.Utilities.Server.Services.CoreService; /// 使用 PBKDF2 密码和不透明会话令牌的登录服务。 public partial class UserLoginService : ServerCoreUserLoginServiceBase where T : class { private static readonly ConcurrentDictionary s_loginAttempts = new(StringComparer.Ordinal); [EntryPoint("user/login")] public async Task Login() { Console.Write("登录请求"); var account = Json?["account"]?.GetString(); var password = Json?["password"]?.GetString(); var deviceInfo = Json?["deviceInfo"]?.GetString(); if (account.IsNullOrWhiteSpace()) throw Error("账户名不能为空"); if (password.IsNullOrWhiteSpace()) throw Error("登录密码不能为空"); if (deviceInfo.IsNullOrWhiteSpace()) throw Error("电脑信息不能为空"); var accountRateKey = $"account:{account.Trim().ToUpperInvariant()}"; var ipRateKey = $"ip:{ReturnArgs.ClientIP}"; if (!TryBeginLogin(accountRateKey) || !TryBeginLogin(ipRateKey)) throw Error("登录尝试过于频繁,请稍后重试", (HttpStatusCode)429); var result = UserHelper.GetUser(account, password, GetUserFunction(), out var user, out var passwordUpgraded); if (result is UserOperateResult.UserNotFound or UserOperateResult.InvalidPassword) { RecordFailure(accountRateKey); RecordFailure(ipRateKey); throw Error("账号或密码错误", HttpStatusCode.Forbidden); } if (result != UserOperateResult.Success || user is null) { RecordFailure(accountRateKey); RecordFailure(ipRateKey); throw Error(UserHelper.OutPutResult(result), HttpStatusCode.Forbidden); } if (passwordUpgraded) UpdateUserFunction(user); s_loginAttempts.TryRemove(accountRateKey, out _); s_loginAttempts.TryRemove(ipRateKey, out _); var token = SessionTokenHelper.Create(out var tokenHash); var expiresAt = DateTimeOffset.UtcNow.AddDays(GetLoginKeepDays()); var login = new EncryptedUserLoginModel { TokenHash = tokenHash, CreatedAtUtc = DateTimeOffset.UtcNow, ExpiresAtUtc = expiresAt, UserLoginModel = new() { Uid = user.Id, DeviceInfo = deviceInfo, LastIPAddress = ReturnArgs.ClientIP, EndDateTime = expiresAt.UtcDateTime } }; lock (SessionStoreSynchronization.Gate) { AddEncryptedUserLoginModelFunction(login); // 每个用户最多保留 10 个活跃会话,优先删除已过期及最旧会话。 var userSessions = GetEncryptedUserLoginModelFunction() .Where(session => session.UserLoginModel.Uid == user.Id) .OrderByDescending(session => session.ExpiresAtUtc <= DateTimeOffset.UtcNow || session.RevokedAtUtc is not null) .ThenBy(session => session.CreatedAtUtc) .ToArray(); foreach (var excessSession in userSessions.Take(Math.Max(0, userSessions.Length - 10))) RemoveEncryptedUserLoginModelFunction(excessSession); } var preview = deviceInfo.Length <= 10 ? deviceInfo : $"{deviceInfo[..10]}..."; Console.Write($"{account}({preview}),到期时间 {expiresAt:O}"); await ReturnArgs.CloseJson(new { session = token, expireDate = expiresAt, userInfo = LoginResultConvertFunction(user) }, JsonSerializerOptions); } private static bool TryBeginLogin(string key) { var now = DateTimeOffset.UtcNow; if (!s_loginAttempts.TryGetValue(key, out var state)) return true; lock (state) { if (state.LockedUntilUtc > now) return false; if (now - state.WindowStartUtc > TimeSpan.FromMinutes(1)) { state.WindowStartUtc = now; state.Failures = 0; } return state.Failures < 5; } } private static void RecordFailure(string key) { var now = DateTimeOffset.UtcNow; if (s_loginAttempts.Count > 10_000) { foreach (var stale in s_loginAttempts.Where(pair => now - pair.Value.WindowStartUtc > TimeSpan.FromMinutes(10)).Select(pair => pair.Key).Take(1_000)) s_loginAttempts.TryRemove(stale, out _); } var state = s_loginAttempts.GetOrAdd(key, _ => new() { WindowStartUtc = now }); lock (state) { if (now - state.WindowStartUtc > TimeSpan.FromMinutes(1)) { state.WindowStartUtc = now; state.Failures = 0; } state.Failures++; if (state.Failures >= 5) state.LockedUntilUtc = now.AddMinutes(1); } } private sealed class LoginAttemptWindow { public DateTimeOffset WindowStartUtc { get; set; } public DateTimeOffset LockedUntilUtc { get; set; } public int Failures { get; set; } } }