From d0bf638a21f73ef433963c56d718690e9e6745c0 Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 23 Nov 2024 20:40:09 +0100 Subject: [PATCH 1/7] fix: check for obviously invalid instance URLs, use correct JSON key for mastodon scopes --- .../Authentication/FediverseAuthController.cs | 4 +++- .../Auth/FediverseAuthService.Mastodon.cs | 24 ++++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs index 43a2955..8dca588 100644 --- a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs @@ -6,7 +6,6 @@ using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using NodaTime; -using FediverseAuthService = Foxnouns.Backend.Services.Auth.FediverseAuthService; namespace Foxnouns.Backend.Controllers.Authentication; @@ -25,6 +24,9 @@ public class FediverseAuthController( [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task GetFediverseUrlAsync([FromQuery] string instance) { + if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.')) + throw new ApiError.BadRequest("Not a valid domain.", "instance", instance); + var url = await fediverseAuthService.GenerateAuthUrlAsync(instance); return Ok(new FediverseUrlResponse(url)); } diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs index 139830b..665e07f 100644 --- a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Web; using Foxnouns.Backend.Database; @@ -17,16 +18,13 @@ public partial class FediverseAuthService Snowflake? existingAppId = null ) { - var resp = await _client.PostAsync( + var resp = await _client.PostAsJsonAsync( $"https://{instance}/api/v1/apps", - new FormUrlEncodedContent( - new Dictionary - { - { "client_name", $"pronouns.cc (+{_config.BaseUrl})" }, - { "redirect_uris", MastodonRedirectUri(instance) }, - { "scope", "read:accounts" }, - { "website", _config.BaseUrl }, - } + new CreateMastodonApplicationRequest( + ClientName: $"pronouns.cc (+{_config.BaseUrl})", + RedirectUris: MastodonRedirectUri(instance), + Scopes: "read read:accounts", + Website: _config.BaseUrl ) ); resp.EnsureSuccessStatusCode(); @@ -237,9 +235,17 @@ public partial class FediverseAuthService private static string MastodonCurrentAppUri(string instance) => $"https://{instance}/api/v1/apps/verify_credentials"; + [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] private record PartialMastodonApplication( [property: J("name")] string Name, [property: J("client_id")] string ClientId, [property: J("client_secret")] string ClientSecret ); + + private record CreateMastodonApplicationRequest( + [property: J("client_name")] string ClientName, + [property: J("redirect_uris")] string RedirectUris, + [property: J("scopes")] string Scopes, + [property: J("website")] string Website + ); } From 6abf505c40e3bb22cb08474a90581a98b19d8622 Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 23 Nov 2024 20:41:11 +0100 Subject: [PATCH 2/7] refactor: make Member.display_name non-nullable and fall back to Member.name --- Foxnouns.Backend/Services/MemberRendererService.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Foxnouns.Backend/Services/MemberRendererService.cs b/Foxnouns.Backend/Services/MemberRendererService.cs index f8588c2..7d7cac0 100644 --- a/Foxnouns.Backend/Services/MemberRendererService.cs +++ b/Foxnouns.Backend/Services/MemberRendererService.cs @@ -32,7 +32,7 @@ public class MemberRendererService(DatabaseContext db, Config config) member.Id, member.Sid, member.Name, - member.DisplayName, + member.DisplayName ?? member.Name, member.Bio, AvatarUrlFor(member), member.Links, @@ -60,7 +60,7 @@ public class MemberRendererService(DatabaseContext db, Config config) member.Id, member.Sid, member.Name, - member.DisplayName, + member.DisplayName ?? member.Name, member.Bio, AvatarUrlFor(member), member.Names, @@ -87,7 +87,7 @@ public class MemberRendererService(DatabaseContext db, Config config) Snowflake Id, string Sid, string Name, - string? DisplayName, + string DisplayName, string? Bio, string? AvatarUrl, IEnumerable Names, @@ -99,7 +99,7 @@ public class MemberRendererService(DatabaseContext db, Config config) Snowflake Id, string Sid, string Name, - string? DisplayName, + string DisplayName, string? Bio, string? AvatarUrl, string[] Links, From d87856bf2c77c662f8231030acc41e8002cfa358 Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 23 Nov 2024 20:41:41 +0100 Subject: [PATCH 3/7] refactor: change ConvertBase64UriToImage from extension method to static method --- .../{AvatarObjectExtensions.cs => ImageObjectExtensions.cs} | 4 ++-- Foxnouns.Backend/Jobs/CreateFlagInvocable.cs | 3 ++- Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs | 6 +++++- Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs | 6 +++++- 4 files changed, 14 insertions(+), 5 deletions(-) rename Foxnouns.Backend/Extensions/{AvatarObjectExtensions.cs => ImageObjectExtensions.cs} (97%) diff --git a/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs b/Foxnouns.Backend/Extensions/ImageObjectExtensions.cs similarity index 97% rename from Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs rename to Foxnouns.Backend/Extensions/ImageObjectExtensions.cs index efa2d60..2126610 100644 --- a/Foxnouns.Backend/Extensions/AvatarObjectExtensions.cs +++ b/Foxnouns.Backend/Extensions/ImageObjectExtensions.cs @@ -10,7 +10,7 @@ using SixLabors.ImageSharp.Processing.Processors.Transforms; namespace Foxnouns.Backend.Extensions; -public static class AvatarObjectExtensions +public static class ImageObjectExtensions { private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"]; @@ -39,7 +39,7 @@ public static class AvatarObjectExtensions ) => await objectStorageService.RemoveObjectAsync(CreateFlagInvocable.Path(hash), ct); public static async Task<(string Hash, Stream Image)> ConvertBase64UriToImage( - this string uri, + string uri, int size, bool crop ) diff --git a/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs b/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs index cfe1ca0..e7ce0e3 100644 --- a/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs +++ b/Foxnouns.Backend/Jobs/CreateFlagInvocable.cs @@ -26,7 +26,8 @@ public class CreateFlagInvocable( try { - var (hash, image) = await Payload.ImageData.ConvertBase64UriToImage( + var (hash, image) = await ImageObjectExtensions.ConvertBase64UriToImage( + Payload.ImageData, size: 256, crop: false ); diff --git a/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs b/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs index 91640cb..d97e1a7 100644 --- a/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs +++ b/Foxnouns.Backend/Jobs/MemberAvatarUpdateInvocable.cs @@ -39,7 +39,11 @@ public class MemberAvatarUpdateInvocable( try { - var (hash, image) = await newAvatar.ConvertBase64UriToImage(size: 512, crop: true); + var (hash, image) = await ImageObjectExtensions.ConvertBase64UriToImage( + newAvatar, + size: 512, + crop: true + ); var prevHash = member.Avatar; await objectStorageService.PutObjectAsync(Path(id, hash), image, "image/webp"); diff --git a/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs b/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs index 31433f9..8147424 100644 --- a/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs +++ b/Foxnouns.Backend/Jobs/UserAvatarUpdateInvocable.cs @@ -39,7 +39,11 @@ public class UserAvatarUpdateInvocable( try { - var (hash, image) = await newAvatar.ConvertBase64UriToImage(size: 512, crop: true); + var (hash, image) = await ImageObjectExtensions.ConvertBase64UriToImage( + newAvatar, + size: 512, + crop: true + ); image.Seek(0, SeekOrigin.Begin); var prevHash = user.Avatar; From 142ff36d3a70e23c68b66af342dbe34fa180453a Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 23 Nov 2024 20:43:43 +0100 Subject: [PATCH 4/7] fix: stop crash on start with empty sentry dsn, make max avatar length a constant --- Foxnouns.Backend/Program.cs | 2 +- Foxnouns.Backend/Utils/ValidationUtils.cs | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index f0c3e19..17a56d9 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -18,7 +18,7 @@ builder.AddSerilog(); builder .WebHost.UseSentry(opts => { - opts.Dsn = config.Logging.SentryUrl; + opts.Dsn = config.Logging.SentryUrl ?? ""; opts.TracesSampleRate = config.Logging.SentryTracesSampleRate; opts.MaxRequestBodySize = RequestSize.Small; }) diff --git a/Foxnouns.Backend/Utils/ValidationUtils.cs b/Foxnouns.Backend/Utils/ValidationUtils.cs index 0969a47..bb225ff 100644 --- a/Foxnouns.Backend/Utils/ValidationUtils.cs +++ b/Foxnouns.Backend/Utils/ValidationUtils.cs @@ -162,6 +162,7 @@ public static partial class ValidationUtils } public const int MaxBioLength = 1024; + public const int MaxAvatarLength = 1_500_000; public static ValidationError? ValidateBio(string? bio) { @@ -183,7 +184,10 @@ public static partial class ValidationUtils return avatar?.Length switch { 0 => ValidationError.GenericValidationError("Avatar cannot be empty", null), - > 1_500_000 => ValidationError.GenericValidationError("Avatar is too large", null), + > MaxAvatarLength => ValidationError.GenericValidationError( + "Avatar is too large", + null + ), _ => null, }; } From 4e9c4af4a5244887e854d4df7d19908b4776451f Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 24 Nov 2024 15:37:36 +0100 Subject: [PATCH 5/7] feat(auth): misc fediverse auth improvements - remove automatic app validation - add force refresh option to GetFediverseUrlAsync - pass state to mastodon authorization URI --- .../Authentication/FediverseAuthController.cs | 21 ++- Foxnouns.Backend/Controllers/SidController.cs | 3 + ...210306_RemoveFediverseApplicationTokens.cs | 40 ++++++ .../DatabaseContextModelSnapshot.cs | 8 -- .../Database/Models/FediverseApplication.cs | 4 - Foxnouns.Backend/Foxnouns.Backend.csproj | 2 + .../Auth/FediverseAuthService.Mastodon.cs | 129 +++--------------- .../Services/Auth/FediverseAuthService.cs | 32 +++-- Foxnouns.Backend/packages.lock.json | 84 ++++++------ 9 files changed, 143 insertions(+), 180 deletions(-) create mode 100644 Foxnouns.Backend/Controllers/SidController.cs create mode 100644 Foxnouns.Backend/Database/Migrations/20241123210306_RemoveFediverseApplicationTokens.cs diff --git a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs index 8dca588..7cb52c8 100644 --- a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs @@ -22,12 +22,15 @@ public class FediverseAuthController( [HttpGet] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] - public async Task GetFediverseUrlAsync([FromQuery] string instance) + public async Task GetFediverseUrlAsync( + [FromQuery] string instance, + [FromQuery] bool forceRefresh = false + ) { if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.')) throw new ApiError.BadRequest("Not a valid domain.", "instance", instance); - var url = await fediverseAuthService.GenerateAuthUrlAsync(instance); + var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh); return Ok(new FediverseUrlResponse(url)); } @@ -36,7 +39,11 @@ public class FediverseAuthController( public async Task FediverseCallbackAsync([FromBody] CallbackRequest req) { var app = await fediverseAuthService.GetApplicationAsync(req.Instance); - var remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code); + var remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync( + app, + req.Code, + req.State + ); var user = await authService.AuthenticateUserAsync( AuthType.Fediverse, @@ -72,12 +79,16 @@ public class FediverseAuthController( ) { var ticketData = await keyCacheService.GetKeyAsync( - $"fediverse:{req.Ticket}" + $"fediverse:{req.Ticket}", + delete: true ); if (ticketData == null) throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); var app = await db.FediverseApplications.FindAsync(ticketData.ApplicationId); + if (app == null) + throw new FoxnounsError("Null application found for ticket"); + if ( await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Fediverse @@ -107,7 +118,7 @@ public class FediverseAuthController( return Ok(await authService.GenerateUserTokenAsync(user)); } - public record CallbackRequest(string Instance, string Code); + public record CallbackRequest(string Instance, string Code, string State); private record FediverseUrlResponse(string Url); diff --git a/Foxnouns.Backend/Controllers/SidController.cs b/Foxnouns.Backend/Controllers/SidController.cs new file mode 100644 index 0000000..26c66ef --- /dev/null +++ b/Foxnouns.Backend/Controllers/SidController.cs @@ -0,0 +1,3 @@ +namespace Foxnouns.Backend.Controllers; + +public class SidController { } diff --git a/Foxnouns.Backend/Database/Migrations/20241123210306_RemoveFediverseApplicationTokens.cs b/Foxnouns.Backend/Database/Migrations/20241123210306_RemoveFediverseApplicationTokens.cs new file mode 100644 index 0000000..fbc8d3d --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20241123210306_RemoveFediverseApplicationTokens.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20241123210306_RemoveFediverseApplicationTokens")] + public partial class RemoveFediverseApplicationTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn(name: "access_token", table: "fediverse_applications"); + + migrationBuilder.DropColumn(name: "token_valid_until", table: "fediverse_applications"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "access_token", + table: "fediverse_applications", + type: "text", + nullable: true + ); + + migrationBuilder.AddColumn( + name: "token_valid_until", + table: "fediverse_applications", + type: "timestamp with time zone", + nullable: true + ); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index 97316ac..e1e05c2 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -107,10 +107,6 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("bigint") .HasColumnName("id"); - b.Property("AccessToken") - .HasColumnType("text") - .HasColumnName("access_token"); - b.Property("ClientId") .IsRequired() .HasColumnType("text") @@ -130,10 +126,6 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("integer") .HasColumnName("instance_type"); - b.Property("TokenValidUntil") - .HasColumnType("timestamp with time zone") - .HasColumnName("token_valid_until"); - b.HasKey("Id") .HasName("pk_fediverse_applications"); diff --git a/Foxnouns.Backend/Database/Models/FediverseApplication.cs b/Foxnouns.Backend/Database/Models/FediverseApplication.cs index fa7b6a6..882a377 100644 --- a/Foxnouns.Backend/Database/Models/FediverseApplication.cs +++ b/Foxnouns.Backend/Database/Models/FediverseApplication.cs @@ -8,10 +8,6 @@ public class FediverseApplication : BaseModel public required string ClientId { get; set; } public required string ClientSecret { get; set; } public required FediverseInstanceType InstanceType { get; set; } - - // These are for ensuring the application is still valid. - public string? AccessToken { get; set; } - public Instant? TokenValidUntil { get; set; } } public enum FediverseInstanceType diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index a9e7b74..dbc46d3 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -20,6 +20,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + @@ -35,6 +36,7 @@ + diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs index 665e07f..97e411a 100644 --- a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs @@ -3,7 +3,7 @@ using System.Net; using System.Web; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; -using Duration = NodaTime.Duration; +using Foxnouns.Backend.Extensions; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; namespace Foxnouns.Backend.Services.Auth; @@ -35,12 +35,6 @@ public partial class FediverseAuthService $"Application created on Mastodon-compatible instance {instance} was null" ); - var token = await GetMastodonAppTokenAsync( - instance, - mastodonApp.ClientId, - mastodonApp.ClientSecret - ); - FediverseApplication app; if (existingAppId == null) @@ -52,8 +46,6 @@ public partial class FediverseAuthService ClientSecret = mastodonApp.ClientSecret, Domain = instance, InstanceType = FediverseInstanceType.MastodonApi, - AccessToken = token, - TokenValidUntil = _clock.GetCurrentInstant() + Duration.FromDays(60), }; _db.Add(app); @@ -67,8 +59,6 @@ public partial class FediverseAuthService app.ClientId = mastodonApp.ClientId; app.ClientSecret = mastodonApp.ClientSecret; app.InstanceType = FediverseInstanceType.MastodonApi; - app.AccessToken = null; - app.TokenValidUntil = null; } await _db.SaveChangesAsync(); @@ -76,8 +66,14 @@ public partial class FediverseAuthService return app; } - private async Task GetMastodonUserAsync(FediverseApplication app, string code) + private async Task GetMastodonUserAsync( + FediverseApplication app, + string code, + string state + ) { + await _keyCacheService.ValidateAuthStateAsync(state); + var tokenResp = await _client.PostAsync( MastodonTokenUri(app.Domain), new FormUrlEncodedContent( @@ -122,109 +118,27 @@ public partial class FediverseAuthService private record MastodonTokenResponse([property: J("access_token")] string AccessToken); - // TODO: Mastodon's OAuth documentation doesn't specify a "state" parameter. that feels... wrong - // https://docs.joinmastodon.org/methods/oauth/ - private async Task GenerateMastodonAuthUrlAsync(FediverseApplication app) + private async Task GenerateMastodonAuthUrlAsync( + FediverseApplication app, + bool forceRefresh + ) { - try + if (forceRefresh) { - await ValidateMastodonAppAsync(app); - } - catch (FoxnounsError.RemoteAuthError e) - { - _logger.Error( - e, - "Error validating app token for {AppId} on {Instance}", - app.Id, - app.Domain + _logger.Information( + "An app credentials refresh was requested for {ApplicationId}, creating a new application", + app.Id ); - app = await CreateMastodonApplicationAsync(app.Domain, existingAppId: app.Id); } + var state = HttpUtility.UrlEncode(await _keyCacheService.GenerateAuthStateAsync()); + return $"https://{app.Domain}/oauth/authorize?response_type=code" + $"&client_id={app.ClientId}" + $"&scope={HttpUtility.UrlEncode("read:accounts")}" - + $"&redirect_uri={HttpUtility.UrlEncode(MastodonRedirectUri(app.Domain))}"; - } - - private async Task ValidateMastodonAppAsync(FediverseApplication app) - { - // If we don't have an access token stored, or it's too old, get one - // When doing this we don't need to fetch the application info - if (app.AccessToken == null || app.TokenValidUntil < _clock.GetCurrentInstant()) - { - _logger.Debug( - "Application {AppId} on instance {Instance} has no valid token, fetching it", - app.Id, - app.Domain - ); - - app.AccessToken = await GetMastodonAppTokenAsync( - app.Domain, - app.ClientId, - app.ClientSecret - ); - app.TokenValidUntil = _clock.GetCurrentInstant() + Duration.FromDays(60); - - _db.Update(app); - await _db.SaveChangesAsync(); - return; - } - - _logger.Debug( - "Checking whether application {AppId} on instance {Instance} is still valid", - app.Id, - app.Domain - ); - - var req = new HttpRequestMessage(HttpMethod.Get, MastodonCurrentAppUri(app.Domain)); - req.Headers.Add("Authorization", $"Bearer {app.AccessToken}"); - - var resp = await _client.SendAsync(req); - if (!resp.IsSuccessStatusCode) - { - var error = await resp.Content.ReadAsStringAsync(); - throw new FoxnounsError.RemoteAuthError( - "Verifying app credentials returned an error", - error - ); - } - } - - private async Task GetMastodonAppTokenAsync( - string instance, - string clientId, - string clientSecret - ) - { - var resp = await _client.PostAsync( - MastodonTokenUri(instance), - new FormUrlEncodedContent( - new Dictionary - { - { "grant_type", "client_credentials" }, - { "client_id", clientId }, - { "client_secret", clientSecret }, - } - ) - ); - if (!resp.IsSuccessStatusCode) - { - var error = await resp.Content.ReadAsStringAsync(); - throw new FoxnounsError.RemoteAuthError( - "Requesting app token returned an error", - error - ); - } - - var token = (await resp.Content.ReadFromJsonAsync())?.AccessToken; - if (token == null) - { - throw new FoxnounsError($"Token response from instance {instance} was invalid"); - } - - return token; + + $"&redirect_uri={HttpUtility.UrlEncode(MastodonRedirectUri(app.Domain))}" + + $"&state={state}"; } private static string MastodonTokenUri(string instance) => $"https://{instance}/oauth/token"; @@ -232,9 +146,6 @@ public partial class FediverseAuthService private static string MastodonCurrentUserUri(string instance) => $"https://{instance}/api/v1/accounts/verify_credentials"; - private static string MastodonCurrentAppUri(string instance) => - $"https://{instance}/api/v1/apps/verify_credentials"; - [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")] private record PartialMastodonApplication( [property: J("name")] string Name, diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs index fc54017..f78fbde 100644 --- a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs @@ -1,7 +1,6 @@ using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Microsoft.EntityFrameworkCore; -using NodaTime; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; namespace Foxnouns.Backend.Services.Auth; @@ -10,26 +9,27 @@ public partial class FediverseAuthService { private const string NodeInfoRel = "http://nodeinfo.diaspora.software/ns/schema/2.0"; - private readonly ILogger _logger; private readonly HttpClient _client; - private readonly DatabaseContext _db; + private readonly ILogger _logger; private readonly Config _config; + private readonly DatabaseContext _db; + private readonly KeyCacheService _keyCacheService; private readonly ISnowflakeGenerator _snowflakeGenerator; - private readonly IClock _clock; public FediverseAuthService( ILogger logger, Config config, DatabaseContext db, - ISnowflakeGenerator snowflakeGenerator, - IClock clock + KeyCacheService keyCacheService, + ISnowflakeGenerator snowflakeGenerator ) { + _logger = logger.ForContext(); _config = config; _db = db; + _keyCacheService = keyCacheService; _snowflakeGenerator = snowflakeGenerator; - _clock = clock; - _logger = logger.ForContext(); + _client = new HttpClient(); _client.DefaultRequestHeaders.Remove("User-Agent"); _client.DefaultRequestHeaders.Remove("Accept"); @@ -37,10 +37,10 @@ public partial class FediverseAuthService _client.DefaultRequestHeaders.Add("Accept", "application/json"); } - public async Task GenerateAuthUrlAsync(string instance) + public async Task GenerateAuthUrlAsync(string instance, bool forceRefresh) { var app = await GetApplicationAsync(instance); - return await GenerateAuthUrlAsync(app); + return await GenerateAuthUrlAsync(app, forceRefresh); } // thank you, gargron and syuilo, for agreeing on a name for *once* in your lives, @@ -96,21 +96,25 @@ public partial class FediverseAuthService ); } - private async Task GenerateAuthUrlAsync(FediverseApplication app) => + private async Task GenerateAuthUrlAsync(FediverseApplication app, bool forceRefresh) => app.InstanceType switch { - FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync(app), + FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync( + app, + forceRefresh + ), FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), }; public async Task GetRemoteFediverseUserAsync( FediverseApplication app, - string code + string code, + string state ) => app.InstanceType switch { - FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code), + FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code, state), FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), }; diff --git a/Foxnouns.Backend/packages.lock.json b/Foxnouns.Backend/packages.lock.json index 90ba53c..02ca7ca 100644 --- a/Foxnouns.Backend/packages.lock.json +++ b/Foxnouns.Backend/packages.lock.json @@ -96,6 +96,19 @@ "Mono.TextTemplating": "2.2.1" } }, + "Microsoft.Extensions.Caching.Memory": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "zbnPX/JQ0pETRSUG9fNPBvpIq42Aufvs15gGYyNIMhCun9yhmWihz0WgsI7bSDPjxWTKBf8oX/zv6v2uZ3W9OQ==", + "dependencies": { + "Microsoft.Extensions.Caching.Abstractions": "9.0.0", + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Logging.Abstractions": "9.0.0", + "Microsoft.Extensions.Options": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" + } + }, "Minio": { "type": "Direct", "requested": "[6.0.3, )", @@ -246,6 +259,16 @@ "Swashbuckle.AspNetCore.SwaggerUI": "6.6.2" } }, + "System.Text.Json": { + "type": "Direct", + "requested": "[9.0.0, )", + "resolved": "9.0.0", + "contentHash": "js7+qAu/9mQvnhA4EfGMZNEzXtJCDxgkgj8ohuxq/Qxv+R56G+ljefhiJHOxTNiw54q8vmABCWUwkMulNdlZ4A==", + "dependencies": { + "System.IO.Pipelines": "9.0.0", + "System.Text.Encodings.Web": "9.0.0" + } + }, "System.Text.RegularExpressions": { "type": "Direct", "requested": "[4.3.1, )", @@ -412,22 +435,10 @@ }, "Microsoft.Extensions.Caching.Abstractions": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", + "resolved": "9.0.0", + "contentHash": "FPWZAa9c0H4dvOj351iR1jkUIs4u9ykL4Bm592yhjDyO5lCoWd+TMAHx2EMbarzUvCvgjWjJIoC6//Q9kH6YhA==", "dependencies": { - "Microsoft.Extensions.Primitives": "8.0.0" - } - }, - "Microsoft.Extensions.Caching.Memory": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "7pqivmrZDzo1ADPkRwjy+8jtRKWRCPag9qPI+p7sgu7Q4QreWhcvbiWXsbhP+yY8XSiDvZpu2/LWdBv7PnmOpQ==", - "dependencies": { - "Microsoft.Extensions.Caching.Abstractions": "8.0.0", - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", - "Microsoft.Extensions.Logging.Abstractions": "8.0.0", - "Microsoft.Extensions.Options": "8.0.0", - "Microsoft.Extensions.Primitives": "8.0.0" + "Microsoft.Extensions.Primitives": "9.0.0" } }, "Microsoft.Extensions.Configuration": { @@ -465,8 +476,8 @@ }, "Microsoft.Extensions.DependencyInjection.Abstractions": { "type": "Transitive", - "resolved": "8.0.1", - "contentHash": "fGLiCRLMYd00JYpClraLjJTNKLmMJPnqxMaiRzEBIIvevlzxz33mXy39Lkd48hu1G+N21S7QpaO5ZzKsI6FRuA==" + "resolved": "9.0.0", + "contentHash": "+6f2qv2a3dLwd5w6JanPIPs47CxRbnk+ZocMJUhv9NxP88VlOcJYZs9jY+MYSjxvady08bUZn6qgiNh7DadGgg==" }, "Microsoft.Extensions.DependencyModel": { "type": "Transitive", @@ -542,10 +553,11 @@ }, "Microsoft.Extensions.Logging.Abstractions": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", + "resolved": "9.0.0", + "contentHash": "g0UfujELzlLbHoVG8kPKVBaW470Ewi+jnptGS9KUi6jcb+k2StujtK3m26DFSGGwQ/+bVgZfsWqNzlP6YOejvw==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "System.Diagnostics.DiagnosticSource": "9.0.0" } }, "Microsoft.Extensions.Logging.Configuration": { @@ -570,11 +582,11 @@ }, "Microsoft.Extensions.Options": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "JOVOfqpnqlVLUzINQ2fox8evY2SKLYJ3BV8QDe/Jyp21u1T7r45x/R/5QdteURMR5r01GxeJSBBUOCOyaNXA3g==", + "resolved": "9.0.0", + "contentHash": "y2146b3jrPI3Q0lokKXdKLpmXqakYbDIPDV6r3M8SqvSf45WwOTzkyfDpxnZXJsJQEpAsAqjUq5Pu8RCJMjubg==", "dependencies": { - "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", - "Microsoft.Extensions.Primitives": "8.0.0" + "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0", + "Microsoft.Extensions.Primitives": "9.0.0" } }, "Microsoft.Extensions.Options.ConfigurationExtensions": { @@ -591,8 +603,8 @@ }, "Microsoft.Extensions.Primitives": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" + "resolved": "9.0.0", + "contentHash": "N3qEBzmLMYiASUlKxxFIISP4AiwuPTHF5uCh+2CWSwwzAJiIYx0kBJsS30cp1nvhSySFAVi30jecD307jV+8Kg==" }, "Microsoft.NETCore.Platforms": { "type": "Transitive", @@ -983,8 +995,8 @@ }, "System.Diagnostics.DiagnosticSource": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ==" + "resolved": "9.0.0", + "contentHash": "ddppcFpnbohLWdYKr/ZeLZHmmI+DXFgZ3Snq+/E7SwcdW4UnvxmaugkwGywvGVWkHPGCSZjCP+MLzu23AL5SDw==" }, "System.Diagnostics.Tracing": { "type": "Transitive", @@ -1072,8 +1084,8 @@ }, "System.IO.Pipelines": { "type": "Transitive", - "resolved": "6.0.3", - "contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" + "resolved": "9.0.0", + "contentHash": "eA3cinogwaNB4jdjQHOP3Z3EuyiDII7MT35jgtnsA4vkn0LUrrSHsU0nzHTzFzmaFYeKV7MYyMxOocFzsBHpTw==" }, "System.Linq": { "type": "Transitive", @@ -1484,16 +1496,8 @@ }, "System.Text.Encodings.Web": { "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==" - }, - "System.Text.Json": { - "type": "Transitive", - "resolved": "8.0.4", - "contentHash": "bAkhgDJ88XTsqczoxEMliSrpijKZHhbJQldhAmObj/RbrN3sU5dcokuXmWJWsdQAhiMJ9bTayWsL1C9fbbCRhw==", - "dependencies": { - "System.Text.Encodings.Web": "8.0.0" - } + "resolved": "9.0.0", + "contentHash": "e2hMgAErLbKyUUwt18qSBf9T5Y+SFAL3ZedM8fLupkVj8Rj2PZ9oxQ37XX2LF8fTO1wNIxvKpihD7Of7D/NxZw==" }, "System.Threading": { "type": "Transitive", From 7cb17409cd3221ac805d75fac51c589b610e7979 Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 24 Nov 2024 15:39:44 +0100 Subject: [PATCH 6/7] fix: explicitly set sids to null so the find free sid functions actually trigger --- Foxnouns.Backend/Controllers/MembersController.cs | 1 + Foxnouns.Backend/Services/Auth/AuthService.cs | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index 22d23d4..ba9cf28 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -94,6 +94,7 @@ public class MembersController( Names = req.Names ?? [], Pronouns = req.Pronouns ?? [], Unlisted = req.Unlisted ?? false, + Sid = null!, }; db.Add(member); diff --git a/Foxnouns.Backend/Services/Auth/AuthService.cs b/Foxnouns.Backend/Services/Auth/AuthService.cs index 9675f22..adbf5b1 100644 --- a/Foxnouns.Backend/Services/Auth/AuthService.cs +++ b/Foxnouns.Backend/Services/Auth/AuthService.cs @@ -45,6 +45,7 @@ public class AuthService( }, }, LastActive = clock.GetCurrentInstant(), + Sid = null!, }; db.Add(user); @@ -88,6 +89,7 @@ public class AuthService( }, }, LastActive = clock.GetCurrentInstant(), + Sid = null!, }; db.Add(user); From c8cd483d20c12f71d02f52304fa44ff8c3c2dcf9 Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 24 Nov 2024 15:40:12 +0100 Subject: [PATCH 7/7] feat: sid redirect controller --- Foxnouns.Backend/Controllers/SidController.cs | 51 ++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/Foxnouns.Backend/Controllers/SidController.cs b/Foxnouns.Backend/Controllers/SidController.cs index 26c66ef..b8f5948 100644 --- a/Foxnouns.Backend/Controllers/SidController.cs +++ b/Foxnouns.Backend/Controllers/SidController.cs @@ -1,3 +1,52 @@ +using System.Diagnostics.CodeAnalysis; +using Foxnouns.Backend.Database; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + namespace Foxnouns.Backend.Controllers; -public class SidController { } +[Route("/sid")] +[SuppressMessage( + "Performance", + "CA1862:Use the \'StringComparison\' method overloads to perform case-insensitive string comparisons", + Justification = "Not usable with EFCore" +)] +public class SidController(Config config, DatabaseContext db) : ApiControllerBase +{ + [HttpGet("{**id}")] + public async Task ResolveSidAsync(string id, CancellationToken ct = default) => + id.Length switch + { + 5 => await ResolveUserSidAsync(id, ct), + 6 => await ResolveMemberSidAsync(id, ct), + _ => Redirect(config.BaseUrl), + }; + + private async Task ResolveUserSidAsync(string id, CancellationToken ct = default) + { + var username = await db + .Users.Where(u => u.Sid == id.ToLowerInvariant() && !u.Deleted) + .Select(u => u.Username) + .FirstOrDefaultAsync(ct); + if (username == null) + return Redirect(config.BaseUrl); + + return Redirect($"{config.BaseUrl}/@{username}"); + } + + private async Task ResolveMemberSidAsync( + string id, + CancellationToken ct = default + ) + { + var member = await db + .Members.Include(m => m.User) + .Where(m => m.Sid == id.ToLowerInvariant() && !m.User.Deleted) + .Select(m => new { m.Name, m.User.Username }) + .FirstOrDefaultAsync(ct); + if (member == null) + return Redirect(config.BaseUrl); + + return Redirect($"{config.BaseUrl}/@{member.Username}/{member.Name}"); + } +}