Compare commits

...

2 commits

Author SHA1 Message Date
sam
de733a0682
feat(frontend): discord registration/login/linking
also moves the registration form found on the mastodon callback page
into a component so we're not repeating the same code for every auth method
2024-11-28 21:37:30 +01:00
sam
4780be3019
fix(backend): add unique index to auth methods 2024-11-28 21:29:25 +01:00
22 changed files with 618 additions and 212 deletions

View file

@ -5,6 +5,7 @@ using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
@ -56,8 +57,9 @@ public class AuthController(
public record AddOauthAccountResponse(
Snowflake Id,
AuthType Type,
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
string RemoteId,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
string? RemoteUsername
);

View file

@ -27,7 +27,8 @@ public class MetaController : ApiControllerBase
new Limits(
MemberCount: MembersController.MaxMemberCount,
BioLength: ValidationUtils.MaxBioLength,
CustomPreferences: ValidationUtils.MaxCustomPreferences
CustomPreferences: ValidationUtils.MaxCustomPreferences,
MaxAuthMethods: AuthUtils.MaxAuthMethodsPerType
)
)
);
@ -49,5 +50,10 @@ public class MetaController : ApiControllerBase
private record UserInfo(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay);
// All limits that the frontend should know about (for UI purposes)
private record Limits(int MemberCount, int BioLength, int CustomPreferences);
private record Limits(
int MemberCount,
int BioLength,
int CustomPreferences,
int MaxAuthMethods
);
}

View file

@ -71,6 +71,22 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique();
modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique();
modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique();
modelBuilder
.Entity<AuthMethod>()
.HasIndex(m => new
{
m.AuthType,
m.RemoteId,
m.FediverseApplicationId,
})
.HasFilter("fediverse_application_id IS NOT NULL")
.IsUnique();
modelBuilder
.Entity<AuthMethod>()
.HasIndex(m => new { m.AuthType, m.RemoteId })
.HasFilter("fediverse_application_id IS NULL")
.IsUnique();
modelBuilder.Entity<User>().Property(u => u.Sid).HasDefaultValueSql("find_free_user_sid()");
modelBuilder.Entity<User>().Property(u => u.Fields).HasColumnType("jsonb");

View file

@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241128202508_AddAuthMethodUniqueIndex")]
public partial class AddAuthMethodUniqueIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "ix_auth_methods_auth_type_remote_id",
table: "auth_methods",
columns: new[] { "auth_type", "remote_id" },
unique: true,
filter: "fediverse_application_id IS NULL"
);
migrationBuilder.CreateIndex(
name: "ix_auth_methods_auth_type_remote_id_fediverse_application_id",
table: "auth_methods",
columns: new[] { "auth_type", "remote_id", "fediverse_application_id" },
unique: true,
filter: "fediverse_application_id IS NOT NULL"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_auth_methods_auth_type_remote_id",
table: "auth_methods"
);
migrationBuilder.DropIndex(
name: "ix_auth_methods_auth_type_remote_id_fediverse_application_id",
table: "auth_methods"
);
}
}
}

View file

@ -98,6 +98,16 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasIndex("UserId")
.HasDatabaseName("ix_auth_methods_user_id");
b.HasIndex("AuthType", "RemoteId")
.IsUnique()
.HasDatabaseName("ix_auth_methods_auth_type_remote_id")
.HasFilter("fediverse_application_id IS NULL");
b.HasIndex("AuthType", "RemoteId", "FediverseApplicationId")
.IsUnique()
.HasDatabaseName("ix_auth_methods_auth_type_remote_id_fediverse_application_id")
.HasFilter("fediverse_application_id IS NOT NULL");
b.ToTable("auth_methods", (string)null);
});

View file

