CloudBuilder/CloudBuilder.Security.Service/AuthService.cs
owenchen 873f65dbbe ow
2026-08-06 13:15:56 +08:00

992 lines
50 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 Azure.Core;
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 Fido2NetLib;
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 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.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
var tokenHandler = new JwtSecurityTokenHandler();
SecurityToken vTocken;
string token = jwtInfo.AccessToken.Replace("Bearer ", "");
try
{
var tokenValidationResult = tokenHandler.ValidateToken(token, parameters, out vTocken);
if (tokenValidationResult == null || !tokenValidationResult.Identities.Any() || !tokenValidationResult.Identities.First().IsAuthenticated)
throw new ServiceErrorException("无效的令牌,请重新登录.");
}
catch (Exception)
{
throw new ServiceErrorException("无效的令牌,请重新登录.");
}
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()!;
//不是最后登录的令牌失效
DateTime? LoginTime = repositoryLog.DetachedEntities.Where(x => x.Username == username).Max(x => x.LoginTime);
if (LoginTime.HasValue && log.LoginTime < LoginTime.Value) throw new ServiceErrorException("无效的令牌,请重新登录.");
if (log == null || log.RefreshToken != jwtInfo.RefreshToken || log.LogoutTime.HasValue ||
log.RefreshToken != jwtInfo.RefreshToken || 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;
if (httpContextAccessor.HttpContext.Request.Headers.ContainsKey("Authorization"))
httpContextAccessor.HttpContext.Request.Headers.Remove("Authorization");
httpContextAccessor.HttpContext.Request.Headers.Add("Authorization", "Bearer " + accessToken);
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.
raw = JsonConvert.DeserializeObject<AuthenticatorAttestationRawResponse>(dto.AttestationJson);
}
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 = Fido2NetLib.Base64Url.Encode(stored.CredentialId);
user.BiometricPublicKey = Fido2NetLib.Base64Url.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 = Fido2NetLib.Base64Url.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(
Fido2NetLib.Base64Url.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!);
}
originalOptions = AssertionOptions.FromJson(token.ToString());
}
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.
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, Fido2NetLib.Base64Url.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;
}
}
// ===== 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(
Fido2NetLib.Base64Url.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.
public bool FinishEpayslipBiometricAssertion(string assertionJson, string requestOptionsJson)
{
if (string.IsNullOrWhiteSpace(assertionJson) || string.IsNullOrWhiteSpace(requestOptionsJson))
throw new ValidationException("assertionJson and requestOptionsJson are 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.");
if (string.IsNullOrEmpty(user.BiometricCredentialId) || string.IsNullOrEmpty(user.BiometricPublicKey))
throw new ValidationException("Biometric not enrolled for this user");
AssertionOptions originalOptions;
try
{
// Same unwrap as FinishBiometricLogin: the frontend
// double-JSON-encodes the options string, so the top-level
// JToken is a JSON string of a JSON object. Unwrap one layer
// before handing to Fido2NetLib's FromJson.
var token = Newtonsoft.Json.Linq.JToken.Parse(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!);
}
originalOptions = AssertionOptions.FromJson(token.ToString());
}
catch (Exception ex)
{
//logger.LogError("FinishEpayslipBiometricAssertion_ERROR_1: " + ex.ToString());
throw new ValidationException("Invalid requestOptionsJson: " + ex.Message);
}
AuthenticatorAssertionRawResponse raw;
try
{
// See WebAuthnAssertionPatcher for why this is needed.
raw = JsonConvert.DeserializeObject<AuthenticatorAssertionRawResponse>(assertionJson);
WebAuthnAssertionPatcher.PatchResponseToByteArrays(raw);
}
catch (Exception ex)
{
//logger.LogError("FinishEpayslipBiometricAssertion_ERROR_2: " + ex.ToString());
throw new ValidationException("Invalid assertionJson: " + ex.Message);
}
if (raw == null)
throw new ValidationException("Invalid assertionJson");
if (raw.Response == null)
throw new ValidationException("assertionJson is missing the response field");
try
{
// user.BiometricPublicKey is stored as base64url; decode to
// byte[] for Fido2NetLib's MakeAssertionAsync signature.
var newCounter = webAuthnService
.FinishAssertionAsync(raw, originalOptions, Fido2NetLib.Base64Url.Decode(user.BiometricPublicKey), (uint)user.BiometricCounter)
.GetAwaiter().GetResult();
user.BiometricCounter = (int)newCounter;
user.BiometricLastUsedAt = DateTime.Now;
repository.UpdateNow(user);
// Stamp the unlock. Default duration is 5 minutes -- see
// EpayslipBiometricUnlockStore.DefaultDuration.
//epayslipUnlockStore.Unlock(username);
//logger.LogInformation("FinishEpayslipBiometricAssertion: unlocked for username={username}", username);
return true;
}
catch (Exception ex)
{
//logger.LogError("FinishEpayslipBiometricAssertion_ERROR_3: " + ex.ToString());
throw;
}
}
[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);
}
}
}