Browse Source

feat: create security container and frontend for 2fa settings

Nicolas Meienberger 2 years ago
parent
commit
904d2c5adc

+ 1 - 0
package.json

@@ -56,6 +56,7 @@
     "node-cron": "^3.0.1",
     "node-fetch-commonjs": "^3.2.4",
     "pg": "^8.10.0",
+    "qrcode.react": "^3.1.0",
     "react": "18.2.0",
     "react-dom": "18.2.0",
     "react-hook-form": "^7.43.7",

+ 11 - 0
pnpm-lock.yaml

@@ -82,6 +82,9 @@ dependencies:
   pg:
     specifier: ^8.10.0
     version: 8.10.0
+  qrcode.react:
+    specifier: ^3.1.0
+    version: 3.1.0(react@18.2.0)
   react:
     specifier: 18.2.0
     version: 18.2.0
@@ -7143,6 +7146,14 @@ packages:
     resolution: {integrity: sha512-t+x1zEHDjBwkDGY5v5ApnZ/utcd4XYDiJsaQQoptTXgUXX95sDg1elCdJghzicm7n2mbCBJ3uYWr6M22SO19rg==}
     dev: true
 
+  /qrcode.react@3.1.0(react@18.2.0):
+    resolution: {integrity: sha512-oyF+Urr3oAMUG/OiOuONL3HXM+53wvuH3mtIWQrYmsXoAq0DkvZp2RYUWFSMFtbdOpuS++9v+WAkzNVkMlNW6Q==}
+    peerDependencies:
+      react: ^16.8.0 || ^17.0.0 || ^18.0.0
+    dependencies:
+      react: 18.2.0
+    dev: false
+
   /qs@6.11.0:
     resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==}
     engines: {node: '>=0.6'}

+ 6 - 1
src/client/components/ui/Input/Input.tsx

@@ -13,16 +13,20 @@ interface IProps {
   onBlur?: (e: React.FocusEvent<HTMLInputElement>) => void;
   disabled?: boolean;
   value?: string;
+  readOnly?: boolean;
 }
 
-export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, placeholder, error, type = 'text', className, value, isInvalid, disabled }, ref) => (
+export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, placeholder, error, type = 'text', className, value, isInvalid, disabled, readOnly }, ref) => (
   <div className={clsx(className)}>
     {label && (
       <label htmlFor={name} className="form-label">
         {label}
       </label>
     )}
+    {/* eslint-disable-next-line jsx-a11y/no-redundant-roles */}
     <input
+      aria-label={name}
+      role="textbox"
       disabled={disabled}
       name={name}
       id={name}
@@ -33,6 +37,7 @@ export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onB
       ref={ref}
       className={clsx('form-control', { 'is-invalid is-invalid-lite': error || isInvalid })}
       placeholder={placeholder}
+      readOnly={readOnly}
     />
     {error && <div className="invalid-feedback">{error}</div>}
   </div>

+ 307 - 0
src/client/modules/Settings/containers/SecurityContainer/SecurityContainer.test.tsx

@@ -0,0 +1,307 @@
+import React from 'react';
+import { server } from '@/client/mocks/server';
+import { getTRPCMock, getTRPCMockError } from '@/client/mocks/getTrpcMock';
+import { useToastStore } from '@/client/state/toastStore';
+import { renderHook } from '@testing-library/react';
+import { render, screen, waitFor, fireEvent } from '../../../../../../tests/test-utils';
+import { SecurityContainer } from './SecurityContainer';
+
+describe('<SecurityContainer />', () => {
+  it('should render', () => {
+    render(<SecurityContainer />);
+  });
+
+  it('should prompt for password when enabling 2FA', async () => {
+    // arrange
+    render(<SecurityContainer />);
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+  });
+
+  it('should prompt for password when disabling 2FA', async () => {
+    // arrange
+    server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
+    render(<SecurityContainer />);
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+  });
+
+  it('should show show error toast if password is incorrect while enabling 2FA', async () => {
+    // arrange
+    const { result } = renderHook(() => useToastStore());
+    server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: false, id: 12, username: 'test' } }));
+    server.use(getTRPCMockError({ path: ['auth', 'getTotpUri'], type: 'mutation', message: 'Invalid password' }));
+    render(<SecurityContainer />);
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+    fireEvent.change(passwordInput, { target: { value: 'test' } });
+    const submitButton = screen.getByRole('button', { name: /Enable 2FA/i });
+    submitButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(result.current.toasts).toHaveLength(1);
+      expect(result.current.toasts[0]?.status).toEqual('error');
+      expect(result.current.toasts[0]?.title).toEqual('Error');
+      expect(result.current.toasts[0]?.description).toEqual('Invalid password');
+    });
+  });
+
+  it('should show show error toast if password is incorrect while disabling 2FA', async () => {
+    // arrange
+    const { result } = renderHook(() => useToastStore());
+    server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
+    server.use(getTRPCMockError({ path: ['auth', 'disableTotp'], type: 'mutation', message: 'Invalid password' }));
+    render(<SecurityContainer />);
+
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+    fireEvent.change(passwordInput, { target: { value: 'test' } });
+    const submitButton = screen.getByRole('button', { name: /Disable 2FA/i });
+    submitButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(result.current.toasts).toHaveLength(1);
+      expect(result.current.toasts[0]?.status).toEqual('error');
+      expect(result.current.toasts[0]?.title).toEqual('Error');
+      expect(result.current.toasts[0]?.description).toEqual('Invalid password');
+    });
+  });
+
+  it('should show success toast if password is correct while disabling 2FA', async () => {
+    // arrange
+    const { result } = renderHook(() => useToastStore());
+    server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, id: 12, username: 'test' } }));
+    server.use(getTRPCMock({ path: ['auth', 'disableTotp'], type: 'mutation', response: true }));
+
+    render(<SecurityContainer />);
+
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+    fireEvent.change(passwordInput, { target: { value: 'test' } });
+    const submitButton = screen.getByRole('button', { name: /Disable 2FA/i });
+    submitButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(result.current.toasts).toHaveLength(1);
+      expect(result.current.toasts[0]?.status).toEqual('success');
+      expect(result.current.toasts[0]?.title).toEqual('Success');
+      expect(result.current.toasts[0]?.description).toEqual('Two-factor authentication disabled');
+    });
+  });
+
+  it('should show secret key and QR code when enabling 2FA', async () => {
+    // arrange
+    server.use(getTRPCMock({ path: ['auth', 'getTotpUri'], type: 'mutation', response: { key: 'test', uri: 'test' } }));
+    render(<SecurityContainer />);
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+    fireEvent.change(passwordInput, { target: { value: 'test' } });
+    const submitButton = screen.getByRole('button', { name: /Enable 2FA/i });
+    submitButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText('Scan this QR code with your authenticator app.')).toBeInTheDocument();
+      expect(screen.getByRole('textbox', { name: 'secret key' })).toHaveValue('test');
+      expect(screen.getByRole('button', { name: 'Enable 2FA' })).toBeDisabled();
+    });
+  });
+
+  it('should show error toast if submitted totp code is invalid', async () => {
+    // arrange
+    const { result } = renderHook(() => useToastStore());
+    server.use(getTRPCMock({ path: ['auth', 'getTotpUri'], type: 'mutation', response: { key: 'test', uri: 'test' } }));
+    server.use(getTRPCMockError({ path: ['auth', 'setupTotp'], type: 'mutation', message: 'Invalid code' }));
+
+    render(<SecurityContainer />);
+
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+    fireEvent.change(passwordInput, { target: { value: 'test' } });
+    const submitButton = screen.getByRole('button', { name: /Enable 2FA/i });
+    submitButton.click();
+
+    await waitFor(() => {
+      expect(screen.getByText('Scan this QR code with your authenticator app.')).toBeInTheDocument();
+    });
+
+    const inputEls = screen.getAllByRole('textbox', { name: /digit-/ });
+
+    inputEls.forEach((inputEl) => {
+      fireEvent.change(inputEl, { target: { value: '1' } });
+    });
+
+    const enable2FAButton = screen.getByRole('button', { name: 'Enable 2FA' });
+    enable2FAButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(result.current.toasts).toHaveLength(1);
+      expect(result.current.toasts[0]?.status).toEqual('error');
+      expect(result.current.toasts[0]?.title).toEqual('Error');
+      expect(result.current.toasts[0]?.description).toEqual('Invalid code');
+    });
+  });
+
+  it('should show success toast if submitted totp code is valid', async () => {
+    // arrange
+    const { result } = renderHook(() => useToastStore());
+    server.use(getTRPCMock({ path: ['auth', 'getTotpUri'], type: 'mutation', response: { key: 'test', uri: 'test' } }));
+    server.use(getTRPCMock({ path: ['auth', 'setupTotp'], type: 'mutation', response: true }));
+    render(<SecurityContainer />);
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+    fireEvent.change(passwordInput, { target: { value: 'test' } });
+    const submitButton = screen.getByRole('button', { name: /Enable 2FA/i });
+    submitButton.click();
+
+    await waitFor(() => {
+      expect(screen.getByText('Scan this QR code with your authenticator app.')).toBeInTheDocument();
+    });
+
+    const inputEls = screen.getAllByRole('textbox', { name: /digit-/ });
+
+    inputEls.forEach((inputEl) => {
+      fireEvent.change(inputEl, { target: { value: '1' } });
+    });
+
+    const enable2FAButton = screen.getByRole('button', { name: 'Enable 2FA' });
+    enable2FAButton.click();
+
+    // assert
+    await waitFor(() => {
+      expect(result.current.toasts).toHaveLength(1);
+      expect(result.current.toasts[0]?.status).toEqual('success');
+      expect(result.current.toasts[0]?.title).toEqual('Success');
+      expect(result.current.toasts[0]?.description).toEqual('Two-factor authentication enabled');
+    });
+  });
+
+  it('can close the setup modal by clicking on the esc key', async () => {
+    // arrange
+    render(<SecurityContainer />);
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+
+    fireEvent.keyDown(document, { key: 'Escape' });
+
+    // assert
+    await waitFor(() => {
+      expect(screen.queryByText('Password needed')).not.toBeInTheDocument();
+    });
+  });
+
+  it('can close the disable modal by clicking on the esc key', async () => {
+    // arrange
+    server.use(getTRPCMock({ path: ['auth', 'me'], response: { totp_enabled: true, username: '', id: 1 } }));
+    render(<SecurityContainer />);
+    const twoFactorAuthButton = screen.getByRole('switch', { name: /Enable two-factor authentication/i });
+    await waitFor(() => {
+      expect(twoFactorAuthButton).toBeEnabled();
+    });
+
+    // act
+    twoFactorAuthButton.click();
+    await waitFor(() => {
+      expect(screen.getByText('Password needed')).toBeInTheDocument();
+    });
+
+    fireEvent.keyDown(document, { key: 'Escape' });
+
+    // assert
+    await waitFor(() => {
+      expect(screen.queryByText('Password needed')).not.toBeInTheDocument();
+    });
+  });
+});

+ 162 - 0
src/client/modules/Settings/containers/SecurityContainer/SecurityContainer.tsx

@@ -0,0 +1,162 @@
+import { Switch } from '@/components/ui/Switch';
+import { QRCodeSVG } from 'qrcode.react';
+import React from 'react';
+import { trpc } from '@/utils/trpc';
+import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/Dialog';
+import { Input } from '@/components/ui/Input';
+import { useToastStore } from '@/client/state/toastStore';
+import { Button } from '@/components/ui/Button';
+import { OtpInput } from '@/components/ui/OtpInput';
+import { IconLock } from '@tabler/icons-react';
+
+export const SecurityContainer = () => {
+  const { addToast } = useToastStore();
+  const [password, setPassword] = React.useState('');
+  const [key, setKey] = React.useState('');
+  const [uri, setUri] = React.useState('');
+  const [totpCode, setTotpCode] = React.useState('');
+
+  // Dialog statuses
+  const [isSetupTotpOpen, setIsSetupTotpOpen] = React.useState(false);
+  const [isDisableTotpOpen, setIsDisableTotpOpen] = React.useState(false);
+
+  const ctx = trpc.useContext();
+  const me = trpc.auth.me.useQuery();
+
+  const getTotpUri = trpc.auth.getTotpUri.useMutation({
+    onMutate: () => {
+      setIsSetupTotpOpen(false);
+    },
+    onError: (e) => {
+      setPassword('');
+      addToast({ title: 'Error', description: e.message, status: 'error' });
+    },
+    onSuccess: (data) => {
+      setKey(data.key);
+      setUri(data.uri);
+    },
+  });
+
+  const setupTotp = trpc.auth.setupTotp.useMutation({
+    onMutate: () => {},
+    onError: (e) => {
+      setTotpCode('');
+      addToast({ title: 'Error', description: e.message, status: 'error' });
+    },
+    onSuccess: () => {
+      setTotpCode('');
+      setKey('');
+      setUri('');
+      addToast({ title: 'Success', description: 'Two-factor authentication enabled', status: 'success' });
+      ctx.auth.me.invalidate();
+    },
+  });
+
+  const disableTotp = trpc.auth.disableTotp.useMutation({
+    onMutate: () => {
+      setIsDisableTotpOpen(false);
+    },
+    onError: (e) => {
+      setPassword('');
+      addToast({ title: 'Error', description: e.message, status: 'error' });
+    },
+    onSuccess: () => {
+      addToast({ title: 'Success', description: 'Two-factor authentication disabled', status: 'success' });
+      ctx.auth.me.invalidate();
+    },
+  });
+
+  const handleTotp = (enabled: boolean) => {
+    if (enabled) setIsSetupTotpOpen(true);
+    else {
+      setIsDisableTotpOpen(true);
+    }
+  };
+
+  const renderSetupQr = () => {
+    if (!uri || me.data?.totp_enabled) return null;
+
+    return (
+      <div className="mt-4">
+        <div className="mb-4">
+          <p className="text-muted">Scan this QR code with your authenticator app.</p>
+          <QRCodeSVG value={uri} />
+        </div>
+        <div className="mb-4">
+          <p className="text-muted">Or enter this key manually.</p>
+          <Input name="secret key" value={key} readOnly />
+        </div>
+        <div className="mb-4">
+          <p className="text-muted">Enter the code from your authenticator app.</p>
+          <OtpInput value={totpCode} valueLength={6} onChange={(e) => setTotpCode(e)} />
+          <Button disabled={totpCode.trim().length < 6} onClick={() => setupTotp.mutate({ totpCode })} className="mt-3 btn-success">
+            Enable 2FA
+          </Button>
+        </div>
+      </div>
+    );
+  };
+
+  return (
+    <div className="card-body">
+      <div className="d-flex">
+        <IconLock className="me-2" />
+        <h2>Two-Factor Authentication</h2>
+      </div>
+      <p className="text-muted">
+        Two-factor authentication (2FA) adds an additional layer of security to your account.
+        <br />
+        When enabled, you will be prompted to enter a code from your authenticator app when you log in.
+      </p>
+      {!key && <Switch disabled={!me.isSuccess} onCheckedChange={handleTotp} checked={me.data?.totp_enabled} label="Enable two-factor authentication" />}
+      {getTotpUri.isLoading && (
+        <div className="progress w-50">
+          <div className="progress-bar progress-bar-indeterminate bg-green" />
+        </div>
+      )}
+      {renderSetupQr()}
+      <Dialog open={isSetupTotpOpen} onOpenChange={(o) => setIsSetupTotpOpen(o)}>
+        <DialogContent size="sm">
+          <DialogHeader>
+            <DialogTitle>Password needed</DialogTitle>
+          </DialogHeader>
+          <DialogDescription className="d-flex flex-column">
+            <form
+              onSubmit={(e) => {
+                e.preventDefault();
+                getTotpUri.mutate({ password });
+              }}
+            >
+              <p className="text-muted">Your password is required to setup two-factor authentication.</p>
+              <Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
+              <Button loading={getTotpUri.isLoading} type="submit" className="btn-success mt-3">
+                Enable 2FA
+              </Button>
+            </form>
+          </DialogDescription>
+        </DialogContent>
+      </Dialog>
+      <Dialog open={isDisableTotpOpen} onOpenChange={(o) => setIsDisableTotpOpen(o)}>
+        <DialogContent size="sm">
+          <DialogHeader>
+            <DialogTitle>Password needed</DialogTitle>
+          </DialogHeader>
+          <DialogDescription className="d-flex flex-column">
+            <form
+              onSubmit={(e) => {
+                e.preventDefault();
+                disableTotp.mutate({ password });
+              }}
+            >
+              <p className="text-muted">Your password is required to disable two-factor authentication.</p>
+              <Input name="password" type="password" onChange={(e) => setPassword(e.target.value)} placeholder="Password" />
+              <Button loading={disableTotp.isLoading} type="submit" className="btn-danger mt-3">
+                Disable 2FA
+              </Button>
+            </form>
+          </DialogDescription>
+        </DialogContent>
+      </Dialog>
+    </div>
+  );
+};

+ 1 - 0
src/client/modules/Settings/containers/SecurityContainer/index.ts

@@ -0,0 +1 @@
+export { SecurityContainer } from './SecurityContainer';

+ 5 - 0
src/client/modules/Settings/pages/SettingsPage/SettingsPage.tsx

@@ -4,6 +4,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
 import { Layout } from '../../../../components/Layout';
 import { GeneralActions } from '../../containers/GeneralActions';
 import { SettingsContainer } from '../../containers/SettingsContainer';
+import { SecurityContainer } from '../../containers/SecurityContainer';
 
 export const SettingsPage: NextPage = () => {
   return (
@@ -13,6 +14,7 @@ export const SettingsPage: NextPage = () => {
           <TabsList>
             <TabsTrigger value="actions">Actions</TabsTrigger>
             <TabsTrigger value="settings">Settings</TabsTrigger>
+            <TabsTrigger value="security">Security</TabsTrigger>
           </TabsList>
           <TabsContent value="actions">
             <GeneralActions />
@@ -20,6 +22,9 @@ export const SettingsPage: NextPage = () => {
           <TabsContent value="settings">
             <SettingsContainer />
           </TabsContent>
+          <TabsContent value="security">
+            <SecurityContainer />
+          </TabsContent>
         </Tabs>
       </div>
     </Layout>

+ 2 - 0
src/server/services/auth/auth.service.ts

@@ -174,6 +174,8 @@ export class AuthServiceClass {
         totp_enabled: true,
       },
     });
+
+    return true;
   };
 
   public disableTotp = async (params: { userId: number; password: string }) => {