Compare commits

..

2 commits

Author SHA1 Message Date
sam
c4cb08cdc1
feat: initial fediverse registration/login 2024-11-03 02:07:07 +01:00
sam
5a22807410
fix: don't pass CancellationToken to method that shouldn't abort
also add license header to project
2024-11-02 21:23:49 +01:00
18 changed files with 487 additions and 121 deletions

View file

@ -50,17 +50,6 @@ public class AuthController(
Instant ExpiresAt 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 OauthRegisterRequest(string Ticket, string Username);
public record CallbackRequest(string Code, string State); public record CallbackRequest(string Code, string State);
@ -77,3 +66,13 @@ public class AuthController(
return NoContent(); 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
);

View file

@ -2,6 +2,7 @@ using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -14,32 +15,25 @@ namespace Foxnouns.Backend.Controllers.Authentication;
public class DiscordAuthController( public class DiscordAuthController(
[UsedImplicitly] Config config, [UsedImplicitly] Config config,
ILogger logger, ILogger logger,
IClock clock,
DatabaseContext db, DatabaseContext db,
KeyCacheService keyCacheService, KeyCacheService keyCacheService,
AuthService authService, AuthService authService,
RemoteAuthService remoteAuthService, RemoteAuthService remoteAuthService
UserRendererService userRenderer
) : ApiControllerBase ) : ApiControllerBase
{ {
private readonly ILogger _logger = logger.ForContext<DiscordAuthController>(); private readonly ILogger _logger = logger.ForContext<DiscordAuthController>();
[HttpPost("callback")] [HttpPost("callback")]
// TODO: duplicating attribute doesn't work, find another way to mark both as possible response [ProducesResponseType<CallbackResponse>(StatusCodes.Status200OK)]
// leaving it here for documentation purposes public async Task<IActionResult> CallbackAsync([FromBody] AuthController.CallbackRequest req)
[ProducesResponseType<AuthController.CallbackResponse>(StatusCodes.Status200OK)]
public async Task<IActionResult> CallbackAsync(
[FromBody] AuthController.CallbackRequest req,
CancellationToken ct = default
)
{ {
CheckRequirements(); CheckRequirements();
await keyCacheService.ValidateAuthStateAsync(req.State, ct); await keyCacheService.ValidateAuthStateAsync(req.State);
var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code, req.State, ct); var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code);
var user = await authService.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id, ct: ct); var user = await authService.AuthenticateUserAsync(AuthType.Discord, remoteUser.Id);
if (user != null) if (user != null)
return Ok(await GenerateUserTokenAsync(user, ct)); return Ok(await authService.GenerateUserTokenAsync(user));
_logger.Debug( _logger.Debug(
"Discord user {Username} ({Id}) authenticated with no local account", "Discord user {Username} ({Id}) authenticated with no local account",
@ -51,12 +45,11 @@ public class DiscordAuthController(
await keyCacheService.SetKeyAsync( await keyCacheService.SetKeyAsync(
$"discord:{ticket}", $"discord:{ticket}",
remoteUser, remoteUser,
Duration.FromMinutes(20), Duration.FromMinutes(20)
ct
); );
return Ok( return Ok(
new AuthController.CallbackResponse( new CallbackResponse(
HasAccount: false, HasAccount: false,
Ticket: ticket, Ticket: ticket,
RemoteUsername: remoteUser.Username, RemoteUsername: remoteUser.Username,
@ -98,42 +91,7 @@ public class DiscordAuthController(
remoteUser.Username remoteUser.Username
); );
return Ok(await GenerateUserTokenAsync(user)); return Ok(await authService.GenerateUserTokenAsync(user));
}
private async Task<AuthController.CallbackResponse> 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
);
} }
private void CheckRequirements() private void CheckRequirements()

View file

@ -3,6 +3,7 @@ using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@ -84,7 +85,7 @@ public class EmailAuthController(
await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20)); await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20));
return Ok( return Ok(
new AuthController.CallbackResponse( new CallbackResponse(
HasAccount: false, HasAccount: false,
Ticket: ticket, Ticket: ticket,
RemoteUsername: state.Email, RemoteUsername: state.Email,

View file

@ -1,11 +1,26 @@
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using FediverseAuthService = Foxnouns.Backend.Services.Auth.FediverseAuthService;
namespace Foxnouns.Backend.Controllers.Authentication; namespace Foxnouns.Backend.Controllers.Authentication;
[Route("/api/internal/auth/fediverse")] [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<FediverseAuthController>();
[HttpGet] [HttpGet]
[ProducesResponseType<FediverseUrlResponse>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<FediverseUrlResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> GetFediverseUrlAsync([FromQuery] string instance) public async Task<IActionResult> GetFediverseUrlAsync([FromQuery] string instance)
@ -14,12 +29,88 @@ public class FediverseAuthController(FediverseAuthService fediverseAuthService)
return Ok(new FediverseUrlResponse(url)); return Ok(new FediverseUrlResponse(url));
} }
[HttpPost("callback")]
[ProducesResponseType<CallbackResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> FediverseCallbackAsync([FromBody] CallbackRequest req) public async Task<IActionResult> 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<AuthController.AuthResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> RegisterAsync(
[FromBody] AuthController.OauthRegisterRequest req
)
{
var ticketData = await keyCacheService.GetKeyAsync<FediverseTicketData>(
$"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); public record CallbackRequest(string Instance, string Code);
private record FediverseUrlResponse(string Url); private record FediverseUrlResponse(string Url);
private record FediverseTicketData(
Snowflake ApplicationId,
FediverseAuthService.FediverseUser User
);
} }

View file

@ -4,12 +4,14 @@ using Foxnouns.Backend.Database;
using Foxnouns.Backend.Jobs; using Foxnouns.Backend.Jobs;
using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Minio; using Minio;
using NodaTime; using NodaTime;
using Prometheus; using Prometheus;
using Serilog; using Serilog;
using Serilog.Events; using Serilog.Events;
using FediverseAuthService = Foxnouns.Backend.Services.Auth.FediverseAuthService;
using IClock = NodaTime.IClock; using IClock = NodaTime.IClock;
namespace Foxnouns.Backend.Extensions; namespace Foxnouns.Backend.Extensions;

View file

@ -1,4 +1,5 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using Foxnouns.Backend.Controllers.Authentication;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
@ -6,10 +7,17 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; 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<AuthService>();
private readonly PasswordHasher<User> _passwordHasher = new(); private readonly PasswordHasher<User> _passwordHasher = new();
/// <summary> /// <summary>
@ -256,6 +264,45 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
); );
} }
/// <summary>
/// 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.
/// </summary>
public async Task<CallbackResponse> 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() private static (string, byte[]) GenerateToken()
{ {
var token = AuthUtils.RandomToken(); var token = AuthUtils.RandomToken();

View file

@ -5,12 +5,12 @@ using Foxnouns.Backend.Database.Models;
using Duration = NodaTime.Duration; using Duration = NodaTime.Duration;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Foxnouns.Backend.Services; namespace Foxnouns.Backend.Services.Auth;
public partial class FediverseAuthService public partial class FediverseAuthService
{ {
private string MastodonRedirectUri(string instance) => private string MastodonRedirectUri(string instance) =>
$"{_config.BaseUrl}/auth/login/mastodon/{instance}"; $"{_config.BaseUrl}/auth/callback/mastodon/{instance}";
private async Task<FediverseApplication> CreateMastodonApplicationAsync( private async Task<FediverseApplication> CreateMastodonApplicationAsync(
string instance, string instance,

View file

@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Foxnouns.Backend.Services; namespace Foxnouns.Backend.Services.Auth;
public partial class FediverseAuthService public partial class FediverseAuthService
{ {
@ -43,12 +43,6 @@ public partial class FediverseAuthService
return await GenerateAuthUrlAsync(app); return await GenerateAuthUrlAsync(app);
} }
public async Task<FediverseUser> 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, // 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 // and having both mastodon and misskey use "username" in the self user response
public record FediverseUser( public record FediverseUser(
@ -56,7 +50,7 @@ public partial class FediverseAuthService
[property: J("username")] string Username [property: J("username")] string Username
); );
private async Task<FediverseApplication> GetApplicationAsync(string instance) public async Task<FediverseApplication> GetApplicationAsync(string instance)
{ {
var app = await _db.FediverseApplications.FirstOrDefaultAsync(a => a.Domain == instance); var app = await _db.FediverseApplications.FirstOrDefaultAsync(a => a.Domain == instance);
if (app != null) if (app != null)
@ -110,7 +104,10 @@ public partial class FediverseAuthService
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
}; };
private async Task<FediverseUser> GetRemoteUserAsync(FediverseApplication app, string code) => public async Task<FediverseUser> GetRemoteFediverseUserAsync(
FediverseApplication app,
string code
) =>
app.InstanceType switch app.InstanceType switch
{ {
FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code), FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code),

View file

@ -13,7 +13,6 @@ public class RemoteAuthService(Config config, ILogger logger)
public async Task<RemoteUser> RequestDiscordTokenAsync( public async Task<RemoteUser> RequestDiscordTokenAsync(
string code, string code,
string state,
CancellationToken ct = default CancellationToken ct = default
) )
{ {

View file

@ -97,7 +97,7 @@ public class UserRendererService(
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
: null; : 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( public record UserResponse(
Snowflake Id, Snowflake Id,

View file

@ -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 (
<Alert variant="danger">
<Alert.Heading as="h4">{t("error.heading")}</Alert.Heading>
<Trans t={t} i18nKey={"log-in.callback.invalid-ticket"}>
Invalid ticket (it might have been too long since you logged in), please{" "}
<Link to="/auth/log-in">try again</Link>.
</Trans>
</Alert>
);
}
if (usernameMessage === "Username is already taken") {
return (
<Alert variant="danger">
<Alert.Heading as="h4">{t("log-in.callback.invalid-username")}</Alert.Heading>
{t("log-in.callback.username-taken")}
</Alert>
);
}
return <ErrorAlert error={error} />;
}

View file

@ -30,7 +30,7 @@ export async function baseRequest(
headers: { headers: {
...params.headers, ...params.headers,
...(params.token ? { Authorization: params.token } : {}), ...(params.token ? { Authorization: params.token } : {}),
"Content-Type": "application/json", ...(params.body ? { "Content-Type": "application/json" } : {}),
}, },
}); });

View file

@ -22,6 +22,7 @@ import ErrorAlert from "~/components/ErrorAlert";
import i18n from "~/i18next.server"; import i18n from "~/i18next.server";
import { tokenCookieName } from "~/lib/utils"; import { tokenCookieName } from "~/lib/utils";
import { useEffect } from "react"; import { useEffect } from "react";
import RegisterError from "~/components/RegisterError";
export const meta: MetaFunction<typeof loader> = ({ data }) => { export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }]; return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }];
@ -163,34 +164,3 @@ export default function DiscordCallbackPage() {
</RemixForm> </RemixForm>
); );
} }
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 (
<Alert variant="danger">
<Alert.Heading as="h4">{t("error.heading")}</Alert.Heading>
<Trans t={t} i18nKey={"log-in.callback.invalid-ticket"}>
Invalid ticket (it might have been too long since you logged in with Discord), please{" "}
<Link to="/auth/log-in">try again</Link>.
</Trans>
</Alert>
);
}
if (usernameMessage === "Username is already taken") {
return (
<Alert variant="danger">
<Alert.Heading as="h4">{t("log-in.callback.invalid-username")}</Alert.Heading>
{t("log-in.callback.username-taken")}
</Alert>
);
}
return <ErrorAlert error={error} />;
}

