From b12528a65c5a497521179eedc59a109372fea517 Mon Sep 17 00:00:00 2001 From: Amir Zarrinkafsh Date: Tue, 29 Dec 2020 13:30:00 +1100 Subject: [PATCH] [FEATURE] Display TOTP secret on device registration (#1551) * This change provides the TOTP secret which allows users to copy and utilise for password managers and other applications. * Hide TextField if secret isn't present * This ensure that the TextField is removed on a page or if there is no secret present. * Add multiple buttons and set default value to OTP URL * Remove inline icon and add icons under text field which allow copying of the secret key and the whole OTP URL. * Fix integration tests * Add notifications on click for secret buttons * Also remove autoFocus on TextField so a user can identify that the full OTP URL is in focus. --- internal/suites/action_totp.go | 5 +- .../RegisterOneTimePassword.tsx | 107 +++++++++++------- 2 files changed, 71 insertions(+), 41 deletions(-) diff --git a/internal/suites/action_totp.go b/internal/suites/action_totp.go index d2e1c1b8..2461e6a3 100644 --- a/internal/suites/action_totp.go +++ b/internal/suites/action_totp.go @@ -2,6 +2,7 @@ package suites import ( "context" + "strings" "testing" "time" @@ -16,8 +17,10 @@ func (wds *WebDriverSession) doRegisterTOTP(ctx context.Context, t *testing.T) s wds.verifyMailNotificationDisplayed(ctx, t) link := doGetLinkFromLastMail(t) wds.doVisit(t, link) - secret, err := wds.WaitElementLocatedByID(ctx, t, "base32-secret").GetAttribute("value") + secretURL, err := wds.WaitElementLocatedByID(ctx, t, "secret-url").GetAttribute("value") assert.NoError(t, err) + + secret := secretURL[strings.LastIndex(secretURL, "=")+1:] assert.NotEqual(t, "", secret) assert.NotNil(t, secret) diff --git a/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx b/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx index 6a3ea06f..93219dbc 100644 --- a/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx +++ b/web/src/views/DeviceRegistration/RegisterOneTimePassword.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useCallback, useState } from "react"; import LoginLayout from "../../layouts/LoginLayout"; import classnames from "classnames"; -import { makeStyles, Typography, Button, Link, CircularProgress } from "@material-ui/core"; +import { makeStyles, Typography, Button, IconButton, Link, CircularProgress, TextField } from "@material-ui/core"; import QRCode from 'qrcode.react'; import AppStoreBadges from "../../components/AppStoreBadges"; import { GoogleAuthenticator } from "../../constants"; @@ -9,7 +9,7 @@ import { useHistory, useLocation } from "react-router"; import { completeTOTPRegistrationProcess } from "../../services/RegisterDevice"; import { useNotifications } from "../../hooks/NotificationsContext"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faTimesCircle } from "@fortawesome/free-solid-svg-icons"; +import { IconDefinition, faCopy, faKey, faTimesCircle } from "@fortawesome/free-solid-svg-icons"; import { red } from "@material-ui/core/colors"; import { extractIdentityToken } from "../../utils/IdentityToken"; import { FirstFactorRoute } from "../../Routes"; @@ -22,7 +22,7 @@ const RegisterOneTimePassword = function () { // The secret retrieved from the API is all is ok. const [secretURL, setSecretURL] = useState("empty"); const [secretBase32, setSecretBase32] = useState(undefined as string | undefined); - const { createErrorNotification } = useNotifications(); + const { createSuccessNotification, createErrorNotification } = useNotifications(); const [hasErrored, setHasErrored] = useState(false); const [isLoading, setIsLoading] = useState(false); @@ -53,44 +53,67 @@ const RegisterOneTimePassword = function () { }, [processToken, createErrorNotification]); useEffect(() => { completeRegistrationProcess() }, [completeRegistrationProcess]); + function SecretButton(text: string | undefined, action: string, icon: IconDefinition) { + return ( + { + navigator.clipboard.writeText(`${text}`); + createSuccessNotification(`${action}`); + }} + > + + + ) + } const qrcodeFuzzyStyle = (isLoading || hasErrored) ? style.fuzzy : undefined return ( - -
-
- Need Google Authenticator? - -
-
- - - {!hasErrored && isLoading ? : null} - {hasErrored ? : null} - -
- {secretBase32 - ? : null} - -
-
+ +
+
+ Need Google Authenticator? + +
+
+ + + {!hasErrored && isLoading ? : null} + {hasErrored ? : null} + +
+
+ {secretURL !== "empty" + ? : null} + {secretBase32 ? SecretButton(secretBase32, "OTP Secret copied to clipboard.", faKey) : null} + {secretURL !== "empty" ? SecretButton(secretURL, "OTP URL copied to clipboard.", faCopy) : null} +
+ +
+
) } @@ -109,14 +132,18 @@ const useStyles = makeStyles(theme => ({ filter: "blur(10px)" }, secret: { - display: "inline-block", - fontSize: theme.typography.fontSize * 0.9, + marginTop: theme.spacing(1), + marginBottom: theme.spacing(1), + width: "256px", }, googleAuthenticator: {}, googleAuthenticatorText: { fontSize: theme.typography.fontSize * 0.8, }, googleAuthenticatorBadges: {}, + secretButtons: { + width: "128px", + }, doneButton: { width: "256px", },