Added mail system. Added password reset and email verify. Switched to new event system. Added event based mail sending

This commit is contained in:
Marcel Baumgartner
2023-10-16 17:13:15 +02:00
parent 49c893f515
commit 0cde0fe302
21 changed files with 538 additions and 180 deletions

View File

@@ -0,0 +1,9 @@
using Moonlight.App.Database.Entities;
namespace Moonlight.App.Event.Args;
public class MailVerificationEventArgs
{
public User User { get; set; }
public string Jwt { get; set; }
}

View File

@@ -0,0 +1,12 @@
using Moonlight.App.Database.Entities;
using Moonlight.App.Event.Args;
namespace Moonlight.App.Event;
public class Events
{
public static EventHandler<User> OnUserRegistered;
public static EventHandler<User> OnUserPasswordChanged;
public static EventHandler<User> OnUserTotpSet;
public static EventHandler<MailVerificationEventArgs> OnUserMailVerify;
}

View File

@@ -0,0 +1,37 @@
namespace Moonlight.App.Extensions;
public static class EventHandlerExtensions
{
public static async Task InvokeAsync(this EventHandler handler)
{
var tasks = handler
.GetInvocationList()
.Select(x => new Task(() => x.DynamicInvoke(null, null)))
.ToArray();
foreach (var task in tasks)
{
task.Start();
}
await Task.WhenAll(tasks);
}
public static async Task InvokeAsync<T>(this EventHandler<T>? handler, T? data = default(T))
{
if(handler == null)
return;
var tasks = handler
.GetInvocationList()
.Select(x => new Task(() => x.DynamicInvoke(null, data)))
.ToArray();
foreach (var task in tasks)
{
task.Start();
}
await Task.WhenAll(tasks);
}
}

View File

@@ -1,140 +0,0 @@
using System.Diagnostics;
using Moonlight.App.Models.Abstractions;
namespace Moonlight.App.Helpers;
public class EventSystem
{
private readonly List<Subscriber> Subscribers = new();
private readonly bool Debug = false;
private readonly bool DisableWarning = false;
private readonly TimeSpan TookToLongTime = TimeSpan.FromSeconds(1);
public Task On<T>(string id, object handle, Func<T, Task> action)
{
if (Debug)
Logger.Debug($"{handle} subscribed to '{id}'");
lock (Subscribers)
{
if (!Subscribers.Any(x => x.Id == id && x.Handle == handle))
{
Subscribers.Add(new()
{
Action = action,
Handle = handle,
Id = id
});
}
}
return Task.CompletedTask;
}
public Task Emit(string id, object? data = null)
{
Subscriber[] subscribers;
lock (Subscribers)
{
subscribers = Subscribers
.Where(x => x.Id == id)
.ToArray();
}
var tasks = new List<Task>();
foreach (var subscriber in subscribers)
{
tasks.Add(new Task(() =>
{
var stopWatch = new Stopwatch();
stopWatch.Start();
var del = (Delegate)subscriber.Action;
try
{
((Task)del.DynamicInvoke(data)!).Wait();
}
catch (Exception e)
{
Logger.Warn($"Error emitting '{subscriber.Id} on {subscriber.Handle}'");
Logger.Warn(e);
}
stopWatch.Stop();
if (!DisableWarning)
{
if (stopWatch.Elapsed.TotalMilliseconds > TookToLongTime.TotalMilliseconds)
{
Logger.Warn(
$"Subscriber {subscriber.Handle} for event '{subscriber.Id}' took long to process. {stopWatch.Elapsed.TotalMilliseconds}ms");
}
}
if (Debug)
{
Logger.Debug(
$"Subscriber {subscriber.Handle} for event '{subscriber.Id}' took {stopWatch.Elapsed.TotalMilliseconds}ms");
}
}));
}
foreach (var task in tasks)
{
task.Start();
}
Task.Run(() =>
{
Task.WaitAll(tasks.ToArray());
if (Debug)
Logger.Debug($"Completed all event tasks for '{id}' and removed object from storage");
});
if (Debug)
Logger.Debug($"Completed event emit '{id}'");
return Task.CompletedTask;
}
public Task Off(string id, object handle)
{
if (Debug)
Logger.Debug($"{handle} unsubscribed to '{id}'");
lock (Subscribers)
{
Subscribers.RemoveAll(x => x.Id == id && x.Handle == handle);
}
return Task.CompletedTask;
}
public Task<T> WaitForEvent<T>(string id, object handle, Func<T, bool>? filter = null)
{
var taskCompletionSource = new TaskCompletionSource<T>();
Func<T, Task> action = async data =>
{
if (filter == null)
{
taskCompletionSource.SetResult(data);
await Off(id, handle);
}
else if (filter.Invoke(data))
{
taskCompletionSource.SetResult(data);
await Off(id, handle);
}
};
On<T>(id, handle, action);
return taskCompletionSource.Task;
}
}