@ -223,6 +223,15 @@ public class AuthService(
{
AssertValidAuthType(authType, null);
// This is already checked when
var currentCount = await db
.AuthMethods.Where(m => m.UserId == userId && m.AuthType == authType)
.CountAsync(ct);
if (currentCount >= AuthUtils.MaxAuthMethodsPerType)
throw new ApiError.BadRequest(
"Too many linked accounts of this type, maximum of 3 per account."
);
var authMethod = new AuthMethod
{
Id = snowflakeGenerator.GenerateSnowflake(),

View file

@ -0,0 +1,35 @@
import { apiRequest } from "$api";
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
import type { AuthResponse } from "$api/models/auth";
import { setToken } from "$lib";
import log from "$lib/log";
import { isRedirect, redirect, type RequestEvent } from "@sveltejs/kit";
export default function createRegisterAction(callbackUrl: string) {
return async function ({ request, fetch, cookies }: RequestEvent) {
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 {
error: { message: "Bad request", code: ErrorCode.BadRequest, status: 403 } as RawApiError,
};
try {
const resp = await apiRequest<AuthResponse>("POST", callbackUrl, {
body: { username, ticket },
isInternal: true,
fetch,
});
setToken(cookies, resp.token);
redirect(303, "/auth/welcome");
} catch (e) {
if (isRedirect(e)) throw e;
log.error("Could not sign up user with username %s:", username, e);
if (e instanceof ApiError) return { error: e.obj };
throw e;
}
};
}

View file

@ -1,4 +1,4 @@
import type { User } from "./user";
import type { AuthType, User } from "./user";
export type AuthResponse = {
user: User;
@ -21,3 +21,10 @@ export type AuthUrls = {
google?: string;
tumblr?: string;
};
export type AddAccountResponse = {
id: string;
type: AuthType;
remote_id: string;
remote_username?: string;
};

View file

@ -16,4 +16,5 @@ export type Limits = {
member_count: number;
bio_length: number;
custom_preferences: number;
max_auth_methods: number;
};

View file

@ -71,9 +71,11 @@ export type PrideFlag = {
description: string | null;
};
export type AuthType = "DISCORD" | "GOOGLE" | "TUMBLR" | "FEDIVERSE" | "EMAIL";
export type AuthMethod = {
id: string;
type: "DISCORD" | "GOOGLE" | "TUMBLR" | "FEDIVERSE" | "EMAIL";
type: AuthType;
remote_id: string;
remote_username?: string;
};

View file

@ -0,0 +1,24 @@
<script lang="ts">
import type { AuthMethod } from "$api/models";
import AuthMethodRow from "./AuthMethodRow.svelte";
type Props = {
methods: AuthMethod[];
canRemove: boolean;
max: number;
buttonLink: string;
buttonText: string;
};
let { methods, canRemove, max, buttonLink, buttonText }: Props = $props();
</script>
{#if methods.length > 0}
<div class="list-group mb-3">
{#each methods as method (method.id)}
<AuthMethodRow {method} {canRemove} />
{/each}
</div>
{/if}
{#if methods.length < max}
<a class="btn btn-primary mb-3" href={buttonLink}>{buttonText}</a>
{/if}

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { t } from "$lib/i18n";
import type { AuthMethod } from "$api/models";
type Props = { method: AuthMethod; canRemove: boolean };
let { method, canRemove }: Props = $props();
let name = $derived(
method.type === "EMAIL" ? method.remote_id : (method.remote_username ?? method.remote_id),
);
let showId = $derived(method.type !== "FEDIVERSE");
</script>
<div class="list-group-item">
<div class="row">
<div class="col">
{name}
{#if showId}({method.remote_id}){/if}
</div>
{#if canRemove}
<div class="col text-end">
<a href="/settings/auth/remove-method/{method.id}">{$t("settings.auth-remove-method")}</a>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import type { AuthMethod, PartialUser } from "$api/models";
import { t } from "$lib/i18n";
type Props = { method: AuthMethod; user: PartialUser };
let { method, user }: Props = $props();
let name = $derived(
method.type === "EMAIL" ? method.remote_id : (method.remote_username ?? method.remote_id),
);
let text = $derived.by(() => {
switch (method.type) {
case "DISCORD":
return $t("auth.successful-link-discord");
case "GOOGLE":
return $t("auth.successful-link-google");
case "TUMBLR":
return $t("auth.successful-link-tumblr");
case "FEDIVERSE":
return $t("auth.successful-link-fedi");
default:
return "<you shouldn't see this!>";
}
});
</script>
<h1>{$t("auth.new-auth-method-added")}</h1>
<p>{text} <code>{name}</code></p>
<p>{$t("auth.successful-link-profile-hint")}</p>
<p>
<a class="btn btn-primary" href="/@{user.username}">{$t("auth.successful-link-profile-link")}</a>
</p>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import type { RawApiError } from "$api/error";
import { enhance } from "$app/forms";
import ErrorAlert from "$components/ErrorAlert.svelte";
import { t } from "$lib/i18n";
import { Button, Input, Label } from "@sveltestrap/sveltestrap";
type Props = {
title: string;
remoteLabel: string;
remoteUser: string;
ticket: string;
error?: RawApiError;
};
let { title, remoteLabel, remoteUser, ticket, error }: Props = $props();
</script>
<h1>{title}</h1>
{#if error}
<ErrorAlert {error} />
{/if}
<form method="POST" use:enhance>
<div class="mb-3">
<Label>{remoteLabel}</Label>
<Input type="text" readonly value={remoteUser} />
</div>
<div class="mb-3">
<Label>{$t("auth.register-username-label")}</Label>
<Input type="text" name="username" required />
</div>
<input type="hidden" name="ticket" value={ticket} />
<Button color="primary" type="submit">{$t("auth.register-button")}</Button>
</form>

View file

@ -18,7 +18,8 @@
"title": {
"log-in": "Log in",
"welcome": "Welcome",
"settings": "Settings"
"settings": "Settings",
"an-error-occurred": "An error occurred"
},
"auth": {
"log-in-form-title": "Log in with email",
@ -37,7 +38,16 @@
"register-button": "Register account",
"register-with-mastodon": "Register with a Fediverse account",
"log-in-with-fediverse-error-blurb": "Is your instance returning an error?",
"log-in-with-fediverse-force-refresh-button": "Force a refresh on our end"
"log-in-with-fediverse-force-refresh-button": "Force a refresh on our end",
"register-with-discord": "Register with a Discord account",
"new-auth-method-added": "Successfully added authentication method!",
"successful-link-discord": "Your account has successfully been linked to the following Discord account:",
"successful-link-google": "Your account has successfully been linked to the following Google account:",
"successful-link-tumblr": "Your account has successfully been linked to the following Tumblr account:",
"successful-link-fedi": "Your account has successfully been linked to the following fediverse account:",
"successful-link-profile-hint": "You now can close this page, or go back to your profile:",
"successful-link-profile-link": "Go to your profile",
"remote-discord-account-label": "Your Discord account"
},
"error": {
"bad-request-header": "Something was wrong with your input",
@ -92,7 +102,8 @@
"avatar": "Avatar",
"username-update-success": "Successfully changed your username!",
"create-member-title": "Create a new member",
"create-member-name-label": "Member name"
"create-member-name-label": "Member name",
"auth-remove-method": "Remove"
},
"yes": "Yes",
"no": "No",

View file

@ -0,0 +1,64 @@
import { apiRequest } from "$api";
import ApiError, { ErrorCode } from "$api/error";
import type { AddAccountResponse, CallbackResponse } from "$api/models/auth";
import { setToken } from "$lib";
import createRegisterAction from "$lib/actions/register.js";
import log from "$lib/log.js";
import { isRedirect, redirect } from "@sveltejs/kit";
export const load = async ({ url, parent, fetch, cookies }) => {
const code = url.searchParams.get("code") as string | null;
const state = url.searchParams.get("state") as string | null;
if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
const { meUser } = await parent();
if (meUser) {
try {
const resp = await apiRequest<AddAccountResponse>(
"POST",
"/auth/discord/add-account/callback",
{
isInternal: true,
body: { code, state },
fetch,
cookies,
},
);
return { hasAccount: true, isLinkRequest: true, newAuthMethod: resp };
} catch (e) {
if (e instanceof ApiError) return { isLinkRequest: true, error: e.obj };
log.error("error linking new discord account to user %s:", meUser.id, e);
throw e;
}
}
try {
const resp = await apiRequest<CallbackResponse>("POST", "/auth/discord/callback", {
body: { code, state },
isInternal: true,
fetch,
});
if (resp.has_account) {
setToken(cookies, resp.token!);
redirect(303, `/@${resp.user!.username}`);
}
return {
hasAccount: false,
isLinkRequest: false,
ticket: resp.ticket!,
remoteUser: resp.remote_username!,
};
} catch (e) {
if (isRedirect(e)) throw e;
if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj };
log.error("error while requesting discord callback:", e);
throw e;
}
};
export const actions = {
default: createRegisterAction("/auth/discord/register"),
};

View file

@ -0,0 +1,31 @@
<script lang="ts">
import Error from "$components/Error.svelte";
import NewAuthMethod from "$components/settings/NewAuthMethod.svelte";
import OauthRegistrationForm from "$components/settings/OauthRegistrationForm.svelte";
import { t } from "$lib/i18n";
import type { ActionData, PageData } from "./$types";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
</script>
<svelte:head>
<title>{$t("auth.register-with-discord")} • pronouns.cc</title>
</svelte:head>
<div class="container">
{#if data.error}
<h1>{$t("auth.register-with-discord")}</h1>
<Error error={data.error} />
{:else if data.isLinkRequest}
<NewAuthMethod method={data.newAuthMethod!} user={data.meUser!} />
{:else}
<OauthRegistrationForm
title={$t("auth.register-with-discord")}
remoteLabel={$t("auth.remote-discord-account-label")}
remoteUser={data.remoteUser!}
ticket={data.ticket!}
error={form?.error}
/>
{/if}
</div>

View file

@ -1,9 +1,9 @@
import { apiRequest } from "$api";
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
import type { AuthResponse, CallbackResponse } from "$api/models/auth.js";
import ApiError, { ErrorCode } from "$api/error";
import type { CallbackResponse } from "$api/models/auth.js";
import { setToken } from "$lib";
import log from "$lib/log.js";
import { isRedirect, redirect } from "@sveltejs/kit";
import createRegisterAction from "$lib/actions/register.js";
import { redirect } from "@sveltejs/kit";
export const load = async ({ parent, params, url, fetch, cookies }) => {
const { meUser } = await parent();
@ -33,30 +33,5 @@ export const load = async ({ parent, params, url, fetch, cookies }) => {
};
export const actions = {
default: async ({ request, fetch, cookies }) => {
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 {
error: { message: "Bad request", code: ErrorCode.BadRequest, status: 403 } as RawApiError,
};
try {
const resp = await apiRequest<AuthResponse>("POST", "/auth/fediverse/register", {
body: { username, ticket },
isInternal: true,
fetch,
});
setToken(cookies, resp.token);
redirect(303, "/auth/welcome");
} catch (e) {
if (isRedirect(e)) throw e;
log.error("Could not sign up user with username %s:", username, e);
if (e instanceof ApiError) return { error: e.obj };
throw e;
}
},
default: createRegisterAction("/auth/fediverse/register"),
};

View file

@ -1,9 +1,7 @@
<script lang="ts">
import { Button, Input, Label } from "@sveltestrap/sveltestrap";
import type { ActionData, PageData } from "./$types";
import { t } from "$lib/i18n";
import { enhance } from "$app/forms";
import ErrorAlert from "$components/ErrorAlert.svelte";
import OauthRegistrationForm from "$components/settings/OauthRegistrationForm.svelte";
type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props();
@ -14,22 +12,11 @@
</svelte:head>
<div class="container">
<h1>{$t("auth.register-with-mastodon")}</h1>
{#if form?.error}
<ErrorAlert error={form?.error} />
{/if}
<form method="POST" use:enhance>
<div class="mb-3">
<Label>{$t("auth.remote-fediverse-account-label")}</Label>
<Input type="text" readonly value={data.remoteUser} />
</div>
<div class="mb-3">
<Label>{$t("auth.register-username-label")}</Label>
<Input type="text" name="username" required />
</div>
<input type="hidden" name="ticket" value={data.ticket} />
<Button color="primary" type="submit">{$t("auth.register-button")}</Button>
</form>
<OauthRegistrationForm
title={$t("auth.register-with-mastodon")}
remoteLabel={$t("auth.remote-fediverse-account-label")}
remoteUser={data.remoteUser}
ticket={data.ticket}
error={form?.error}
/>
</div>

View file

@ -0,0 +1,7 @@
import { apiRequest } from "$api";
import type { AuthUrls } from "$api/models/auth";
export const load = async ({ fetch }) => {
const urls = await apiRequest<AuthUrls>("POST", "/auth/urls", { fetch, isInternal: true });
return { urls };
};

View file

@ -0,0 +1,65 @@
<script lang="ts">
import AuthMethodList from "$components/settings/AuthMethodList.svelte";
import AuthMethodRow from "$components/settings/AuthMethodRow.svelte";
import type { PageData } from "./$types";
type Props = { data: PageData };
let { data }: Props = $props();
let max = $derived(data.meta.limits.max_auth_methods);
let canRemove = $derived(data.user.auth_methods.length > 1);
let emails = $derived(data.user.auth_methods.filter((m) => m.type === "EMAIL"));
let discordAccounts = $derived(data.user.auth_methods.filter((m) => m.type === "DISCORD"));
let googleAccounts = $derived(data.user.auth_methods.filter((m) => m.type === "GOOGLE"));
let tumblrAccounts = $derived(data.user.auth_methods.filter((m) => m.type === "TUMBLR"));
let fediAccounts = $derived(data.user.auth_methods.filter((m) => m.type === "FEDIVERSE"));
</script>
{#if data.urls.email_enabled}
<h3>Email addresses</h3>
<AuthMethodList
methods={emails}
{canRemove}
{max}
buttonLink="/settings/auth/add-email"
buttonText="Add email address"
/>
{/if}
{#if data.urls.discord}
<h3>Discord accounts</h3>
<AuthMethodList
methods={discordAccounts}
{canRemove}
{max}
buttonLink="/settings/auth/add-discord"
buttonText="Link Discord account"
/>
{/if}
{#if data.urls.google}
<h3>Google accounts</h3>
<AuthMethodList
methods={googleAccounts}
{canRemove}
{max}
buttonLink="/settings/auth/add-google"
buttonText="Link Google account"
/>
{/if}
{#if data.urls.tumblr}
<h3>Tumblr accounts</h3>
<AuthMethodList
methods={tumblrAccounts}
{canRemove}
{max}
buttonLink="/settings/auth/add-tumblr"
buttonText="Link Tumblr account"
/>
{/if}
<h3>Fediverse accounts</h3>
<AuthMethodList
methods={fediAccounts}
{canRemove}
{max}
buttonLink="/settings/auth/add-fediverse"
buttonText="Link Fediverse account"
/>

View file

@ -0,0 +1,12 @@
import { apiRequest } from "$api";
import { redirect } from "@sveltejs/kit";
export const load = async ({ fetch, cookies }) => {
const { url } = await apiRequest<{ url: string }>("GET", "/auth/discord/add-account", {
isInternal: true,
fetch,
cookies,
});
redirect(303, url);
};