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/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/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/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index a5dede8..ddf7853 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -84,6 +84,9 @@ 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(); + + // Two indexes on auth_methods, one for fediverse auth and one for all other types. modelBuilder .Entity() .HasIndex(m => new @@ -94,7 +97,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/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/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..cfe2513 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"); @@ -359,7 +358,7 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("boolean") .HasColumnName("manually_expired"); - b.Property("Scopes") + b.PrimitiveCollection("Scopes") .IsRequired() .HasColumnType("text[]") .HasColumnName("scopes"); @@ -428,7 +427,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/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/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/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/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.Backend/Services/DataCleanupService.cs b/Foxnouns.Backend/Services/DataCleanupService.cs index 2b42f86..3d60462 100644 --- a/Foxnouns.Backend/Services/DataCleanupService.cs +++ b/Foxnouns.Backend/Services/DataCleanupService.cs @@ -33,10 +33,10 @@ public class DataCleanupService( public async Task InvokeAsync(CancellationToken ct = default) { - _logger.Information("Cleaning up expired users"); + _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); } 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 9ae61bd..a1444d9 100644 --- a/Foxnouns.Backend/Services/MailService.cs +++ b/Foxnouns.Backend/Services/MailService.cs @@ -12,6 +12,7 @@ // // 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.Mailables; @@ -26,25 +27,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 +47,31 @@ 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 + { + await mailer.SendAsync(mailable); + } + catch (Exception exc) + { + _logger.Error(exc, "Sending email"); + } + } } 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 = 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/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 = { diff --git a/Foxnouns.Frontend/static/favicon.png b/Foxnouns.Frontend/static/favicon.png deleted file mode 100644 index 825b9e6..0000000 Binary files a/Foxnouns.Frontend/static/favicon.png and /dev/null differ