View File

@@ -0,0 +1,64 @@
using Microsoft.AspNetCore.Mvc;
using Moonlight.App.Database.Entities;
using Moonlight.App.Models.Enums;
using Moonlight.App.Repositories;
using Moonlight.App.Services;
using Moonlight.App.Services.Utils;
namespace Moonlight.App.Http.Controllers.Api.Auth;
[ApiController]
[Route("api/auth/reset")]
public class ResetController : Controller
{
private readonly Repository<User> UserRepository;
private readonly IdentityService IdentityService;
private readonly JwtService JwtService;
public ResetController(Repository<User> userRepository, IdentityService identityService, JwtService jwtService)
{
UserRepository = userRepository;
IdentityService = identityService;
JwtService = jwtService;
}
[HttpGet]
public async Task<ActionResult> Get([FromQuery] string token)
{
// Validate token
if (!await JwtService.Validate(token))
return Redirect("/password-reset");
var data = await JwtService.Decode(token);
if (!data.ContainsKey("accountToReset"))
return Redirect("/password-reset");
var userId = int.Parse(data["accountToReset"]);
var user = UserRepository
.Get()
.FirstOrDefault(x => x.Id == userId);
// User may have been deleted, so we check here
if (user == null)
return Redirect("/password-reset");
// In order to allow the user to get access to the change password screen
// we need to authenticate him so we can read his flags.
// That's why we are creating a session here
var sessionToken = await IdentityService.GenerateToken(user);
// Authenticate the current identity service instance in order to
// get access to the flags field.
await IdentityService.Authenticate(sessionToken);
IdentityService.Flags[UserFlag.PasswordPending] = true;
await IdentityService.SaveFlags();
// Make the user login so he can reach the change password screen
Response.Cookies.Append("token", sessionToken);
return Redirect("/");
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.AspNetCore.Mvc;
using Moonlight.App.Helpers;
using Moonlight.App.Models.Enums;
using Moonlight.App.Services;
using Moonlight.App.Services.Utils;
namespace Moonlight.App.Http.Controllers.Api.Auth;
[ApiController]
[Route("api/auth/verify")]
public class VerifyController : Controller
{
private readonly IdentityService IdentityService;
private readonly JwtService JwtService;
public VerifyController(IdentityService identityService, JwtService jwtService)
{
IdentityService = identityService;
JwtService = jwtService;
}
[HttpGet]
public async Task<ActionResult> Get([FromQuery] string token)
{
await IdentityService.Authenticate(Request);
if (!IdentityService.IsSignedIn)
return Redirect("/login");
if (!await JwtService.Validate(token))
return Redirect("/login");
var data = await JwtService.Decode(token);
if (!data.ContainsKey("mailToVerify"))
return Redirect("/login");
var mailToVerify = data["mailToVerify"];
if (mailToVerify != IdentityService.CurrentUser.Email)
{
Logger.Warn($"User {IdentityService.CurrentUser.Email} tried to mail verify {mailToVerify} via verify api endpoint", "security");
return Redirect("/login");
}
IdentityService.Flags[UserFlag.MailVerified] = true;
await IdentityService.SaveFlags();
return Redirect("/");
}
}

View File

@@ -5,6 +5,6 @@ namespace Moonlight.App.Models.Forms;
public class ResetPasswordForm
{
[Required(ErrorMessage = "You need to specify an email address")]
[EmailAddress]
[EmailAddress(ErrorMessage = "You need to enter a valid email address")]
public string Email { get; set; } = "";
}

View File

@@ -0,0 +1,6 @@
namespace Moonlight.App.Models.Templates;
public class MailVerify
{
public string Url { get; set; } = "";
}

View File

@@ -0,0 +1,6 @@
namespace Moonlight.App.Models.Templates;
public class ResetPassword
{
public string Url { get; set; } = "";
}

View File

@@ -0,0 +1,23 @@
using Moonlight.App.Database.Entities;
using Moonlight.App.Event;
namespace Moonlight.App.Services.Background;
public class AutoMailSendService // This service is responsible for sending mails automatically
{
private readonly MailService MailService;
private readonly ConfigService ConfigService;
public AutoMailSendService(MailService mailService, ConfigService configService)
{
MailService = mailService;
ConfigService = configService;
Events.OnUserRegistered += OnUserRegistered;
}
private async void OnUserRegistered(object? _, User user)
{
await MailService.Send(user, $"Welcome {user.Username}", "welcome", user);
}
}

View File

@@ -0,0 +1,103 @@
using MailKit.Net.Smtp;
using MimeKit;
using Moonlight.App.Database.Entities;
using Moonlight.App.Helpers;
namespace Moonlight.App.Services;
public class MailService
{
private readonly ConfigService ConfigService;
private readonly string BasePath;
public MailService(ConfigService configService)
{
ConfigService = configService;
BasePath = PathBuilder.Dir("storage", "mail");
Directory.CreateDirectory(BasePath);
}
public async Task Send(User user, string title, string templateName, params object[] models)
{
var config = ConfigService.Get().MailServer;
try
{
// Build mail message
var message = new MimeMessage();
message.From.Add(new MailboxAddress(
"Moonlight System", //TODO: Replace with config option
config.Email
));
message.To.Add(new MailboxAddress(
$"{user.Username}",
user.Email
));
message.Subject = Formatter.ProcessTemplating(title, models);
var body = new BodyBuilder()
{
HtmlBody = await ParseTemplate(templateName, models)
};
message.Body = body.ToMessageBody();
// The actual sending will not be done in the mail thread to prevent long loading times
Task.Run(async () =>
{
using var smtpClient = new SmtpClient();
try
{
await smtpClient.ConnectAsync(config.Host, config.Port, config.UseSsl);
await smtpClient.AuthenticateAsync(config.Email, config.Password);
await smtpClient.SendAsync(message);
await smtpClient.DisconnectAsync(true);
}
catch (Exception e)
{
Logger.Warn("An unexpected error occured while connecting and transferring mail to mailserver");
Logger.Warn(e);
}
});
}
catch (FileNotFoundException)
{
// ignored as we log it anyways in the parse template function
}
catch (Exception e)
{
Logger.Warn("Unhandled error occured during sending mail:");
Logger.Warn(e);
}
}
private async Task<string> ParseTemplate(string templateName, params object[] models)
{
if (!File.Exists(PathBuilder.File(BasePath, templateName + ".html")))
{
Logger.Warn($"Mail template '{templateName}' is missing. Skipping sending mail");
throw new FileNotFoundException();
}
var text = await File.ReadAllTextAsync(
PathBuilder.File(BasePath, templateName + ".html")
);
// For details how the templating works, check out the explanation of the ProcessTemplating in the Formatter class
text = Formatter.ProcessTemplating(text, models);
return text;
}
// Helpers
public async Task Send(IEnumerable<User> users, string title, string templateName, params object[] models)
{
foreach (var user in users)
await Send(user, title, templateName, models);
}
}

View File

@@ -1,8 +1,11 @@
using Moonlight.App.Database.Entities;
using Moonlight.App.Event;
using Moonlight.App.Exceptions;
using Moonlight.App.Extensions;
using Moonlight.App.Helpers;
using Moonlight.App.Models.Abstractions;
using Moonlight.App.Models.Enums;
using Moonlight.App.Models.Templates;
using Moonlight.App.Repositories;
using Moonlight.App.Services.Utils;
using OtpNet;
@@ -12,20 +15,20 @@ namespace Moonlight.App.Services.Users;
public class UserAuthService
{
private readonly Repository<User> UserRepository;
//private readonly MailService MailService;
private readonly JwtService JwtService;
private readonly ConfigService ConfigService;
private readonly MailService MailService;
public UserAuthService(
Repository<User> userRepository,
//MailService mailService,
JwtService jwtService,
ConfigService configService)
ConfigService configService,
MailService mailService)
{
UserRepository = userRepository;
//MailService = mailService;
JwtService = jwtService;
ConfigService = configService;
MailService = mailService;
}
public async Task<User> Register(string username, string email, string password)
@@ -50,29 +53,24 @@ public class UserAuthService
};
var result = UserRepository.Add(user);
/*
await MailService.Send(
result,
"Welcome {{User.Username}}",
"register",
result
);*/
await Events.OnUserRegistered.InvokeAsync(result);
return result;
}
public Task ChangePassword(User user, string newPassword)
public async Task ChangePassword(User user, string newPassword)
{
user.Password = HashHelper.HashToString(newPassword);
user.TokenValidTimestamp = DateTime.UtcNow;
UserRepository.Update(user);
return Task.CompletedTask;
await Events.OnUserPasswordChanged.InvokeAsync(user);
}
public Task SeedTotp(User user)
{
var key = Base32Encoding.ToString(KeyGeneration.GenerateRandomKey(20));
var key = Base32Encoding.ToString(KeyGeneration.GenerateRandomKey(20));
user.TotpKey = key;
UserRepository.Update(user);
@@ -80,7 +78,7 @@ public class UserAuthService
return Task.CompletedTask;
}
public Task SetTotp(User user, bool state)
public async Task SetTotp(User user, bool state)
{
// Access to flags without identity service
var flags = new FlagStorage(user.Flags);
@@ -92,22 +90,19 @@ public class UserAuthService
UserRepository.Update(user);
return Task.CompletedTask;
await Events.OnUserTotpSet.InvokeAsync(user);
}
// Mails
public async Task SendVerification(User user)
{
var jwt = await JwtService.Create(data =>
{
data.Add("mailToVerify", user.Email);
}, TimeSpan.FromMinutes(10));
/*
var jwt = await JwtService.Create(data => { data.Add("mailToVerify", user.Email); }, TimeSpan.FromMinutes(10));
await MailService.Send(user, "Verify your account", "verifyMail", user, new MailVerify()
{
Url = ConfigService.Get().AppUrl + "/api/verify?token=" + jwt
});*/
Url = ConfigService.Get().AppUrl + "/api/auth/verify?token=" + jwt
});
}
public async Task SendResetPassword(string email)
@@ -119,14 +114,11 @@ public class UserAuthService
if (user == null)
throw new DisplayException("An account with that email was not found");
var jwt = await JwtService.Create(data =>
{
data.Add("accountToReset", user.Id.ToString());
});
/*
var jwt = await JwtService.Create(data => { data.Add("accountToReset", user.Id.ToString()); });
await MailService.Send(user, "Password reset for your account", "passwordReset", user, new ResetPassword()
{
Url = ConfigService.Get().AppUrl + "/api/reset?token=" + jwt
});*/
Url = ConfigService.Get().AppUrl + "/api/auth/reset?token=" + jwt
});
}
}

View File

@@ -24,6 +24,7 @@
<ItemGroup>
<PackageReference Include="Ben.Demystifier" Version="0.4.1" />
<PackageReference Include="JWT" Version="10.1.1" />
<PackageReference Include="MailKit" Version="4.2.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>

View File

@@ -4,6 +4,7 @@ using Moonlight.App.Helpers;
using Moonlight.App.Helpers.LogMigrator;
using Moonlight.App.Repositories;
using Moonlight.App.Services;
using Moonlight.App.Services.Background;
using Moonlight.App.Services.Interop;
using Moonlight.App.Services.Users;
using Moonlight.App.Services.Utils;
@@ -40,11 +41,15 @@ builder.Services.AddScoped<UserService>();
builder.Services.AddScoped<UserAuthService>();
builder.Services.AddScoped<UserDetailsService>();
// Services / Background
builder.Services.AddSingleton<AutoMailSendService>();
// Services
builder.Services.AddScoped<IdentityService>();
builder.Services.AddSingleton<ConfigService>();
builder.Services.AddSingleton<SessionService>();
builder.Services.AddSingleton<BucketService>();
builder.Services.AddSingleton<MailService>();
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
@@ -68,4 +73,7 @@ app.MapBlazorHub();
app.MapFallbackToPage("/_Host");
app.MapControllers();
// Auto start background services
app.Services.GetRequiredService<AutoMailSendService>();
app.Run();

View File

@@ -0,0 +1,65 @@
@using Moonlight.App.Services
@using Moonlight.App.Services.Users
@using Moonlight.App.Models.Forms
@using Moonlight.App.Models.Enums
@inject IdentityService IdentityService
@inject UserService UserService
<div class="w-100">
<div class="card-body">
<div class="text-start mb-8">
<h1 class="text-dark mb-3 fs-3x">
Change your password
</h1>
<div class="text-gray-400 fw-semibold fs-6">
You need to change your password in order to continue
</div>
</div>
<SmartForm Model="Form" OnValidSubmit="OnValidSubmit">
<div class="fv-row mb-7">
<input @bind="Form.Password" type="password" placeholder="Password" class="form-control form-control-solid">
</div>
<div class="fv-row mb-7">
<input @bind="Form.RepeatedPassword" type="password" placeholder="Repeat password" class="form-control form-control-solid">
</div>
<div class="d-flex flex-stack">
<button type="submit" class="btn btn-primary me-2 flex-shrink-0">Continue</button>
</div>
</SmartForm>
</div>
</div>
@code
{
private UpdateAccountPasswordForm Form = new();
private async Task OnValidSubmit()
{
if (Form.Password != Form.RepeatedPassword)
throw new DisplayException("The password do not match");
// Because of UserService.Auth.ChangePassword may logout the user before we can reset the flag
// we reset the flag before changing the password and if any error occurs we simple set it again
try
{
IdentityService.Flags[UserFlag.PasswordPending] = false;
await IdentityService.SaveFlags();
await UserService.Auth.ChangePassword(IdentityService.CurrentUser, Form.Password);
}
catch (Exception)
{
IdentityService.Flags[UserFlag.PasswordPending] = true;
await IdentityService.SaveFlags();
throw;
}
await IdentityService.Authenticate();
}
}

