Compare commits
No commits in common. "77c3047b1ef02b9112ba2f90c11b4bb1ff9229bc" and "7f8e72e857f67d6dbc3b607cffc4dcdc432eb28a" have entirely different histories.
77c3047b1e
...
7f8e72e857
23 changed files with 89 additions and 447 deletions
|
@ -36,7 +36,6 @@ public class EmailAuthController(
|
|||
DatabaseContext db,
|
||||
AuthService authService,
|
||||
MailService mailService,
|
||||
EmailRateLimiter rateLimiter,
|
||||
KeyCacheService keyCacheService,
|
||||
UserRendererService userRenderer,
|
||||
IClock clock,
|
||||
|
@ -69,9 +68,6 @@ public class EmailAuthController(
|
|||
return NoContent();
|
||||
}
|
||||
|
||||
if (IsRateLimited())
|
||||
return NoContent();
|
||||
|
||||
mailService.QueueAccountCreationEmail(req.Email, state);
|
||||
return NoContent();
|
||||
}
|
||||
|
@ -225,9 +221,6 @@ public class EmailAuthController(
|
|||
return NoContent();
|
||||
}
|
||||
|
||||
if (IsRateLimited())
|
||||
return NoContent();
|
||||
|
||||
mailService.QueueAddEmailAddressEmail(req.Email, state, CurrentUser.Username);
|
||||
return NoContent();
|
||||
}
|
||||
|
@ -281,34 +274,4 @@ public class EmailAuthController(
|
|||
if (!config.EmailAuth.Enabled)
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -161,13 +161,20 @@ 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,
|
||||
|
|
|
@ -34,8 +34,7 @@ public class FlagsController(
|
|||
) : ApiControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[Limit(UsableBySuspendedUsers = true)]
|
||||
[Authorize("user.read_flags")]
|
||||
[Authorize("identify")]
|
||||
[ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
|
||||
{
|
||||
|
@ -51,7 +50,7 @@ public class FlagsController(
|
|||
public const int MaxFlagCount = 500;
|
||||
|
||||
[HttpPost]
|
||||
[Authorize("user.update_flags")]
|
||||
[Authorize("user.update")]
|
||||
[ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status202Accepted)]
|
||||
public async Task<IActionResult> CreateFlagAsync([FromBody] CreateFlagRequest req)
|
||||
{
|
||||
|
@ -80,7 +79,7 @@ public class FlagsController(
|
|||
}
|
||||
|
||||
[HttpPatch("{id}")]
|
||||
[Authorize("user.create_flags")]
|
||||
[Authorize("user.update")]
|
||||
[ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req)
|
||||
{
|
||||
|
@ -105,7 +104,7 @@ public class FlagsController(
|
|||
}
|
||||
|
||||
[HttpDelete("{id}")]
|
||||
[Authorize("user.update_flags")]
|
||||
[Authorize("user.update")]
|
||||
public async Task<IActionResult> DeleteFlagAsync(Snowflake id)
|
||||
{
|
||||
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
||||
|
|
|
@ -44,7 +44,6 @@ public class MembersController(
|
|||
|
||||
[HttpGet]
|
||||
[ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)]
|
||||
[Limit(UsableBySuspendedUsers = true)]
|
||||
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
|
||||
{
|
||||
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||
|
@ -53,7 +52,6 @@ public class MembersController(
|
|||
|
||||
[HttpGet("{memberRef}")]
|
||||
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
||||
[Limit(UsableBySuspendedUsers = true)]
|
||||
public async Task<IActionResult> GetMemberAsync(
|
||||
string userRef,
|
||||
string memberRef,
|
||||
|
|
|
@ -42,7 +42,6 @@ public class UsersController(
|
|||
|
||||
[HttpGet("{userRef}")]
|
||||
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||
[Limit(UsableBySuspendedUsers = true)]
|
||||
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
|
||||
{
|
||||
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||
|
|
|
@ -84,9 +84,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
|
|||
modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique();
|
||||
modelBuilder.Entity<Member>().HasIndex(m => m.Sid).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
|
||||
.Entity<AuthMethod>()
|
||||
.HasIndex(m => new
|
||||
|
@ -97,6 +94,7 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
|
|||
})
|
||||
.HasFilter("fediverse_application_id IS NOT NULL")
|
||||
.IsUnique();
|
||||
modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique();
|
||||
|
||||
modelBuilder
|
||||
.Entity<AuthMethod>()
|
||||
|
|
|
@ -31,7 +31,6 @@ 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(
|
||||
|
@ -44,14 +43,14 @@ public static class DatabaseQueryExtensions
|
|||
if (Snowflake.TryParse(userRef, out Snowflake? snowflake))
|
||||
{
|
||||
user = await context
|
||||
.Users.Where(u => !u.Deleted || (token != null && token.UserId == u.Id))
|
||||
.Users.Where(u => !u.Deleted)
|
||||
.FirstOrDefaultAsync(u => u.Id == snowflake, ct);
|
||||
if (user != null)
|
||||
return user;
|
||||
}
|
||||
|
||||
user = await context
|
||||
.Users.Where(u => !u.Deleted || (token != null && token.UserId == u.Id))
|
||||
.Users.Where(u => !u.Deleted)
|
||||
.FirstOrDefaultAsync(u => u.Username == userRef, ct);
|
||||
if (user != null)
|
||||
return user;
|
||||
|
@ -99,14 +98,13 @@ public static class DatabaseQueryExtensions
|
|||
)
|
||||
{
|
||||
User user = await context.ResolveUserAsync(userRef, token, ct);
|
||||
return await context.ResolveMemberAsync(user.Id, memberRef, token, ct);
|
||||
return await context.ResolveMemberAsync(user.Id, memberRef, ct);
|
||||
}
|
||||
|
||||
public static async Task<Member> ResolveMemberAsync(
|
||||
this DatabaseContext context,
|
||||
Snowflake userId,
|
||||
string memberRef,
|
||||
Token? token = null,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
|
@ -116,8 +114,7 @@ public static class DatabaseQueryExtensions
|
|||
member = await context
|
||||
.Members.Include(m => m.User)
|
||||
.Include(m => m.ProfileFlags)
|
||||
// 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))
|
||||
.Where(m => !m.User.Deleted)
|
||||
.FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct);
|
||||
if (member != null)
|
||||
return member;
|
||||
|
@ -126,8 +123,7 @@ public static class DatabaseQueryExtensions
|
|||
member = await context
|
||||
.Members.Include(m => m.User)
|
||||
.Include(m => m.ProfileFlags)
|
||||
// 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))
|
||||
.Where(m => !m.User.Deleted)
|
||||
.FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct);
|
||||
if (member != null)
|
||||
return member;
|
||||
|
|
|
@ -1,53 +0,0 @@
|
|||
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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
|
@ -19,7 +20,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.0")
|
||||
.HasAnnotation("ProductVersion", "8.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
@ -45,12 +46,12 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.PrimitiveCollection<string[]>("RedirectUris")
|
||||
b.Property<string[]>("RedirectUris")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]")
|
||||
.HasColumnName("redirect_uris");
|
||||
|
||||
b.PrimitiveCollection<string[]>("Scopes")
|
||||
b.Property<string[]>("Scopes")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]")
|
||||
.HasColumnName("scopes");
|
||||
|
@ -192,7 +193,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
.HasColumnType("jsonb")
|
||||
.HasColumnName("fields");
|
||||
|
||||
b.PrimitiveCollection<string[]>("Links")
|
||||
b.Property<string[]>("Links")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]")
|
||||
.HasColumnName("links");
|
||||
|
@ -358,7 +359,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
.HasColumnType("boolean")
|
||||
.HasColumnName("manually_expired");
|
||||
|
||||
b.PrimitiveCollection<string[]>("Scopes")
|
||||
b.Property<string[]>("Scopes")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]")
|
||||
.HasColumnName("scopes");
|
||||
|
@ -427,7 +428,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_sid_reroll");
|
||||
|
||||
b.PrimitiveCollection<string[]>("Links")
|
||||
b.Property<string[]>("Links")
|
||||
.IsRequired()
|
||||
.HasColumnType("text[]")
|
||||
.HasColumnName("links");
|
||||
|
|
|
@ -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 = null);
|
||||
public record FediverseCallbackRequest(string Instance, string Code, string State);
|
||||
|
|
|
@ -91,34 +91,6 @@ public static class KeyCacheExtensions
|
|||
string state,
|
||||
CancellationToken ct = default
|
||||
) => 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(
|
||||
|
@ -126,6 +98,4 @@ 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);
|
||||
|
|
|
@ -110,7 +110,6 @@ public static class WebApplicationExtensions
|
|||
.AddSingleton<IClock>(SystemClock.Instance)
|
||||
.AddSnowflakeGenerator()
|
||||
.AddSingleton<MailService>()
|
||||
.AddSingleton<EmailRateLimiter>()
|
||||
.AddScoped<UserRendererService>()
|
||||
.AddScoped<MemberRendererService>()
|
||||
.AddScoped<AuthService>()
|
||||
|
|
|
@ -14,7 +14,6 @@
|
|||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Utils;
|
||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||
|
||||
namespace Foxnouns.Backend.Middleware;
|
||||
|
||||
|
@ -23,11 +22,9 @@ public class AuthorizationMiddleware : IMiddleware
|
|||
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
||||
{
|
||||
Endpoint? endpoint = ctx.GetEndpoint();
|
||||
AuthorizeAttribute? authorizeAttribute =
|
||||
endpoint?.Metadata.GetMetadata<AuthorizeAttribute>();
|
||||
LimitAttribute? limitAttribute = endpoint?.Metadata.GetMetadata<LimitAttribute>();
|
||||
AuthorizeAttribute? attribute = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>();
|
||||
|
||||
if (authorizeAttribute == null || authorizeAttribute.Scopes.Length == 0)
|
||||
if (attribute == null)
|
||||
{
|
||||
await next(ctx);
|
||||
return;
|
||||
|
@ -42,35 +39,24 @@ public class AuthorizationMiddleware : IMiddleware
|
|||
);
|
||||
}
|
||||
|
||||
// Users who got suspended by a moderator can still access *some* endpoints.
|
||||
if (
|
||||
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()
|
||||
attribute.Scopes.Length > 0
|
||||
&& attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()
|
||||
)
|
||||
{
|
||||
throw new ApiError.Forbidden(
|
||||
"This endpoint requires ungranted scopes.",
|
||||
authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()),
|
||||
attribute.Scopes.Except(token.Scopes.ExpandScopes()),
|
||||
ErrorCode.MissingScopes
|
||||
);
|
||||
}
|
||||
|
||||
if (limitAttribute?.RequireAdmin == true && token.User.Role != UserRole.Admin)
|
||||
{
|
||||
if (attribute.RequireAdmin && token.User.Role != UserRole.Admin)
|
||||
throw new ApiError.Forbidden("This endpoint can only be used by admins.");
|
||||
}
|
||||
|
||||
if (
|
||||
limitAttribute?.RequireModerator == true
|
||||
&& token.User.Role is not (UserRole.Admin or UserRole.Moderator)
|
||||
attribute.RequireModerator
|
||||
&& token.User.Role != UserRole.Admin
|
||||
&& token.User.Role != UserRole.Moderator
|
||||
)
|
||||
{
|
||||
throw new ApiError.Forbidden("This endpoint can only be used by moderators.");
|
||||
|
@ -83,13 +69,8 @@ public class AuthorizationMiddleware : IMiddleware
|
|||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public class AuthorizeAttribute(params string[] scopes) : Attribute
|
||||
{
|
||||
public readonly string[] Scopes = scopes.Except([":admin", ":moderator", ":deleted"]).ToArray();
|
||||
}
|
||||
public readonly bool RequireAdmin = scopes.Contains(":admin");
|
||||
public readonly bool RequireModerator = scopes.Contains(":moderator");
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public class LimitAttribute : Attribute
|
||||
{
|
||||
public bool UsableBySuspendedUsers { get; init; }
|
||||
public bool RequireAdmin { get; init; }
|
||||
public bool RequireModerator { get; init; }
|
||||
public readonly string[] Scopes = scopes.Except([":admin", ":moderator"]).ToArray();
|
||||
}
|
||||
|
|
|
@ -66,9 +66,7 @@ builder
|
|||
})
|
||||
.ConfigureApiBehaviorOptions(options =>
|
||||
{
|
||||
// the type isn't needed but without it, rider keeps complaining for no reason (it compiles just fine)
|
||||
options.InvalidModelStateResponseFactory = (ActionContext actionContext) =>
|
||||
new BadRequestObjectResult(
|
||||
options.InvalidModelStateResponseFactory = actionContext => new BadRequestObjectResult(
|
||||
new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson()
|
||||
);
|
||||
});
|
||||
|
|
|
@ -1,170 +0,0 @@
|
|||
// 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
|
||||
);
|
||||
}
|
|
@ -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 ApiError.BadRequest($"{softwareName} is not a supported instance type, sorry.");
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private async Task<string> GetSoftwareNameAsync(string instance)
|
||||
|
@ -129,11 +129,7 @@ public partial class FediverseAuthService
|
|||
forceRefresh,
|
||||
state
|
||||
),
|
||||
FediverseInstanceType.MisskeyApi => await GenerateMisskeyAuthUrlAsync(
|
||||
app,
|
||||
forceRefresh,
|
||||
state
|
||||
),
|
||||
FediverseInstanceType.MisskeyApi => throw new NotImplementedException(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
|
||||
};
|
||||
|
||||
|
@ -145,7 +141,7 @@ public partial class FediverseAuthService
|
|||
app.InstanceType switch
|
||||
{
|
||||
FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code, state),
|
||||
FediverseInstanceType.MisskeyApi => await GetMisskeyUserAsync(app, code),
|
||||
FediverseInstanceType.MisskeyApi => throw new NotImplementedException(),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
|
||||
};
|
||||
|
||||
|
|
|
@ -33,10 +33,10 @@ public class DataCleanupService(
|
|||
|
||||
public async Task InvokeAsync(CancellationToken ct = default)
|
||||
{
|
||||
_logger.Debug("Cleaning up expired users");
|
||||
_logger.Information("Cleaning up expired users");
|
||||
await CleanUsersAsync(ct);
|
||||
|
||||
_logger.Debug("Cleaning up expired data exports");
|
||||
_logger.Information("Cleaning up expired data exports");
|
||||
await CleanExportsAsync(ct);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,36 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -12,7 +12,6 @@
|
|||
//
|
||||
// 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 Coravel.Mailer.Mail;
|
||||
using Coravel.Mailer.Mail.Interfaces;
|
||||
using Coravel.Queuing.Interfaces;
|
||||
using Foxnouns.Backend.Mailables;
|
||||
|
@ -27,8 +26,10 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
|
|||
{
|
||||
queue.QueueAsyncTask(async () =>
|
||||
{
|
||||
await SendEmailAsync(
|
||||
to,
|
||||
_logger.Debug("Sending account creation email to {ToEmail}", to);
|
||||
try
|
||||
{
|
||||
await mailer.SendAsync(
|
||||
new AccountCreationMailable(
|
||||
config,
|
||||
new AccountCreationMailableView
|
||||
|
@ -39,6 +40,11 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
|
|||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
catch (Exception exc)
|
||||
{
|
||||
_logger.Error(exc, "Sending account creation email");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -47,8 +53,9 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
|
|||
_logger.Debug("Sending add email address email to {ToEmail}", to);
|
||||
queue.QueueAsyncTask(async () =>
|
||||
{
|
||||
await SendEmailAsync(
|
||||
to,
|
||||
try
|
||||
{
|
||||
await mailer.SendAsync(
|
||||
new AddEmailMailable(
|
||||
config,
|
||||
new AddEmailMailableView
|
||||
|
@ -60,18 +67,11 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
|
|||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async Task SendEmailAsync<T>(string to, Mailable<T> mailable)
|
||||
{
|
||||
try
|
||||
{
|
||||
await mailer.SendAsync(mailable);
|
||||
}
|
||||
catch (Exception exc)
|
||||
{
|
||||
_logger.Error(exc, "Sending email");
|
||||
_logger.Error(exc, "Sending add email address email");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,9 +35,6 @@ 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 =
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
|
|
@ -5,10 +5,9 @@ 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;
|
||||
const token = url.searchParams.get("token") as string | null;
|
||||
if ((!code || !state) && !token) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
|
||||
if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
|
||||
|
||||
return { code: code || token, state, instance: params.instance! };
|
||||
return { code, state, instance: params.instance! };
|
||||
});
|
||||
|
||||
export const actions = {
|
||||
|
|
BIN
Foxnouns.Frontend/static/favicon.png
Normal file
BIN
Foxnouns.Frontend/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
Loading…
Reference in a new issue