View file

@ -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<typeof loader> = ({ 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<CallbackResponse>("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<AuthResponse>("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<typeof loader>();
const actionData = useActionData<typeof action>();
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 (
<>
<h1>{t("log-in.callback.success")}</h1>
<p>
<Trans
t={t}
i18nKey={"log-in.callback.success-link"}
values={{ username: data.user!.username }}
>
{/* @ts-expect-error react-i18next handles interpolation here */}
Welcome back, <Link to={`/@${data.user!.username}`}>@{{ username }}</Link>!
</Trans>
<br />
{t("log-in.callback.redirect-hint")}
</p>
</>
);
}
return (
<RemixForm method="POST">
<Form as="div">
{actionData?.error && <RegisterError error={actionData.error} />}
<Form.Group className="mb-3" controlId="remote-username">
<Form.Label>{t("log-in.callback.remote-username.fediverse")}</Form.Label>
<Form.Control type="text" readOnly={true} value={data.remoteUser!} />
</Form.Group>
<Form.Group className="mb-3" controlId="username">
<Form.Label>{t("log-in.callback.username")}</Form.Label>
<Form.Control name="username" type="text" required />
</Form.Group>
<input type="hidden" name="ticket" value={data.ticket!} />
<Button variant="primary" type="submit">
{t("log-in.callback.sign-up-button")}
</Button>
</Form>
</RemixForm>
);
}

View file

@ -126,6 +126,9 @@ export default function LoginPage() {
{t("log-in.3rd-party.tumblr")} {t("log-in.3rd-party.tumblr")}
</ListGroup.Item> </ListGroup.Item>
)} )}
<ListGroup.Item action href="/auth/log-in/fediverse">
{t("log-in.3rd-party.fediverse")}
</ListGroup.Item>
</ListGroup> </ListGroup>
</div> </div>
{!urls.email_enabled && <div className="col-lg-3"></div>} {!urls.email_enabled && <div className="col-lg-3"></div>}

View file

@ -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<typeof loader> = ({ 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<typeof action>();
return (
<>
<h2>{t("log-in.fediverse.choose-form-title")}</h2>
{data?.error && <ErrorAlert error={data.error} />}
<RemixForm method="POST">
<Form as="div">
<Form.Group className="mb-3" controlId="instance">
<Form.Label>{t("log-in.fediverse-instance-label")}</Form.Label>
<Form.Control name="instance" type="text" />
</Form.Group>
<Button variant="primary" type="submit">
{t("log-in.fediverse-log-in-button")}
</Button>
</Form>
</RemixForm>
</>
);
}

View file

@ -47,22 +47,31 @@
}, },
"log-in": { "log-in": {
"callback": { "callback": {
"invalid-ticket": "Invalid ticket (it might have been too long since you logged in), please <2>try again</2>.",
"invalid-username": "Invalid username",
"username-taken": "That username is already taken, please try something else.",
"title": { "title": {
"discord-success": "Log in with Discord", "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": "Successfully logged in!",
"success-link": "Welcome back, <1>@{{username}}</1>!", "success-link": "Welcome back, <1>@{{username}}</1>!",
"redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.", "redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.",
"remote-username": { "remote-username": {
"discord": "Your discord username" "discord": "Your Discord username",
"fediverse": "Your Fediverse account"
}, },
"username": "Username", "username": "Username",
"sign-up-button": "Sign up", "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</2>.",
"invalid-username": "Invalid username",
"username-taken": "That username is already taken, please try something else."
}, },
"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", "title": "Log in",
"form-title": "Log in with email", "form-title": "Log in with email",
"email": "Email address", "email": "Email address",
@ -74,7 +83,8 @@
"desc": "If you prefer, you can also log in with one of these services:", "desc": "If you prefer, you can also log in with one of these services:",
"discord": "Log in with Discord", "discord": "Log in with Discord",
"google": "Log in with Google", "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." "invalid-credentials": "Invalid email address or password, please check your spelling and try again."
}, },

View file

@ -1,3 +1,18 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation"> <wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeStyle/FileHeader/FileHeaderText/@EntryValue">Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see &lt;https://www.gnu.org/licenses/&gt;.</s:String>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EFeature_002EServices_002ECodeCleanup_002EFileHeader_002EFileHeaderSettingsMigrate/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=foxnouns/@EntryIndexedValue">True</s:Boolean> <s:Boolean x:Key="/Default/UserDictionary/Words/=foxnouns/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=pronounscc/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary> <s:Boolean x:Key="/Default/UserDictionary/Words/=pronounscc/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>