Files
Docker/CloudBuilder/CloudBuilder.Security.Service/AuthService.cs
T
2026-10-17 13:27:26 +08:00

1151 lines
56 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using CloudBuilder.Core.Authorization;
using CloudBuilder.Core.DatabaseAccessor;
using CloudBuilder.Core.DatabaseAccessor.Entity;
using CloudBuilder.Core.DependencyInjection.EventBuses;
using CloudBuilder.Core.DependencyInjection.Request;
using CloudBuilder.Core.Policy;
using CloudBuilder.Core.Service;
using CloudBuilder.Security.Data;
using CloudBuilder.Security.Entity;
using CloudBuilder.Security.Policy;
using CloudBuilder.Security.Publisher;
using CloudBuilder.Security.Service.Authorization;
using CloudBuilder.Security.Service.Publisher;
using DocumentFormat.OpenXml.Spreadsheet;
using EnvDTE;
using Fido2NetLib;
using Fido2NetLib.Objects;
using Fido2NetLib.Serialization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;
using System.Buffers.Text;
using System.ComponentModel.DataAnnotations;
using System.IdentityModel.Tokens.Jwt;
using System.Runtime;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using RSA = System.RSA;
namespace CloudBuilder.Security.Service
{
public class AuthService : IAuthService
{
private readonly IApplicationService service;
private readonly IHttpContextAccessor httpContextAccessor;
private readonly ILocaleMessageManager localeMessageManager;
private readonly IContext context;
private readonly IWebAuthnService webAuthnService;
private IRepository<SecurityUserEntity> repository;
public AuthService(IApplicationService service, IHttpContextAccessor httpContextAccessor, ILocaleMessageManager localeMessageManager, IContext context, IWebAuthnService webAuthnService)
{
this.service = service;
this.httpContextAccessor = httpContextAccessor;
this.localeMessageManager = localeMessageManager;
this.context = context;
this.webAuthnService = webAuthnService;
repository = service.GetRepository<IRepository<SecurityUserEntity>>();
}
[AllowAnonymous]
[UnitOfWork]
public LoginProfile Login(LoginData data)
{
context.SetCulture(data.Culture);
string certification = localeMessageManager.ComposeMessage(typeof(AuthService).Name, "certification");
SecurityUserEntity securityUserEntity = repository.DetachedEntities.Where(x =>
(x.UserName == data.UserMobile || x.UserMobile == data.UserMobile)
&& x.UserIsValidIndc == YesNoPolicy.YES).FirstOrDefault()!;
if (securityUserEntity == null)
{
throw new ValidatedException(certification);
}
var descKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_DescKey];
var privateKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_RsaPrivateKey];
var rsa = new System.RSA(privateKey, true);
var psd = rsa.DecodeOrNull(data.RsaUserPassword);
if (string.IsNullOrEmpty(psd)) throw new ValidatedException(certification);
//052636a8-b146-9efd-f5dc-0034a3c5bb34+password
var guid = psd.Substring(0, 36);
if (guid != data.Guid)
{
throw new ValidatedException(certification);
}
if (securityUserEntity.IsAccountsLockedIndc == YesNoPolicy.YES && securityUserEntity.AccountsLockedDatetime.HasValue
&& securityUserEntity.AccountsLockedDatetime.Value.Subtract(DateTime.Now).TotalMinutes > 0)
{
throw new ValidatedException(localeMessageManager.ComposeMessage("AuthService", "locked"));
}
data.UserPassword = psd.Substring(36, psd.Length - 36);
if (securityUserEntity.UserPassword != data.UserPassword)
{
securityUserEntity.ErrorLoginCount += 1;
//输错多少次后,账号锁定
if (securityUserEntity.ErrorLoginCount > SecurityUserPolicy.ERROR_LOGIN_COUNT)
{
securityUserEntity.IsAccountsLockedIndc = YesNoPolicy.YES;
securityUserEntity.AccountsLockedDatetime = DateTime.Now.AddDays(1);
}
repository.UpdateNow(securityUserEntity);
throw new ValidatedException(certification);
}
securityUserEntity.ErrorLoginCount = 0;
securityUserEntity.IsAccountsLockedIndc = YesNoPolicy.NO;
securityUserEntity.AccountsLockedDatetime = null;
//更新最后登录日期
securityUserEntity.LastLoginDatetime = DateTime.Now;
repository.UpdateNow(securityUserEntity);
return PackLoginProfile(securityUserEntity, data.Culture);
}
private LoginProfile PackLoginProfile(SecurityUserEntity securityUserEntity, string culture)
{
JwtGenerate jwtGenerate = service.ServiceProvider.GetService<JwtGenerate>()!;
string clainmName = Guid.NewGuid().ToString();
Dictionary<string, string> claims = new Dictionary<string, string>();
claims.Add(ClaimPolicy.CLAINM_NAME, clainmName);
claims.Add(ClaimPolicy.CLAINM_ACCOUNT, securityUserEntity.UserMobile!);
claims.Add(ClaimPolicy.CLAINM_USERID, securityUserEntity.UserName);
claims.Add(ClaimPolicy.CLAINM_LOCALE, culture);
var accessToken = jwtGenerate.Generate(claims);
if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey("Authorization"))
httpContextAccessor.HttpContext.Request.Headers.Remove("Authorization");
httpContextAccessor.HttpContext.Request.Headers.Add("Authorization", "Bearer " + accessToken);
if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey(ClaimPolicy.HEADER_LOCALE))
httpContextAccessor.HttpContext.Request.Headers.Remove(ClaimPolicy.HEADER_LOCALE);
httpContextAccessor.HttpContext.Request.Headers.Add(ClaimPolicy.HEADER_LOCALE, culture);
service.UserId = securityUserEntity.UserName;
IRepository<SecurityUserWebLoginEntity> repositoryLog = service.GetRepository<IRepository<SecurityUserWebLoginEntity>>();
SecurityUserWebLoginEntity webLogin = new SecurityUserWebLoginEntity
{
Username = securityUserEntity.UserName,
SessionId = clainmName,
LoginTime = DateTime.Now,
HostIp = httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.MapToIPv4()?.ToString(),
HostUserAgent = this.httpContextAccessor?.HttpContext?.Request.Headers.UserAgent.ToString(),
RefreshToken = jwtGenerate.GenerateRefreshToken(),
RefreshTokenRefreshedTime = DateTime.Now,
};
repositoryLog.InsertNow(webLogin);
PermissionContext permissionContext = new PermissionContext(service);
return new LoginProfile
{
Token = new JwtInfo() { AccessToken = accessToken, RefreshToken = webLogin.RefreshToken, CreatedTime = webLogin.RefreshTokenRefreshedTime },
Username = securityUserEntity.UserName,
UserType = securityUserEntity.UserType,
UserLocalName = securityUserEntity.UserLocalName,
UserEngName = securityUserEntity.UserEngName,
UserMobile = securityUserEntity.UserMobile!,
PermissionInfo = permissionContext.GetUserPermissionInfo()
};
}
[PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: "LoginAgent")]
[UnitOfWork]
public LoginProfile LoginAgent(LoginData data)
{
LoginProfile loginProfile = Login(data);
SecurityUserEntity agent = repository.DetachedEntities.Where(x =>
(x.UserMobile == data.AgentUserName && x.UserName == data.AgentUserName && x.UserIsValidIndc == YesNoPolicy.YES)).FirstOrDefault()!;
if (agent == null)
{
throw new ValidatedException("代理用户不存在.");
}
JwtGenerate jwtGenerate = service.ServiceProvider.GetService<JwtGenerate>()!;
Dictionary<string, string> claims = new Dictionary<string, string>();
claims.Add(ClaimPolicy.CLAINM_NAME, agent.UserName);
claims.Add(ClaimPolicy.CLAINM_ACCOUNT, agent.UserMobile!);
claims.Add(ClaimPolicy.CLAINM_USERID, agent.UserName);
claims.Add(ClaimPolicy.CLAINM_LOCALE, data.Culture);
var accessToken = jwtGenerate.Generate(claims);
if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey("Authorization"))
httpContextAccessor.HttpContext.Request.Headers.Remove("Authorization");
httpContextAccessor.HttpContext.Request.Headers.Add("Authorization", "Bearer " + accessToken);
if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey(ClaimPolicy.HEADER_LOCALE))
httpContextAccessor.HttpContext.Request.Headers.Remove(ClaimPolicy.HEADER_LOCALE);
httpContextAccessor.HttpContext.Request.Headers.Add(ClaimPolicy.HEADER_LOCALE, data.Culture);
SecurityUserWebLoginEntity webLogin = new SecurityUserWebLoginEntity
{
Username = agent.UserName,
SessionId = Guid.NewGuid().ToString(),
LoginTime = DateTime.Now,
HostIp = loginProfile.Username,
HostUserAgent = loginProfile.UserMobile!,
RefreshToken = jwtGenerate.GenerateRefreshToken(),
RefreshTokenRefreshedTime = DateTime.Now,
};
IRepository<SecurityUserWebLoginEntity> repositoryLog = service.GetRepository<IRepository<SecurityUserWebLoginEntity>>();
repositoryLog.InsertNow(webLogin);
service.UserId = agent.UserName;
PermissionContext permissionContext = new PermissionContext(service);
return new LoginProfile
{
Token = new JwtInfo() { AccessToken = accessToken, RefreshToken = webLogin.RefreshToken, CreatedTime = webLogin.RefreshTokenRefreshedTime },
Username = agent.UserName,
UserType = agent.UserType,
UserLocalName = agent.UserLocalName,
UserEngName = agent.UserEngName,
UserMobile = agent.UserMobile!,
PermissionInfo = permissionContext.GetUserPermissionInfo()
};
}
[AllowAnonymous]
[UnitOfWork]
public string Register(RegisterData data)
{
SecurityUserEntity securityUser = repository.DetachedEntities.Where(x => x.UserMobile == data.UserMobile).FirstOrDefault()!;
if (securityUser != null)
{
throw new ValidatedException("手机号已注册.");
}
securityUser = new SecurityUserEntity();
if (string.IsNullOrEmpty(data.UserMobile) || string.IsNullOrEmpty(data.RsaUserPassword)) return null;
ISecurityGeneratorNumberService securityGeneratorNumberService = service.ServiceProvider.GetService<ISecurityGeneratorNumberService>()!;
string userName = securityGeneratorNumberService.GetNo(SecurityPrefixPolicy.SECURITY_USER, SecurityPrefixPolicy.SECURITY_USER_USER_NAME, null, YesNoPolicy.NO, SecurityPrefixPolicy.SECURITY_USER_FORMAT);
var privateKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_RsaPrivateKey];
var rsa = new System.RSA(privateKey, true);
securityUser.UserPassword = rsa.DecodeOrNull(data.RsaUserPassword);
//052636a8-b146-9efd-f5dc-0034a3c5bb34+password(MD5)
string guid = securityUser.UserPassword.Substring(0, 36);
securityUser.UserPassword = securityUser.UserPassword.Substring(36, securityUser.UserPassword.Length - 36);
securityUser.UserName = userName;
securityUser.UserMobile = data.UserMobile;
securityUser.UserLocalName = "用户" + securityUser.UserName;
securityUser.UserIsValidIndc = YesNoPolicy.YES;
JwtGenerate jwtGenerate = service.ServiceProvider.GetService<JwtGenerate>()!;
Dictionary<string, string> claims = new Dictionary<string, string>();
claims.Add(ClaimPolicy.CLAINM_NAME, securityUser.UserName);
claims.Add(ClaimPolicy.CLAINM_ACCOUNT, securityUser.UserName);
claims.Add(ClaimPolicy.CLAINM_USERID, securityUser.UserName);
claims.Add(ClaimPolicy.CLAINM_LOCALE, CulturePolicy.ZH_CN);
service.UserId = securityUser.UserName;
var accessToken = jwtGenerate.Generate(claims);
if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey("Authorization"))
httpContextAccessor.HttpContext.Request.Headers.Remove("Authorization");
httpContextAccessor.HttpContext.Request.Headers.Add("Authorization", "Bearer " + accessToken);
repository.InsertNow(securityUser);
//发布注册的事务
EventData eventData = new EventData();
ISecurityUserRegisterPublisher publisher = service.ServiceProvider.GetService<ISecurityUserRegisterPublisher>()!;
eventData.EventSource = securityUser;
eventData.EventId = typeof(SecurityUserRegisterPublisher).Name;
publisher.Publish(eventData);
return securityUser.UserMobile;
}
[PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: Operate.Update)]
[UnitOfWork]
public LoginProfile AmendPassword(LoginData data)
{
SecurityUserEntity securityUserEntity = repository.DetachedEntities.Where(x =>
x.UserName == service.UserId
&& x.UserIsValidIndc == YesNoPolicy.YES).FirstOrDefault()!;
if (securityUserEntity == null)
{
throw new ValidatedException("认证不通过.");
}
var descKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_DescKey];
var privateKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_RsaPrivateKey];
var rsa = new RSA(privateKey, true);
var psd = rsa.DecodeOrNull(data.RsaUserPassword);
if (string.IsNullOrEmpty(psd)) throw new ValidatedException("认证不通过.");
//052636a8-b146-9efd-f5dc-0034a3c5bb34+password
var guid = psd.Substring(0, 36);
if (guid != data.UserName)
{
throw new ValidatedException("认证不通过.");
}
if (securityUserEntity.IsAccountsLockedIndc == YesNoPolicy.YES && securityUserEntity.AccountsLockedDatetime.HasValue
&& securityUserEntity.AccountsLockedDatetime.Value.Subtract(DateTime.Now).TotalMinutes > 0)
{
throw new ValidatedException("用户账号已锁定.");
}
if (securityUserEntity.UserPassword != psd.Substring(36, psd.Length - 36))
{
securityUserEntity.ErrorLoginCount += 1;
//输错多少次后,账号锁定
if (securityUserEntity.ErrorLoginCount > SecurityUserPolicy.ERROR_LOGIN_COUNT)
{
securityUserEntity.IsAccountsLockedIndc = YesNoPolicy.YES;
securityUserEntity.AccountsLockedDatetime = DateTime.Now.AddDays(1);
}
repository.UpdateNow(securityUserEntity);
throw new ValidatedException("认证不通过.");
}
JwtGenerate jwtGenerate = service.ServiceProvider.GetService<JwtGenerate>()!;
string clainmName = Guid.NewGuid().ToString();
Dictionary<string, string> claims = new Dictionary<string, string>();
claims.Add(ClaimPolicy.CLAINM_NAME, clainmName);
claims.Add(ClaimPolicy.CLAINM_ACCOUNT, securityUserEntity.UserMobile!);
claims.Add(ClaimPolicy.CLAINM_USERID, securityUserEntity.UserName);
claims.Add(ClaimPolicy.CLAINM_LOCALE, data.Culture ?? CulturePolicy.ZH_CN);
var accessToken = jwtGenerate.Generate(claims);
if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey("Authorization"))
httpContextAccessor.HttpContext.Request.Headers.Remove("Authorization");
httpContextAccessor.HttpContext.Request.Headers.Add("Authorization", "Bearer " + accessToken);
securityUserEntity.ErrorLoginCount = 0;
securityUserEntity.IsAccountsLockedIndc = YesNoPolicy.NO;
securityUserEntity.AccountsLockedDatetime = null;
//更新最后登录日期
securityUserEntity.LastLoginDatetime = DateTime.Now;
psd = rsa.DecodeOrNull(data.UserPassword);
if (string.IsNullOrEmpty(psd)) throw new ValidatedException("认证不通过.");
securityUserEntity.UserPassword = psd.Substring(36, psd.Length - 36);
repository.UpdateNow(securityUserEntity);
IRepository<SecurityUserWebLoginEntity> repositoryLog = service.GetRepository<IRepository<SecurityUserWebLoginEntity>>();
SecurityUserWebLoginEntity webLogin = new SecurityUserWebLoginEntity
{
Username = securityUserEntity.UserName,
SessionId = clainmName,
LoginTime = DateTime.Now,
HostIp = httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.MapToIPv4()?.ToString(),
HostUserAgent = this.httpContextAccessor?.HttpContext?.Request.Headers.UserAgent.ToString(),
RefreshToken = jwtGenerate.GenerateRefreshToken(),
RefreshTokenRefreshedTime = DateTime.Now,
};
repositoryLog.InsertNow(webLogin);
PermissionContext permissionContext = new PermissionContext(service);
return new LoginProfile
{
Token = new JwtInfo() { AccessToken = accessToken, RefreshToken = webLogin.RefreshToken, CreatedTime = webLogin.RefreshTokenRefreshedTime },
Username = securityUserEntity.UserName,
UserType = securityUserEntity.UserType,
UserLocalName = securityUserEntity.UserLocalName,
UserEngName = securityUserEntity.UserEngName,
UserMobile = securityUserEntity.UserMobile!,
PermissionInfo = permissionContext.GetUserPermissionInfo()
};
}
[AllowAnonymous]
[HttpGet]
public string GetRsaPublicKey()
{
if (string.IsNullOrEmpty(service.Configuration["SecuritySettings:RsaPublicKey"]))
throw new ValidatedException("[SecuritySettings:RsaPublicKey]");
return service.Configuration["SecuritySettings:RsaPublicKey"]!;
}
[AllowAnonymous]
public JwtInfo RefreshToken(JwtInfo jwtInfo)
{
if (jwtInfo == null || string.IsNullOrEmpty(jwtInfo.AccessToken) || string.IsNullOrEmpty(jwtInfo.RefreshToken))
throw new ServiceErrorException("无效的令牌,请重新登录.");
TokenValidationParameters parameters = new TokenValidationParameters
{
ValidateIssuerSigningKey = true,
//获取或设置要使用的Microsoft.IdentityModel.Tokens.SecurityKey用于签名验证。
IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(service.CloudBuilder.CloudBuilderOptions.Jwt.Secret)),
//获取或设置一个System.String,它表示将使用的有效发行者检查代币的发行者。
ValidIssuer = service.CloudBuilder.CloudBuilderOptions.Jwt.Issuer,
//获取或设置一个字符串,该字符串表示将用于检查的有效受众反对令牌的观众。
ValidAudience = service.CloudBuilder.CloudBuilderOptions.Jwt.Audience,
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = false,//不验证过期时间
};
// 验证 Token。
// 必须用 JsonWebTokenHandler(.NET 8 的 JwtBearer 鉴权、ClaimPolicy.GetClaimValue 用的都是它):
// legacy 的 JwtSecurityTokenHandler 与 Microsoft.IdentityModel.Tokens 8.x 版本混用时会错乱解析令牌
// (issuer 读成空 → IDX10211),于是刷新恒失败、访问令牌两小时一到期用户就被踢下线。
var tokenHandler = new JsonWebTokenHandler();
string token = jwtInfo.AccessToken.Replace("Bearer ", "");
try
{
var tokenValidationResult = tokenHandler.ValidateTokenAsync(token, parameters).GetAwaiter().GetResult();
if (tokenValidationResult == null || !tokenValidationResult.IsValid
|| tokenValidationResult.ClaimsIdentity == null || !tokenValidationResult.ClaimsIdentity.IsAuthenticated)
throw new ServiceErrorException($"无效的令牌,请重新登录.({tokenValidationResult?.Exception?.Message})");
}
catch (ServiceErrorException)
{
throw;
}
catch (Exception ex)
{
// 带上原始异常:以前直接吞掉,出问题时只看到"无效的令牌",无法定位
throw new ServiceErrorException($"无效的令牌,请重新登录.({ex.Message})");
}
string clainmName = ClaimPolicy.GetClaimValue(token, ClaimPolicy.CLAINM_NAME);
string username = ClaimPolicy.GetClaimValue(token, ClaimPolicy.CLAINM_USERID);
string userMobile = ClaimPolicy.GetClaimValue(token, ClaimPolicy.CLAINM_ACCOUNT);
string locale = ClaimPolicy.GetClaimValue(token, ClaimPolicy.CLAINM_LOCALE);
if (string.IsNullOrEmpty(clainmName) || string.IsNullOrEmpty(username))
throw new ServiceErrorException("无效的令牌,请重新登录.");
IRepository<SecurityUserEntity> repositorySecurityUser = service.GetRepository<IRepository<SecurityUserEntity>>();
SecurityUserEntity user = repositorySecurityUser.DetachedEntities.Where(x => x.UserName == username && x.UserIsValidIndc == YesNoPolicy.YES).FirstOrDefault();
if (user == null) throw new ServiceErrorException("无效的令牌,请重新登录.");
IRepository<SecurityUserWebLoginEntity> repositoryLog = service.GetRepository<IRepository<SecurityUserWebLoginEntity>>();
SecurityUserWebLoginEntity log = repositoryLog.DetachedEntities.Where(x => x.Username == username && x.SessionId == clainmName).FirstOrDefault();
// 先判空:原来判空写在后面,却已经先用了 log.LoginTime,
// 会话记录查不到时(日志被清理、拿了旧 token 来刷新)会抛 NullReference,
// 而不是返回友好的"无效的令牌,请重新登录."。
if (log == null) throw new ServiceErrorException("无效的令牌,请重新登录.");
// 不是最后一次登录的令牌失效。
// 用 Any(存在更晚的登录) 代替 Max(LoginTime) 聚合:语义完全一致
// (只失效"登录时间更晚"的会话,同一时刻的并列登录依旧有效),
// 而且 Any 命中索引即可短路返回,比每次刷新都算一遍 Max 便宜。
bool hasLaterLogin = repositoryLog.DetachedEntities.Any(x => x.Username == username && x.LoginTime > log.LoginTime);
if (hasLaterLogin) throw new ServiceErrorException("无效的令牌,请重新登录.");
// 原来 RefreshToken 的比较写了两遍,去掉重复
if (log.RefreshToken != jwtInfo.RefreshToken || log.LogoutTime.HasValue ||
log.RefreshTokenRefreshedTime.AddMinutes(Convert.ToInt32(service.CloudBuilder.CloudBuilderOptions.Jwt.RefreshExpireMins)) < DateTime.Now)
throw new ServiceErrorException("无效的令牌,请重新登录.");
JwtGenerate jwtGenerate = service.ServiceProvider.GetService<JwtGenerate>()!;
Dictionary<string, string> claims = new Dictionary<string, string>();
claims.Add(ClaimPolicy.CLAINM_NAME, clainmName);
claims.Add(ClaimPolicy.CLAINM_ACCOUNT, userMobile);
claims.Add(ClaimPolicy.CLAINM_USERID, username);
claims.Add(ClaimPolicy.CLAINM_LOCALE, locale ?? CulturePolicy.ZH_CN);
var accessToken = jwtGenerate.Generate(claims);
log.RefreshToken = jwtGenerate.GenerateRefreshToken();
log.RefreshTokenRefreshedTime = DateTime.Now;
repositoryLog.UpdateNow(log);
jwtInfo.AccessToken = accessToken;
jwtInfo.RefreshToken = log.RefreshToken;
jwtInfo.CreatedTime = log.RefreshTokenRefreshedTime;
return jwtInfo;
}
[AllowAnonymous]
public void Logout()
{
string username = ClaimPolicy.GetClaimValue(httpContextAccessor, ClaimPolicy.CLAINM_USERID);
string clainmName = ClaimPolicy.GetClaimValue(httpContextAccessor, ClaimPolicy.CLAINM_NAME);
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(clainmName)) { return; }
IRepository<SecurityUserWebLoginEntity> repositoryLog = service.GetRepository<IRepository<SecurityUserWebLoginEntity>>();
SecurityUserWebLoginEntity log = repositoryLog.DetachedEntities.Where(x => x.Username == username && x.SessionId == clainmName).FirstOrDefault()!;
if (log == null || log.LogoutTime.HasValue) { return; }
log.LogoutTime = DateTime.Now;
repositoryLog.UpdateNow(log);
}
// ===== Biometric / WebAuthn login (fingerprint, face, device PIN) =====
// Two-phase registration + two-phase assertion. The browser drives
// navigator.credentials.create() / navigator.credentials.get(); we
// generate challenges server-side, verify the response, and persist
// the credential on the user. Login re-uses LoginProfileFactory so
// the JWT + SecurityUserWebLogin flow is identical to the password
// login in AuthProcess.
// Verifies the password of the currently-authenticated user.
// Used as a step-up authentication gate before sensitive
// operations like enabling biometric login on a new device.
//
// The username is taken from `loginCredential` (the JWT/cookie
// identity), not from the request body — the request only carries
// the password. This means:
// 1. A valid JWT is required to even hit this method.
// 2. The user can only verify their OWN password, not someone
// else's (even if they craft a request with a different
// username, that field is ignored).
// 3. No new session is minted — the existing one is preserved.
//
// Returns true if the password matches, throws ValidationException
// otherwise. The frontend treats the exception as "wrong password".
[PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: Operate.Find)]
public bool ConfirmPassword(string password)
{
if (string.IsNullOrWhiteSpace(password))
throw new ValidationException("password is required");
var username = service.UserId;
if (string.IsNullOrWhiteSpace(username))
throw new ValidationException("User not found.");
var user = repository.DetachedEntities.FirstOrDefault(x => x.UserName == username);
if (user == null)
throw new ValidationException("User not found.");
var descKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_DescKey];
var privateKey = service.Configuration[SecuritySettingsPolicy.SecuritySettings_RsaPrivateKey];
var rsa = new System.RSA(privateKey, true);
var psd = rsa.DecodeOrNull(password);
if (string.IsNullOrEmpty(psd)) return false;
return user.UserPassword == psd.Substring(36, psd.Length - 36);
}
[PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: Operate.Find)]
public string BeginBiometricRegistration(string deviceName)
{
try
{
if (string.IsNullOrWhiteSpace(deviceName))
deviceName = "Unnamed device";
var username = service.UserId;// loginCredential.GetUsername();
if (string.IsNullOrWhiteSpace(username))
throw new ValidationException("User not found.");
var userIdBytes = Encoding.UTF8.GetBytes(username);
// v1: one credential per user — exclude list is empty.
var options = webAuthnService.BuildCreationOptions(username, userIdBytes, Array.Empty<byte[]>());
return options.ToJson();
}
catch (Exception ex)
{
//logger.LogError("BeginBiometricRegistration ERROR: " + ex.ToString());
throw;
}
}
[PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: Operate.Find)]
public BiometricRegistrationResultDto FinishBiometricRegistration(FinishBiometricRegistrationData dto)
{
if (dto == null || string.IsNullOrWhiteSpace(dto.AttestationJson) || string.IsNullOrWhiteSpace(dto.CreationOptionsJson))
throw new ValidationException("attestationJson and creationOptionsJson are required");
var username = service.UserId;// loginCredential.GetUsername();
if (string.IsNullOrWhiteSpace(username))
throw new ValidationException("User not found.");
var user = repository.DetachedEntities.FirstOrDefault(x => x.UserName == username);
if (user == null)
throw new ValidationException("User not found.");
// Diagnostic: log the raw JSON we received so we can diagnose
// Fido2NetLib "Expected rawResponse, got null" failures. The
// Fido2NetLib 1.0.0-alpha VerifyAsync checks `Raw == null` at
// the top, before any field-level checks — if it fires, the
// wrapper was constructed with a null raw, which means either
// (a) CredentialCreateOptions.FromJson returned null and the
// constructor blew up, or (b) the C# deserializer produced a
// shell object whose required properties are all null.
//logger.LogInformation(
// "FinishBiometricRegistration_DIAG attestationJson[0..400]={att} creationOptionsJson[0..200]={opts}",
// dto.AttestationJson.Substring(0, Math.Min(400, dto.AttestationJson.Length)),
// dto.CreationOptionsJson.Substring(0, Math.Min(200, dto.CreationOptionsJson.Length)));
CredentialCreateOptions originalOptions;
try
{
originalOptions = CredentialCreateOptions.FromJson(dto.CreationOptionsJson);
}
catch (Exception ex)
{
throw new ValidationException("Invalid creationOptionsJson: " + ex.Message);
}
if (originalOptions == null)
throw new ValidationException("CredentialCreateOptions.FromJson returned null (creationOptionsJson parsed as the JSON literal 'null' or empty)");
AuthenticatorAttestationRawResponse raw;
try
{
//logger.LogInformation("DTO: " + dto.AttestationJson);
// MUST use Newtonsoft.Json here, not System.Text.Json. Fido2NetLib's
// AuthenticatorAttestationRawResponse relies on Newtonsoft.Json-specific
// attributes that System.Text.Json does not honor:
// * [JsonConverter(typeof(Base64UrlConverter))] on Id/RawId/AttestationObject/ClientDataJson
// (converts base64url strings to byte[]; System.Text.Json would leave them as strings)
// * [JsonConverter(typeof(StringEnumConverter))] on PublicKeyCredentialType.Type
// with [EnumMember(Value = "public-key")] on the PublicKey member
//
// And Fido2NetLib's AuthenticatorAttestationResponse.Parse throws
// "Expected rawResponse, got null" when rawResponse.Response is null —
// which is exactly what System.Text.Json produces, because it is
// case-sensitive by default and looks for `Response` (PascalCase) in
// the JSON, while the browser sends `response` (lowercase).
// Newtonsoft.Json's default ContractResolver is case-insensitive.
AuthenticatorAttestationRawResponseModel oldModel = JsonConvert.DeserializeObject<AuthenticatorAttestationRawResponseModel>(dto.AttestationJson);
raw = ToNewAuthenticatorAttestationRawResponse(oldModel);
}
catch (Exception ex)
{
throw new ValidationException("Invalid attestationJson: " + ex.Message);
}
if (raw == null) throw new ValidationException("Invalid attestationJson");
if (raw.Response == null) throw new ValidationException("raw.Response is null after deserialization — the browser's JSON didn't bind to Fido2NetLib's Response property");
// Diagnostic: log what Fido2NetLib will see. If any of these
// are null, Fido2NetLib's VerifyAsync will throw the cryptic
// "Expected rawResponse, got null" or "Id is missing" / "RawId
// is missing" / "clientDataJson cannot be null" etc.
//logger.LogInformation(
// "FinishBiometricRegistration_DIAG raw.Id={id} raw.RawId={rawId} raw.Type={type} raw.Response.AttestationObject={ao} raw.Response.ClientDataJson={cdj}",
// raw.Id,
// raw.RawId,
// raw.Type,
// raw.Response?.AttestationObject == null ? "<null>" : $"<{raw.Response.AttestationObject.Length} chars>",
// raw.Response?.ClientDataJson == null ? "<null>" : $"<{raw.Response.ClientDataJson.Length} chars>");
// Fido2NetLib verifies signature, certificate chain, challenge,
// origin, and rpId. We only need to await it.
var stored = webAuthnService
.FinishRegistrationAsync(raw, originalOptions, _ => Task.FromResult(true))
.GetAwaiter().GetResult();
// Persist on the user. v1 = one credential per user: just
// overwrite. (The excludeCredentials list passed to
// BuildCreationOptions is empty, so the authenticator will
// refuse to re-enroll the same key on the same device — but
// re-enrolling on a new device overwrites. That's intentional.)
//
// SecurityUser.BiometricCredentialId / BiometricPublicKey are
// `string` columns (per the request to keep the schema as-is).
// Fido2NetLib gives us `byte[]`; we encode with base64url so
// the stored value is directly usable as the credential id
// (matches the format returned to the browser in
// BiometricRegistrationResultDto.CredentialIdBase64Url) and
// the public key can be decoded back to byte[] for the
// assertion verification path.
user.BiometricCredentialId = MyBase64Url.Encode(stored.CredentialId);
user.BiometricPublicKey = MyBase64Url.Encode(stored.PublicKey);
user.BiometricCounter = 0;
user.BiometricDeviceName = dto.AttestationJson.Length > 0 ? "(set on the device)" : null;
user.BiometricEnrolledAt = DateTime.Now;
user.BiometricLastUsedAt = null;
repository.UpdateNow(user);
return new BiometricRegistrationResultDto
{
CredentialIdBase64Url = MyBase64Url.Encode(stored.CredentialId)
};
}
[AllowAnonymous]
public BiometricLoginChallengeDto BeginBiometricLogin(string username)
{
if (string.IsNullOrWhiteSpace(username))
throw new ValidationException("username is required");
var user = repository.DetachedEntities.FirstOrDefault(x => x.UserName == username);
if (user == null)
throw new ValidationException("User not found.");
if (user.BiometricCredentialId == null || user.BiometricCredentialId.Length == 0)
throw new ValidationException("Biometric not enrolled for this user");
// user.BiometricCredentialId is stored as base64url (see
// FinishBiometricRegistration). Decode back to byte[] for
// Fido2NetLib's PublicKeyCredentialDescriptor.Id.
// BuildRequestOptionsJson bypasses Fido2NetLib 1.0.0-alpha's
// PascalCase serializer. See WebAuthnService.BuildRequestOptionsJson
// for why the wire shape has to be hand-built.
var optionsJson = webAuthnService.BuildRequestOptionsJson(
MyBase64Url.Decode(user.BiometricCredentialId));
return new BiometricLoginChallengeDto
{
RequestOptionsJson = optionsJson,
ChallengeId = Guid.NewGuid().ToString()
};
}
[AllowAnonymous]
public LoginProfile FinishBiometricLogin(FinishBiometricLoginData dto)
{
if (dto == null || string.IsNullOrWhiteSpace(dto.Username) || string.IsNullOrWhiteSpace(dto.AssertionJson) || string.IsNullOrWhiteSpace(dto.RequestOptionsJson))
throw new ValidationException("username, assertionJson, and requestOptionsJson are required");
var user = repository.DetachedEntities.FirstOrDefault(x => x.UserMobile == dto.Username || x.UserName == dto.Username);
if (user == null)
throw new ValidationException("User not found.");
if (user.BiometricCredentialId == null || user.BiometricPublicKey == null)
throw new ValidationException("Biometric not enrolled for this user");
AssertionOptions originalOptions;
try
{
// dto.RequestOptionsJson arrives double-JSON-encoded: the
// backend returns the options as a JSON string (e.g.
// '{"challenge":"...","rpId":"..."}'
// ), and the frontend then does JSON.stringify() on that
// string, wrapping it in another layer of quotes:
// '"{\"challenge\":\"...\",\"rpId\":\"..."}"'
// Fido2NetLib's AssertionOptions.FromJson then tries to
// deserialize that as an AssertionOptions object and throws
// "Could not cast or convert from System.String to
// Fido2NetLib.AssertionOptions" because it sees a string
// token at the top level.
//
// Unwrap one layer with JToken.Parse: if the result is a
// JValue (the JSON-encoded string case), parse its string
// value to get the inner JSON. If it's already a JObject
// (no double-encoding), just use ToString().
var token = Newtonsoft.Json.Linq.JToken.Parse(dto.RequestOptionsJson);
if (token is Newtonsoft.Json.Linq.JValue v && v.Type == Newtonsoft.Json.Linq.JTokenType.String)
{
token = Newtonsoft.Json.Linq.JToken.Parse((string)v.Value!);
}
AssertionOptionsModel assertionOptionsModel = AssertionOptionsModel.FromJson(token.ToString());
originalOptions = ConvertToLibAssertionOptions(assertionOptionsModel);
}
catch (Exception ex)
{
//logger.LogError("FinishBiometricLogin_ERROR_1: " + ex.ToString());
throw new ValidationException("Invalid requestOptionsJson: " + ex.Message);
}
AuthenticatorAssertionRawResponse raw;
try
{
// Fido2NetLib 1.0.0-alpha declares Id/RawId/Response.* as `string`
// (no [JsonConverter] — that was added in v1.1.0+), and internally
// does (byte[])raw.Id to look up the credential. The (byte[]) cast
// throws "Unable to cast object of type 'System.String' to type
// 'System.Byte[]'" on the assertion flow (the registration flow
// doesn't hit it because its Parse() path only touches
// Response.AttestationObject, never Id/RawId).
//
// Fix: deserialize normally with Newtonsoft.Json, then patch the
// base64url string fields to byte[] via reflection. If the C#
// property is byte[] (1.1.0+ shape) this is a no-op. If it's
// string (1.0.0-alpha shape) the reflection write will fail at
// runtime with a clear ArgumentException — see the catch below
// for the workaround.
AuthenticatorAssertionRawResponseModel model =
JsonConvert.DeserializeObject<AuthenticatorAssertionRawResponseModel>(dto.AssertionJson);
raw = AssertionModelConverter.ToLibAuthenticatorAssertionRawResponse(model);
//raw = JsonConvert.DeserializeObject<AuthenticatorAssertionRawResponse>(dto.AssertionJson);
WebAuthnAssertionPatcher.PatchResponseToByteArrays(raw);
// DIAG: log the actual runtime property type so we can confirm
// the shape of the Fido2NetLib 1.0.0-alpha class and choose the
// right patching strategy.
//logger.LogInformation(
// "FinishBiometricLogin_DIAG Id={idType} RawId={rawIdType} Response.Id={respIdType} Response.AuthenticatorData={adType} Response.Signature={sigType} Response.ClientDataJson={cdjType}",
// typeof(AuthenticatorAssertionRawResponse).GetProperty("Id")?.PropertyType.Name,
// typeof(AuthenticatorAssertionRawResponse).GetProperty("RawId")?.PropertyType.Name,
// typeof(AuthenticatorAssertionRawResponse.AssertionResponse).GetProperty("Id")?.PropertyType.Name,
// typeof(AuthenticatorAssertionRawResponse.AssertionResponse).GetProperty("AuthenticatorData")?.PropertyType.Name,
// typeof(AuthenticatorAssertionRawResponse.AssertionResponse).GetProperty("Signature")?.PropertyType.Name,
// typeof(AuthenticatorAssertionRawResponse.AssertionResponse).GetProperty("ClientDataJson")?.PropertyType.Name);
}
catch (Exception ex)
{
//logger.LogError("FinishBiometricLogin_ERROR_2: " + ex.ToString());
throw new ValidationException("Invalid assertionJson: " + ex.Message);
}
if (raw == null)
{
//logger.LogError("FinishBiometricLogin_ERROR_3:");
throw new ValidationException("Invalid assertionJson");
}
try
{
// user.BiometricPublicKey is stored as base64url; decode to
// byte[] for Fido2NetLib's MakeAssertionAsync signature.
var newCounter = webAuthnService
.FinishAssertionAsync(raw, originalOptions, MyBase64Url.Decode(user.BiometricPublicKey), (uint)user.BiometricCounter)
.GetAwaiter().GetResult();
// Persist counter + last-used timestamp.
user.BiometricCounter = (int)newCounter;
user.BiometricLastUsedAt = DateTime.Now;
repository.UpdateNow(user);
// Mint the session the same way AuthProcess.Login does.
return PackLoginProfile(user, CulturePolicy.ZH_CN);
}
catch (Exception ex)
{
//logger.LogError("FinishBiometricLogin_ERROR_4: " + ex.ToString());
throw;
}
}
public static AuthenticatorAttestationRawResponse ToNewAuthenticatorAttestationRawResponse(AuthenticatorAttestationRawResponseModel oldModel)
{
if (oldModel == null)
throw new ArgumentNullException(nameof(oldModel));
if (oldModel.Response == null)
throw new ArgumentNullException(nameof(oldModel.Response), "oldModel.Response 不能为null");
var raw = new AuthenticatorAttestationRawResponse
{
// v4 Id字段是base64url字符串,由RawId字节编码而来
Id = Base64Url.EncodeToString(oldModel.RawId),
RawId = oldModel.RawId,
Type = PublicKeyCredentialType.PublicKey,
ClientExtensionResults = new AuthenticationExtensionsClientOutputs(),
Response = new AuthenticatorAttestationRawResponse.AttestationResponse
{
AttestationObject = oldModel.Response.AttestationObject,
ClientDataJson = oldModel.Response.ClientDataJson,
Transports = Array.Empty<AuthenticatorTransport>()
}
};
return raw;
}
public static AssertionOptions ConvertToLibAssertionOptions(AssertionOptionsModel model)
{
if (model == null)
throw new ArgumentNullException(nameof(model));
var assertionOptions = new AssertionOptions
{
Challenge = MyBase64Url.Decode(model.Challenge)!,
RpId = model.RpId,
Timeout = model.Timeout,
//数组转IReadOnlyList,直接赋值
AllowCredentials = model.AllowCredentials ?? Array.Empty<PublicKeyCredentialDescriptor>(),
UserVerification = model.UserVerification,
//Model不包含的库字段,填充默认空值
Hints = Array.Empty<PublicKeyCredentialHint>(),
Extensions = null
};
return assertionOptions;
}
// ===== Epayslip biometric verification (step-up auth, not a new login) =====
//
// BeginBiometricLogin / FinishBiometricLogin mint a new JWT session
// (they are the password-less login flow). The two webmethods below
// reuse the same WebAuthn ceremony against the CURRENTLY-authenticated
// user's stored credential, but instead of issuing a fresh profile
// they just stamp a 5-minute unlock in EpayslipBiometricUnlockStore
// so SalaryTaxWebProcess.SalaryTax can accept biometricVerified=true
// as a substitute for the epayslip password.
//
// Both webmethods require the user to be logged in (the auto-generated
// UserProfileController has [Authorize] at the class level) AND
// require biometric_for_epayslip=1 -- we re-check the server flag
// here even though the frontend gates on it, so a user cannot
// accidentally hit Begin from a stale tab after disabling the flag.
// Returns the WebAuthn request options JSON for the current user's
// stored credential. The frontend parses the string and passes it to
// @simplewebauthn/browser's startAuthentication.
//
// Wrapped in EpayslipBiometricAssertionChallengeDto (not returned
// as a raw string) so the auto-generated TS service stub's
// resp.requestOptionsJson mirrors the BiometricLoginChallengeDto
// contract used by LoginView. A raw-string return would serialize
// as a JSON-encoded quoted string and confuse the frontend's
// JSON.parse step.
public BiometricLoginChallengeDto BeginEpayslipBiometricAssertion()
{
var username = service.UserId;
if (string.IsNullOrWhiteSpace(username))
throw new ValidationException("User not found.");
var user = repository.DetachedEntities.FirstOrDefault(x => x.UserName == username);
if (user == null)
throw new ValidationException("User not found.");
// Defense in depth: refuse to issue a challenge if the user
// has not enrolled biometric login or has not opted in to
// biometric-for-epayslip.
if (string.IsNullOrEmpty(user.BiometricCredentialId))
throw new ValidationException("Biometric login must be enabled first");
// user.BiometricCredentialId is stored as base64url (see
// FinishBiometricRegistration). Decode back to byte[] for
// Fido2NetLib's PublicKeyCredentialDescriptor.Id.
return new BiometricLoginChallengeDto
{
RequestOptionsJson = webAuthnService.BuildRequestOptionsJson(
MyBase64Url.Decode(user.BiometricCredentialId))
};
}
// Verifies the assertion produced by the browser from
// BeginEpayslipBiometricAssertion's challenge. On success stamps
// the unlock store for 5 minutes -- SalaryTax can then accept
// biometricVerified=true instead of the epayslip password hash.
//
// Same Newtonsoft + double-JSON-decode dance as FinishBiometricLogin
// (see that method's comments for why Newtonsoft specifically and
// why the options string needs unwrapping); only the persistence
// step is different -- we do not mint a new session.
[PermissionOperate(name: SecurityPermissionPolicy.SecurityUser, operate: Operate.Find)]
public void RemoveBiometric()
{
var username = service.UserId;// loginCredential.GetUsername();
if (string.IsNullOrWhiteSpace(username))
throw new ValidationException("User not found.");
var user = repository.DetachedEntities.FirstOrDefault(x => x.UserName == username);
if (user == null)
throw new ValidationException("User not found.");
user.BiometricCredentialId = null;
user.BiometricPublicKey = null;
user.BiometricCounter = 0;
user.BiometricDeviceName = null;
user.BiometricEnrolledAt = null;
user.BiometricLastUsedAt = null;
repository.UpdateNow(user);
}
}
public class AuthenticatorAttestationRawResponseModel
{
public class ResponseData
{
[JsonConverter(typeof(Base64UrlConverterModel))]
public byte[] AttestationObject { get; set; }
[JsonConverter(typeof(Base64UrlConverterModel))]
public byte[] ClientDataJson { get; set; }
}
[JsonConverter(typeof(Base64UrlConverterModel))]
public byte[] Id { get; set; }
[JsonConverter(typeof(Base64UrlConverterModel))]
public byte[] RawId { get; set; }
public string Type { get; set; }
public ResponseData Response { get; set; }
}
public class Base64UrlConverterModel : JsonConverter<byte[]>
{
private readonly Required requirement = Required.DisallowNull;
public Base64UrlConverterModel()
{
}
public Base64UrlConverterModel(Required required = Required.DisallowNull)
{
requirement = required;
}
public override void WriteJson(JsonWriter writer, byte[] value, JsonSerializer serializer)
{
writer.WriteValue(Base64Url.EncodeToString(value));
}
public override byte[] ReadJson(JsonReader reader, Type objectType, byte[] existingValue, bool hasExistingValue, JsonSerializer serializer)
{
byte[] result = null;
if (reader.Value == null && requirement == Required.AllowNull)
{
return result;
}
if (reader.Value == null)
{
throw new Fido2VerificationException("json value must not be null");
}
if (Type.GetType("System.String") != reader.ValueType)
{
throw new Fido2VerificationException("json valuetype must be string");
}
try
{
return Base64Url.DecodeFromChars((string)reader.Value);
}
catch (FormatException)
{
throw new Fido2VerificationException("json value must be valid base64 encoded string");
}
}
}
public class AuthenticatorAssertionRawResponseModel
{
public string Id { get; set; } = string.Empty;
public string RawId { get; set; } = string.Empty;
public AssertionResponseModel Response { get; set; } = new();
public PublicKeyCredentialType Type { get; set; }
public AuthenticationExtensionsClientOutputs ClientExtensionResults { get; set; } = new();
public sealed class AssertionResponseModel
{
public string AuthenticatorData { get; set; } = string.Empty;
public string Signature { get; set; } = string.Empty;
public string ClientDataJSON { get; set; } = string.Empty;
public string? UserHandle { get; set; }
}
}
public static class AssertionModelConverter
{
/// <summary>
/// 把simplewebauthn过渡Model,转为Fido2NetLib v4.1原生 AuthenticatorAssertionRawResponse
/// </summary>
/// <param name="model">过渡模型</param>
/// <exception cref="ArgumentNullException"></exception>
/// <returns>Fido2NetLib原生 AuthenticatorAssertionRawResponse</returns>
public static AuthenticatorAssertionRawResponse ToLibAuthenticatorAssertionRawResponse(
AuthenticatorAssertionRawResponseModel model)
{
if (model == null) throw new ArgumentNullException(nameof(model));
if (model.Response == null) throw new ArgumentNullException(nameof(model.Response));
return new AuthenticatorAssertionRawResponse
{
Id = model.Id,
RawId = Base64Url.DecodeFromChars(model.RawId),
Type = model.Type,
ClientExtensionResults = model.ClientExtensionResults,
Response = new AuthenticatorAssertionRawResponse.AssertionResponse
{
AuthenticatorData = Base64Url.DecodeFromChars(model.Response.AuthenticatorData),
Signature = Base64Url.DecodeFromChars(model.Response.Signature),
ClientDataJson = Base64Url.DecodeFromChars(model.Response.ClientDataJSON),
UserHandle = string.IsNullOrEmpty(model.Response.UserHandle)
? null
: Base64Url.DecodeFromChars(model.Response.UserHandle)
}
};
}
}
/*
public class AssertionOptionsModel
{
[JsonProperty("status")]
public string Status { get; set; } = "ok";
[JsonProperty("errorMessage")]
public string ErrorMessage { get; set; } = string.Empty;
[JsonConverter(typeof(Base64UrlConverterModel))]
public byte[] Challenge { get; set; }
public uint Timeout { get; set; }
public string RpId { get; set; }
public IEnumerable<PublicKeyCredentialDescriptor> AllowCredentials { get; set; }
public UserVerificationRequirement UserVerification { get; set; }
internal static AssertionOptionsModel Create(byte[] challenge, IEnumerable<PublicKeyCredentialDescriptor> allowedCredentials, UserVerificationRequirement userVerification)
{
return new AssertionOptionsModel
{
Challenge = challenge,
AllowCredentials = (allowedCredentials ?? new List<PublicKeyCredentialDescriptor>()),
UserVerification = userVerification
};
}
public string ToJson()
{
return JsonConvert.SerializeObject(this);
}
public static AssertionOptionsModel FromJson(string json)
{
return JsonConvert.DeserializeObject<AssertionOptionsModel>(json);
}
}
*/
}