Merge pull request #100 from ente-io/3fa

3fa
This commit is contained in:
Abhinav-grd 2021-07-01 15:11:07 +05:30 committed by GitHub
commit 6f5aa9de0e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 812 additions and 81 deletions

View file

@ -42,6 +42,7 @@
"react-burger-menu": "^3.0.4",
"react-dom": "16.13.1",
"react-dropzone": "^11.2.4",
"react-otp-input": "^2.3.1",
"react-select": "^4.3.1",
"react-top-loading-bar": "^2.0.1",
"react-virtualized-auto-sizer": "^1.0.2",

View file

@ -103,7 +103,8 @@ function CollectionShare(props: Props) {
attributes={{ title: constants.SHARE_COLLECTION }}
>
<DeadCenter style={{ width: '85%', margin: 'auto' }}>
<p>{constants.SHARE_WITH_PEOPLE}</p>
<h6>{constants.SHARE_WITH_PEOPLE}</h6>
<p />
<Formik<formValues>
initialValues={{ email: '' }}
validationSchema={Yup.object().shape({

View file

@ -0,0 +1,19 @@
import { FlashMessage } from 'pages/_app';
import React from 'react';
import Alert from 'react-bootstrap/Alert';
export default function FlashMessageBar({ flashMessage, onClose }: { flashMessage: FlashMessage, onClose: () => void }) {
return (
<Alert
className="flash-message text-center"
variant={flashMessage.severity}
dismissible
onClose={onClose}
>
<div style={{ maxWidth: '1024px', width: '80%', margin: 'auto' }}>
{flashMessage.message}
</div>
</Alert>
);
}

View file

@ -52,7 +52,7 @@ export default function MessageDialog({
</Modal.Header>
{(children || attributes?.content) && (
<Modal.Body style={{ borderTop: '1px solid #444' }}>
{children || <h5>{attributes.content}</h5>}
{children || <p style={{ fontSize: '1.25rem', marginBottom: 0 }}>{attributes.content}</p>}
</Modal.Body>
)}
{(attributes.close || attributes.proceed) && (
@ -67,8 +67,7 @@ export default function MessageDialog({
<Button
variant={`outline-${attributes.close?.variant ?? 'secondary'}`}
onClick={() => {
attributes.close?.action && attributes.close?.action();
props.onHide();
attributes.close?.action ? attributes.close?.action() : props.onHide();
}}
style={{
padding: '6px 3em',

View file

@ -472,11 +472,11 @@ const PhotoFrame = ({
{!isFirstLoad && files.length === 0 && !searchMode ? (
<EmptyScreen>
<img height={150} src='/images/gallery.png' />
<br/>
<Button
variant="outline-success"
onClick={openFileUploader}
style={{
marginTop: '32px',
paddingLeft: '32px',
paddingRight: '32px',
paddingTop: '12px',

View file

@ -4,7 +4,26 @@ import { getRecoveryKey } from 'utils/crypto';
import constants from 'utils/strings/constants';
import MessageDialog from './MessageDialog';
import EnteSpinner from './EnteSpinner';
import styled from 'styled-components';
export const CodeBlock = styled.div<{ height: number }>`
display: flex;
align-items: center;
justify-content: center;
background: #1a1919;
height: ${(props) => props.height}px;
padding-left:30px;
padding-right:20px;
color: white;
margin: 20px 0;
width:100%;
`;
export const FreeFlowText = styled.div`
word-wrap: break-word;
overflow-wrap: break-word;
min-width: 30%;
`;
interface Props {
show: boolean;
onHide: () => void;
@ -55,32 +74,15 @@ function RecoveryKeyModal({ somethingWentWrong, ...props }: Props) {
}}
>
<p>{constants.RECOVERY_KEY_DESCRIPTION}</p>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#1a1919',
height: '150px',
padding: '40px',
color: 'white',
margin: '20px 0',
}}
>
<CodeBlock height={150}>
{recoveryKey ? (
<div
style={{
wordWrap: 'break-word',
overflowWrap: 'break-word',
minWidth: '30%',
}}
>
<FreeFlowText>
{recoveryKey}
</div>
</FreeFlowText>
) : (
<EnteSpinner />
)}
</div>
</CodeBlock>
<p>{constants.KEY_NOT_STORED_DISCLAIMER}</p>
</MessageDialog >
);

View file

@ -28,11 +28,14 @@ import { LogoImage } from 'pages/_app';
import { SetDialogMessage } from './MessageDialog';
import EnteSpinner from './EnteSpinner';
import RecoveryKeyModal from './RecoveryKeyModal';
import TwoFactorModal from './TwoFactorModal';
import { SetLoading } from 'pages/gallery';
interface Props {
files: File[];
collections: Collection[];
setDialogMessage: SetDialogMessage;
setLoading: SetLoading,
showPlanSelectorModal: () => void;
}
export default function Sidebar(props: Props) {
@ -45,6 +48,7 @@ export default function Sidebar(props: Props) {
}, []);
const [isOpen, setIsOpen] = useState(false);
const [recoverModalView, setRecoveryModalView] = useState(false);
const [twoFactorModalView, setTwoFactorModalView] = useState(false);
useEffect(() => {
const main = async () => {
if (!isOpen) {
@ -214,11 +218,26 @@ export default function Sidebar(props: Props) {
{constants.DOWNLOAD_RECOVERY_KEY}
</LinkButton>
</>
<>
<TwoFactorModal
show={twoFactorModalView}
onHide={() => setTwoFactorModalView(false)}
setDialogMessage={props.setDialogMessage}
closeSidebar={() => setIsOpen(false)}
setLoading={props.setLoading}
/>
<LinkButton
style={{ marginTop: '30px' }}
onClick={() => setTwoFactorModalView(true)}
>
{constants.TWO_FACTOR}
</LinkButton>
</>
<LinkButton
style={{ marginTop: '30px' }}
onClick={() => {
setData(LS_KEYS.SHOW_BACK_BUTTON, { value: true });
router.push('changePassword');
router.push('change-password');
}}
>
{constants.CHANGE_PASSWORD}

View file

@ -22,7 +22,7 @@ const SubmitButton = ({
<Spinner
as="span"
animation="border"
style={{ width: '22px', height: '22px', borderWidth: '0.20em' }}
style={{ width: '22px', height: '22px', borderWidth: '0.20em', color: '#2dc262' }}
/>
) : (
buttonText

View file

@ -0,0 +1,123 @@
import { useRouter } from 'next/router';
import { DeadCenter, SetLoading } from 'pages/gallery';
import { AppContext } from 'pages/_app';
import React, { useContext, useEffect, useState } from 'react';
import { Button } from 'react-bootstrap';
import { disableTwoFactor, getTwoFactorStatus } from 'services/userService';
import styled from 'styled-components';
import { getData, LS_KEYS, setData } from 'utils/storage/localStorage';
import constants from 'utils/strings/constants';
import MessageDialog, { SetDialogMessage } from './MessageDialog';
interface Props {
show: boolean;
onHide: () => void;
setDialogMessage: SetDialogMessage;
setLoading: SetLoading
closeSidebar: () => void;
}
const Row = styled.div`
display:flex;
align-items:center;
margin-bottom:20px;
flex:1
`;
const Label = styled.div`
width:70%;
`;
function TwoFactorModal(props: Props) {
const router = useRouter();
const [isTwoFactorEnabled, setTwoFactorStatus] = useState(false);
const appContext = useContext(AppContext);
useEffect(() => {
if (!props.show) {
return;
}
const isTwoFactorEnabled = getData(LS_KEYS.USER).isTwoFactorEnabled ?? false;
setTwoFactorStatus(isTwoFactorEnabled);
const main = async () => {
const isTwoFactorEnabled = await getTwoFactorStatus();
setTwoFactorStatus(isTwoFactorEnabled);
setData(LS_KEYS.USER, { ...getData(LS_KEYS.USER), isTwoFactorEnabled: false });
};
main();
}, [props.show]);
const warnTwoFactorDisable = async () => {
props.setDialogMessage({
title: constants.DISABLE_TWO_FACTOR,
staticBackdrop: true,
content: constants.DISABLE_TWO_FACTOR_MESSAGE,
close: { text: constants.CANCEL },
proceed: {
variant: 'danger',
text: constants.DISABLE,
action: twoFactorDisable,
},
});
};
const twoFactorDisable = async () => {
try {
await disableTwoFactor();
setData(LS_KEYS.USER, { ...getData(LS_KEYS.USER), isTwoFactorEnabled: false });
props.onHide();
props.closeSidebar();
appContext.setDisappearingFlashMessage({ message: constants.TWO_FACTOR_DISABLE_SUCCESS, severity: 'info' });
} catch (e) {
appContext.setDisappearingFlashMessage({ message: constants.TWO_FACTOR_DISABLE_FAILED, severity: 'danger' });
}
};
const warnTwoFactorReconfigure = async () => {
props.setDialogMessage({
title: constants.UPDATE_TWO_FACTOR,
staticBackdrop: true,
content: constants.UPDATE_TWO_FACTOR_MESSAGE,
close: { text: constants.CANCEL },
proceed: {
variant: 'success',
text: constants.UPDATE,
action: reconfigureTwoFactor,
},
});
};
const reconfigureTwoFactor = async () => {
router.push('/two-factor/setup');
};
return (
<MessageDialog
show={props.show}
onHide={props.onHide}
attributes={{
title: constants.TWO_FACTOR_AUTHENTICATION,
staticBackdrop: true,
}}
>
<div {...(!isTwoFactorEnabled ? { style: { padding: '10px 10px 30px 10px' } } : { style: { padding: '10px' } })}>
{
isTwoFactorEnabled ?
<>
<Row>
<Label>{constants.UPDATE_TWO_FACTOR_HINT}</Label> <Button variant={'outline-success'} style={{ width: '30%' }} onClick={warnTwoFactorReconfigure}>{constants.RECONFIGURE}</Button>
</Row>
<Row>
<Label>{constants.DISABLE_TWO_FACTOR_HINT} </Label><Button variant={'outline-danger'} style={{ width: '30%' }} onClick={warnTwoFactorDisable}>{constants.DISABLE}</Button>
</Row>
</> : (
<DeadCenter>
<svg xmlns="http://www.w3.org/2000/svg" height="36px" viewBox="0 0 24 24" width="36px" fill="#000000"><g fill="none"><path d="M0 0h24v24H0V0z" /><path d="M0 0h24v24H0V0z" opacity=".87" /></g><path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zM9 6c0-1.66 1.34-3 3-3s3 1.34 3 3v2H9V6zm9 14H6V10h12v10zm-6-3c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2z" /></svg>
<p />
<p>{constants.TWO_FACTOR_INFO}</p>
<div style={{ height: '10px' }} />
<Button variant="outline-success" onClick={() => router.push('/two-factor/setup')}>{constants.ENABLE_TWO_FACTOR}</Button>
</DeadCenter>
)
}
</div>
</MessageDialog >
);
}
export default TwoFactorModal;

View file

@ -0,0 +1,96 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { Formik, FormikHelpers } from 'formik';
import { DeadCenter } from 'pages/gallery';
import React, { useRef, useState } from 'react';
import { Form } from 'react-bootstrap';
import OtpInput from 'react-otp-input';
import constants from 'utils/strings/constants';
import SubmitButton from './SubmitButton';
interface formValues {
otp: string;
}
interface Props {
onSubmit: any
back: any
buttonText: string;
}
export default function VerifyTwoFactor(props: Props) {
const [waiting, setWaiting] = useState(false);
const otpInputRef = useRef(null);
const submitForm = async (
{ otp }: formValues,
{ setFieldError, resetForm }: FormikHelpers<formValues>,
) => {
try {
setWaiting(true);
await props.onSubmit(otp);
} catch (e) {
resetForm();
for (let i = 0; i < 6; i++) {
otpInputRef.current?.focusPrevInput();
}
setFieldError('otp', `${constants.UNKNOWN_ERROR} ${e.message}`);
}
setWaiting(false);
};
const onChange = (otp: string, callback: Function, triggerSubmit: Function) => {
callback(otp);
if (otp.length === 6) {
triggerSubmit(otp);
}
};
return (
<>
<p style={{ marginBottom: '30px' }}>enter the 6-digit code from your authenticator app.</p>
<Formik<formValues>
initialValues={{ otp: '' }}
validateOnChange={false}
validateOnBlur={false}
onSubmit={submitForm}
>
{({
values,
errors,
handleChange,
handleSubmit,
submitForm,
}) => (
<Form noValidate onSubmit={handleSubmit} style={{ width: '100%' }}>
<Form.Group style={{ marginBottom: '32px' }} controlId="formBasicEmail">
<DeadCenter>
<OtpInput
placeholder="123456"
ref={otpInputRef}
shouldAutoFocus
value={values.otp}
onChange={(otp) => {
onChange(otp, handleChange('otp'), submitForm);
}}
numInputs={6}
separator={'-'}
isInputNum
className={'otp-input'}
/>
{errors.otp &&
<div style={{ display: 'block', marginTop: '16px' }} className="invalid-feedback">{constants.INCORRECT_CODE}</div>
}
</DeadCenter>
</Form.Group>
<SubmitButton
buttonText={props.buttonText}
loading={waiting}
disabled={values.otp.length < 6}
/>
</Form>
)}
</Formik>
</>
);
}

View file

@ -13,6 +13,7 @@ import { Workbox } from 'workbox-window';
import { getEndpoint } from 'utils/common/apiUtil';
import { getData, LS_KEYS } from 'utils/storage/localStorage';
import HTTPService from 'services/HTTPService';
import FlashMessageBar from 'components/FlashMessageBar';
const GlobalStyles = createGlobalStyle`
/* ubuntu-regular - latin */
@ -46,6 +47,9 @@ const GlobalStyles = createGlobalStyle`
color: #aaa;
font-family:Ubuntu, Arial, sans-serif !important;
}
:is(h1, h2, h3, h4, h5, h6) {
color: #d7d7d7;
}
#__next {
flex: 1;
@ -161,11 +165,14 @@ const GlobalStyles = createGlobalStyle`
background-size: 20px 20px;
background-position: center;
}
.btn.focus , .btn:focus{
box-shadow: none;
}
.btn-success {
background: #2dc262;
border-color: #29a354;
}
.btn-success:hover ,.btn-success:focus .btn-success:active{
.btn-success:hover .btn-success:focus .btn-success:active {
background-color: #29a354;
border-color: #2dc262;
}
@ -177,12 +184,19 @@ const GlobalStyles = createGlobalStyle`
border-color: #2dc262;
border-width: 2px;
}
.btn-outline-success:hover {
.btn-outline-success:hover:enabled {
background: #2dc262;
color: white;
}
.btn-outline-danger, .btn-outline-secondary, .btn-outline-primary{
border-width: 2px;
}
.btn-link-danger {
color: #dc3545;
}
.btn-link-danger:hover {
color: #ff495a;
}
.card {
background-color: #242424;
color: #d1d1d1;
@ -194,11 +208,11 @@ const GlobalStyles = createGlobalStyle`
text-align: center;
margin-top: 50px;
}
.alert-success {
.alert-primary {
background-color: rgb(235, 255, 243);
color: #000000;
}
.alert-primary {
.alert-success {
background-color: #c4ffd6;
}
.bm-burger-button {
@ -314,6 +328,28 @@ const GlobalStyles = createGlobalStyle`
.carousel-indicators .active {
background-color: #2dc262;
}
div.otp-input input {
width: 36px !important;
height: 36px;
margin: 0 10px;
}
div.otp-input input::placeholder {
opacity:0;
}
div.otp-input input:not(:placeholder-shown) , div.otp-input input:focus{
border: 2px solid #2dc262;
border-radius:1px;
-webkit-transition: 0.5s;
transition: 0.5s;
outline: none;
}
.flash-message{
padding:16px;
display:flex;
align-items:center;
}
`;
export const LogoImage = styled.img`
@ -344,8 +380,13 @@ type AppContextType = {
showNavBar: (show: boolean) => void;
sharedFiles: File[];
resetSharedFiles: () => void;
setDisappearingFlashMessage: (message: FlashMessage) => void;
}
export interface FlashMessage {
message: string;
severity: string
}
export const AppContext = createContext<AppContextType>(null);
const redirectMap = {
@ -361,6 +402,7 @@ export default function App({ Component, err }) {
const [showNavbar, setShowNavBar] = useState(false);
const [sharedFiles, setSharedFiles] = useState<File[]>(null);
const [redirectName, setRedirectName] = useState<string>(null);
const [flashMessage, setFlashMessage] = useState<FlashMessage>(null);
useEffect(() => {
if (
@ -438,9 +480,11 @@ export default function App({ Component, err }) {
window.removeEventListener('offline', setUserOffline);
};
}, [redirectName]);
const showNavBar = (show: boolean) => setShowNavBar(show);
const setDisappearingFlashMessage = (flashMessages: FlashMessage) => {
setFlashMessage(flashMessages);
setTimeout(() => setFlashMessage(null), 5000);
};
return (
<>
<Head>
@ -470,10 +514,12 @@ export default function App({ Component, err }) {
(router.pathname === '/gallery' ?
<MessageContainer>{constants.FILES_TO_BE_UPLOADED(sharedFiles.length)}</MessageContainer> :
<MessageContainer>{constants.LOGIN_TO_UPLOAD_FILES(sharedFiles.length)}</MessageContainer>)}
{flashMessage && <FlashMessageBar flashMessage={flashMessage} onClose={() => setFlashMessage(null)} />}
<AppContext.Provider value={{
showNavBar,
sharedFiles,
resetSharedFiles,
setDisappearingFlashMessage,
}}>
{loading ? (
<Container>

View file

@ -67,7 +67,6 @@ export const DeadCenter = styled.div`
display: flex;
justify-content: center;
align-items: center;
color: #fff;
text-align: center;
flex-direction: column;
`;
@ -426,6 +425,7 @@ export default function Gallery() {
files={files}
collections={collections}
setDialogMessage={setDialogMessage}
setLoading={setLoading}
showPlanSelectorModal={() => setPlanModalView(true)}
/>
<UploadButton isFirstFetch={isFirstFetch} openFileUploader={openFileUploader} />

View file

@ -0,0 +1,113 @@
import React, { useEffect, useState } from 'react';
import constants from 'utils/strings/constants';
import { getData, LS_KEYS, setData } from 'utils/storage/localStorage';
import { useRouter } from 'next/router';
import CryptoWorker from 'utils/crypto';
import SingleInputForm from 'components/SingleInputForm';
import MessageDialog from 'components/MessageDialog';
import Container from 'components/Container';
import { Card, Button } from 'react-bootstrap';
import LogoImg from 'components/LogoImg';
import { logError } from 'utils/sentry';
import { B64EncryptionResult } from 'services/uploadService';
import { recoverTwoFactor, removeTwoFactor } from 'services/userService';
export default function Recover() {
const router = useRouter();
const [messageDialogView, SetMessageDialogView] = useState(false);
const [encryptedTwoFactorSecret, setEncryptedTwoFactorSecret] = useState<B64EncryptionResult>(null);
const [sessionID, setSessionID] = useState(null);
useEffect(() => {
router.prefetch('/gallery');
const user = getData(LS_KEYS.USER);
if (!user?.email) {
router.push('/');
}
setSessionID(user.twoFactorSessionID);
const main = async () => {
const resp = await recoverTwoFactor(user.twoFactorSessionID);
setEncryptedTwoFactorSecret({
encryptedData: resp.encryptedSecret,
nonce: resp.secretDecryptionNonce,
key: null,
});
};
main();
}, []);
const recover = async (recoveryKey: string, setFieldError) => {
try {
const cryptoWorker = await new CryptoWorker();
const twoFactorSecret: string = await cryptoWorker.decryptB64(
encryptedTwoFactorSecret.encryptedData,
encryptedTwoFactorSecret.nonce,
await cryptoWorker.fromHex(recoveryKey),
);
const resp = await removeTwoFactor(sessionID, twoFactorSecret);
const { keyAttributes, encryptedToken, token, id } = resp;
setData(LS_KEYS.USER, {
...getData(LS_KEYS.USER),
token,
encryptedToken,
id,
isTwoFactorEnabled: false,
});
setData(LS_KEYS.KEY_ATTRIBUTES, keyAttributes);
router.push('/credentials');
} catch (e) {
logError(e);
setFieldError('passphrase', constants.INCORRECT_RECOVERY_KEY);
}
};
return (
<>
<Container>
<Card
style={{ minWidth: '320px' }}
className="text-center"
>
<Card.Body style={{ padding: '40px 30px' }}>
<Card.Title style={{ marginBottom: '32px' }}>
<LogoImg src='/icon.svg' />
{constants.RECOVER_TWO_FACTOR}
</Card.Title>
<SingleInputForm
callback={recover}
fieldType="text"
placeholder={constants.RETURN_RECOVERY_KEY_HINT}
buttonText={constants.RECOVER}
/>
<div
style={{
display: 'flex',
flexDirection: 'column',
marginTop: '12px',
}}
>
<Button
variant="link"
onClick={() => SetMessageDialogView(true)}
>
{constants.NO_RECOVERY_KEY}
</Button>
<Button variant="link" onClick={router.back}>
{constants.GO_BACK}
</Button>
</div>
</Card.Body>
</Card>
</Container>
<MessageDialog
size="lg"
show={messageDialogView}
onHide={() => SetMessageDialogView(false)}
attributes={{
title: constants.CONTACT_SUPPORT,
close: {},
content: constants.NO_TWO_FACTOR_RECOVERY_KEY_MESSAGE(),
}}
/>
</>
);
}

View file

@ -0,0 +1,111 @@
import EnteSpinner from 'components/EnteSpinner';
import LogoImg from 'components/LogoImg';
import { CodeBlock, FreeFlowText } from 'components/RecoveryKeyModal';
import { DeadCenter } from 'pages/gallery';
import React, { useContext, useEffect, useState } from 'react';
import { Button, Card } from 'react-bootstrap';
import { enableTwoFactor, setupTwoFactor, TwoFactorSecret } from 'services/userService';
import styled from 'styled-components';
import constants from 'utils/strings/constants';
import Container from 'components/Container';
import { useRouter } from 'next/router';
import VerifyTwoFactor from 'components/VerifyTwoFactor';
import { B64EncryptionResult } from 'services/uploadService';
import { encryptWithRecoveryKey } from 'utils/crypto';
import { setData, LS_KEYS, getData } from 'utils/storage/localStorage';
import { AppContext } from 'pages/_app';
enum SetupMode {
QR_CODE,
MANUAL_CODE,
}
const QRCode = styled.img`
height:200px;
width:200px;
margin:1rem;
`;
export default function SetupTwoFactor() {
const [setupMode, setSetupMode] = useState<SetupMode>(SetupMode.QR_CODE);
const [twoFactorSecret, setTwoFactorSecret] = useState<TwoFactorSecret>(null);
const [recoveryEncryptedTwoFactorSecret, setRecoveryEncryptedTwoFactorSecret] = useState<B64EncryptionResult>(null);
const router = useRouter();
const appContext = useContext(AppContext);
useEffect(() => {
if (twoFactorSecret) {
return;
}
const main = async () => {
try {
const twoFactorSecret = await setupTwoFactor();
const recoveryEncryptedTwoFactorSecret = await encryptWithRecoveryKey(twoFactorSecret.secretCode);
setTwoFactorSecret(twoFactorSecret);
setRecoveryEncryptedTwoFactorSecret(recoveryEncryptedTwoFactorSecret);
} catch (e) {
appContext.setDisappearingFlashMessage({ message: constants.TWO_FACTOR_SETUP_FAILED, severity: 'danger' });
router.push('/gallery');
}
};
main();
}, []);
const onSubmit = async (otp: string) => {
await enableTwoFactor(otp, recoveryEncryptedTwoFactorSecret);
setData(LS_KEYS.USER, { ...getData(LS_KEYS.USER), isTwoFactorEnabled: true });
appContext.setDisappearingFlashMessage({ message: constants.TWO_FACTOR_SETUP_SUCCESS, severity: 'info' });
router.push('/gallery');
};
return (
<Container>
<Card style={{ minWidth: '300px' }} className="text-center">
<Card.Body style={{ padding: '40px 30px', minHeight: '400px' }}>
<DeadCenter>
<Card.Title style={{ marginBottom: '32px' }}>
<LogoImg src='/icon.svg' />
{constants.TWO_FACTOR}
</Card.Title>
{setupMode === SetupMode.QR_CODE ? (
<>
<p>{constants.TWO_FACTOR_QR_INSTRUCTION}</p>
<DeadCenter>
{!twoFactorSecret ? <div style={{ height: '200px', width: '200px', margin: '1rem', display: 'flex', justifyContent: 'center', alignItems: 'center', border: '1px solid #aaa' }}><EnteSpinner /></div> :
<QRCode src={`data:image/png;base64,${twoFactorSecret.qrCode}`} />
}
<Button block variant="link" onClick={() => setSetupMode(SetupMode.MANUAL_CODE)}>
{constants.ENTER_CODE_MANUALLY}
</Button>
</DeadCenter>
</>
) : (<>
<p>{constants.TWO_FACTOR_MANUAL_CODE_INSTRUCTION}</p>
<CodeBlock height={100}>
{!twoFactorSecret ? <EnteSpinner /> : (
<FreeFlowText>
{twoFactorSecret.secretCode}
</FreeFlowText>
)}
</CodeBlock>
<Button block variant="link" style={{ marginBottom: '1rem' }} onClick={() => setSetupMode(SetupMode.QR_CODE)}>
{constants.SCAN_QR_CODE}
</Button>
</>
)}
<div
style={{
height: '1px',
marginBottom: '20px',
width: '100%',
}}
/>
<VerifyTwoFactor onSubmit={onSubmit} back={router.back} buttonText={constants.ENABLE} />
<Button style={{ marginTop: '16px' }} variant="link-danger" onClick={router.back}>
{constants.GO_BACK}
</Button>
</DeadCenter>
</Card.Body>
</Card>
</Container >
);
}

View file

@ -0,0 +1,80 @@
import Container from 'components/Container';
import LogoImg from 'components/LogoImg';
import VerifyTwoFactor from 'components/VerifyTwoFactor';
import router from 'next/router';
import React, { useEffect, useState } from 'react';
import { Button, Card } from 'react-bootstrap';
import { logoutUser, verifyTwoFactor } from 'services/userService';
import { setData, LS_KEYS, getData } from 'utils/storage/localStorage';
import constants from 'utils/strings/constants';
export default function Home() {
const [email, setEmail] = useState('');
const [sessionID, setSessionID] = useState('');
useEffect(() => {
const main = async () => {
router.prefetch('/credentials');
const user = getData(LS_KEYS.USER);
if (!user?.email) {
router.push('/');
} else {
setEmail(user.email);
setSessionID(user.twoFactorSessionID);
}
};
main();
}, []);
const onSubmit = async (otp: string) => {
try {
const resp = await verifyTwoFactor(otp, sessionID);
const { keyAttributes, encryptedToken, token, id } = resp;
setData(LS_KEYS.USER, {
...getData(LS_KEYS.USER),
email,
token,
encryptedToken,
id,
});
setData(LS_KEYS.KEY_ATTRIBUTES, keyAttributes);
router.push('/credentials');
} catch (e) {
if (e.status === 404) {
logoutUser();
} else {
throw e;
}
}
};
return (
<Container>
<Card style={{ minWidth: '300px' }} className="text-center">
<Card.Body style={{ padding: '40px 30px', minHeight: '400px' }}>
<Card.Title style={{ marginBottom: '32px' }}>
<LogoImg src='/icon.svg' />
{constants.TWO_FACTOR}
</Card.Title>
<VerifyTwoFactor onSubmit={onSubmit} back={router.back} buttonText={constants.VERIFY} />
<div
style={{
display: 'flex',
flexDirection: 'column',
marginTop: '12px',
}}
>
<Button
variant="link"
onClick={() => router.push('/two-factor/recover')}
>
{constants.LOST_DEVICE}
</Button>
<Button variant="link" onClick={logoutUser}>
{constants.GO_BACK}
</Button>
</div>
</Card.Body>
</Card>
</Container>
);
}

View file

@ -13,7 +13,7 @@ import {
logoutUser,
clearFiles,
isTokenValid,
VerificationResponse,
EmailVerificationResponse,
} from 'services/userService';
import { setIsFirstLogin } from 'utils/storage';
import SubmitButton from 'components/SubmitButton';
@ -34,6 +34,7 @@ export default function Verify() {
useEffect(() => {
const main = async () => {
router.prefetch('/twoFactor/verify');
router.prefetch('/credentials');
router.prefetch('/generate');
const user = getData(LS_KEYS.USER);
@ -60,7 +61,12 @@ export default function Verify() {
try {
setLoading(true);
const resp = await verifyOtt(email, ott);
const { keyAttributes, encryptedToken, token, id } = resp.data as VerificationResponse;
const { keyAttributes, encryptedToken, token, id, twoFactorSessionID } = resp.data as EmailVerificationResponse;
if (twoFactorSessionID) {
setData(LS_KEYS.USER, { email, twoFactorSessionID, isTwoFactorEnabled: true });
router.push('/two-factor/verify');
return;
}
setData(LS_KEYS.USER, {
...getData(LS_KEYS.USER),
email,

View file

@ -6,6 +6,7 @@ import { clearData } from 'utils/storage/localStorage';
import localForage from 'utils/storage/localForage';
import { getToken } from 'utils/common/key';
import HTTPService from './HTTPService';
import { B64EncryptionResult } from './uploadService';
export interface UpdatedKey {
kekSalt: string;
@ -28,13 +29,31 @@ export interface User {
name: string;
email: string;
}
export interface VerificationResponse {
export interface EmailVerificationResponse {
id: number;
keyAttributes?: KeyAttributes;
encryptedToken?: string;
token?: string;
twoFactorSessionID: string
}
export interface TwoFactorVerificationResponse {
id: number;
keyAttributes: KeyAttributes;
encryptedToken?: string;
token?: string;
}
export interface TwoFactorSecret {
secretCode: string
qrCode: string
}
export interface TwoFactorRecoveryResponse {
encryptedSecret: string
secretDecryptionNonce: string
}
export const getOtt = (email: string) => HTTPService.get(`${ENDPOINT}/users/ott`, {
email,
client: 'web',
@ -98,3 +117,54 @@ export const isTokenValid = async () => {
return false;
}
};
export const setupTwoFactor = async () => {
const resp = await HTTPService.post(`${ENDPOINT}/users/two-factor/setup`, null, null, {
'X-Auth-Token': getToken(),
});
return resp.data as TwoFactorSecret;
};
export const enableTwoFactor = async (code: string, recoveryEncryptedTwoFactorSecret: B64EncryptionResult) => {
await HTTPService.post(`${ENDPOINT}/users/two-factor/enable`, {
code,
encryptedTwoFactorSecret: recoveryEncryptedTwoFactorSecret.encryptedData,
twoFactorSecretDecryptionNonce: recoveryEncryptedTwoFactorSecret.nonce,
}, null, {
'X-Auth-Token': getToken(),
});
};
export const verifyTwoFactor = async (code: string, sessionID: string) => {
const resp = await HTTPService.post(`${ENDPOINT}/users/two-factor/verify`, {
code, sessionID,
}, null);
return resp.data as TwoFactorVerificationResponse;
};
export const recoverTwoFactor = async (sessionID: string) => {
const resp = await HTTPService.get(`${ENDPOINT}/users/two-factor/recover`, {
sessionID,
});
return resp.data as TwoFactorRecoveryResponse;
};
export const removeTwoFactor = async (sessionID: string, secret: string) => {
const resp = await HTTPService.post(`${ENDPOINT}/users/two-factor/remove`, {
sessionID, secret,
});
return resp.data as TwoFactorVerificationResponse;
};
export const disableTwoFactor = async () => {
await HTTPService.post(`${ENDPOINT}/users/two-factor/disable`, null, null, {
'X-Auth-Token': getToken(),
});
};
export const getTwoFactorStatus = async () => {
const resp = await HTTPService.get(`${ENDPOINT}/users/two-factor/status`, null, {
'X-Auth-Token': getToken(),
});
return resp.data['status'];
};

View file

@ -167,4 +167,12 @@ export async function decryptAndStoreToken(masterKey: string) {
});
}
}
export async function encryptWithRecoveryKey(key: string) {
const cryptoWorker = await new CryptoWorker();
const hexRecoveryKey = await getRecoveryKey();
const recoveryKey = await cryptoWorker.fromHex(hexRecoveryKey);
const encryptedKey: B64EncryptionResult = await cryptoWorker.encryptToB64(key, recoveryKey);
return encryptedKey;
}
export default CryptoWorker;

View file

@ -167,6 +167,10 @@ const englishConstants = {
SORRY: 'sorry',
NO_RECOVERY_KEY_MESSAGE:
'due to the nature of our end-to-end encryption protocol, your data cannot be decrypted without your password or recovery key',
NO_TWO_FACTOR_RECOVERY_KEY_MESSAGE: () => (<>
please drop an email to <a href="mailto:support@ente.io">support@ente.io</a> from your registered email
</>),
CONTACT_SUPPORT: 'contact support',
REQUEST_FEATURE: 'request feature',
SUPPORT: 'support',
CONFIRM: 'confirm',
@ -324,7 +328,7 @@ const englishConstants = {
SHAREES: 'shared with',
ZERO_SHAREES: () => (
<>
<p>currently shared with no one 😔</p>
<h6>currently shared with no one 😔</h6>
<em style={{ color: '#777' }}>"memories are fonder when shared"</em>
</>
),
@ -397,6 +401,34 @@ const englishConstants = {
SHOW_ALL: 'show all',
LOGIN_TO_UPLOAD_FILES: (count: number) => count === 1 ? `1 file received. login to upload` : `${count} files received. login to upload`,
FILES_TO_BE_UPLOADED: (count: number) => count === 1 ? `1 file received. uploading in a jiffy` : `${count} files received. Uploading in a jiffy`,
TWO_FACTOR: 'two-factor',
TWO_FACTOR_AUTHENTICATION: 'two-factor authentication',
TWO_FACTOR_QR_INSTRUCTION: 'scan the QR code below with your favorite authenticator app',
ENTER_CODE_MANUALLY: 'enter the code manually',
TWO_FACTOR_MANUAL_CODE_INSTRUCTION: 'please enter this code in your favorite authenticator app',
SCAN_QR_CODE: 'scan QR code instead',
CONTINUE: 'continue',
BACK: 'back',
ENABLE_TWO_FACTOR: 'enable two-factor',
ENABLE: 'enable',
LOST_DEVICE: 'lost two-factor device?',
INCORRECT_CODE: 'incorrect code',
RECOVER_TWO_FACTOR: 'recover two-factor',
TWO_FACTOR_INFO: 'add an additional layer of security by requiring more than your email and password to log in to your account',
DISABLE_TWO_FACTOR_HINT: 'disable two-factor authentication',
UPDATE_TWO_FACTOR_HINT: 'update your authenticator device',
DISABLE: 'disable',
RECONFIGURE: 'reconfigure',
UPDATE_TWO_FACTOR: 'update two-factor',
UPDATE_TWO_FACTOR_MESSAGE: 'continuing forward will void any previously configured authenticators',
UPDATE: 'update',
DISABLE_TWO_FACTOR: 'disable two-factor',
DISABLE_TWO_FACTOR_MESSAGE: 'are you sure you want to disable your two-factor authentication',
TWO_FACTOR_SETUP_FAILED: 'failed to setup two factor, please try again',
TWO_FACTOR_SETUP_SUCCESS: 'two factor authentication successfully configured',
TWO_FACTOR_DISABLE_SUCCESS: 'two factor authentication disabled',
TWO_FACTOR_DISABLE_FAILED: 'failed to disable two factor, please try again',
};
export default englishConstants;

View file

@ -5125,6 +5125,11 @@ react-lifecycles-compat@^3.0.4:
resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362"
integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==
react-otp-input@^2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/react-otp-input/-/react-otp-input-2.3.1.tgz#85d85c01e5d51b0a43c3dc03927292a54d15ed98"
integrity sha512-ka14XemxkVh+nl1ykD9HutTyx5ailf4smOZ9gqciQ7GODbepWcjfh1pqsoEB2Mib/sFf1I6OjaPnct5d27+SFA==
react-overlays@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/react-overlays/-/react-overlays-5.0.1.tgz#7e2c3cd3c0538048b0b7451d203b1289c561b7f2"