using System.Text;
using System.Text.Json;
using Dpz.Core.Auth.Models.AdminApi;
using Dpz.Core.Authenticator;
using Dpz.Core.Public.ViewModel.WebAuthn;
using Fido2NetLib.Objects;

namespace Dpz.Core.Auth.Service;

public class UserMfaVerificationService(
    IConfiguration configuration,
    IFido2 fido2,
    IFusionCache fusionCache,
    IUserTwoFactorService userTwoFactorService,
    IUserWebAuthnCredentialService credentialService,
    IUserPasskeySessionService passkeySessionService,
    ILogger<UserMfaVerificationService> logger
) : IUserMfaVerificationService
{
    private const ulong WebAuthnTimeoutMilliseconds = 60000;

    private static readonly TimeSpan WebAuthnChallengeCacheDuration = TimeSpan.FromMilliseconds(
        WebAuthnTimeoutMilliseconds + 15000
    );

    private static readonly JsonSerializerOptions SerializerOptions = new(
        JsonSerializerDefaults.Web
    );

    public async Task<ResponseResult> ValidateRequiredTwoFactorAsync(
        string? account,
        string? pinCode,
        CancellationToken cancellationToken = default
    )
    {
        if (configuration.GetValue("BypassTwoFactorIn", false))
        {
            return ResponseResult.Ok();
        }

        var key = await userTwoFactorService.GetBoundKeyAsync(account, cancellationToken);
        if (string.IsNullOrWhiteSpace(key))
        {
            return ResponseResult.Fail("请先绑定双因素认证");
        }

        if (string.IsNullOrWhiteSpace(pinCode))
        {
            return ResponseResult.Fail("请输入PIN码");
        }

        return ValidateTwoFactorPin(key, pinCode, "PIN码验证错误");
    }

    public async Task<ResponseResult> ValidateOptionalTwoFactorAsync(
        string? account,
        string? pinCode,
        CancellationToken cancellationToken = default
    )
    {
        var key = await userTwoFactorService.GetBoundKeyAsync(account, cancellationToken);
        if (string.IsNullOrWhiteSpace(key))
        {
            return ResponseResult.Ok();
        }

        if (string.IsNullOrWhiteSpace(pinCode))
        {
            return ResponseResult.Fail("您已启用双因素验证,请输入验证码");
        }

        return ValidateTwoFactorPin(key, pinCode, "双因素验证码验证失败,请检查验证码是否正确");
    }

    /// <summary>
    /// 验证后台敏感操作,优先使用当前设备的 Passkey,当前设备不可用时回退至 2FA 动态验证码;
    /// 账号未绑定任何 MFA 方式时操作直接放行,由前端提醒用户完成安全绑定
    /// </summary>
    /// <param name="account">当前登录账号</param>
    /// <param name="operationKey">操作标识,用于防重放和缓存隔离</param>
    /// <param name="request">客户端提交的 MFA 凭证(可为空,表示首次请求)</param>
    /// <param name="cancellationToken">取消令牌</param>
    /// <returns>
    /// RequiresVerification 为 true 时表示需要前端完成 Passkey 或 2FA 验证后重新提交;
    /// 为 false 且 NeedsSecuritySetup 为 true 时表示操作直接放行,但前端应提醒用户完成安全绑定
    /// </returns>
    public async Task<ResponseResult<AdminMfaVerificationResult>> VerifyAdminOperationAsync(
        string? account,
        string operationKey,
        AdminMfaRequest? request,
        CancellationToken cancellationToken = default
    )
    {
        if (string.IsNullOrWhiteSpace(account))
        {
            return ResponseResult<AdminMfaVerificationResult>.Fail("用户未登录");
        }

        request ??= new AdminMfaRequest();
        var status = await userTwoFactorService.GetBindingStatusAsync(account, cancellationToken);

        // 账号未绑定任何 MFA 方式:直接放行操作,但要求前端提醒用户完成安全绑定
        if (!status.HasPasskey && !status.HasTwoFactor)
        {
            return ResponseResult<AdminMfaVerificationResult>.Ok(
                new AdminMfaVerificationResult { NeedsSecuritySetup = true },
                "操作完成。当前账号未绑定通行密钥或双因素认证,建议前往安全设置完成绑定"
            );
        }

        if (!string.IsNullOrWhiteSpace(request.PinCode))
        {
            if (!status.HasTwoFactor)
            {
                return ResponseResult<AdminMfaVerificationResult>.Fail("您未绑定双因素认证");
            }

            var pinResult = await ValidateRequiredTwoFactorAsync(
                account,
                request.PinCode,
                cancellationToken
            );
            return pinResult.Success
                ? ResponseResult<AdminMfaVerificationResult>.Ok(new AdminMfaVerificationResult())
                : ResponseResult<AdminMfaVerificationResult>.Fail(
                    pinResult.Message,
                    pinResult.Code
                );
        }

        if (request.Credential != null || !string.IsNullOrWhiteSpace(request.Challenge))
        {
            var passkeyResult = await CompleteAdminPasskeyOperationAsync(
                account,
                operationKey,
                request,
                cancellationToken
            );
            return passkeyResult.Success
                ? ResponseResult<AdminMfaVerificationResult>.Ok(new AdminMfaVerificationResult())
                : ResponseResult<AdminMfaVerificationResult>.Fail(
                    passkeyResult.Message,
                    passkeyResult.Code
                );
        }

        // 仅当前设备持有可用 Passkey(Cookie 指向的凭证仍有效)时才发起 Passkey 验证,
        // 避免在未绑定通行密钥的设备上要求无法完成的 Passkey 二次验证
        if (status.HasPasskey && await HasValidDevicePasskeyAsync(account, cancellationToken))
        {
            var passkeyOptions = await BeginAdminPasskeyOperationAsync(
                account,
                operationKey,
                status.HasTwoFactor,
                cancellationToken
            );
            if (passkeyOptions != null)
            {
                return ResponseResult<AdminMfaVerificationResult>.Ok(
                    passkeyOptions,
                    "请完成 Passkey 验证后继续"
                );
            }
        }

        if (status.HasTwoFactor)
        {
            return ResponseResult<AdminMfaVerificationResult>.Ok(
                new AdminMfaVerificationResult
                {
                    RequiresVerification = true,
                    VerificationType = "twoFactor",
                },
                status.HasPasskey
                    ? "当前设备未绑定通行密钥,请输入双因素验证码后继续"
                    : "请输入双因素验证码后继续"
            );
        }

        // 其他设备已绑定 Passkey、但当前设备没有可用凭证且未绑定 2FA:放行并提醒安全绑定
        return ResponseResult<AdminMfaVerificationResult>.Ok(
            new AdminMfaVerificationResult { NeedsSecuritySetup = true },
            "操作完成。当前设备未绑定通行密钥且未绑定双因素认证,建议前往安全设置完成绑定"
        );
    }

    public async Task<PasskeyOperationVerificationOptions?> BeginPasskeyOperationAsync(
        string account,
        string targetCredentialId,
        string purpose,
        CancellationToken cancellationToken = default
    )
    {
        var currentCredentialId = passkeySessionService.GetCurrentCredentialId();
        if (string.IsNullOrWhiteSpace(currentCredentialId))
        {
            return null;
        }

        var credential = await credentialService.FindAssertionInfoByCredentialIdAsync(
            currentCredentialId,
            cancellationToken
        );
        if (credential == null || credential.UserId != account)
        {
            passkeySessionService.ClearCurrentCredentialId();
            return null;
        }

        var options = fido2.GetAssertionOptions(
            new GetAssertionOptionsParams
            {
                AllowedCredentials =
                [
                    new PublicKeyCredentialDescriptor(
                        AuthHelper.FromBase64Url(currentCredentialId)
                    ),
                ],
                UserVerification = UserVerificationRequirement.Required,
                Extensions = new AuthenticationExtensionsClientInputs(),
            }
        );
        options.Timeout = WebAuthnTimeoutMilliseconds;
        var challenge = AuthHelper.ToBase64Url(options.Challenge);
        var cacheModel = new PasskeyOperationChallenge(
            account,
            purpose,
            targetCredentialId,
            currentCredentialId,
            options
        );

        await fusionCache.SetAsync(
            GetOperationCacheKey(challenge),
            cacheModel,
            WebAuthnChallengeCacheDuration,
            token: cancellationToken
        );

        return new PasskeyOperationVerificationOptions
        {
            AssertionOptions = options,
            Challenge = challenge,
        };
    }

    public async Task<ResponseResult> CompletePasskeyOperationAsync(
        string account,
        string targetCredentialId,
        string purpose,
        DeleteWebAuthnCredentialRequest request,
        CancellationToken cancellationToken = default
    )
    {
        if (request.Credential == null || string.IsNullOrWhiteSpace(request.Challenge))
        {
            return ResponseResult.Fail("Passkey 验证数据无效");
        }

        var cacheKey = GetOperationCacheKey(request.Challenge);
        var cacheModel = await fusionCache.GetOrDefaultAsync<PasskeyOperationChallenge>(
            cacheKey,
            token: cancellationToken
        );
        if (cacheModel == null)
        {
            return ResponseResult.Fail("Passkey 验证请求已过期,请重新开始");
        }
        await fusionCache.RemoveAsync(cacheKey, token: cancellationToken);

        if (
            cacheModel.Account != account
            || cacheModel.Purpose != purpose
            || cacheModel.TargetCredentialId != targetCredentialId
            || cacheModel.CurrentCredentialId != passkeySessionService.GetCurrentCredentialId()
        )
        {
            return ResponseResult.Fail("Passkey 验证请求已过期,请重新开始");
        }

        AuthenticatorAssertionRawResponse? response = null;
        try
        {
            response = request.Credential.Deserialize<AuthenticatorAssertionRawResponse>(
                SerializerOptions
            );
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "解析 Passkey 操作验证数据失败");
        }

        if (response == null || response.Id != cacheModel.CurrentCredentialId)
        {
            return ResponseResult.Fail("Passkey 验证数据无效");
        }
        WebAuthnAssertionDiagnostics.LogAssertionFlags(
            logger,
            request.Credential,
            cacheModel.AssertionOptionsJson,
            "delete-passkey",
            response.Id,
            request.Challenge,
            null,
            null
        );

        var credential = await credentialService.FindAssertionInfoByCredentialIdAsync(
            response.Id,
            cancellationToken
        );
        if (credential == null || credential.UserId != account)
        {
            return ResponseResult.Fail("Passkey 凭证不存在");
        }

        VerifyAssertionResult result;
        try
        {
            result = await fido2.MakeAssertionAsync(
                new MakeAssertionParams
                {
                    AssertionResponse = response,
                    OriginalOptions = cacheModel.AssertionOptionsJson,
                    StoredPublicKey = AuthHelper.FromBase64Url(credential.PublicKey),
                    StoredSignatureCounter = credential.SignatureCounter,
                    IsUserHandleOwnerOfCredentialIdCallback = (args, token) =>
                        ValidateUserHandleAsync(args, cacheModel.AssertionOptionsJson, token),
                },
                cancellationToken
            );
        }
        catch (Fido2VerificationException ex)
        {
            if (AuthHelper.IsUserVerificationRequired(ex))
            {
                return ResponseResult.Fail(
                    AuthHelper.UserVerificationRequiredMessage,
                    WebAuthnErrorCodes.PasskeyUserVerificationFailed
                );
            }

            logger.LogWarning(
                ex,
                "用户 {Account} Passkey 操作验证失败,Credential={CredentialId},Challenge={Challenge}",
                account,
                ShortId(response.Id),
                ShortId(request.Challenge)
            );
            return ResponseResult.Fail(AuthHelper.GenericWebAuthnVerificationFailureMessage);
        }

        await credentialService.UpdateCounterAsync(
            account,
            response.Id,
            result.SignCount,
            cancellationToken
        );
        passkeySessionService.SetCurrentCredentialId(response.Id);
        return ResponseResult.Ok();
    }

    /// <summary>
    /// 开始后台操作的 Passkey 验证流程,为账号下所有凭证创建断言选项并缓存
    /// </summary>
    private async Task<AdminMfaVerificationResult?> BeginAdminPasskeyOperationAsync(
        string account,
        string operationKey,
        bool allowsTwoFactorFallback,
        CancellationToken cancellationToken
    )
    {
        var credentials = await credentialService.ListAsync(account, cancellationToken);
        if (credentials.Count == 0)
        {
            return null;
        }

        var descriptors = credentials
            .Select(x => new PublicKeyCredentialDescriptor(
                AuthHelper.FromBase64Url(x.CredentialId)
            ))
            .ToList();
        var options = fido2.GetAssertionOptions(
            new GetAssertionOptionsParams
            {
                AllowedCredentials = descriptors,
                UserVerification = UserVerificationRequirement.Required,
                Extensions = new AuthenticationExtensionsClientInputs(),
            }
        );
        options.Timeout = WebAuthnTimeoutMilliseconds;
        var challenge = AuthHelper.ToBase64Url(options.Challenge);
        await fusionCache.SetAsync(
            GetAdminOperationCacheKey(challenge),
            new AdminPasskeyOperationChallenge(account, operationKey, options),
            WebAuthnChallengeCacheDuration,
            token: cancellationToken
        );

        return new AdminMfaVerificationResult
        {
            RequiresVerification = true,
            VerificationType = "passkey",
            AllowsTwoFactorFallback = allowsTwoFactorFallback,
            AssertionOptions = options,
            Challenge = challenge,
        };
    }

    /// <summary>
    /// 完成后台操作的 Passkey 断言验证,校验凭证有效性并更新签名计数器
    /// </summary>
    private async Task<ResponseResult> CompleteAdminPasskeyOperationAsync(
        string account,
        string operationKey,
        AdminMfaRequest request,
        CancellationToken cancellationToken
    )
    {
        if (request.Credential == null || string.IsNullOrWhiteSpace(request.Challenge))
        {
            return ResponseResult.Fail("Passkey 验证数据无效");
        }

        var cacheKey = GetAdminOperationCacheKey(request.Challenge);
        var cacheModel = await fusionCache.GetOrDefaultAsync<AdminPasskeyOperationChallenge>(
            cacheKey,
            token: cancellationToken
        );
        if (cacheModel == null)
        {
            return ResponseResult.Fail("Passkey 验证请求已过期,请重新开始");
        }
        await fusionCache.RemoveAsync(cacheKey, token: cancellationToken);

        if (cacheModel.Account != account || cacheModel.OperationKey != operationKey)
        {
            return ResponseResult.Fail("Passkey 验证请求已过期,请重新开始");
        }

        AuthenticatorAssertionRawResponse? response = null;
        try
        {
            response = request.Credential.Deserialize<AuthenticatorAssertionRawResponse>(
                SerializerOptions
            );
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "解析后台 Passkey 操作验证数据失败");
        }

        if (response == null)
        {
            return ResponseResult.Fail("Passkey 验证数据无效");
        }
        WebAuthnAssertionDiagnostics.LogAssertionFlags(
            logger,
            request.Credential,
            cacheModel.AssertionOptionsJson,
            "admin-mfa",
            response.Id,
            request.Challenge,
            null,
            null
        );

        var credential = await credentialService.FindAssertionInfoByCredentialIdAsync(
            response.Id,
            cancellationToken
        );
        if (credential == null || credential.UserId != account)
        {
            return ResponseResult.Fail("Passkey 凭证不存在");
        }

        VerifyAssertionResult result;
        try
        {
            result = await fido2.MakeAssertionAsync(
                new MakeAssertionParams
                {
                    AssertionResponse = response,
                    OriginalOptions = cacheModel.AssertionOptionsJson,
                    StoredPublicKey = AuthHelper.FromBase64Url(credential.PublicKey),
                    StoredSignatureCounter = credential.SignatureCounter,
                    IsUserHandleOwnerOfCredentialIdCallback = (args, token) =>
                        ValidateUserHandleAsync(args, cacheModel.AssertionOptionsJson, token),
                },
                cancellationToken
            );
        }
        catch (Fido2VerificationException ex)
        {
            if (AuthHelper.IsUserVerificationRequired(ex))
            {
                return ResponseResult.Fail(
                    AuthHelper.UserVerificationRequiredMessage,
                    WebAuthnErrorCodes.PasskeyUserVerificationFailed
                );
            }

            logger.LogWarning(
                ex,
                "用户 {Account} 后台 Passkey 操作验证失败,Credential={CredentialId},Challenge={Challenge}",
                account,
                ShortId(response.Id),
                ShortId(request.Challenge)
            );
            return ResponseResult.Fail(AuthHelper.GenericWebAuthnVerificationFailureMessage);
        }

        await credentialService.UpdateCounterAsync(
            account,
            response.Id,
            result.SignCount,
            cancellationToken
        );
        passkeySessionService.SetCurrentCredentialId(response.Id);
        return ResponseResult.Ok();
    }

    /// <summary>
    /// 判断当前设备是否持有可用的通行密钥:Passkey Cookie 指向的凭证仍存在且归属当前账号
    /// </summary>
    /// <param name="account">当前登录账号</param>
    /// <param name="cancellationToken">取消令牌</param>
    /// <returns>当前设备是否可发起 Passkey 验证</returns>
    private async Task<bool> HasValidDevicePasskeyAsync(
        string account,
        CancellationToken cancellationToken
    )
    {
        var currentCredentialId = passkeySessionService.GetCurrentCredentialId();
        if (string.IsNullOrWhiteSpace(currentCredentialId))
        {
            return false;
        }

        var credential = await credentialService.FindAssertionInfoByCredentialIdAsync(
            currentCredentialId,
            cancellationToken
        );
        if (credential == null || credential.UserId != account)
        {
            // Cookie 指向的凭证已被删除或不属于当前账号:清除失效标记
            passkeySessionService.ClearCurrentCredentialId();
            return false;
        }

        return true;
    }

    private static ResponseResult ValidateTwoFactorPin(
        string key,
        string pinCode,
        string errorMessage
    )
    {
        var twoFactorAuthenticator = new TwoFactorAuthenticator();
        var keyBuffer = Encoding.UTF8.GetBytes(key);
        var keyBase32 = Base32Encoding.ToString(keyBuffer);
        var twoFactorResult = twoFactorAuthenticator.ValidateTwoFactorPIN(keyBase32, pinCode, true);

        return twoFactorResult ? ResponseResult.Ok(null) : ResponseResult.Fail(errorMessage);
    }

    private static string GetOperationCacheKey(string challenge) =>
        $"Auth:WebAuthn:Operation:{challenge}";

    private static string GetAdminOperationCacheKey(string challenge) =>
        $"Auth:WebAuthn:AdminOperation:{challenge}";

    private Task<bool> ValidateUserHandleAsync(
        IsUserHandleOwnerOfCredentialIdParams args,
        AssertionOptions options,
        CancellationToken cancellationToken
    ) =>
        WebAuthnUserHandleValidator.ValidateAsync(
            credentialService,
            args,
            options.AllowCredentials.Count == 0,
            logger,
            cancellationToken
        );

    private static string ShortId(string? value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            return "";
        }

        return value.Length <= 12 ? value : $"{value[..6]}...{value[^6..]}";
    }
}

