diff --git a/.env b/.env index 7be22b18940fb5a1d51dc8b91a04e6ad152a01cf..ee716e348de5b20285c684989e9f6122fafa4af0 100644 --- a/.env +++ b/.env @@ -31,3 +31,8 @@ CERN_OIDC_CLIENT_SECRET=fill-me EMAIL_AES_SECRET_KEY=fill-mefill-mefill-mefill-mefill +# SMIME signed emails +EMAIL_BACKEND=vendor.django_mail.backends.smimesmtp.EmailBackend +EMAIL_SMIME_CERT_FILE_PATH=/etc/notifications-noreply.pem +EMAIL_SMIME_CERT_KEY_FILE_PATH=/etc/notifications-noreply-key.pem + diff --git a/notifications_consumer/config.py b/notifications_consumer/config.py index 5c5c207dcb35dcf8089d434069208fb62cdadd1d..ee747649c1e4437e88564f861b0f0fed7734ff5b 100644 --- a/notifications_consumer/config.py +++ b/notifications_consumer/config.py @@ -55,6 +55,10 @@ class Config: EMAIL_WHITELIST = ast.literal_eval(os.getenv("EMAIL_WHITELIST", "['user@cern.ch']")) EMAIL_BACKEND = os.getenv("EMAIL_BACKEND", "vendor.django_mail.backends.console.EmailBackend") + + EMAIL_SMIME_CERT_FILE_PATH = os.getenv("EMAIL_SMIME_CERT_FILE_PATH") + EMAIL_SMIME_CERT_KEY_FILE_PATH = os.getenv("EMAIL_SMIME_CERT_KEY_FILE_PATH") + NOREPLY_ADDRESS = os.getenv("NOREPLY_ADDRESS", "noreply@cern.ch") EMAIL_RECIPIENT_REGEX = r"^(.*?)\+(.*?)\+(.*?)$" diff --git a/poetry.lock b/poetry.lock index fe4ee4c5e2b42cc38dc536cd1a58c81d53698689..bdb95b8c387db0f68fd53b713974c8cd5464a590 100644 --- a/poetry.lock +++ b/poetry.lock @@ -22,6 +22,14 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "asn1crypto" +version = "1.4.0" +description = "Fast ASN.1 parser and serializer with definitions for private keys, public keys, certificates, CRL, OCSP, CMS, PKCS#3, PKCS#7, PKCS#8, PKCS#12, PKCS#5, X.509 and TSP" +category = "main" +optional = false +python-versions = "*" + [[package]] name = "atomicwrites" version = "1.4.0" @@ -314,6 +322,17 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "oscrypto" +version = "1.2.1" +description = "TLS (SSL) sockets, key generation, encryption, decryption, signing, verification and KDFs using the OS crypto libraries. Does not require a compiler, and relies on the OS for patching. Works on Windows, OS X and Linux/BSD." +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +asn1crypto = ">=1.0.0" + [[package]] name = "packaging" version = "20.9" @@ -470,6 +489,21 @@ toml = "*" [package.extras] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] +[[package]] +name = "python-smail" +version = "0.9.0" +description = "Simple S/MIME e-mails with Python3" +category = "main" +optional = false +python-versions = "*" + +[package.dependencies] +asn1crypto = "*" +oscrypto = "*" + +[package.extras] +test = ["coverage", "flake8", "tox"] + [[package]] name = "pywebpush" version = "1.13.0" @@ -658,7 +692,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt [metadata] lock-version = "1.1" python-versions = "^3.6.1" -content-hash = "b289528ab61c5d62e64fcab64e19a335ada3683c86b33bb29ed7b23840a47260" +content-hash = "4ad4f600d58c371c5ee85fd33950f55c212edc0620b969f9c7541246c1800f73" [metadata.files] apns2 = [ @@ -669,6 +703,10 @@ appdirs = [ {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, ] +asn1crypto = [ + {file = "asn1crypto-1.4.0-py2.py3-none-any.whl", hash = "sha256:4bcdf33c861c7d40bdcd74d8e4dd7661aac320fcdf40b9a3f95b4ee12fde2fa8"}, + {file = "asn1crypto-1.4.0.tar.gz", hash = "sha256:f4f6e119474e58e04a2b1af817eb585b4fd72bdd89b998624712b5c99be7641c"}, +] atomicwrites = [ {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, @@ -698,24 +736,36 @@ cffi = [ {file = "cffi-1.14.5-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:48e1c69bbacfc3d932221851b39d49e81567a4d4aac3b21258d9c24578280058"}, {file = "cffi-1.14.5-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:69e395c24fc60aad6bb4fa7e583698ea6cc684648e1ffb7fe85e3c1ca131a7d5"}, {file = "cffi-1.14.5-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:9e93e79c2551ff263400e1e4be085a1210e12073a31c2011dbbda14bda0c6132"}, + {file = "cffi-1.14.5-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:24ec4ff2c5c0c8f9c6b87d5bb53555bf267e1e6f70e52e5a9740d32861d36b6f"}, + {file = "cffi-1.14.5-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c3f39fa737542161d8b0d680df2ec249334cd70a8f420f71c9304bd83c3cbed"}, + {file = "cffi-1.14.5-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:681d07b0d1e3c462dd15585ef5e33cb021321588bebd910124ef4f4fb71aef55"}, {file = "cffi-1.14.5-cp36-cp36m-win32.whl", hash = "sha256:58e3f59d583d413809d60779492342801d6e82fefb89c86a38e040c16883be53"}, {file = "cffi-1.14.5-cp36-cp36m-win_amd64.whl", hash = "sha256:005a36f41773e148deac64b08f233873a4d0c18b053d37da83f6af4d9087b813"}, {file = "cffi-1.14.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2894f2df484ff56d717bead0a5c2abb6b9d2bf26d6960c4604d5c48bbc30ee73"}, {file = "cffi-1.14.5-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:0857f0ae312d855239a55c81ef453ee8fd24136eaba8e87a2eceba644c0d4c06"}, {file = "cffi-1.14.5-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:cd2868886d547469123fadc46eac7ea5253ea7fcb139f12e1dfc2bbd406427d1"}, {file = "cffi-1.14.5-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:35f27e6eb43380fa080dccf676dece30bef72e4a67617ffda586641cd4508d49"}, + {file = "cffi-1.14.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06d7cd1abac2ffd92e65c0609661866709b4b2d82dd15f611e602b9b188b0b69"}, + {file = "cffi-1.14.5-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f861a89e0043afec2a51fd177a567005847973be86f709bbb044d7f42fc4e05"}, + {file = "cffi-1.14.5-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc5a8e069b9ebfa22e26d0e6b97d6f9781302fe7f4f2b8776c3e1daea35f1adc"}, {file = "cffi-1.14.5-cp37-cp37m-win32.whl", hash = "sha256:9ff227395193126d82e60319a673a037d5de84633f11279e336f9c0f189ecc62"}, {file = "cffi-1.14.5-cp37-cp37m-win_amd64.whl", hash = "sha256:9cf8022fb8d07a97c178b02327b284521c7708d7c71a9c9c355c178ac4bbd3d4"}, {file = "cffi-1.14.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8b198cec6c72df5289c05b05b8b0969819783f9418e0409865dac47288d2a053"}, {file = "cffi-1.14.5-cp38-cp38-manylinux1_i686.whl", hash = "sha256:ad17025d226ee5beec591b52800c11680fca3df50b8b29fe51d882576e039ee0"}, {file = "cffi-1.14.5-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6c97d7350133666fbb5cf4abdc1178c812cb205dc6f41d174a7b0f18fb93337e"}, {file = "cffi-1.14.5-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:8ae6299f6c68de06f136f1f9e69458eae58f1dacf10af5c17353eae03aa0d827"}, + {file = "cffi-1.14.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:04c468b622ed31d408fea2346bec5bbffba2cc44226302a0de1ade9f5ea3d373"}, + {file = "cffi-1.14.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:06db6321b7a68b2bd6df96d08a5adadc1fa0e8f419226e25b2a5fbf6ccc7350f"}, + {file = "cffi-1.14.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:293e7ea41280cb28c6fcaaa0b1aa1f533b8ce060b9e701d78511e1e6c4a1de76"}, {file = "cffi-1.14.5-cp38-cp38-win32.whl", hash = "sha256:b85eb46a81787c50650f2392b9b4ef23e1f126313b9e0e9013b35c15e4288e2e"}, {file = "cffi-1.14.5-cp38-cp38-win_amd64.whl", hash = "sha256:1f436816fc868b098b0d63b8920de7d208c90a67212546d02f84fe78a9c26396"}, {file = "cffi-1.14.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1071534bbbf8cbb31b498d5d9db0f274f2f7a865adca4ae429e147ba40f73dea"}, {file = "cffi-1.14.5-cp39-cp39-manylinux1_i686.whl", hash = "sha256:9de2e279153a443c656f2defd67769e6d1e4163952b3c622dcea5b08a6405322"}, {file = "cffi-1.14.5-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:6e4714cc64f474e4d6e37cfff31a814b509a35cb17de4fb1999907575684479c"}, {file = "cffi-1.14.5-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:158d0d15119b4b7ff6b926536763dc0714313aa59e320ddf787502c70c4d4bee"}, + {file = "cffi-1.14.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bf1ac1984eaa7675ca8d5745a8cb87ef7abecb5592178406e55858d411eadc0"}, + {file = "cffi-1.14.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:df5052c5d867c1ea0b311fb7c3cd28b19df469c056f7fdcfe88c7473aa63e333"}, + {file = "cffi-1.14.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24a570cd11895b60829e941f2613a4f79df1a27344cbbb82164ef2e0116f09c7"}, {file = "cffi-1.14.5-cp39-cp39-win32.whl", hash = "sha256:afb29c1ba2e5a3736f1c301d9d0abe3ec8b86957d04ddfa9d7a6a42b9367e396"}, {file = "cffi-1.14.5-cp39-cp39-win_amd64.whl", hash = "sha256:f2d45f97ab6bb54753eab54fffe75aaf3de4ff2341c9daee1987ee1837636f1d"}, {file = "cffi-1.14.5.tar.gz", hash = "sha256:fd78e5fee591709f32ef6edb9a015b4aa1a5022598e36227500c8f4e02328d9c"}, @@ -855,6 +905,10 @@ nodeenv = [ {file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"}, {file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"}, ] +oscrypto = [ + {file = "oscrypto-1.2.1-py2.py3-none-any.whl", hash = "sha256:988087e05b17df8bfcc7c5fac51f54595e46d3e4dffa7b3d15955cf61a633529"}, + {file = "oscrypto-1.2.1.tar.gz", hash = "sha256:7d2cca6235d89d1af6eb9cfcd4d2c0cb405849868157b2f7b278beb644d48694"}, +] packaging = [ {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, @@ -942,6 +996,10 @@ pytest = [ {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"}, {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, ] +python-smail = [ + {file = "python-smail-0.9.0.tar.gz", hash = "sha256:e0da2fea2189a8dece2ab1ea78a670ba2f1d025742ea118b7358e5a9ca2f052f"}, + {file = "python_smail-0.9.0-py2-none-any.whl", hash = "sha256:b15e085efcf10813c37bb9fc0c1b0d6564a39b30b91c308cd443201942c718e5"}, +] pywebpush = [ {file = "pywebpush-1.13.0.tar.gz", hash = "sha256:97ef000a685cd1f63d9d3553568508508904bfe419485df2b83b025d94e9ae54"}, ] diff --git a/pyproject.toml b/pyproject.toml index 435399063edf7cb3f3fc0c68d4092afba6ae73e8..46da9dda32a4b1a5d839a9d7d8deb7d54e0c51ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ apns2 = "^0.7.2" pytest = "^6.2.3" unittest = "^0.0" pycrypto = "^2.6.1" +python-smail = "^0.9.0" [tool.poetry.dev-dependencies] pre-commit = "~2.9.2" diff --git a/vendor/django_mail/backends/smimesmtp.py b/vendor/django_mail/backends/smimesmtp.py new file mode 100644 index 0000000000000000000000000000000000000000..02d6f94bff493949325a1a77624b754215448486 --- /dev/null +++ b/vendor/django_mail/backends/smimesmtp.py @@ -0,0 +1,174 @@ +# The code is taken from `newdle.newdle.vendor.django_mail.backends.smtp` +# Minor changes has been done to substitute config values. + +# The code in here is taken almost verbatim from `django.core.mail.backends.smtp`, +# which is licensed under the three-clause BSD license and is originally +# available on the following URL: +# https://github.com/django/django/blob/stable/2.2.x/django/core/mail/backends/smtp.py +# Credits of the original code go to the Django Software Foundation +# and their contributors. + +"""SMTP email backend class.""" +import smtplib +import socket +import ssl +import threading + +## SMIME https://pypi.org/project/python-smail/ +from smail import sign_message +from notifications_consumer.config import Config + +from ..mail_utils import DEFAULT_CHARSET, DNS_NAME +from ..message import sanitize_address +from .base import BaseEmailBackend +from vendor.django_mail.message import SafeMIMEMultipart + + +class EmailBackend(BaseEmailBackend): + """ + A wrapper that manages the SMTP network connection. + """ + + def __init__( + self, + host=None, + port=None, + username=None, + password=None, + use_tls=None, + fail_silently=False, + use_ssl=None, + timeout=None, + ssl_keyfile=None, + ssl_certfile=None, + **kwargs, + ): + super().__init__(fail_silently=fail_silently) + self.host = host or Config.EMAIL_HOST + self.port = port or Config.EMAIL_PORT + self.username = Config.EMAIL_HOST_USER if username is None else username + self.password = ( + Config.EMAIL_HOST_PASSWORD if password is None else password + ) + self.use_tls = Config.EMAIL_USE_TLS if use_tls is None else use_tls + self.use_ssl = Config.EMAIL_USE_SSL if use_ssl is None else use_ssl + self.timeout = Config.EMAIL_TIMEOUT if timeout is None else timeout + self.ssl_keyfile = ssl_keyfile + self.ssl_certfile = ssl_certfile + if self.use_ssl and self.use_tls: + raise ValueError( + "EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive, so only set " + "one of those settings to True." + ) + self.connection = None + self._lock = threading.RLock() + + @property + def connection_class(self): + return smtplib.SMTP_SSL if self.use_ssl else smtplib.SMTP + + def open(self): + """ + Ensure an open connection to the email server. Return whether or not a + new connection was required (True or False) or None if an exception + passed silently. + """ + if self.connection: + # Nothing to do if the connection is already open. + return False + + # If local_hostname is not specified, socket.getfqdn() gets used. + # For performance, we use the cached FQDN for local_hostname. + connection_params = {"local_hostname": DNS_NAME.get_fqdn()} + if self.timeout is not None: + connection_params["timeout"] = self.timeout + if self.use_ssl: + connection_params.update( + {"keyfile": self.ssl_keyfile, "certfile": self.ssl_certfile} + ) + try: + self.connection = self.connection_class( + self.host, self.port, **connection_params + ) + + # TLS/SSL are mutually exclusive, so only attempt TLS over + # non-secure connections. + if not self.use_ssl and self.use_tls: + self.connection.starttls( + keyfile=self.ssl_keyfile, certfile=self.ssl_certfile + ) + if self.username and self.password: + self.connection.login(self.username, self.password) + return True + except (smtplib.SMTPException, socket.error): + if not self.fail_silently: + raise + + def close(self): + """Close the connection to the email server.""" + if self.connection is None: + return + try: + try: + self.connection.quit() + except (ssl.SSLError, smtplib.SMTPServerDisconnected): + # This happens when calling quit() on a TLS connection + # sometimes, or when the connection was already disconnected + # by the server. + self.connection.close() + except smtplib.SMTPException: + if self.fail_silently: + return + raise + finally: + self.connection = None + + def send_messages(self, email_messages): + """ + Send one or more EmailMessage objects and return the number of email + messages sent. + """ + if not email_messages: + return 0 + with self._lock: + new_conn_created = self.open() + if not self.connection or new_conn_created is None: + # We failed silently on open(). + # Trying to send would be pointless. + return 0 + num_sent = 0 + for message in email_messages: + sent = self._send(message) + if sent: + num_sent += 1 + if new_conn_created: + self.close() + return num_sent + + def _send(self, email_message): + """A helper method that does the actual sending.""" + if not email_message.recipients(): + return False + encoding = email_message.encoding or DEFAULT_CHARSET + from_email = sanitize_address(email_message.from_email, encoding) + recipients = [ + sanitize_address(addr, encoding) for addr in email_message.recipients() + ] + + to_sign = email_message.message() + key_signer = Config.EMAIL_SMIME_CERT_KEY_FILE_PATH + cert_signer = Config.EMAIL_SMIME_CERT_FILE_PATH + signed_message = sign_message(to_sign, key_signer, cert_signer, multipart_class=SafeMIMEMultipart) + + try: + self.connection.sendmail( + from_email, + recipients, + # Ugly fix for obscure boundary with newline when using SafeMIMEMultipart + signed_message.as_string().replace('\n', '\r\n').replace('; boundary', ';\r\n boundary') + ) + except smtplib.SMTPException: + if not self.fail_silently: + raise + return False + return True