diff --git a/DysonNetwork.Sphere/Account/AccountService.cs b/DysonNetwork.Sphere/Account/AccountService.cs index ccc6523..62acb34 100644 --- a/DysonNetwork.Sphere/Account/AccountService.cs +++ b/DysonNetwork.Sphere/Account/AccountService.cs @@ -1,9 +1,14 @@ using System.Globalization; using DysonNetwork.Sphere.Auth; +using DysonNetwork.Sphere.Email; +using DysonNetwork.Sphere.Localization; +using DysonNetwork.Sphere.Pages.Emails; using DysonNetwork.Sphere.Storage; using EFCore.BulkExtensions; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Localization; using NodaTime; +using Org.BouncyCastle.Utilities; using OtpNet; namespace DysonNetwork.Sphere.Account; @@ -12,7 +17,10 @@ public class AccountService( AppDatabase db, MagicSpellService spells, NotificationService nty, - ICacheService cache + EmailService email, + IStringLocalizer localizer, + ICacheService cache, + ILogger logger ) { public static void SetCultureInfo(Account account) @@ -26,7 +34,7 @@ public class AccountService( CultureInfo.CurrentCulture = info; CultureInfo.CurrentUICulture = info; } - + public const string AccountCachePrefix = "account:"; public async Task PurgeAccountCache(Account account) @@ -192,6 +200,119 @@ public class AccountService( await db.SaveChangesAsync(); } + /// + /// Send the auth factor verification code to users, for factors like in-app code and email. + /// Sometimes it requires a hint, like a part of the user's email address to ensure the user is who own the account. + /// + /// The owner of the auth factor + /// The auth factor needed to send code + /// The part of the contact method for verification + public async Task SendFactorCode(Account account, AccountAuthFactor factor, string? hint = null) + { + var code = new Random().Next(100000, 999999).ToString("000000"); + + switch (factor.Type) + { + case AccountAuthFactorType.InAppCode: + if (await _GetFactorCode(factor) is not null) + throw new InvalidOperationException("A factor code has been sent and in active duration."); + + await nty.SendNotification( + account, + "auth.verification", + localizer["AuthCodeTitle"], + null, + localizer["AuthCodeBody", code], + save: true + ); + await _SetFactorCode(factor, code, TimeSpan.FromMinutes(5)); + break; + case AccountAuthFactorType.EmailCode: + if (await _GetFactorCode(factor) is not null) + throw new InvalidOperationException("A factor code has been sent and in active duration."); + + ArgumentNullException.ThrowIfNull(hint); + hint = hint.Replace("@", "").Replace(".", "").Replace("+", "").Replace("%", ""); + if (string.IsNullOrWhiteSpace(hint)) + { + logger.LogWarning( + "Unable to send factor code to #{FactorId} with hint {Hint}, due to invalid hint...", + factor.Id, + hint + ); + return; + } + + var contact = await db.AccountContacts + .Where(c => c.Type == AccountContactType.Email) + .Where(c => c.VerifiedAt != null) + .Where(c => EF.Functions.ILike(c.Content, $"%{hint}%")) + .Include(c => c.Account) + .FirstOrDefaultAsync(); + if (contact is null) + { + logger.LogWarning( + "Unable to send factor code to #{FactorId} with hint {Hint}, due to no contact method found according to hint...", + factor.Id, + hint + ); + return; + } + + await email.SendTemplatedEmailAsync( + contact.Content, + localizer["EmailVerificationTitle"], + localizer["VerificationEmail"], + new VerificationEmailModel + { + Name = account.Name, + Code = code + } + ); + + await _SetFactorCode(factor, code, TimeSpan.FromMinutes(30)); + break; + case AccountAuthFactorType.Password: + case AccountAuthFactorType.TimedCode: + default: + // No need to send, such as password etc... + return; + } + } + + public async Task VerifyFactorCode(AccountAuthFactor factor, string code) + { + switch (factor.Type) + { + case AccountAuthFactorType.EmailCode: + case AccountAuthFactorType.InAppCode: + var correctCode = await _GetFactorCode(factor); + return correctCode is not null && string.Equals(correctCode, code, StringComparison.OrdinalIgnoreCase); + case AccountAuthFactorType.Password: + case AccountAuthFactorType.TimedCode: + default: + return factor.VerifyPassword(code); + } + } + + private const string AuthFactorCachePrefix = "authfactor:"; + + private async Task _SetFactorCode(AccountAuthFactor factor, string code, TimeSpan expires) + { + await cache.SetAsync( + $"{AuthFactorCachePrefix}{factor.Id}:code", + code, + expires + ); + } + + private async Task _GetFactorCode(AccountAuthFactor factor) + { + return await cache.GetAsync( + $"{AuthFactorCachePrefix}{factor.Id}:code" + ); + } + public async Task DeleteSession(Account account, Guid sessionId) { var session = await db.AuthSessions @@ -204,10 +325,10 @@ public class AccountService( .Include(s => s.Challenge) .Where(s => s.AccountId == session.Id && s.Challenge.DeviceId == session.Challenge.DeviceId) .ToListAsync(); - - if(session.Challenge.DeviceId is not null) + + if (session.Challenge.DeviceId is not null) await nty.UnsubscribePushNotifications(session.Challenge.DeviceId); - + // The current session should be included in the sessions' list db.AuthSessions.RemoveRange(sessions); await db.SaveChangesAsync(); diff --git a/DysonNetwork.Sphere/Account/NotificationService.cs b/DysonNetwork.Sphere/Account/NotificationService.cs index 5b76518..646acc7 100644 --- a/DysonNetwork.Sphere/Account/NotificationService.cs +++ b/DysonNetwork.Sphere/Account/NotificationService.cs @@ -67,13 +67,12 @@ public class NotificationService( string? subtitle = null, string? content = null, Dictionary? meta = null, - bool isSilent = false + bool isSilent = false, + bool save = true ) { if (title is null && subtitle is null && content is null) - { throw new ArgumentException("Unable to send notification that completely empty."); - } var notification = new Notification { @@ -85,8 +84,11 @@ public class NotificationService( AccountId = account.Id, }; - db.Add(notification); - await db.SaveChangesAsync(); + if (save) + { + db.Add(notification); + await db.SaveChangesAsync(); + } if (!isSilent) _ = DeliveryNotification(notification); diff --git a/DysonNetwork.Sphere/Auth/AuthController.cs b/DysonNetwork.Sphere/Auth/AuthController.cs index 42a14e5..1027240 100644 --- a/DysonNetwork.Sphere/Auth/AuthController.cs +++ b/DysonNetwork.Sphere/Auth/AuthController.cs @@ -87,7 +87,11 @@ public class AuthController( } [HttpPost("challenge/{id:guid}/factors/{factorId:guid}")] - public async Task RequestFactorCode([FromRoute] Guid id, [FromRoute] Guid factorId) + public async Task RequestFactorCode( + [FromRoute] Guid id, + [FromRoute] Guid factorId, + [FromBody] string? hint + ) { var challenge = await db.AuthChallenges .Include(e => e.Account) @@ -98,7 +102,14 @@ public class AuthController( .Where(e => e.Account == challenge.Account).FirstOrDefaultAsync(); if (factor is null) return NotFound("Auth factor was not found."); - // TODO do the logic here + try + { + await accounts.SendFactorCode(challenge.Account, factor, hint); + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } return Ok(); } @@ -127,7 +138,7 @@ public class AuthController( try { - if (factor.VerifyPassword(request.Password)) + if (await accounts.VerifyFactorCode(factor, request.Password)) { challenge.StepRemain--; challenge.BlacklistFactors.Add(factor.Id); @@ -226,8 +237,8 @@ public class AuthController( var tk = auth.CreateToken(session); return Ok(new TokenExchangeResponse { Token = tk }); case "refresh_token": - // Since we no longer need the refresh token - // This case is blank for now, thinking to mock it if the OIDC standard requires it + // Since we no longer need the refresh token + // This case is blank for now, thinking to mock it if the OIDC standard requires it default: return BadRequest("Unsupported grant type."); } diff --git a/DysonNetwork.Sphere/Email/EmailModels.cs b/DysonNetwork.Sphere/Email/EmailModels.cs index 9864c7b..355f6d1 100644 --- a/DysonNetwork.Sphere/Email/EmailModels.cs +++ b/DysonNetwork.Sphere/Email/EmailModels.cs @@ -16,4 +16,10 @@ public class PasswordResetEmailModel { public required string Name { get; set; } public required string Link { get; set; } +} + +public class VerificationEmailModel +{ + public required string Name { get; set; } + public required string Code { get; set; } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Pages/Emails/VerificationEmail.razor b/DysonNetwork.Sphere/Pages/Emails/VerificationEmail.razor new file mode 100644 index 0000000..f7d2fdf --- /dev/null +++ b/DysonNetwork.Sphere/Pages/Emails/VerificationEmail.razor @@ -0,0 +1,62 @@ +@using DysonNetwork.Sphere.Localization +@using Microsoft.Extensions.Localization +@using EmailResource = DysonNetwork.Sphere.Localization.EmailResource + + + + + + + + + + + + + + + + + + +
+

+ @(Localizer["VerificationHeader1"]) +

+
+

+ @(Localizer["VerificationPara1"]) @Name, +

+

+ @(Localizer["VerificationPara2"]) +

+

+ @(Localizer["VerificationPara3"]) +

+
+
+
+ @Code +
+
+
+

+ @(Localizer["VerificationPara4"]) +

+

+ @(Localizer["VerificationPara5"]) +

+

+ @(LocalizerShared["EmailFooter1"])
+ @(LocalizerShared["EmailFooter2"]) +

+
+
+ +@code { + [Parameter] public required string Name { get; set; } + [Parameter] public required string Code { get; set; } + + [Inject] IStringLocalizer Localizer { get; set; } = null!; + [Inject] IStringLocalizer LocalizerShared { get; set; } = null!; +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Resources/Localization/EmailResource.resx b/DysonNetwork.Sphere/Resources/Localization/EmailResource.resx index 0581dca..889be82 100644 --- a/DysonNetwork.Sphere/Resources/Localization/EmailResource.resx +++ b/DysonNetwork.Sphere/Resources/Localization/EmailResource.resx @@ -81,4 +81,25 @@ If you didn't request this, you can ignore this email safety. + + Verify Your Email Address + + + Dear, + + + Thank you for creating an account on the Solar Network. We're excited to have you join our community! + + + To verify your email address and access all features of your account, please use the verification code below: + + + This code will expire in 30 minutes. Please enter it on the verification page to complete your registration. + + + If you didn't create this account, please ignore this email. + + + Verify your email address + \ No newline at end of file diff --git a/DysonNetwork.Sphere/Resources/Localization/EmailResource.zh-hans.resx b/DysonNetwork.Sphere/Resources/Localization/EmailResource.zh-hans.resx index d61581b..d782a74 100644 --- a/DysonNetwork.Sphere/Resources/Localization/EmailResource.zh-hans.resx +++ b/DysonNetwork.Sphere/Resources/Localization/EmailResource.zh-hans.resx @@ -74,4 +74,25 @@ 重置您的密码 + + 验证您的电子邮箱 + + + 尊敬的 + + + 感谢您在 Solar Network 上注册账号,我们很高兴您即将加入我们的社区! + + + 请使用以下验证码来验证您的电子邮箱并获取账号的所有功能: + + + 此验证码将在30分钟后失效。请在验证页面输入此验证码以完成注册。 + + + 如果您并未创建此账号,请忽略此邮件。 + + + 验证您的电子邮箱 + \ No newline at end of file diff --git a/DysonNetwork.Sphere/Resources/Localization/NotificationResource.Designer.cs b/DysonNetwork.Sphere/Resources/Localization/NotificationResource.Designer.cs index fc2a062..545ae65 100644 --- a/DysonNetwork.Sphere/Resources/Localization/NotificationResource.Designer.cs +++ b/DysonNetwork.Sphere/Resources/Localization/NotificationResource.Designer.cs @@ -98,5 +98,17 @@ namespace DysonNetwork.Sphere.Resources.Localization { return ResourceManager.GetString("PostReactContentBody", resourceCulture); } } + + internal static string AuthCodeTitle { + get { + return ResourceManager.GetString("AuthCodeTitle", resourceCulture); + } + } + + internal static string AuthCodeBody { + get { + return ResourceManager.GetString("AuthCodeBody", resourceCulture); + } + } } } diff --git a/DysonNetwork.Sphere/Resources/Localization/NotificationResource.resx b/DysonNetwork.Sphere/Resources/Localization/NotificationResource.resx index a6ab5db..63e663c 100644 --- a/DysonNetwork.Sphere/Resources/Localization/NotificationResource.resx +++ b/DysonNetwork.Sphere/Resources/Localization/NotificationResource.resx @@ -45,4 +45,10 @@ {0} added a reaction {1} to your post {2} + + Disposable Verification Code + + + {0} is your disposable code, it will expires in 5 minutes + \ No newline at end of file diff --git a/DysonNetwork.Sphere/Resources/Localization/NotificationResource.zh-hans.resx b/DysonNetwork.Sphere/Resources/Localization/NotificationResource.zh-hans.resx index a078557..234481c 100644 --- a/DysonNetwork.Sphere/Resources/Localization/NotificationResource.zh-hans.resx +++ b/DysonNetwork.Sphere/Resources/Localization/NotificationResource.zh-hans.resx @@ -38,4 +38,10 @@ {0} 给你的帖子添加了一个 {1} 的反应 {2} + + 一次性验证码 + + + {0} 是你的一次性验证码,它将会在五分钟内过期 + \ No newline at end of file diff --git a/DysonNetwork.sln.DotSettings.user b/DysonNetwork.sln.DotSettings.user index 43f6825..365dc15 100644 --- a/DysonNetwork.sln.DotSettings.user +++ b/DysonNetwork.sln.DotSettings.user @@ -15,6 +15,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded @@ -106,8 +107,8 @@ False True True - False - True + True + False