0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-27 21:39:16 -05:00

feat(experience): block email sign-in and register (#4886)

* feat(experience): block email sign-in and register

block email sign-in and register if SSO is enabled for that domain address

* test(experience): add ut

add ut
This commit is contained in:
simeng-li 2023-11-17 15:41:35 +08:00 committed by GitHub
parent 2c1cf66c6b
commit e68637f12d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 388 additions and 108 deletions

View file

@ -49,6 +49,7 @@ const App = () => {
<BrowserRouter>
<PageContextProvider>
<SettingsProvider>
<SingleSignOnContextProvider>
<AppBoundary>
<AppInsightsBoundary cloudRole="ui">
<Routes>
@ -114,7 +115,7 @@ const App = () => {
{/* Single sign on */}
{isDevelopmentFeaturesEnabled && (
<Route path={singleSignOnPath} element={<SingleSignOnContextProvider />}>
<Route path={singleSignOnPath}>
<Route path="email" element={<SingleSignOnEmail />} />
<Route path="connectors" element={<SingleSignOnConnectors />} />
</Route>
@ -125,6 +126,7 @@ const App = () => {
</Routes>
</AppInsightsBoundary>
</AppBoundary>
</SingleSignOnContextProvider>
</SettingsProvider>
</PageContextProvider>
</BrowserRouter>

View file

@ -1,13 +1,16 @@
import { type SsoConnectorMetadata } from '@logto/schemas';
import { useEffect, useMemo, useState } from 'react';
import { Outlet } from 'react-router-dom';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import useSessionStorage, { StorageKeys } from '@/hooks/use-session-storages';
import { useSieMethods } from '@/hooks/use-sie';
import SingleSignOnContext, { type SingleSignOnContextType } from './SingleSignOnContext';
const SingleSignOnContextProvider = () => {
type Props = {
children: ReactNode;
};
const SingleSignOnContextProvider = ({ children }: Props) => {
const { ssoConnectors } = useSieMethods();
const { get, set, remove } = useSessionStorage();
const [email, setEmail] = useState<string | undefined>(get(StorageKeys.SsoEmail));
@ -51,7 +54,7 @@ const SingleSignOnContextProvider = () => {
return (
<SingleSignOnContext.Provider value={singleSignOnContext}>
<Outlet />
{children}
</SingleSignOnContext.Provider>
);
};

View file

@ -5,10 +5,11 @@ import { useNavigate } from 'react-router-dom';
import SingleSignOnContext from '@/Providers/SingleSignOnContextProvider/SingleSignOnContext';
import { getSingleSignOnConnectors } from '@/apis/single-sign-on';
import { singleSignOnPath } from '@/constants/env';
import useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler';
const useOnSubmit = () => {
const useCheckSingleSignOn = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const request = useApi(getSingleSignOnConnectors);
@ -29,6 +30,11 @@ const useOnSubmit = () => {
setSsoConnectors([]);
}, [setEmail, setSsoConnectors]);
/**
* Check if the email is registered with any SSO connectors
* @param {string} email
* @returns {Promise<boolean>} - true if the email is registered with any SSO connectors
*/
const onSubmit = useCallback(
async (email: string) => {
clearContext();
@ -57,7 +63,8 @@ const useOnSubmit = () => {
setSsoConnectors(connectors);
setEmail(email);
navigate('../connectors');
navigate(`/${singleSignOnPath}/connectors`);
return true;
},
[
availableSsoConnectorsMap,
@ -78,4 +85,4 @@ const useOnSubmit = () => {
};
};
export default useOnSubmit;
export default useCheckSingleSignOn;

View file

@ -1,18 +1,22 @@
import { SignInIdentifier } from '@logto/schemas';
import { SignInIdentifier, type SsoConnectorMetadata } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { fireEvent, act, waitFor } from '@testing-library/react';
import ConfirmModalProvider from '@/Providers/ConfirmModalProvider';
import SingleSignOnContextProvider from '@/Providers/SingleSignOnContextProvider';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import { mockSignInExperienceSettings, mockSsoConnectors } from '@/__mocks__/logto';
import { registerWithUsernamePassword } from '@/apis/interaction';
import { sendVerificationCodeApi } from '@/apis/utils';
import { singleSignOnPath } from '@/constants/env';
import { UserFlow } from '@/types';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
import IdentifierRegisterForm from '.';
const mockedNavigate = jest.fn();
const getSingleSignOnConnectorsMock = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@ -33,11 +37,25 @@ jest.mock('@/apis/interaction', () => ({
registerWithUsernamePassword: jest.fn(async () => ({})),
}));
const renderForm = (signUpMethods: SignInIdentifier[] = [SignInIdentifier.Username]) => {
jest.mock('@/apis/single-sign-on', () => ({
getSingleSignOnConnectors: () => getSingleSignOnConnectorsMock(),
}));
const renderForm = (
signUpMethods: SignInIdentifier[] = [SignInIdentifier.Username],
ssoConnectors: SsoConnectorMetadata[] = []
) => {
return renderWithPageContext(
<SettingsProvider>
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
ssoConnectors,
}}
>
<ConfirmModalProvider>
<SingleSignOnContextProvider>
<IdentifierRegisterForm signUpMethods={signUpMethods} />
</SingleSignOnContextProvider>
</ConfirmModalProvider>
</SettingsProvider>
);
@ -282,4 +300,85 @@ describe('<IdentifierRegisterForm />', () => {
});
}
);
describe('single sign on register form', () => {
const email = 'foo@email.com';
it('should not call check single sign-on connector when no single sign-on connector is enabled', async () => {
const { getByText, container } = renderForm([SignInIdentifier.Email]);
const submitButton = getByText('action.create_account');
const emailInput = container.querySelector('input[name="identifier"]');
const termsButton = getByText('description.agree_with_terms');
assert(emailInput, new Error('username input not found'));
act(() => {
fireEvent.change(emailInput, { target: { value: email } });
fireEvent.click(termsButton);
});
act(() => {
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(getSingleSignOnConnectorsMock).not.toBeCalled();
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, {
email,
});
});
});
it('should call check single sign-on connector when the identifier is email, but process to password sign-in if no sso connector is matched', async () => {
getSingleSignOnConnectorsMock.mockRejectedValueOnce([]);
const { getByText, container } = renderForm([SignInIdentifier.Email], mockSsoConnectors);
const submitButton = getByText('action.create_account');
const emailInput = container.querySelector('input[name="identifier"]');
const termsButton = getByText('description.agree_with_terms');
assert(emailInput, new Error('username input not found'));
act(() => {
fireEvent.change(emailInput, { target: { value: email } });
fireEvent.click(termsButton);
});
act(() => {
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(getSingleSignOnConnectorsMock).toBeCalled();
expect(sendVerificationCodeApi).toBeCalledWith(UserFlow.Register, {
email,
});
});
});
it('should call check single sign-on connector when the identifier is email, but process to password sign-in if no sso connector is matched', async () => {
getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id));
const { getByText, container } = renderForm([SignInIdentifier.Email], mockSsoConnectors);
const submitButton = getByText('action.create_account');
const emailInput = container.querySelector('input[name="identifier"]');
const termsButton = getByText('description.agree_with_terms');
assert(emailInput, new Error('username input not found'));
act(() => {
fireEvent.change(emailInput, { target: { value: email } });
fireEvent.click(termsButton);
});
act(() => {
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(getSingleSignOnConnectorsMock).toBeCalled();
expect(mockedNavigate).toBeCalledWith(`/${singleSignOnPath}/connectors`);
});
});
});
});

View file

@ -1,14 +1,17 @@
import { SignInIdentifier } from '@logto/schemas';
import { useCallback } from 'react';
import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import { useSieMethods } from '@/hooks/use-sie';
import { UserFlow } from '@/types';
import useRegisterWithUsername from './use-register-with-username';
// TODO: extract the errorMessage and clear method from useRegisterWithUsername and useSendVerificationCode
const useOnSubmit = () => {
const { ssoConnectors } = useSieMethods();
const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn();
const {
errorMessage: usernameRegisterErrorMessage,
clearErrorMessage: clearUsernameRegisterErrorMessage,
@ -34,9 +37,18 @@ const useOnSubmit = () => {
return;
}
// Check if the email is registered with any SSO connectors. If the email is registered with any SSO connectors, we should not proceed to the next step
if (identifier === SignInIdentifier.Email && ssoConnectors.length > 0) {
const result = await checkSingleSignOn(value);
if (result) {
return;
}
}
await sendVerificationCode({ identifier, value });
},
[registerWithUsername, sendVerificationCode]
[checkSingleSignOn, registerWithUsername, sendVerificationCode, ssoConnectors.length]
);
return {

View file

@ -1,11 +1,18 @@
import type { SignIn } from '@logto/schemas';
import type { SignIn, SsoConnectorMetadata } from '@logto/schemas';
import { SignInIdentifier } from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import { fireEvent, act, waitFor } from '@testing-library/react';
import SingleSignOnContextProvider from '@/Providers/SingleSignOnContextProvider';
import renderWithPageContext from '@/__mocks__/RenderWithPageContext';
import { mockSignInMethodSettingsTestCases } from '@/__mocks__/logto';
import SettingsProvider from '@/__mocks__/RenderWithPageContext/SettingsProvider';
import {
mockSignInMethodSettingsTestCases,
mockSignInExperienceSettings,
mockSsoConnectors,
} from '@/__mocks__/logto';
import { sendVerificationCodeApi } from '@/apis/utils';
import { singleSignOnPath } from '@/constants/env';
import { UserFlow } from '@/types';
import { getDefaultCountryCallingCode } from '@/utils/country-code';
@ -18,6 +25,7 @@ jest.mock('i18next', () => ({
}));
const mockedNavigate = jest.fn();
const getSingleSignOnConnectorsMock = jest.fn();
jest.mock('@/apis/utils', () => ({
sendVerificationCodeApi: jest.fn(),
@ -28,12 +36,27 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockedNavigate,
}));
jest.mock('@/apis/single-sign-on', () => ({
getSingleSignOnConnectors: () => getSingleSignOnConnectorsMock(),
}));
const username = 'foo';
const email = 'foo@email.com';
const phone = '8573333333';
const renderForm = (signInMethods: SignIn['methods']) =>
renderWithPageContext(<IdentifierSignInForm signInMethods={signInMethods} />);
const renderForm = (signInMethods: SignIn['methods'], ssoConnectors: SsoConnectorMetadata[] = []) =>
renderWithPageContext(
<SettingsProvider
settings={{
...mockSignInExperienceSettings,
ssoConnectors,
}}
>
<SingleSignOnContextProvider>
<IdentifierSignInForm signInMethods={signInMethods} />
</SingleSignOnContextProvider>
</SettingsProvider>
);
describe('IdentifierSignInForm', () => {
afterEach(() => {
@ -152,4 +175,116 @@ describe('IdentifierSignInForm', () => {
});
}
);
describe('email single sign-on tests', () => {
it('should not call check single sign-on connector when the identifier is not email', async () => {
const { getByText, container } = renderForm(
mockSignInMethodSettingsTestCases[0]!,
mockSsoConnectors
);
const inputField = container.querySelector('input[name="identifier"]');
const submitButton = getByText('action.sign_in');
if (inputField) {
act(() => {
fireEvent.change(inputField, { target: { value: username } });
});
}
act(() => {
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(getSingleSignOnConnectorsMock).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: 'password' },
{ state: { identifier: SignInIdentifier.Username, value: username } }
);
});
});
it('should not call check single sign-on connector when no single sign-on connector is enabled', async () => {
const { getByText, container } = renderForm(mockSignInMethodSettingsTestCases[0]!);
const inputField = container.querySelector('input[name="identifier"]');
const submitButton = getByText('action.sign_in');
if (inputField) {
act(() => {
fireEvent.change(inputField, { target: { value: email } });
});
}
act(() => {
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(getSingleSignOnConnectorsMock).not.toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: 'password' },
{ state: { identifier: SignInIdentifier.Email, value: email } }
);
});
});
it('should call check single sign-on connector when the identifier is email, but process to password sign-in if no sso connector is matched', async () => {
getSingleSignOnConnectorsMock.mockRejectedValueOnce([]);
const { getByText, container } = renderForm(
mockSignInMethodSettingsTestCases[0]!,
mockSsoConnectors
);
const inputField = container.querySelector('input[name="identifier"]');
const submitButton = getByText('action.sign_in');
if (inputField) {
act(() => {
fireEvent.change(inputField, { target: { value: email } });
});
}
act(() => {
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(getSingleSignOnConnectorsMock).toBeCalled();
expect(mockedNavigate).toBeCalledWith(
{ pathname: 'password' },
{ state: { identifier: SignInIdentifier.Email, value: email } }
);
});
});
it('should call check single sign-on connector when the identifier is email, and process to single sign-on if a sso connector is matched', async () => {
getSingleSignOnConnectorsMock.mockResolvedValueOnce(mockSsoConnectors.map(({ id }) => id));
const { getByText, container } = renderForm(
mockSignInMethodSettingsTestCases[0]!,
mockSsoConnectors
);
const inputField = container.querySelector('input[name="identifier"]');
const submitButton = getByText('action.sign_in');
if (inputField) {
act(() => {
fireEvent.change(inputField, { target: { value: email } });
});
}
act(() => {
fireEvent.submit(submitButton);
});
await waitFor(() => {
expect(getSingleSignOnConnectorsMock).toBeCalled();
expect(mockedNavigate).toBeCalledWith(`/${singleSignOnPath}/connectors`);
});
});
});
});

