[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.
This commit is contained in:
Amir Zarrinkafsh 2020-12-29 13:30:00 +11:00 committed by GitHub
parent 2763aefe81
commit b12528a65c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 71 additions and 41 deletions

View File

@ -2,6 +2,7 @@ package suites
import ( import (
"context" "context"
"strings"
"testing" "testing"
"time" "time"
@ -16,8 +17,10 @@ func (wds *WebDriverSession) doRegisterTOTP(ctx context.Context, t *testing.T) s
wds.verifyMailNotificationDisplayed(ctx, t) wds.verifyMailNotificationDisplayed(ctx, t)
link := doGetLinkFromLastMail(t) link := doGetLinkFromLastMail(t)
wds.doVisit(t, link) 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) assert.NoError(t, err)
secret := secretURL[strings.LastIndex(secretURL, "=")+1:]
assert.NotEqual(t, "", secret) assert.NotEqual(t, "", secret)
assert.NotNil(t, secret) assert.NotNil(t, secret)

View File

@ -1,7 +1,7 @@
import React, { useEffect, useCallback, useState } from "react"; import React, { useEffect, useCallback, useState } from "react";
import LoginLayout from "../../layouts/LoginLayout"; import LoginLayout from "../../layouts/LoginLayout";
import classnames from "classnames"; 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 QRCode from 'qrcode.react';
import AppStoreBadges from "../../components/AppStoreBadges"; import AppStoreBadges from "../../components/AppStoreBadges";
import { GoogleAuthenticator } from "../../constants"; import { GoogleAuthenticator } from "../../constants";
@ -9,7 +9,7 @@ import { useHistory, useLocation } from "react-router";
import { completeTOTPRegistrationProcess } from "../../services/RegisterDevice"; import { completeTOTPRegistrationProcess } from "../../services/RegisterDevice";
import { useNotifications } from "../../hooks/NotificationsContext"; import { useNotifications } from "../../hooks/NotificationsContext";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 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 { red } from "@material-ui/core/colors";
import { extractIdentityToken } from "../../utils/IdentityToken"; import { extractIdentityToken } from "../../utils/IdentityToken";
import { FirstFactorRoute } from "../../Routes"; import { FirstFactorRoute } from "../../Routes";
@ -22,7 +22,7 @@ const RegisterOneTimePassword = function () {
// The secret retrieved from the API is all is ok. // The secret retrieved from the API is all is ok.
const [secretURL, setSecretURL] = useState("empty"); const [secretURL, setSecretURL] = useState("empty");
const [secretBase32, setSecretBase32] = useState(undefined as string | undefined); const [secretBase32, setSecretBase32] = useState(undefined as string | undefined);
const { createErrorNotification } = useNotifications(); const { createSuccessNotification, createErrorNotification } = useNotifications();
const [hasErrored, setHasErrored] = useState(false); const [hasErrored, setHasErrored] = useState(false);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -53,44 +53,67 @@ const RegisterOneTimePassword = function () {
}, [processToken, createErrorNotification]); }, [processToken, createErrorNotification]);
useEffect(() => { completeRegistrationProcess() }, [completeRegistrationProcess]); useEffect(() => { completeRegistrationProcess() }, [completeRegistrationProcess]);
function SecretButton(text: string | undefined, action: string, icon: IconDefinition) {
return (
<IconButton
className={style.secretButtons}
color="primary"
onClick={() => {
navigator.clipboard.writeText(`${text}`);
createSuccessNotification(`${action}`);
}}
>
<FontAwesomeIcon icon={icon} />
</IconButton>
)
}
const qrcodeFuzzyStyle = (isLoading || hasErrored) ? style.fuzzy : undefined const qrcodeFuzzyStyle = (isLoading || hasErrored) ? style.fuzzy : undefined
return ( return (
<LoginLayout title="Scan QRCode"> <LoginLayout title="Scan QRCode">
<div className={style.root}> <div className={style.root}>
<div className={style.googleAuthenticator}> <div className={style.googleAuthenticator}>
<Typography className={style.googleAuthenticatorText}>Need Google Authenticator?</Typography> <Typography className={style.googleAuthenticatorText}>Need Google Authenticator?</Typography>
<AppStoreBadges <AppStoreBadges
iconSize={128} iconSize={128}
targetBlank targetBlank
className={style.googleAuthenticatorBadges} className={style.googleAuthenticatorBadges}
googlePlayLink={GoogleAuthenticator.googlePlay} googlePlayLink={GoogleAuthenticator.googlePlay}
appleStoreLink={GoogleAuthenticator.appleStore} /> appleStoreLink={GoogleAuthenticator.appleStore} />
</div> </div>
<div className={style.qrcodeContainer}> <div className={style.qrcodeContainer}>
<Link href={secretURL}> <Link href={secretURL}>
<QRCode <QRCode
value={secretURL} value={secretURL}
className={classnames(qrcodeFuzzyStyle, style.qrcode)} className={classnames(qrcodeFuzzyStyle, style.qrcode)}
size={256} /> size={256} />
{!hasErrored && isLoading ? <CircularProgress className={style.loader} size={128} /> : null} {!hasErrored && isLoading ? <CircularProgress className={style.loader} size={128} /> : null}
{hasErrored ? <FontAwesomeIcon className={style.failureIcon} icon={faTimesCircle} /> : null} {hasErrored ? <FontAwesomeIcon className={style.failureIcon} icon={faTimesCircle} /> : null}
</Link> </Link>
</div> </div>
{secretBase32 <div>
? <input id="base32-secret" {secretURL !== "empty"
style={{ display: "none" }} ? <TextField
value={secretBase32} /> : null} id="secret-url"
<Button label="Secret"
variant="contained" className={style.secret}
color="primary" value={secretURL}
className={style.doneButton} InputProps={{
onClick={handleDoneClick} readOnly: true
disabled={isLoading}> }} /> : null}
Done {secretBase32 ? SecretButton(secretBase32, "OTP Secret copied to clipboard.", faKey) : null}
</Button> {secretURL !== "empty" ? SecretButton(secretURL, "OTP URL copied to clipboard.", faCopy) : null}
</div> </div>
</LoginLayout> <Button
variant="contained"
color="primary"
className={style.doneButton}
onClick={handleDoneClick}
disabled={isLoading}>
Done
</Button>
</div>
</LoginLayout>
) )
} }
@ -109,14 +132,18 @@ const useStyles = makeStyles(theme => ({
filter: "blur(10px)" filter: "blur(10px)"
}, },
secret: { secret: {
display: "inline-block", marginTop: theme.spacing(1),
fontSize: theme.typography.fontSize * 0.9, marginBottom: theme.spacing(1),
width: "256px",
}, },
googleAuthenticator: {}, googleAuthenticator: {},
googleAuthenticatorText: { googleAuthenticatorText: {
fontSize: theme.typography.fontSize * 0.8, fontSize: theme.typography.fontSize * 0.8,
}, },
googleAuthenticatorBadges: {}, googleAuthenticatorBadges: {},
secretButtons: {
width: "128px",
},
doneButton: { doneButton: {
width: "256px", width: "256px",
}, },