/// <summary>
/// Passkey 敏感操作缓存的上下文,用于防止重放和校验操作一致性
/// </summary>
file record PasskeyOperationChallenge(
    string Account,
    string Purpose,
    string TargetCredentialId,
    string CurrentCredentialId,
    AssertionOptions AssertionOptionsJson
);

/// <summary>
/// 后台管理操作 Passkey 验证缓存的上下文,绑定操作标识用于防止跨操作重放
/// </summary>
file record AdminPasskeyOperationChallenge(
    string Account,
    string OperationKey,
    AssertionOptions AssertionOptionsJson
);
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

代码解释

这是一个 用户多因素认证(MFA)验证服务 的实现类,主要负责处理双因素认证(2FA)和通行密钥(Passkey/WebAuthn)的验证逻辑。

主要功能模块

1. 双因素认证(2FA)验证

ValidateRequiredTwoFactorAsync - 强制2FA验证

  • 检查是否配置了绕过模式(BypassTwoFactorIn
  • 验证用户是否已绑定2FA密钥
  • 验证用户输入的PIN码是否正确

ValidateOptionalTwoFactorAsync - 可选2FA验证

  • 如果用户未绑定2FA,直接放行
  • 如果已绑定2FA但未提供PIN码,返回错误
  • 验证PIN码的正确性

2. 后台敏感操作验证

VerifyAdminOperationAsync - 核心验证方法

这是一个智能的多因素验证流程,策略如下:

优先级顺序:
1. 如果提供了PIN码 → 验证2FA
2. 如果提供了Passkey凭证 → 验证Passkey
3. 如果当前设备有有效Passkey → 发起Passkey验证
4. 如果绑定了2FA → 要求输入验证码
5. 如果没有任何MFA → 放行但提示绑定安全设置

关键逻辑

  • 优先使用当前设备的Passkey(更安全、更便捷)
  • 当设备不可用时回退到2FA验证码
  • 未绑定任何MFA时操作放行,但返回安全提示

3. Passkey操作验证

BeginPasskeyOperationAsync - 开始Passkey验证

  • 获取当前设备的凭证ID(从Cookie)
  • 验证凭证的有效性和归属
  • 生成WebAuthn断言选项(AssertionOptions)
  • 缓存挑战(Challenge)用于后续验证

CompletePasskeyOperationAsync - 完成Passkey验证

  • 从缓存中获取挑战上下文
  • 验证请求的一致性(账号、目的、凭证ID)
  • 调用Fido2库验证签名
  • 更新签名计数器(防止克隆攻击)
  • 设置当前凭证ID到Cookie

4. 后台操作的Passkey验证

BeginAdminPasskeyOperationAsync - 为账号所有凭证创建验证选项

  • 查询用户的所有Passkey凭证
  • 生成包含所有凭证的断言选项
  • 缓存验证上下文(绑定操作标识)

CompleteAdminPasskeyOperationAsync - 完成后台操作验证

  • 验证挑战有效性和操作一致性
  • 执行WebAuthn断言验证
  • 更新签名计数器并记录当前凭证

5. 辅助方法

HasValidDevicePasskeyAsync - 检查设备是否有有效Passkey

  • 检查Cookie中的凭证ID
  • 验证凭证是否存在且属于当前账号
  • 失效时自动清除Cookie

ValidateTwoFactorPin - 验证TOTP动态码

  • 使用Base32编码密钥
  • 验证时间窗口内的PIN码(允许时间偏移)

安全特性

  1. 防重放攻击:每个挑战都有时间限制(60秒+15秒缓冲),验证后立即删除
  2. 签名计数器:防止凭证克隆攻击
  3. 用户验证要求:强制要求生物识别或PIN码
  4. 操作绑定:挑战与特定操作/账号绑定,防止跨操作重放
  5. 详细日志:记录验证失败和调试信息

缓存模型

  • PasskeyOperationChallenge:单次Passkey操作的上下文(删除凭证等)
  • AdminPasskeyOperationChallenge:后台管理操作的上下文(通用敏感操作)

错误处理

  • 区分用户验证失败(返回特定错误码)
  • 通用验证失败返回模糊错误信息(避免信息泄露)
  • 详细日志记录便于调试和审计

这是一个设计良好的现代化认证服务,平衡了安全性、用户体验和灵活性。

评论加载中...