View file

@ -3,11 +3,15 @@ import { SignInIdentifier } from '@logto/schemas';
import { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import useCheckSingleSignOn from '@/hooks/use-check-single-sign-on';
import useSendVerificationCode from '@/hooks/use-send-verification-code';
import { useSieMethods } from '@/hooks/use-sie';
import { UserFlow } from '@/types';
const useOnSubmit = (signInMethods: SignIn['methods']) => {
const navigate = useNavigate();
const { ssoConnectors } = useSieMethods();
const { onSubmit: checkSingleSignOn } = useCheckSingleSignOn();
const signInWithPassword = useCallback(
(identifier: SignInIdentifier, value: string) => {
@ -27,7 +31,8 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
onSubmit: sendVerificationCode,
} = useSendVerificationCode(UserFlow.SignIn);
const onSubmit = async (identifier: SignInIdentifier, value: string) => {
const onSubmit = useCallback(
async (identifier: SignInIdentifier, value: string) => {
const method = signInMethods.find((method) => method.identifier === identifier);
if (!method) {
@ -42,6 +47,15 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
return;
}
// Check if the email is registered with any SSO connectors. If the email is registered with any SSO connectors, we should not proceed to the next step
if (identifier === SignInIdentifier.Email && ssoConnectors.length > 0) {
const result = await checkSingleSignOn(value);
if (result) {
return;
}
}
if (password && (isPasswordPrimary || !verificationCode)) {
signInWithPassword(identifier, value);
@ -51,7 +65,15 @@ const useOnSubmit = (signInMethods: SignIn['methods']) => {
if (verificationCode) {
await sendVerificationCode({ identifier, value });
}
};
},
[
checkSingleSignOn,
sendVerificationCode,
signInMethods,
signInWithPassword,
ssoConnectors.length,
]
);
return {
errorMessage,

View file

@ -9,10 +9,10 @@ import ErrorMessage from '@/components/ErrorMessage';
import SmartInputField, {
type IdentifierInputValue,
} from '@/components/InputFields/SmartInputField';
import useOnSubmit from '@/hooks/use-check-single-sign-on';
import { getGeneralIdentifierErrorMessage, validateIdentifierField } from '@/utils/form';
import * as styles from './index.module.scss';
import useOnSubmit from './use-on-submit';
type FormState = {
identifier: IdentifierInputValue;