From 904d2c5adccc24fbaf3dd1ab27c61b863d9339ca Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Fri, 7 Apr 2023 19:35:42 +0200 Subject: [PATCH] feat: create security container and frontend for 2fa settings --- package.json | 1 + pnpm-lock.yaml | 11 + src/client/components/ui/Input/Input.tsx | 7 +- .../SecurityContainer.test.tsx | 307 ++++++++++++++++++ .../SecurityContainer/SecurityContainer.tsx | 162 +++++++++ .../containers/SecurityContainer/index.ts | 1 + .../pages/SettingsPage/SettingsPage.tsx | 5 + src/server/services/auth/auth.service.ts | 2 + 8 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 src/client/modules/Settings/containers/SecurityContainer/SecurityContainer.test.tsx create mode 100644 src/client/modules/Settings/containers/SecurityContainer/SecurityContainer.tsx create mode 100644 src/client/modules/Settings/containers/SecurityContainer/index.ts diff --git a/package.json b/package.json index 15a3bbcc..d32bfa0e 100644 --- a/package.json +++ b/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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8e94f859..905f5b9f 100644 --- a/pnpm-lock.yaml +++ b/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'} diff --git a/src/client/components/ui/Input/Input.tsx b/src/client/components/ui/Input/Input.tsx index 894750ad..69feeb29 100644 --- a/src/client/components/ui/Input/Input.tsx +++ b/src/client/components/ui/Input/Input.tsx @@ -13,16 +13,20 @@ interface IProps { onBlur?: (e: React.FocusEvent) => void; disabled?: boolean; value?: string; + readOnly?: boolean; } -export const Input = React.forwardRef(({ onChange, onBlur, name, label, placeholder, error, type = 'text', className, value, isInvalid, disabled }, ref) => ( +export const Input = React.forwardRef(({ onChange, onBlur, name, label, placeholder, error, type = 'text', className, value, isInvalid, disabled, readOnly }, ref) => (
{label && ( )} + {/* eslint-disable-next-line jsx-a11y/no-redundant-roles */} (({ onChange, onB ref={ref} className={clsx('form-control', { 'is-invalid is-invalid-lite': error || isInvalid })} placeholder={placeholder} + readOnly={readOnly} /> {error &&
{error}
}
diff --git a/src/client/modules/Settings/containers/SecurityContainer/SecurityContainer.test.tsx b/src/client/modules/Settings/containers/SecurityContainer/SecurityContainer.test.tsx new file mode 100644 index 00000000..c3a79c47 --- /dev/null +++ b/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('', () => { + it('should render', () => { + render(); + }); + + it('should prompt for password when enabling 2FA', async () => { + // arrange + render(); + 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(); + 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(); + 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(); + + 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(); + + 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(); + 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(); + + 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(); + 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(); + 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(); + 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(); + }); + }); +}); diff --git a/src/client/modules/Settings/containers/SecurityContainer/SecurityContainer.tsx b/src/client/modules/Settings/containers/SecurityContainer/SecurityContainer.tsx new file mode 100644 index 00000000..bba39c91 --- /dev/null +++ b/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 ( +
+
+

Scan this QR code with your authenticator app.

+ +
+
+

Or enter this key manually.

+ +
+
+

Enter the code from your authenticator app.

+ setTotpCode(e)} /> + +
+
+ ); + }; + + return ( +
+
+ +

Two-Factor Authentication

+
+

+ Two-factor authentication (2FA) adds an additional layer of security to your account. +
+ When enabled, you will be prompted to enter a code from your authenticator app when you log in. +

+ {!key && } + {getTotpUri.isLoading && ( +
+
+
+ )} + {renderSetupQr()} + setIsSetupTotpOpen(o)}> + + + Password needed + + +
{ + e.preventDefault(); + getTotpUri.mutate({ password }); + }} + > +

Your password is required to setup two-factor authentication.

+ setPassword(e.target.value)} placeholder="Password" /> + +
+
+
+
+ setIsDisableTotpOpen(o)}> + + + Password needed + + +
{ + e.preventDefault(); + disableTotp.mutate({ password }); + }} + > +

Your password is required to disable two-factor authentication.

+ setPassword(e.target.value)} placeholder="Password" /> + +
+
+
+
+
+ ); +}; diff --git a/src/client/modules/Settings/containers/SecurityContainer/index.ts b/src/client/modules/Settings/containers/SecurityContainer/index.ts new file mode 100644 index 00000000..384dfc56 --- /dev/null +++ b/src/client/modules/Settings/containers/SecurityContainer/index.ts @@ -0,0 +1 @@ +export { SecurityContainer } from './SecurityContainer'; diff --git a/src/client/modules/Settings/pages/SettingsPage/SettingsPage.tsx b/src/client/modules/Settings/pages/SettingsPage/SettingsPage.tsx index f5ef4a01..a147d11e 100644 --- a/src/client/modules/Settings/pages/SettingsPage/SettingsPage.tsx +++ b/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 = () => { Actions Settings + Security @@ -20,6 +22,9 @@ export const SettingsPage: NextPage = () => { + + +
diff --git a/src/server/services/auth/auth.service.ts b/src/server/services/auth/auth.service.ts index a027194c..1ba38397 100644 --- a/src/server/services/auth/auth.service.ts +++ b/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 }) => {