Compare commits

..

5 commits

Author SHA1 Message Date
sam
77c3047b1e
feat: misskey auth 2024-12-12 16:44:01 +01:00
sam
51e335f090
feat: use a FixedWindowRateLimiter keyed by IP to rate limit emails
we don't talk about the sent_emails table :)
2024-12-11 21:17:46 +01:00
sam
1ce4f9d278
fix: favicon 2024-12-11 20:43:55 +01:00
sam
ff8d53814d
feat: rate limit emails to two per address per hour 2024-12-11 20:42:48 +01:00
sam
5cb3faa92b
feat(backend): allow suspended users to access some endpoints, add flag scopes 2024-12-11 20:42:26 +01:00
23 changed files with 448 additions and 90 deletions

View file

@ -36,6 +36,7 @@ public class EmailAuthController(
DatabaseContext db, DatabaseContext db,
AuthService authService, AuthService authService,
MailService mailService, MailService mailService,
EmailRateLimiter rateLimiter,
KeyCacheService keyCacheService, KeyCacheService keyCacheService,
UserRendererService userRenderer, UserRendererService userRenderer,
IClock clock, IClock clock,
@ -68,6 +69,9 @@ public class EmailAuthController(
return NoContent(); return NoContent();
} }
if (IsRateLimited())
return NoContent();
mailService.QueueAccountCreationEmail(req.Email, state); mailService.QueueAccountCreationEmail(req.Email, state);
return NoContent(); return NoContent();
} }
@ -221,6 +225,9 @@ public class EmailAuthController(
return NoContent(); return NoContent();
} }
if (IsRateLimited())
return NoContent();
mailService.QueueAddEmailAddressEmail(req.Email, state, CurrentUser.Username); mailService.QueueAddEmailAddressEmail(req.Email, state, CurrentUser.Username);
return NoContent(); return NoContent();
} }
@ -274,4 +281,34 @@ public class EmailAuthController(
if (!config.EmailAuth.Enabled) if (!config.EmailAuth.Enabled)
throw new ApiError.BadRequest("Email authentication is not enabled on this instance."); throw new ApiError.BadRequest("Email authentication is not enabled on this instance.");
} }
/// <summary>
/// Checks whether the context's IP address is rate limited from dispatching emails.
/// </summary>
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;
}
} }

View file

