Compare commits
5 commits
7f8e72e857
...
77c3047b1e
Author | SHA1 | Date | |
---|---|---|---|
77c3047b1e | |||
51e335f090 | |||
1ce4f9d278 | |||
ff8d53814d | |||
5cb3faa92b |
23 changed files with 448 additions and 90 deletions
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 =>
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>()
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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");
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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>()
|
||||||
|
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,7 +66,9 @@ 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)
|
||||||
|
options.InvalidModelStateResponseFactory = (ActionContext actionContext) =>
|
||||||
|
new BadRequestObjectResult(
|
||||||
new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson()
|
new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
170
Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs
Normal file
170
Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs
Normal 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
|
||||||
|
);
|
||||||
|
}
|
|
@ -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),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
36
Foxnouns.Backend/Services/EmailRateLimiter.cs
Normal file
36
Foxnouns.Backend/Services/EmailRateLimiter.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,10 +27,8 @@ 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,
|
||||||
{
|
|
||||||
await mailer.SendAsync(
|
|
||||||
new AccountCreationMailable(
|
new AccountCreationMailable(
|
||||||
config,
|
config,
|
||||||
new AccountCreationMailableView
|
new AccountCreationMailableView
|
||||||
|
@ -40,11 +39,6 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
|
||||||
catch (Exception exc)
|
|
||||||
{
|
|
||||||
_logger.Error(exc, "Sending account creation email");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -53,9 +47,8 @@ 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
|
||||||
|
@ -67,11 +60,18 @@ 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)
|
catch (Exception exc)
|
||||||
{
|
{
|
||||||
_logger.Error(exc, "Sending add email address email");
|
_logger.Error(exc, "Sending email");
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 =
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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 |
Loading…
Reference in a new issue