From 07e7e1eb8762c65ae6b6dfadd5108a94e51992b6 Mon Sep 17 00:00:00 2001
From: Dessalines <dessalines@users.noreply.github.com>
Date: Thu, 2 Mar 2023 18:30:38 -0500
Subject: [PATCH] Adding 2FA support. Fixes #938 (#939)

* Adding 2FA support. Fixes #938

* Updating totp_2fa names.
---
 package.json                              |  2 +-
 src/shared/components/home/login.tsx      | 45 +++++++++++++-
 src/shared/components/person/settings.tsx | 71 +++++++++++++++++++++++
 src/shared/utils.ts                       |  1 +
 yarn.lock                                 |  8 +--
 5 files changed, 119 insertions(+), 8 deletions(-)

diff --git a/package.json b/package.json
index 839a3d8..26f10e9 100644
--- a/package.json
+++ b/package.json
@@ -45,7 +45,7 @@
     "inferno-server": "^8.0.6",
     "isomorphic-cookie": "^1.2.4",
     "jwt-decode": "^3.1.2",
-    "lemmy-js-client": "0.17.2-rc.1",
+    "lemmy-js-client": "0.17.2-rc.3",
     "markdown-it": "^13.0.1",
     "markdown-it-container": "^3.0.0",
     "markdown-it-footnote": "^3.0.3",
diff --git a/src/shared/components/home/login.tsx b/src/shared/components/home/login.tsx
index 7cdde92..dea6484 100644
--- a/src/shared/components/home/login.tsx
+++ b/src/shared/components/home/login.tsx
@@ -26,8 +26,10 @@ interface State {
   form: {
     username_or_email?: string;
     password?: string;
+    totp_2fa_token?: string;
   };
   loginLoading: boolean;
+  showTotp: boolean;
   siteRes: GetSiteResponse;
 }
 
