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
|
||||
{
|
||||
|
|
143
Foxnouns.Backend/Services/Auth/RemoteAuthService.cs
Normal file
143
Foxnouns.Backend/Services/Auth/RemoteAuthService.cs
Normal file
|
@ -0,0 +1,143 @@
|
|||
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.Auth;
|
||||
|
||||
public class RemoteAuthService(
|
||||
Config config,
|
||||
ILogger logger,
|
||||
DatabaseContext db,
|
||||
KeyCacheService keyCacheService
|
||||
)
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<RemoteAuthService>();
|
||||
private readonly HttpClient _httpClient = new();
|
||||
|
||||
private readonly Uri _discordTokenUri = new("https://discord.com/api/oauth2/token");
|
||||
private readonly Uri _discordUserUri = new("https://discord.com/api/v10/users/@me");
|
||||
|
||||
public async Task<RemoteUser> RequestDiscordTokenAsync(
|
||||
string code,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var redirectUri = $"{config.BaseUrl}/auth/callback/discord";
|
||||
var resp = await _httpClient.PostAsync(
|
||||
_discordTokenUri,
|
||||
new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "client_id", config.DiscordAuth.ClientId! },
|
||||
{ "client_secret", config.DiscordAuth.ClientSecret! },
|
||||
{ "grant_type", "authorization_code" },
|
||||
{ "code", code },
|
||||
{ "redirect_uri", redirectUri },
|
||||
}
|
||||
),
|
||||
ct
|
||||
);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var respBody = await resp.Content.ReadAsStringAsync(ct);
|
||||
_logger.Error(
|
||||
"Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}",
|
||||
(int)resp.StatusCode,
|
||||
respBody
|
||||
);
|
||||
throw new FoxnounsError("Invalid Discord OAuth response");
|
||||
}
|
||||
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(ct);
|
||||
if (token == null)
|
||||
throw new FoxnounsError("Discord token response was null");
|
||||
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri);
|
||||
req.Headers.Add("Authorization", $"{token.token_type} {token.access_token}");
|
||||
|
||||
var resp2 = await _httpClient.SendAsync(req, ct);
|
||||
resp2.EnsureSuccessStatusCode();
|
||||
var user = await resp2.Content.ReadFromJsonAsync<DiscordUserResponse>(ct);
|
||||
if (user == null)
|
||||
throw new FoxnounsError("Discord user response was null");
|
||||
|
||||
return new RemoteUser(user.id, user.username);
|
||||
}
|
||||
|
||||
[SuppressMessage(
|
||||
"ReSharper",
|
||||
"InconsistentNaming",
|
||||
Justification = "Easier to use snake_case here, rather than passing in JSON converter options"
|
||||
)]
|
||||
[UsedImplicitly]
|
||||
private record DiscordTokenResponse(string access_token, string token_type);
|
||||
|
||||
[SuppressMessage(
|
||||
"ReSharper",
|
||||
"InconsistentNaming",
|
||||
Justification = "Easier to use snake_case here, rather than passing in JSON converter options"
|
||||
)]
|
||||
[UsedImplicitly]
|
||||
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