diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index 30bcbe9..a634eb2 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -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); diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs index aad683f..ee22804 100644 --- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs @@ -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 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 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) diff --git a/Foxnouns.Backend/ExpectedError.cs b/Foxnouns.Backend/ExpectedError.cs index 1b92a7e..e185d4e 100644 --- a/Foxnouns.Backend/ExpectedError.cs +++ b/Foxnouns.Backend/ExpectedError.cs @@ -147,6 +147,7 @@ public enum ErrorCode GenericApiError, UserNotFound, MemberNotFound, + AccountAlreadyLinked, } public class ValidationError diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs index 8178dd6..522c8d6 100644 --- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs +++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs @@ -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 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 GetAddExtraAccountStateAsync( + this KeyCacheService keyCacheService, + string state, + CancellationToken ct = default + ) => + await keyCacheService.GetKeyAsync( + $"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); diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index b560ba6..145de1a 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -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( diff --git a/Foxnouns.Frontend/app/components/ErrorAlert.tsx b/Foxnouns.Frontend/app/components/ErrorAlert.tsx index d66b6a1..be470a4 100644 --- a/Foxnouns.Frontend/app/components/ErrorAlert.tsx +++ b/Foxnouns.Frontend/app/components/ErrorAlert.tsx @@ -140,6 +140,8 @@ export const errorCodeDesc = (t: TFunction, code: ErrorCode) => { return t("error.errors.member-not-found"); case ErrorCode.UserNotFound: return t("error.errors.user-not-found"); + case ErrorCode.AccountAlreadyLinked: + return t("error.errors.account-already-linked"); } return t("error.errors.generic-error"); diff --git a/Foxnouns.Frontend/app/lib/api/error.ts b/Foxnouns.Frontend/app/lib/api/error.ts index 0a3d9b9..3f33566 100644 --- a/Foxnouns.Frontend/app/lib/api/error.ts +++ b/Foxnouns.Frontend/app/lib/api/error.ts @@ -16,6 +16,7 @@ export enum ErrorCode { GenericApiError = "GENERIC_API_ERROR", UserNotFound = "USER_NOT_FOUND", MemberNotFound = "MEMBER_NOT_FOUND", + AccountAlreadyLinked = "ACCOUNT_ALREADY_LINKED", } export type ValidationError = { diff --git a/Foxnouns.Frontend/app/lib/api/user.ts b/Foxnouns.Frontend/app/lib/api/user.ts index 4f07301..4b39502 100644 --- a/Foxnouns.Frontend/app/lib/api/user.ts +++ b/Foxnouns.Frontend/app/lib/api/user.ts @@ -71,7 +71,6 @@ export type AuthMethod = { type: "DISCORD" | "GOOGLE" | "TUMBLR" | "FEDIVERSE" | "EMAIL"; remote_id: string; remote_username?: string; - fediverse_instance?: string; }; export type CustomPreference = { diff --git a/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx b/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx index 3a1fd1a..c34ac58 100644 --- a/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx +++ b/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx @@ -5,8 +5,8 @@ import { LoaderFunctionArgs, MetaFunction, } from "@remix-run/node"; -import { type ApiError, ErrorCode, firstErrorFor } from "~/lib/api/error"; -import serverRequest, { writeCookie } from "~/lib/request.server"; +import { type ApiError, ErrorCode } from "~/lib/api/error"; +import serverRequest, { getToken, writeCookie } from "~/lib/request.server"; import { AuthResponse, CallbackResponse } from "~/lib/api/auth"; import { Form as RemixForm, @@ -17,12 +17,13 @@ import { useNavigate, } from "@remix-run/react"; import { Trans, useTranslation } from "react-i18next"; -import { Form, Button, Alert } from "react-bootstrap"; -import ErrorAlert from "~/components/ErrorAlert"; +import { Form, Button } from "react-bootstrap"; import i18n from "~/i18next.server"; import { tokenCookieName } from "~/lib/utils"; import { useEffect } from "react"; import RegisterError from "~/components/RegisterError"; +import { AuthMethod } from "~/lib/api/user"; +import { errorCodeDesc } from "~/components/ErrorAlert"; export const meta: MetaFunction = ({ data }) => { return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }]; @@ -39,9 +40,43 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const code = url.searchParams.get("code"); const state = url.searchParams.get("state"); + const token = getToken(request); + if (!code || !state) throw { status: 400, code: ErrorCode.BadRequest, message: "Missing code or state" } as ApiError; + if (token) { + try { + const resp = await serverRequest("POST", "/auth/discord/add-account/callback", { + body: { code, state }, + token, + isInternal: true, + }); + + return json({ + isLinkRequest: true, + meta: { title: t("log-in.callback.title.discord-link") }, + error: null, + hasAccount: false, + user: null, + ticket: null, + remoteUser: null, + newAuthMethod: resp, + }); + } catch (e) { + return json({ + isLinkRequest: true, + meta: { title: t("log-in.callback.title.discord-link") }, + error: e as ApiError, + hasAccount: false, + user: null, + ticket: null, + remoteUser: null, + newAuthMethod: null, + }); + } + } + const resp = await serverRequest("POST", "/auth/discord/callback", { body: { code, state }, isInternal: true, @@ -50,11 +85,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { if (resp.has_account) { return json( { + isLinkRequest: false, meta: { title: t("log-in.callback.title.discord-success") }, + error: null, hasAccount: true, user: resp.user!, ticket: null, remoteUser: null, + newAuthMethod: null, }, { headers: { @@ -65,11 +103,14 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { } return json({ + isLinkRequest: false, meta: { title: t("log-in.callback.title.discord-register") }, + error: null, hasAccount: false, user: null, ticket: resp.ticket!, remoteUser: resp.remote_username!, + newAuthMethod: null, }); }; @@ -122,6 +163,30 @@ export default function DiscordCallbackPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + if (data.isLinkRequest) { + if (data.error) { + return ( + <> +

{t("log-in.callback.link-error")}

+

{errorCodeDesc(t, data.error.code)}

+ + ); + } + + const authMethod = data.newAuthMethod!; + + return ( + <> +

{t("log-in.callback.discord-link-success")}

+

+ {t("log-in.callback.discord-link-success-hint", { + username: authMethod.remote_username ?? authMethod.remote_id, + })} +

+ + ); + } + if (data.hasAccount) { const username = data.user!.username; diff --git a/Foxnouns.Frontend/app/routes/settings.auth/route.tsx b/Foxnouns.Frontend/app/routes/settings.auth/route.tsx index 125f413..4954027 100644 --- a/Foxnouns.Frontend/app/routes/settings.auth/route.tsx +++ b/Foxnouns.Frontend/app/routes/settings.auth/route.tsx @@ -23,7 +23,13 @@ export default function AuthSettings() { const { urls } = useLoaderData(); const { user } = useRouteLoaderData("routes/settings")!; - return
{urls.email_enabled && }
; + return ( +
+ {urls.email_enabled && } + {urls.discord && } + +
+ ); } function EmailSettings({ user }: { user: MeUser }) { @@ -75,3 +81,86 @@ function EmailRow({ email, disabled }: { email: AuthMethod; disabled: boolean }) ); } + +function DiscordSettings({ user }: { user: MeUser }) { + const { t } = useTranslation(); + const oneAuthMethod = user.auth_methods.length === 1; + const discordAccounts = user.auth_methods.filter((m) => m.type === "DISCORD"); + + return ( + <> +

{t("settings.auth.discord-accounts")}

+ {discordAccounts.length > 0 && ( + <> + + {discordAccounts.map((a) => ( + + ))} + + + )} + {discordAccounts.length < 3 && ( +

+ {/* @ts-expect-error as=Link */} + +

+ )} + + ); +} + +function FediverseSettings({ user }: { user: MeUser }) { + const { t } = useTranslation(); + const oneAuthMethod = user.auth_methods.length === 1; + const fediAccounts = user.auth_methods.filter((m) => m.type === "FEDIVERSE"); + + return ( + <> +

{t("settings.auth.fediverse-accounts")}

+ {fediAccounts.length > 0 && ( + <> + + {fediAccounts.map((a) => ( + + ))} + + + )} + {fediAccounts.length < 3 && ( +

+ {/* @ts-expect-error as=Link */} + +

+ )} + + ); +} + +function NonEmailRow({ account, disabled }: { account: AuthMethod; disabled: boolean }) { + const { t } = useTranslation(); + + return ( + +
+
+ {account.remote_username} {account.type !== "FEDIVERSE" && <>({account.remote_id})} +
+ {!disabled && ( +
+ + {t("settings.auth.remove-auth-method")} + +
+ )} +
+
+ ); +} diff --git a/Foxnouns.Frontend/app/routes/settings.auth_.add-discord-account/route.tsx b/Foxnouns.Frontend/app/routes/settings.auth_.add-discord-account/route.tsx new file mode 100644 index 0000000..045fe85 --- /dev/null +++ b/Foxnouns.Frontend/app/routes/settings.auth_.add-discord-account/route.tsx @@ -0,0 +1,26 @@ +import { LoaderFunctionArgs, redirect, json } from "@remix-run/node"; +import serverRequest, { getToken } from "~/lib/request.server"; +import { ApiError } from "~/lib/api/error"; +import { useLoaderData } from "@remix-run/react"; +import ErrorAlert from "~/components/ErrorAlert"; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const token = getToken(request); + + try { + const { url } = await serverRequest<{ url: string }>("GET", "/auth/discord/add-account", { + isInternal: true, + token, + }); + + return redirect(url, 303); + } catch (e) { + return json({ error: e as ApiError }); + } +}; + +export default function AddDiscordAccountPage() { + const { error } = useLoaderData(); + + return ; +} diff --git a/Foxnouns.Frontend/public/locales/en.json b/Foxnouns.Frontend/public/locales/en.json index 78e2bf3..c7c1779 100644 --- a/Foxnouns.Frontend/public/locales/en.json +++ b/Foxnouns.Frontend/public/locales/en.json @@ -16,7 +16,8 @@ "generic-error": "An unknown error occurred.", "internal-server-error": "Server experienced an internal error, please try again later.", "member-not-found": "Member not found, please check your spelling and try again.", - "user-not-found": "User not found, please check your spelling and try again." + "user-not-found": "User not found, please check your spelling and try again.", + "account-already-linked": "This account is already linked with a pronouns.cc account." }, "title": "An error occurred", "more-info": "Click here for a more detailed error" @@ -51,11 +52,15 @@ "invalid-username": "Invalid username", "username-taken": "That username is already taken, please try something else.", "title": { + "discord-link": "Link a new Discord account", "discord-success": "Log in with Discord", "discord-register": "Register with Discord", "fediverse-success": "Log in with a Fediverse account", "fediverse-register": "Register with a Fediverse account" }, + "link-error": "Could not link account", + "discord-link-success": "Linked a new Discord account!", + "discord-link-success-hint": "Successfully linked the Discord account {{username}} with your pronouns.cc account. You can now close this page.", "success": "Successfully logged in!", "success-link": "Welcome back, <1>@{{username}}!", "redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.", @@ -126,14 +131,20 @@ "email-address": "Email address", "password-1": "Password", "password-2": "Confirm password", - "add-email-button": "Add email address" + "add-email-button": "Add email address", + "add-first-discord-account": "Link a Discord account", + "add-extra-discord-account": "Link another Discord account", + "add-first-fediverse-account": "Link a Fediverse account", + "add-extra-fediverse-account": "Link another Fediverse account" }, "no-email": "You haven't linked any email addresses yet. You can add one using this form.", "new-email-pending": "Email address added! Click the link in your inbox to confirm.", "email-link-success": "Email successfully linked", "redirect-to-auth-hint": "You will be redirected back to the authentication page in a few seconds.", "email-addresses": "Email addresses", - "remove-auth-method": "Remove" + "remove-auth-method": "Remove", + "discord-accounts": "Linked Discord accounts", + "fediverse-accounts": "Linked Fediverse accounts" }, "title": "Settings", "nav": {