feat(backend): email registration
This commit is contained in:
parent
c77ee660ca
commit
13a0cac663
15 changed files with 120 additions and 82 deletions
|
@ -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<IActionResult> 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<IActionResult> 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<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")]
|
||||
[ProducesResponseType<AuthController.AuthResponse>(StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> 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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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<string> 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<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);
|
|
@ -79,7 +79,7 @@ public static class WebApplicationExtensions
|
|||
{
|
||||
services
|
||||
.AddQueue()
|
||||
.AddMailer(ctx.Configuration)
|
||||
.AddSmtpMailer(ctx.Configuration)
|
||||
.AddDbContext<DatabaseContext>()
|
||||
.AddMetricServer(o => o.Port = config.Logging.MetricsPort)
|
||||
.AddMinio(c =>
|
||||
|
|
|
@ -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; }
|
||||
}
|
7
Foxnouns.Backend/Mailables/BaseView.cs
Normal file
7
Foxnouns.Backend/Mailables/BaseView.cs
Normal 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; }
|
||||
}
|
|
@ -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 <i>not</i> save the resulting user, the caller must still call <see cref="M:Microsoft.EntityFrameworkCore.DbContext.SaveChanges" />.
|
||||
/// </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
|
||||
{
|
||||
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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<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);
|
||||
return value == null ? default : JsonConvert.DeserializeObject<T>(value);
|
||||
|
|
|
@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
12
Foxnouns.Backend/Views/Mail/AccountCreation.cshtml
Normal file
12
Foxnouns.Backend/Views/Mail/AccountCreation.cshtml
Normal 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>
|
|
@ -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>
|
||||
}
|
17
Foxnouns.Backend/Views/Mail/Layout.cshtml
Normal file
17
Foxnouns.Backend/Views/Mail/Layout.cshtml
Normal 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>
|
|
@ -1,3 +1,2 @@
|
|||
@using Foxnouns.Backend
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@addTagHelper *, Coravel.Mailer.ViewComponents
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
@{
|
||||
Layout = "~/Areas/Coravel/Pages/Mail/Template.cshtml";
|
||||
Layout = "~/Views/Mail/Layout.cshtml";
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue