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

@ -91,7 +91,7 @@ function CollectionShare(props: Props) {
}}
onClick={() => collectionUnshare(sharee)}
>
-
-
</Button>
</td>
</tr>
@ -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

@ -62,7 +62,7 @@ const ListItem = styled.div`
justify-content: center;
`;
const getTemplateColumns = (columns: number, groups?: number[]):string => {
const getTemplateColumns = (columns: number, groups?: number[]): string => {
if (groups) {
const sum = groups.reduce((acc, item) => acc + item, 0);
if (sum < columns) {
@ -82,12 +82,12 @@ const ListContainer = styled.div<{ columns: number, groups?: number[] }>`
width: 100%;
color: #fff;
@media(max-width: ${IMAGE_CONTAINER_MAX_WIDTH*4}px) {
@media(max-width: ${IMAGE_CONTAINER_MAX_WIDTH * 4}px) {
padding: 0 4px;
}
`;
const DateContainer = styled.div<{span: number}>`
const DateContainer = styled.div<{ span: number }>`
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@ -97,7 +97,7 @@ const DateContainer = styled.div<{span: number}>`
height: ${DATE_CONTAINER_HEIGHT}px;
`;
const BannerContainer = styled.div<{span: number}>`
const BannerContainer = styled.div<{ span: number }>`
color: #979797;
text-align: center;
grid-column: span ${(props) => props.span};
@ -293,7 +293,7 @@ const PhotoFrame = ({
try {
await new Promise((resolve, reject) => {
const video = document.createElement('video');
video.addEventListener('timeupdate', function() {
video.addEventListener('timeupdate', function () {
clearTimeout(t);
resolve(null);
});
@ -396,8 +396,8 @@ const PhotoFrame = ({
const isSameDay = (first, second) => (
first.getFullYear() === second.getFullYear() &&
first.getMonth() === second.getMonth() &&
first.getDate() === second.getDate()
first.getMonth() === second.getMonth() &&
first.getDate() === second.getDate()
);
/**
@ -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,34 +74,17 @@ 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>
</MessageDialog >
);
}
export default RecoveryKeyModal;

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

@ -5,7 +5,7 @@ import styled from 'styled-components';
* Global English constants.
*/
const dateString = function(date) {
const dateString = function (date) {
return new Date(date / 1000).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
@ -24,7 +24,7 @@ const Logo = styled.img`
`;
const englishConstants = {
HERO_HEADER: () => <div>with <Logo src='/icon.svg' /><br/>your <Strong>memories</Strong> are</div>,
HERO_HEADER: () => <div>with <Logo src='/icon.svg' /><br />your <Strong>memories</Strong> are</div>,
HERO_SLIDE_1_TITLE: 'protected',
HERO_SLIDE_1: 'end-to-end encrypted with your password, visible only to you',
HERO_SLIDE_2_TITLE: 'synced',
@ -46,7 +46,7 @@ const englishConstants = {
VERIFY_EMAIL: 'verify email',
EMAIL_SENT: ({ email }) => (
<p>
we have sent a mail to <b>{email}</b>
we have sent a mail to <b>{email}</b>
</p>
),
CHECK_INBOX: 'please check your inbox (and spam) to complete verification',
@ -67,10 +67,10 @@ const englishConstants = {
'please enter a password that we can use to encrypt your data',
PASSPHRASE_DISCLAIMER: () => (
<p>
we don't store your password, so if you forget,
we don't store your password, so if you forget,
<strong> we will not be able to help you</strong>
{' '}
recover your data.
recover your data.
</p>
),
PASSPHRASE_HINT: 'password',
@ -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',
@ -182,26 +186,26 @@ const englishConstants = {
MESSAGE: 'message',
INSTALL_MOBILE_APP: () => (
<>
install our{' '}
install our{' '}
<a
href="https://play.google.com/store/apps/details?id=io.ente.photos"
target="_blank"
style={{ color: '#2dc262' }} rel="noreferrer"
>
android
android
</a>
{' '}
or
or
{' '}
<a
href="https://apps.apple.com/in/app/ente-photos/id1542026904"
style={{ color: '#2dc262' }}
target="_blank" rel="noreferrer"
>
ios app
ios app
{' '}
</a>
to automatically backup all your photos
to automatically backup all your photos
</>
),
DOWNLOAD_APP_MESSAGE: () => (
@ -230,41 +234,41 @@ const englishConstants = {
FREE_SUBSCRIPTION_INFO: (expiryTime) => (
<>
<p>
you are on the <strong>free</strong>
you are on the <strong>free</strong>
{' '}
plan that expires on{' '}
plan that expires on{' '}
{dateString(expiryTime)}
</p>
</>
),
RENEWAL_ACTIVE_SUBSCRIPTION_INFO: (expiryTime) => (
<p>
your subscription will renew on {dateString(expiryTime)}
your subscription will renew on {dateString(expiryTime)}
</p>
),
RENEWAL_CANCELLED_SUBSCRIPTION_INFO: (expiryTime) => (
<>
<p>
your subscription will be cancelled on {dateString(expiryTime)}
your subscription will be cancelled on {dateString(expiryTime)}
</p>
</>
),
USAGE_INFO: (usage, quota) => (
<p>
you have used {usage}
you have used {usage}
{' '}
GB out of your {quota}
GB out of your {quota}
{' '}
GB quota
GB quota
</p>
),
SUBSCRIPTION_PURCHASE_SUCCESS: (expiryTime) => (
<>
<p>we've received your payment</p>
your subscription is valid till{' '}
your subscription is valid till{' '}
<strong>{dateString(expiryTime)}</strong>
</>
),
@ -287,8 +291,8 @@ const englishConstants = {
CANCEL_SUBSCRIPTION_MESSAGE: () => (
<>
<p>
all of your data will be deleted from our servers at the end of
this billing period.
all of your data will be deleted from our servers at the end of
this billing period.
</p>
<p>are you sure that you want to unsubscribe?</p>
</>
@ -313,8 +317,8 @@ const englishConstants = {
<>
<p>are you sure you want to delete this album?</p>
<p>
all files that are present only in this album will be
permanently deleted
all files that are present only in this album will be
permanently deleted
</p>
</>
),
@ -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>
</>
),
@ -336,42 +340,42 @@ const englishConstants = {
SEARCH_HINT: () => <span>try searching for New York, April 14, Christmas...</span>,
TERMS_AND_CONDITIONS: () => (
<p>
I agree to the{' '}
I agree to the{' '}
<a href="https://ente.io/terms" target="_blank" rel="noreferrer">
terms
terms
</a>
{' '}
and
and
{' '}
<a href="https://ente.io/privacy" target="_blank" rel="noreferrer">
privacy policy
privacy policy
</a>
{' '}
</p>
),
CONFIRM_PASSWORD_NOT_SAVED: () => (
<p>
i understand that if i lose my password , i may lose my data since
my data is{' '}
i understand that if i lose my password , i may lose my data since
my data is{' '}
<a href="https://ente.io/encryption" target="_blank" rel="noreferrer">
end-to-end encrypted
end-to-end encrypted
</a>
{' '}
with ente
with ente
</p>
),
SEARCH_STATS: ({ resultCount, timeTaken }) => (
<span>
found <span style={{ color: '#2dc262' }}>{resultCount}</span>
found <span style={{ color: '#2dc262' }}>{resultCount}</span>
{' '}
memories (
memories (
{' '}
<span style={{ color: '#2dc262' }}>
{' '}
{timeTaken}
</span>
{' '}
seconds )
seconds )
</span>
),
NOT_FILE_OWNER: 'deleting shared collection files is not allowed',
@ -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"