feat: link fediverse account to existing user
This commit is contained in:
		
							parent
							
								
									03209e4028
								
							
						
					
					
						commit
						57e1ec09c0
					
				
					 17 changed files with 335 additions and 95 deletions
				
			
		|  | @ -104,21 +104,9 @@ public class DiscordAuthController( | ||||||
|     { |     { | ||||||
|         CheckRequirements(); |         CheckRequirements(); | ||||||
| 
 | 
 | ||||||
|         var existingAccounts = await db |         var state = await remoteAuthService.ValidateAddAccountRequestAsync( | ||||||
|             .AuthMethods.Where(m => m.UserId == CurrentUser!.Id && m.AuthType == AuthType.Discord) |             CurrentUser!.Id, | ||||||
|             .CountAsync(); |             AuthType.Discord | ||||||
|         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 url = |         var url = | ||||||
|  | @ -138,12 +126,11 @@ public class DiscordAuthController( | ||||||
|     { |     { | ||||||
|         CheckRequirements(); |         CheckRequirements(); | ||||||
| 
 | 
 | ||||||
|         var accountState = await keyCacheService.GetAddExtraAccountStateAsync(req.State); |         await remoteAuthService.ValidateAddAccountStateAsync( | ||||||
|         if ( |             req.State, | ||||||
|             accountState is not { AuthType: AuthType.Discord } |             CurrentUser!.Id, | ||||||
|             || accountState.UserId != CurrentUser!.Id |             AuthType.Discord | ||||||
|         ) |         ); | ||||||
|             throw new ApiError.BadRequest("Invalid state", "state", req.State); |  | ||||||
| 
 | 
 | ||||||
|         var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code); |         var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code); | ||||||
|         try |         try | ||||||
|  |  | ||||||
|  | @ -1,5 +1,8 @@ | ||||||
|  | using System.Net; | ||||||
|  | using EntityFramework.Exceptions.Common; | ||||||
| using Foxnouns.Backend.Database; | using Foxnouns.Backend.Database; | ||||||
| using Foxnouns.Backend.Database.Models; | using Foxnouns.Backend.Database.Models; | ||||||
|  | using Foxnouns.Backend.Middleware; | ||||||
| using Foxnouns.Backend.Services; | using Foxnouns.Backend.Services; | ||||||
| using Foxnouns.Backend.Services.Auth; | using Foxnouns.Backend.Services.Auth; | ||||||
| using Foxnouns.Backend.Utils; | using Foxnouns.Backend.Utils; | ||||||
|  | @ -15,13 +18,14 @@ public class FediverseAuthController( | ||||||
|     DatabaseContext db, |     DatabaseContext db, | ||||||
|     FediverseAuthService fediverseAuthService, |     FediverseAuthService fediverseAuthService, | ||||||
|     AuthService authService, |     AuthService authService, | ||||||
|  |     RemoteAuthService remoteAuthService, | ||||||
|     KeyCacheService keyCacheService |     KeyCacheService keyCacheService | ||||||
| ) : ApiControllerBase | ) : ApiControllerBase | ||||||
| { | { | ||||||
|     private readonly ILogger _logger = logger.ForContext<FediverseAuthController>(); |     private readonly ILogger _logger = logger.ForContext<FediverseAuthController>(); | ||||||
| 
 | 
 | ||||||
|     [HttpGet] |     [HttpGet] | ||||||
|     [ProducesResponseType<FediverseUrlResponse>(statusCode: StatusCodes.Status200OK)] |     [ProducesResponseType<AuthController.SingleUrlResponse>(statusCode: StatusCodes.Status200OK)] | ||||||
|     public async Task<IActionResult> GetFediverseUrlAsync( |     public async Task<IActionResult> GetFediverseUrlAsync( | ||||||
|         [FromQuery] string instance, |         [FromQuery] string instance, | ||||||
|         [FromQuery] bool forceRefresh = false |         [FromQuery] bool forceRefresh = false | ||||||
|  | @ -31,7 +35,7 @@ public class FediverseAuthController( | ||||||
|             throw new ApiError.BadRequest("Not a valid domain.", "instance", instance); |             throw new ApiError.BadRequest("Not a valid domain.", "instance", instance); | ||||||
| 
 | 
 | ||||||
|         var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh); |         var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh); | ||||||
|         return Ok(new FediverseUrlResponse(url)); |         return Ok(new AuthController.SingleUrlResponse(url)); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     [HttpPost("callback")] |     [HttpPost("callback")] | ||||||
|  | @ -118,9 +122,74 @@ public class FediverseAuthController( | ||||||
|         return Ok(await authService.GenerateUserTokenAsync(user)); |         return Ok(await authService.GenerateUserTokenAsync(user)); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public record CallbackRequest(string Instance, string Code, string State); |     [HttpGet("add-account")] | ||||||
|  |     [Authorize("*")] | ||||||
|  |     public async Task<IActionResult> 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<IActionResult> 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( |     private record FediverseTicketData( | ||||||
|         Snowflake ApplicationId, |         Snowflake ApplicationId, | ||||||
|  |  | ||||||
|  | @ -63,13 +63,14 @@ public static class KeyCacheExtensions | ||||||
|         this KeyCacheService keyCacheService, |         this KeyCacheService keyCacheService, | ||||||
|         AuthType authType, |         AuthType authType, | ||||||
|         Snowflake userId, |         Snowflake userId, | ||||||
|  |         string? instance = null, | ||||||
|         CancellationToken ct = default |         CancellationToken ct = default | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         var state = AuthUtils.RandomToken(); |         var state = AuthUtils.RandomToken(); | ||||||
|         await keyCacheService.SetKeyAsync( |         await keyCacheService.SetKeyAsync( | ||||||
|             $"add_account:{state}", |             $"add_account:{state}", | ||||||
|             new AddExtraAccountState(authType, userId), |             new AddExtraAccountState(authType, userId, instance), | ||||||
|             Duration.FromDays(1), |             Duration.FromDays(1), | ||||||
|             ct |             ct | ||||||
|         ); |         ); | ||||||
|  | @ -93,4 +94,4 @@ public record RegisterEmailState( | ||||||
|     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId |     [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId | ||||||
| ); | ); | ||||||
| 
 | 
 | ||||||
| public record AddExtraAccountState(AuthType AuthType, Snowflake UserId); | public record AddExtraAccountState(AuthType AuthType, Snowflake UserId, string? Instance = null); | ||||||
|  |  | ||||||
|  | @ -12,6 +12,7 @@ | ||||||
|         <PackageReference Include="Coravel.Mailer" Version="5.0.1"/> |         <PackageReference Include="Coravel.Mailer" Version="5.0.1"/> | ||||||
|         <PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/> |         <PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/> | ||||||
|         <PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2"/> |         <PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2"/> | ||||||
|  |         <PackageReference Include="Humanizer.Core" Version="2.14.1"/> | ||||||
|         <PackageReference Include="JetBrains.Annotations" Version="2024.2.0"/> |         <PackageReference Include="JetBrains.Annotations" Version="2024.2.0"/> | ||||||
|         <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7"/> |         <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7"/> | ||||||
|         <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7"/> |         <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7"/> | ||||||
|  |  | ||||||
|  | @ -218,10 +218,11 @@ public class AuthService( | ||||||
|         AuthType authType, |         AuthType authType, | ||||||
|         string remoteId, |         string remoteId, | ||||||
|         string? remoteUsername = null, |         string? remoteUsername = null, | ||||||
|  |         FediverseApplication? app = null, | ||||||
|         CancellationToken ct = default |         CancellationToken ct = default | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         AssertValidAuthType(authType, null); |         AssertValidAuthType(authType, app); | ||||||
| 
 | 
 | ||||||
|         // This is already checked when |         // This is already checked when | ||||||
|         var currentCount = await db |         var currentCount = await db | ||||||
|  | @ -237,6 +238,7 @@ public class AuthService( | ||||||
|             Id = snowflakeGenerator.GenerateSnowflake(), |             Id = snowflakeGenerator.GenerateSnowflake(), | ||||||
|             AuthType = authType, |             AuthType = authType, | ||||||
|             RemoteId = remoteId, |             RemoteId = remoteId, | ||||||
|  |             FediverseApplicationId = app?.Id, | ||||||
|             RemoteUsername = remoteUsername, |             RemoteUsername = remoteUsername, | ||||||
|             UserId = userId, |             UserId = userId, | ||||||
|         }; |         }; | ||||||
|  |  | ||||||
|  | @ -69,10 +69,11 @@ public partial class FediverseAuthService | ||||||
|     private async Task<FediverseUser> GetMastodonUserAsync( |     private async Task<FediverseUser> GetMastodonUserAsync( | ||||||
|         FediverseApplication app, |         FediverseApplication app, | ||||||
|         string code, |         string code, | ||||||
|         string state |         string? state = null | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         await _keyCacheService.ValidateAuthStateAsync(state); |         if (state != null) | ||||||
|  |             await _keyCacheService.ValidateAuthStateAsync(state); | ||||||
| 
 | 
 | ||||||
|         var tokenResp = await _client.PostAsync( |         var tokenResp = await _client.PostAsync( | ||||||
|             MastodonTokenUri(app.Domain), |             MastodonTokenUri(app.Domain), | ||||||
|  | @ -120,7 +121,8 @@ public partial class FediverseAuthService | ||||||
| 
 | 
 | ||||||
|     private async Task<string> GenerateMastodonAuthUrlAsync( |     private async Task<string> GenerateMastodonAuthUrlAsync( | ||||||
|         FediverseApplication app, |         FediverseApplication app, | ||||||
|         bool forceRefresh |         bool forceRefresh, | ||||||
|  |         string? state = null | ||||||
|     ) |     ) | ||||||
|     { |     { | ||||||
|         if (forceRefresh) |         if (forceRefresh) | ||||||
|  | @ -132,7 +134,7 @@ public partial class FediverseAuthService | ||||||
|             app = await CreateMastodonApplicationAsync(app.Domain, existingAppId: app.Id); |             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" |         return $"https://{app.Domain}/oauth/authorize?response_type=code" | ||||||
|             + $"&client_id={app.ClientId}" |             + $"&client_id={app.ClientId}" | ||||||
|  |  | ||||||
|  | @ -37,10 +37,14 @@ public partial class FediverseAuthService | ||||||
|         _client.DefaultRequestHeaders.Add("Accept", "application/json"); |         _client.DefaultRequestHeaders.Add("Accept", "application/json"); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     public async Task<string> GenerateAuthUrlAsync(string instance, bool forceRefresh) |     public async Task<string> GenerateAuthUrlAsync( | ||||||
|  |         string instance, | ||||||
|  |         bool forceRefresh, | ||||||
|  |         string? state = null | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var app = await GetApplicationAsync(instance); |         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, |     // 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<string> GenerateAuthUrlAsync(FediverseApplication app, bool forceRefresh) => |     private async Task<string> GenerateAuthUrlAsync( | ||||||
|  |         FediverseApplication app, | ||||||
|  |         bool forceRefresh, | ||||||
|  |         string? state = null | ||||||
|  |     ) => | ||||||
|         app.InstanceType switch |         app.InstanceType switch | ||||||
|         { |         { | ||||||
|             FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync( |             FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync( | ||||||
|                 app, |                 app, | ||||||
|                 forceRefresh |                 forceRefresh, | ||||||
|  |                 state | ||||||
|             ), |             ), | ||||||
|             FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), |             FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), | ||||||
|             _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), |             _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), | ||||||
|  | @ -110,7 +119,7 @@ public partial class FediverseAuthService | ||||||
|     public async Task<FediverseUser> GetRemoteFediverseUserAsync( |     public async Task<FediverseUser> GetRemoteFediverseUserAsync( | ||||||
|         FediverseApplication app, |         FediverseApplication app, | ||||||
|         string code, |         string code, | ||||||
|         string state |         string? state = null | ||||||
|     ) => |     ) => | ||||||
|         app.InstanceType switch |         app.InstanceType switch | ||||||
|         { |         { | ||||||
|  |  | ||||||
|  | @ -1,9 +1,21 @@ | ||||||
| using System.Diagnostics.CodeAnalysis; | 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 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<RemoteAuthService>(); |     private readonly ILogger _logger = logger.ForContext<RemoteAuthService>(); | ||||||
|     private readonly HttpClient _httpClient = new(); |     private readonly HttpClient _httpClient = new(); | ||||||
|  | @ -76,4 +88,56 @@ public class RemoteAuthService(Config config, ILogger logger) | ||||||
|     private record DiscordUserResponse(string id, string username); |     private record DiscordUserResponse(string id, string username); | ||||||
| 
 | 
 | ||||||
|     public record RemoteUser(string Id, string Username); |     public record RemoteUser(string Id, string Username); | ||||||
|  | 
 | ||||||
|  |     /// <summary> | ||||||
|  |     /// Validates whether a user can still add a new account of the given AuthType, and throws an error if they can't. | ||||||
|  |     /// </summary> | ||||||
|  |     /// <param name="userId">The user to check.</param> | ||||||
|  |     /// <param name="authType">The auth type to check.</param> | ||||||
|  |     /// <param name="instance">The optional fediverse instance to generate a state for.</param> | ||||||
|  |     /// <returns>A state for the given auth type and user ID.</returns> | ||||||
|  |     /// <exception cref="ApiError.BadRequest">The given user can't add another account of this type. | ||||||
|  |     /// This exception should not be caught by controller code.</exception> | ||||||
|  |     public async Task<string> 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) | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     /// <summary> | ||||||
|  |     /// Checks whether the given state is correct for the given user/auth type combination. | ||||||
|  |     /// </summary> | ||||||
|  |     /// <exception cref="ApiError.BadRequest">The state doesn't match. | ||||||
|  |     /// This exception should not be caught by controller code.</exception> | ||||||
|  |     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); | ||||||
|  |     } | ||||||
| } | } | ||||||
|  | @ -127,6 +127,9 @@ public static partial class ValidationUtils | ||||||
|         if (entries.Length > Limits.FieldEntriesLimit + 50) |         if (entries.Length > Limits.FieldEntriesLimit + 50) | ||||||
|             return errors; |             return errors; | ||||||
| 
 | 
 | ||||||
|  |         var customPreferenceIds = | ||||||
|  |             customPreferences?.Keys.Select(id => id.ToString()).ToArray() ?? []; | ||||||
|  | 
 | ||||||
|         foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) |         foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) | ||||||
|         { |         { | ||||||
|             switch (entry.Value.Length) |             switch (entry.Value.Length) | ||||||
|  | @ -159,8 +162,6 @@ public static partial class ValidationUtils | ||||||
|                     break; |                     break; | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? []; |  | ||||||
| 
 |  | ||||||
|             if ( |             if ( | ||||||
|                 !DefaultStatusOptions.Contains(entry.Status) |                 !DefaultStatusOptions.Contains(entry.Status) | ||||||
|                 && !customPreferenceIds.Contains(entry.Status) |                 && !customPreferenceIds.Contains(entry.Status) | ||||||
|  | @ -203,6 +204,9 @@ public static partial class ValidationUtils | ||||||
|         if (entries.Length > Limits.FieldEntriesLimit + 50) |         if (entries.Length > Limits.FieldEntriesLimit + 50) | ||||||
|             return errors; |             return errors; | ||||||
| 
 | 
 | ||||||
|  |         var customPreferenceIds = | ||||||
|  |             customPreferences?.Keys.Select(id => id.ToString()).ToList() ?? []; | ||||||
|  | 
 | ||||||
|         foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) |         foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) | ||||||
|         { |         { | ||||||
|             switch (entry.Value.Length) |             switch (entry.Value.Length) | ||||||
|  | @ -268,8 +272,6 @@ public static partial class ValidationUtils | ||||||
|                 } |                 } | ||||||
|             } |             } | ||||||
| 
 | 
 | ||||||
|             var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? []; |  | ||||||
| 
 |  | ||||||
|             if ( |             if ( | ||||||
|                 !DefaultStatusOptions.Contains(entry.Status) |                 !DefaultStatusOptions.Contains(entry.Status) | ||||||
|                 && !customPreferenceIds.Contains(entry.Status) |                 && !customPreferenceIds.Contains(entry.Status) | ||||||
|  |  | ||||||
|  | @ -45,6 +45,12 @@ | ||||||
|           "Npgsql": "8.0.1" |           "Npgsql": "8.0.1" | ||||||
|         } |         } | ||||||
|       }, |       }, | ||||||
|  |       "Humanizer.Core": { | ||||||
|  |         "type": "Direct", | ||||||
|  |         "requested": "[2.14.1, )", | ||||||
|  |         "resolved": "2.14.1", | ||||||
|  |         "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" | ||||||
|  |       }, | ||||||
|       "JetBrains.Annotations": { |       "JetBrains.Annotations": { | ||||||
|         "type": "Direct", |         "type": "Direct", | ||||||
|         "requested": "[2024.2.0, )", |         "requested": "[2024.2.0, )", | ||||||
|  | @ -291,11 +297,6 @@ | ||||||
|           "Microsoft.EntityFrameworkCore.Relational": "8.0.0" |           "Microsoft.EntityFrameworkCore.Relational": "8.0.0" | ||||||
|         } |         } | ||||||
|       }, |       }, | ||||||
|       "Humanizer.Core": { |  | ||||||
|         "type": "Transitive", |  | ||||||
|         "resolved": "2.14.1", |  | ||||||
|         "contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw==" |  | ||||||
|       }, |  | ||||||
|       "MailKit": { |       "MailKit": { | ||||||
|         "type": "Transitive", |         "type": "Transitive", | ||||||
|         "resolved": "2.5.1", |         "resolved": "2.5.1", | ||||||
|  |  | ||||||
|  | @ -47,7 +47,8 @@ | ||||||
| 		"successful-link-fedi": "Your account has successfully been linked to the following fediverse account:", | 		"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-hint": "You now can close this page, or go back to your profile:", | ||||||
| 		"successful-link-profile-link": "Go 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": { | 	"error": { | ||||||
| 		"bad-request-header": "Something was wrong with your input", | 		"bad-request-header": "Something was wrong with your input", | ||||||
|  |  | ||||||
|  | @ -1,35 +1,62 @@ | ||||||
| import { apiRequest } from "$api"; | import { apiRequest } from "$api"; | ||||||
| import ApiError, { ErrorCode } from "$api/error"; | 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 { setToken } from "$lib"; | ||||||
| import createRegisterAction from "$lib/actions/register.js"; | 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 }) => { | 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 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; | 	if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj; | ||||||
| 
 | 
 | ||||||
| 	const resp = await apiRequest<CallbackResponse>("POST", "/auth/fediverse/callback", { | 	const { meUser } = await parent(); | ||||||
| 		body: { code, state, instance: params.instance }, | 	if (meUser) { | ||||||
| 		isInternal: true, | 		try { | ||||||
| 		fetch, | 			const resp = await apiRequest<AddAccountResponse>( | ||||||
| 	}); | 				"POST", | ||||||
|  | 				"/auth/fediverse/add-account/callback", | ||||||
|  | 				{ | ||||||
|  | 					isInternal: true, | ||||||
|  | 					body: { code, state, instance: params.instance }, | ||||||
|  | 					fetch, | ||||||
|  | 					cookies, | ||||||
|  | 				}, | ||||||
|  | 			); | ||||||
| 
 | 
 | ||||||
| 	if (resp.has_account) { | 			return { hasAccount: true, isLinkRequest: true, newAuthMethod: resp }; | ||||||
| 		setToken(cookies, resp.token!); | 		} catch (e) { | ||||||
| 		redirect(303, `/@${resp.user!.username}`); | 			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 { | 	try { | ||||||
| 		hasAccount: false, | 		const resp = await apiRequest<CallbackResponse>("POST", "/auth/fediverse/callback", { | ||||||
| 		instance: params.instance, | 			body: { code, state, instance: params.instance }, | ||||||
| 		ticket: resp.ticket!, | 			isInternal: true, | ||||||
| 		remoteUser: resp.remote_username!, | 			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 = { | export const actions = { | ||||||
|  |  | ||||||
|  | @ -1,7 +1,9 @@ | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| 	import type { ActionData, PageData } from "./$types"; | 	import type { ActionData, PageData } from "./$types"; | ||||||
| 	import { t } from "$lib/i18n"; | 	import { t } from "$lib/i18n"; | ||||||
|  | 	import Error from "$components/Error.svelte"; | ||||||
| 	import OauthRegistrationForm from "$components/settings/OauthRegistrationForm.svelte"; | 	import OauthRegistrationForm from "$components/settings/OauthRegistrationForm.svelte"; | ||||||
|  | 	import NewAuthMethod from "$components/settings/NewAuthMethod.svelte"; | ||||||
| 
 | 
 | ||||||
| 	type Props = { data: PageData; form: ActionData }; | 	type Props = { data: PageData; form: ActionData }; | ||||||
| 	let { data, form }: Props = $props(); | 	let { data, form }: Props = $props(); | ||||||
|  | @ -12,11 +14,18 @@ | ||||||
| </svelte:head> | </svelte:head> | ||||||
| 
 | 
 | ||||||
| <div class="container"> | <div class="container"> | ||||||
| 	<OauthRegistrationForm | 	{#if data.error} | ||||||
| 		title={$t("auth.register-with-mastodon")} | 		<h1>{$t("auth.register-with-mastodon")}</h1> | ||||||
| 		remoteLabel={$t("auth.remote-fediverse-account-label")} | 		<Error error={data.error} /> | ||||||
| 		remoteUser={data.remoteUser} | 	{:else if data.isLinkRequest} | ||||||
| 		ticket={data.ticket} | 		<NewAuthMethod method={data.newAuthMethod!} user={data.meUser!} /> | ||||||
| 		error={form?.error} | 	{:else} | ||||||
| 	/> | 		<OauthRegistrationForm | ||||||
|  | 			title={$t("auth.register-with-mastodon")} | ||||||
|  | 			remoteLabel={$t("auth.remote-fediverse-account-label")} | ||||||
|  | 			remoteUser={data.remoteUser!} | ||||||
|  | 			ticket={data.ticket!} | ||||||
|  | 			error={form?.error} | ||||||
|  | 		/> | ||||||
|  | 	{/if} | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
|  | @ -72,7 +72,11 @@ | ||||||
| 				<h4 class="mt-4">{$t("auth.log-in-with-the-fediverse")}</h4> | 				<h4 class="mt-4">{$t("auth.log-in-with-the-fediverse")}</h4> | ||||||
| 				<form method="POST" action="?/fedi" use:enhance> | 				<form method="POST" action="?/fedi" use:enhance> | ||||||
| 					<InputGroup> | 					<InputGroup> | ||||||
| 						<Input name="instance" type="text" placeholder="Your instance (i.e. mastodon.social)" /> | 						<Input | ||||||
|  | 							name="instance" | ||||||
|  | 							type="text" | ||||||
|  | 							placeholder={$t("auth.log-in-with-fediverse-instance-placeholder")} | ||||||
|  | 						/> | ||||||
| 						<Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button> | 						<Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button> | ||||||
| 					</InputGroup> | 					</InputGroup> | ||||||
| 					<p> | 					<p> | ||||||
|  |  | ||||||
|  | @ -0,0 +1,37 @@ | ||||||
|  | import { apiRequest } from "$api"; | ||||||
|  | import { redirect } from "@sveltejs/kit"; | ||||||
|  | 
 | ||||||
|  | export const actions = { | ||||||
|  | 	add: async ({ request, fetch, cookies }) => { | ||||||
|  | 		const body = await request.formData(); | ||||||
|  | 		const instance = body.get("instance") as string; | ||||||
|  | 
 | ||||||
|  | 		const { url } = await apiRequest<{ url: string }>( | ||||||
|  | 			"GET", | ||||||
|  | 			`/auth/fediverse/add-account?instance=${encodeURIComponent(instance)}`, | ||||||
|  | 			{ | ||||||
|  | 				isInternal: true, | ||||||
|  | 				fetch, | ||||||
|  | 				cookies, | ||||||
|  | 			}, | ||||||
|  | 		); | ||||||
|  | 
 | ||||||
|  | 		redirect(303, url); | ||||||
|  | 	}, | ||||||
|  | 	forceRefresh: async ({ request, fetch, cookies }) => { | ||||||
|  | 		const body = await request.formData(); | ||||||
|  | 		const instance = body.get("instance") as string; | ||||||
|  | 
 | ||||||
|  | 		const { url } = await apiRequest<{ url: string }>( | ||||||
|  | 			"GET", | ||||||
|  | 			`/auth/fediverse/add-account?instance=${encodeURIComponent(instance)}&forceRefresh=true`, | ||||||
|  | 			{ | ||||||
|  | 				isInternal: true, | ||||||
|  | 				fetch, | ||||||
|  | 				cookies, | ||||||
|  | 			}, | ||||||
|  | 		); | ||||||
|  | 
 | ||||||
|  | 		redirect(303, url); | ||||||
|  | 	}, | ||||||
|  | }; | ||||||
|  | @ -0,0 +1,23 @@ | ||||||
|  | <script lang="ts"> | ||||||
|  | 	import { t } from "$lib/i18n"; | ||||||
|  | 	import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap"; | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <h3>Link a new Fediverse account</h3> | ||||||
|  | 
 | ||||||
|  | <form method="POST" action="?/add"> | ||||||
|  | 	<InputGroup> | ||||||
|  | 		<Input | ||||||
|  | 			name="instance" | ||||||
|  | 			type="text" | ||||||
|  | 			placeholder={$t("auth.log-in-with-fediverse-instance-placeholder")} | ||||||
|  | 		/> | ||||||
|  | 		<Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button> | ||||||
|  | 	</InputGroup> | ||||||
|  | 	<p> | ||||||
|  | 		{$t("auth.log-in-with-fediverse-error-blurb")} | ||||||
|  | 		<Button formaction="?/forceRefresh" type="submit" color="link"> | ||||||
|  | 			{$t("auth.log-in-with-fediverse-force-refresh-button")} | ||||||
|  | 		</Button> | ||||||
|  | 	</p> | ||||||
|  | </form> | ||||||
|  | @ -1,25 +1,26 @@ | ||||||
| <Project Sdk="Microsoft.NET.Sdk"> | <Project Sdk="Microsoft.NET.Sdk"> | ||||||
| 
 | 
 | ||||||
|   <PropertyGroup> |     <PropertyGroup> | ||||||
|     <OutputType>Exe</OutputType> |         <OutputType>Exe</OutputType> | ||||||
|     <TargetFramework>net8.0</TargetFramework> |         <TargetFramework>net8.0</TargetFramework> | ||||||
|     <ImplicitUsings>enable</ImplicitUsings> |         <ImplicitUsings>enable</ImplicitUsings> | ||||||
|     <Nullable>enable</Nullable> |         <Nullable>enable</Nullable> | ||||||
|   </PropertyGroup> |     </PropertyGroup> | ||||||
| 
 | 
 | ||||||
|   <ItemGroup> |     <ItemGroup> | ||||||
|     <ProjectReference Include="..\..\Foxnouns.Backend\Foxnouns.Backend.csproj" /> |         <ProjectReference Include="..\..\Foxnouns.Backend\Foxnouns.Backend.csproj"/> | ||||||
|   </ItemGroup> |     </ItemGroup> | ||||||
| 
 | 
 | ||||||
|   <ItemGroup> |     <ItemGroup> | ||||||
|     <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0" /> |         <PackageReference Include="Humanizer.Core" Version="2.14.1"/> | ||||||
|     <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7" /> |         <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0"/> | ||||||
|     <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7"> |         <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7"/> | ||||||
|       <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> |         <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7"> | ||||||
|       <PrivateAssets>all</PrivateAssets> |             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> | ||||||
|     </PackageReference> |             <PrivateAssets>all</PrivateAssets> | ||||||
|     <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" /> |         </PackageReference> | ||||||
|     <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4" /> |         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4"/> | ||||||
|   </ItemGroup> |         <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4"/> | ||||||
|  |     </ItemGroup> | ||||||
| 
 | 
 | ||||||
| </Project> | </Project> | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue