XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

XFEExtension.NetCore.ServerInteractive

[DLL] Server interaction extension, including user identity verification and querying in conjunction with AutoConfig

公开
关注 0 Fork 0 Star 0
UTF-8
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;

/// <summary>使用 PBKDF2 密码和不透明会话令牌的登录服务。</summary>
public partial class UserLoginService<T> : ServerCoreUserLoginServiceBase<T> where T : class
{
    private static readonly ConcurrentDictionary<string, LoginAttemptWindow> 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; }
    }
}