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
				
			
		|  | @ -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, | ||||
|         }; | ||||
|  |  | |||
|  | @ -69,10 +69,11 @@ public partial class FediverseAuthService | |||
|     private async Task<FediverseUser> 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<string> 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}" | ||||
|  |  | |||
|  | @ -37,10 +37,14 @@ public partial class FediverseAuthService | |||
|         _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); | ||||
|         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<string> GenerateAuthUrlAsync(FediverseApplication app, bool forceRefresh) => | ||||
|     private async Task<string> 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<FediverseUser> GetRemoteFediverseUserAsync( | ||||
|         FediverseApplication app, | ||||
|         string code, | ||||
|         string state | ||||
|         string? state = null | ||||
|     ) => | ||||
|         app.InstanceType switch | ||||
|         { | ||||
|  |  | |||
|  | @ -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<RemoteAuthService>(); | ||||
|     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); | ||||
| 
 | ||||
|     /// <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); | ||||
|     } | ||||
| } | ||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue