mirror of
https://github.com/ae-utbm/sith.git
synced 2024-11-23 14:43:22 +00:00
Add antispam app
* update_spam_database command to update suspicious domains from an external provider * Add a AntiSpamEmailField that deny emails from suspicious domains * Update documentation
This commit is contained in:
parent
7b97f0bf47
commit
181e74b1d1
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,4 +1,4 @@
|
|||||||
db.sqlite3
|
*.sqlite3
|
||||||
*.log
|
*.log
|
||||||
*.pyc
|
*.pyc
|
||||||
*.mo
|
*.mo
|
||||||
|
0
antispam/__init__.py
Normal file
0
antispam/__init__.py
Normal file
10
antispam/admin.py
Normal file
10
antispam/admin.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from antispam.models import ToxicDomain
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ToxicDomain)
|
||||||
|
class ToxicDomainAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("domain", "is_externally_managed", "created")
|
||||||
|
search_fields = ("domain", "is_externally_managed", "created")
|
||||||
|
list_filter = ("is_externally_managed",)
|
7
antispam/apps.py
Normal file
7
antispam/apps.py
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AntispamConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
verbose_name = "antispam"
|
||||||
|
name = "antispam"
|
18
antispam/forms.py
Normal file
18
antispam/forms.py
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from django import forms
|
||||||
|
from django.core.validators import EmailValidator
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from antispam.models import ToxicDomain
|
||||||
|
|
||||||
|
|
||||||
|
class AntiSpamEmailField(forms.EmailField):
|
||||||
|
"""An email field that email addresses with a known toxic domain."""
|
||||||
|
|
||||||
|
def run_validators(self, value: str):
|
||||||
|
super().run_validators(value)
|
||||||
|
# Domain part should exist since email validation is guaranteed to run first
|
||||||
|
domain = re.search(EmailValidator.domain_regex, value)
|
||||||
|
if ToxicDomain.objects.filter(domain=domain[0]).exists():
|
||||||
|
raise forms.ValidationError(_("Email domain is not allowed."))
|
0
antispam/management/commands/__init__.py
Normal file
0
antispam/management/commands/__init__.py
Normal file
69
antispam/management/commands/update_spam_database.py
Normal file
69
antispam/management/commands/update_spam_database.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import requests
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management import BaseCommand
|
||||||
|
from django.db.models import Max
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from antispam.models import ToxicDomain
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
"""Update blocked ips/mails database"""
|
||||||
|
|
||||||
|
help = "Update blocked ips/mails database"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--force", action="store_true", help="Force re-creation even if up to date"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _should_update(self, *, force: bool = False) -> bool:
|
||||||
|
if force:
|
||||||
|
return True
|
||||||
|
oldest = ToxicDomain.objects.filter(is_externally_managed=True).aggregate(
|
||||||
|
res=Max("created")
|
||||||
|
)["res"]
|
||||||
|
return not (oldest and timezone.now() < (oldest + timezone.timedelta(days=1)))
|
||||||
|
|
||||||
|
def _download_domains(self, providers: list[str]) -> set[str]:
|
||||||
|
domains = set()
|
||||||
|
for provider in providers:
|
||||||
|
res = requests.get(provider)
|
||||||
|
if not res.ok:
|
||||||
|
self.stderr.write(
|
||||||
|
f"Source {provider} responded with code {res.status_code}"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
domains |= set(res.content.decode().splitlines())
|
||||||
|
return domains
|
||||||
|
|
||||||
|
def _update_domains(self, domains: set[str]):
|
||||||
|
# Cleanup database
|
||||||
|
ToxicDomain.objects.filter(is_externally_managed=True).delete()
|
||||||
|
|
||||||
|
# Create database
|
||||||
|
ToxicDomain.objects.bulk_create(
|
||||||
|
[
|
||||||
|
ToxicDomain(domain=domain, is_externally_managed=True)
|
||||||
|
for domain in domains
|
||||||
|
],
|
||||||
|
ignore_conflicts=True,
|
||||||
|
)
|
||||||
|
self.stdout.write("Domain database updated")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
if not self._should_update(force=options["force"]):
|
||||||
|
self.stdout.write("Domain database is up to date")
|
||||||
|
return
|
||||||
|
self.stdout.write("Updating domain database")
|
||||||
|
|
||||||
|
domains = self._download_domains(settings.TOXIC_DOMAINS_PROVIDERS)
|
||||||
|
|
||||||
|
if not domains:
|
||||||
|
self.stderr.write(
|
||||||
|
"No domains could be fetched from settings.TOXIC_DOMAINS_PROVIDERS. "
|
||||||
|
"Please, have a look at your settings."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self._update_domains(domains)
|
35
antispam/migrations/0001_initial.py
Normal file
35
antispam/migrations/0001_initial.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 4.2.14 on 2024-08-03 23:05
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="ToxicDomain",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"domain",
|
||||||
|
models.URLField(
|
||||||
|
max_length=253,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="domain",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("created", models.DateTimeField(auto_now_add=True)),
|
||||||
|
(
|
||||||
|
"is_externally_managed",
|
||||||
|
models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
help_text="True if kept up-to-date using external toxic domain providers, else False",
|
||||||
|
verbose_name="is externally managed",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
0
antispam/migrations/__init__.py
Normal file
0
antispam/migrations/__init__.py
Normal file
19
antispam/models.py
Normal file
19
antispam/models.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class ToxicDomain(models.Model):
|
||||||
|
"""Domain marked as spam in public databases"""
|
||||||
|
|
||||||
|
domain = models.URLField(_("domain"), max_length=253, primary_key=True)
|
||||||
|
created = models.DateTimeField(auto_now_add=True)
|
||||||
|
is_externally_managed = models.BooleanField(
|
||||||
|
_("is externally managed"),
|
||||||
|
default=False,
|
||||||
|
help_text=_(
|
||||||
|
"True if kept up-to-date using external toxic domain providers, else False"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return self.domain
|
@ -24,8 +24,10 @@ from django.core.mail import EmailMessage
|
|||||||
from django.test import Client, TestCase
|
from django.test import Client, TestCase
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
|
from model_bakery import baker
|
||||||
from pytest_django.asserts import assertInHTML, assertRedirects
|
from pytest_django.asserts import assertInHTML, assertRedirects
|
||||||
|
|
||||||
|
from antispam.models import ToxicDomain
|
||||||
from club.models import Membership
|
from club.models import Membership
|
||||||
from core.markdown import markdown
|
from core.markdown import markdown
|
||||||
from core.models import AnonymousUser, Group, Page, User
|
from core.models import AnonymousUser, Group, Page, User
|
||||||
@ -48,6 +50,10 @@ class TestUserRegistration:
|
|||||||
"captcha_1": "PASSED",
|
"captcha_1": "PASSED",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def scam_domains(self):
|
||||||
|
return [baker.make(ToxicDomain, domain="scammer.spam")]
|
||||||
|
|
||||||
def test_register_user_form_ok(self, client, valid_payload):
|
def test_register_user_form_ok(self, client, valid_payload):
|
||||||
"""Should register a user correctly."""
|
"""Should register a user correctly."""
|
||||||
assert not User.objects.filter(email=valid_payload["email"]).exists()
|
assert not User.objects.filter(email=valid_payload["email"]).exists()
|
||||||
@ -64,14 +70,25 @@ class TestUserRegistration:
|
|||||||
{"password2": "not the same as password1"},
|
{"password2": "not the same as password1"},
|
||||||
"Les deux mots de passe ne correspondent pas.",
|
"Les deux mots de passe ne correspondent pas.",
|
||||||
),
|
),
|
||||||
({"email": "not-an-email"}, "Saisissez une adresse de courriel valide."),
|
(
|
||||||
|
{"email": "not-an-email"},
|
||||||
|
"Saisissez une adresse de courriel valide.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"email": "not\\an@email.com"},
|
||||||
|
"Saisissez une adresse de courriel valide.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{"email": "legit@scammer.spam"},
|
||||||
|
"Le domaine de l'addresse e-mail n'est pas autorisé.",
|
||||||
|
),
|
||||||
({"first_name": ""}, "Ce champ est obligatoire."),
|
({"first_name": ""}, "Ce champ est obligatoire."),
|
||||||
({"last_name": ""}, "Ce champ est obligatoire."),
|
({"last_name": ""}, "Ce champ est obligatoire."),
|
||||||
({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"),
|
({"captcha_1": "WRONG_CAPTCHA"}, "CAPTCHA invalide"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_register_user_form_fail(
|
def test_register_user_form_fail(
|
||||||
self, client, valid_payload, payload_edit, expected_error
|
self, client, scam_domains, valid_payload, payload_edit, expected_error
|
||||||
):
|
):
|
||||||
"""Should not register a user correctly."""
|
"""Should not register a user correctly."""
|
||||||
payload = valid_payload | payload_edit
|
payload = valid_payload | payload_edit
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
# details.
|
# details.
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU General Public License along with
|
# You should have received a copy of the GNU General Public License along with
|
||||||
# this program; if not, write to the Free Sofware Foundation, Inc., 59 Temple
|
# this program; if not, write to the Free Software Foundation, Inc., 59 Temple
|
||||||
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
# Place - Suite 330, Boston, MA 02111-1307, USA.
|
||||||
#
|
#
|
||||||
#
|
#
|
||||||
@ -45,6 +45,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
from phonenumber_field.widgets import RegionalPhoneNumberWidget
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
from antispam.forms import AntiSpamEmailField
|
||||||
from core.models import Gift, Page, SithFile, User
|
from core.models import Gift, Page, SithFile, User
|
||||||
from core.utils import resize_image
|
from core.utils import resize_image
|
||||||
|
|
||||||
@ -194,6 +195,9 @@ class RegisteringForm(UserCreationForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = User
|
model = User
|
||||||
fields = ("first_name", "last_name", "email")
|
fields = ("first_name", "last_name", "email")
|
||||||
|
field_classes = {
|
||||||
|
"email": AntiSpamEmailField,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class UserProfileForm(forms.ModelForm):
|
class UserProfileForm(forms.ModelForm):
|
||||||
|
1
docs/reference/antispam/forms.md
Normal file
1
docs/reference/antispam/forms.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
::: antispam.forms
|
1
docs/reference/antispam/models.md
Normal file
1
docs/reference/antispam/models.md
Normal file
@ -0,0 +1 @@
|
|||||||
|
::: antispam.models
|
10
docs/tutorial/install_advanced.md
Normal file
10
docs/tutorial/install_advanced.md
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
## Mettre à jour la base de données antispam
|
||||||
|
|
||||||
|
L'anti spam nécessite d'être à jour par rapport à des bases de données externe.
|
||||||
|
Il existe une commande pour ça qu'il faut lancer régulièrement.
|
||||||
|
Lors de la mise en production, il est judicieux de configurer
|
||||||
|
un cron pour la mettre à jour au moins une fois par jour.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python manage.py update_spam_database
|
||||||
|
```
|
@ -64,17 +64,19 @@ sith3/
|
|||||||
│ └── ...
|
│ └── ...
|
||||||
├── trombi/ (22)
|
├── trombi/ (22)
|
||||||
│ └── ...
|
│ └── ...
|
||||||
|
├── antispam/ (23)
|
||||||
|
│ └── ...
|
||||||
│
|
│
|
||||||
├── .coveragerc (23)
|
├── .coveragerc (24)
|
||||||
├── .envrc (24)
|
├── .envrc (25)
|
||||||
├── .gitattributes
|
├── .gitattributes
|
||||||
├── .gitignore
|
├── .gitignore
|
||||||
├── .mailmap
|
├── .mailmap
|
||||||
├── .env.exemple
|
├── .env.exemple
|
||||||
├── manage.py (25)
|
├── manage.py (26)
|
||||||
├── mkdocs.yml (26)
|
├── mkdocs.yml (27)
|
||||||
├── poetry.lock
|
├── poetry.lock
|
||||||
├── pyproject.toml (27)
|
├── pyproject.toml (28)
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
</div>
|
</div>
|
||||||
@ -112,15 +114,16 @@ sith3/
|
|||||||
19. Application principale du projet, contenant sa configuration.
|
19. Application principale du projet, contenant sa configuration.
|
||||||
20. Gestion des stocks des comptoirs.
|
20. Gestion des stocks des comptoirs.
|
||||||
21. Gestion des cotisations des utilisateurs du site.
|
21. Gestion des cotisations des utilisateurs du site.
|
||||||
22. Gestion des trombinoscopes.
|
22. Fonctionalitées pour gérer le spam.
|
||||||
23. Fichier de configuration de coverage.
|
23. Gestion des trombinoscopes.
|
||||||
24. Fichier de configuration de direnv.
|
24. Fichier de configuration de coverage.
|
||||||
25. Fichier généré automatiquement par Django. C'est lui
|
25. Fichier de configuration de direnv.
|
||||||
|
26. Fichier généré automatiquement par Django. C'est lui
|
||||||
qui permet d'appeler des commandes de gestion du projet
|
qui permet d'appeler des commandes de gestion du projet
|
||||||
avec la syntaxe `python ./manage.py <nom de la commande>`
|
avec la syntaxe `python ./manage.py <nom de la commande>`
|
||||||
26. Le fichier de configuration de la documentation,
|
27. Le fichier de configuration de la documentation,
|
||||||
avec ses plugins et sa table des matières.
|
avec ses plugins et sa table des matières.
|
||||||
27. Le fichier où sont déclarés les dépendances et la configuration
|
28. Le fichier où sont déclarés les dépendances et la configuration
|
||||||
de certaines d'entre elles.
|
de certaines d'entre elles.
|
||||||
|
|
||||||
|
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -66,6 +66,7 @@ nav:
|
|||||||
- Gestion des permissions: tutorial/perms.md
|
- Gestion des permissions: tutorial/perms.md
|
||||||
- Gestion des groupes: tutorial/groups.md
|
- Gestion des groupes: tutorial/groups.md
|
||||||
- Etransactions: tutorial/etransaction.md
|
- Etransactions: tutorial/etransaction.md
|
||||||
|
- Installer le projet (Avancé): tutorial/install_advanced.md
|
||||||
- How-to:
|
- How-to:
|
||||||
- L'ORM de Django: howto/querysets.md
|
- L'ORM de Django: howto/querysets.md
|
||||||
- Gérer les migrations: howto/migrations.md
|
- Gérer les migrations: howto/migrations.md
|
||||||
@ -80,6 +81,9 @@ nav:
|
|||||||
- accounting:
|
- accounting:
|
||||||
- reference/accounting/models.md
|
- reference/accounting/models.md
|
||||||
- reference/accounting/views.md
|
- reference/accounting/views.md
|
||||||
|
- antispam:
|
||||||
|
- reference/antispam/models.md
|
||||||
|
- reference/antispam/forms.md
|
||||||
- club:
|
- club:
|
||||||
- reference/club/models.md
|
- reference/club/models.md
|
||||||
- reference/club/views.md
|
- reference/club/views.md
|
||||||
|
@ -98,6 +98,7 @@ INSTALLED_APPS = (
|
|||||||
"matmat",
|
"matmat",
|
||||||
"pedagogy",
|
"pedagogy",
|
||||||
"galaxy",
|
"galaxy",
|
||||||
|
"antispam",
|
||||||
)
|
)
|
||||||
|
|
||||||
MIDDLEWARE = (
|
MIDDLEWARE = (
|
||||||
@ -204,13 +205,12 @@ SASS_PRECISION = 8
|
|||||||
WSGI_APPLICATION = "sith.wsgi.application"
|
WSGI_APPLICATION = "sith.wsgi.application"
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/1.8/ref/settings/#databases
|
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "django.db.backends.sqlite3",
|
"ENGINE": "django.db.backends.sqlite3",
|
||||||
"NAME": BASE_DIR / "db.sqlite3",
|
"NAME": BASE_DIR / "db.sqlite3",
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
|
SESSION_ENGINE = "django.contrib.sessions.backends.cached_db"
|
||||||
@ -673,6 +673,10 @@ SITH_GIFT_LIST = [("AE Tee-shirt", _("AE tee-shirt"))]
|
|||||||
SENTRY_DSN = ""
|
SENTRY_DSN = ""
|
||||||
SENTRY_ENV = "production"
|
SENTRY_ENV = "production"
|
||||||
|
|
||||||
|
TOXIC_DOMAINS_PROVIDERS = [
|
||||||
|
"https://www.stopforumspam.com/downloads/toxic_domains_whole.txt",
|
||||||
|
]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .settings_custom import *
|
from .settings_custom import *
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user