From 5cb3faa92ba7d67690392cd783762baab4c51911 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 11 Dec 2024 16:54:06 +0100 Subject: [PATCH 1/5] feat(backend): allow suspended users to access some endpoints, add flag scopes --- .../Controllers/FlagsController.cs | 9 ++-- .../Controllers/MembersController.cs | 2 + .../Controllers/UsersController.cs | 1 + .../Database/DatabaseQueryExtensions.cs | 14 +++--- .../Middleware/AuthorizationMiddleware.cs | 45 +++++++++++++------ Foxnouns.Backend/Program.cs | 8 ++-- Foxnouns.Backend/Utils/AuthUtils.cs | 3 ++ 7 files changed, 57 insertions(+), 25 deletions(-) diff --git a/Foxnouns.Backend/Controllers/FlagsController.cs b/Foxnouns.Backend/Controllers/FlagsController.cs index 0c35afd..c68fb96 100644 --- a/Foxnouns.Backend/Controllers/FlagsController.cs +++ b/Foxnouns.Backend/Controllers/FlagsController.cs @@ -34,7 +34,8 @@ public class FlagsController( ) : ApiControllerBase { [HttpGet] - [Authorize("identify")] + [Limit(UsableBySuspendedUsers = true)] + [Authorize("user.read_flags")] [ProducesResponseType>(statusCode: StatusCodes.Status200OK)] public async Task GetFlagsAsync(CancellationToken ct = default) { @@ -50,7 +51,7 @@ public class FlagsController( public const int MaxFlagCount = 500; [HttpPost] - [Authorize("user.update")] + [Authorize("user.update_flags")] [ProducesResponseType(statusCode: StatusCodes.Status202Accepted)] public async Task CreateFlagAsync([FromBody] CreateFlagRequest req) { @@ -79,7 +80,7 @@ public class FlagsController( } [HttpPatch("{id}")] - [Authorize("user.update")] + [Authorize("user.create_flags")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req) { @@ -104,7 +105,7 @@ public class FlagsController( } [HttpDelete("{id}")] - [Authorize("user.update")] + [Authorize("user.update_flags")] public async Task DeleteFlagAsync(Snowflake id) { PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f => diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index 534c51f..9b94b30 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -44,6 +44,7 @@ public class MembersController( [HttpGet] [ProducesResponseType>(StatusCodes.Status200OK)] + [Limit(UsableBySuspendedUsers = true)] public async Task GetMembersAsync(string userRef, CancellationToken ct = default) { User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); @@ -52,6 +53,7 @@ public class MembersController( [HttpGet("{memberRef}")] [ProducesResponseType(StatusCodes.Status200OK)] + [Limit(UsableBySuspendedUsers = true)] public async Task GetMemberAsync( string userRef, string memberRef, diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index 4a3be72..d567bdb 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -42,6 +42,7 @@ public class UsersController( [HttpGet("{userRef}")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] + [Limit(UsableBySuspendedUsers = true)] public async Task GetUserAsync(string userRef, CancellationToken ct = default) { User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); diff --git a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs index 790b9df..d804dfe 100644 --- a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs +++ b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs @@ -31,6 +31,7 @@ public static class DatabaseQueryExtensions { if (userRef == "@me") { + // Not filtering deleted users, as a suspended user should still be able to look at their own profile. return token != null ? await context.Users.FirstAsync(u => u.Id == token.UserId, ct) : throw new ApiError.Unauthorized( @@ -43,14 +44,14 @@ public static class DatabaseQueryExtensions if (Snowflake.TryParse(userRef, out Snowflake? snowflake)) { user = await context - .Users.Where(u => !u.Deleted) + .Users.Where(u => !u.Deleted || (token != null && token.UserId == u.Id)) .FirstOrDefaultAsync(u => u.Id == snowflake, ct); if (user != null) return user; } user = await context - .Users.Where(u => !u.Deleted) + .Users.Where(u => !u.Deleted || (token != null && token.UserId == u.Id)) .FirstOrDefaultAsync(u => u.Username == userRef, ct); if (user != null) return user; @@ -98,13 +99,14 @@ public static class DatabaseQueryExtensions ) { User user = await context.ResolveUserAsync(userRef, token, ct); - return await context.ResolveMemberAsync(user.Id, memberRef, ct); + return await context.ResolveMemberAsync(user.Id, memberRef, token, ct); } public static async Task ResolveMemberAsync( this DatabaseContext context, Snowflake userId, string memberRef, + Token? token = null, CancellationToken ct = default ) { @@ -114,7 +116,8 @@ public static class DatabaseQueryExtensions member = await context .Members.Include(m => m.User) .Include(m => m.ProfileFlags) - .Where(m => !m.User.Deleted) + // Return members if their user isn't deleted or the user querying it is the member's owner + .Where(m => !m.User.Deleted || (token != null && token.UserId == m.UserId)) .FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct); if (member != null) return member; @@ -123,7 +126,8 @@ public static class DatabaseQueryExtensions member = await context .Members.Include(m => m.User) .Include(m => m.ProfileFlags) - .Where(m => !m.User.Deleted) + // Return members if their user isn't deleted or the user querying it is the member's owner + .Where(m => !m.User.Deleted || (token != null && token.UserId == m.UserId)) .FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct); if (member != null) return member; diff --git a/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs b/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs index 1132dc1..908598a 100644 --- a/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs +++ b/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs @@ -14,6 +14,7 @@ // along with this program. If not, see . using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Utils; +using Microsoft.AspNetCore.Mvc.ViewFeatures; namespace Foxnouns.Backend.Middleware; @@ -22,9 +23,11 @@ public class AuthorizationMiddleware : IMiddleware public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) { Endpoint? endpoint = ctx.GetEndpoint(); - AuthorizeAttribute? attribute = endpoint?.Metadata.GetMetadata(); + AuthorizeAttribute? authorizeAttribute = + endpoint?.Metadata.GetMetadata(); + LimitAttribute? limitAttribute = endpoint?.Metadata.GetMetadata(); - if (attribute == null) + if (authorizeAttribute == null || authorizeAttribute.Scopes.Length == 0) { await next(ctx); return; @@ -39,24 +42,35 @@ public class AuthorizationMiddleware : IMiddleware ); } + // Users who got suspended by a moderator can still access *some* endpoints. if ( - attribute.Scopes.Length > 0 - && attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any() + token.User.Deleted + && (limitAttribute?.UsableBySuspendedUsers != true || token.User.DeletedBy == null) + ) + { + throw new ApiError.Forbidden("Deleted users cannot access this endpoint."); + } + + if ( + authorizeAttribute.Scopes.Length > 0 + && authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()).Any() ) { throw new ApiError.Forbidden( "This endpoint requires ungranted scopes.", - attribute.Scopes.Except(token.Scopes.ExpandScopes()), + authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()), ErrorCode.MissingScopes ); } - if (attribute.RequireAdmin && token.User.Role != UserRole.Admin) + if (limitAttribute?.RequireAdmin == true && token.User.Role != UserRole.Admin) + { throw new ApiError.Forbidden("This endpoint can only be used by admins."); + } + if ( - attribute.RequireModerator - && token.User.Role != UserRole.Admin - && token.User.Role != UserRole.Moderator + limitAttribute?.RequireModerator == true + && token.User.Role is not (UserRole.Admin or UserRole.Moderator) ) { throw new ApiError.Forbidden("This endpoint can only be used by moderators."); @@ -69,8 +83,13 @@ public class AuthorizationMiddleware : IMiddleware [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] public class AuthorizeAttribute(params string[] scopes) : Attribute { - public readonly bool RequireAdmin = scopes.Contains(":admin"); - public readonly bool RequireModerator = scopes.Contains(":moderator"); - - public readonly string[] Scopes = scopes.Except([":admin", ":moderator"]).ToArray(); + public readonly string[] Scopes = scopes.Except([":admin", ":moderator", ":deleted"]).ToArray(); +} + +[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] +public class LimitAttribute : Attribute +{ + public bool UsableBySuspendedUsers { get; init; } + public bool RequireAdmin { get; init; } + public bool RequireModerator { get; init; } } diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index 3597ae7..66e57a6 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -66,9 +66,11 @@ builder }) .ConfigureApiBehaviorOptions(options => { - options.InvalidModelStateResponseFactory = actionContext => new BadRequestObjectResult( - new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson() - ); + // the type isn't needed but without it, rider keeps complaining for no reason (it compiles just fine) + options.InvalidModelStateResponseFactory = (ActionContext actionContext) => + new BadRequestObjectResult( + new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson() + ); }); builder.Services.AddOpenApi( diff --git a/Foxnouns.Backend/Utils/AuthUtils.cs b/Foxnouns.Backend/Utils/AuthUtils.cs index bcc2700..491694a 100644 --- a/Foxnouns.Backend/Utils/AuthUtils.cs +++ b/Foxnouns.Backend/Utils/AuthUtils.cs @@ -35,6 +35,9 @@ public static class AuthUtils "user.read_hidden", "user.read_privileged", "user.update", + "user.read_flags", + "user.create_flags", + "user.update_flags", ]; public static readonly string[] MemberScopes = From ff8d53814deb56d4c227ab8f8653f20db41da5fe Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 11 Dec 2024 20:42:48 +0100 Subject: [PATCH 2/5] feat: rate limit emails to two per address per hour --- Foxnouns.Backend/Database/DatabaseContext.cs | 6 +- .../20241211193653_AddSentEmailCache.cs | 53 +++++++++ .../DatabaseContextModelSnapshot.cs | 40 +++++-- Foxnouns.Backend/Database/Models/SentEmail.cs | 13 ++ .../Services/DataCleanupService.cs | 15 ++- Foxnouns.Backend/Services/MailService.cs | 111 ++++++++++++------ 6 files changed, 189 insertions(+), 49 deletions(-) create mode 100644 Foxnouns.Backend/Database/Migrations/20241211193653_AddSentEmailCache.cs create mode 100644 Foxnouns.Backend/Database/Models/SentEmail.cs diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index a5dede8..7407c5b 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -66,6 +66,7 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) public DbSet Applications { get; init; } = null!; public DbSet TemporaryKeys { get; init; } = null!; public DbSet DataExports { get; init; } = null!; + public DbSet SentEmails { get; init; } = null!; public DbSet PrideFlags { get; init; } = null!; public DbSet UserFlags { get; init; } = null!; @@ -84,6 +85,10 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) modelBuilder.Entity().HasIndex(m => new { m.UserId, m.Name }).IsUnique(); modelBuilder.Entity().HasIndex(m => m.Sid).IsUnique(); modelBuilder.Entity().HasIndex(k => k.Key).IsUnique(); + modelBuilder.Entity().HasIndex(d => d.Filename).IsUnique(); + modelBuilder.Entity().HasIndex(e => new { e.Email, e.SentAt }); + + // Two indexes on auth_methods, one for fediverse auth and one for all other types. modelBuilder .Entity() .HasIndex(m => new @@ -94,7 +99,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) }) .HasFilter("fediverse_application_id IS NOT NULL") .IsUnique(); - modelBuilder.Entity().HasIndex(d => d.Filename).IsUnique(); modelBuilder .Entity() diff --git a/Foxnouns.Backend/Database/Migrations/20241211193653_AddSentEmailCache.cs b/Foxnouns.Backend/Database/Migrations/20241211193653_AddSentEmailCache.cs new file mode 100644 index 0000000..e0fe00d --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20241211193653_AddSentEmailCache.cs @@ -0,0 +1,53 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20241211193653_AddSentEmailCache")] + public partial class AddSentEmailCache : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "sent_emails", + columns: table => new + { + id = table + .Column(type: "integer", nullable: false) + .Annotation( + "Npgsql:ValueGenerationStrategy", + NpgsqlValueGenerationStrategy.IdentityByDefaultColumn + ), + email = table.Column(type: "text", nullable: false), + sent_at = table.Column( + type: "timestamp with time zone", + nullable: false + ), + }, + constraints: table => + { + table.PrimaryKey("pk_sent_emails", x => x.id); + } + ); + + migrationBuilder.CreateIndex( + name: "ix_sent_emails_email_sent_at", + table: "sent_emails", + columns: new[] { "email", "sent_at" } + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "sent_emails"); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index 4bd1ede..a9f59e6 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -1,5 +1,4 @@ // -using System; using System.Collections.Generic; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; @@ -20,7 +19,7 @@ namespace Foxnouns.Backend.Database.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("ProductVersion", "9.0.0") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -46,12 +45,12 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("text") .HasColumnName("name"); - b.Property("RedirectUris") + b.PrimitiveCollection("RedirectUris") .IsRequired() .HasColumnType("text[]") .HasColumnName("redirect_uris"); - b.Property("Scopes") + b.PrimitiveCollection("Scopes") .IsRequired() .HasColumnType("text[]") .HasColumnName("scopes"); @@ -193,7 +192,7 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("jsonb") .HasColumnName("fields"); - b.Property("Links") + b.PrimitiveCollection("Links") .IsRequired() .HasColumnType("text[]") .HasColumnName("links"); @@ -303,6 +302,33 @@ namespace Foxnouns.Backend.Database.Migrations b.ToTable("pride_flags", (string)null); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.SentEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("SentAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("sent_at"); + + b.HasKey("Id") + .HasName("pk_sent_emails"); + + b.HasIndex("Email", "SentAt") + .HasDatabaseName("ix_sent_emails_email_sent_at"); + + b.ToTable("sent_emails", (string)null); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => { b.Property("Id") @@ -359,7 +385,7 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("boolean") .HasColumnName("manually_expired"); - b.Property("Scopes") + b.PrimitiveCollection("Scopes") .IsRequired() .HasColumnType("text[]") .HasColumnName("scopes"); @@ -428,7 +454,7 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("last_sid_reroll"); - b.Property("Links") + b.PrimitiveCollection("Links") .IsRequired() .HasColumnType("text[]") .HasColumnName("links"); diff --git a/Foxnouns.Backend/Database/Models/SentEmail.cs b/Foxnouns.Backend/Database/Models/SentEmail.cs new file mode 100644 index 0000000..09f03d9 --- /dev/null +++ b/Foxnouns.Backend/Database/Models/SentEmail.cs @@ -0,0 +1,13 @@ +using System.ComponentModel.DataAnnotations.Schema; +using NodaTime; + +namespace Foxnouns.Backend.Database.Models; + +public class SentEmail +{ + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int Id { get; init; } + + public required string Email { get; init; } + public required Instant SentAt { get; init; } +} diff --git a/Foxnouns.Backend/Services/DataCleanupService.cs b/Foxnouns.Backend/Services/DataCleanupService.cs index 2b42f86..5e40c08 100644 --- a/Foxnouns.Backend/Services/DataCleanupService.cs +++ b/Foxnouns.Backend/Services/DataCleanupService.cs @@ -33,13 +33,24 @@ public class DataCleanupService( public async Task InvokeAsync(CancellationToken ct = default) { - _logger.Information("Cleaning up expired users"); + _logger.Debug("Cleaning up sent email cache"); + await CleanEmailsAsync(ct); + + _logger.Debug("Cleaning up expired users"); await CleanUsersAsync(ct); - _logger.Information("Cleaning up expired data exports"); + _logger.Debug("Cleaning up expired data exports"); await CleanExportsAsync(ct); } + private async Task CleanEmailsAsync(CancellationToken ct = default) + { + Instant expiry = clock.GetCurrentInstant() - Duration.FromHours(2); + int count = await db.SentEmails.Where(e => e.SentAt < expiry).ExecuteDeleteAsync(ct); + if (count != 0) + _logger.Information("Deleted {Count} entries from the sent email cache", expiry); + } + private async Task CleanUsersAsync(CancellationToken ct = default) { Instant selfDeleteExpires = clock.GetCurrentInstant() - User.DeleteAfter; diff --git a/Foxnouns.Backend/Services/MailService.cs b/Foxnouns.Backend/Services/MailService.cs index 9ae61bd..e162b33 100644 --- a/Foxnouns.Backend/Services/MailService.cs +++ b/Foxnouns.Backend/Services/MailService.cs @@ -12,13 +12,25 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . +using Coravel.Mailer.Mail; using Coravel.Mailer.Mail.Interfaces; using Coravel.Queuing.Interfaces; +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Mailables; +using Microsoft.EntityFrameworkCore; +using NodaTime; namespace Foxnouns.Backend.Services; -public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config config) +public class MailService( + ILogger logger, + IMailer mailer, + IQueue queue, + IClock clock, + Config config, + IServiceProvider serviceProvider +) { private readonly ILogger _logger = logger.ForContext(); @@ -26,25 +38,18 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co { queue.QueueAsyncTask(async () => { - _logger.Debug("Sending account creation email to {ToEmail}", to); - try - { - 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"); - } + await SendEmailAsync( + to, + new AccountCreationMailable( + config, + new AccountCreationMailableView + { + BaseUrl = config.BaseUrl, + To = to, + Code = code, + } + ) + ); }); } @@ -53,25 +58,53 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co _logger.Debug("Sending add email address email to {ToEmail}", to); queue.QueueAsyncTask(async () => { - try - { - await mailer.SendAsync( - new AddEmailMailable( - config, - new AddEmailMailableView - { - BaseUrl = config.BaseUrl, - To = to, - Code = code, - Username = username, - } - ) - ); - } - catch (Exception exc) - { - _logger.Error(exc, "Sending add email address email"); - } + await SendEmailAsync( + to, + new AddEmailMailable( + config, + new AddEmailMailableView + { + BaseUrl = config.BaseUrl, + To = to, + Code = code, + Username = username, + } + ) + ); }); } + + private async Task SendEmailAsync(string to, Mailable mailable) + { + try + { + // ReSharper disable SuggestVarOrType_SimpleTypes + await using var scope = serviceProvider.CreateAsyncScope(); + await using var db = scope.ServiceProvider.GetRequiredService(); + // ReSharper restore SuggestVarOrType_SimpleTypes + + Instant now = clock.GetCurrentInstant(); + + int count = await db.SentEmails.CountAsync(e => + e.Email == to && e.SentAt > (now - Duration.FromHours(1)) + ); + if (count >= 2) + { + _logger.Information( + "Have already sent 2 or more emails to {ToAddress} in the past hour, not sending new email", + to + ); + return; + } + + await mailer.SendAsync(mailable); + + db.SentEmails.Add(new SentEmail { Email = to, SentAt = now }); + await db.SaveChangesAsync(); + } + catch (Exception exc) + { + _logger.Error(exc, "Sending email"); + } + } } From 1ce4f9d278edc128655321192231147de8b47af7 Mon Sep 17 00:00:00 2001 From: sam Date: Wed, 11 Dec 2024 20:43:55 +0100 Subject: [PATCH 3/5] fix: favicon --- Foxnouns.Frontend/src/app.html | 2 +- Foxnouns.Frontend/static/favicon.png | Bin 1571 -> 0 bytes 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 Foxnouns.Frontend/static/favicon.png diff --git a/Foxnouns.Frontend/src/app.html b/Foxnouns.Frontend/src/app.html index 77a5ff5..1391f88 100644 --- a/Foxnouns.Frontend/src/app.html +++ b/Foxnouns.Frontend/src/app.html @@ -2,7 +2,7 @@ - + %sveltekit.head% diff --git a/Foxnouns.Frontend/static/favicon.png b/Foxnouns.Frontend/static/favicon.png deleted file mode 100644 index 825b9e65af7c104cfb07089bb28659393b4f2097..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1571 zcmV+;2Hg3HP)Px)-AP12RCwC$UE6KzI1p6{F2N z1VK2vi|pOpn{~#djwYcWXTI_im_u^TJgMZ4JMOsSj!0ma>B?-(Hr@X&W@|R-$}W@Z zgj#$x=!~7LGqHW?IO8+*oE1MyDp!G=L0#^lUx?;!fXv@l^6SvTnf^ac{5OurzC#ZMYc20lI%HhX816AYVs1T3heS1*WaWH z%;x>)-J}YB5#CLzU@GBR6sXYrD>Vw(Fmt#|JP;+}<#6b63Ike{Fuo!?M{yEffez;| zp!PfsuaC)>h>-AdbnwN13g*1LowNjT5?+lFVd#9$!8Z9HA|$*6dQ8EHLu}U|obW6f z2%uGv?vr=KNq7YYa2Roj;|zooo<)lf=&2yxM@e`kM$CmCR#x>gI>I|*Ubr({5Y^rb zghxQU22N}F51}^yfDSt786oMTc!W&V;d?76)9KXX1 z+6Okem(d}YXmmOiZq$!IPk5t8nnS{%?+vDFz3BevmFNgpIod~R{>@#@5x9zJKEHLHv!gHeK~n)Ld!M8DB|Kfe%~123&Hz1Z(86nU7*G5chmyDe ziV7$pB7pJ=96hpxHv9rCR29%bLOXlKU<_13_M8x)6;P8E1Kz6G<&P?$P^%c!M5`2` zfY2zg;VK5~^>TJGQzc+33-n~gKt{{of8GzUkWmU110IgI0DLxRIM>0US|TsM=L|@F z0Bun8U!cRB7-2apz=y-7*UxOxz@Z0)@QM)9wSGki1AZ38ceG7Q72z5`i;i=J`ILzL z@iUO?SBBG-0cQuo+an4TsLy-g-x;8P4UVwk|D8{W@U1Zi z!M)+jqy@nQ$p?5tsHp-6J304Q={v-B>66$P0IDx&YT(`IcZ~bZfmn11#rXd7<5s}y zBi9eim&zQc0Dk|2>$bs0PnLmDfMP5lcXRY&cvJ=zKxI^f0%-d$tD!`LBf9^jMSYUA zI8U?CWdY@}cRq6{5~y+)#h1!*-HcGW@+gZ4B};0OnC~`xQOyH19z*TA!!BJ%9s0V3F?CAJ{hTd#*tf+ur-W9MOURF-@B77_-OshsY}6 zOXRY=5%C^*26z?l)1=$bz30!so5tfABdSYzO+H=CpV~aaUefmjvfZ3Ttu9W&W3Iu6 zROlh0MFA5h;my}8lB0tAV-Rvc2Zs_CCSJnx@d`**$idgy-iMob4dJWWw|21b4NB=LfsYp0Aeh{Ov)yztQi;eL4y5 zMi>8^SzKqk8~k?UiQK^^-5d8c%bV?$F8%X~czyiaKCI2=UH Date: Wed, 11 Dec 2024 21:17:46 +0100 Subject: [PATCH 4/5] feat: use a FixedWindowRateLimiter keyed by IP to rate limit emails we don't talk about the sent_emails table :) --- .../Authentication/EmailAuthController.cs | 37 +++++++++++++++++++ Foxnouns.Backend/Database/DatabaseContext.cs | 2 - .../DatabaseContextModelSnapshot.cs | 27 -------------- Foxnouns.Backend/Database/Models/SentEmail.cs | 13 ------- .../Extensions/WebApplicationExtensions.cs | 1 + .../Services/DataCleanupService.cs | 11 ------ Foxnouns.Backend/Services/EmailRateLimiter.cs | 36 ++++++++++++++++++ Foxnouns.Backend/Services/MailService.cs | 35 +----------------- 8 files changed, 75 insertions(+), 87 deletions(-) delete mode 100644 Foxnouns.Backend/Database/Models/SentEmail.cs create mode 100644 Foxnouns.Backend/Services/EmailRateLimiter.cs diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index a3854a6..1587f87 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -36,6 +36,7 @@ public class EmailAuthController( DatabaseContext db, AuthService authService, MailService mailService, + EmailRateLimiter rateLimiter, KeyCacheService keyCacheService, UserRendererService userRenderer, IClock clock, @@ -68,6 +69,9 @@ public class EmailAuthController( return NoContent(); } + if (IsRateLimited()) + return NoContent(); + mailService.QueueAccountCreationEmail(req.Email, state); return NoContent(); } @@ -221,6 +225,9 @@ public class EmailAuthController( return NoContent(); } + if (IsRateLimited()) + return NoContent(); + mailService.QueueAddEmailAddressEmail(req.Email, state, CurrentUser.Username); return NoContent(); } @@ -274,4 +281,34 @@ public class EmailAuthController( if (!config.EmailAuth.Enabled) throw new ApiError.BadRequest("Email authentication is not enabled on this instance."); } + + /// + /// Checks whether the context's IP address is rate limited from dispatching emails. + /// + private bool IsRateLimited() + { + if (HttpContext.Connection.RemoteIpAddress == null) + { + _logger.Information( + "No remote IP address in HTTP context for email-related request, ignoring as we can't rate limit it" + ); + return true; + } + + if ( + !rateLimiter.IsLimited( + HttpContext.Connection.RemoteIpAddress.ToString(), + out Duration retryAfter + ) + ) + { + return false; + } + + _logger.Information( + "IP address cannot send email until {RetryAfter}, ignoring", + retryAfter + ); + return true; + } } diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index 7407c5b..ddf7853 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -66,7 +66,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) public DbSet Applications { get; init; } = null!; public DbSet TemporaryKeys { get; init; } = null!; public DbSet DataExports { get; init; } = null!; - public DbSet SentEmails { get; init; } = null!; public DbSet PrideFlags { get; init; } = null!; public DbSet UserFlags { get; init; } = null!; @@ -86,7 +85,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) modelBuilder.Entity().HasIndex(m => m.Sid).IsUnique(); modelBuilder.Entity().HasIndex(k => k.Key).IsUnique(); modelBuilder.Entity().HasIndex(d => d.Filename).IsUnique(); - modelBuilder.Entity().HasIndex(e => new { e.Email, e.SentAt }); // Two indexes on auth_methods, one for fediverse auth and one for all other types. modelBuilder diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index a9f59e6..cfe2513 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -302,33 +302,6 @@ namespace Foxnouns.Backend.Database.Migrations b.ToTable("pride_flags", (string)null); }); - modelBuilder.Entity("Foxnouns.Backend.Database.Models.SentEmail", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("integer") - .HasColumnName("id"); - - NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); - - b.Property("Email") - .IsRequired() - .HasColumnType("text") - .HasColumnName("email"); - - b.Property("SentAt") - .HasColumnType("timestamp with time zone") - .HasColumnName("sent_at"); - - b.HasKey("Id") - .HasName("pk_sent_emails"); - - b.HasIndex("Email", "SentAt") - .HasDatabaseName("ix_sent_emails_email_sent_at"); - - b.ToTable("sent_emails", (string)null); - }); - modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => { b.Property("Id") diff --git a/Foxnouns.Backend/Database/Models/SentEmail.cs b/Foxnouns.Backend/Database/Models/SentEmail.cs deleted file mode 100644 index 09f03d9..0000000 --- a/Foxnouns.Backend/Database/Models/SentEmail.cs +++ /dev/null @@ -1,13 +0,0 @@ -using System.ComponentModel.DataAnnotations.Schema; -using NodaTime; - -namespace Foxnouns.Backend.Database.Models; - -public class SentEmail -{ - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public int Id { get; init; } - - public required string Email { get; init; } - public required Instant SentAt { get; init; } -} diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 30d97d8..41c9712 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -110,6 +110,7 @@ public static class WebApplicationExtensions .AddSingleton(SystemClock.Instance) .AddSnowflakeGenerator() .AddSingleton() + .AddSingleton() .AddScoped() .AddScoped() .AddScoped() diff --git a/Foxnouns.Backend/Services/DataCleanupService.cs b/Foxnouns.Backend/Services/DataCleanupService.cs index 5e40c08..3d60462 100644 --- a/Foxnouns.Backend/Services/DataCleanupService.cs +++ b/Foxnouns.Backend/Services/DataCleanupService.cs @@ -33,9 +33,6 @@ public class DataCleanupService( public async Task InvokeAsync(CancellationToken ct = default) { - _logger.Debug("Cleaning up sent email cache"); - await CleanEmailsAsync(ct); - _logger.Debug("Cleaning up expired users"); await CleanUsersAsync(ct); @@ -43,14 +40,6 @@ public class DataCleanupService( await CleanExportsAsync(ct); } - private async Task CleanEmailsAsync(CancellationToken ct = default) - { - Instant expiry = clock.GetCurrentInstant() - Duration.FromHours(2); - int count = await db.SentEmails.Where(e => e.SentAt < expiry).ExecuteDeleteAsync(ct); - if (count != 0) - _logger.Information("Deleted {Count} entries from the sent email cache", expiry); - } - private async Task CleanUsersAsync(CancellationToken ct = default) { Instant selfDeleteExpires = clock.GetCurrentInstant() - User.DeleteAfter; diff --git a/Foxnouns.Backend/Services/EmailRateLimiter.cs b/Foxnouns.Backend/Services/EmailRateLimiter.cs new file mode 100644 index 0000000..9e73792 --- /dev/null +++ b/Foxnouns.Backend/Services/EmailRateLimiter.cs @@ -0,0 +1,36 @@ +using System.Collections.Concurrent; +using System.Threading.RateLimiting; +using NodaTime; +using NodaTime.Extensions; + +namespace Foxnouns.Backend.Services; + +public class EmailRateLimiter +{ + private readonly ConcurrentDictionary _limiters = new(); + + private readonly FixedWindowRateLimiterOptions _limiterOptions = + new() { Window = TimeSpan.FromHours(2), PermitLimit = 3 }; + + private RateLimiter GetLimiter(string bucket) => + _limiters.GetOrAdd(bucket, _ => new FixedWindowRateLimiter(_limiterOptions)); + + public bool IsLimited(string bucket, out Duration retryAfter) + { + RateLimiter limiter = GetLimiter(bucket); + RateLimitLease lease = limiter.AttemptAcquire(); + + if (!lease.IsAcquired) + { + retryAfter = lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan timeSpan) + ? timeSpan.ToDuration() + : default; + } + else + { + retryAfter = Duration.Zero; + } + + return !lease.IsAcquired; + } +} diff --git a/Foxnouns.Backend/Services/MailService.cs b/Foxnouns.Backend/Services/MailService.cs index e162b33..a1444d9 100644 --- a/Foxnouns.Backend/Services/MailService.cs +++ b/Foxnouns.Backend/Services/MailService.cs @@ -15,22 +15,11 @@ using Coravel.Mailer.Mail; using Coravel.Mailer.Mail.Interfaces; using Coravel.Queuing.Interfaces; -using Foxnouns.Backend.Database; -using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Mailables; -using Microsoft.EntityFrameworkCore; -using NodaTime; namespace Foxnouns.Backend.Services; -public class MailService( - ILogger logger, - IMailer mailer, - IQueue queue, - IClock clock, - Config config, - IServiceProvider serviceProvider -) +public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config config) { private readonly ILogger _logger = logger.ForContext(); @@ -78,29 +67,7 @@ public class MailService( { try { - // ReSharper disable SuggestVarOrType_SimpleTypes - await using var scope = serviceProvider.CreateAsyncScope(); - await using var db = scope.ServiceProvider.GetRequiredService(); - // ReSharper restore SuggestVarOrType_SimpleTypes - - Instant now = clock.GetCurrentInstant(); - - int count = await db.SentEmails.CountAsync(e => - e.Email == to && e.SentAt > (now - Duration.FromHours(1)) - ); - if (count >= 2) - { - _logger.Information( - "Have already sent 2 or more emails to {ToAddress} in the past hour, not sending new email", - to - ); - return; - } - await mailer.SendAsync(mailable); - - db.SentEmails.Add(new SentEmail { Email = to, SentAt = now }); - await db.SaveChangesAsync(); } catch (Exception exc) { From 77c3047b1ef02b9112ba2f90c11b4bb1ff9229bc Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 12 Dec 2024 16:44:01 +0100 Subject: [PATCH 5/5] feat: misskey auth --- .../Authentication/FediverseAuthController.cs | 9 +- Foxnouns.Backend/Dto/Auth.cs | 2 +- .../Extensions/KeyCacheExtensions.cs | 30 ++++ .../Auth/FediverseAuthService.Misskey.cs | 170 ++++++++++++++++++ .../Services/Auth/FediverseAuthService.cs | 14 +- .../mastodon/[instance]/+page.server.ts | 5 +- 6 files changed, 214 insertions(+), 16 deletions(-) create mode 100644 Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs diff --git a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs index d95c622..3dcc817 100644 --- a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs @@ -161,20 +161,13 @@ public class FediverseAuthController( [FromBody] FediverseCallbackRequest req ) { - await remoteAuthService.ValidateAddAccountStateAsync( - req.State, - CurrentUser!.Id, - AuthType.Fediverse, - req.Instance - ); - FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance); FediverseAuthService.FediverseUser remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code); try { AuthMethod authMethod = await authService.AddAuthMethodAsync( - CurrentUser.Id, + CurrentUser!.Id, AuthType.Fediverse, remoteUser.Id, remoteUser.Username, diff --git a/Foxnouns.Backend/Dto/Auth.cs b/Foxnouns.Backend/Dto/Auth.cs index 05308a5..ea9e67d 100644 --- a/Foxnouns.Backend/Dto/Auth.cs +++ b/Foxnouns.Backend/Dto/Auth.cs @@ -59,4 +59,4 @@ public record EmailCallbackRequest(string State); public record EmailChangePasswordRequest(string Current, string New); -public record FediverseCallbackRequest(string Instance, string Code, string State); +public record FediverseCallbackRequest(string Instance, string Code, string? State = null); diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs index 1d99830..d7e8784 100644 --- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -91,6 +91,34 @@ public static class KeyCacheExtensions string state, CancellationToken ct = default ) => await keyCacheService.GetKeyAsync($"add_account:{state}", true, ct); + + public static async Task GenerateForgotPasswordStateAsync( + this KeyCacheService keyCacheService, + string email, + Snowflake userId, + CancellationToken ct = default + ) + { + string state = AuthUtils.RandomToken(); + await keyCacheService.SetKeyAsync( + $"forgot_password:{state}", + new ForgotPasswordState(email, userId), + Duration.FromHours(1), + ct + ); + return state; + } + + public static async Task GetForgotPasswordStateAsync( + this KeyCacheService keyCacheService, + string state, + CancellationToken ct = default + ) => + await keyCacheService.GetKeyAsync( + $"forgot_password:{state}", + true, + ct + ); } public record RegisterEmailState( @@ -98,4 +126,6 @@ public record RegisterEmailState( [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId ); +public record ForgotPasswordState(string Email, Snowflake UserId); + public record AddExtraAccountState(AuthType AuthType, Snowflake UserId, string? Instance = null); diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs new file mode 100644 index 0000000..beff74a --- /dev/null +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs @@ -0,0 +1,170 @@ +// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +using System.Net; +using System.Text.Json.Serialization; +using System.Web; +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Extensions; + +namespace Foxnouns.Backend.Services.Auth; + +public partial class FediverseAuthService +{ + private static string MisskeyAppUri(string instance) => $"https://{instance}/api/app/create"; + + private static string MisskeyTokenUri(string instance) => + $"https://{instance}/api/auth/session/userkey"; + + private static string MisskeyGenerateSessionUri(string instance) => + $"https://{instance}/api/auth/session/generate"; + + private async Task CreateMisskeyApplicationAsync( + string instance, + Snowflake? existingAppId = null + ) + { + HttpResponseMessage resp = await _client.PostAsJsonAsync( + MisskeyAppUri(instance), + new CreateMisskeyApplicationRequest( + $"pronouns.cc (+{_config.BaseUrl})", + $"pronouns.cc on {_config.BaseUrl}", + ["read:account"], + MastodonRedirectUri(instance) + ) + ); + resp.EnsureSuccessStatusCode(); + + PartialMisskeyApplication? misskeyApp = + await resp.Content.ReadFromJsonAsync(); + if (misskeyApp == null) + { + throw new FoxnounsError( + $"Application created on Misskey-compatible instance {instance} was null" + ); + } + + FediverseApplication app; + + if (existingAppId == null) + { + app = new FediverseApplication + { + Id = existingAppId ?? _snowflakeGenerator.GenerateSnowflake(), + ClientId = misskeyApp.Id, + ClientSecret = misskeyApp.Secret, + Domain = instance, + InstanceType = FediverseInstanceType.MisskeyApi, + }; + + _db.Add(app); + } + else + { + app = + await _db.FediverseApplications.FindAsync(existingAppId) + ?? throw new FoxnounsError($"Existing app with ID {existingAppId} was null"); + + app.ClientId = misskeyApp.Id; + app.ClientSecret = misskeyApp.Secret; + app.InstanceType = FediverseInstanceType.MisskeyApi; + } + + await _db.SaveChangesAsync(); + + return app; + } + + private record GetMisskeySessionUserKeyRequest( + [property: JsonPropertyName("appSecret")] string Secret, + [property: JsonPropertyName("token")] string Token + ); + + private record GetMisskeySessionUserKeyResponse( + [property: JsonPropertyName("user")] FediverseUser User + ); + + private async Task GetMisskeyUserAsync(FediverseApplication app, string code) + { + HttpResponseMessage resp = await _client.PostAsJsonAsync( + MisskeyTokenUri(app.Domain), + new GetMisskeySessionUserKeyRequest(app.ClientSecret, code) + ); + if (resp.StatusCode == HttpStatusCode.Unauthorized) + { + throw new FoxnounsError($"Application for instance {app.Domain} was invalid"); + } + + resp.EnsureSuccessStatusCode(); + GetMisskeySessionUserKeyResponse? userResp = + await resp.Content.ReadFromJsonAsync(); + if (userResp == null) + { + throw new FoxnounsError($"User response from instance {app.Domain} was invalid"); + } + + return userResp.User; + } + + private async Task GenerateMisskeyAuthUrlAsync( + FediverseApplication app, + bool forceRefresh, + string? state = null + ) + { + if (forceRefresh) + { + _logger.Information( + "An app credentials refresh was requested for {ApplicationId}, creating a new application", + app.Id + ); + app = await CreateMisskeyApplicationAsync(app.Domain, app.Id); + } + + HttpResponseMessage resp = await _client.PostAsJsonAsync( + MisskeyGenerateSessionUri(app.Domain), + new CreateMisskeySessionUriRequest(app.ClientSecret) + ); + resp.EnsureSuccessStatusCode(); + + CreateMisskeySessionUriResponse? misskeyResp = + await resp.Content.ReadFromJsonAsync(); + if (misskeyResp == null) + throw new FoxnounsError($"Session create response for app {app.Id} was null"); + + return misskeyResp.Url; + } + + private record CreateMisskeySessionUriRequest( + [property: JsonPropertyName("appSecret")] string Secret + ); + + private record CreateMisskeySessionUriResponse( + [property: JsonPropertyName("token")] string Token, + [property: JsonPropertyName("url")] string Url + ); + + private record CreateMisskeyApplicationRequest( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("permission")] string[] Permissions, + [property: JsonPropertyName("callbackUrl")] string CallbackUrl + ); + + private record PartialMisskeyApplication( + [property: JsonPropertyName("id")] string Id, + [property: JsonPropertyName("secret")] string Secret + ); +} diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs index 7e67fa7..250455a 100644 --- a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs @@ -81,11 +81,11 @@ public partial class FediverseAuthService string softwareName = await GetSoftwareNameAsync(instance); if (IsMastodonCompatible(softwareName)) - { return await CreateMastodonApplicationAsync(instance); - } + if (IsMisskeyCompatible(softwareName)) + return await CreateMisskeyApplicationAsync(instance); - throw new NotImplementedException(); + throw new ApiError.BadRequest($"{softwareName} is not a supported instance type, sorry."); } private async Task GetSoftwareNameAsync(string instance) @@ -129,7 +129,11 @@ public partial class FediverseAuthService forceRefresh, state ), - FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), + FediverseInstanceType.MisskeyApi => await GenerateMisskeyAuthUrlAsync( + app, + forceRefresh, + state + ), _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), }; @@ -141,7 +145,7 @@ public partial class FediverseAuthService app.InstanceType switch { FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code, state), - FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), + FediverseInstanceType.MisskeyApi => await GetMisskeyUserAsync(app, code), _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), }; diff --git a/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.server.ts b/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.server.ts index fff5322..b092b1e 100644 --- a/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.server.ts +++ b/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.server.ts @@ -5,9 +5,10 @@ import createRegisterAction from "$lib/actions/register"; export const load = createCallbackLoader("fediverse", async ({ params, url }) => { const code = url.searchParams.get("code") as string | null; const state = url.searchParams.get("state") as string | null; - if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj; + const token = url.searchParams.get("token") as string | null; + if ((!code || !state) && !token) throw new ApiError(undefined, ErrorCode.BadRequest).obj; - return { code, state, instance: params.instance! }; + return { code: code || token, state, instance: params.instance! }; }); export const actions = {