Implementation of email code and in app code

This commit is contained in:
LittleSheep 2025-06-07 02:16:13 +08:00
parent 3c123be6a7
commit af39694be6
11 changed files with 286 additions and 17 deletions

View File

@ -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<NotificationResource> localizer,
ICacheService cache,
ILogger<AccountService> 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();
}
/// <summary>
/// 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.
/// </summary>
/// <param name="account">The owner of the auth factor</param>
/// <param name="factor">The auth factor needed to send code</param>
/// <param name="hint">The part of the contact method for verification</param>
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<VerificationEmail, VerificationEmailModel>(
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<bool> 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<string?> _GetFactorCode(AccountAuthFactor factor)
{
return await cache.GetAsync<string?>(
$"{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();

View File

@ -67,13 +67,12 @@ public class NotificationService(
string? subtitle = null,
string? content = null,
Dictionary<string, object>? 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);

View File

@ -87,7 +87,11 @@ public class AuthController(
}
[HttpPost("challenge/{id:guid}/factors/{factorId:guid}")]
public async Task<ActionResult> RequestFactorCode([FromRoute] Guid id, [FromRoute] Guid factorId)
public async Task<ActionResult> 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.");
}

View File

@ -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; }
}

View File

@ -0,0 +1,62 @@
@using DysonNetwork.Sphere.Localization
@using Microsoft.Extensions.Localization
@using EmailResource = DysonNetwork.Sphere.Localization.EmailResource
<EmailLayout>
<table class="container">
<tr>
<td class="columns">
<h1 style="font-size: 1.875rem; font-weight: 700; color: #111827; margin: 0; text-align: center;">
@(Localizer["VerificationHeader1"])
</h1>
</td>
</tr>
<tr>
<td class="columns">
<p style="color: #374151; margin: 0;">
@(Localizer["VerificationPara1"]) @Name,
</p>
<p style="color: #374151; margin: 0;">
@(Localizer["VerificationPara2"])
</p>
<p style="color: #374151; margin: 0;">
@(Localizer["VerificationPara3"])
</p>
</td>
</tr>
<tr>
<td class="columns">
<div style="text-align: center;">
<div style="background-color: #f3f4f6; padding: 1rem; border-radius: 0.5rem; display: inline-block; margin: 1rem 0;">
<span style="font-size: 1.5rem; font-weight: 600; color: #111827; letter-spacing: 0.1em;">@Code</span>
</div>
</div>
</td>
</tr>
<tr>
<td class="columns">
<p style="color: #374151; margin: 0;">
@(Localizer["VerificationPara4"])
</p>
<p style="color: #374151; margin: 0;">
@(Localizer["VerificationPara5"])
</p>
<p style="color: #374151; margin: 2rem 0 0 0;">
@(LocalizerShared["EmailFooter1"]) <br />
@(LocalizerShared["EmailFooter2"])
</p>
</td>
</tr>
</table>
</EmailLayout>
@code {
[Parameter] public required string Name { get; set; }
[Parameter] public required string Code { get; set; }
[Inject] IStringLocalizer<EmailResource> Localizer { get; set; } = null!;
[Inject] IStringLocalizer<SharedResource> LocalizerShared { get; set; } = null!;
}

View File

@ -81,4 +81,25 @@
<data name="PasswordResetPara4" xml:space="preserve">
<value>If you didn't request this, you can ignore this email safety.</value>
</data>
<data name="VerificationHeader1" xml:space="preserve">
<value>Verify Your Email Address</value>
</data>
<data name="VerificationPara1" xml:space="preserve">
<value>Dear, </value>
</data>
<data name="VerificationPara2" xml:space="preserve">
<value>Thank you for creating an account on the Solar Network. We're excited to have you join our community!</value>
</data>
<data name="VerificationPara3" xml:space="preserve">
<value>To verify your email address and access all features of your account, please use the verification code below:</value>
</data>
<data name="VerificationPara4" xml:space="preserve">
<value>This code will expire in 30 minutes. Please enter it on the verification page to complete your registration.</value>
</data>
<data name="VerificationPara5" xml:space="preserve">
<value>If you didn't create this account, please ignore this email.</value>
</data>
<data name="EmailVerificationTitle" xml:space="preserve">
<value>Verify your email address</value>
</data>
</root>

View File

