diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs index ee22804..118caa8 100644 --- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs @@ -104,21 +104,9 @@ public class DiscordAuthController( { CheckRequirements(); - var existingAccounts = await db - .AuthMethods.Where(m => m.UserId == CurrentUser!.Id && m.AuthType == AuthType.Discord) - .CountAsync(); - if (existingAccounts > AuthUtils.MaxAuthMethodsPerType) - { - throw new ApiError.BadRequest( - "Too many linked Discord accounts, maximum of 3 per account." - ); - } - - var state = HttpUtility.UrlEncode( - await keyCacheService.GenerateAddExtraAccountStateAsync( - AuthType.Discord, - CurrentUser!.Id - ) + var state = await remoteAuthService.ValidateAddAccountRequestAsync( + CurrentUser!.Id, + AuthType.Discord ); var url = @@ -138,12 +126,11 @@ public class DiscordAuthController( { CheckRequirements(); - var accountState = await keyCacheService.GetAddExtraAccountStateAsync(req.State); - if ( - accountState is not { AuthType: AuthType.Discord } - || accountState.UserId != CurrentUser!.Id - ) - throw new ApiError.BadRequest("Invalid state", "state", req.State); + await remoteAuthService.ValidateAddAccountStateAsync( + req.State, + CurrentUser!.Id, + AuthType.Discord + ); var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code); try diff --git a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs index 7cb52c8..103061b 100644 --- a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs @@ -1,5 +1,8 @@ +using System.Net; +using EntityFramework.Exceptions.Common; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; using Foxnouns.Backend.Services.Auth; using Foxnouns.Backend.Utils; @@ -15,13 +18,14 @@ public class FediverseAuthController( DatabaseContext db, FediverseAuthService fediverseAuthService, AuthService authService, + RemoteAuthService remoteAuthService, KeyCacheService keyCacheService ) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); [HttpGet] - [ProducesResponseType(statusCode: StatusCodes.Status200OK)] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task GetFediverseUrlAsync( [FromQuery] string instance, [FromQuery] bool forceRefresh = false @@ -31,7 +35,7 @@ public class FediverseAuthController( throw new ApiError.BadRequest("Not a valid domain.", "instance", instance); var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh); - return Ok(new FediverseUrlResponse(url)); + return Ok(new AuthController.SingleUrlResponse(url)); } [HttpPost("callback")] @@ -118,9 +122,74 @@ public class FediverseAuthController( return Ok(await authService.GenerateUserTokenAsync(user)); } - public record CallbackRequest(string Instance, string Code, string State); + [HttpGet("add-account")] + [Authorize("*")] + public async Task AddFediverseAccountAsync( + [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); - private record FediverseUrlResponse(string Url); + var state = await remoteAuthService.ValidateAddAccountRequestAsync( + CurrentUser!.Id, + AuthType.Fediverse, + instance + ); + + var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh, state); + return Ok(new AuthController.SingleUrlResponse(url)); + } + + [HttpPost("add-account/callback")] + [Authorize("*")] + public async Task AddAccountCallbackAsync([FromBody] CallbackRequest req) + { + await remoteAuthService.ValidateAddAccountStateAsync( + req.State, + CurrentUser!.Id, + AuthType.Fediverse, + req.Instance + ); + + var app = await fediverseAuthService.GetApplicationAsync(req.Instance); + var remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code); + try + { + var authMethod = await authService.AddAuthMethodAsync( + CurrentUser.Id, + AuthType.Fediverse, + remoteUser.Id, + remoteUser.Username, + app + ); + _logger.Debug( + "Added new Fediverse auth method {AuthMethodId} to user {UserId}", + authMethod.Id, + CurrentUser.Id + ); + + return Ok( + new AuthController.AddOauthAccountResponse( + authMethod.Id, + AuthType.Fediverse, + authMethod.RemoteId, + $"{authMethod.RemoteUsername}@{app.Domain}" + ) + ); + } + catch (UniqueConstraintException) + { + throw new ApiError( + "That account is already linked.", + HttpStatusCode.BadRequest, + ErrorCode.AccountAlreadyLinked + ); + } + } + + public record CallbackRequest(string Instance, string Code, string State); private record FediverseTicketData( Snowflake ApplicationId, diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs index 522c8d6..f3e1467 100644 --- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -63,13 +63,14 @@ public static class KeyCacheExtensions this KeyCacheService keyCacheService, AuthType authType, Snowflake userId, + string? instance = null, CancellationToken ct = default ) { var state = AuthUtils.RandomToken(); await keyCacheService.SetKeyAsync( $"add_account:{state}", - new AddExtraAccountState(authType, userId), + new AddExtraAccountState(authType, userId, instance), Duration.FromDays(1), ct ); @@ -93,4 +94,4 @@ public record RegisterEmailState( [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId ); -public record AddExtraAccountState(AuthType AuthType, Snowflake UserId); +public record AddExtraAccountState(AuthType AuthType, Snowflake UserId, string? Instance = null); diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index dbc46d3..d604012 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -12,6 +12,7 @@ + diff --git a/Foxnouns.Backend/Services/Auth/AuthService.cs b/Foxnouns.Backend/Services/Auth/AuthService.cs index 4eca66e..e3ec4c4 100644 --- a/Foxnouns.Backend/Services/Auth/AuthService.cs +++ b/Foxnouns.Backend/Services/Auth/AuthService.cs @@ -218,10 +218,11 @@ public class AuthService( AuthType authType, string remoteId, string? remoteUsername = null, + FediverseApplication? app = null, CancellationToken ct = default ) { - AssertValidAuthType(authType, null); + AssertValidAuthType(authType, app); // This is already checked when var currentCount = await db @@ -237,6 +238,7 @@ public class AuthService( Id = snowflakeGenerator.GenerateSnowflake(), AuthType = authType, RemoteId = remoteId, + FediverseApplicationId = app?.Id, RemoteUsername = remoteUsername, UserId = userId, }; diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs index 97e411a..ba232bf 100644 --- a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs @@ -69,10 +69,11 @@ public partial class FediverseAuthService private async Task GetMastodonUserAsync( FediverseApplication app, string code, - string state + string? state = null ) { - await _keyCacheService.ValidateAuthStateAsync(state); + if (state != null) + await _keyCacheService.ValidateAuthStateAsync(state); var tokenResp = await _client.PostAsync( MastodonTokenUri(app.Domain), @@ -120,7 +121,8 @@ public partial class FediverseAuthService private async Task GenerateMastodonAuthUrlAsync( FediverseApplication app, - bool forceRefresh + bool forceRefresh, + string? state = null ) { if (forceRefresh) @@ -132,7 +134,7 @@ public partial class FediverseAuthService app = await CreateMastodonApplicationAsync(app.Domain, existingAppId: app.Id); } - var state = HttpUtility.UrlEncode(await _keyCacheService.GenerateAuthStateAsync()); + state ??= HttpUtility.UrlEncode(await _keyCacheService.GenerateAuthStateAsync()); return $"https://{app.Domain}/oauth/authorize?response_type=code" + $"&client_id={app.ClientId}" diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs index f78fbde..224c0a3 100644 --- a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs @@ -37,10 +37,14 @@ public partial class FediverseAuthService _client.DefaultRequestHeaders.Add("Accept", "application/json"); } - public async Task GenerateAuthUrlAsync(string instance, bool forceRefresh) + public async Task GenerateAuthUrlAsync( + string instance, + bool forceRefresh, + string? state = null + ) { var app = await GetApplicationAsync(instance); - return await GenerateAuthUrlAsync(app, forceRefresh); + return await GenerateAuthUrlAsync(app, forceRefresh, state); } // thank you, gargron and syuilo, for agreeing on a name for *once* in your lives, @@ -96,12 +100,17 @@ public partial class FediverseAuthService ); } - private async Task GenerateAuthUrlAsync(FediverseApplication app, bool forceRefresh) => + private async Task GenerateAuthUrlAsync( + FediverseApplication app, + bool forceRefresh, + string? state = null + ) => app.InstanceType switch { FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync( app, - forceRefresh + forceRefresh, + state ), FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), @@ -110,7 +119,7 @@ public partial class FediverseAuthService public async Task GetRemoteFediverseUserAsync( FediverseApplication app, string code, - string state + string? state = null ) => app.InstanceType switch { diff --git a/Foxnouns.Backend/Services/RemoteAuthService.cs b/Foxnouns.Backend/Services/Auth/RemoteAuthService.cs similarity index 52% rename from Foxnouns.Backend/Services/RemoteAuthService.cs rename to Foxnouns.Backend/Services/Auth/RemoteAuthService.cs index 91a2dc5..9b62a70 100644 --- a/Foxnouns.Backend/Services/RemoteAuthService.cs +++ b/Foxnouns.Backend/Services/Auth/RemoteAuthService.cs @@ -1,9 +1,21 @@ using System.Diagnostics.CodeAnalysis; +using System.Web; +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Extensions; +using Foxnouns.Backend.Utils; +using Humanizer; using JetBrains.Annotations; +using Microsoft.EntityFrameworkCore; -namespace Foxnouns.Backend.Services; +namespace Foxnouns.Backend.Services.Auth; -public class RemoteAuthService(Config config, ILogger logger) +public class RemoteAuthService( + Config config, + ILogger logger, + DatabaseContext db, + KeyCacheService keyCacheService +) { private readonly ILogger _logger = logger.ForContext(); private readonly HttpClient _httpClient = new(); @@ -76,4 +88,56 @@ public class RemoteAuthService(Config config, ILogger logger) private record DiscordUserResponse(string id, string username); public record RemoteUser(string Id, string Username); + + /// + /// Validates whether a user can still add a new account of the given AuthType, and throws an error if they can't. + /// + /// The user to check. + /// The auth type to check. + /// The optional fediverse instance to generate a state for. + /// A state for the given auth type and user ID. + /// The given user can't add another account of this type. + /// This exception should not be caught by controller code. + public async Task ValidateAddAccountRequestAsync( + Snowflake userId, + AuthType authType, + string? instance = null + ) + { + var existingAccounts = await db + .AuthMethods.Where(m => m.UserId == userId && m.AuthType == authType) + .CountAsync(); + if (existingAccounts > AuthUtils.MaxAuthMethodsPerType) + { + throw new ApiError.BadRequest( + $"Too many linked {authType.Humanize()} accounts, maximum of {AuthUtils.MaxAuthMethodsPerType} per account." + ); + } + + return HttpUtility.UrlEncode( + await keyCacheService.GenerateAddExtraAccountStateAsync(authType, userId, instance) + ); + } + + /// + /// Checks whether the given state is correct for the given user/auth type combination. + /// + /// The state doesn't match. + /// This exception should not be caught by controller code. + public async Task ValidateAddAccountStateAsync( + string state, + Snowflake userId, + AuthType authType, + string? instance = null + ) + { + var accountState = await keyCacheService.GetAddExtraAccountStateAsync(state); + if ( + accountState == null + || accountState.AuthType != authType + || accountState.UserId != userId + || (instance != null && accountState.Instance != instance) + ) + throw new ApiError.BadRequest("Invalid state", "state", state); + } } diff --git a/Foxnouns.Backend/Utils/ValidationUtils.Fields.cs b/Foxnouns.Backend/Utils/ValidationUtils.Fields.cs index 1ed083c..4271459 100644 --- a/Foxnouns.Backend/Utils/ValidationUtils.Fields.cs +++ b/Foxnouns.Backend/Utils/ValidationUtils.Fields.cs @@ -127,6 +127,9 @@ public static partial class ValidationUtils if (entries.Length > Limits.FieldEntriesLimit + 50) return errors; + var customPreferenceIds = + customPreferences?.Keys.Select(id => id.ToString()).ToArray() ?? []; + foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) { switch (entry.Value.Length) @@ -159,8 +162,6 @@ public static partial class ValidationUtils break; } - var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? []; - if ( !DefaultStatusOptions.Contains(entry.Status) && !customPreferenceIds.Contains(entry.Status) @@ -203,6 +204,9 @@ public static partial class ValidationUtils if (entries.Length > Limits.FieldEntriesLimit + 50) return errors; + var customPreferenceIds = + customPreferences?.Keys.Select(id => id.ToString()).ToList() ?? []; + foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) { switch (entry.Value.Length) @@ -268,8 +272,6 @@ public static partial class ValidationUtils } } - var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? []; - if ( !DefaultStatusOptions.Contains(entry.Status) && !customPreferenceIds.Contains(entry.Status) diff --git a/Foxnouns.Backend/packages.lock.json b/Foxnouns.Backend/packages.lock.json index 02ca7ca..5a5a32b 100644 --- a/Foxnouns.Backend/packages.lock.json +++ b/Foxnouns.Backend/packages.lock.json @@ -45,6 +45,12 @@ "Npgsql": "8.0.1" } }, + "Humanizer.Core": { + "type": "Direct", + "requested": "[2.14.1, )", + "resolved": "2.14.1", + "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" + }, "JetBrains.Annotations": { "type": "Direct", "requested": "[2024.2.0, )", @@ -291,11 +297,6 @@ "Microsoft.EntityFrameworkCore.Relational": "8.0.0" } }, - "Humanizer.Core": { - "type": "Transitive", - "resolved": "2.14.1", - "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" - }, "MailKit": { "type": "Transitive", "resolved": "2.5.1", diff --git a/Foxnouns.Frontend/src/lib/i18n/locales/en.json b/Foxnouns.Frontend/src/lib/i18n/locales/en.json index 6a9f9f6..b8a0aac 100644 --- a/Foxnouns.Frontend/src/lib/i18n/locales/en.json +++ b/Foxnouns.Frontend/src/lib/i18n/locales/en.json @@ -47,7 +47,8 @@ "successful-link-fedi": "Your account has successfully been linked to the following fediverse account:", "successful-link-profile-hint": "You now can close this page, or go back to your profile:", "successful-link-profile-link": "Go to your profile", - "remote-discord-account-label": "Your Discord account" + "remote-discord-account-label": "Your Discord account", + "log-in-with-fediverse-instance-placeholder": "Your instance (i.e. mastodon.social)" }, "error": { "bad-request-header": "Something was wrong with your input", diff --git a/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.server.ts b/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.server.ts index ce4d473..64339a1 100644 --- a/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.server.ts +++ b/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.server.ts @@ -1,35 +1,62 @@ import { apiRequest } from "$api"; import ApiError, { ErrorCode } from "$api/error"; -import type { CallbackResponse } from "$api/models/auth.js"; +import type { AddAccountResponse, CallbackResponse } from "$api/models/auth.js"; import { setToken } from "$lib"; import createRegisterAction from "$lib/actions/register.js"; -import { redirect } from "@sveltejs/kit"; +import log from "$lib/log"; +import { isRedirect, redirect } from "@sveltejs/kit"; export const load = async ({ parent, params, url, fetch, cookies }) => { - const { meUser } = await parent(); - if (meUser) redirect(303, `/@${meUser.username}`); - const code = url.searchParams.get("code") as string | null; const state = url.searchParams.get("state") as string | null; if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj; - const resp = await apiRequest("POST", "/auth/fediverse/callback", { - body: { code, state, instance: params.instance }, - isInternal: true, - fetch, - }); + const { meUser } = await parent(); + if (meUser) { + try { + const resp = await apiRequest( + "POST", + "/auth/fediverse/add-account/callback", + { + isInternal: true, + body: { code, state, instance: params.instance }, + fetch, + cookies, + }, + ); - if (resp.has_account) { - setToken(cookies, resp.token!); - redirect(303, `/@${resp.user!.username}`); + return { hasAccount: true, isLinkRequest: true, newAuthMethod: resp }; + } catch (e) { + if (e instanceof ApiError) return { isLinkRequest: true, error: e.obj }; + log.error("error linking new fediverse account to user %s:", meUser.id, e); + throw e; + } } - return { - hasAccount: false, - instance: params.instance, - ticket: resp.ticket!, - remoteUser: resp.remote_username!, - }; + try { + const resp = await apiRequest("POST", "/auth/fediverse/callback", { + body: { code, state, instance: params.instance }, + isInternal: true, + fetch, + }); + + if (resp.has_account) { + setToken(cookies, resp.token!); + redirect(303, `/@${resp.user!.username}`); + } + + return { + hasAccount: false, + isLinkRequest: false, + ticket: resp.ticket!, + remoteUser: resp.remote_username!, + }; + } catch (e) { + if (isRedirect(e)) throw e; + if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj }; + log.error("error while requesting fediverse callback:", e); + throw e; + } }; export const actions = { diff --git a/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.svelte b/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.svelte index 5d02eeb..99bd00c 100644 --- a/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.svelte +++ b/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.svelte @@ -1,7 +1,9 @@ + +

Link a new Fediverse account

+ +
+ + + + +

+ {$t("auth.log-in-with-fediverse-error-blurb")} + +

+
diff --git a/migrators/NetImporter/NetImporter.csproj b/migrators/NetImporter/NetImporter.csproj index e62f921..08a1aa3 100644 --- a/migrators/NetImporter/NetImporter.csproj +++ b/migrators/NetImporter/NetImporter.csproj @@ -1,25 +1,26 @@  - - Exe - net8.0 - enable - enable - + + Exe + net8.0 + enable + enable + - - - + + + - - - - - runtime; build; native; contentfiles; analyzers; buildtransitive - all - - - - + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + +