Skip to content

Commit 41aeaae

Browse files
authored
feat(core,experience): add disposable email validation (#7374)
* feat(core,experience): add disposable email validation add disposable email validation * chore(core): remove useless false return value remove useless false return value * refactor(core): optimize the error message optimize the error message
1 parent 4941483 commit 41aeaae

File tree

21 files changed

+157
-9
lines changed

21 files changed

+157
-9
lines changed

packages/console/src/pages/Security/Blocklist/BlocklistForm/index.tsx

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import FormCard from '@/components/FormCard';
1111
import MultiOptionInput from '@/components/MultiOptionInput';
1212
import UnsavedChangesAlertModal from '@/components/UnsavedChangesAlertModal';
1313
import { emailBlocklist } from '@/consts';
14+
import { isCloud } from '@/consts/env';
1415
import { latestProPlanId } from '@/consts/subscriptions';
1516
import FormField from '@/ds-components/FormField';
1617
import Switch from '@/ds-components/Switch';
@@ -94,13 +95,15 @@ function BlocklistForm({ formData }: Props) {
9495
}
9596
learnMoreLink={{ href: emailBlocklist }}
9697
>
97-
<FormField title="security.blocklist.disposable_email.title">
98-
<Switch
99-
disabled={isFreeTenant}
100-
label={t('blocklist.disposable_email.description')}
101-
{...register('blockDisposableAddresses')}
102-
/>
103-
</FormField>
98+
{isCloud && (
99+
<FormField title="security.blocklist.disposable_email.title">
100+
<Switch
101+
disabled={isFreeTenant}
102+
label={t('blocklist.disposable_email.description')}
103+
{...register('blockDisposableAddresses')}
104+
/>
105+
</FormField>
106+
)}
104107
<FormField title="security.blocklist.email_subaddressing.title">
105108
<Switch
106109
disabled={isFreeTenant}

packages/core/src/libraries/sign-in-experience/email-blocklist-policy.ts

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { emailOrEmailDomainRegex } from '@logto/core-kit';
22
import { type EmailBlocklistPolicy } from '@logto/schemas';
33
import { conditional, deduplicate } from '@silverhand/essentials';
4+
import { got } from 'got';
5+
import { z } from 'zod';
46

57
import { EnvSet } from '#src/env-set/index.js';
68
import RequestError from '#src/errors/RequestError/index.js';
@@ -50,12 +52,75 @@ export const parseEmailBlocklistPolicy = (
5052

5153
const { customBlocklist, ...rest } = emailBlocklistPolicy;
5254

55+
// BlockDisposableAddresses is not supported for OSS.
56+
if (rest.blockDisposableAddresses) {
57+
assertThat(
58+
EnvSet.values.isCloud,
59+
new RequestError('request.invalid_input', {
60+
details: 'blockDisposableAddresses is not supported in this environment',
61+
})
62+
);
63+
}
64+
5365
return {
5466
...rest,
5567
...conditional(customBlocklist && { customBlocklist: parseCustomBlocklist(customBlocklist) }),
5668
};
5769
};
5870

71+
const disposableEmailDomainValidationResponseGuard = z.object({
72+
isDisposable: z.boolean(),
73+
});
74+
75+
const validateDisposableEmailDomain = async (email: string) => {
76+
// TODO: Skip the validation for integration test for now
77+
if (EnvSet.values.isIntegrationTest) {
78+
return;
79+
}
80+
81+
try {
82+
assertThat(
83+
EnvSet.values.azureFunctionAppEndpoint,
84+
new Error('Environment variable AZURE_FUNCTION_APP_ENDPOINT is not set')
85+
);
86+
87+
const result = await got
88+
.post(
89+
new URL('/api/disposable-email-domain-validation', EnvSet.values.azureFunctionAppEndpoint),
90+
{
91+
json: {
92+
email,
93+
},
94+
headers: {
95+
'x-functions-key': EnvSet.values.azureFunctionAppKey,
96+
},
97+
}
98+
)
99+
.json<unknown>();
100+
101+
const { isDisposable } = disposableEmailDomainValidationResponseGuard.parse(result);
102+
103+
assertThat(
104+
!isDisposable,
105+
new RequestError({
106+
code: 'session.email_blocklist.email_not_allowed',
107+
status: 422,
108+
email,
109+
})
110+
);
111+
} catch (error: unknown) {
112+
if (error instanceof RequestError) {
113+
throw error;
114+
}
115+
116+
throw new RequestError({
117+
code: 'session.email_blocklist.disposable_email_validation_failed',
118+
status: 500,
119+
error,
120+
});
121+
}
122+
};
123+
59124
/**
60125
* Guard the email address is not in the sign-in experience blocklist. *
61126
*
@@ -80,7 +145,7 @@ export const validateEmailAgainstBlocklistPolicy = async (
80145

81146
// Guard disposable email domain if enabled
82147
if (EnvSet.values.isCloud && blockDisposableAddresses) {
83-
// TODO: call Azure function
148+
await validateDisposableEmailDomain(email);
84149
}
85150

86151
// Guard email subaddressing if enabled
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { type RequestErrorBody } from '@logto/schemas';
2+
import { useCallback, useMemo } from 'react';
3+
import { useNavigate } from 'react-router-dom';
4+
5+
import { usePromiseConfirmModal } from './use-confirm-modal';
6+
import { type ErrorHandlers } from './use-error-handler';
7+
8+
const useEmailBlockedErrorHandler = (): ErrorHandlers => {
9+
const navigate = useNavigate();
10+
const { show } = usePromiseConfirmModal();
11+
12+
const errorCallback = useCallback(
13+
async (error: RequestErrorBody) => {
14+
await show({
15+
type: 'alert',
16+
ModalContent: error.message,
17+
cancelText: 'action.got_it',
18+
});
19+
navigate(-1);
20+
},
21+
[navigate, show]
22+
);
23+
24+
return useMemo<ErrorHandlers>(
25+
() => ({
26+
'session.email_blocklist.email_not_allowed': errorCallback,
27+
'session.email_blocklist.email_subaddressing_not_allowed': errorCallback,
28+
}),
29+
[errorCallback]
30+
);
31+
};
32+
33+
export default useEmailBlockedErrorHandler;

packages/experience/src/hooks/use-submit-interaction-error-handler.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useMemo } from 'react';
22

33
import { type ContinueFlowInteractionEvent } from '@/types';
44

5+
import useEmailBlockedErrorHandler from './use-email-blocked-error-handler';
56
import { type ErrorHandlers } from './use-error-handler';
67
import useMfaErrorHandler, {
78
type Options as UseMfaVerificationErrorHandlerOptions,
@@ -36,13 +37,15 @@ const useSubmitInteractionErrorHandler = (
3637
...rest,
3738
});
3839
const mfaErrorHandler = useMfaErrorHandler({ replace });
40+
const emailBlockedErrorHandler = useEmailBlockedErrorHandler();
3941

4042
return useMemo(
4143
() => ({
44+
...emailBlockedErrorHandler,
4245
...requiredProfileErrorHandler,
4346
...mfaErrorHandler,
4447
}),
45-
[mfaErrorHandler, requiredProfileErrorHandler]
48+
[emailBlockedErrorHandler, mfaErrorHandler, requiredProfileErrorHandler]
4649
);
4750
};
4851

packages/phrases/src/locales/ar/errors/session.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ const session = {
4242
captcha_required: 'مطلوب التحقق من Captcha.',
4343
captcha_failed: 'فشل التحقق من Captcha.',
4444
email_blocklist: {
45+
/** UNTRANSLATED */
46+
disposable_email_validation_failed: 'Email address validation failed.',
4547
/** UNTRANSLATED */
4648
invalid_email: 'Invalid email address.',
4749
/** UNTRANSLATED */

packages/phrases/src/locales/de/errors/session.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ const session = {
5252
captcha_required: 'Captcha ist erforderlich.',
5353
captcha_failed: 'Captcha-Verifizierung fehlgeschlagen.',
5454
email_blocklist: {
55+
/** UNTRANSLATED */
56+
disposable_email_validation_failed: 'Email address validation failed.',
5557
/** UNTRANSLATED */
5658
invalid_email: 'Invalid email address.',
5759
/** UNTRANSLATED */

packages/phrases/src/locales/en/errors/session.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ const session = {
4747
captcha_required: 'Captcha is required.',
4848
captcha_failed: 'Captcha verification failed.',
4949
email_blocklist: {
50+
disposable_email_validation_failed: 'Email address validation failed.',
5051
invalid_email: 'Invalid email address.',
5152
email_subaddressing_not_allowed: 'Email subaddressing is not allowed.',
5253
email_not_allowed:

packages/phrases/src/locales/es/errors/session.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ const session = {
5353
captcha_required: 'Se requiere captcha.',
5454
captcha_failed: 'La verificación del captcha falló.',
5555
email_blocklist: {
56+
/** UNTRANSLATED */
57+
disposable_email_validation_failed: 'Email address validation failed.',
5658
/** UNTRANSLATED */
5759
invalid_email: 'Invalid email address.',
5860
/** UNTRANSLATED */

packages/phrases/src/locales/fr/errors/session.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ const session = {
5454
captcha_required: 'Le captcha est requis.',
5555
captcha_failed: 'La vérification du captcha a échoué.',
5656
email_blocklist: {
57+
/** UNTRANSLATED */
58+
disposable_email_validation_failed: 'Email address validation failed.',
5759
/** UNTRANSLATED */
5860
invalid_email: 'Invalid email address.',
5961
/** UNTRANSLATED */

packages/phrases/src/locales/it/errors/session.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ const session = {
5050
captcha_required: 'È richiesto il Captcha.',
5151
captcha_failed: 'La verifica del Captcha non è riuscita.',
5252
email_blocklist: {
53+
/** UNTRANSLATED */
54+
disposable_email_validation_failed: 'Email address validation failed.',
5355
/** UNTRANSLATED */
5456
invalid_email: 'Invalid email address.',
5557
/** UNTRANSLATED */

0 commit comments

Comments
 (0)