From 77c3047b1ef02b9112ba2f90c11b4bb1ff9229bc Mon Sep 17 00:00:00 2001 From: sam Date: Thu, 12 Dec 2024 16:44:01 +0100 Subject: [PATCH] feat: misskey auth --- .../Authentication/FediverseAuthController.cs | 9 +- Foxnouns.Backend/Dto/Auth.cs | 2 +- .../Extensions/KeyCacheExtensions.cs | 30 ++++ .../Auth/FediverseAuthService.Misskey.cs | 170 ++++++++++++++++++ .../Services/Auth/FediverseAuthService.cs | 14 +- .../mastodon/[instance]/+page.server.ts | 5 +- 6 files changed, 214 insertions(+), 16 deletions(-) create mode 100644 Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs diff --git a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs index d95c622..3dcc817 100644 --- a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs @@ -161,20 +161,13 @@ public class FediverseAuthController( [FromBody] FediverseCallbackRequest req ) { - await remoteAuthService.ValidateAddAccountStateAsync( - req.State, - CurrentUser!.Id, - AuthType.Fediverse, - req.Instance - ); - FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance); FediverseAuthService.FediverseUser remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code); try { AuthMethod authMethod = await authService.AddAuthMethodAsync( - CurrentUser.Id, + CurrentUser!.Id, AuthType.Fediverse, remoteUser.Id, remoteUser.Username, diff --git a/Foxnouns.Backend/Dto/Auth.cs b/Foxnouns.Backend/Dto/Auth.cs index 05308a5..ea9e67d 100644 --- a/Foxnouns.Backend/Dto/Auth.cs +++ b/Foxnouns.Backend/Dto/Auth.cs @@ -59,4 +59,4 @@ public record EmailCallbackRequest(string State); public record EmailChangePasswordRequest(string Current, string New); -public record FediverseCallbackRequest(string Instance, string Code, string State); +public record FediverseCallbackRequest(string Instance, string Code, string? State = null); diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs index 1d99830..d7e8784 100644 --- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -91,6 +91,34 @@ public static class KeyCacheExtensions string state, CancellationToken ct = default ) => await keyCacheService.GetKeyAsync($"add_account:{state}", true, ct); + + public static async Task 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 GetForgotPasswordStateAsync( + this KeyCacheService keyCacheService, + string state, + CancellationToken ct = default + ) => + await keyCacheService.GetKeyAsync( + $"forgot_password:{state}", + true, + ct + ); } public record RegisterEmailState( @@ -98,4 +126,6 @@ public record RegisterEmailState( [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId ); +public record ForgotPasswordState(string Email, Snowflake UserId); + public record AddExtraAccountState(AuthType AuthType, Snowflake UserId, string? Instance = null); diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs new file mode 100644 index 0000000..beff74a --- /dev/null +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs @@ -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 . +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 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(); + 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 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(); + if (userResp == null) + { + throw new FoxnounsError($"User response from instance {app.Domain} was invalid"); + } + + return userResp.User; + } + + private async Task 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(); + 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 + ); +} diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs index 7e67fa7..250455a 100644 --- a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs @@ -81,11 +81,11 @@ public partial class FediverseAuthService string softwareName = await GetSoftwareNameAsync(instance); if (IsMastodonCompatible(softwareName)) - { return await CreateMastodonApplicationAsync(instance); - } + if (IsMisskeyCompatible(softwareName)) + return await CreateMisskeyApplicationAsync(instance); - throw new NotImplementedException(); + throw new ApiError.BadRequest($"{softwareName} is not a supported instance type, sorry."); } private async Task GetSoftwareNameAsync(string instance) @@ -129,7 +129,11 @@ public partial class FediverseAuthService forceRefresh, state ), - FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), + FediverseInstanceType.MisskeyApi => await GenerateMisskeyAuthUrlAsync( + app, + forceRefresh, + state + ), _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), }; @@ -141,7 +145,7 @@ public partial class FediverseAuthService app.InstanceType switch { 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), }; 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 fff5322..b092b1e 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 @@ -5,9 +5,10 @@ import createRegisterAction from "$lib/actions/register"; export const load = createCallbackLoader("fediverse", async ({ params, url }) => { const code = url.searchParams.get("code") as string | null; const state = url.searchParams.get("state") as string | null; - 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 = {