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

This commit is contained in:
Nicolas Meienberger 2023-04-07 20:01:54 +02:00
parent 274d5e4a9a
commit 2e50af2a58
6 changed files with 172 additions and 10 deletions

View file

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

View file

@ -36,11 +36,11 @@ export const LoginForm: React.FC<IProps> = ({ loading, onSubmit }) => {
return (
<form className="flex flex-col" onSubmit={handleSubmit(onSubmit)}>
<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">
<Link href="/reset-password">Forgot password?</Link>
</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">
Login
</Button>

View file

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

View file

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

View file

@ -28,8 +28,8 @@ describe('Test: LoginContainer', () => {
// Arrange
render(<LoginContainer />);
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
fireEvent.change(emailInput, { target: { value: faker.internet.email() } });
@ -49,8 +49,8 @@ describe('Test: LoginContainer', () => {
// Act
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(passwordInput, { target: { value: password } });
@ -70,8 +70,8 @@ describe('Test: LoginContainer', () => {
// Act
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(passwordInput, { target: { value: 'test' } });
@ -86,4 +86,111 @@ describe('Test: LoginContainer', () => {
const token = localStorage.getItem('token');
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);
});
});
});

View file

@ -1,13 +1,15 @@
import { useRouter } from 'next/router';
import React from 'react';
import React, { useState } from 'react';
import { useToastStore } from '../../../../state/toastStore';
import { trpc } from '../../../../utils/trpc';
import { AuthFormLayout } from '../../components/AuthFormLayout';
import { LoginForm } from '../../components/LoginForm';
import { TotpForm } from '../../components/TotpForm';
type FormValues = { email: string; password: string };
export const LoginContainer: React.FC = () => {
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
const router = useRouter();
const { addToast } = useToastStore();
const utils = trpc.useContext();
@ -16,6 +18,21 @@ export const LoginContainer: React.FC = () => {
localStorage.removeItem('token');
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) => {
localStorage.setItem('token', data.token);
utils.auth.me.invalidate();
@ -29,7 +46,11 @@ export const LoginContainer: React.FC = () => {
return (
<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>
);
};