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")]
|
[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);
|
||||||
}
|
}
|
|
@ -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.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);
|
|
@ -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 =>
|
||||||
|
|
|
@ -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; }
|
||||||
}
|
}
|
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.
|
/// 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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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 () =>
|
||||||
{
|
{
|
||||||
await mailer.SendAsync(new AccountCreationMailable(config, new AccountCreationMailableView
|
_logger.Debug("Sending account creation email to {ToEmail}", to);
|
||||||
|
try
|
||||||
{
|
{
|
||||||
To = to,
|
await mailer.SendAsync(new AccountCreationMailable(config, new AccountCreationMailableView
|
||||||
Code = code
|
{
|
||||||
}));
|
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
|
@using Foxnouns.Backend
|
||||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
@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
|
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
|
||||||
|
|
Loading…
Reference in a new issue