diff --git a/constraints.txt b/constraints.txt index 18e05ba613732671a30c101753f10d1c621c7fc0..f952fe3bd4861674c2bc508f9a5bf8b2b4119aa5 100644 --- a/constraints.txt +++ b/constraints.txt @@ -7,6 +7,7 @@ charset-normalizer==2.1.1 click==8.1.3 dateutils==0.6.12 deprecation==2.1.0 +dnspython==2.2.1 dominate==2.7.0 Flask==2.2.2 Flask-Bootstrap==3.3.7.1 diff --git a/doc/admin.md b/doc/admin.md new file mode 100644 index 0000000000000000000000000000000000000000..9cc34d51dace0afb2d1b003dc286c3e3bd6bb278 --- /dev/null +++ b/doc/admin.md @@ -0,0 +1,11 @@ +Návod pro správce +================= + +Zatím pouze útržkovité poznámky, které časem přestěhujeme na lepší místo + +- *Falešné e-maily:* Pokud je potřeba založit účet bez e-mailové adresy, + dá se použít adresa tvaru *někdo*`@nomail`. Na tu pak poštu neposíláme. + Při vytváření účtu je ale potřeba ověřit, že vymyšlená adresa je unikátní. + +- *Testovací e-maily:* Podobně v testovacích instancích používáme účty + *někdo*`@test`. Na ty se pošta také neposílá. diff --git a/mo/email.py b/mo/email.py index d2fa1572952b2192241d1d2cce6484506cda35ff..1ce77aad1a3a50da77b5658b58098eeb2b3fb4a9 100644 --- a/mo/email.py +++ b/mo/email.py @@ -12,6 +12,7 @@ import urllib.parse import mo.db as db import mo.config as config +import mo.users from mo.util import logger, ExceptionInfo @@ -45,6 +46,10 @@ def send_email(send_to: str, full_name: str, subject: str, body: str) -> bool: if mail_instead is not None: send_to = mail_instead + if mo.users.email_is_fake(send_to): + logger.info('Mail: Neposíláme, adresa je falešná') + return True + sm = subprocess.Popen( [ '/usr/sbin/sendmail', diff --git a/mo/imports.py b/mo/imports.py index 620d4971185c553c5f3bb551d9f694ce93f4b1d3..5833b3a4039a0bd5c94117f267f3322d0137bbb7 100644 --- a/mo/imports.py +++ b/mo/imports.py @@ -91,6 +91,8 @@ class Import: return self.error('Chybí e-mailová adresa') try: + # XXX: Zde si nemůžeme dovolit kontrolovat existenci domén, + # protože import by byl příliš pomalý. Možná z něj jednou uděláme job... return mo.users.normalize_email(email) except mo.CheckError as e: return self.error(str(e)) diff --git a/mo/users.py b/mo/users.py index b57c6e0557861c16c31f7ac9afb63089d2d94aeb..e765fe2f5366c20d0b778c54568b8aea8b9c44e1 100644 --- a/mo/users.py +++ b/mo/users.py @@ -3,6 +3,8 @@ import bcrypt import datetime import dateutil.tz +import dns.exception +import dns.resolver import email.errors import email.headerregistry import re @@ -246,7 +248,44 @@ def find_or_create_participation(user: db.User, contest: db.Contest, place: Opti return pion, is_new -def normalize_email(addr: str) -> str: +def email_is_fake(addr: str) -> bool: + return addr.endswith('@nomail') or addr.endswith('@test') + + +bad_domains = { + 'gmail.cz', +} + + +def email_check_domain(domain: str): + # Některé domény rovnou odmítáme + if domain in bad_domains: + logger.info(f'DNS: Doména <{domain}> na blacklistu') + raise mo.CheckError('Doména {domain} nepřijímá poštu') + + for record in ['MX', 'A', 'AAAA']: + try: + dns.resolver.resolve(domain, record, lifetime=2, search=False) + except dns.exception.Timeout: + # Kontrola je konzervativní, při timeoutu adresu raději schválíme + logger.info(f'DNS: Timeout při kontrole domény <{domain}>') + return + except dns.resolver.NoAnswer: + logger.debug(f'DNS: Doména <{domain}> neobsahuje {record}') + except dns.resolver.NXDOMAIN: + logger.info(f'DNS: Doména <{domain}> neexistuje') + raise mo.CheckError('Adresa obsahuje neexistující doménu {domain}') + except dns.exception.DNSException as e: + logger.warn(f'DNS: Záznam <{domain}>/{record} nejde resolvovat: {e}') + return + logger.debug(f'DNS: Doména <{domain}> OK') + return + + logger.info(f'DNS: Doména <{domain}> nepřijímá poštu') + raise mo.CheckError('Doména {domain} nepřijímá poštu') + + +def normalize_email(addr: str, check_existence: bool = False) -> str: if not re.fullmatch(r'.+@.+', addr): raise mo.CheckError('V e-mailové adrese chybí zavináč') @@ -263,10 +302,16 @@ def normalize_email(addr: str) -> str: try: # Tady úmyslně používáme knihovnu jen ke kontrole a ne k normalizaci, # protože nechceme riskovat, že se normalizovaný tvar časem změní. - email.headerregistry.Address(addr_spec=addr) + addr_obj = email.headerregistry.Address(addr_spec=addr) except (email.errors.HeaderParseError, ValueError): raise mo.CheckError('Chybná syntaxe mailové adresy') + if addr_obj.display_name != "": + raise mo.CheckError('Chybná syntaxe mailové adresy') + + if check_existence and not email_is_fake(addr): + email_check_domain(addr_obj.domain) + # XXX: Striktně vzato, tohle není korektní, protože některé domény mohou # mít case-sensitive levou stranu adresy. Ale i na nich se prakticky nevyskytují # levé strany s velkými písmeny, zatímco uživatelé při psaní adres běžně velká diff --git a/mo/web/acct.py b/mo/web/acct.py index 24f34bc2a38ac17937c03e88e57dd99a186e9365..d906e40943b0f4a79399e7154e88e09cef4bb8fa 100644 --- a/mo/web/acct.py +++ b/mo/web/acct.py @@ -156,7 +156,7 @@ def user_settings(): class PersonalSettingsForm(FlaskForm): - email = mo_fields.Email(validators=[validators.DataRequired()]) + email = mo_fields.Email(validators=[validators.DataRequired()], check_existence=True) current_passwd = mo_fields.Password('Aktuální heslo', validators=[validators.DataRequired()]) new_passwd = mo_fields.NewPassword( description=mo.users.password_help + ' Pokud nechcete heslo měnit, ponechte toto políčko prázdné.', @@ -388,7 +388,7 @@ class Reg1: class Reg1Form(FlaskForm): - email = mo_fields.Email(validators=[validators.DataRequired()]) + email = mo_fields.Email(validators=[validators.DataRequired()], check_existence=True) token = wtforms.HiddenField() captcha = mo_fields.String('Kontrolní odpověď', validators=[validators.DataRequired()]) submit = wtforms.SubmitField('Vytvořit účet') diff --git a/mo/web/fields.py b/mo/web/fields.py index 446b08023d4636801750c66056e06665977e1235..4c6ce5f57e226441d64a60a2fdca4c6f8437ef10 100644 --- a/mo/web/fields.py +++ b/mo/web/fields.py @@ -74,13 +74,16 @@ class Points(Decimal): class Email(Stripped, EmailField): - def __init__(self, label="E-mail", validators=None, **kwargs): + check_existence: bool + + def __init__(self, label="E-mail", validators=None, check_existence: bool = False, **kwargs): + self.check_existence = check_existence super().__init__(label, validators, **kwargs) def pre_validate(field, form): if field.data: try: - field.data = mo.users.normalize_email(field.data) + field.data = mo.users.normalize_email(field.data, check_existence=field.check_existence) except mo.CheckError as e: raise wtforms.ValidationError(str(e)) diff --git a/mo/web/org_contest.py b/mo/web/org_contest.py index ed0eda1673c5859eddf9ca618c46418f44f466f0..5361d860187a4b6c89f45c04ddfb59fcabada283 100644 --- a/mo/web/org_contest.py +++ b/mo/web/org_contest.py @@ -1810,7 +1810,7 @@ def check_contest_state(round: db.Round, contest: Optional[db.Contest], state: d class ParticipantAddForm(FlaskForm): - email = mo_fields.Email(validators=[validators.DataRequired()]) + email = mo_fields.Email(validators=[validators.DataRequired()], check_existence=True) first_name = mo_fields.FirstName(validators=[validators.Optional()]) last_name = mo_fields.LastName(validators=[validators.Optional()]) school = mo_fields.School(validators=[validators.Optional()]) diff --git a/mo/web/org_users.py b/mo/web/org_users.py index 237983f1387660b053ad377ded3fa6ef64585fa3..400d142ff41827bfefbc1b9d00edd635e3fedd89 100644 --- a/mo/web/org_users.py +++ b/mo/web/org_users.py @@ -413,7 +413,7 @@ def org_user(id: int): class UserEditForm(FlaskForm): first_name = mo_fields.FirstName(validators=[DataRequired()], render_kw={'autofocus': True}) last_name = mo_fields.LastName(validators=[DataRequired()]) - email = mo_fields.Email(validators=[DataRequired()]) + email = mo_fields.Email(validators=[DataRequired()], check_existence=True) note = wtforms.TextAreaField("Poznámka") is_test = wtforms.BooleanField("Testovací účet") allow_duplicate_name = wtforms.BooleanField("Přidat účet s duplicitním jménem") diff --git a/setup.py b/setup.py index 64f47dc1cc7c5cc15d2901f9b1fae1c673e58841..77e694f6268133fb1feb3b49e03a8dcbba227bd1 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,7 @@ setuptools.setup( 'blinker', 'click', 'dateutils', + 'dnspython', 'flask_bootstrap', 'flask_sqlalchemy', 'markdown',