feat(backend): email registration

This commit is contained in:
sam 2024-09-10 02:39:07 +02:00
parent c77ee660ca
commit 13a0cac663
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
15 changed files with 120 additions and 82 deletions

View file

@ -12,6 +12,7 @@ namespace Foxnouns.Backend.Controllers.Authentication;
[Route("/api/v2/auth/email")] [Route("/api/v2/auth/email")]
public class EmailAuthController( public class EmailAuthController(
DatabaseContext db, DatabaseContext db,
Config config,
AuthService authService, AuthService authService,
MailService mailService, MailService mailService,
KeyCacheService keyCacheService, KeyCacheService keyCacheService,
@ -24,9 +25,13 @@ public class EmailAuthController(
[HttpPost("register")] [HttpPost("register")]
public async Task<IActionResult> RegisterAsync([FromBody] RegisterRequest req, CancellationToken ct = default) public async Task<IActionResult> RegisterAsync([FromBody] RegisterRequest req, CancellationToken ct = default)
{ {
CheckRequirements();
if (!req.Email.Contains('@')) throw new ApiError.BadRequest("Email is invalid", "email", req.Email); if (!req.Email.Contains('@')) throw new ApiError.BadRequest("Email is invalid", "email", req.Email);
var state = await keyCacheService.GenerateRegisterEmailStateAsync(req.Email, userId: null, ct); 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)) if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Email && a.RemoteId == req.Email, ct))
return NoContent(); return NoContent();
@ -37,9 +42,12 @@ public class EmailAuthController(
[HttpPost("callback")] [HttpPost("callback")]
public async Task<IActionResult> CallbackAsync([FromBody] CallbackRequest req, CancellationToken ct = default) public async Task<IActionResult> CallbackAsync([FromBody] CallbackRequest req, CancellationToken ct = default)
{ {
CheckRequirements();
var state = await keyCacheService.GetRegisterEmailStateAsync(req.State, ct); var state = await keyCacheService.GetRegisterEmailStateAsync(req.State, ct);
if (state == null) throw new ApiError.BadRequest("Invalid state", "state", req.State); 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) if (state.ExistingUserId != null)
{ {
var authMethod = var authMethod =
@ -49,15 +57,49 @@ public class EmailAuthController(
} }
var ticket = AuthUtils.RandomToken(); 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)); return Ok(new AuthController.CallbackResponse(false, ticket, state.Email));
} }
[HttpPost("complete-registration")]
public async Task<IActionResult> 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")] [HttpPost("login")]
[ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)] [ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> LoginAsync([FromBody] LoginRequest req, CancellationToken ct = default) public async Task<IActionResult> LoginAsync([FromBody] LoginRequest req, CancellationToken ct = default)
{ {
CheckRequirements();
var (user, authenticationResult) = await authService.AuthenticateUserAsync(req.Email, req.Password, ct); var (user, authenticationResult) = await authService.AuthenticateUserAsync(req.Email, req.Password, ct);
if (authenticationResult == AuthService.EmailAuthenticationResult.MfaRequired) if (authenticationResult == AuthService.EmailAuthenticationResult.MfaRequired)
throw new NotImplementedException("MFA is not implemented yet"); 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 LoginRequest(string Email, string Password);
public record RegisterRequest(string Email); public record RegisterRequest(string Email);
public record CompleteRegistrationRequest(string Ticket, string Username, string Password);
public record CallbackRequest(string State); public record CallbackRequest(string State);
} }

View file

@ -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<DebugController>();
[HttpPost("users")]
[ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> 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);
}

View file

@ -1,6 +1,7 @@
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
using NodaTime; using NodaTime;
namespace Foxnouns.Backend.Extensions; namespace Foxnouns.Backend.Extensions;
@ -25,7 +26,8 @@ public static class KeyCacheExtensions
public static async Task<string> GenerateRegisterEmailStateAsync(this KeyCacheService keyCacheService, string email, public static async Task<string> GenerateRegisterEmailStateAsync(this KeyCacheService keyCacheService, string email,
Snowflake? userId = null, CancellationToken ct = default) 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), await keyCacheService.SetKeyAsync($"email_state:{state}", new RegisterEmailState(email, userId),
Duration.FromDays(1), ct); Duration.FromDays(1), ct);
return state; return state;
@ -36,4 +38,7 @@ public static class KeyCacheExtensions
await keyCacheService.GetKeyAsync<RegisterEmailState>($"email_state:{state}", delete: true, ct); await keyCacheService.GetKeyAsync<RegisterEmailState>($"email_state:{state}", delete: true, ct);
} }
public record RegisterEmailState(string Email, Snowflake? ExistingUserId); public record RegisterEmailState(
string Email,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
Snowflake? ExistingUserId);

View file

