diff --git a/Foxnouns.Backend/Config.cs b/Foxnouns.Backend/Config.cs index 132a28c..96d724b 100644 --- a/Foxnouns.Backend/Config.cs +++ b/Foxnouns.Backend/Config.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Serilog.Events; namespace Foxnouns.Backend; @@ -15,6 +16,7 @@ public class Config public LoggingConfig Logging { get; init; } = new(); public DatabaseConfig Database { get; init; } = new(); public StorageConfig Storage { get; init; } = new(); + public EmailAuthConfig EmailAuth { get; init; } = new(); public DiscordAuthConfig DiscordAuth { get; init; } = new(); public GoogleAuthConfig GoogleAuth { get; init; } = new(); public TumblrAuthConfig TumblrAuth { get; init; } = new(); @@ -46,6 +48,12 @@ public class Config public string Bucket { get; init; } = string.Empty; } + public class EmailAuthConfig + { + public bool Enabled => From != null; + public string? From { get; init; } + } + public class DiscordAuthConfig { public bool Enabled => ClientId != null && ClientSecret != null; diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index db2e21f..2a0f3d4 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -13,13 +13,13 @@ public class AuthController(Config config, KeyCacheService keyCacheSvc, ILogger [HttpPost("urls")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task UrlsAsync() + public async Task UrlsAsync(CancellationToken ct = default) { _logger.Debug("Generating auth URLs for Discord: {Discord}, Google: {Google}, Tumblr: {Tumblr}", config.DiscordAuth.Enabled, config.GoogleAuth.Enabled, config.TumblrAuth.Enabled); - var state = HttpUtility.UrlEncode(await keyCacheSvc.GenerateAuthStateAsync()); + var state = HttpUtility.UrlEncode(await keyCacheSvc.GenerateAuthStateAsync(ct)); string? discord = null; if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null }) discord = diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs index d717aba..6729fc0 100644 --- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs @@ -27,31 +27,31 @@ public class DiscordAuthController( // leaving it here for documentation purposes [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task CallbackAsync([FromBody] AuthController.CallbackRequest req) + public async Task CallbackAsync([FromBody] AuthController.CallbackRequest req, CancellationToken ct = default) { CheckRequirements(); - await keyCacheSvc.ValidateAuthStateAsync(req.State); + await keyCacheSvc.ValidateAuthStateAsync(req.State, ct); - var remoteUser = await remoteAuthSvc.RequestDiscordTokenAsync(req.Code, req.State); - var user = await authSvc.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id); - if (user != null) return Ok(await GenerateUserTokenAsync(user)); + var remoteUser = await remoteAuthSvc.RequestDiscordTokenAsync(req.Code, req.State, ct); + var user = await authSvc.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id, ct: ct); + if (user != null) return Ok(await GenerateUserTokenAsync(user,ct)); _logger.Debug("Discord user {Username} ({Id}) authenticated with no local account", remoteUser.Username, remoteUser.Id); var ticket = AuthUtils.RandomToken(); - await keyCacheSvc.SetKeyAsync($"discord:{ticket}", remoteUser, Duration.FromMinutes(20)); + await keyCacheSvc.SetKeyAsync($"discord:{ticket}", remoteUser, Duration.FromMinutes(20), ct); return Ok(new AuthController.CallbackResponse(false, ticket, remoteUser.Username)); } [HttpPost("register")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task RegisterAsync([FromBody] AuthController.OauthRegisterRequest req) + public async Task RegisterAsync([FromBody] AuthController.OauthRegisterRequest req, CancellationToken ct = default) { - var remoteUser = await keyCacheSvc.GetKeyAsync($"discord:{req.Ticket}"); + var remoteUser = await keyCacheSvc.GetKeyAsync($"discord:{req.Ticket}",ct:ct); if (remoteUser == null) throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); - if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id)) + if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id, ct)) { _logger.Error("Discord user {Id} has valid ticket but is already linked to an existing account", remoteUser.Id); @@ -59,14 +59,14 @@ public class DiscordAuthController( } var user = await authSvc.CreateUserWithRemoteAuthAsync(req.Username, AuthType.Discord, remoteUser.Id, - remoteUser.Username); + remoteUser.Username, ct: ct); - return Ok(await GenerateUserTokenAsync(user)); + return Ok(await GenerateUserTokenAsync(user, ct)); } - private async Task GenerateUserTokenAsync(User user) + private async Task GenerateUserTokenAsync(User user, CancellationToken ct = default) { - var frontendApp = await db.GetFrontendApplicationAsync(); + var frontendApp = await db.GetFrontendApplicationAsync(ct); _logger.Debug("Logging user {Id} in with Discord", user.Id); var (tokenStr, token) = @@ -75,10 +75,10 @@ public class DiscordAuthController( _logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id); - await db.SaveChangesAsync(); + await db.SaveChangesAsync(ct); return new AuthController.AuthResponse( - await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false), + await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct), tokenStr, token.ExpiresAt ); diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index 19dfc2f..317d62c 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -1,6 +1,10 @@ using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; +using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using NodaTime; namespace Foxnouns.Backend.Controllers.Authentication; @@ -9,21 +13,56 @@ namespace Foxnouns.Backend.Controllers.Authentication; public class EmailAuthController( DatabaseContext db, AuthService authSvc, + MailService mailSvc, + KeyCacheService keyCacheSvc, UserRendererService userRendererSvc, IClock clock, ILogger logger) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); + [HttpPost("register")] + public async Task RegisterAsync([FromBody] RegisterRequest req, CancellationToken ct = default) + { + if (!req.Email.Contains('@')) throw new ApiError.BadRequest("Email is invalid", "email", req.Email); + + var state = await keyCacheSvc.GenerateRegisterEmailStateAsync(req.Email, userId: null, ct); + if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Email && a.RemoteId == req.Email, ct)) + return NoContent(); + + mailSvc.QueueAccountCreationEmail(req.Email, state); + return NoContent(); + } + + [HttpPost("callback")] + public async Task CallbackAsync([FromBody] CallbackRequest req, CancellationToken ct = default) + { + var state = await keyCacheSvc.GetRegisterEmailStateAsync(req.State, ct); + if (state == null) throw new ApiError.BadRequest("Invalid state", "state", req.State); + + if (state.ExistingUserId != null) + { + var authMethod = + await authSvc.AddAuthMethodAsync(state.ExistingUserId.Value, AuthType.Email, state.Email, ct: ct); + _logger.Debug("Added email auth {AuthId} for user {UserId}", authMethod.Id, state.ExistingUserId); + return NoContent(); + } + + var ticket = AuthUtils.RandomToken(); + await keyCacheSvc.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20)); + + return Ok(new AuthController.CallbackResponse(false, ticket, state.Email)); + } + [HttpPost("login")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task LoginAsync([FromBody] LoginRequest req) + public async Task LoginAsync([FromBody] LoginRequest req, CancellationToken ct = default) { - var (user, authenticationResult) = await authSvc.AuthenticateUserAsync(req.Email, req.Password); + var (user, authenticationResult) = await authSvc.AuthenticateUserAsync(req.Email, req.Password, ct); if (authenticationResult == AuthService.EmailAuthenticationResult.MfaRequired) throw new NotImplementedException("MFA is not implemented yet"); - var frontendApp = await db.GetFrontendApplicationAsync(); + var frontendApp = await db.GetFrontendApplicationAsync(ct); _logger.Debug("Logging user {Id} in with email and password", user.Id); @@ -33,14 +72,18 @@ public class EmailAuthController( _logger.Debug("Generated token {TokenId} for {UserId}", token.Id, user.Id); - await db.SaveChangesAsync(); + await db.SaveChangesAsync(ct); return Ok(new AuthController.AuthResponse( - await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false), + await userRendererSvc.RenderUserAsync(user, selfUser: user, renderMembers: false, ct: ct), tokenStr, token.ExpiresAt )); } public record LoginRequest(string Email, string Password); + + public record RegisterRequest(string Email); + + public record CallbackRequest(string State); } \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index 19a9569..5f09e35 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -25,24 +25,24 @@ public class MembersController( [HttpGet] [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task GetMembersAsync(string userRef) + public async Task GetMembersAsync(string userRef, CancellationToken ct = default) { - var user = await db.ResolveUserAsync(userRef, CurrentToken); + var user = await db.ResolveUserAsync(userRef, CurrentToken, ct); return Ok(await memberRendererService.RenderUserMembersAsync(user, CurrentToken)); } [HttpGet("{memberRef}")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task GetMemberAsync(string userRef, string memberRef) + public async Task GetMemberAsync(string userRef, string memberRef, CancellationToken ct = default) { - var member = await db.ResolveMemberAsync(userRef, memberRef, CurrentToken); + var member = await db.ResolveMemberAsync(userRef, memberRef, CurrentToken, ct); return Ok(memberRendererService.RenderMember(member, CurrentToken)); } [HttpPost("/api/v2/users/@me/members")] [ProducesResponseType(StatusCodes.Status200OK)] [Authorize("member.create")] - public async Task CreateMemberAsync([FromBody] CreateMemberRequest req) + public async Task CreateMemberAsync([FromBody] CreateMemberRequest req, CancellationToken ct = default) { ValidationUtils.Validate([ ("name", ValidationUtils.ValidateMemberName(req.Name)), @@ -71,7 +71,7 @@ public class MembersController( try { - await db.SaveChangesAsync(); + await db.SaveChangesAsync(ct); } catch (UniqueConstraintException) { @@ -88,18 +88,18 @@ public class MembersController( [HttpDelete("/api/v2/users/@me/members/{memberRef}")] [Authorize("member.update")] - public async Task DeleteMemberAsync(string memberRef) + public async Task DeleteMemberAsync(string memberRef, CancellationToken ct = default) { - var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef); + var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef, ct); var deleteCount = await db.Members.Where(m => m.UserId == CurrentUser!.Id && m.Id == member.Id) - .ExecuteDeleteAsync(); + .ExecuteDeleteAsync(ct); if (deleteCount == 0) { _logger.Warning("Successfully resolved member {Id} but could not delete them", member.Id); return NoContent(); } - await db.SaveChangesAsync(); + await db.SaveChangesAsync(ct); if (member.Avatar != null) await objectStorage.DeleteMemberAvatarAsync(member.Id, member.Avatar); return NoContent(); diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index a2c6219..8b9299a 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -36,10 +36,10 @@ public class UsersController( [HttpPatch("@me")] [Authorize("user.update")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] - public async Task UpdateUserAsync([FromBody] UpdateUserRequest req) + public async Task UpdateUserAsync([FromBody] UpdateUserRequest req, CancellationToken ct = default) { - await using var tx = await db.Database.BeginTransactionAsync(); - var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id); + await using var tx = await db.Database.BeginTransactionAsync(ct); + var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct); var errors = new List<(string, ValidationError?)>(); if (req.Username != null && req.Username != user.Username) @@ -76,20 +76,20 @@ public class UsersController( queue.QueueInvocableWithPayload( new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar)); - await db.SaveChangesAsync(); - await tx.CommitAsync(); + await db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); return Ok(await userRendererService.RenderUserAsync(user, CurrentUser, renderMembers: false, - renderAuthMethods: false)); + renderAuthMethods: false, ct: ct)); } [HttpPatch("@me/custom-preferences")] [Authorize("user.update")] [ProducesResponseType>(StatusCodes.Status200OK)] - public async Task UpdateCustomPreferencesAsync([FromBody] List req) + public async Task UpdateCustomPreferencesAsync([FromBody] List req, CancellationToken ct = default) { ValidationUtils.Validate(ValidateCustomPreferences(req)); - var user = await db.ResolveUserAsync(CurrentUser!.Id); + var user = await db.ResolveUserAsync(CurrentUser!.Id, ct); var preferences = user.CustomPreferences.Where(x => req.Any(r => r.Id == x.Key)).ToDictionary(); foreach (var r in req) @@ -119,7 +119,7 @@ public class UsersController( } user.CustomPreferences = preferences; - await db.SaveChangesAsync(); + await db.SaveChangesAsync(ct); return Ok(user.CustomPreferences); } diff --git a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs index b6ccaaa..76043d7 100644 --- a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs +++ b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs @@ -36,34 +36,36 @@ public static class DatabaseQueryExtensions throw new ApiError.NotFound("No user with that ID or username found.", code: ErrorCode.UserNotFound); } - public static async Task ResolveUserAsync(this DatabaseContext context, Snowflake id) + public static async Task ResolveUserAsync(this DatabaseContext context, Snowflake id, + CancellationToken ct = default) { var user = await context.Users .Where(u => !u.Deleted) - .FirstOrDefaultAsync(u => u.Id == id); + .FirstOrDefaultAsync(u => u.Id == id, ct); if (user != null) return user; throw new ApiError.NotFound("No user with that ID found.", code: ErrorCode.UserNotFound); } - public static async Task ResolveMemberAsync(this DatabaseContext context, Snowflake id) + public static async Task ResolveMemberAsync(this DatabaseContext context, Snowflake id, + CancellationToken ct = default) { var member = await context.Members .Include(m => m.User) .Where(m => !m.User.Deleted) - .FirstOrDefaultAsync(m => m.Id == id); + .FirstOrDefaultAsync(m => m.Id == id, ct); if (member != null) return member; throw new ApiError.NotFound("No member with that ID found.", code: ErrorCode.MemberNotFound); } public static async Task ResolveMemberAsync(this DatabaseContext context, string userRef, string memberRef, - Token? token) + Token? token, CancellationToken ct = default) { - var user = await context.ResolveUserAsync(userRef, token); - return await context.ResolveMemberAsync(user.Id, memberRef); + var user = await context.ResolveUserAsync(userRef, token, ct); + return await context.ResolveMemberAsync(user.Id, memberRef, ct); } public static async Task ResolveMemberAsync(this DatabaseContext context, Snowflake userId, - string memberRef) + string memberRef, CancellationToken ct = default) { Member? member; if (Snowflake.TryParse(memberRef, out var snowflake)) @@ -71,21 +73,22 @@ public static class DatabaseQueryExtensions member = await context.Members .Include(m => m.User) .Where(m => !m.User.Deleted) - .FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId); + .FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct); if (member != null) return member; } member = await context.Members .Include(m => m.User) .Where(m => !m.User.Deleted) - .FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId); + .FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct); if (member != null) return member; throw new ApiError.NotFound("No member with that ID or name found.", code: ErrorCode.MemberNotFound); } - public static async Task GetFrontendApplicationAsync(this DatabaseContext context) + public static async Task GetFrontendApplicationAsync(this DatabaseContext context, + CancellationToken ct = default) { - var app = await context.Applications.FirstOrDefaultAsync(a => a.Id == new Snowflake(0)); + var app = await context.Applications.FirstOrDefaultAsync(a => a.Id == new Snowflake(0), ct); if (app != null) return app; app = new Application @@ -99,7 +102,7 @@ public static class DatabaseQueryExtensions }; context.Add(app); - await context.SaveChangesAsync(); + await context.SaveChangesAsync(ct); return app; } } \ No newline at end of file diff --git a/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs b/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs index f7c2b6f..2d0fd16 100644 --- a/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs +++ b/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs @@ -14,12 +14,12 @@ public static class AvatarObjectExtensions private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"]; public static async Task - DeleteMemberAvatarAsync(this ObjectStorageService objectStorage, Snowflake id, string hash) => - await objectStorage.RemoveObjectAsync(MemberAvatarUpdateInvocable.Path(id, hash)); + DeleteMemberAvatarAsync(this ObjectStorageService objectStorage, Snowflake id, string hash, CancellationToken ct = default) => + await objectStorage.RemoveObjectAsync(MemberAvatarUpdateInvocable.Path(id, hash), ct); public static async Task - DeleteUserAvatarAsync(this ObjectStorageService objectStorage, Snowflake id, string hash) => - await objectStorage.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash)); + DeleteUserAvatarAsync(this ObjectStorageService objectStorage, Snowflake id, string hash, CancellationToken ct = default) => + await objectStorage.RemoveObjectAsync(UserAvatarUpdateInvocable.Path(id, hash), ct); public static async Task ConvertBase64UriToAvatar(this string uri) { diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs index 3b8e35c..eb1cecc 100644 --- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -1,3 +1,4 @@ +using Foxnouns.Backend.Database; using Foxnouns.Backend.Services; using Foxnouns.Backend.Utils; using NodaTime; @@ -6,16 +7,31 @@ namespace Foxnouns.Backend.Extensions; public static class KeyCacheExtensions { - public static async Task GenerateAuthStateAsync(this KeyCacheService keyCacheSvc) + public static async Task GenerateAuthStateAsync(this KeyCacheService keyCacheSvc, CancellationToken ct = default) { var state = AuthUtils.RandomToken(); - await keyCacheSvc.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10)); + await keyCacheSvc.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct); return state; } - public static async Task ValidateAuthStateAsync(this KeyCacheService keyCacheSvc, string state) + public static async Task ValidateAuthStateAsync(this KeyCacheService keyCacheSvc, string state, CancellationToken ct = default) { - var val = await keyCacheSvc.GetKeyAsync($"oauth_state:{state}", delete: true); + var val = await keyCacheSvc.GetKeyAsync($"oauth_state:{state}", delete: true, ct); if (val == null) throw new ApiError.BadRequest("Invalid OAuth state"); } -} \ No newline at end of file + + public static async Task GenerateRegisterEmailStateAsync(this KeyCacheService keyCacheSvc, string email, + Snowflake? userId = null, CancellationToken ct = default) + { + var state = AuthUtils.RandomToken(); + await keyCacheSvc.SetKeyAsync($"email_state:{state}", new RegisterEmailState(email, userId), + Duration.FromDays(1), ct); + return state; + } + + public static async Task GetRegisterEmailStateAsync(this KeyCacheService keyCacheSvc, + string state, CancellationToken ct = default) => + await keyCacheSvc.GetKeyAsync($"email_state:{state}", delete: true, ct); +} + +public record RegisterEmailState(string Email, Snowflake? ExistingUserId); \ No newline at end of file diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 1f1ee31..014eeb1 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -70,38 +70,43 @@ public static class WebApplicationExtensions } /// - /// Adds required services to the IServiceCollection. + /// Adds required services to the WebApplicationBuilder. /// This should only add services that are not ASP.NET-related (i.e. no middleware). /// - public static IServiceCollection AddServices(this IServiceCollection services, Config config) + public static IServiceCollection AddServices(this WebApplicationBuilder builder, Config config) { - services - .AddQueue() - .AddDbContext() - .AddMetricServer(o => o.Port = config.Logging.MetricsPort) - .AddMinio(c => - c.WithEndpoint(config.Storage.Endpoint) - .WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey) - .Build()) - .AddSingleton() - .AddSingleton(SystemClock.Instance) - .AddSnowflakeGenerator() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - .AddScoped() - // Background services - .AddHostedService() - // Transient jobs - .AddTransient() - .AddTransient(); - - if (!config.Logging.EnableMetrics) - services.AddHostedService(); - - return services; + builder.Host.ConfigureServices((ctx, services) => + { + services + .AddQueue() + .AddMailer(ctx.Configuration) + .AddDbContext() + .AddMetricServer(o => o.Port = config.Logging.MetricsPort) + .AddMinio(c => + c.WithEndpoint(config.Storage.Endpoint) + .WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey) + .Build()) + .AddSingleton() + .AddSingleton(SystemClock.Instance) + .AddSnowflakeGenerator() + .AddSingleton() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + // Background services + .AddHostedService() + // Transient jobs + .AddTransient() + .AddTransient(); + + if (!config.Logging.EnableMetrics) + services.AddHostedService(); + }); + + return builder.Services; } public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index c22fdf4..92abc6a 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -6,31 +6,32 @@ - - - - - - + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + @@ -39,6 +40,6 @@ - + diff --git a/Foxnouns.Backend/Mailables/AccountCreationMailable.cs b/Foxnouns.Backend/Mailables/AccountCreationMailable.cs new file mode 100644 index 0000000..b55c9e6 --- /dev/null +++ b/Foxnouns.Backend/Mailables/AccountCreationMailable.cs @@ -0,0 +1,19 @@ +using Coravel.Mailer.Mail; + +namespace Foxnouns.Backend.Mailables; + +public class AccountCreationMailable(Config config, AccountCreationMailableView view) : Mailable +{ + public override void Build() + { + To(view.To) + .From(config.EmailAuth.From!) + .View("~/Views/Mail/AccountCreation.cshtml", view); + } +} + +public class AccountCreationMailableView +{ + public required string To { get; init; } + public required string Code { get; init; } +} \ No newline at end of file diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index 911c895..65e508a 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -58,8 +58,7 @@ JsonConvert.DefaultSettings = () => new JsonSerializerSettings } }; -builder.Services - .AddServices(config) +builder.AddServices(config) .AddCustomMiddleware() .AddEndpointsApiExplorer() .AddSwaggerGen(); diff --git a/Foxnouns.Backend/Services/AuthService.cs b/Foxnouns.Backend/Services/AuthService.cs index c75f926..8d6052d 100644 --- a/Foxnouns.Backend/Services/AuthService.cs +++ b/Foxnouns.Backend/Services/AuthService.cs @@ -42,11 +42,11 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s /// This method does not save the resulting user, the caller must still call . /// public async Task CreateUserWithRemoteAuthAsync(string username, AuthType authType, string remoteId, - string remoteUsername, FediverseApplication? instance = null) + string remoteUsername, FediverseApplication? instance = null, CancellationToken ct = default) { AssertValidAuthType(authType, instance); - if (await db.Users.AnyAsync(u => u.Username == username)) + if (await db.Users.AnyAsync(u => u.Username == username, ct)) throw new ApiError.BadRequest("Username is already taken", "username", username); var user = new User @@ -76,20 +76,20 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s /// A tuple of the authenticated user and whether multi-factor authentication is required /// Thrown if the email address is not associated with any user /// or if the password is incorrect - public async Task<(User, EmailAuthenticationResult)> AuthenticateUserAsync(string email, string password) + public async Task<(User, EmailAuthenticationResult)> AuthenticateUserAsync(string email, string password, CancellationToken ct = default) { var user = await db.Users.FirstOrDefaultAsync(u => - u.AuthMethods.Any(a => a.AuthType == AuthType.Email && a.RemoteId == email)); + u.AuthMethods.Any(a => a.AuthType == AuthType.Email && a.RemoteId == email), ct); if (user == null) throw new ApiError.NotFound("No user with that email address found, or password is incorrect"); - var pwResult = await Task.Run(() => _passwordHasher.VerifyHashedPassword(user, user.Password!, password)); + var pwResult = await Task.Run(() => _passwordHasher.VerifyHashedPassword(user, user.Password!, password), ct); if (pwResult == PasswordVerificationResult.Failed) throw new ApiError.NotFound("No user with that email address found, or password is incorrect"); if (pwResult == PasswordVerificationResult.SuccessRehashNeeded) { - user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password)); - await db.SaveChangesAsync(); + user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct); + await db.SaveChangesAsync(ct); } return (user, EmailAuthenticationResult.AuthSuccessful); @@ -108,17 +108,38 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s /// The remote user ID /// The Fediverse instance, if authType is Fediverse. /// Will throw an exception if passed with another authType. + /// Cancellation token. /// A user object, or null if the remote account isn't linked to any user. /// Thrown if instance is passed when not required, /// or not passed when required public async Task AuthenticateUserAsync(AuthType authType, string remoteId, - FediverseApplication? instance = null) + FediverseApplication? instance = null, CancellationToken ct = default) { AssertValidAuthType(authType, instance); return await db.Users.FirstOrDefaultAsync(u => u.AuthMethods.Any(a => - a.AuthType == authType && a.RemoteId == remoteId && a.FediverseApplication == instance)); + a.AuthType == authType && a.RemoteId == remoteId && a.FediverseApplication == instance), ct); + } + + public async Task AddAuthMethodAsync(Snowflake userId, AuthType authType, string remoteId, + string? remoteUsername = null, + CancellationToken ct = default) + { + AssertValidAuthType(authType, null); + + var authMethod = new AuthMethod + { + Id = snowflakeGenerator.GenerateSnowflake(), + AuthType = authType, + RemoteId = remoteId, + RemoteUsername = remoteUsername, + UserId = userId + }; + + db.Add(authMethod); + await db.SaveChangesAsync(ct); + return authMethod; } public (string, Token) GenerateToken(User user, Application application, string[] scopes, Instant expires) diff --git a/Foxnouns.Backend/Services/KeyCacheService.cs b/Foxnouns.Backend/Services/KeyCacheService.cs index bd8a862..d8c5434 100644 --- a/Foxnouns.Backend/Services/KeyCacheService.cs +++ b/Foxnouns.Backend/Services/KeyCacheService.cs @@ -11,10 +11,10 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) { private readonly ILogger _logger = logger.ForContext(); - public Task SetKeyAsync(string key, string value, Duration expireAfter) => - SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter); + public Task SetKeyAsync(string key, string value, Duration expireAfter, CancellationToken ct = default) => + SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter, ct); - public async Task SetKeyAsync(string key, string value, Instant expires) + public async Task SetKeyAsync(string key, string value, Instant expires, CancellationToken ct = default) { db.TemporaryKeys.Add(new TemporaryKey { @@ -22,18 +22,18 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) Key = key, Value = value, }); - await db.SaveChangesAsync(); + await db.SaveChangesAsync(ct); } - public async Task GetKeyAsync(string key, bool delete = false) + public async Task GetKeyAsync(string key, bool delete = false, CancellationToken ct = default) { - var value = await db.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key); + 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(); - await db.SaveChangesAsync(); + await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct); + await db.SaveChangesAsync(ct); } return value.Value; @@ -45,18 +45,18 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger) if (count != 0) _logger.Information("Removed {Count} expired keys from the database", count); } - public Task SetKeyAsync(string key, T obj, Duration expiresAt) where T : class => - SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt); + public Task SetKeyAsync(string key, T obj, Duration expiresAt, CancellationToken ct = default) where T : class => + SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt, ct); - public async Task SetKeyAsync(string key, T obj, Instant expires) where T : class + public async Task SetKeyAsync(string key, T obj, Instant expires, CancellationToken ct = default) where T : class { var value = JsonConvert.SerializeObject(obj); - await SetKeyAsync(key, value, expires); + await SetKeyAsync(key, value, expires, ct); } - public async Task GetKeyAsync(string key, bool delete = false) where T : class + public async Task GetKeyAsync(string key, bool delete = false, CancellationToken ct = default) where T : class { - var value = await GetKeyAsync(key, delete: false); + var value = await GetKeyAsync(key, delete, ct); return value == null ? default : JsonConvert.DeserializeObject(value); } } \ No newline at end of file diff --git a/Foxnouns.Backend/Services/MailService.cs b/Foxnouns.Backend/Services/MailService.cs new file mode 100644 index 0000000..271d41c --- /dev/null +++ b/Foxnouns.Backend/Services/MailService.cs @@ -0,0 +1,24 @@ +using Coravel.Mailer.Mail.Interfaces; +using Coravel.Queuing.Interfaces; +using Foxnouns.Backend.Mailables; + +namespace Foxnouns.Backend.Services; + +public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config config) +{ + private readonly ILogger _logger = logger.ForContext(); + + 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 + { + To = to, + Code = code + })); + }); + } +} \ No newline at end of file diff --git a/Foxnouns.Backend/Services/ObjectStorageService.cs b/Foxnouns.Backend/Services/ObjectStorageService.cs index de60074..45e9678 100644 --- a/Foxnouns.Backend/Services/ObjectStorageService.cs +++ b/Foxnouns.Backend/Services/ObjectStorageService.cs @@ -8,12 +8,13 @@ public class ObjectStorageService(ILogger logger, Config config, IMinioClient mi { private readonly ILogger _logger = logger.ForContext(); - public async Task RemoveObjectAsync(string path) + public async Task RemoveObjectAsync(string path, CancellationToken ct = default) { _logger.Debug("Deleting object at path {Path}", path); try { - await minio.RemoveObjectAsync(new RemoveObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path)); + await minio.RemoveObjectAsync(new RemoveObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path), + ct); } catch (InvalidObjectNameException) { @@ -21,17 +22,17 @@ public class ObjectStorageService(ILogger logger, Config config, IMinioClient mi } } - public async Task PutObjectAsync(string path, Stream data, string contentType) + public async Task PutObjectAsync(string path, Stream data, string contentType, CancellationToken ct = default) { _logger.Debug("Putting object at path {Path} with length {Length} and content type {ContentType}", path, data.Length, contentType); await minio.PutObjectAsync(new PutObjectArgs() - .WithBucket(config.Storage.Bucket) - .WithObject(path) - .WithObjectSize(data.Length) - .WithStreamData(data) - .WithContentType(contentType) + .WithBucket(config.Storage.Bucket) + .WithObject(path) + .WithObjectSize(data.Length) + .WithStreamData(data) + .WithContentType(contentType), ct ); } } \ No newline at end of file diff --git a/Foxnouns.Backend/Services/RemoteAuthService.cs b/Foxnouns.Backend/Services/RemoteAuthService.cs index 48d6b84..389412f 100644 --- a/Foxnouns.Backend/Services/RemoteAuthService.cs +++ b/Foxnouns.Backend/Services/RemoteAuthService.cs @@ -10,7 +10,7 @@ public class RemoteAuthService(Config config) private readonly Uri _discordTokenUri = new("https://discord.com/api/oauth2/token"); private readonly Uri _discordUserUri = new("https://discord.com/api/v10/users/@me"); - public async Task RequestDiscordTokenAsync(string code, string state) + public async Task RequestDiscordTokenAsync(string code, string state, CancellationToken ct = default) { var redirectUri = $"{config.BaseUrl}/auth/login/discord"; var resp = await _httpClient.PostAsync(_discordTokenUri, new FormUrlEncodedContent( @@ -22,17 +22,17 @@ public class RemoteAuthService(Config config) { "code", code }, { "redirect_uri", redirectUri } } - )); + ), ct); resp.EnsureSuccessStatusCode(); - var token = await resp.Content.ReadFromJsonAsync(); + var token = await resp.Content.ReadFromJsonAsync(ct); if (token == null) throw new FoxnounsError("Discord token response was null"); var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri); req.Headers.Add("Authorization", $"{token.token_type} {token.access_token}"); - var resp2 = await _httpClient.SendAsync(req); + var resp2 = await _httpClient.SendAsync(req, ct); resp2.EnsureSuccessStatusCode(); - var user = await resp2.Content.ReadFromJsonAsync(); + var user = await resp2.Content.ReadFromJsonAsync(ct); if (user == null) throw new FoxnounsError("Discord user response was null"); return new RemoteUser(user.id, user.username); diff --git a/Foxnouns.Backend/Views/Mail/Example.cshtml b/Foxnouns.Backend/Views/Mail/Example.cshtml new file mode 100644 index 0000000..e1acaaa --- /dev/null +++ b/Foxnouns.Backend/Views/Mail/Example.cshtml @@ -0,0 +1,15 @@ +@{ + 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/_ViewImports.cshtml b/Foxnouns.Backend/Views/Mail/_ViewImports.cshtml new file mode 100644 index 0000000..8c050b2 --- /dev/null +++ b/Foxnouns.Backend/Views/Mail/_ViewImports.cshtml @@ -0,0 +1,3 @@ +@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 new file mode 100644 index 0000000..1d54d45 --- /dev/null +++ b/Foxnouns.Backend/Views/Mail/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "~/Areas/Coravel/Pages/Mail/Template.cshtml"; +} diff --git a/Foxnouns.Backend/config.example.ini b/Foxnouns.Backend/config.example.ini index 27e5cbf..edc8a28 100644 --- a/Foxnouns.Backend/config.example.ini +++ b/Foxnouns.Backend/config.example.ini @@ -41,6 +41,18 @@ AccessKey = SecretKey = Bucket = pronounscc +[EmailAuth] +; The address that emails will be sent from. If not set, email auth is disabled. +From = noreply@accounts.pronouns.cc + +; The Coravel mail driver configuration. Keys should be self-explanatory. +[Coravel.Mail] +Driver = SMTP +Host = localhost +Port = 1025 +Username = smtp-username +Password = smtp-password + [DiscordAuth] ClientId = ClientSecret =