View File

@@ -29,7 +29,7 @@
</div>
<div class="d-flex flex-stack flex-wrap gap-3 fs-base fw-semibold mb-10">
<a href="/reset-password" class="link-primary">
<a href="/password-reset" class="link-primary">
Forgot Password ?
</a>
<a href="/register" class="link-primary">

View File

@@ -0,0 +1,46 @@
@using Moonlight.App.Services.Users
@using Moonlight.App.Services
@inject UserService UserService
@inject IdentityService IdentityService
<div class="w-100">
<div class="card-body">
@if (HasBeenSend)
{
<div class="text-start mb-8">
<h1 class="text-dark mb-3 fs-3x">
Email verification sent
</h1>
<div class="text-gray-400 fw-semibold fs-6">
You should receive an email shortly. If you see no email in your inbox, look inside your spam folder
</div>
</div>
}
else
{
<div class="text-start mb-8">
<h1 class="text-dark mb-3 fs-3x">
Verify your email address
</h1>
<div class="text-gray-400 fw-semibold fs-6">
We will sent you an email to verify your account
</div>
</div>
<WButton OnClick="Send" Text="Continue" CssClasses="btn btn-primary me-2 flex-shrink-0" />
}
</div>
</div>
@code
{
private bool HasBeenSend = false;
private async Task Send()
{
await UserService.Auth.SendVerification(IdentityService.CurrentUser);
HasBeenSend = true;
await InvokeAsync(StateHasChanged);
}
}

