feat: link discord account to existing account

This commit is contained in:
sam 2024-11-03 13:53:16 +01:00
parent c4cb08cdc1
commit 201c56c3dd
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
12 changed files with 333 additions and 14 deletions

View file

@ -1,5 +1,6 @@
using System.Web;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
@ -50,6 +51,15 @@ public class AuthController(
Instant ExpiresAt
);
public record SingleUrlResponse(string Url);
public record AddOauthAccountResponse(
Snowflake Id,
AuthType Type,
string RemoteId,
string? RemoteUsername
);
public record OauthRegisterRequest(string Ticket, string Username);
public record CallbackRequest(string Code, string State);

View file

@ -1,6 +1,10 @@
using System.Net;
using System.Web;
using EntityFramework.Exceptions.Common;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Utils;
@ -94,6 +98,87 @@ public class DiscordAuthController(
return Ok(await authService.GenerateUserTokenAsync(user));
}
[HttpGet("add-account")]
[Authorize("*")]
public async Task<IActionResult> AddDiscordAccountAsync()
{
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 url =
$"https://discord.com/oauth2/authorize?response_type=code"
+ $"&client_id={config.DiscordAuth.ClientId}&scope=identify"
+ $"&prompt=none&state={state}"
+ $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}";
return Ok(new AuthController.SingleUrlResponse(url));
}
[HttpPost("add-account/callback")]
[Authorize("*")]
public async Task<IActionResult> AddAccountCallbackAsync(
[FromBody] AuthController.CallbackRequest req
)
{
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);
var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code);
try
{
var authMethod = await authService.AddAuthMethodAsync(
CurrentUser.Id,
AuthType.Discord,
remoteUser.Id,
remoteUser.Username
);
_logger.Debug(
"Added new Discord auth method {AuthMethodId} to user {UserId}",
authMethod.Id,
CurrentUser.Id
);
return Ok(
new AuthController.AddOauthAccountResponse(
authMethod.Id,
AuthType.Discord,
authMethod.RemoteId,
authMethod.RemoteUsername
)
);
}
catch (UniqueConstraintException)
{
throw new ApiError(
"That account is already linked.",
HttpStatusCode.BadRequest,
ErrorCode.AccountAlreadyLinked
);
}
}
private void CheckRequirements()
{
if (!config.DiscordAuth.Enabled)

View file

@ -147,6 +147,7 @@ public enum ErrorCode
GenericApiError,
UserNotFound,
MemberNotFound,
AccountAlreadyLinked,
}
public class ValidationError

View file

@ -1,4 +1,5 @@
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
@ -57,9 +58,39 @@ public static class KeyCacheExtensions
delete: true,
ct
);
public static async Task<string> GenerateAddExtraAccountStateAsync(
this KeyCacheService keyCacheService,
AuthType authType,
Snowflake userId,
CancellationToken ct = default
)
{
var state = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync(
$"add_account:{state}",
new AddExtraAccountState(authType, userId),
Duration.FromDays(1),
ct
);
return state;
}
public static async Task<AddExtraAccountState?> GetAddExtraAccountStateAsync(
this KeyCacheService keyCacheService,
string state,
CancellationToken ct = default
) =>
await keyCacheService.GetKeyAsync<AddExtraAccountState>(
$"add_account:{state}",
delete: true,
ct
);
}
public record RegisterEmailState(
string Email,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId
);
public record AddExtraAccountState(AuthType AuthType, Snowflake UserId);

View file

@ -72,8 +72,9 @@ public class UserRendererService(
a.Id,
a.AuthType,
a.RemoteId,
a.RemoteUsername,
a.FediverseApplication?.Domain
a.FediverseApplication != null
? $"@{a.RemoteUsername}@{a.FediverseApplication.Domain}"
: a.RemoteUsername
))
: null,
tokenHidden ? user.ListHidden : null,
@ -130,9 +131,7 @@ public class UserRendererService(
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
string RemoteId,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
string? RemoteUsername,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
string? FediverseInstance
string? RemoteUsername
);
public record PartialUser(