增加MFA认证
continuous-integration/drone/push Build is passing Details

IRC_NewDev
hang 2024-06-11 17:58:06 +08:00
parent eb0f13c075
commit 25efb34f1f
9 changed files with 401 additions and 172 deletions

View File

@ -30,6 +30,8 @@ using IRaCIS.Core.Application.Helper;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using IRaCIS.Core.Application.Contracts; using IRaCIS.Core.Application.Contracts;
using LoginReturnDTO = IRaCIS.Application.Contracts.LoginReturnDTO; using LoginReturnDTO = IRaCIS.Application.Contracts.LoginReturnDTO;
using DocumentFormat.OpenXml.Spreadsheet;
using AutoMapper.QueryableExtensions;
namespace IRaCIS.Api.Controllers namespace IRaCIS.Api.Controllers
{ {
@ -96,14 +98,54 @@ namespace IRaCIS.Api.Controllers
/// <summary> 系统用户登录接口[New] </summary> /// <summary> 系统用户登录接口[New] </summary>
[HttpPost, Route("user/login")] [HttpPost, Route("user/login")]
[AllowAnonymous] [AllowAnonymous]
public async Task<IResponseOutput<LoginReturnDTO>> Login(UserLoginDTO loginUser, [FromServices] IEasyCachingProvider provider, [FromServices] IUserService _userService, public async Task<IResponseOutput> Login(UserLoginDTO loginUser,
[FromServices] IEasyCachingProvider provider,
[FromServices] IUserService _userService,
[FromServices] ITokenService _tokenService, [FromServices] ITokenService _tokenService,
[FromServices] IReadingImageTaskService readingImageTaskService, [FromServices] IReadingImageTaskService readingImageTaskService,
[FromServices] IConfiguration configuration) IOptionsMonitor<ServiceVerifyConfigOption> _verifyConfig,
IMailVerificationService _mailVerificationService)
{ {
//MFA 邮箱验证 前端传递用户Id 和MFACode
if (loginUser.UserId != null && !string.IsNullOrEmpty(loginUser.MFACode) && _verifyConfig.CurrentValue.OpenLoginMFA)
{
Guid userId = (Guid)loginUser.UserId;
//验证MFA 编码是否有问题
await _userService.VerifyMFACodeAsync(userId, loginUser.MFACode);
var basicInfo = await _userService.GetUserBasicInfo(userId);
var loginReturn = new LoginReturnDTO() { BasicInfo = basicInfo };
loginReturn.JWTStr = _tokenService.GetToken(IRaCISClaims.Create(loginReturn.BasicInfo));
// 创建一个 CookieOptions 对象,用于设置 Cookie 的属性
var option = new CookieOptions
{
Expires = DateTime.Now.AddMonths(1), // 设置过期时间为 30 分钟之后
HttpOnly = false, // 确保 cookie 只能通过 HTTP 访问
SameSite = Microsoft.AspNetCore.Http.SameSiteMode.None, // 设置 SameSite 属性
Secure = false // 确保 cookie 只能通过 HTTPS 访问
};
HttpContext.Response.Cookies.Append("access_token", loginReturn.JWTStr, option);
// 验证阅片休息时间
await readingImageTaskService.ResetReadingRestTime(userId);
await provider.SetAsync(userId.ToString(), loginReturn.JWTStr, TimeSpan.FromDays(7));
await provider.SetAsync($"{userId.ToString()}_Online", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), TimeSpan.FromMinutes(_verifyConfig.CurrentValue.AutoLoginOutMinutes));
return ResponseOutput.Ok(loginReturn);
}
else
{
var returnModel = await _userService.Login(loginUser.UserName, loginUser.Password); var returnModel = await _userService.Login(loginUser.UserName, loginUser.Password);
if (returnModel.IsSuccess) if (returnModel.IsSuccess)
@ -169,6 +211,45 @@ namespace IRaCIS.Api.Controllers
#endregion #endregion
var userId = returnModel.Data.BasicInfo.Id;
if (_verifyConfig.CurrentValue.OpenLoginMFA)
{
//发版屏蔽
returnModel.Data.JWTStr = _tokenService.GetToken(IRaCISClaims.Create(returnModel.Data.BasicInfo));
//MFA 发送邮件
returnModel.Data.IsMFA = true;
var email = returnModel.Data.BasicInfo.EMail;
#region 隐藏Email
// 找到 "@" 符号的位置
int atIndex = email.IndexOf('@');
// 替换 "@" 符号前的中间两位为星号
string visiblePart = email.Substring(0, atIndex);
int startIndex = (visiblePart.Length - 2) / 2;
// 替换中间两位字符为星号
string hiddenPartBeforeAt = visiblePart.Substring(0, startIndex) + "**" + visiblePart.Substring(startIndex + 2);
string afterAt = email.Substring(atIndex + 1);
// 组合隐藏和可见部分
string hiddenEmail = hiddenPartBeforeAt + "@" + afterAt;
#endregion
returnModel.Data.BasicInfo.EMail = hiddenEmail;
await _userService.SendMFAEmail(userId);
}
else
{
returnModel.Data.JWTStr = _tokenService.GetToken(IRaCISClaims.Create(returnModel.Data.BasicInfo)); returnModel.Data.JWTStr = _tokenService.GetToken(IRaCISClaims.Create(returnModel.Data.BasicInfo));
// 创建一个 CookieOptions 对象,用于设置 Cookie 的属性 // 创建一个 CookieOptions 对象,用于设置 Cookie 的属性
@ -182,16 +263,28 @@ namespace IRaCIS.Api.Controllers
HttpContext.Response.Cookies.Append("access_token", returnModel.Data.JWTStr, option); HttpContext.Response.Cookies.Append("access_token", returnModel.Data.JWTStr, option);
}
var userId = returnModel.Data.BasicInfo.Id.ToString();
//provider.Set(userId, userId, TimeSpan.FromMinutes(AppSettings.LoginExpiredTimeSpan));
// 验证阅片休息时间 // 验证阅片休息时间
await readingImageTaskService.ResetReadingRestTime(returnModel.Data.BasicInfo.Id); await readingImageTaskService.ResetReadingRestTime(returnModel.Data.BasicInfo.Id);
await provider.SetAsync(userId.ToString(), returnModel.Data.JWTStr, TimeSpan.FromDays(7)); await provider.SetAsync(userId.ToString(), returnModel.Data.JWTStr, TimeSpan.FromDays(7));
return returnModel;
await provider.SetAsync($"{userId.ToString()}_Online", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), TimeSpan.FromMinutes(_verifyConfig.CurrentValue.AutoLoginOutMinutes));
} }
}
return returnModel;
}
}
[HttpGet, Route("imageShare/ShareImage")] [HttpGet, Route("imageShare/ShareImage")]

View File

@ -29,7 +29,7 @@
<param name="doctorId"></param> <param name="doctorId"></param>
<returns></returns> <returns></returns>
</member> </member>
<member name="M:IRaCIS.Api.Controllers.ExtraController.Login(IRaCIS.Application.Contracts.UserLoginDTO,EasyCaching.Core.IEasyCachingProvider,IRaCIS.Application.Services.IUserService,IRaCIS.Core.Application.Auth.ITokenService,IRaCIS.Core.Application.Contracts.IReadingImageTaskService,Microsoft.Extensions.Configuration.IConfiguration)"> <member name="M:IRaCIS.Api.Controllers.ExtraController.Login(IRaCIS.Application.Contracts.UserLoginDTO,EasyCaching.Core.IEasyCachingProvider,IRaCIS.Application.Services.IUserService,IRaCIS.Core.Application.Auth.ITokenService,IRaCIS.Core.Application.Contracts.IReadingImageTaskService,Microsoft.Extensions.Options.IOptionsMonitor{IRaCIS.Core.Domain.Share.ServiceVerifyConfigOption},IRaCIS.Application.Services.IMailVerificationService)">
<summary> 系统用户登录接口[New] </summary> <summary> 系统用户登录接口[New] </summary>
</member> </member>
<member name="M:IRaCIS.Core.API.Controllers.Special.FinancialChangeController.AddOrUpdateTrialInspection(IRaCIS.Core.Application.Service.Inspection.DTO.DataInspectionDto{IRaCIS.Application.Contracts.TrialCommand})"> <member name="M:IRaCIS.Core.API.Controllers.Special.FinancialChangeController.AddOrUpdateTrialInspection(IRaCIS.Core.Application.Service.Inspection.DTO.DataInspectionDto{IRaCIS.Application.Contracts.TrialCommand})">

View File

@ -60,7 +60,10 @@
"LoginMaxFailCount": 5, "LoginMaxFailCount": 5,
"LoginFailLockMinutes": 1, "LoginFailLockMinutes": 1,
"AutoLoginOutMinutes": 1
"AutoLoginOutMinutes": 1,
"OpenLoginMFA": true
}, },
"SystemEmailSendConfig": { "SystemEmailSendConfig": {

View File

@ -21,6 +21,8 @@ namespace IRaCIS.Application.Services
Task SiteSurveyRejectEmail(MimeMessage messageToSend); Task SiteSurveyRejectEmail(MimeMessage messageToSend);
Task SenMFAVerifyEmail(Guid userId, string userName, string emailAddress, int verificationCode);
Task SendMailEditEmail(Guid userId, string userName, string emailAddress, int verificationCode); Task SendMailEditEmail(Guid userId, string userName, string emailAddress, int verificationCode);
Task AnolymousSendEmailForResetAccount(string emailAddress, int verificationCode); Task AnolymousSendEmailForResetAccount(string emailAddress, int verificationCode);
@ -91,6 +93,66 @@ namespace IRaCIS.Application.Services
return str; return str;
} }
//MFA
public async Task SenMFAVerifyEmail(Guid userId, string userName, string emailAddress, int verificationCode)
{
var messageToSend = new MimeMessage();
//发件地址
messageToSend.From.Add(new MailboxAddress(_systemEmailConfig.FromName, _systemEmailConfig.FromEmail));
//收件地址
messageToSend.To.Add(new MailboxAddress(userName, emailAddress));
//主题
//---[来自{0}] 关于MFA邮箱验证的提醒
messageToSend.Subject = _localizer["Mail_EmailMFATopic", _userInfo.IsEn_Us ? _systemEmailConfig.CompanyShortName : _systemEmailConfig.CompanyShortNameCN];
var builder = new BodyBuilder();
var pathToFile = _hostEnvironment.WebRootPath
+ Path.DirectorySeparatorChar.ToString()
+ "EmailTemplate"
+ Path.DirectorySeparatorChar.ToString()
//+ "UserOptCommon.html";
+ (_userInfo.IsEn_Us ? "UserOptCommon_US.html" : "UserOptCommon.html");
using (StreamReader SourceReader = System.IO.File.OpenText(pathToFile))
{
var templateInfo = SourceReader.ReadToEnd();
builder.HtmlBody = string.Format(ReplaceCompanyName(templateInfo),
userName,
_localizer["Mail_MFAEmail"],
verificationCode
);
}
messageToSend.Body = builder.ToMessageBody();
EventHandler<MessageSentEventArgs> sucessHandle = (sender, args) =>
{
// args.Response
var code = verificationCode.ToString();
_ = _verificationCodeRepository.AddAsync(new VerificationCode()
{
CodeType = 0,
HasSend = true,
Code = code,
UserId = userId,
ExpirationTime = DateTime.Now.AddMinutes(3)
}).Result;
_ = _verificationCodeRepository.SaveChangesAsync().Result;
};
await SendEmailHelper.SendEmailAsync(messageToSend, _systemEmailConfig, sucessHandle);
}
//重置邮箱 //重置邮箱
public async Task SendMailEditEmail(Guid userId, string userName, string emailAddress, int verificationCode) public async Task SendMailEditEmail(Guid userId, string userName, string emailAddress, int verificationCode)
{ {

View File

@ -21,6 +21,9 @@ namespace IRaCIS.Application.Contracts
{ {
public string UserName { get; set; } = string.Empty; public string UserName { get; set; } = string.Empty;
public string Password { get; set; } = string.Empty; public string Password { get; set; } = string.Empty;
public Guid? UserId { get; set; }
public string MFACode { get; set; } = string.Empty;
} }
public class LoginReturnDTO public class LoginReturnDTO
@ -28,6 +31,8 @@ namespace IRaCIS.Application.Contracts
public UserBasicInfo BasicInfo { get; set; } = new UserBasicInfo(); public UserBasicInfo BasicInfo { get; set; } = new UserBasicInfo();
public string JWTStr { get; set; }=string.Empty; public string JWTStr { get; set; }=string.Empty;
public bool IsMFA { get; set; } = false;
} }
public class UserBasicInfo public class UserBasicInfo
@ -59,7 +64,7 @@ namespace IRaCIS.Application.Contracts
public string PermissionStr { get; set; } = String.Empty; public string PermissionStr { get; set; } = String.Empty;
public string EMail { get; set; } = string.Empty;
public bool IsFirstAdd { get; set; } public bool IsFirstAdd { get; set; }
public bool IsReviewer { get; set; } = false; public bool IsReviewer { get; set; } = false;

View File

@ -10,6 +10,10 @@ namespace IRaCIS.Application.Services
Task<UserDetailDTO> GetUser(Guid id); Task<UserDetailDTO> GetUser(Guid id);
Task<PageOutput<UserListDTO>> GetUserList(UserListQueryDTO param); Task<PageOutput<UserListDTO>> GetUserList(UserListQueryDTO param);
Task<IResponseOutput<LoginReturnDTO>> Login(string userName, string password); Task<IResponseOutput<LoginReturnDTO>> Login(string userName, string password);
Task<IResponseOutput> VerifyMFACodeAsync(Guid userId, string Code);
Task<IResponseOutput> SendMFAEmail(Guid userId);
Task<UserBasicInfo> GetUserBasicInfo(Guid userId);
Task<IResponseOutput> ModifyPassword(EditPasswordCommand editPwModel); Task<IResponseOutput> ModifyPassword(EditPasswordCommand editPwModel);
Task<IResponseOutput> ResetPassword(Guid userId); Task<IResponseOutput> ResetPassword(Guid userId);

View File

@ -15,6 +15,9 @@ using Medallion.Threading;
using EasyCaching.Core; using EasyCaching.Core;
using IRaCIS.Core.Application.Contracts; using IRaCIS.Core.Application.Contracts;
using LoginReturnDTO = IRaCIS.Application.Contracts.LoginReturnDTO; using LoginReturnDTO = IRaCIS.Application.Contracts.LoginReturnDTO;
using IRaCIS.Core.Application.Auth;
using BeetleX.Redis.Commands;
using IRaCIS.Core.Domain.Models;
namespace IRaCIS.Application.Services namespace IRaCIS.Application.Services
{ {
@ -639,7 +642,70 @@ namespace IRaCIS.Application.Services
} }
public async Task<UserBasicInfo> GetUserBasicInfo(Guid userId)
{
var info = await _userRepository.Where(u => u.Id == userId).ProjectTo<UserBasicInfo>(_mapper.ConfigurationProvider).FirstNotNullAsync();
return info;
}
/// <summary>
/// 发送MFA 验证邮件
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
[AllowAnonymous]
public async Task<IResponseOutput> SendMFAEmail(Guid userId)
{
var userInfo = await _userRepository.Where(u => u.Id == userId).Select(t => new { t.FullName, t.EMail }).FirstOrDefaultAsync();
int verificationCode = new Random().Next(100000, 1000000);
await _mailVerificationService.SenMFAVerifyEmail(userId, userInfo.FullName, userInfo.EMail, verificationCode);
return ResponseOutput.Ok();
}
/// <summary>
/// 验证MFA 邮件
/// </summary>
/// <param name="userId"></param>
/// <param name="Code"></param>
/// <returns></returns>
/// <exception cref="BusinessValidationFailedException"></exception>
[AllowAnonymous]
public async Task<IResponseOutput> VerifyMFACodeAsync(Guid userId, string Code)
{
var verificationRecord = await _repository.GetQueryable<VerificationCode>().OrderByDescending(x => x.ExpirationTime).Where(t => t.UserId == userId && t.Code == Code && t.CodeType == VerifyType.Email).FirstOrDefaultAsync();
VerifyEmialGetDoctorInfoOutDto result = new VerifyEmialGetDoctorInfoOutDto();
//检查数据库是否存在该验证码
if (verificationRecord == null)
{
await _userLogRepository.AddAsync(new UserLog() { IP = _userInfo.IP, LoginUserId = userId, OptUserId = userId, OptType = UserOptType.MFALoginFail }, true);
//---验证码错误。
throw new BusinessValidationFailedException(_localizer["TrialSiteSurvey_WrongVerificationCode"]);
}
else
{
//检查验证码是否失效
if (verificationRecord.ExpirationTime < DateTime.Now)
{
await _userLogRepository.AddAsync(new UserLog() { IP = _userInfo.IP, LoginUserId = userId, OptUserId = userId, OptType = UserOptType.MFALoginFail }, true);
//---验证码已经过期。
throw new BusinessValidationFailedException(_localizer["TrialSiteSurvey_ExpiredVerificationCode"]);
}
else //验证码正确 并且 没有超时
{
await _userLogRepository.AddAsync(new UserLog() { IP = _userInfo.IP, LoginUserId = userId, OptUserId = userId, OptType = UserOptType.MFALogin }, true);
}
}
return ResponseOutput.Ok();
}
/// <summary> /// <summary>
/// 用户登陆 /// 用户登陆
@ -696,8 +762,6 @@ namespace IRaCIS.Application.Services
return ResponseOutput.NotOk(_localizer["User_CheckNameOrPw"], new LoginReturnDTO()); return ResponseOutput.NotOk(_localizer["User_CheckNameOrPw"], new LoginReturnDTO());
} }
if (loginUser.Status == 0) if (loginUser.Status == 0)
@ -751,14 +815,6 @@ namespace IRaCIS.Application.Services
}); });
// 登录 清除缓存
//_cache.Remove(userLoginReturnModel.BasicInfo.Id.ToString());
var userId = loginUser.Id;
await _cache.SetAsync($"{userId.ToString()}_Online", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), TimeSpan.FromMinutes(_verifyConfig.CurrentValue.AutoLoginOutMinutes));
return ResponseOutput.Ok(userLoginReturnModel); return ResponseOutput.Ok(userLoginReturnModel);
} }

View File

@ -93,7 +93,11 @@ namespace IRaCIS.Core.Domain.Models
DeleteUser=10, DeleteUser=10,
UpdateUser=11 UpdateUser=11,
MFALogin=12,
MFALoginFail=13,
} }
} }

View File

@ -22,6 +22,8 @@ namespace IRaCIS.Core.Domain.Share
public int LoginFailLockMinutes { get; set; } public int LoginFailLockMinutes { get; set; }
public int AutoLoginOutMinutes { get; set; } public int AutoLoginOutMinutes { get; set; }
public bool OpenLoginMFA { get; set; }
} }
public class SystemEmailSendConfig public class SystemEmailSendConfig