@ -74,4 +74,25 @@
<data name="EmailPasswordResetTitle" xml:space="preserve">
<value>重置您的密码</value>
</data>
<data name="VerificationHeader1" xml:space="preserve">
<value>验证您的电子邮箱</value>
</data>
<data name="VerificationPara1" xml:space="preserve">
<value>尊敬的 </value>
</data>
<data name="VerificationPara2" xml:space="preserve">
<value>感谢您在 Solar Network 上注册账号,我们很高兴您即将加入我们的社区!</value>
</data>
<data name="VerificationPara3" xml:space="preserve">
<value>请使用以下验证码来验证您的电子邮箱并获取账号的所有功能:</value>
</data>
<data name="VerificationPara4" xml:space="preserve">
<value>此验证码将在30分钟后失效。请在验证页面输入此验证码以完成注册。</value>
</data>
<data name="VerificationPara5" xml:space="preserve">
<value>如果您并未创建此账号,请忽略此邮件。</value>
</data>
<data name="EmailVerificationTitle" xml:space="preserve">
<value>验证您的电子邮箱</value>
</data>
</root>

View File

@ -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);
}
}
}
}

View File

@ -45,4 +45,10 @@
<data name="PostReactContentBody" xml:space="preserve">
<value>{0} added a reaction {1} to your post {2}</value>
</data>
<data name="AuthCodeTitle" xml:space="preserve">
<value>Disposable Verification Code</value>
</data>
<data name="AuthCodeBody" xml:space="preserve">
<value>{0} is your disposable code, it will expires in 5 minutes</value>
</data>
</root>

View File

@ -38,4 +38,10 @@
<data name="PostReactContentBody" xml:space="preserve">
<value>{0} 给你的帖子添加了一个 {1} 的反应 {2}</value>
</data>
<data name="AuthCodeTitle" xml:space="preserve">
<value>一次性验证码</value>
</data>
<data name="AuthCodeBody" xml:space="preserve">
<value>{0} 是你的一次性验证码,它将会在五分钟内过期</value>
</data>
</root>

View File

@ -15,6 +15,7 @@
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADailyTimeIntervalScheduleBuilder_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F929ef51651404d13aacd3eb8198d2961e4800_003F2b_003Ff86eadcb_003FDailyTimeIntervalScheduleBuilder_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADailyTimeIntervalTriggerBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F929ef51651404d13aacd3eb8198d2961e4800_003F5c_003F297b8312_003FDailyTimeIntervalTriggerBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADbContext_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FSourcesCache_003Fa0b45f29f34f594814a7b1fbc25fe5ef3c18257956ed4f4fbfa68717db58_003FDbContext_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADbFunctionsExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FSourcesCache_003Fc1c46ed28c61e1caa79185e4375a8ae7cd11cd5ba8853dcb37577f93f2ca8d5_003FDbFunctionsExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADiagnosticServiceCollectionExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2025_002E1_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F47e01f36dea14a23aaea6e0391c1347ace00_003F3c_003F140e6d8b_003FDiagnosticServiceCollectionExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003ADirectory_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003Fb6f0571a6bc744b0b551fd4578292582e54c00_003Fde_003F94973e27_003FDirectory_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
<s:String x:Key="/Default/CodeInspection/ExcludedFiles/FilesAndFoldersToSkip2/=7020124F_002D9FFC_002D4AC3_002D8F3D_002DAAB8E0240759_002Ff_003AEndpointConventionBuilderExtensions_002Ecs_002Fl_003A_002E_002E_003F_002E_002E_003F_002E_002E_003FLibrary_003FApplication_0020Support_003FJetBrains_003FRider2024_002E3_003Fresharper_002Dhost_003FDecompilerCache_003Fdecompiler_003F8bb08a178b5b43c5bac20a5a54159a5b2a800_003F8a_003F101938e3_003FEndpointConventionBuilderExtensions_002Ecs/@EntryIndexedValue">ForceIncluded</s:String>
@ -106,8 +107,8 @@
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmail_002ELandingResource/@EntryIndexedValue">False</s:Boolean>
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmails_002FEmail_002ELandingResource/@EntryIndexRemoved">True</s:Boolean>
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmail_002ELandingResource/@EntryIndexRemoved">True</s:Boolean>
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmailResource/@EntryIndexedValue">False</s:Boolean>
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FNotificationResource/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FEmailResource/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/ResxEditorPersonal/CheckedGroups/=DysonNetwork_002ESphere_002FResources_002FLocalization_002FNotificationResource/@EntryIndexedValue">False</s:Boolean>