@ -161,20 +161,13 @@ public class FediverseAuthController(
[FromBody] FediverseCallbackRequest req [FromBody] FediverseCallbackRequest req
) )
{ {
await remoteAuthService.ValidateAddAccountStateAsync(
req.State,
CurrentUser!.Id,
AuthType.Fediverse,
req.Instance
);
FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance); FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance);
FediverseAuthService.FediverseUser remoteUser = FediverseAuthService.FediverseUser remoteUser =
await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code); await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code);
try try
{ {
AuthMethod authMethod = await authService.AddAuthMethodAsync( AuthMethod authMethod = await authService.AddAuthMethodAsync(
CurrentUser.Id, CurrentUser!.Id,
AuthType.Fediverse, AuthType.Fediverse,
remoteUser.Id, remoteUser.Id,
remoteUser.Username, remoteUser.Username,

View file

@ -34,7 +34,8 @@ public class FlagsController(
) : ApiControllerBase ) : ApiControllerBase
{ {
[HttpGet] [HttpGet]
[Authorize("identify")] [Limit(UsableBySuspendedUsers = true)]
[Authorize("user.read_flags")]
[ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default) public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
{ {
@ -50,7 +51,7 @@ public class FlagsController(
public const int MaxFlagCount = 500; public const int MaxFlagCount = 500;
[HttpPost] [HttpPost]
[Authorize("user.update")] [Authorize("user.update_flags")]
[ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status202Accepted)] [ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status202Accepted)]
public async Task<IActionResult> CreateFlagAsync([FromBody] CreateFlagRequest req) public async Task<IActionResult> CreateFlagAsync([FromBody] CreateFlagRequest req)
{ {
@ -79,7 +80,7 @@ public class FlagsController(
} }
[HttpPatch("{id}")] [HttpPatch("{id}")]
[Authorize("user.update")] [Authorize("user.create_flags")]
[ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req) public async Task<IActionResult> UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req)
{ {
@ -104,7 +105,7 @@ public class FlagsController(
} }
[HttpDelete("{id}")] [HttpDelete("{id}")]
[Authorize("user.update")] [Authorize("user.update_flags")]
public async Task<IActionResult> DeleteFlagAsync(Snowflake id) public async Task<IActionResult> DeleteFlagAsync(Snowflake id)
{ {
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f => PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>

View file

@ -44,6 +44,7 @@ public class MembersController(
[HttpGet] [HttpGet]
[ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)] [ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)]
[Limit(UsableBySuspendedUsers = true)]
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default) public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
{ {
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
@ -52,6 +53,7 @@ public class MembersController(
[HttpGet("{memberRef}")] [HttpGet("{memberRef}")]
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)] [ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
[Limit(UsableBySuspendedUsers = true)]
public async Task<IActionResult> GetMemberAsync( public async Task<IActionResult> GetMemberAsync(
string userRef, string userRef,
string memberRef, string memberRef,

View file

@ -42,6 +42,7 @@ public class UsersController(
[HttpGet("{userRef}")] [HttpGet("{userRef}")]
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
[Limit(UsableBySuspendedUsers = true)]
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default) public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
{ {
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct); User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);

View file

@ -84,6 +84,9 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique(); modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique();
modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique(); modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique();
modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique(); modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique();
modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique();
// Two indexes on auth_methods, one for fediverse auth and one for all other types.
modelBuilder modelBuilder
.Entity<AuthMethod>() .Entity<AuthMethod>()
.HasIndex(m => new .HasIndex(m => new
@ -94,7 +97,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
}) })
.HasFilter("fediverse_application_id IS NOT NULL") .HasFilter("fediverse_application_id IS NOT NULL")
.IsUnique(); .IsUnique();
modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique();
modelBuilder modelBuilder
.Entity<AuthMethod>() .Entity<AuthMethod>()

View file

@ -31,6 +31,7 @@ public static class DatabaseQueryExtensions
{ {
if (userRef == "@me") if (userRef == "@me")
{ {
// Not filtering deleted users, as a suspended user should still be able to look at their own profile.
return token != null return token != null
? await context.Users.FirstAsync(u => u.Id == token.UserId, ct) ? await context.Users.FirstAsync(u => u.Id == token.UserId, ct)
: throw new ApiError.Unauthorized( : throw new ApiError.Unauthorized(
@ -43,14 +44,14 @@ public static class DatabaseQueryExtensions
if (Snowflake.TryParse(userRef, out Snowflake? snowflake)) if (Snowflake.TryParse(userRef, out Snowflake? snowflake))
{ {
user = await context 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); .FirstOrDefaultAsync(u => u.Id == snowflake, ct);
if (user != null) if (user != null)
return user; return user;
} }
user = await context 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); .FirstOrDefaultAsync(u => u.Username == userRef, ct);
if (user != null) if (user != null)
return user; return user;
@ -98,13 +99,14 @@ public static class DatabaseQueryExtensions
) )
{ {
User user = await context.ResolveUserAsync(userRef, token, ct); 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<Member> ResolveMemberAsync( public static async Task<Member> ResolveMemberAsync(
this DatabaseContext context, this DatabaseContext context,
Snowflake userId, Snowflake userId,
string memberRef, string memberRef,
Token? token = null,
CancellationToken ct = default CancellationToken ct = default
) )
{ {
@ -114,7 +116,8 @@ public static class DatabaseQueryExtensions
member = await context member = await context
.Members.Include(m => m.User) .Members.Include(m => m.User)
.Include(m => m.ProfileFlags) .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); .FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct);
if (member != null) if (member != null)
return member; return member;
@ -123,7 +126,8 @@ public static class DatabaseQueryExtensions
member = await context member = await context
.Members.Include(m => m.User) .Members.Include(m => m.User)
.Include(m => m.ProfileFlags) .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); .FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct);
if (member != null) if (member != null)
return member; return member;

View file

@ -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
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241211193653_AddSentEmailCache")]
public partial class AddSentEmailCache : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "sent_emails",
columns: table => new
{
id = table
.Column<int>(type: "integer", nullable: false)
.Annotation(
"Npgsql:ValueGenerationStrategy",
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
),
email = table.Column<string>(type: "text", nullable: false),
sent_at = table.Column<Instant>(
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" }
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "sent_emails");
}
}
}

View file

@ -1,5 +1,4 @@
// <auto-generated /> // <auto-generated />
using System;
using System.Collections.Generic; using System.Collections.Generic;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
@ -20,7 +19,7 @@ namespace Foxnouns.Backend.Database.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "8.0.7") .HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@ -46,12 +45,12 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("name"); .HasColumnName("name");
b.Property<string[]>("RedirectUris") b.PrimitiveCollection<string[]>("RedirectUris")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
.HasColumnName("redirect_uris"); .HasColumnName("redirect_uris");
b.Property<string[]>("Scopes") b.PrimitiveCollection<string[]>("Scopes")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
.HasColumnName("scopes"); .HasColumnName("scopes");
@ -193,7 +192,7 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("jsonb") .HasColumnType("jsonb")
.HasColumnName("fields"); .HasColumnName("fields");
b.Property<string[]>("Links") b.PrimitiveCollection<string[]>("Links")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
.HasColumnName("links"); .HasColumnName("links");
@ -359,7 +358,7 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("manually_expired"); .HasColumnName("manually_expired");
b.Property<string[]>("Scopes") b.PrimitiveCollection<string[]>("Scopes")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
.HasColumnName("scopes"); .HasColumnName("scopes");
@ -428,7 +427,7 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("timestamp with time zone") .HasColumnType("timestamp with time zone")
.HasColumnName("last_sid_reroll"); .HasColumnName("last_sid_reroll");
b.Property<string[]>("Links") b.PrimitiveCollection<string[]>("Links")
.IsRequired() .IsRequired()
.HasColumnType("text[]") .HasColumnType("text[]")
.HasColumnName("links"); .HasColumnName("links");

View file

@ -59,4 +59,4 @@ public record EmailCallbackRequest(string State);
public record EmailChangePasswordRequest(string Current, string New); 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);

View file

@ -91,6 +91,34 @@ public static class KeyCacheExtensions
string state, string state,
CancellationToken ct = default CancellationToken ct = default
) => await keyCacheService.GetKeyAsync<AddExtraAccountState>($"add_account:{state}", true, ct); ) => await keyCacheService.GetKeyAsync<AddExtraAccountState>($"add_account:{state}", true, ct);
public static async Task<string> 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<ForgotPasswordState?> GetForgotPasswordStateAsync(
this KeyCacheService keyCacheService,
string state,
CancellationToken ct = default
) =>
await keyCacheService.GetKeyAsync<ForgotPasswordState>(
$"forgot_password:{state}",
true,
ct
);
} }
public record RegisterEmailState( public record RegisterEmailState(
@ -98,4 +126,6 @@ public record RegisterEmailState(
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId
); );
public record ForgotPasswordState(string Email, Snowflake UserId);
public record AddExtraAccountState(AuthType AuthType, Snowflake UserId, string? Instance = null); public record AddExtraAccountState(AuthType AuthType, Snowflake UserId, string? Instance = null);

View file

@ -110,6 +110,7 @@ public static class WebApplicationExtensions
.AddSingleton<IClock>(SystemClock.Instance) .AddSingleton<IClock>(SystemClock.Instance)
.AddSnowflakeGenerator() .AddSnowflakeGenerator()
.AddSingleton<MailService>() .AddSingleton<MailService>()
.AddSingleton<EmailRateLimiter>()
.AddScoped<UserRendererService>() .AddScoped<UserRendererService>()
.AddScoped<MemberRendererService>() .AddScoped<MemberRendererService>()
.AddScoped<AuthService>() .AddScoped<AuthService>()

View file

@ -14,6 +14,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
namespace Foxnouns.Backend.Middleware; namespace Foxnouns.Backend.Middleware;
@ -22,9 +23,11 @@ public class AuthorizationMiddleware : IMiddleware
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{ {
Endpoint? endpoint = ctx.GetEndpoint(); Endpoint? endpoint = ctx.GetEndpoint();
AuthorizeAttribute? attribute = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>(); AuthorizeAttribute? authorizeAttribute =
endpoint?.Metadata.GetMetadata<AuthorizeAttribute>();
LimitAttribute? limitAttribute = endpoint?.Metadata.GetMetadata<LimitAttribute>();
if (attribute == null) if (authorizeAttribute == null || authorizeAttribute.Scopes.Length == 0)
{ {
await next(ctx); await next(ctx);
return; return;
@ -39,24 +42,35 @@ public class AuthorizationMiddleware : IMiddleware
); );
} }
// Users who got suspended by a moderator can still access *some* endpoints.
if ( if (
attribute.Scopes.Length > 0 token.User.Deleted
&& attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any() && (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( throw new ApiError.Forbidden(
"This endpoint requires ungranted scopes.", "This endpoint requires ungranted scopes.",
attribute.Scopes.Except(token.Scopes.ExpandScopes()), authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()),
ErrorCode.MissingScopes 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."); throw new ApiError.Forbidden("This endpoint can only be used by admins.");
}
if ( if (
attribute.RequireModerator limitAttribute?.RequireModerator == true
&& token.User.Role != UserRole.Admin && token.User.Role is not (UserRole.Admin or UserRole.Moderator)
&& token.User.Role != UserRole.Moderator
) )
{ {
throw new ApiError.Forbidden("This endpoint can only be used by moderators."); 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)] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthorizeAttribute(params string[] scopes) : Attribute public class AuthorizeAttribute(params string[] scopes) : Attribute
{ {
public readonly bool RequireAdmin = scopes.Contains(":admin"); public readonly string[] Scopes = scopes.Except([":admin", ":moderator", ":deleted"]).ToArray();
public readonly bool RequireModerator = scopes.Contains(":moderator"); }
public readonly string[] Scopes = scopes.Except([":admin", ":moderator"]).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; }
} }

View file

@ -66,9 +66,11 @@ builder
}) })
.ConfigureApiBehaviorOptions(options => .ConfigureApiBehaviorOptions(options =>
{ {
options.InvalidModelStateResponseFactory = actionContext => new BadRequestObjectResult( // the type isn't needed but without it, rider keeps complaining for no reason (it compiles just fine)
new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson() options.InvalidModelStateResponseFactory = (ActionContext actionContext) =>
); new BadRequestObjectResult(
new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson()
);
}); });
builder.Services.AddOpenApi( builder.Services.AddOpenApi(

View file

@ -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 <https://www.gnu.org/licenses/>.
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<FediverseApplication> 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<PartialMisskeyApplication>();
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<FediverseUser> 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<GetMisskeySessionUserKeyResponse>();
if (userResp == null)
{
throw new FoxnounsError($"User response from instance {app.Domain} was invalid");
}
return userResp.User;
}
private async Task<string> 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<CreateMisskeySessionUriResponse>();
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
);
}

View file

@ -81,11 +81,11 @@ public partial class FediverseAuthService
string softwareName = await GetSoftwareNameAsync(instance); string softwareName = await GetSoftwareNameAsync(instance);
if (IsMastodonCompatible(softwareName)) if (IsMastodonCompatible(softwareName))
{
return await CreateMastodonApplicationAsync(instance); 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<string> GetSoftwareNameAsync(string instance) private async Task<string> GetSoftwareNameAsync(string instance)
@ -129,7 +129,11 @@ public partial class FediverseAuthService
forceRefresh, forceRefresh,
state state
), ),
FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), FediverseInstanceType.MisskeyApi => await GenerateMisskeyAuthUrlAsync(
app,
forceRefresh,
state
),
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
}; };
@ -141,7 +145,7 @@ public partial class FediverseAuthService
app.InstanceType switch app.InstanceType switch
{ {
FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code, state), 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), _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
}; };

View file

@ -33,10 +33,10 @@ public class DataCleanupService(
public async Task InvokeAsync(CancellationToken ct = default) public async Task InvokeAsync(CancellationToken ct = default)
{ {
_logger.Information("Cleaning up expired users"); _logger.Debug("Cleaning up expired users");
await CleanUsersAsync(ct); await CleanUsersAsync(ct);
_logger.Information("Cleaning up expired data exports"); _logger.Debug("Cleaning up expired data exports");
await CleanExportsAsync(ct); await CleanExportsAsync(ct);
} }

View file

@ -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<string, RateLimiter> _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;
}
}

