Selaa lähdekoodia

feat: add 2fa form on login if user has it enabled

Nicolas Meienberger 2 vuotta sitten
vanhempi
commit
d841c43b77

+ 1 - 0
src/client/mocks/handlers.ts

@@ -61,6 +61,7 @@ export const handlers = [
     path: ['auth', 'me'],
     path: ['auth', 'me'],
     type: 'query',
     type: 'query',
     response: {
     response: {
+      totp_enabled: false,
       id: faker.datatype.number(),
       id: faker.datatype.number(),
       username: faker.internet.userName(),
       username: faker.internet.userName(),
     },
     },

+ 2 - 2
src/client/modules/Auth/components/LoginForm/LoginForm.tsx

@@ -36,11 +36,11 @@ export const LoginForm: React.FC<IProps> = ({ loading, onSubmit }) => {
   return (
   return (
     <form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
     <form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
       <h2 className="h2 text-center mb-3">Login to your account</h2>
       <h2 className="h2 text-center mb-3">Login to your account</h2>
-      <Input {...register('email')} label="Email address" error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder="you@example.com" />
+      <Input {...register('email')} name="email" label="Email address" error={errors.email?.message} disabled={loading} type="email" className="mb-3" placeholder="you@example.com" />
       <span className="form-label-description">
       <span className="form-label-description">
         <Link href="/reset-password">Forgot password?</Link>
         <Link href="/reset-password">Forgot password?</Link>
       </span>
       </span>
-      <Input {...register('password')} label="Password" error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder="Your password" />
+      <Input {...register('password')} name="password" label="Password" error={errors.password?.message} disabled={loading} type="password" className="mb-3" placeholder="Your password" />
       <Button disabled={isDisabled} loading={loading} type="submit" className="btn btn-primary w-100">
       <Button disabled={isDisabled} loading={loading} type="submit" className="btn btn-primary w-100">
         Login
         Login
       </Button>
       </Button>

+ 32 - 0
src/client/modules/Auth/components/TotpForm/TotpForm.tsx

@@ -0,0 +1,32 @@
+import { Button } from '@/components/ui/Button';
+import { OtpInput } from '@/components/ui/OtpInput';
+import React from 'react';
+
+type Props = {
+  onSubmit: (totpCode: string) => void;
+  loading?: boolean;
+};
+
+export const TotpForm = (props: Props) => {
+  const { onSubmit, loading } = props;
+  const [totpCode, setTotpCode] = React.useState('');
+
+  return (
+    <form
+      onSubmit={(e) => {
+        setTotpCode('');
+        e.preventDefault();
+        onSubmit(totpCode);
+      }}
+    >
+      <div className="flex items-center justify-center">
+        <h3 className="">Two-factor authentication</h3>
+        <p className="text-sm text-gray-500">Enter the code from your authenticator app</p>
+        <OtpInput valueLength={6} value={totpCode} onChange={(o) => setTotpCode(o)} />
+        <Button disabled={totpCode.trim().length < 6} loading={loading} type="submit" className="mt-3">
+          Confirm
+        </Button>
+      </div>
+    </form>
+  );
+};

+ 1 - 0
src/client/modules/Auth/components/TotpForm/index.ts

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

+ 113 - 6
src/client/modules/Auth/containers/LoginContainer/LoginContainer.test.tsx

@@ -28,8 +28,8 @@ describe('Test: LoginContainer', () => {
     // Arrange
     // Arrange
     render(<LoginContainer />);
     render(<LoginContainer />);
     const loginButton = screen.getByRole('button', { name: 'Login' });
     const loginButton = screen.getByRole('button', { name: 'Login' });
-    const emailInput = screen.getByLabelText('Email address');
-    const passwordInput = screen.getByLabelText('Password');
+    const emailInput = screen.getByRole('textbox', { name: 'email' });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
 
 
     // Act
     // Act
     fireEvent.change(emailInput, { target: { value: faker.internet.email() } });
     fireEvent.change(emailInput, { target: { value: faker.internet.email() } });
@@ -49,8 +49,8 @@ describe('Test: LoginContainer', () => {
 
 
     // Act
     // Act
     const loginButton = screen.getByRole('button', { name: 'Login' });
     const loginButton = screen.getByRole('button', { name: 'Login' });
-    const emailInput = screen.getByLabelText('Email address');
-    const passwordInput = screen.getByLabelText('Password');
+    const emailInput = screen.getByRole('textbox', { name: 'email' });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
 
 
     fireEvent.change(emailInput, { target: { value: email } });
     fireEvent.change(emailInput, { target: { value: email } });
     fireEvent.change(passwordInput, { target: { value: password } });
     fireEvent.change(passwordInput, { target: { value: password } });
@@ -70,8 +70,8 @@ describe('Test: LoginContainer', () => {
 
 
     // Act
     // Act
     const loginButton = screen.getByRole('button', { name: 'Login' });
     const loginButton = screen.getByRole('button', { name: 'Login' });
-    const emailInput = screen.getByLabelText('Email address');
-    const passwordInput = screen.getByLabelText('Password');
+    const emailInput = screen.getByRole('textbox', { name: 'email' });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
 
 
     fireEvent.change(emailInput, { target: { value: 'test@test.com' } });
     fireEvent.change(emailInput, { target: { value: 'test@test.com' } });
     fireEvent.change(passwordInput, { target: { value: 'test' } });
     fireEvent.change(passwordInput, { target: { value: 'test' } });
@@ -86,4 +86,111 @@ describe('Test: LoginContainer', () => {
     const token = localStorage.getItem('token');
     const token = localStorage.getItem('token');
     expect(token).toBeNull();
     expect(token).toBeNull();
   });
   });
+
+  it('should show totp form if totpSessionId is returned', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const password = faker.internet.password();
+    const totpSessionId = faker.datatype.uuid();
+    server.use(
+      getTRPCMock({
+        path: ['auth', 'login'],
+        type: 'mutation',
+        response: { totpSessionId },
+      }),
+    );
+    render(<LoginContainer />);
+
+    // act
+    const loginButton = screen.getByRole('button', { name: 'Login' });
+    const emailInput = screen.getByRole('textbox', { name: 'email' });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+
+    fireEvent.change(emailInput, { target: { value: email } });
+    fireEvent.change(passwordInput, { target: { value: password } });
+    fireEvent.click(loginButton);
+
+    // assert
+    await waitFor(() => {
+      expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
+    });
+  });
+
+  it('should show error message if totp code is invalid', async () => {
+    // arrange
+    const { result } = renderHook(() => useToastStore());
+    const email = faker.internet.email();
+    const password = faker.internet.password();
+    const totpSessionId = faker.datatype.uuid();
+    server.use(getTRPCMock({ path: ['auth', 'login'], type: 'mutation', response: { totpSessionId } }));
+    server.use(getTRPCMockError({ path: ['auth', 'verifyTotp'], type: 'mutation', status: 500, message: 'Invalid totp code' }));
+    render(<LoginContainer />);
+
+    // act
+    const loginButton = screen.getByRole('button', { name: 'Login' });
+    const emailInput = screen.getByRole('textbox', { name: 'email' });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+
+    fireEvent.change(emailInput, { target: { value: email } });
+    fireEvent.change(passwordInput, { target: { value: password } });
+    fireEvent.click(loginButton);
+
+    await waitFor(() => {
+      expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
+    });
+
+    const totpInputs = screen.getAllByRole('textbox', { name: /digit/ });
+
+    totpInputs.forEach((input, index) => {
+      fireEvent.change(input, { target: { value: index } });
+    });
+
+    const totpSubmitButton = screen.getByRole('button', { name: 'Confirm' });
+    fireEvent.click(totpSubmitButton);
+
+    // assert
+    await waitFor(() => {
+      expect(result.current.toasts).toHaveLength(1);
+      expect(result.current.toasts[0].description).toEqual('Invalid totp code');
+      expect(result.current.toasts[0].status).toEqual('error');
+    });
+  });
+
+  it('should add token in localStorage if totp code is valid', async () => {
+    // arrange
+    const email = faker.internet.email();
+    const password = faker.internet.password();
+    const totpSessionId = faker.datatype.uuid();
+    const token = faker.datatype.uuid();
+    server.use(getTRPCMock({ path: ['auth', 'login'], type: 'mutation', response: { totpSessionId } }));
+    server.use(getTRPCMock({ path: ['auth', 'verifyTotp'], type: 'mutation', response: { token } }));
+    render(<LoginContainer />);
+
+    // act
+    const loginButton = screen.getByRole('button', { name: 'Login' });
+    const emailInput = screen.getByRole('textbox', { name: 'email' });
+    const passwordInput = screen.getByRole('textbox', { name: 'password' });
+
+    fireEvent.change(emailInput, { target: { value: email } });
+    fireEvent.change(passwordInput, { target: { value: password } });
+    fireEvent.click(loginButton);
+
+    await waitFor(() => {
+      expect(screen.getByText('Two-factor authentication')).toBeInTheDocument();
+    });
+
+    const totpInputs = screen.getAllByRole('textbox', { name: /digit/ });
+
+    totpInputs.forEach((input, index) => {
+      fireEvent.change(input, { target: { value: index } });
+    });
+
+    const totpSubmitButton = screen.getByRole('button', { name: 'Confirm' });
+    fireEvent.click(totpSubmitButton);
+
+    // assert
+    await waitFor(() => {
+      expect(localStorage.getItem('token')).toEqual(token);
+    });
+  });
 });
 });

+ 23 - 2
src/client/modules/Auth/containers/LoginContainer/LoginContainer.tsx

@@ -1,13 +1,15 @@
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
-import React from 'react';
+import React, { useState } from 'react';
 import { useToastStore } from '../../../../state/toastStore';
 import { useToastStore } from '../../../../state/toastStore';
 import { trpc } from '../../../../utils/trpc';
 import { trpc } from '../../../../utils/trpc';
 import { AuthFormLayout } from '../../components/AuthFormLayout';
 import { AuthFormLayout } from '../../components/AuthFormLayout';
 import { LoginForm } from '../../components/LoginForm';
 import { LoginForm } from '../../components/LoginForm';
+import { TotpForm } from '../../components/TotpForm';
 
 
 type FormValues = { email: string; password: string };
 type FormValues = { email: string; password: string };
 
 
 export const LoginContainer: React.FC = () => {
 export const LoginContainer: React.FC = () => {
+  const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
   const router = useRouter();
   const router = useRouter();
   const { addToast } = useToastStore();
   const { addToast } = useToastStore();
   const utils = trpc.useContext();
   const utils = trpc.useContext();
@@ -16,6 +18,21 @@ export const LoginContainer: React.FC = () => {
       localStorage.removeItem('token');
       localStorage.removeItem('token');
       addToast({ title: 'Login error', description: e.message, status: 'error' });
       addToast({ title: 'Login error', description: e.message, status: 'error' });
     },
     },
+    onSuccess: (data) => {
+      if (data.totpSessionId) {
+        setTotpSessionId(data.totpSessionId);
+      } else if (data.token) {
+        localStorage.setItem('token', data.token);
+        utils.auth.me.invalidate();
+        router.push('/');
+      }
+    },
+  });
+
+  const verifyTotp = trpc.auth.verifyTotp.useMutation({
+    onError: (e) => {
+      addToast({ title: 'Error', description: e.message, status: 'error' });
+    },
     onSuccess: (data) => {
     onSuccess: (data) => {
       localStorage.setItem('token', data.token);
       localStorage.setItem('token', data.token);
       utils.auth.me.invalidate();
       utils.auth.me.invalidate();
@@ -29,7 +46,11 @@ export const LoginContainer: React.FC = () => {
 
 
   return (
   return (
     <AuthFormLayout>
     <AuthFormLayout>
-      <LoginForm onSubmit={handlerSubmit} loading={login.isLoading} />
+      {totpSessionId ? (
+        <TotpForm onSubmit={(o) => verifyTotp.mutate({ totpCode: o, totpSessionId })} loading={verifyTotp.isLoading} />
+      ) : (
+        <LoginForm onSubmit={handlerSubmit} loading={login.isLoading} />
+      )}
     </AuthFormLayout>
     </AuthFormLayout>
   );
   );
 };
 };