feat: add support for blocking disposable email addresses

- Introduced a new exception for disposable email addresses.
- Updated user account creation and email handling to check against a list of disposable email domains.
- Added a new API endpoint to enable or disable disposable email checks for projects.
- Updated project model to include disposable email settings.
- Configured loading of disposable email domains from a separate configuration file.
This commit is contained in:
Eldad Fux
2025-10-13 23:43:54 +01:00
parent 3e47f9442f
commit ff06920e24
8 changed files with 103 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
<?php
return [
'0-mail.com' => true,
'027168.com' => true,
'062e.com' => true,
'0815.ru' => true,
'0815.su' => true,
'0845.ru' => true,
'0box.eu' => true,
'0cd.cn' => true,
'10minutemail.com' => true,
'tempmail.org' => true,
'guerrillamail.com' => true,
'mailinator.com' => true,
'temp-mail.org' => true,
'throwaway.email' => true,
'yopmail.com' => true,
'maildrop.cc' => true,
'getnada.com' => true,
'tempail.com' => true,
];
+5
View File
@@ -221,6 +221,11 @@ return [
'description' => 'A user with the same email already exists in the current project.',
'code' => 409,
],
Exception::USER_EMAIL_DISPOSABLE => [
'name' => Exception::USER_EMAIL_DISPOSABLE,
'description' => 'Disposable email addresses are not allowed. Please use a permanent email address.',
'code' => 400,
],
Exception::USER_PASSWORD_MISMATCH => [
'name' => Exception::USER_PASSWORD_MISMATCH,
'description' => 'Passwords do not match. Please check the password and confirm password.',
+32
View File
@@ -369,6 +369,14 @@ App::post('/v1/account')
}
}
if ($project->getAttribute('auths', [])['disposableEmails'] ?? false) {
$disposableEmails = Config::getParam('disposableEmails', []);
$emailDomain = substr(strrchr($email, "@"), 1);
if (isset($disposableEmails[$emailDomain])) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
}
$hooks->trigger('passwordValidator', [$dbForProject, $project, $password, &$user, true]);
$passwordHistory = $project->getAttribute('auths', [])['passwordHistory'] ?? 0;
@@ -1967,6 +1975,14 @@ App::post('/v1/account/tokens/magic-url')
throw new Exception(Exception::USER_EMAIL_ALREADY_EXISTS);
}
if ($project->getAttribute('auths', [])['disposableEmails'] ?? false) {
$disposableEmails = Config::getParam('disposableEmails', []);
$emailDomain = substr(strrchr($email, "@"), 1);
if (isset($disposableEmails[$emailDomain])) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
}
$userId = $userId === 'unique()' ? ID::unique() : $userId;
$user->setAttributes([
@@ -2217,6 +2233,14 @@ App::post('/v1/account/tokens/email')
throw new Exception(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */
}
if ($project->getAttribute('auths', [])['disposableEmails'] ?? false) {
$disposableEmails = Config::getParam('disposableEmails', []);
$emailDomain = substr(strrchr($email, "@"), 1);
if (isset($disposableEmails[$emailDomain])) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
}
$userId = $userId === 'unique()' ? ID::unique() : $userId;
$user->setAttributes([
@@ -3050,6 +3074,14 @@ App::patch('/v1/account/email')
throw new Exception(Exception::GENERAL_BAD_REQUEST); /** Return a generic bad request to prevent exposing existing accounts */
}
if ($project->getAttribute('auths', [])['disposableEmails'] ?? false) {
$disposableEmails = Config::getParam('disposableEmails', []);
$emailDomain = substr(strrchr($email, "@"), 1);
if (isset($disposableEmails[$emailDomain])) {
throw new Exception(Exception::USER_EMAIL_DISPOSABLE);
}
}
$user
->setAttribute('email', $email)
->setAttribute('emailVerification', false) // After this user needs to confirm mail again
+39
View File
@@ -120,6 +120,7 @@ App::post('/v1/projects')
'passwordDictionary' => false,
'duration' => Auth::TOKEN_EXPIRATION_LOGIN_LONG,
'personalDataCheck' => false,
'disposableEmails' => false,
'mockNumbers' => [],
'sessionAlerts' => false,
'membershipsUserName' => false,
@@ -1021,6 +1022,44 @@ App::patch('/v1/projects/:projectId/auth/personal-data')
$response->dynamic($project, Response::MODEL_PROJECT);
});
App::patch('/v1/projects/:projectId/auth/disposable-emails')
->desc('Update disposable emails check')
->groups(['api', 'projects'])
->label('scope', 'projects.write')
->label('sdk', new Method(
namespace: 'projects',
group: 'auth',
name: 'updateDisposableEmails',
description: '/docs/references/projects/update-disposable-emails.md',
auth: [AuthType::ADMIN],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_OK,
model: Response::MODEL_PROJECT,
)
]
))
->param('projectId', '', new UID(), 'Project unique ID.')
->param('enabled', false, new Boolean(false), 'Set whether or not to block disposable email addresses. Default is false.')
->inject('response')
->inject('dbForPlatform')
->action(function (string $projectId, bool $enabled, Response $response, Database $dbForPlatform) {
$project = $dbForPlatform->getDocument('projects', $projectId);
if ($project->isEmpty()) {
throw new Exception(Exception::PROJECT_NOT_FOUND);
}
$auths = $project->getAttribute('auths', []);
$auths['disposableEmails'] = $enabled;
$dbForPlatform->updateDocument('projects', $project->getId(), $project
->setAttribute('auths', $auths));
$response->dynamic($project, Response::MODEL_PROJECT);
});
App::patch('/v1/projects/:projectId/auth/max-sessions')
->desc('Update project user sessions limit')
->groups(['api', 'projects'])
+2
View File
@@ -97,6 +97,7 @@ function createUser(string $hash, mixed $hashOptions, string $userId, ?string $e
}
}
$password = (!empty($password)) ? ($hash === 'plaintext' ? Auth::passwordHash($password, $hash, $hashOptionsObject) : $password) : null;
$user = new Document([
'$id' => $userId,
@@ -1324,6 +1325,7 @@ App::patch('/v1/users/:userId/password')
}
}
if (\strlen($password) === 0) {
$user
->setAttribute('password', '')
+1
View File
@@ -40,3 +40,4 @@ Config::load('storage-outputs', __DIR__ . '/../config/storage/outputs.php');
Config::load('specifications', __DIR__ . '/../config/specifications.php');
Config::load('templates-function', __DIR__ . '/../config/templates/function.php');
Config::load('templates-site', __DIR__ . '/../config/templates/site.php');
Config::load('disposableEmails', __DIR__ . '/../config/domains/disposable-emails.php'); // (eldad) following cloud camelCase format. Wish we could be consistent with the rest of the codebase.
+1
View File
@@ -81,6 +81,7 @@ class Exception extends \Exception
public const string USER_PASSWORD_RECENTLY_USED = 'password_recently_used';
public const string USER_PASSWORD_PERSONAL_DATA = 'password_personal_data';
public const string USER_EMAIL_ALREADY_EXISTS = 'user_email_already_exists';
public const string USER_EMAIL_DISPOSABLE = 'user_email_disposable';
public const string USER_PASSWORD_MISMATCH = 'user_password_mismatch';
public const string USER_SESSION_NOT_FOUND = 'user_session_not_found';
public const string USER_IDENTITY_NOT_FOUND = 'user_identity_not_found';
@@ -377,6 +377,7 @@ class Project extends Model
$document->setAttribute('authPasswordHistory', $authValues['passwordHistory'] ?? 0);
$document->setAttribute('authPasswordDictionary', $authValues['passwordDictionary'] ?? false);
$document->setAttribute('authPersonalDataCheck', $authValues['personalDataCheck'] ?? false);
$document->setAttribute('authDisposableEmails', $authValues['disposableEmails'] ?? false);
$document->setAttribute('authMockNumbers', $authValues['mockNumbers'] ?? []);
$document->setAttribute('authSessionAlerts', $authValues['sessionAlerts'] ?? false);
$document->setAttribute('authMembershipsUserName', $authValues['membershipsUserName'] ?? true);