View file

@ -12,6 +12,7 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
using Coravel.Mailer.Mail;
using Coravel.Mailer.Mail.Interfaces; using Coravel.Mailer.Mail.Interfaces;
using Coravel.Queuing.Interfaces; using Coravel.Queuing.Interfaces;
using Foxnouns.Backend.Mailables; using Foxnouns.Backend.Mailables;
@ -26,25 +27,18 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
{ {
queue.QueueAsyncTask(async () => queue.QueueAsyncTask(async () =>
{ {
_logger.Debug("Sending account creation email to {ToEmail}", to); await SendEmailAsync(
try to,
{ new AccountCreationMailable(
await mailer.SendAsync( config,
new AccountCreationMailable( new AccountCreationMailableView
config, {
new AccountCreationMailableView BaseUrl = config.BaseUrl,
{ To = to,
BaseUrl = config.BaseUrl, Code = code,
To = to, }
Code = code, )
} );
)
);
}
catch (Exception exc)
{
_logger.Error(exc, "Sending account creation email");
}
}); });
} }
@ -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); _logger.Debug("Sending add email address email to {ToEmail}", to);
queue.QueueAsyncTask(async () => queue.QueueAsyncTask(async () =>
{ {
try await SendEmailAsync(
{ to,
await mailer.SendAsync( new AddEmailMailable(
new AddEmailMailable( config,
config, new AddEmailMailableView
new AddEmailMailableView {
{ BaseUrl = config.BaseUrl,
BaseUrl = config.BaseUrl, To = to,
To = to, Code = code,
Code = code, Username = username,
Username = username, }
} )
) );
);
}
catch (Exception exc)
{
_logger.Error(exc, "Sending add email address email");
}
}); });
} }
private async Task SendEmailAsync<T>(string to, Mailable<T> mailable)
{
try
{
await mailer.SendAsync(mailable);
}
catch (Exception exc)
{
_logger.Error(exc, "Sending email");
}
}
} }

View file

@ -35,6 +35,9 @@ public static class AuthUtils
"user.read_hidden", "user.read_hidden",
"user.read_privileged", "user.read_privileged",
"user.update", "user.update",
"user.read_flags",
"user.create_flags",
"user.update_flags",
]; ];
public static readonly string[] MemberScopes = public static readonly string[] MemberScopes =

View file

@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
</head> </head>

View file

@ -5,9 +5,10 @@ import createRegisterAction from "$lib/actions/register";
export const load = createCallbackLoader("fediverse", async ({ params, url }) => { export const load = createCallbackLoader("fediverse", async ({ params, url }) => {
const code = url.searchParams.get("code") as string | null; const code = url.searchParams.get("code") as string | null;
const state = url.searchParams.get("state") 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 = { export const actions = {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB