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/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 +-
11 files changed, 1209 insertions(+), 860 deletions(-)
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/views')
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.
+