From 365b5753419238bb96bc3f9b744d380ff20cbafc Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Mon, 7 Apr 2025 16:14:41 +0200
Subject: [feature] add TOTP two-factor authentication (2FA) (#3960)
* [feature] add TOTP two-factor authentication (2FA)
* use byteutil.S2B to avoid allocations when comparing + generating password hashes
* don't bother with string conversion for consts
* use io.ReadFull
* use MustGenerateSecret for backup codes
* rename util functions
---
web/source/settings/lib/query/gts-api.ts | 22 +-
web/source/settings/lib/query/user/index.ts | 3 +-
web/source/settings/lib/query/user/twofactor.ts | 82 +++++
web/source/settings/lib/types/user.ts | 1 +
web/source/settings/redux/store.ts | 3 +-
web/source/settings/views/user/account/email.tsx | 123 +++++++
web/source/settings/views/user/account/index.tsx | 75 +++++
.../settings/views/user/account/password.tsx | 103 ++++++
.../settings/views/user/account/twofactor.tsx | 308 +++++++++++++++++
web/source/settings/views/user/emailpassword.tsx | 264 ---------------
web/source/settings/views/user/menu.tsx | 10 +-
web/source/settings/views/user/migration.tsx | 213 ------------
web/source/settings/views/user/migration/index.tsx | 213 ++++++++++++
web/source/settings/views/user/profile.tsx | 371 --------------------
web/source/settings/views/user/profile/profile.tsx | 375 +++++++++++++++++++++
web/source/settings/views/user/router.tsx | 14 +-
16 files changed, 1310 insertions(+), 870 deletions(-)
create mode 100644 web/source/settings/lib/query/user/twofactor.ts
create mode 100644 web/source/settings/views/user/account/email.tsx
create mode 100644 web/source/settings/views/user/account/index.tsx
create mode 100644 web/source/settings/views/user/account/password.tsx
create mode 100644 web/source/settings/views/user/account/twofactor.tsx
delete mode 100644 web/source/settings/views/user/emailpassword.tsx
delete mode 100644 web/source/settings/views/user/migration.tsx
create mode 100644 web/source/settings/views/user/migration/index.tsx
delete mode 100644 web/source/settings/views/user/profile.tsx
create mode 100644 web/source/settings/views/user/profile/profile.tsx
(limited to 'web/source/settings')
diff --git a/web/source/settings/lib/query/gts-api.ts b/web/source/settings/lib/query/gts-api.ts
index 540191132..9d38e435d 100644
--- a/web/source/settings/lib/query/gts-api.ts
+++ b/web/source/settings/lib/query/gts-api.ts
@@ -143,15 +143,20 @@ const gtsBaseQuery: BaseQueryFn<
return headers;
},
responseHandler: (response) => {
- // Return just text if caller has
- // set a custom accept content-type.
- if (accept !== "application/json") {
- return response.text();
+ switch (true) {
+ case (accept === "application/json"):
+ // return good old
+ // fashioned JSON baby!
+ return response.json();
+ case (accept.startsWith("image/")):
+ // It's an image,
+ // return the blob.
+ return response.blob();
+ default:
+ // God knows what it
+ // is, just return text.
+ return response.text();
}
-
- // Else return good old
- // fashioned JSON baby!
- return response.json();
},
})(args, api, extraOptions);
};
@@ -174,6 +179,7 @@ export const gtsApi = createApi({
"DomainPermissionExclude",
"DomainPermissionSubscription",
"TokenInfo",
+ "User",
],
endpoints: (build) => ({
instanceV1: build.query({
diff --git a/web/source/settings/lib/query/user/index.ts b/web/source/settings/lib/query/user/index.ts
index 80aeea2a4..7b0914cd8 100644
--- a/web/source/settings/lib/query/user/index.ts
+++ b/web/source/settings/lib/query/user/index.ts
@@ -58,7 +58,8 @@ const extended = gtsApi.injectEndpoints({
}),
user: build.query({
- query: () => ({url: `/api/v1/user`})
+ query: () => ({url: `/api/v1/user`}),
+ providesTags: ["User"],
}),
passwordChange: build.mutation({
diff --git a/web/source/settings/lib/query/user/twofactor.ts b/web/source/settings/lib/query/user/twofactor.ts
new file mode 100644
index 000000000..ea9d9981b
--- /dev/null
+++ b/web/source/settings/lib/query/user/twofactor.ts
@@ -0,0 +1,82 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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 .
+*/
+
+import { gtsApi } from "../gts-api";
+import { FetchBaseQueryError } from "@reduxjs/toolkit/query";
+
+const extended = gtsApi.injectEndpoints({
+ endpoints: (build) => ({
+ twoFactorQRCodeURI: build.mutation({
+ query: () => ({
+ url: `/api/v1/user/2fa/qruri`,
+ acceptContentType: "text/plain",
+ })
+ }),
+
+ twoFactorQRCodePng: build.mutation({
+ async queryFn(_arg, _api, _extraOpts, fetchWithBQ) {
+ const blobRes = await fetchWithBQ({
+ url: `/api/v1/user/2fa/qr.png`,
+ acceptContentType: "image/png",
+ });
+ if (blobRes.error) {
+ return { error: blobRes.error as FetchBaseQueryError };
+ }
+
+ if (blobRes.meta?.response?.status !== 200) {
+ return { error: blobRes.data };
+ }
+
+ const blob = blobRes.data as Blob;
+ const url = URL.createObjectURL(blob);
+
+ return { data: url };
+ },
+ }),
+
+ twoFactorEnable: build.mutation({
+ query: (formData) => ({
+ method: "POST",
+ url: `/api/v1/user/2fa/enable`,
+ asForm: true,
+ body: formData,
+ discardEmpty: true
+ })
+ }),
+
+ twoFactorDisable: build.mutation({
+ query: (formData) => ({
+ method: "POST",
+ url: `/api/v1/user/2fa/disable`,
+ asForm: true,
+ body: formData,
+ discardEmpty: true,
+ acceptContentType: "*/*",
+ }),
+ invalidatesTags: ["User"]
+ }),
+ })
+});
+
+export const {
+ useTwoFactorQRCodeURIMutation,
+ useTwoFactorQRCodePngMutation,
+ useTwoFactorEnableMutation,
+ useTwoFactorDisableMutation,
+} = extended;
diff --git a/web/source/settings/lib/types/user.ts b/web/source/settings/lib/types/user.ts
index 92210d5d3..34f7a8430 100644
--- a/web/source/settings/lib/types/user.ts
+++ b/web/source/settings/lib/types/user.ts
@@ -31,4 +31,5 @@ export interface User {
disabled: boolean;
approved: boolean;
reset_password_sent_at?: string;
+ two_factor_enabled_at?: string;
}
diff --git a/web/source/settings/redux/store.ts b/web/source/settings/redux/store.ts
index 076f5f88d..e6826d7ad 100644
--- a/web/source/settings/redux/store.ts
+++ b/web/source/settings/redux/store.ts
@@ -71,7 +71,8 @@ export const store = configureStore({
PERSIST,
PURGE,
REGISTER,
- ]
+ ],
+ ignoredPaths: ['api.queries.twoFactorQRCodePng(undefined).data.data'],
}
}).concat(gtsApi.middleware);
}
diff --git a/web/source/settings/views/user/account/email.tsx b/web/source/settings/views/user/account/email.tsx
new file mode 100644
index 000000000..16cdebf66
--- /dev/null
+++ b/web/source/settings/views/user/account/email.tsx
@@ -0,0 +1,123 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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 .
+*/
+
+import React from "react";
+import { useTextInput } from "../../../lib/form";
+import useFormSubmit from "../../../lib/form/submit";
+import { TextInput } from "../../../components/form/inputs";
+import MutationButton from "../../../components/form/mutation-button";
+import { useEmailChangeMutation } from "../../../lib/query/user";
+import { User } from "../../../lib/types/user";
+
+export default function EmailChange({user, oidcEnabled}: { user: User, oidcEnabled?: boolean }) {
+ const form = {
+ currentEmail: useTextInput("current_email", {
+ defaultValue: user.email,
+ nosubmit: true
+ }),
+ newEmail: useTextInput("new_email", {
+ validator: (value: string | undefined) => {
+ if (!value) {
+ return "";
+ }
+
+ if (value.toLowerCase() === user.email?.toLowerCase()) {
+ return "cannot change to your existing address";
+ }
+
+ if (value.toLowerCase() === user.unconfirmed_email?.toLowerCase()) {
+ return "you already have a pending email address change to this address";
+ }
+
+ return "";
+ },
+ }),
+ password: useTextInput("password"),
+ };
+ const [submitForm, result] = useFormSubmit(form, useEmailChangeMutation());
+
+ return (
+
+ );
+}
diff --git a/web/source/settings/views/user/account/index.tsx b/web/source/settings/views/user/account/index.tsx
new file mode 100644
index 000000000..707181f3d
--- /dev/null
+++ b/web/source/settings/views/user/account/index.tsx
@@ -0,0 +1,75 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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 .
+*/
+
+import React from "react";
+import EmailChange from "./email";
+import PasswordChange from "./password";
+import TwoFactor from "./twofactor";
+import { useInstanceV1Query } from "../../../lib/query/gts-api";
+import Loading from "../../../components/loading";
+import { useUserQuery } from "../../../lib/query/user";
+
+export default function Account() {
+ // Load instance data.
+ const {
+ data: instance,
+ isFetching: isFetchingInstance,
+ isLoading: isLoadingInstance
+ } = useInstanceV1Query();
+
+ // Load user data.
+ const {
+ data: user,
+ isFetching: isFetchingUser,
+ isLoading: isLoadingUser
+ } = useUserQuery();
+
+ if (
+ (isFetchingInstance || isLoadingInstance) ||
+ (isFetchingUser || isLoadingUser)
+ ) {
+ return ;
+ }
+
+ if (user === undefined) {
+ throw "could not fetch user";
+ }
+
+ if (instance === undefined) {
+ throw "could not fetch instance";
+ }
+
+ return (
+ <>
+
Account Settings
+
+
+
+ >
+ );
+}
+
diff --git a/web/source/settings/views/user/account/password.tsx b/web/source/settings/views/user/account/password.tsx
new file mode 100644
index 000000000..a2f0eeb3b
--- /dev/null
+++ b/web/source/settings/views/user/account/password.tsx
@@ -0,0 +1,103 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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 .
+*/
+
+import React from "react";
+import { useTextInput } from "../../../lib/form";
+import useFormSubmit from "../../../lib/form/submit";
+import { TextInput } from "../../../components/form/inputs";
+import MutationButton from "../../../components/form/mutation-button";
+import { usePasswordChangeMutation } from "../../../lib/query/user";
+
+export default function PasswordChange({ oidcEnabled }: { oidcEnabled?: boolean }) {
+ const form = {
+ oldPassword: useTextInput("old_password"),
+ newPassword: useTextInput("new_password", {
+ validator(val) {
+ if (val != "" && val == form.oldPassword.value) {
+ return "New password same as old password";
+ }
+ return "";
+ }
+ })
+ };
+
+ const verifyNewPassword = useTextInput("verifyNewPassword", {
+ validator(val) {
+ if (val != "" && val != form.newPassword.value) {
+ return "Passwords do not match";
+ }
+ return "";
+ }
+ });
+
+ const [submitForm, result] = useFormSubmit(form, usePasswordChangeMutation());
+
+ return (
+
+ );
+}
diff --git a/web/source/settings/views/user/account/twofactor.tsx b/web/source/settings/views/user/account/twofactor.tsx
new file mode 100644
index 000000000..217de6c04
--- /dev/null
+++ b/web/source/settings/views/user/account/twofactor.tsx
@@ -0,0 +1,308 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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 .
+*/
+
+import React, { ReactNode, useEffect, useMemo, useState } from "react";
+import { TextInput } from "../../../components/form/inputs";
+import MutationButton from "../../../components/form/mutation-button";
+import useFormSubmit from "../../../lib/form/submit";
+import {
+ useTwoFactorQRCodeURIMutation,
+ useTwoFactorDisableMutation,
+ useTwoFactorEnableMutation,
+ useTwoFactorQRCodePngMutation,
+} from "../../../lib/query/user/twofactor";
+import { useTextInput } from "../../../lib/form";
+import Loading from "../../../components/loading";
+import { Error } from "../../../components/error";
+import { HighlightedCode } from "../../../components/highlightedcode";
+import { useDispatch } from "react-redux";
+import { gtsApi } from "../../../lib/query/gts-api";
+
+interface TwoFactorProps {
+ twoFactorEnabledAt?: string,
+ oidcEnabled?: boolean,
+}
+
+export default function TwoFactor({ twoFactorEnabledAt, oidcEnabled }: TwoFactorProps) {
+ switch (true) {
+ case oidcEnabled:
+ // Can't enable if OIDC is in place.
+ return ;
+ case twoFactorEnabledAt !== undefined:
+ // Already enabled. Show the disable form.
+ return ;
+ default:
+ // Not enabled. Show the enable form.
+ return ;
+ }
+}
+
+function CannotEnable() {
+ return (
+
+ }
+ />
+
+ );
+}
+
+function EnableForm() {
+ const form = { code: useTextInput("code") };
+ const [ recoveryCodes, setRecoveryCodes ] = useState();
+ const dispatch = useDispatch();
+
+ // Prepare trigger to submit the code and enable 2FA.
+ // If the enable call is a success, set the recovery
+ // codes state to a nice newline-separated text.
+ const [submitForm, result] = useFormSubmit(form, useTwoFactorEnableMutation(), {
+ changedOnly: true,
+ onFinish: (res) => {
+ const codes = res.data as string[];
+ if (!codes) {
+ return;
+ }
+ setRecoveryCodes(codes.join("\n"));
+ },
+ });
+
+ // When the component is unmounted, clear the user
+ // cache if 2FA was just enabled. This will prevent
+ // the recovery codes from being shown again.
+ useEffect(() => {
+ return () => {
+ if (recoveryCodes) {
+ dispatch(gtsApi.util.invalidateTags(["User"]));
+ }
+ };
+ }, [recoveryCodes, dispatch]);
+
+ return (
+
+ );
+}
+
+// Load and show QR code png only when
+// the "Show QR Code" button is clicked.
+function CodePng() {
+ const [
+ getPng, {
+ isUninitialized,
+ isLoading,
+ isSuccess,
+ data,
+ error,
+ reset,
+ }
+ ] = useTwoFactorQRCodePngMutation();
+
+ const [ content, setContent ] = useState();
+ useEffect(() => {
+ if (isLoading) {
+ setContent();
+ } else if (isSuccess && data) {
+ setContent();
+ } else {
+ setContent();
+ }
+ }, [isLoading, isSuccess, data, error]);
+
+ return (
+ <>
+ { isUninitialized
+ ?
+ :
+ }
+ { content }
+ >
+ );
+}
+
+// Get 2fa secret from server and
+// load it into clipboard on click.
+function Secret() {
+ const [
+ getURI,
+ {
+ isUninitialized,
+ isSuccess,
+ data,
+ error,
+ reset,
+ },
+ ] = useTwoFactorQRCodeURIMutation();
+
+ const [ buttonContents, setButtonContents ] = useState();
+ useEffect(() => {
+ if (isUninitialized) {
+ setButtonContents("Copy 2FA secret to clipboard");
+ } else if (isSuccess && data) {
+ const url = new URL(data);
+ const secret = url.searchParams.get("secret");
+ if (!secret) {
+ throw "null secret";
+ }
+ navigator.clipboard.writeText(secret);
+ setButtonContents("Copied!");
+ setTimeout(() => { reset(); }, 3000);
+ } else {
+ setButtonContents();
+ }
+ }, [isUninitialized, isSuccess, data, reset, error]);
+
+ return (
+
+ );
+}
+
+function DisableForm({ twoFactorEnabledAt }: { twoFactorEnabledAt: string }) {
+ const enabledAt = useMemo(() => {
+ const enabledAt = new Date(twoFactorEnabledAt);
+ return ;
+ }, [twoFactorEnabledAt]);
+
+ const form = {
+ password: useTextInput("password"),
+ };
+
+ const [submitForm, result] = useFormSubmit(form, useTwoFactorDisableMutation());
+ return (
+
+ );
+}
+
+function TwoFactorHeader({ blurb }: { blurb: ReactNode }) {
+ return (
+
+ );
+}
diff --git a/web/source/settings/views/user/emailpassword.tsx b/web/source/settings/views/user/emailpassword.tsx
deleted file mode 100644
index 32df0e39d..000000000
--- a/web/source/settings/views/user/emailpassword.tsx
+++ /dev/null
@@ -1,264 +0,0 @@
-/*
- GoToSocial
- Copyright (C) GoToSocial Authors admin@gotosocial.org
- SPDX-License-Identifier: AGPL-3.0-or-later
-
- 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 .
-*/
-
-import React from "react";
-import { useTextInput } from "../../lib/form";
-import useFormSubmit from "../../lib/form/submit";
-import { TextInput } from "../../components/form/inputs";
-import MutationButton from "../../components/form/mutation-button";
-import { useEmailChangeMutation, usePasswordChangeMutation, useUserQuery } from "../../lib/query/user";
-import Loading from "../../components/loading";
-import { User } from "../../lib/types/user";
-import { useInstanceV1Query } from "../../lib/query/gts-api";
-
-export default function EmailPassword() {
- return (
- <>
-
- );
-}
-
-function AlsoKnownAsURI({ index, data }) {
- const name = `${index}`;
- const form = useWithFormContext(index, {
- alsoKnownAsURI: useTextInput(
- name,
- // Only one field per entry.
- { defaultValue: data[0] ?? "" },
- ),
- });
-
- return (
-
- );
-}
-
-function MoveForm({ data: profile }) {
- let urlStr = store.getState().login.instanceUrl ?? "";
- let url = new URL(urlStr);
-
- const form = {
- movedToURI: useTextInput("moved_to_uri", {
- source: profile,
- valueSelector: (p) => p.moved?.url },
- ),
- password: useTextInput("password"),
- };
-
- const [submitForm, result] = useFormSubmit(form, useMoveAccountMutation(), {
- changedOnly: false,
- });
-
- return (
-
- );
-}
diff --git a/web/source/settings/views/user/migration/index.tsx b/web/source/settings/views/user/migration/index.tsx
new file mode 100644
index 000000000..d2bbbdf12
--- /dev/null
+++ b/web/source/settings/views/user/migration/index.tsx
@@ -0,0 +1,213 @@
+/*
+ GoToSocial
+ Copyright (C) GoToSocial Authors admin@gotosocial.org
+ SPDX-License-Identifier: AGPL-3.0-or-later
+
+ 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 .
+*/
+
+import React from "react";
+
+import FormWithData from "../../../lib/form/form-with-data";
+
+import { useVerifyCredentialsQuery } from "../../../lib/query/login";
+import { useArrayInput, useTextInput } from "../../../lib/form";
+import { TextInput } from "../../../components/form/inputs";
+import useFormSubmit from "../../../lib/form/submit";
+import MutationButton from "../../../components/form/mutation-button";
+import { useAliasAccountMutation, useMoveAccountMutation } from "../../../lib/query/user";
+import { FormContext, useWithFormContext } from "../../../lib/form/context";
+import { store } from "../../../redux/store";
+
+export default function Migration() {
+ return (
+
+ );
+}
+
+function MigrationForm({ data: profile }) {
+ return (
+ <>
+
Account Migration Settings
+
+ The following settings allow you to alias your account to
+ another account elsewhere, or to move to another account.
+
+
+ Account aliasing is harmless and reversible; you can
+ set and unset up to five account aliases as many times as you wish.
+
+
+ The account move action, on the other
+ hand, has serious and irreversible consequences.
+
+
+ For more information on account migration, please see the documentation.
+