diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index 4e006af..fcf8b8e 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -12,6 +12,7 @@ namespace Foxnouns.Backend.Controllers.Authentication; [Route("/api/v2/auth/email")] public class EmailAuthController( DatabaseContext db, + Config config, AuthService authService, MailService mailService, KeyCacheService keyCacheService, @@ -24,9 +25,13 @@ public class EmailAuthController( [HttpPost("register")] public async Task RegisterAsync([FromBody] RegisterRequest req, CancellationToken ct = default) { + CheckRequirements(); + if (!req.Email.Contains('@')) throw new ApiError.BadRequest("Email is invalid", "email", req.Email); var state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, userId: null, ct); + + // If there's already a user with that email address, pretend we sent an email but actually ignore it if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Email && a.RemoteId == req.Email, ct)) return NoContent(); @@ -37,9 +42,12 @@ public class EmailAuthController( [HttpPost("callback")] public async Task CallbackAsync([FromBody] CallbackRequest req, CancellationToken ct = default) { + CheckRequirements(); + var state = await keyCacheService.GetRegisterEmailStateAsync(req.State, ct); if (state == null) throw new ApiError.BadRequest("Invalid state", "state", req.State); - + + // If this callback is for an existing user, add the email address to their auth methods if (state.ExistingUserId != null) { var authMethod = @@ -49,15 +57,49 @@ public class EmailAuthController( } var ticket = AuthUtils.RandomToken(); - await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20)); + await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20), ct); return Ok(new AuthController.CallbackResponse(false, ticket, state.Email)); } + [HttpPost("complete-registration")] + public async Task CompleteRegistrationAsync([FromBody] CompleteRegistrationRequest req, + CancellationToken ct = default) + { + var email = await keyCacheService.GetKeyAsync($"email:{req.Ticket}", ct: ct); + if (email == null) throw new ApiError.BadRequest("Unknown ticket", "ticket", req.Ticket); + + // Check if username is valid at all + ValidationUtils.Validate([("username", ValidationUtils.ValidateUsername(req.Username))]); + // Check if username is already taken + if (await db.Users.AnyAsync(u => u.Username == req.Username, ct)) + throw new ApiError.BadRequest("Username is already taken", "username", req.Username); + + var user = await authService.CreateUserWithPasswordAsync(req.Username, email, req.Password, ct); + var frontendApp = await db.GetFrontendApplicationAsync(ct); + + var (tokenStr, token) = + authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); + db.Add(token); + + await db.SaveChangesAsync(ct); + + // Specifically do *not* pass the CancellationToken so we don't cancel the rendering after creating the user account. + await keyCacheService.DeleteKeyAsync($"email:{req.Ticket}", ct: default); + + return Ok(new AuthController.AuthResponse( + await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: default), + tokenStr, + token.ExpiresAt + )); + } + [HttpPost("login")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task LoginAsync([FromBody] LoginRequest req, CancellationToken ct = default) { + CheckRequirements(); + var (user, authenticationResult) = await authService.AuthenticateUserAsync(req.Email, req.Password, ct); if (authenticationResult == AuthService.EmailAuthenticationResult.MfaRequired) throw new NotImplementedException("MFA is not implemented yet"); @@ -81,9 +123,17 @@ public class EmailAuthController( )); } + private void CheckRequirements() + { + if (!config.DiscordAuth.Enabled) + throw new ApiError.BadRequest("Email authentication is not enabled on this instance."); + } + public record LoginRequest(string Email, string Password); public record RegisterRequest(string Email); + public record CompleteRegistrationRequest(string Ticket, string Username, string Password); + public record CallbackRequest(string State); } \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/DebugController.cs b/Foxnouns.Backend/Controllers/DebugController.cs deleted file mode 100644 index 2d22c03..0000000 --- a/Foxnouns.Backend/Controllers/DebugController.cs +++ /dev/null @@ -1,42 +0,0 @@ -using Foxnouns.Backend.Controllers.Authentication; -using Foxnouns.Backend.Database; -using Foxnouns.Backend.Services; -using Microsoft.AspNetCore.Mvc; -using NodaTime; - -namespace Foxnouns.Backend.Controllers; - -[Route("/api/v2/debug")] -public class DebugController( - DatabaseContext db, - AuthService authService, - UserRendererService userRenderer, - IClock clock, - ILogger logger) : ApiControllerBase -{ - private readonly ILogger _logger = logger.ForContext(); - - [HttpPost("users")] - [ProducesResponseType(StatusCodes.Status200OK)] - public async Task CreateUserAsync([FromBody] CreateUserRequest req) - { - _logger.Debug("Creating user with username {Username} and email {Email}", req.Username, req.Email); - - var user = await authService.CreateUserWithPasswordAsync(req.Username, req.Email, req.Password); - var frontendApp = await db.GetFrontendApplicationAsync(); - - var (tokenStr, token) = - authService.GenerateToken(user, frontendApp, ["*"], clock.GetCurrentInstant() + Duration.FromDays(365)); - db.Add(token); - - await db.SaveChangesAsync(); - - return Ok(new AuthController.AuthResponse( - await userRenderer.RenderUserAsync(user, selfUser: user, renderMembers: false), - tokenStr, - token.ExpiresAt - )); - } - - public record CreateUserRequest(string Username, string Password, string Email); -} \ No newline at end of file diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs index e67d72e..7de7396 100644 --- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -1,6 +1,7 @@ using Foxnouns.Backend.Database; using Foxnouns.Backend.Services; using Foxnouns.Backend.Utils; +using Newtonsoft.Json; using NodaTime; namespace Foxnouns.Backend.Extensions; @@ -25,7 +26,8 @@ public static class KeyCacheExtensions public static async Task GenerateRegisterEmailStateAsync(this KeyCacheService keyCacheService, string email, Snowflake? userId = null, CancellationToken ct = default) { - var state = AuthUtils.RandomToken(); + // This state is used in links, not just as JSON values, so make it URL-safe + var state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_'); await keyCacheService.SetKeyAsync($"email_state:{state}", new RegisterEmailState(email, userId), Duration.FromDays(1), ct); return state; @@ -36,4 +38,7 @@ public static class KeyCacheExtensions await keyCacheService.GetKeyAsync($"email_state:{state}", delete: true, ct); } -public record RegisterEmailState(string Email, Snowflake? ExistingUserId); \ No newline at end of file +public record RegisterEmailState( + string Email, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + Snowflake? ExistingUserId); \ No newline at end of file diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 014eeb1..55ba99e 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -79,7 +79,7 @@ public static class WebApplicationExtensions { services .AddQueue() - .AddMailer(ctx.Configuration) + .AddSmtpMailer(ctx.Configuration) .AddDbContext() .AddMetricServer(o => o.Port = config.Logging.MetricsPort) .AddMinio(c => diff --git a/Foxnouns.Backend/Mailables/AccountCreationMailable.cs b/Foxnouns.Backend/Mailables/AccountCreationMailable.cs index b55c9e6..3fc1ff4 100644 --- a/Foxnouns.Backend/Mailables/AccountCreationMailable.cs +++ b/Foxnouns.Backend/Mailables/AccountCreationMailable.cs @@ -12,8 +12,7 @@ public class AccountCreationMailable(Config config, AccountCreationMailableView } } -public class AccountCreationMailableView +public class AccountCreationMailableView : BaseView { - public required string To { get; init; } public required string Code { get; init; } } \ No newline at end of file diff --git a/Foxnouns.Backend/Mailables/BaseView.cs b/Foxnouns.Backend/Mailables/BaseView.cs new file mode 100644 index 0000000..e664f4e --- /dev/null +++ b/Foxnouns.Backend/Mailables/BaseView.cs @@ -0,0 +1,7 @@ +namespace Foxnouns.Backend.Mailables; + +public abstract class BaseView +{ + public required string BaseUrl { get; init; } + public required string To { get; init; } +} \ No newline at end of file diff --git a/Foxnouns.Backend/Services/AuthService.cs b/Foxnouns.Backend/Services/AuthService.cs index 8d6052d..decb240 100644 --- a/Foxnouns.Backend/Services/AuthService.cs +++ b/Foxnouns.Backend/Services/AuthService.cs @@ -16,7 +16,7 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s /// Creates a new user with the given email address and password. /// This method does not save the resulting user, the caller must still call . /// - public async Task CreateUserWithPasswordAsync(string username, string email, string password) + public async Task CreateUserWithPasswordAsync(string username, string email, string password, CancellationToken ct = default) { var user = new User { @@ -31,7 +31,7 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s }; db.Add(user); - user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password)); + user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct); return user; } diff --git a/Foxnouns.Backend/Services/KeyCacheService.cs b/Foxnouns.Backend/Services/KeyCacheService.cs index d8c5434..33b595f 100644 --- a/Foxnouns.Backend/Services/KeyCacheService.cs +++ b/Foxnouns.Backend/Services/KeyCacheService.cs @@ -30,15 +30,14 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) var value = await db.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key, ct); if (value == null) return null; - if (delete) - { - await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct); - await db.SaveChangesAsync(ct); - } + if (delete) await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct); return value.Value; } + public async Task DeleteKeyAsync(string key, CancellationToken ct = default) => + await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct); + public async Task DeleteExpiredKeysAsync(CancellationToken ct) { var count = await db.TemporaryKeys.Where(k => k.Expires < clock.GetCurrentInstant()).ExecuteDeleteAsync(ct); @@ -54,7 +53,8 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) await SetKeyAsync(key, value, expires, ct); } - public async Task GetKeyAsync(string key, bool delete = false, CancellationToken ct = default) where T : class + public async Task GetKeyAsync(string key, bool delete = false, CancellationToken ct = default) + where T : class { var value = await GetKeyAsync(key, delete, ct); return value == null ? default : JsonConvert.DeserializeObject(value); diff --git a/Foxnouns.Backend/Services/MailService.cs b/Foxnouns.Backend/Services/MailService.cs index 271d41c..e030425 100644 --- a/Foxnouns.Backend/Services/MailService.cs +++ b/Foxnouns.Backend/Services/MailService.cs @@ -10,15 +10,22 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co public void QueueAccountCreationEmail(string to, string code) { - _logger.Debug("Sending account creation email to {ToEmail}", to); - queue.QueueAsyncTask(async () => { - await mailer.SendAsync(new AccountCreationMailable(config, new AccountCreationMailableView + _logger.Debug("Sending account creation email to {ToEmail}", to); + try { - To = to, - Code = code - })); + await mailer.SendAsync(new AccountCreationMailable(config, new AccountCreationMailableView + { + BaseUrl = config.BaseUrl, + To = to, + Code = code + })); + } + catch (Exception exc) + { + _logger.Error(exc, "Sending account creation email"); + } }); } } \ No newline at end of file diff --git a/Foxnouns.Backend/Views/Mail/AccountCreation.cshtml b/Foxnouns.Backend/Views/Mail/AccountCreation.cshtml new file mode 100644 index 0000000..b2b0f2e --- /dev/null +++ b/Foxnouns.Backend/Views/Mail/AccountCreation.cshtml @@ -0,0 +1,12 @@ +@model Foxnouns.Backend.Mailables.AccountCreationMailableView + +

+ Please continue creating a new pronouns.cc account by using the following link: +
+ Confirm your email address +
+ Note that this link will expire in one hour. +

+

+ If you didn't mean to create a new account, feel free to ignore this email. +

\ No newline at end of file diff --git a/Foxnouns.Backend/Views/Mail/Example.cshtml b/Foxnouns.Backend/Views/Mail/Example.cshtml deleted file mode 100644 index e1acaaa..0000000 --- a/Foxnouns.Backend/Views/Mail/Example.cshtml +++ /dev/null @@ -1,15 +0,0 @@ -@{ - ViewBag.Heading = "Welcome!"; - ViewBag.Preview = "Example Email"; -} - -

- Let's see what you can build! - To render a button inside your email, use the EmailLinkButton component: - @await Component.InvokeAsync("EmailLinkButton", new { text = "Click Me!", url = "https://www.google.com" }) -

- -@section links -{ - Coravel -} diff --git a/Foxnouns.Backend/Views/Mail/Layout.cshtml b/Foxnouns.Backend/Views/Mail/Layout.cshtml new file mode 100644 index 0000000..b92faa5 --- /dev/null +++ b/Foxnouns.Backend/Views/Mail/Layout.cshtml @@ -0,0 +1,17 @@ + + + + + + + + + + +@RenderBody() + + \ No newline at end of file diff --git a/Foxnouns.Backend/Views/Mail/_ViewImports.cshtml b/Foxnouns.Backend/Views/Mail/_ViewImports.cshtml index 8c050b2..6ececef 100644 --- a/Foxnouns.Backend/Views/Mail/_ViewImports.cshtml +++ b/Foxnouns.Backend/Views/Mail/_ViewImports.cshtml @@ -1,3 +1,2 @@ @using Foxnouns.Backend @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers -@addTagHelper *, Coravel.Mailer.ViewComponents diff --git a/Foxnouns.Backend/Views/Mail/_ViewStart.cshtml b/Foxnouns.Backend/Views/Mail/_ViewStart.cshtml index 1d54d45..b74bab7 100644 --- a/Foxnouns.Backend/Views/Mail/_ViewStart.cshtml +++ b/Foxnouns.Backend/Views/Mail/_ViewStart.cshtml @@ -1,3 +1,3 @@ @{ - Layout = "~/Areas/Coravel/Pages/Mail/Template.cshtml"; + Layout = "~/Views/Mail/Layout.cshtml"; } diff --git a/Foxnouns.Backend/config.example.ini b/Foxnouns.Backend/config.example.ini index edc8a28..941c25c 100644 --- a/Foxnouns.Backend/config.example.ini +++ b/Foxnouns.Backend/config.example.ini @@ -46,8 +46,7 @@ Bucket = pronounscc From = noreply@accounts.pronouns.cc ; The Coravel mail driver configuration. Keys should be self-explanatory. -[Coravel.Mail] -Driver = SMTP +[Coravel:Mail] Host = localhost Port = 1025 Username = smtp-username