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码(允许时间偏移)
安全特性
- 防重放攻击:每个挑战都有时间限制(60秒+15秒缓冲),验证后立即删除
- 签名计数器:防止凭证克隆攻击
- 用户验证要求:强制要求生物识别或PIN码
- 操作绑定:挑战与特定操作/账号绑定,防止跨操作重放
- 详细日志:记录验证失败和调试信息
缓存模型
PasskeyOperationChallenge:单次Passkey操作的上下文(删除凭证等)AdminPasskeyOperationChallenge:后台管理操作的上下文(通用敏感操作)
错误处理
- 区分用户验证失败(返回特定错误码)
- 通用验证失败返回模糊错误信息(避免信息泄露)
- 详细日志记录便于调试和审计
这是一个设计良好的现代化认证服务,平衡了安全性、用户体验和灵活性。
AI 正在分析代码…
评论加载中...