992 lines
50 KiB
C#
992 lines
50 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|