From c4cb08cdc18f84f78859445e6e2c54c5d3cc4f3c Mon Sep 17 00:00:00 2001 From: sam Date: Sun, 3 Nov 2024 02:07:07 +0100 Subject: [PATCH] feat: initial fediverse registration/login --- .../Authentication/AuthController.cs | 21 ++- .../Authentication/DiscordAuthController.cs | 50 +----- .../Authentication/EmailAuthController.cs | 3 +- .../Authentication/FediverseAuthController.cs | 95 +++++++++- .../Extensions/WebApplicationExtensions.cs | 2 + .../Services/{ => Auth}/AuthService.cs | 51 +++++- .../FediverseAuthService.Mastodon.cs | 4 +- .../{ => Auth}/FediverseAuthService.cs | 15 +- .../Services/UserRendererService.cs | 2 +- .../app/components/RegisterError.tsx | 36 ++++ Foxnouns.Frontend/app/lib/request.server.ts | 2 +- .../routes/auth.callback.discord/route.tsx | 32 +--- .../route.tsx | 163 ++++++++++++++++++ .../app/routes/auth.log-in/route.tsx | 3 + .../routes/auth.log-in_.fediverse/route.tsx | 75 ++++++++ Foxnouns.Frontend/public/locales/en.json | 24 ++- 16 files changed, 467 insertions(+), 111 deletions(-) rename Foxnouns.Backend/Services/{ => Auth}/AuthService.cs (85%) rename Foxnouns.Backend/Services/{ => Auth}/FediverseAuthService.Mastodon.cs (98%) rename Foxnouns.Backend/Services/{ => Auth}/FediverseAuthService.cs (93%) create mode 100644 Foxnouns.Frontend/app/components/RegisterError.tsx create mode 100644 Foxnouns.Frontend/app/routes/auth.callback.mastodon.$instance/route.tsx create mode 100644 Foxnouns.Frontend/app/routes/auth.log-in_.fediverse/route.tsx diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index 1a737eb..30bcbe9 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -50,17 +50,6 @@ public class AuthController( Instant ExpiresAt ); - public record CallbackResponse( - bool HasAccount, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - string? RemoteUsername, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - UserRendererService.UserResponse? User, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token, - [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt - ); - public record OauthRegisterRequest(string Ticket, string Username); public record CallbackRequest(string Code, string State); @@ -77,3 +66,13 @@ public class AuthController( return NoContent(); } } + +public record CallbackResponse( + bool HasAccount, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Ticket, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + UserRendererService.UserResponse? User, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? Token, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? ExpiresAt +); diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs index 54ca24b..aad683f 100644 --- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs @@ -2,6 +2,7 @@ using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Services; +using Foxnouns.Backend.Services.Auth; using Foxnouns.Backend.Utils; using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc; @@ -14,20 +15,16 @@ namespace Foxnouns.Backend.Controllers.Authentication; public class DiscordAuthController( [UsedImplicitly] Config config, ILogger logger, - IClock clock, DatabaseContext db, KeyCacheService keyCacheService, AuthService authService, - RemoteAuthService remoteAuthService, - UserRendererService userRenderer + RemoteAuthService remoteAuthService ) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); [HttpPost("callback")] - // TODO: duplicating attribute doesn't work, find another way to mark both as possible response - // leaving it here for documentation purposes - [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status200OK)] public async Task CallbackAsync([FromBody] AuthController.CallbackRequest req) { CheckRequirements(); @@ -36,7 +33,7 @@ public class DiscordAuthController( var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code); var user = await authService.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id); if (user != null) - return Ok(await GenerateUserTokenAsync(user)); + return Ok(await authService.GenerateUserTokenAsync(user)); _logger.Debug( "Discord user {Username} ({Id}) authenticated with no local account", @@ -52,7 +49,7 @@ public class DiscordAuthController( ); return Ok( - new AuthController.CallbackResponse( + new CallbackResponse( HasAccount: false, Ticket: ticket, RemoteUsername: remoteUser.Username, @@ -94,42 +91,7 @@ public class DiscordAuthController( remoteUser.Username ); - return Ok(await GenerateUserTokenAsync(user)); - } - - private async Task GenerateUserTokenAsync( - User user, - CancellationToken ct = default - ) - { - var frontendApp = await db.GetFrontendApplicationAsync(ct); - _logger.Debug("Logging user {Id} in with Discord", user.Id); - - var (tokenStr, token) = authService.GenerateToken( - user, - frontendApp, - ["*"], - clock.GetCurrentInstant() + Duration.FromDays(365) - ); - db.Add(token); - - _logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id); - - await db.SaveChangesAsync(ct); - - return new AuthController.CallbackResponse( - HasAccount: true, - Ticket: null, - RemoteUsername: null, - User: await userRenderer.RenderUserAsync( - user, - selfUser: user, - renderMembers: false, - ct: ct - ), - Token: tokenStr, - ExpiresAt: token.ExpiresAt - ); + return Ok(await authService.GenerateUserTokenAsync(user)); } private void CheckRequirements() diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index 7e3706e..6aadf65 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -3,6 +3,7 @@ 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; using JetBrains.Annotations; using Microsoft.AspNetCore.Mvc; @@ -84,7 +85,7 @@ public class EmailAuthController( await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20)); return Ok( - new AuthController.CallbackResponse( + new CallbackResponse( HasAccount: false, Ticket: ticket, RemoteUsername: state.Email, diff --git a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs index fdd10b7..43a2955 100644 --- a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs @@ -1,11 +1,26 @@ +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Services; +using Foxnouns.Backend.Services.Auth; +using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NodaTime; +using FediverseAuthService = Foxnouns.Backend.Services.Auth.FediverseAuthService; namespace Foxnouns.Backend.Controllers.Authentication; [Route("/api/internal/auth/fediverse")] -public class FediverseAuthController(FediverseAuthService fediverseAuthService) : ApiControllerBase +public class FediverseAuthController( + ILogger logger, + DatabaseContext db, + FediverseAuthService fediverseAuthService, + AuthService authService, + KeyCacheService keyCacheService +) : ApiControllerBase { + private readonly ILogger _logger = logger.ForContext(); + [HttpGet] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task GetFediverseUrlAsync([FromQuery] string instance) @@ -14,12 +29,88 @@ public class FediverseAuthController(FediverseAuthService fediverseAuthService) return Ok(new FediverseUrlResponse(url)); } + [HttpPost("callback")] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task FediverseCallbackAsync([FromBody] CallbackRequest req) { - throw new NotImplementedException(); + var app = await fediverseAuthService.GetApplicationAsync(req.Instance); + var remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code); + + var user = await authService.AuthenticateUserAsync( + AuthType.Fediverse, + remoteUser.Id, + instance: app + ); + if (user != null) + return Ok(await authService.GenerateUserTokenAsync(user)); + + var ticket = AuthUtils.RandomToken(); + await keyCacheService.SetKeyAsync( + $"fediverse:{ticket}", + new FediverseTicketData(app.Id, remoteUser), + Duration.FromMinutes(20) + ); + + return Ok( + new CallbackResponse( + HasAccount: false, + Ticket: ticket, + RemoteUsername: $"@{remoteUser.Username}@{app.Domain}", + User: null, + Token: null, + ExpiresAt: null + ) + ); + } + + [HttpPost("register")] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] + public async Task RegisterAsync( + [FromBody] AuthController.OauthRegisterRequest req + ) + { + var ticketData = await keyCacheService.GetKeyAsync( + $"fediverse:{req.Ticket}" + ); + if (ticketData == null) + throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); + + var app = await db.FediverseApplications.FindAsync(ticketData.ApplicationId); + if ( + await db.AuthMethods.AnyAsync(a => + a.AuthType == AuthType.Fediverse + && a.RemoteId == ticketData.User.Id + && a.FediverseApplicationId == app.Id + ) + ) + { + _logger.Error( + "Fediverse user {Id}/{ApplicationId} ({Username} on {Domain}) has valid ticket but is already linked to an existing account", + ticketData.User.Id, + ticketData.ApplicationId, + ticketData.User.Username, + app.Domain + ); + throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); + } + + var user = await authService.CreateUserWithRemoteAuthAsync( + req.Username, + AuthType.Fediverse, + ticketData.User.Id, + ticketData.User.Username, + instance: app + ); + + return Ok(await authService.GenerateUserTokenAsync(user)); } public record CallbackRequest(string Instance, string Code); private record FediverseUrlResponse(string Url); + + private record FediverseTicketData( + Snowflake ApplicationId, + FediverseAuthService.FediverseUser User + ); } diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index ce2f59b..c505f4d 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -4,12 +4,14 @@ using Foxnouns.Backend.Database; using Foxnouns.Backend.Jobs; using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; +using Foxnouns.Backend.Services.Auth; using Microsoft.EntityFrameworkCore; using Minio; using NodaTime; using Prometheus; using Serilog; using Serilog.Events; +using FediverseAuthService = Foxnouns.Backend.Services.Auth.FediverseAuthService; using IClock = NodaTime.IClock; namespace Foxnouns.Backend.Extensions; diff --git a/Foxnouns.Backend/Services/AuthService.cs b/Foxnouns.Backend/Services/Auth/AuthService.cs similarity index 85% rename from Foxnouns.Backend/Services/AuthService.cs rename to Foxnouns.Backend/Services/Auth/AuthService.cs index d03496c..9675f22 100644 --- a/Foxnouns.Backend/Services/AuthService.cs +++ b/Foxnouns.Backend/Services/Auth/AuthService.cs @@ -1,4 +1,5 @@ using System.Security.Cryptography; +using Foxnouns.Backend.Controllers.Authentication; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Utils; @@ -6,10 +7,17 @@ using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using NodaTime; -namespace Foxnouns.Backend.Services; +namespace Foxnouns.Backend.Services.Auth; -public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator snowflakeGenerator) +public class AuthService( + IClock clock, + ILogger logger, + DatabaseContext db, + ISnowflakeGenerator snowflakeGenerator, + UserRendererService userRenderer +) { + private readonly ILogger _logger = logger.ForContext(); private readonly PasswordHasher _passwordHasher = new(); /// @@ -256,6 +264,45 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s ); } + /// + /// Generates a token for the given user and adds it to the database, returning a fully formed auth response for the user. + /// This method is always called at the end of an endpoint method, so the resulting token + /// (and user, if this is a registration request) is also saved to the database. + /// + public async Task GenerateUserTokenAsync( + User user, + CancellationToken ct = default + ) + { + var frontendApp = await db.GetFrontendApplicationAsync(ct); + + var (tokenStr, token) = GenerateToken( + user, + frontendApp, + ["*"], + clock.GetCurrentInstant() + Duration.FromDays(365) + ); + db.Add(token); + + _logger.Debug("Generated token {TokenId} for {UserId}", user.Id, token.Id); + + await db.SaveChangesAsync(ct); + + return new CallbackResponse( + HasAccount: true, + Ticket: null, + RemoteUsername: null, + User: await userRenderer.RenderUserAsync( + user, + selfUser: user, + renderMembers: false, + ct: ct + ), + Token: tokenStr, + ExpiresAt: token.ExpiresAt + ); + } + private static (string, byte[]) GenerateToken() { var token = AuthUtils.RandomToken(); diff --git a/Foxnouns.Backend/Services/FediverseAuthService.Mastodon.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs similarity index 98% rename from Foxnouns.Backend/Services/FediverseAuthService.Mastodon.cs rename to Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs index e0e5e98..139830b 100644 --- a/Foxnouns.Backend/Services/FediverseAuthService.Mastodon.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Mastodon.cs @@ -5,12 +5,12 @@ using Foxnouns.Backend.Database.Models; using Duration = NodaTime.Duration; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; -namespace Foxnouns.Backend.Services; +namespace Foxnouns.Backend.Services.Auth; public partial class FediverseAuthService { private string MastodonRedirectUri(string instance) => - $"{_config.BaseUrl}/auth/login/mastodon/{instance}"; + $"{_config.BaseUrl}/auth/callback/mastodon/{instance}"; private async Task CreateMastodonApplicationAsync( string instance, diff --git a/Foxnouns.Backend/Services/FediverseAuthService.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs similarity index 93% rename from Foxnouns.Backend/Services/FediverseAuthService.cs rename to Foxnouns.Backend/Services/Auth/FediverseAuthService.cs index ff39e88..fc54017 100644 --- a/Foxnouns.Backend/Services/FediverseAuthService.cs +++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore; using NodaTime; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; -namespace Foxnouns.Backend.Services; +namespace Foxnouns.Backend.Services.Auth; public partial class FediverseAuthService { @@ -43,12 +43,6 @@ public partial class FediverseAuthService return await GenerateAuthUrlAsync(app); } - public async Task GetRemoteFediverseUserAsync(string instance, string code) - { - var app = await GetApplicationAsync(instance); - return await GetRemoteUserAsync(app, code); - } - // thank you, gargron and syuilo, for agreeing on a name for *once* in your lives, // and having both mastodon and misskey use "username" in the self user response public record FediverseUser( @@ -56,7 +50,7 @@ public partial class FediverseAuthService [property: J("username")] string Username ); - private async Task GetApplicationAsync(string instance) + public async Task GetApplicationAsync(string instance) { var app = await _db.FediverseApplications.FirstOrDefaultAsync(a => a.Domain == instance); if (app != null) @@ -110,7 +104,10 @@ public partial class FediverseAuthService _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), }; - private async Task GetRemoteUserAsync(FediverseApplication app, string code) => + public async Task GetRemoteFediverseUserAsync( + FediverseApplication app, + string code + ) => app.InstanceType switch { FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code), diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index b47832d..b560ba6 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -97,7 +97,7 @@ public class UserRendererService( ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" : null; - public string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp"; + private string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp"; public record UserResponse( Snowflake Id, diff --git a/Foxnouns.Frontend/app/components/RegisterError.tsx b/Foxnouns.Frontend/app/components/RegisterError.tsx new file mode 100644 index 0000000..1ecbbdb --- /dev/null +++ b/Foxnouns.Frontend/app/components/RegisterError.tsx @@ -0,0 +1,36 @@ +import { ApiError, firstErrorFor } from "~/lib/api/error"; +import { Trans, useTranslation } from "react-i18next"; +import { Alert } from "react-bootstrap"; +import { Link } from "@remix-run/react"; +import ErrorAlert from "~/components/ErrorAlert"; + +export default function RegisterError({ error }: { error: ApiError }) { + const { t } = useTranslation(); + + // TODO: maybe turn these messages into their own error codes? + const ticketMessage = firstErrorFor(error, "ticket")?.message; + const usernameMessage = firstErrorFor(error, "username")?.message; + + if (ticketMessage === "Invalid ticket") { + return ( + + {t("error.heading")} + + Invalid ticket (it might have been too long since you logged in), please{" "} + try again. + + + ); + } + + if (usernameMessage === "Username is already taken") { + return ( + + {t("log-in.callback.invalid-username")} + {t("log-in.callback.username-taken")} + + ); + } + + return ; +} diff --git a/Foxnouns.Frontend/app/lib/request.server.ts b/Foxnouns.Frontend/app/lib/request.server.ts index 8dab154..49f2d20 100644 --- a/Foxnouns.Frontend/app/lib/request.server.ts +++ b/Foxnouns.Frontend/app/lib/request.server.ts @@ -30,7 +30,7 @@ export async function baseRequest( headers: { ...params.headers, ...(params.token ? { Authorization: params.token } : {}), - "Content-Type": "application/json", + ...(params.body ? { "Content-Type": "application/json" } : {}), }, }); diff --git a/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx b/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx index 5fb246c..3a1fd1a 100644 --- a/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx +++ b/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx @@ -22,6 +22,7 @@ import ErrorAlert from "~/components/ErrorAlert"; import i18n from "~/i18next.server"; import { tokenCookieName } from "~/lib/utils"; import { useEffect } from "react"; +import RegisterError from "~/components/RegisterError"; export const meta: MetaFunction = ({ data }) => { return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }]; @@ -163,34 +164,3 @@ export default function DiscordCallbackPage() { ); } - -function RegisterError({ error }: { error: ApiError }) { - const { t } = useTranslation(); - - // TODO: maybe turn these messages into their own error codes? - const ticketMessage = firstErrorFor(error, "ticket")?.message; - const usernameMessage = firstErrorFor(error, "username")?.message; - - if (ticketMessage === "Invalid ticket") { - return ( - - {t("error.heading")} - - Invalid ticket (it might have been too long since you logged in with Discord), please{" "} - try again. - - - ); - } - - if (usernameMessage === "Username is already taken") { - return ( - - {t("log-in.callback.invalid-username")} - {t("log-in.callback.username-taken")} - - ); - } - - return ; -} diff --git a/Foxnouns.Frontend/app/routes/auth.callback.mastodon.$instance/route.tsx b/Foxnouns.Frontend/app/routes/auth.callback.mastodon.$instance/route.tsx new file mode 100644 index 0000000..8444bb5 --- /dev/null +++ b/Foxnouns.Frontend/app/routes/auth.callback.mastodon.$instance/route.tsx @@ -0,0 +1,163 @@ +import { + ActionFunctionArgs, + json, + LoaderFunctionArgs, + MetaFunction, + redirect, +} from "@remix-run/node"; +import i18n from "~/i18next.server"; +import { type ApiError, ErrorCode } from "~/lib/api/error"; +import serverRequest, { writeCookie } from "~/lib/request.server"; +import { AuthResponse, CallbackResponse } from "~/lib/api/auth"; +import { tokenCookieName } from "~/lib/utils"; +import { + Link, + ShouldRevalidateFunction, + useActionData, + useLoaderData, + useNavigate, +} from "@remix-run/react"; +import { Trans, useTranslation } from "react-i18next"; +import { useEffect } from "react"; +import { Form as RemixForm } from "@remix-run/react/dist/components"; +import { Button, Form } from "react-bootstrap"; +import RegisterError from "~/components/RegisterError"; + +export const meta: MetaFunction = ({ data }) => { + return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }]; +}; + +export const shouldRevalidate: ShouldRevalidateFunction = ({ actionResult }) => { + return !actionResult; +}; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const t = await i18n.getFixedT(request); + const url = new URL(request.url); + + const code = url.searchParams.get("code"); + if (!code) throw { status: 400, code: ErrorCode.BadRequest, message: "Missing code" } as ApiError; + + const resp = await serverRequest("POST", "/auth/fediverse/callback", { + body: { code, instance: params.instance! }, + isInternal: true, + }); + + if (resp.has_account) { + return json( + { + meta: { title: t("log-in.callback.title.fediverse-success") }, + hasAccount: true, + user: resp.user!, + ticket: null, + remoteUser: null, + }, + { + headers: { + "Set-Cookie": writeCookie(tokenCookieName, resp.token!), + }, + }, + ); + } + + return json({ + meta: { title: t("log-in.callback.title.fediverse-register") }, + hasAccount: false, + user: null, + instance: params.instance!, + ticket: resp.ticket!, + remoteUser: resp.remote_username!, + }); +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const data = await request.formData(); + const username = data.get("username") as string | null; + const ticket = data.get("ticket") as string | null; + + if (!username || !ticket) + return json({ + error: { + status: 403, + code: ErrorCode.BadRequest, + message: "Invalid username or ticket", + } as ApiError, + user: null, + }); + + try { + const resp = await serverRequest("POST", "/auth/fediverse/register", { + body: { username, ticket }, + isInternal: true, + }); + + return redirect("/auth/welcome", { + headers: { + "Set-Cookie": writeCookie(tokenCookieName, resp.token), + }, + status: 303, + }); + } catch (e) { + JSON.stringify(e); + + return json({ error: e as ApiError }); + } +}; + +export default function FediverseCallbackPage() { + const { t } = useTranslation(); + const data = useLoaderData(); + const actionData = useActionData(); + const navigate = useNavigate(); + + useEffect(() => { + setTimeout(() => { + if (data.hasAccount) { + navigate(`/@${data.user!.username}`); + } + }, 2000); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + if (data.hasAccount) { + const username = data.user!.username; + + return ( + <> +

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

