summary refs log blame commit diff
path: root/synapse/handlers/account_validity.py
blob: d04e0fe576e58d1dab84d1d0f97d10ca81c7c928 (plain) (tree)






















                                                                          
                                                           
                                                                                
                                    










                                                         
                               










                                                                                    
                                   
                 
                                                                                       






                                                                             






                                                           

                                                                         






                                                                                         











                                                                                     
                                                                                   

                          



                                                                            












                                                                                      

                                                                             































                                                                             




                                                                  



                                                               














                                                                                       
















                                                                             
                        


















                                                                                   
                                    









                                                                                     
                                                      
           



                                                                                 
                                                                
                                                  
 
                               



                                                                                    
 












                                                                                        
                                                                               
         
 
                            
# -*- coding: utf-8 -*-
# Copyright 2019 New Vector Ltd
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import email.mime.multipart
import email.utils
import logging
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

from twisted.internet import defer

from synapse.api.errors import StoreError
from synapse.logging.context import make_deferred_yieldable
from synapse.metrics.background_process_metrics import run_as_background_process
from synapse.types import UserID
from synapse.util import stringutils

try:
    from synapse.push.mailer import load_jinja2_templates
except ImportError:
    load_jinja2_templates = None

logger = logging.getLogger(__name__)


class AccountValidityHandler(object):
    def __init__(self, hs):
        self.hs = hs
        self.config = hs.config
        self.store = self.hs.get_datastore()
        self.sendmail = self.hs.get_sendmail()
        self.clock = self.hs.get_clock()

        self._account_validity = self.hs.config.account_validity

        if self._account_validity.renew_by_email_enabled and load_jinja2_templates:
            # Don't do email-specific configuration if renewal by email is disabled.
            try:
                app_name = self.hs.config.email_app_name

                self._subject = self._account_validity.renew_email_subject % {
                    "app": app_name
                }

                self._from_string = self.hs.config.email_notif_from % {"app": app_name}
            except Exception:
                # If substitution failed, fall back to the bare strings.
                self._subject = self._account_validity.renew_email_subject
                self._from_string = self.hs.config.email_notif_from

            self._raw_from = email.utils.parseaddr(self._from_string)[1]

            self._template_html, self._template_text = load_jinja2_templates(
                self.config.email_template_dir,
                [
                    self.config.email_expiry_template_html,
                    self.config.email_expiry_template_text,
                ],
                apply_format_ts_filter=True,
                apply_mxc_to_http_filter=True,
                public_baseurl=self.config.public_baseurl,
            )

            # Check the renewal emails to send and send them every 30min.
            def send_emails():
                # run as a background process to make sure that the database transactions
                # have a logcontext to report to
                return run_as_background_process(
                    "send_renewals", self.send_renewal_emails
                )

            self.clock.looping_call(send_emails, 30 * 60 * 1000)

    @defer.inlineCallbacks
    def send_renewal_emails(self):
        """Gets the list of users whose account is expiring in the amount of time
        configured in the ``renew_at`` parameter from the ``account_validity``
        configuration, and sends renewal emails to all of these users as long as they
        have an email 3PID attached to their account.
        """
        expiring_users = yield self.store.get_users_expiring_soon()

        if expiring_users:
            for user in expiring_users:
                yield self._send_renewal_email(
                    user_id=user["user_id"], expiration_ts=user["expiration_ts_ms"]
                )

    @defer.inlineCallbacks
    def send_renewal_email_to_user(self, user_id):
        expiration_ts = yield self.store.get_expiration_ts_for_user(user_id)
        yield self._send_renewal_email(user_id, expiration_ts)

    @defer.inlineCallbacks
    def _send_renewal_email(self, user_id, expiration_ts):
        """Sends out a renewal email to every email address attached to the given user
        with a unique link allowing them to renew their account.

        Args:
            user_id (str): ID of the user to send email(s) to.
            expiration_ts (int): Timestamp in milliseconds for the expiration date of
                this user's account (used in the email templates).
        """
        addresses = yield self._get_email_addresses_for_user(user_id)

        # Stop right here if the user doesn't have at least one email address.
        # In this case, they will have to ask their server admin to renew their
        # account manually.
        # We don't need to do a specific check to make sure the account isn't
        # deactivated, as a deactivated account isn't supposed to have any
        # email address attached to it.
        if not addresses:
            return

        try:
            user_display_name = yield self.store.get_profile_displayname(
                UserID.from_string(user_id).localpart
            )
            if user_display_name is None:
                user_display_name = user_id
        except StoreError:
            user_display_name = user_id

        renewal_token = yield self._get_renewal_token(user_id)
        url = "%s_matrix/client/unstable/account_validity/renew?token=%s" % (
            self.hs.config.public_baseurl,
            renewal_token,
        )

        template_vars = {
            "display_name": user_display_name,
            "expiration_ts": expiration_ts,
            "url": url,
        }

        html_text = self._template_html.render(**template_vars)
        html_part = MIMEText(html_text, "html", "utf8")

        plain_text = self._template_text.render(**template_vars)
        text_part = MIMEText(plain_text, "plain", "utf8")

        for address in addresses:
            raw_to = email.utils.parseaddr(address)[1]

            multipart_msg = MIMEMultipart("alternative")
            multipart_msg["Subject"] = self._subject
            multipart_msg["From"] = self._from_string
            multipart_msg["To"] = address
            multipart_msg["Date"] = email.utils.formatdate()
            multipart_msg["Message-ID"] = email.utils.make_msgid()
            multipart_msg.attach(text_part)
            multipart_msg.attach(html_part)

            logger.info("Sending renewal email to %s", address)

            yield make_deferred_yieldable(
                self.sendmail(
                    self.hs.config.email_smtp_host,
                    self._raw_from,
                    raw_to,
                    multipart_msg.as_string().encode("utf8"),
                    reactor=self.hs.get_reactor(),
                    port=self.hs.config.email_smtp_port,
                    requireAuthentication=self.hs.config.email_smtp_user is not None,
                    username=self.hs.config.email_smtp_user,
                    password=self.hs.config.email_smtp_pass,
                    requireTransportSecurity=self.hs.config.require_transport_security,
                )
            )

        yield self.store.set_renewal_mail_status(user_id=user_id, email_sent=True)

    @defer.inlineCallbacks
    def _get_email_addresses_for_user(self, user_id):
        """Retrieve the list of email addresses attached to a user's account.

        Args:
            user_id (str): ID of the user to lookup email addresses for.

        Returns:
            defer.Deferred[list[str]]: Email addresses for this account.
        """
        threepids = yield self.store.user_get_threepids(user_id)

        addresses = []
        for threepid in threepids:
            if threepid["medium"] == "email":
                addresses.append(threepid["address"])

        return addresses

    @defer.inlineCallbacks
    def _get_renewal_token(self, user_id):
        """Generates a 32-byte long random string that will be inserted into the
        user's renewal email's unique link, then saves it into the database.

        Args:
            user_id (str): ID of the user to generate a string for.

        Returns:
            defer.Deferred[str]: The generated string.

        Raises:
            StoreError(500): Couldn't generate a unique string after 5 attempts.
        """
        attempts = 0
        while attempts < 5:
            try:
                renewal_token = stringutils.random_string(32)
                yield self.store.set_renewal_token_for_user(user_id, renewal_token)
                return renewal_token
            except StoreError:
                attempts += 1
        raise StoreError(500, "Couldn't generate a unique string as refresh string.")

    @defer.inlineCallbacks
    def renew_account(self, renewal_token):
        """Renews the account attached to a given renewal token by pushing back the
        expiration date by the current validity period in the server's configuration.

        Args:
            renewal_token (str): Token sent with the renewal request.
        Returns:
            bool: Whether the provided token is valid.
        """
        try:
            user_id = yield self.store.get_user_from_renewal_token(renewal_token)
        except StoreError:
            defer.returnValue(False)

        logger.debug("Renewing an account for user %s", user_id)
        yield self.renew_account_for_user(user_id)

        defer.returnValue(True)

    @defer.inlineCallbacks
    def renew_account_for_user(self, user_id, expiration_ts=None, email_sent=False):
        """Renews the account attached to a given user by pushing back the
        expiration date by the current validity period in the server's
        configuration.

        Args:
            renewal_token (str): Token sent with the renewal request.
            expiration_ts (int): New expiration date. Defaults to now + validity period.
            email_sent (bool): Whether an email has been sent for this validity period.
                Defaults to False.

        Returns:
            defer.Deferred[int]: New expiration date for this account, as a timestamp
                in milliseconds since epoch.
        """
        if expiration_ts is None:
            expiration_ts = self.clock.time_msec() + self._account_validity.period

        yield self.store.set_account_validity_for_user(
            user_id=user_id, expiration_ts=expiration_ts, email_sent=email_sent
        )

        return expiration_ts