Merge branch 'develop' into feature/subscription-info-in-preferences

This commit is contained in:
Antonella Sgarlatta
2021-09-07 13:14:44 -03:00
committed by GitHub
34 changed files with 413 additions and 319 deletions

View File

@@ -1,9 +1,12 @@
import { FunctionComponent } from 'preact';
import { PreferencesPane } from '../components';
import { TwoFactorAuthWrapper } from './two-factor-auth';
import { MfaProps } from './two-factor-auth/MfaProps';
export const Security: FunctionComponent = () => (
interface SecurityProps extends MfaProps {}
export const Security: FunctionComponent<SecurityProps> = (props) => (
<PreferencesPane>
<TwoFactorAuthWrapper />
<TwoFactorAuthWrapper mfaGateway={props.mfaGateway} />
</PreferencesPane>
);

View File

@@ -0,0 +1,17 @@
export interface MfaGateway {
getUser(): { uuid: string; email: string } | undefined;
isMfaActivated(): Promise<boolean>;
generateMfaSecret(): Promise<string>;
getOtpToken(secret: string): Promise<string>;
enableMfa(secret: string, otpToken: string): Promise<void>;
disableMfa(): Promise<void>;
}
export interface MfaProps {
mfaGateway: MfaGateway;
}

View File

@@ -4,7 +4,7 @@ import { IconButton } from '@/components/IconButton';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { downloadSecretKey } from './download-secret-key';
import { TwoFactorActivation } from './model';
import { TwoFactorActivation } from './TwoFactorActivation';
import {
TwoFactorDialog,
TwoFactorDialogLabel,

View File

@@ -1,9 +1,12 @@
import { FunctionComponent } from 'preact';
import { observer } from 'mobx-react-lite';
import QRCode from 'qrcode.react';
import { DecoratedInput } from '../../../components/DecoratedInput';
import { IconButton } from '../../../components/IconButton';
import { Button } from '@/components/Button';
import { TwoFactorActivation } from './model';
import { TwoFactorActivation } from './TwoFactorActivation';
import {
TwoFactorDialog,
TwoFactorDialogLabel,
@@ -35,7 +38,7 @@ export const ScanQRCode: FunctionComponent<{
<TwoFactorDialogDescription>
<div className="flex flex-row gap-3 items-center">
<div className="w-25 h-25 flex items-center justify-center bg-info">
QR code
<QRCode value={act.qrCode} size={100} />
</div>
<div className="flex-grow flex flex-col gap-2">
<div className="flex flex-row gap-1 items-center">

View File

@@ -0,0 +1,121 @@
import { action, makeAutoObservable, observable, untracked } from 'mobx';
import { MfaGateway } from './MfaProps';
type ActivationStep = 'scan-qr-code' | 'save-secret-key' | 'verification';
type VerificationStatus = 'none' | 'invalid' | 'valid';
export class TwoFactorActivation {
public readonly type = 'two-factor-activation' as const;
private _activationStep: ActivationStep;
private _2FAVerification: VerificationStatus = 'none';
private inputSecretKey = '';
private inputOtpToken = '';
constructor(
private mfaGateway: MfaGateway,
private readonly _secretKey: string,
private _cancelActivation: () => void,
private _enabled2FA: () => void
) {
this._activationStep = 'scan-qr-code';
makeAutoObservable<
TwoFactorActivation,
| '_secretKey'
| '_authCode'
| '_step'
| '_enable2FAVerification'
| 'refreshOtp'
| 'inputOtpToken'
| 'inputSecretKey'
>(
this,
{
_secretKey: observable,
_authCode: observable,
_step: observable,
_enable2FAVerification: observable,
refreshOtp: action,
inputOtpToken: observable,
inputSecretKey: observable,
},
{ autoBind: true }
);
}
get secretKey(): string {
return this._secretKey;
}
get activationStep(): ActivationStep {
return this._activationStep;
}
get verificationStatus(): VerificationStatus {
return this._2FAVerification;
}
get qrCode(): string {
const email = this.mfaGateway.getUser()!.email;
return `otpauth://totp/2FA?secret=${this._secretKey}&issuer=Standard%20Notes&label=${email}`;
}
cancelActivation(): void {
this._cancelActivation();
}
openScanQRCode(): void {
const preconditions: ActivationStep[] = ['save-secret-key'];
if (preconditions.includes(this._activationStep)) {
this._activationStep = 'scan-qr-code';
}
}
openSaveSecretKey(): void {
const preconditions: ActivationStep[] = ['scan-qr-code', 'verification'];
if (preconditions.includes(this._activationStep)) {
this._activationStep = 'save-secret-key';
}
}
openVerification(): void {
this.inputOtpToken = '';
this.inputSecretKey = '';
const preconditions: ActivationStep[] = ['save-secret-key'];
if (preconditions.includes(this._activationStep)) {
this._activationStep = 'verification';
this._2FAVerification = 'none';
}
}
setInputSecretKey(secretKey: string): void {
this.inputSecretKey = secretKey;
}
setInputOtpToken(otpToken: string): void {
this.inputOtpToken = otpToken;
}
enable2FA(): void {
if (this.inputSecretKey === this._secretKey) {
this.mfaGateway
.enableMfa(this.inputSecretKey, this.inputOtpToken)
.then(
action(() => {
this._2FAVerification = 'valid';
this._enabled2FA();
})
)
.catch(
action(() => {
this._2FAVerification = 'invalid';
})
);
} else {
this._2FAVerification = 'invalid';
}
}
}

View File

@@ -1,6 +1,6 @@
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { TwoFactorActivation } from './model';
import { TwoFactorActivation } from './TwoFactorActivation';
import { SaveSecretKey } from './SaveSecretKey';
import { ScanQRCode } from './ScanQRCode';
import { Verification } from './Verification';
@@ -9,10 +9,12 @@ export const TwoFactorActivationView: FunctionComponent<{
activation: TwoFactorActivation;
}> = observer(({ activation: act }) => (
<>
{act.step === 'scan-qr-code' && <ScanQRCode activation={act} />}
{act.activationStep === 'scan-qr-code' && <ScanQRCode activation={act} />}
{act.step === 'save-secret-key' && <SaveSecretKey activation={act} />}
{act.activationStep === 'save-secret-key' && (
<SaveSecretKey activation={act} />
)}
{act.step === 'verification' && <Verification activation={act} />}
{act.activationStep === 'verification' && <Verification activation={act} />}
</>
));

View File

@@ -0,0 +1,130 @@
import { action, makeAutoObservable, observable } from 'mobx';
import { MfaGateway } from './MfaProps';
import { TwoFactorActivation } from './TwoFactorActivation';
type TwoFactorStatus =
| 'two-factor-enabled'
| TwoFactorActivation
| 'two-factor-disabled';
export const is2FADisabled = (s: TwoFactorStatus): s is 'two-factor-disabled' =>
s === 'two-factor-disabled';
export const is2FAActivation = (s: TwoFactorStatus): s is TwoFactorActivation =>
(s as any).type === 'two-factor-activation';
export const is2FAEnabled = (s: TwoFactorStatus): s is 'two-factor-enabled' =>
s === 'two-factor-enabled';
export class TwoFactorAuth {
private _status: TwoFactorStatus | 'fetching' = 'fetching';
private _errorMessage: string | null;
constructor(private readonly mfaGateway: MfaGateway) {
this._errorMessage = null;
makeAutoObservable<
TwoFactorAuth,
'_status' | '_errorMessage' | 'deactivateMfa' | 'startActivation'
>(this, {
_status: observable,
_errorMessage: observable,
deactivateMfa: action,
startActivation: action,
});
}
private startActivation(): void {
const setDisabled = action(() => (this._status = 'two-factor-disabled'));
const setEnabled = action(() => (this._status = 'two-factor-enabled'));
this.mfaGateway
.generateMfaSecret()
.then(
action((secret) => {
this._status = new TwoFactorActivation(
this.mfaGateway,
secret,
setDisabled,
setEnabled
);
})
)
.catch(
action((e) => {
this.setError(e.message);
})
);
}
private deactivate2FA(): void {
this.mfaGateway
.disableMfa()
.then(
action(() => {
this._status = 'two-factor-disabled';
})
)
.catch(
action((e) => {
this.setError(e.message);
})
);
}
private get isLoggedIn(): boolean {
return this.mfaGateway.getUser() != undefined;
}
fetchStatus(): void {
this._status = 'fetching';
if (!this.isLoggedIn) {
this.setError('To enable 2FA, sign in or register for an account.');
return;
}
this.mfaGateway
.isMfaActivated()
.then(
action((active) => {
this._status = active ? 'two-factor-enabled' : 'two-factor-disabled';
this.setError(null);
})
)
.catch(
action((e) => {
this._status = 'two-factor-disabled';
this.setError(e.message);
})
);
}
setError(errorMessage: string | null): void {
this._errorMessage = errorMessage;
}
toggle2FA(): void {
if (!this.isLoggedIn) {
return;
}
if (this._status === 'two-factor-disabled') {
return this.startActivation();
}
if (this._status === 'two-factor-enabled') {
return this.deactivate2FA();
}
}
get errorMessage(): string | null {
return this._errorMessage;
}
get status(): TwoFactorStatus {
if (this._status === 'fetching') {
return 'two-factor-disabled';
}
return this._status;
}
}

View File

@@ -12,9 +12,8 @@ import {
is2FADisabled,
is2FAEnabled,
TwoFactorAuth,
} from './model';
} from './TwoFactorAuth';
import { TwoFactorDisabledView } from './TwoFactorDisabledView';
import { TwoFactorEnabledView } from './TwoFactorEnabledView';
import { TwoFactorActivationView } from './TwoFactorActivationView';
export const TwoFactorAuthView: FunctionComponent<{
@@ -28,6 +27,9 @@ export const TwoFactorAuthView: FunctionComponent<{
<Text>
An extra layer of security when logging in to your account.
</Text>
{auth.errorMessage != null && (
<Text className="color-danger">{auth.errorMessage}</Text>
)}
</div>
<Switch
checked={!is2FADisabled(auth.status)}
@@ -35,19 +37,15 @@ export const TwoFactorAuthView: FunctionComponent<{
/>
</div>
</PreferencesSegment>
<PreferencesSegment>
{is2FAEnabled(auth.status) && (
<TwoFactorEnabledView
secretKey={auth.status.secretKey}
authCode={auth.status.authCode}
/>
)}
{is2FAActivation(auth.status) && (
<TwoFactorActivationView activation={auth.status} />
)}
{is2FAActivation(auth.status) ? (
<TwoFactorActivationView activation={auth.status} />
) : null}
{!is2FAEnabled(auth.status) && <TwoFactorDisabledView />}
</PreferencesSegment>
{!is2FAEnabled(auth.status) ? (
<PreferencesSegment>
<TwoFactorDisabledView />
</PreferencesSegment>
) : null}
</PreferencesGroup>
));

View File

@@ -19,8 +19,8 @@ export const TwoFactorDialog: FunctionComponent<{
return (
<AlertDialog leastDestructiveRef={ldRef}>
{/* sn-component is focusable by default, but doesn't stretch to child width
resulting in a badly focused dialog. Utility classes are not available
at the sn-component level, only below it. tabIndex -1 disables focus
resulting in a badly focused dialog. Utility classes are not available
at the sn-component level, only below it. tabIndex -1 disables focus
and enables it on the child component */}
<div tabIndex={-1} className="sn-component">
<div

View File

@@ -1,45 +0,0 @@
import { CircleProgressTime } from '@/components/CircleProgressTime';
import { DecoratedInput } from '@/components/DecoratedInput';
import { IconButton } from '@/components/IconButton';
import { FunctionComponent } from 'preact';
import { downloadSecretKey } from './download-secret-key';
import { Text } from '../../components';
export const TwoFactorEnabledView: FunctionComponent<{
secretKey: string;
authCode: string;
}> = ({ secretKey, authCode }) => {
const download = (
<IconButton
icon="download"
onClick={() => {
downloadSecretKey(secretKey);
}}
/>
);
const copy = (
<IconButton
icon="copy"
onClick={() => {
navigator?.clipboard?.writeText(secretKey);
}}
/>
);
const progress = <CircleProgressTime time={30000} />;
return (
<div className="flex flex-row gap-4">
<div className="flex-grow flex flex-col">
<Text>Secret Key</Text>
<DecoratedInput
disabled={true}
right={[copy, download]}
text={secretKey}
/>
</div>
<div className="w-30 flex flex-col">
<Text>Authentication Code</Text>
<DecoratedInput disabled={true} text={authCode} right={[progress]} />
</div>
</div>
);
};

View File

@@ -2,7 +2,7 @@ import { Button } from '@/components/Button';
import { DecoratedInput } from '@/components/DecoratedInput';
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { TwoFactorActivation } from './model';
import { TwoFactorActivation } from './TwoFactorActivation';
import {
TwoFactorDialog,
TwoFactorDialogLabel,
@@ -17,11 +17,7 @@ export const Verification: FunctionComponent<{
act.verificationStatus === 'invalid' ? 'border-dark-red' : '';
return (
<TwoFactorDialog>
<TwoFactorDialogLabel
closeDialog={() => {
act.cancelActivation();
}}
>
<TwoFactorDialogLabel closeDialog={act.cancelActivation}>
Step 3 of 3 - Verification
</TwoFactorDialogLabel>
<TwoFactorDialogDescription>
@@ -30,20 +26,26 @@ export const Verification: FunctionComponent<{
<div className="text-sm">
Enter your <b>secret key</b>:
</div>
<DecoratedInput className={borderInv} />
<DecoratedInput
className={borderInv}
onChange={act.setInputSecretKey}
/>
</div>
<div className="flex flex-row items-center gap-2">
<div className="text-sm">
Verify the <b>authentication code</b> generated by your
authenticator app:
</div>
<DecoratedInput className={`w-30 ${borderInv}`} />
<DecoratedInput
className={`w-30 ${borderInv}`}
onChange={act.setInputOtpToken}
/>
</div>
</div>
</TwoFactorDialogDescription>
<TwoFactorDialogButtons>
{act.verificationStatus === 'invalid' && (
<div className="text-sm color-dark-red">
<div className="text-sm color-danger">
Incorrect credentials, please try again.
</div>
)}
@@ -51,13 +53,13 @@ export const Verification: FunctionComponent<{
className="min-w-20"
type="normal"
label="Back"
onClick={() => act.openSaveSecretKey()}
onClick={act.openSaveSecretKey}
/>
<Button
className="min-w-20"
type="primary"
label="Next"
onClick={() => act.enable2FA('X', 'X')}
onClick={act.enable2FA}
/>
</TwoFactorDialogButtons>
</TwoFactorDialog>

View File

@@ -1,10 +1,13 @@
import { observer } from 'mobx-react-lite';
import { FunctionComponent } from 'preact';
import { useState } from 'preact/hooks';
import { TwoFactorAuth } from './model';
import { MfaProps } from './MfaProps';
import { TwoFactorAuth } from './TwoFactorAuth';
import { TwoFactorAuthView } from './TwoFactorAuthView';
export const TwoFactorAuthWrapper: FunctionComponent = () => {
const [auth] = useState(() => new TwoFactorAuth());
export const TwoFactorAuthWrapper: FunctionComponent<MfaProps> = ({
mfaGateway,
}) => {
const [auth] = useState(() => new TwoFactorAuth(mfaGateway));
auth.fetchStatus();
return <TwoFactorAuthView auth={auth} />;
};

View File

@@ -1,168 +0,0 @@
import { action, makeAutoObservable, observable, untracked } from 'mobx';
function getNewAuthCode() {
const MIN = 100000;
const MAX = 999999;
const code = Math.floor(Math.random() * (MAX - MIN) + MIN);
return code.toString();
}
const activationSteps = [
'scan-qr-code',
'save-secret-key',
'verification',
] as const;
type ActivationStep = typeof activationSteps[number];
export class TwoFactorActivation {
public readonly type = 'two-factor-activation' as const;
private _step: ActivationStep;
private _secretKey: string;
private _authCode: string;
private _2FAVerification: 'none' | 'invalid' | 'valid' = 'none';
constructor(
private _cancelActivation: () => void,
private _enable2FA: (secretKey: string) => void
) {
this._secretKey = 'FHJJSAJKDASKW43KJS';
this._authCode = getNewAuthCode();
this._step = 'scan-qr-code';
makeAutoObservable<
TwoFactorActivation,
'_secretKey' | '_authCode' | '_step' | '_enable2FAVerification'
>(
this,
{
_secretKey: observable,
_authCode: observable,
_step: observable,
_enable2FAVerification: observable,
},
{ autoBind: true }
);
}
get secretKey() {
return this._secretKey;
}
get authCode() {
return this._authCode;
}
get step() {
return this._step;
}
get verificationStatus() {
return this._2FAVerification;
}
cancelActivation() {
this._cancelActivation();
}
openScanQRCode() {
this._step = 'scan-qr-code';
}
openSaveSecretKey() {
this._step = 'save-secret-key';
}
openVerification() {
this._step = 'verification';
this._2FAVerification = 'none';
}
enable2FA(secretKey: string, authCode: string) {
if (secretKey === this._secretKey && authCode === this._authCode) {
this._2FAVerification = 'valid';
this._enable2FA(secretKey);
return;
}
// Change to invalid upon implementation
this._2FAVerification = 'valid';
// Remove after implementation
this._enable2FA(secretKey);
}
}
export class TwoFactorEnabled {
public readonly type = 'two-factor-enabled' as const;
private _secretKey: string;
private _authCode: string;
constructor(secretKey: string) {
this._secretKey = secretKey;
this._authCode = getNewAuthCode();
makeAutoObservable<TwoFactorEnabled, '_secretKey' | '_authCode'>(this, {
_secretKey: observable,
_authCode: observable,
});
}
get secretKey() {
return this._secretKey;
}
get authCode() {
return this._authCode;
}
refreshAuthCode() {
this._authCode = getNewAuthCode();
}
}
type TwoFactorStatus =
| TwoFactorEnabled
| TwoFactorActivation
| 'two-factor-disabled';
export const is2FADisabled = (s: TwoFactorStatus): s is 'two-factor-disabled' =>
s === 'two-factor-disabled';
export const is2FAActivation = (s: TwoFactorStatus): s is TwoFactorActivation =>
(s as any).type === 'two-factor-activation';
export const is2FAEnabled = (s: TwoFactorStatus): s is TwoFactorEnabled =>
(s as any).type === 'two-factor-enabled';
export class TwoFactorAuth {
private _status: TwoFactorStatus = 'two-factor-disabled';
constructor() {
makeAutoObservable<TwoFactorAuth, '_status'>(this, {
_status: observable,
});
}
private startActivation() {
const cancel = action(() => (this._status = 'two-factor-disabled'));
const enable = action(
(secretKey: string) => (this._status = new TwoFactorEnabled(secretKey))
);
this._status = new TwoFactorActivation(cancel, enable);
}
private deactivate2FA() {
this._status = 'two-factor-disabled';
}
toggle2FA() {
if (this._status === 'two-factor-disabled') this.startActivation();
else this.deactivate2FA();
}
get status() {
return this._status;
}
}