@ -79,7 +79,7 @@ public static class WebApplicationExtensions
{ {
services services
.AddQueue() .AddQueue()
.AddMailer(ctx.Configuration) .AddSmtpMailer(ctx.Configuration)
.AddDbContext<DatabaseContext>() .AddDbContext<DatabaseContext>()
.AddMetricServer(o => o.Port = config.Logging.MetricsPort) .AddMetricServer(o => o.Port = config.Logging.MetricsPort)
.AddMinio(c => .AddMinio(c =>

View file

@ -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; } public required string Code { get; init; }
} }

View file

@ -0,0 +1,7 @@
namespace Foxnouns.Backend.Mailables;
public abstract class BaseView
{
public required string BaseUrl { get; init; }
public required string To { get; init; }
}

View file

@ -16,7 +16,7 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
/// Creates a new user with the given email address and password. /// Creates a new user with the given email address and password.
/// This method does <i>not</i> save the resulting user, the caller must still call <see cref="M:Microsoft.EntityFrameworkCore.DbContext.SaveChanges" />. /// This method does <i>not</i> save the resulting user, the caller must still call <see cref="M:Microsoft.EntityFrameworkCore.DbContext.SaveChanges" />.
/// </summary> /// </summary>
public async Task<User> CreateUserWithPasswordAsync(string username, string email, string password) public async Task<User> CreateUserWithPasswordAsync(string username, string email, string password, CancellationToken ct = default)
{ {
var user = new User var user = new User
{ {
@ -31,7 +31,7 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
}; };
db.Add(user); db.Add(user);
user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password)); user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct);
return user; return user;
} }

View file

@ -30,15 +30,14 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger)
var value = await db.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key, ct); var value = await db.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key, ct);
if (value == null) return null; if (value == null) return null;
if (delete) if (delete) await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct);
{
await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct);
await db.SaveChangesAsync(ct);
}
return value.Value; 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) public async Task DeleteExpiredKeysAsync(CancellationToken ct)
{ {
var count = await db.TemporaryKeys.Where(k => k.Expires < clock.GetCurrentInstant()).ExecuteDeleteAsync(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); await SetKeyAsync(key, value, expires, ct);
} }
public async Task<T?> GetKeyAsync<T>(string key, bool delete = false, CancellationToken ct = default) where T : class public async Task<T?> GetKeyAsync<T>(string key, bool delete = false, CancellationToken ct = default)
where T : class
{ {
var value = await GetKeyAsync(key, delete, ct); var value = await GetKeyAsync(key, delete, ct);
return value == null ? default : JsonConvert.DeserializeObject<T>(value); return value == null ? default : JsonConvert.DeserializeObject<T>(value);

View file

@ -10,15 +10,22 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
public void QueueAccountCreationEmail(string to, string code) public void QueueAccountCreationEmail(string to, string code)
{ {
_logger.Debug("Sending account creation email to {ToEmail}", to);
queue.QueueAsyncTask(async () => queue.QueueAsyncTask(async () =>
{
_logger.Debug("Sending account creation email to {ToEmail}", to);
try
{ {
await mailer.SendAsync(new AccountCreationMailable(config, new AccountCreationMailableView await mailer.SendAsync(new AccountCreationMailable(config, new AccountCreationMailableView
{ {
BaseUrl = config.BaseUrl,
To = to, To = to,
Code = code Code = code
})); }));
}
catch (Exception exc)
{
_logger.Error(exc, "Sending account creation email");
}
}); });
} }
} }

View file

@ -0,0 +1,12 @@
@model Foxnouns.Backend.Mailables.AccountCreationMailableView
<p>
Please continue creating a new pronouns.cc account by using the following link:
<br/>
<a href="@Model.BaseUrl/auth/signup/confirm/@Model.Code">Confirm your email address</a>
<br/>
Note that this link will expire in one hour.
</p>
<p>
If you didn't mean to create a new account, feel free to ignore this email.
</p>

View file

@ -1,15 +0,0 @@
@{
ViewBag.Heading = "Welcome!";
ViewBag.Preview = "Example Email";
}
<p>
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" })
</p>
@section links
{
<a href="https://github.com/jamesmh/coravel">Coravel</a>
}

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<style>
body {
font-family: sans-serif;
}
</style>
</head>
<body>
@RenderBody()
</body>
</html>

View file

@ -1,3 +1,2 @@
@using Foxnouns.Backend @using Foxnouns.Backend
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, Coravel.Mailer.ViewComponents

View file

@ -1,3 +1,3 @@
@{ @{
Layout = "~/Areas/Coravel/Pages/Mail/Template.cshtml"; Layout = "~/Views/Mail/Layout.cshtml";
} }

View file

@ -46,8 +46,7 @@ Bucket = pronounscc
From = noreply@accounts.pronouns.cc From = noreply@accounts.pronouns.cc
; The Coravel mail driver configuration. Keys should be self-explanatory. ; The Coravel mail driver configuration. Keys should be self-explanatory.
[Coravel.Mail] [Coravel:Mail]
Driver = SMTP
Host = localhost Host = localhost
Port = 1025 Port = 1025
Username = smtp-username Username = smtp-username