+

+ + {/* @ts-expect-error react-i18next handles interpolation here */} + Welcome back, @{{ username }}! + +
+ {t("log-in.callback.redirect-hint")} +

+ + ); + } + + return ( + +
+ {actionData?.error && } + + {t("log-in.callback.remote-username.fediverse")} + + + + {t("log-in.callback.username")} + + + + + +
+ ); +} diff --git a/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx b/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx index aaffd80..dc3af98 100644 --- a/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx +++ b/Foxnouns.Frontend/app/routes/auth.log-in/route.tsx @@ -126,6 +126,9 @@ export default function LoginPage() { {t("log-in.3rd-party.tumblr")} )} + + {t("log-in.3rd-party.fediverse")} + {!urls.email_enabled &&
} diff --git a/Foxnouns.Frontend/app/routes/auth.log-in_.fediverse/route.tsx b/Foxnouns.Frontend/app/routes/auth.log-in_.fediverse/route.tsx new file mode 100644 index 0000000..a7304f5 --- /dev/null +++ b/Foxnouns.Frontend/app/routes/auth.log-in_.fediverse/route.tsx @@ -0,0 +1,75 @@ +import { + LoaderFunctionArgs, + json, + MetaFunction, + ActionFunctionArgs, + redirect, +} from "@remix-run/node"; +import i18n from "~/i18next.server"; +import { useTranslation } from "react-i18next"; +import { Form as RemixForm, useActionData } from "@remix-run/react"; +import { Button, Form } from "react-bootstrap"; +import serverRequest from "~/lib/request.server"; +import { ApiError, ErrorCode } from "~/lib/api/error"; +import ErrorAlert from "~/components/ErrorAlert"; + +export const meta: MetaFunction = ({ data }) => { + return [{ title: `${data?.meta.title || "Log in with a Fediverse account"} • pronouns.cc` }]; +}; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const t = await i18n.getFixedT(request); + + return json({ meta: { title: t("log-in.fediverse.choose-title") } }); +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const body = await request.formData(); + const instance = body.get("instance") as string | null; + if (!instance) + return json({ + error: { + status: 403, + code: ErrorCode.BadRequest, + message: "Invalid instance name", + } as ApiError, + }); + + try { + const resp = await serverRequest<{ url: string }>( + "GET", + `/auth/fediverse?instance=${encodeURIComponent(instance)}`, + { + isInternal: true, + }, + ); + + return redirect(resp.url); + } catch (e) { + return json({ error: e as ApiError }); + } +}; + +export default function AuthFediversePage() { + const { t } = useTranslation(); + + const data = useActionData(); + + return ( + <> +

{t("log-in.fediverse.choose-form-title")}

+ {data?.error && } + +
+ + {t("log-in.fediverse-instance-label")} + + + +
+
+ + ); +} diff --git a/Foxnouns.Frontend/public/locales/en.json b/Foxnouns.Frontend/public/locales/en.json index c834e95..78e2bf3 100644 --- a/Foxnouns.Frontend/public/locales/en.json +++ b/Foxnouns.Frontend/public/locales/en.json @@ -47,22 +47,31 @@ }, "log-in": { "callback": { + "invalid-ticket": "Invalid ticket (it might have been too long since you logged in), please <2>try again.", + "invalid-username": "Invalid username", + "username-taken": "That username is already taken, please try something else.", "title": { "discord-success": "Log in with Discord", - "discord-register": "Register with Discord" + "discord-register": "Register with Discord", + "fediverse-success": "Log in with a Fediverse account", + "fediverse-register": "Register with a Fediverse account" }, "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.", "remote-username": { - "discord": "Your discord username" + "discord": "Your Discord username", + "fediverse": "Your Fediverse account" }, "username": "Username", - "sign-up-button": "Sign up", - "invalid-ticket": "Invalid ticket (it might have been too long since you logged in with Discord), please <2>try again.", - "invalid-username": "Invalid username", - "username-taken": "That username is already taken, please try something else." + "sign-up-button": "Sign up" }, + "fediverse": { + "choose-title": "Log in with a Fediverse account", + "choose-form-title": "Choose a Fediverse instance" + }, + "fediverse-instance-label": "Your Fediverse instance", + "fediverse-log-in-button": "Log in", "title": "Log in", "form-title": "Log in with email", "email": "Email address", @@ -74,7 +83,8 @@ "desc": "If you prefer, you can also log in with one of these services:", "discord": "Log in with Discord", "google": "Log in with Google", - "tumblr": "Log in with Tumblr" + "tumblr": "Log in with Tumblr", + "fediverse": "Log in with the Fediverse" }, "invalid-credentials": "Invalid email address or password, please check your spelling and try again." },