feat: create security container and frontend for 2fa settings

This commit is contained in:
Nicolas Meienberger 2023-04-07 19:35:42 +02:00 committed by Nicolas Meienberger
parent 866bee4491
commit 904d2c5adc
8 changed files with 495 additions and 1 deletions

View file

@ -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",

View file

@ -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'}

View file

@ -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>

View file

@ -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();
});
});
});

View file

@ -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>
);
};

View file

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

View file

@ -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>

View file

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