Compare commits
No commits in common. "77c3047b1ef02b9112ba2f90c11b4bb1ff9229bc" and "7f8e72e857f67d6dbc3b607cffc4dcdc432eb28a" have entirely different histories.
77c3047b1e
...
7f8e72e857
23 changed files with 89 additions and 447 deletions
Foxnouns.Backend
Controllers
Database
Dto
Extensions
Middleware
Program.csServices
Utils
Foxnouns.Frontend
|
@ -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 ![]() (image error) Size: 1.5 KiB |
Loading…
Add table
Add a link
Reference in a new issue