View File

@@ -0,0 +1,56 @@
@page "/password-reset"
@using Moonlight.App.Services.Users
@using Moonlight.App.Models.Forms
@inject UserService UserService
<div class="w-100">
<div class="card-body">
@if (HasBeenSend)
{
<div class="text-start mb-8">
<h1 class="text-dark mb-3 fs-3x">
Password reset email sent
</h1>
<div class="text-gray-400 fw-semibold fs-6">
You should receive the email shortly. If you see no email in your inbox, look inside your spam folder
</div>
</div>
}
else
{
<div class="text-start mb-8">
<h1 class="text-dark mb-3 fs-3x">
Reset your password
</h1>
<div class="text-gray-400 fw-semibold fs-6">
We will sent you an email to reset your account password
</div>
</div>
<SmartForm Model="Form" OnValidSubmit="OnValidSubmit">
<div class="fv-row mb-8">
<input @bind="Form.Email" type="text" placeholder="Email" class="form-control form-control-solid">
</div>
<div class="d-flex flex-stack">
<button type="submit" class="btn btn-primary me-2 flex-shrink-0">Continue</button>
</div>
</SmartForm>
}
</div>
</div>
@code
{
private bool HasBeenSend = false;
private ResetPasswordForm Form = new();
private async Task OnValidSubmit()
{
await UserService.Auth.SendResetPassword(Form.Email);
HasBeenSend = true;
await InvokeAsync(StateHasChanged);
}
}

View File

@@ -88,7 +88,7 @@
ErrorMessages.Add(displayException.Message);
}
else
throw e;
throw;
}
});

View File

@@ -37,7 +37,14 @@
<div class="menu-item px-3">
<div class="menu-content d-flex align-items-center px-3">
<div class="symbol symbol-50px me-5">
<img alt="Logo" src="/metronic8/demo38/assets/media/avatars/300-2.jpg">
@if (IdentityService.CurrentUser.Avatar == null)
{
<img src="/assets/img/avatar.png" alt="Avatar">
}
else
{
<img src="/api/bucket/avatars/@(IdentityService.CurrentUser.Avatar)" alt="Avatar">
}
</div>
<div class="d-flex flex-column">
<div class="fw-bold d-flex align-items-center fs-5">

View File

@@ -22,9 +22,15 @@
{
if (!IdentityService.Flags[UserFlag.MailVerified] && ConfigService.Get().Security.EnableEmailVerify)
{
<OverlayLayout>
<MailVerify />
</OverlayLayout>
}
else if (IdentityService.Flags[UserFlag.PasswordPending])
{
<OverlayLayout>
<ChangePassword />
</OverlayLayout>
}
else
{
@@ -43,6 +49,12 @@
<Register />
</OverlayLayout>
}
else if (url.LocalPath == "/password-reset")
{
<OverlayLayout>
<PasswordReset />
</OverlayLayout>
}
else
{
<OverlayLayout>