@@ -38,6 +40,7 @@ export class Login extends Component<any, State> {
   state: State = {
     form: {},
     loginLoading: false,
+    showTotp: false,
     siteRes: this.isoData.site_res,
   };
 
@@ -141,6 +144,28 @@ export class Login extends Component<any, State> {
               </button>
             </div>
           </div>
+          {this.state.showTotp && (
+            <div className="form-group row">
+              <label
+                className="col-sm-6 col-form-label"
+                htmlFor="login-totp-token"
+              >
+                {i18n.t("two_factor_token")}
+              </label>
+              <div className="col-sm-6">
+                <input
+                  type="number"
+                  inputMode="numeric"
+                  className="form-control"
+                  id="login-totp-token"
+                  pattern="[0-9]*"
+                  autoComplete="one-time-code"
+                  value={this.state.form.totp_2fa_token}
+                  onInput={linkEvent(this, this.handleLoginTotpChange)}
+                />
+              </div>
+            </div>
+          )}
           <div className="form-group row">
             <div className="col-sm-10">
               <button type="submit" className="btn btn-secondary">
@@ -159,10 +184,12 @@ export class Login extends Component<any, State> {
     let lForm = i.state.form;
     let username_or_email = lForm.username_or_email;
     let password = lForm.password;
+    let totp_2fa_token = lForm.totp_2fa_token;
     if (username_or_email && password) {
       let form: LoginI = {
         username_or_email,
         password,
+        totp_2fa_token,
       };
       WebSocketService.Instance.send(wsClient.login(form));
     }
@@ -173,6 +200,11 @@ export class Login extends Component<any, State> {
     i.setState(i.state);
   }
 
+  handleLoginTotpChange(i: Login, event: any) {
+    i.state.form.totp_2fa_token = event.target.value;
+    i.setState(i.state);
+  }
+
   handleLoginPasswordChange(i: Login, event: any) {
     i.state.form.password = event.target.value;
     i.setState(i.state);
@@ -191,9 +223,16 @@ export class Login extends Component<any, State> {
     let op = wsUserOp(msg);
     console.log(msg);
     if (msg.error) {
-      toast(i18n.t(msg.error), "danger");
-      this.setState({ form: {} });
-      return;
+      // If the error comes back that the token is missing, show the TOTP field
+      if (msg.error == "missing_totp_token") {
+        this.setState({ showTotp: true, loginLoading: false });
+        toast(i18n.t("enter_two_factor_code"));
+        return;
+      } else {
+        toast(i18n.t(msg.error), "danger");
+        this.setState({ form: {}, loginLoading: false });
+        return;
+      }
     } else {
       if (op == UserOperation.Login) {
         let data = wsJsonToRes<LoginResponse>(msg);
diff --git a/src/shared/components/person/settings.tsx b/src/shared/components/person/settings.tsx
index 734ae71..a349d1c 100644
--- a/src/shared/components/person/settings.tsx
+++ b/src/shared/components/person/settings.tsx
@@ -86,6 +86,7 @@ interface SettingsState {
     show_read_posts?: boolean;
     show_new_post_notifs?: boolean;
     discussion_languages?: number[];
+    generate_totp_2fa?: boolean;
   };
   changePasswordForm: {
     new_password?: string;
@@ -789,6 +790,7 @@ export class Settings extends Component<any, SettingsState> {
               </label>
             </div>
           </div>
+          {this.totpSection()}
           <div className="form-group">
             <button type="submit" className="btn btn-block btn-secondary mr-4">
               {this.state.saveUserSettingsLoading ? (
@@ -853,6 +855,58 @@ export class Settings extends Component<any, SettingsState> {
     );
   }
 
+  totpSection() {
+    let totpUrl =
+      UserService.Instance.myUserInfo?.local_user_view.local_user.totp_2fa_url;
+
+    return (
+      <>
+        {!totpUrl && (
+          <div className="form-group">
+            <div className="form-check">
+              <input
+                className="form-check-input"
+                id="user-generate-totp"
+                type="checkbox"
+                checked={this.state.saveUserSettingsForm.generate_totp_2fa}
+                onChange={linkEvent(this, this.handleGenerateTotp)}
+              />
+              <label className="form-check-label" htmlFor="user-generate-totp">
+                {i18n.t("set_up_two_factor")}
+              </label>
+            </div>
+          </div>
+        )}
+
+        {totpUrl && (
+          <>
+            <div>
+              <a className="btn btn-secondary mb-2" href={totpUrl}>
+                {i18n.t("two_factor_link")}
+              </a>
+            </div>
+            <div className="form-group">
+              <div className="form-check">
+                <input
+                  className="form-check-input"
+                  id="user-remove-totp"
+                  type="checkbox"
+                  checked={
+                    this.state.saveUserSettingsForm.generate_totp_2fa == false
+                  }
+                  onChange={linkEvent(this, this.handleRemoveTotp)}
+                />
+                <label className="form-check-label" htmlFor="user-remove-totp">
+                  {i18n.t("remove_two_factor")}
+                </label>
+              </div>
+            </div>
+          </>
+        )}
+      </>
+    );
+  }
+
   setupBlockPersonChoices() {
     if (isBrowser()) {
       let selectId: any = document.getElementById("block-person-filter");
@@ -1017,6 +1071,23 @@ export class Settings extends Component<any, SettingsState> {
     i.setState(i.state);
   }
 
+  handleGenerateTotp(i: Settings, event: any) {
+    // Coerce false to undefined here, so it won't generate it.
+    let checked: boolean | undefined = event.target.checked || undefined;
+    if (checked) {
+      toast(i18n.t("two_factor_setup_instructions"));
+    }
+    i.state.saveUserSettingsForm.generate_totp_2fa = checked;
+    i.setState(i.state);
+  }
+
+  handleRemoveTotp(i: Settings, event: any) {
+    // Coerce true to undefined here, so it won't generate it.
+    let checked: boolean | undefined = !event.target.checked && undefined;
+    i.state.saveUserSettingsForm.generate_totp_2fa = checked;
+    i.setState(i.state);
+  }
+
   handleSendNotificationsToEmailChange(i: Settings, event: any) {
     i.state.saveUserSettingsForm.send_notifications_to_email =
       event.target.checked;
diff --git a/src/shared/utils.ts b/src/shared/utils.ts
index 09da9db..bfbd538 100644
--- a/src/shared/utils.ts
+++ b/src/shared/utils.ts
@@ -500,6 +500,7 @@ export function toast(text: string, background = "success") {
       backgroundColor: backgroundColor,
       gravity: "bottom",
       position: "left",
+      duration: 5000,
     }).showToast();
   }
 }
diff --git a/yarn.lock b/yarn.lock
index b9a50db..cf26c24 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5403,10 +5403,10 @@ leac@^0.6.0:
   resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912"
   integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==
 
-lemmy-js-client@0.17.2-rc.1:
-  version "0.17.2-rc.1"
-  resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.17.2-rc.1.tgz#fe8d1508311bbf245acc98c2c3e47e2165a95b14"
-  integrity sha512-YrOXuCofgkqp28krmPTQZAfUWL5zEDA0sRJ0abKcgf/I8YYkYkUkPS9TOORN5Lv3bc8RAAz4+2/zLHqYL/Tnow==
+lemmy-js-client@0.17.2-rc.3:
+  version "0.17.2-rc.3"
+  resolved "https://registry.yarnpkg.com/lemmy-js-client/-/lemmy-js-client-0.17.2-rc.3.tgz#dc2a33e9228aef260b03a6e1f55698a2f975f979"
+  integrity sha512-FlWEPMrW2Q/FbtihLOHq2YtcRuoX7700LweCnsm6R6dD6SzsnWy9nKJhn24fcjcR2o6tw0oZKgP0ccq9jPDgfQ==
   dependencies:
     node-fetch "2.6.6"
 
-- 
2.44.1