diff --git a/res/templates/password_reset.html b/res/templates/password_reset.html
new file mode 100644
index 0000000000..68673df8ae
--- /dev/null
+++ b/res/templates/password_reset.html
@@ -0,0 +1,10 @@
+<html>
+<title>time to reset your password!!!</title>
+<body>
+ <p>here's the link you forgetful person you!</p>
+
+ <a href="{{ link }}">Reset your password</a>
+
+ <p>now get on back to happy town!</p>
+</body>
+</html>
diff --git a/res/templates/password_reset.txt b/res/templates/password_reset.txt
new file mode 100644
index 0000000000..0744747a7d
--- /dev/null
+++ b/res/templates/password_reset.txt
@@ -0,0 +1,5 @@
+here's the link you forgetful person you!
+
+{{ link }}
+
+now get on back to happy town!
diff --git a/synapse/api/errors.py b/synapse/api/errors.py
index e91697049c..66201d6efe 100644
--- a/synapse/api/errors.py
+++ b/synapse/api/errors.py
@@ -339,6 +339,15 @@ class UnsupportedRoomVersionError(SynapseError):
)
+class ThreepidValidationError(SynapseError):
+ """An error raised when there was a problem authorising an event."""
+
+ def __init__(self, *args, **kwargs):
+ if "errcode" not in kwargs:
+ kwargs["errcode"] = Codes.FORBIDDEN
+ super(ThreepidValidationError, self).__init__(*args, **kwargs)
+
+
class IncompatibleRoomVersionError(SynapseError):
"""A server is trying to join a room whose version it does not support.
diff --git a/synapse/api/urls.py b/synapse/api/urls.py
index 3c6bddff7a..e16c386a14 100644
--- a/synapse/api/urls.py
+++ b/synapse/api/urls.py
@@ -26,6 +26,7 @@ CLIENT_API_PREFIX = "/_matrix/client"
FEDERATION_PREFIX = "/_matrix/federation"
FEDERATION_V1_PREFIX = FEDERATION_PREFIX + "/v1"
FEDERATION_V2_PREFIX = FEDERATION_PREFIX + "/v2"
+FEDERATION_UNSTABLE_PREFIX = FEDERATION_PREFIX + "/unstable"
STATIC_PREFIX = "/_matrix/static"
WEB_CLIENT_PREFIX = "/_matrix/client"
CONTENT_REPO_PREFIX = "/_matrix/content"
diff --git a/synapse/config/emailconfig.py b/synapse/config/emailconfig.py
index 342a6ce5fd..fe7dd5645f 100644
--- a/synapse/config/emailconfig.py
+++ b/synapse/config/emailconfig.py
@@ -34,7 +34,15 @@ class EmailConfig(Config):
email_config = config.get("email", {})
self.email_enable_notifs = email_config.get("enable_notifs", False)
- if self.email_enable_notifs:
+ self.email_enable_password_reset_from_is = email_config.get(
+ "enable_password_reset_from_is", False,
+ )
+
+ self.validation_token_lifetime = email_config.get(
+ "validation_token_lifetime", 15 * 60,
+ )
+
+ if email_config != {}:
# make sure we can import the required deps
import jinja2
import bleach
@@ -42,6 +50,80 @@ class EmailConfig(Config):
jinja2
bleach
+ self.email_smtp_host = email_config["smtp_host"]
+ self.email_smtp_port = email_config["smtp_port"]
+ self.email_notif_from = email_config["notif_from"]
+
+ template_dir = email_config.get("template_dir")
+ # we need an absolute path, because we change directory after starting (and
+ # we don't yet know what auxilliary templates like mail.css we will need).
+ # (Note that loading as package_resources with jinja.PackageLoader doesn't
+ # work for the same reason.)
+ if not template_dir:
+ template_dir = pkg_resources.resource_filename(
+ 'synapse', 'res/templates'
+ )
+ self.email_template_dir = os.path.abspath(template_dir)
+ self.email_riot_base_url = email_config.get(
+ "riot_base_url", None
+ )
+ self.email_smtp_user = email_config.get(
+ "smtp_user", None
+ )
+ self.email_smtp_pass = email_config.get(
+ "smtp_pass", None
+ )
+ self.require_transport_security = email_config.get(
+ "require_transport_security", False
+ )
+ self.email_app_name = email_config.get("app_name", "Matrix")
+
+ # make sure it's valid
+ parsed = email.utils.parseaddr(self.email_notif_from)
+ if parsed[1] == '':
+ raise RuntimeError("Invalid notif_from address")
+
+ if not self.email_enable_password_reset_from_is:
+ required = [
+ "smtp_host",
+ "smtp_port",
+ "notif_from",
+ ]
+
+ missing = []
+ for k in required:
+ if k not in email_config:
+ missing.append(k)
+
+ if (len(missing) > 0):
+ raise RuntimeError(
+ "email.enable_password_reset_from_is is False "
+ "but required keys are missing: %s" %
+ (", ".join(["email." + k for k in missing]),)
+ )
+
+ # Templates for password reset emails
+ self.email_password_reset_template_html = email_config.get(
+ "password_reset_template_html", "password_reset.html",
+ )
+ self.email_password_reset_template_text = email_config.get(
+ "password_reset_template_text", "password_reset.txt",
+ )
+
+ # Check templates exist
+ for f in [self.email_password_reset_template_html,
+ self.email_password_reset_template_text]:
+ p = os.path.join(self.email_template_dir, f)
+ if not os.path.isfile(p):
+ raise ConfigError("Unable to find template file %s" % (p, ))
+
+ if config.get("public_baseurl") is None:
+ raise RuntimeError(
+ "email.enable_password_reset_from_is is False but no "
+ "public_baseurl is set"
+ )
+
+ if self.email_enable_notifs:
required = [
"smtp_host",
"smtp_port",
@@ -66,9 +148,6 @@ class EmailConfig(Config):
"email.enable_notifs is True but no public_baseurl is set"
)
- self.email_smtp_host = email_config["smtp_host"]
- self.email_smtp_port = email_config["smtp_port"]
- self.email_notif_from = email_config["notif_from"]
self.email_notif_template_html = email_config["notif_template_html"]
self.email_notif_template_text = email_config["notif_template_text"]
self.email_expiry_template_html = email_config.get(
@@ -78,79 +157,88 @@ class EmailConfig(Config):
"expiry_template_text", "notice_expiry.txt",
)
- template_dir = email_config.get("template_dir")
- # we need an absolute path, because we change directory after starting (and
- # we don't yet know what auxilliary templates like mail.css we will need).
- # (Note that loading as package_resources with jinja.PackageLoader doesn't
- # work for the same reason.)
- if not template_dir:
- template_dir = pkg_resources.resource_filename(
- 'synapse', 'res/templates'
- )
- template_dir = os.path.abspath(template_dir)
-
for f in self.email_notif_template_text, self.email_notif_template_html:
- p = os.path.join(template_dir, f)
+ p = os.path.join(self.email_template_dir, f)
if not os.path.isfile(p):
raise ConfigError("Unable to find email template file %s" % (p, ))
- self.email_template_dir = template_dir
self.email_notif_for_new_users = email_config.get(
"notif_for_new_users", True
)
- self.email_riot_base_url = email_config.get(
- "riot_base_url", None
- )
- self.email_smtp_user = email_config.get(
- "smtp_user", None
- )
- self.email_smtp_pass = email_config.get(
- "smtp_pass", None
- )
- self.require_transport_security = email_config.get(
- "require_transport_security", False
- )
- if "app_name" in email_config:
- self.email_app_name = email_config["app_name"]
- else:
- self.email_app_name = "Matrix"
- # make sure it's valid
- parsed = email.utils.parseaddr(self.email_notif_from)
- if parsed[1] == '':
- raise RuntimeError("Invalid notif_from address")
- else:
- self.email_enable_notifs = False
- # Not much point setting defaults for the rest: it would be an
- # error for them to be used.
+ def _get_template_content(self, template_dir, path):
+ fullpath = os.path.join(template_dir, path)
+
+ try:
+ with open(fullpath) as f:
+ return f.read()
+ except Exception as e:
+ raise ConfigError(
+ "Unable to read content of template: %s - %s", fullpath, e,
+ )
def default_config(self, config_dir_path, server_name, **kwargs):
return """
- # Enable sending emails for notification events or expiry notices
- # Defining a custom URL for Riot is only needed if email notifications
- # should contain links to a self-hosted installation of Riot; when set
- # the "app_name" setting is ignored.
+ # Enable sending emails for password resets, notification events or
+ # account expiry notices
#
# If your SMTP server requires authentication, the optional smtp_user &
# smtp_pass variables should be used
#
#email:
- # enable_notifs: false
# smtp_host: "localhost"
- # smtp_port: 25
+ # smtp_port: 25 # SSL: 465, STARTTLS: 587
# smtp_user: "exampleusername"
# smtp_pass: "examplepassword"
# require_transport_security: False
# notif_from: "Your Friendly %(app)s Home Server <noreply@example.com>"
# app_name: Matrix
- # # if template_dir is unset, uses the example templates that are part of
- # # the Synapse distribution.
+ #
+ # # Enable sending email notifications for new chat messages
+ # #
+ # enable_notifs: False
+ #
+ # # Enable email notifications by default
+ # notif_for_new_users: True
+ #
+ # # Defining a custom URL for Riot is only needed if email notifications
+ # # should contain links to a self-hosted installation of Riot; when set
+ # # the "app_name" setting is ignored
+ # riot_base_url: "http://localhost/riot"
+ #
+ # # Disable sending password reset emails via the configured, trusted
+ # # identity servers
+ # #
+ # # IMPORTANT! This will give a malicious or overtaken identity server
+ # # the ability to reset passwords for your users! Make absolutely sure
+ # # that you want to do this! It is strongly recommended that password
+ # # reset emails be sent by the homeserver instead
+ # #
+ # #enable_password_reset_from_is: False
+ #
+ # # Configure the time in seconds that a validation email or text
+ # # message code will expire after sending
+ # #
+ # # This is currently used for password resets
+ # #validation_token_lifetime: 900 # 15 minutes
+ #
+ # # Template directory. All template files should be stored within this
+ # # directory
+ # #
# #template_dir: res/templates
+ #
+ # # Templates for email notifications
+ # #
# notif_template_html: notif_mail.html
# notif_template_text: notif_mail.txt
- # # Templates for account expiry notices.
+ #
+ # # Templates for account expiry notices
+ # #
# expiry_template_html: notice_expiry.html
# expiry_template_text: notice_expiry.txt
- # notif_for_new_users: True
- # riot_base_url: "http://localhost/riot"
+ #
+ # # Templates for password reset emails sent by the homeserver
+ # #
+ # #password_reset_template_html: password_reset.html
+ # #password_reset_template_text: password_reset.txt
"""
diff --git a/synapse/push/mailer.py b/synapse/push/mailer.py
index c269bcf4a4..005fbc06e2 100644
--- a/synapse/push/mailer.py
+++ b/synapse/push/mailer.py
@@ -80,10 +80,10 @@ ALLOWED_ATTRS = {
class Mailer(object):
- def __init__(self, hs, app_name, notif_template_html, notif_template_text):
+ def __init__(self, hs, app_name, template_html, template_text):
self.hs = hs
- self.notif_template_html = notif_template_html
- self.notif_template_text = notif_template_text
+ self.template_html = template_html
+ self.template_text = template_text
self.sendmail = self.hs.get_sendmail()
self.store = self.hs.get_datastore()
@@ -94,21 +94,48 @@ class Mailer(object):
logger.info("Created Mailer for app_name %s" % app_name)
@defer.inlineCallbacks
- def send_notification_mail(self, app_id, user_id, email_address,
- push_actions, reason):
- try:
- from_string = self.hs.config.email_notif_from % {
- "app": self.app_name
- }
- except TypeError:
- from_string = self.hs.config.email_notif_from
+ def send_password_reset_mail(
+ self,
+ email_address,
+ token,
+ client_secret,
+ sid,
+ ):
+ """Send an email with a password reset link to a user
+
+ Args:
+ email_address (str): Email address we're sending the password
+ reset to
+ token (str): Unique token generated by the server to verify
+ password reset email was received
+ client_secret (str): Unique token generated by the client to
+ group together multiple email sending attempts
+ sid (str): The generated session ID
+ """
+ if email.utils.parseaddr(email_address)[1] == '':
+ raise RuntimeError("Invalid 'to' email address")
+
+ link = (
+ self.hs.config.public_baseurl +
+ "_matrix/identity/api/v1/validate/email/submitToken"
+ "?token=%s&client_secret=%s&sid=%s" %
+ (token, client_secret, sid)
+ )
- raw_from = email.utils.parseaddr(from_string)[1]
- raw_to = email.utils.parseaddr(email_address)[1]
+ template_vars = {
+ "link": link,
+ }
- if raw_to == '':
- raise RuntimeError("Invalid 'to' address")
+ yield self.send_email(
+ email_address,
+ "[%s] Password Reset Email" % self.hs.config.server_name,
+ template_vars,
+ )
+ @defer.inlineCallbacks
+ def send_notification_mail(self, app_id, user_id, email_address,
+ push_actions, reason):
+ """Send email regarding a user's room notifications"""
rooms_in_order = deduped_ordered_list(
[pa['room_id'] for pa in push_actions]
)
@@ -176,14 +203,36 @@ class Mailer(object):
"reason": reason,
}
- html_text = self.notif_template_html.render(**template_vars)
+ yield self.send_email(
+ email_address,
+ "[%s] %s" % (self.app_name, summary_text),
+ template_vars,
+ )
+
+ @defer.inlineCallbacks
+ def send_email(self, email_address, subject, template_vars):
+ """Send an email with the given information and template text"""
+ try:
+ from_string = self.hs.config.email_notif_from % {
+ "app": self.app_name
+ }
+ except TypeError:
+ from_string = self.hs.config.email_notif_from
+
+ raw_from = email.utils.parseaddr(from_string)[1]
+ raw_to = email.utils.parseaddr(email_address)[1]
+
+ if raw_to == '':
+ raise RuntimeError("Invalid 'to' address")
+
+ html_text = self.template_html.render(**template_vars)
html_part = MIMEText(html_text, "html", "utf8")
- plain_text = self.notif_template_text.render(**template_vars)
+ plain_text = self.template_text.render(**template_vars)
text_part = MIMEText(plain_text, "plain", "utf8")
multipart_msg = MIMEMultipart('alternative')
- multipart_msg['Subject'] = "[%s] %s" % (self.app_name, summary_text)
+ multipart_msg['Subject'] = subject
multipart_msg['From'] = from_string
multipart_msg['To'] = email_address
multipart_msg['Date'] = email.utils.formatdate()
diff --git a/synapse/push/pusher.py b/synapse/push/pusher.py
index 14bc7823cf..aff85daeb5 100644
--- a/synapse/push/pusher.py
+++ b/synapse/push/pusher.py
@@ -70,8 +70,8 @@ class PusherFactory(object):
mailer = Mailer(
hs=self.hs,
app_name=app_name,
- notif_template_html=self.notif_template_html,
- notif_template_text=self.notif_template_text,
+ template_html=self.notif_template_html,
+ template_text=self.notif_template_text,
)
self.mailers[app_name] = mailer
return EmailPusher(self.hs, pusherdict, mailer)
diff --git a/synapse/rest/client/v2_alpha/account.py b/synapse/rest/client/v2_alpha/account.py
index ee069179f0..37d5e67748 100644
--- a/synapse/rest/client/v2_alpha/account.py
+++ b/synapse/rest/client/v2_alpha/account.py
@@ -27,7 +27,9 @@ from synapse.http.servlet import (
assert_params_in_dict,
parse_json_object_from_request,
)
+from synapse.push.mailer import Mailer, load_jinja2_templates
from synapse.util.msisdn import phone_number_to_msisdn
+from synapse.util.stringutils import random_string
from synapse.util.threepids import check_3pid_allowed
from ._base import client_v2_patterns, interactive_auth_handler
@@ -41,14 +43,35 @@ class EmailPasswordRequestTokenRestServlet(RestServlet):
def __init__(self, hs):
super(EmailPasswordRequestTokenRestServlet, self).__init__()
self.hs = hs
+ self.datastore = hs.get_datastore()
+ self.config = hs.config
self.identity_handler = hs.get_handlers().identity_handler
+ if not hs.config.email_enable_password_reset_from_is:
+ templates = load_jinja2_templates(
+ config=hs.config,
+ template_html_name=hs.config.email_password_reset_template_html,
+ template_text_name=hs.config.email_password_reset_template_text,
+ )
+ self.mailer = Mailer(
+ hs=self.hs,
+ app_name=self.config.email_app_name,
+ template_html=templates[0],
+ template_text=templates[1],
+ )
+
+ # Create a background job for culling expired 3PID validity tokens
+ # every minute
+ hs.get_clock().looping_call(
+ self.datastore.cull_expired_threepid_validation_tokens, 60 * 1000,
+ )
+
@defer.inlineCallbacks
def on_POST(self, request):
body = parse_json_object_from_request(request)
assert_params_in_dict(body, [
- 'id_server', 'client_secret', 'email', 'send_attempt'
+ 'client_secret', 'email', 'send_attempt'
])
if not check_3pid_allowed(self.hs, "email", body['email']):
@@ -65,9 +88,90 @@ class EmailPasswordRequestTokenRestServlet(RestServlet):
if existingUid is None:
raise SynapseError(400, "Email not found", Codes.THREEPID_NOT_FOUND)
- ret = yield self.identity_handler.requestEmailToken(**body)
+ if self.config.email_enable_password_reset_from_is:
+ if 'id_server' not in body:
+ raise SynapseError(400, "Missing 'id_server' param in body")
+
+ # Have the identity server handle the password reset flow
+ ret = yield self.identity_handler.requestEmailToken(**body)
+ else:
+ # Send password reset emails from Synapse
+ sid = yield self.send_password_reset(**body)
+
+ # Wrap the session id in a JSON object
+ ret = {"sid": sid}
+
defer.returnValue((200, ret))
+ @defer.inlineCallbacks
+ def send_password_reset(self, email, client_secret, send_attempt, **kwargs):
+ """Send a password reset email
+
+ Args:
+ email (str): The user's email address
+ client_secret (str): The provided client secret
+ send_attempt (int): Which send attempt this is
+
+ Returns:
+ The new session_id upon success
+
+ Raises:
+ SynapseError is an error occurred when sending the email
+ """
+ # Check that this email/client_secret/send_attempt combo is new or
+ # greater than what we've seen previously
+ ret = yield self.datastore.get_threepid_validation_session(
+ "email", client_secret, address=email, validated=False,
+ )
+
+ logger.info("Ret is %s", ret)
+
+ # Check to see if a session already exists and that it is not yet
+ # marked as validated
+ if ret and ret.get("validated_at") is None:
+ session_id = ret['session_id']
+ last_send_attempt = ret['last_send_attempt']
+
+ # Check that the send_attempt is higher than previous attempts
+ if send_attempt <= last_send_attempt:
+ # If not, just return a success without sending an email
+ defer.returnValue(session_id)
+ else:
+ # An non-validated session does not exist yet.
+ # Generate a session id
+ session_id = random_string(16)
+
+ # Generate a new validation token
+ token = random_string(32)
+
+ # Send the mail with the link containing the token, client_secret
+ # and session_id
+ try:
+ yield self.mailer.send_password_reset_mail(
+ email, token, client_secret, session_id,
+ )
+ except Exception:
+ logger.exception(
+ "Error sending a password reset email to %s", email,
+ )
+ raise SynapseError(
+ 500, "An error was encountered when sending the password reset email"
+ )
+
+ token_expires = (self.hs.clock.time_msec() +
+ self.config.validation_token_lifetime * 1000)
+
+ yield self.datastore.insert_threepid_validation_token(
+ session_id, token, kwargs.get("next_link"), token_expires,
+ )
+
+ # Save the session_id and send_attempt to the database
+ yield self.datastore.upsert_threepid_validation_session(
+ "email", email, client_secret, send_attempt, session_id,
+ )
+
+ defer.returnValue(session_id)
+
class MsisdnPasswordRequestTokenRestServlet(RestServlet):
PATTERNS = client_v2_patterns("/account/password/msisdn/requestToken$")
@@ -144,6 +248,7 @@ class PasswordRestServlet(RestServlet):
result, params, _ = yield self.auth_handler.check_auth(
[[LoginType.EMAIL_IDENTITY], [LoginType.MSISDN]],
body, self.hs.get_ip_from_request(request),
+ password_servlet=True,
)
if LoginType.EMAIL_IDENTITY in result:
diff --git a/synapse/storage/_base.py b/synapse/storage/_base.py
index fa6839ceca..ba03487b57 100644
--- a/synapse/storage/_base.py
+++ b/synapse/storage/_base.py
@@ -570,6 +570,10 @@ class SQLBaseStore(object):
Args:
table : string giving the table name
values : dict of new column names and values for them
+ or_ignore : bool stating whether an exception should be raised
+ when a conflicting row already exists. If True, False will be
+ returned by the function instead
+ desc : string giving a description of the transaction
Returns:
bool: Whether the row was inserted or not. Only useful when
@@ -1210,8 +1214,8 @@ class SQLBaseStore(object):
)
txn.execute(select_sql, list(keyvalues.values()))
-
row = txn.fetchone()
+
if not row:
if allow_none:
return None
diff --git a/synapse/storage/prepare_database.py b/synapse/storage/prepare_database.py
index c1711bc8bd..23a4baa484 100644
--- a/synapse/storage/prepare_database.py
+++ b/synapse/storage/prepare_database.py
@@ -25,7 +25,7 @@ logger = logging.getLogger(__name__)
# Remember to update this number every time a change is made to database
# schema files, so the users will be informed on server restarts.
-SCHEMA_VERSION = 54
+SCHEMA_VERSION = 55
dir_path = os.path.abspath(os.path.dirname(__file__))
diff --git a/synapse/storage/registration.py b/synapse/storage/registration.py
index 4cf159ba81..e4aa386a2d 100644
--- a/synapse/storage/registration.py
+++ b/synapse/storage/registration.py
@@ -17,12 +17,13 @@
import re
+from six import iterkeys
from six.moves import range
from twisted.internet import defer
from synapse.api.constants import UserTypes
-from synapse.api.errors import Codes, StoreError
+from synapse.api.errors import Codes, StoreError, ThreepidValidationError
from synapse.storage import background_updates
from synapse.storage._base import SQLBaseStore
from synapse.types import UserID
@@ -422,7 +423,7 @@ class RegistrationWorkerStore(SQLBaseStore):
defer.returnValue(None)
@defer.inlineCallbacks
- def get_user_id_by_threepid(self, medium, address):
+ def get_user_id_by_threepid(self, medium, address, require_verified=False):
"""Returns user id from threepid
Args:
@@ -963,7 +964,6 @@ class RegistrationStore(
We do this by grandfathering in existing user threepids assuming that
they used one of the server configured trusted identity servers.
"""
-
id_servers = set(self.config.trusted_third_party_id_servers)
def _bg_user_threepids_grandfather_txn(txn):
@@ -984,3 +984,256 @@ class RegistrationStore(
yield self._end_background_update("user_threepids_grandfather")
defer.returnValue(1)
+
+ def get_threepid_validation_session(
+ self,
+ medium,
+ client_secret,
+ address=None,
+ sid=None,
+ validated=None,
+ ):
+ """Gets a session_id and last_send_attempt (if available) for a
+ client_secret/medium/(address|session_id) combo
+
+ Args:
+ medium (str): The medium of the 3PID
+ address (str): The address of the 3PID
+ sid (str): The ID of the validation session
+ client_secret (str): A unique string provided by the client to
+ help identify this validation attempt
+ validated (bool|None): Whether sessions should be filtered by
+ whether they have been validated already or not. None to
+ perform no filtering
+
+ Returns:
+ deferred {str, int}|None: A dict containing the
+ latest session_id and send_attempt count for this 3PID.
+ Otherwise None if there hasn't been a previous attempt
+ """
+ keyvalues = {
+ "medium": medium,
+ "client_secret": client_secret,
+ }
+
+ if sid:
+ keyvalues["session_id"] = sid
+ elif address:
+ keyvalues["address"] = address
+ else:
+ raise StoreError(500, "Either address or sid must be provided")
+
+ def get_threepid_validation_session_txn(txn):
+ cols_to_return = [
+ "session_id", "medium", "address",
+ "client_secret", "last_send_attempt", "validated_at",
+ ]
+
+ sql = "SELECT %s FROM threepid_validation_session" % ", ".join(cols_to_return)
+ sql += " WHERE %s" % " AND ".join("%s = ?" % k for k in iterkeys(keyvalues))
+
+ if validated is not None:
+ sql += " AND validated_at IS " + ("NOT NULL" if validated else "NULL")
+
+ sql += " LIMIT 1"
+
+ txn.execute(sql, list(keyvalues.values()))
+ row = txn.fetchone()
+
+ if not row:
+ return None
+
+ # Convert the resulting row to a dictionary
+ ret = {}
+ for i in range(len(cols_to_return)):
+ ret[cols_to_return[i]] = row[i]
+
+ return ret
+
+ return self.runInteraction(
+ "get_threepid_validation_session",
+ get_threepid_validation_session_txn,
+ )
+
+ @defer.inlineCallbacks
+ def validate_threepid_session(
+ self,
+ session_id,
+ client_secret,
+ token,
+ current_ts,
+ ):
+ """Attempt to validate a threepid session using a token
+
+ Args:
+ session_id (str): The id of a validation session
+ client_secret (str): A unique string provided by the client to
+ help identify this validation attempt
+ token (str): A validation token
+ current_ts (int): The current unix time in milliseconds. Used for
+ checking token expiry status
+
+ Returns:
+ deferred str|None: A str representing a link to redirect the user
+ to if there is one.
+ """
+ row = yield self._simple_select_one(
+ table="threepid_validation_session",
+ keyvalues={"session_id": session_id},
+ retcols=["client_secret", "validated_at"],
+ desc="validate_threepid_session_select_session",
+ allow_none=True,
+ )
+
+ if not row:
+ raise ThreepidValidationError(400, "Unknown session_id")
+ retrieved_client_secret = row["client_secret"]
+ validated_at = row["validated_at"]
+
+ if validated_at:
+ raise ThreepidValidationError(
+ 400, "This session has already been validated",
+ )
+ if retrieved_client_secret != client_secret:
+ raise ThreepidValidationError(
+ 400, "This client_secret does not match the provided session_id",
+ )
+
+ row = yield self._simple_select_one(
+ table="threepid_validation_token",
+ keyvalues={"session_id": session_id, "token": token},
+ retcols=["expires", "next_link"],
+ desc="validate_threepid_session_select_token",
+ allow_none=True,
+ )
+
+ if not row:
+ raise ThreepidValidationError(
+ 400, "Validation token not found or has expired",
+ )
+ expires = row["expires"]
+ next_link = row["next_link"]
+
+ if expires <= current_ts:
+ raise ThreepidValidationError(
+ 400, "This token has expired. Please request a new one",
+ )
+
+ # Looks good. Validate the session
+ yield self._simple_update(
+ table="threepid_validation_session",
+ keyvalues={"session_id": session_id},
+ updatevalues={"validated_at": self.clock.time_msec()},
+ desc="validate_threepid_session_update",
+ )
+
+ # Return next_link if it exists
+ defer.returnValue(next_link)
+
+ def upsert_threepid_validation_session(
+ self,
+ medium,
+ address,
+ client_secret,
+ send_attempt,
+ session_id,
+ validated_at=None,
+ ):
+ """Upsert a threepid validation session
+
+ Args:
+ medium (str): The medium of the 3PID
+ address (str): The address of the 3PID
+ client_secret (str): A unique string provided by the client to
+ help identify this validation attempt
+ session_id (str): The id of this validation session
+ validated_at (int): The unix timestamp in milliseconds of when
+ the session was marked as valid
+ """
+ insertion_values = {
+ "medium": medium,
+ "address": address,
+ "client_secret": client_secret,
+ }
+
+ if validated_at:
+ insertion_values["validated_at"] = validated_at
+
+ return self._simple_upsert(
+ table="threepid_validation_session",
+ keyvalues={"session_id": session_id},
+ values={"last_send_attempt": send_attempt},
+ insertion_values=insertion_values,
+ desc="upsert_threepid_validation_session",
+ )
+
+ def insert_threepid_validation_token(
+ self,
+ session_id,
+ token,
+ next_link,
+ expires,
+ ):
+ """Insert a new 3PID validation token and details
+
+ Args:
+ session_id (str): The id of the validation session this attempt
+ is related to
+ token (str): The validation token
+ expires (int): The timestamp for which after this token will no
+ longer be valid
+ """
+ return self._simple_insert(
+ table="threepid_validation_token",
+ values={
+ "session_id": session_id,
+ "token": token,
+ "next_link": next_link,
+ "expires": expires,
+ },
+ desc="insert_threepid_validation_token",
+ )
+
+ def cull_expired_threepid_validation_tokens(self):
+ """Remove threepid validation tokens with expiry dates that have passed"""
+ def cull_expired_threepid_validation_tokens_txn(txn, ts):
+ sql = """
+ DELETE FROM threepid_validation_token WHERE
+ expires < ?
+ """
+
+ return txn.execute(sql, (ts,))
+
+ return self.runInteraction(
+ "cull_expired_threepid_validation_tokens",
+ cull_expired_threepid_validation_tokens_txn,
+ self.clock.time_msec(),
+ )
+
+ def delete_threepid_session(self, session_id):
+ """Removes a threepid validation session from the database. This can
+ be done after validation has been performed and whatever action was
+ waiting on it has been carried out
+
+ Args:
+ session_id (str): The ID of the session to delete
+ """
+ return self._simple_delete(
+ table="threepid_validation_session",
+ keyvalues={"session_id": session_id},
+ desc="delete_threepid_session",
+ )
+
+ def delete_threepid_tokens(self, session_id):
+ """Removes threepid validation tokens from the database which match a
+ given session ID.
+
+ Args:
+ session_id (str): The ID of the session to delete
+ """
+ # Delete tokens associated with this session id
+ return self._simple_delete_one(
+ table="threepid_validation_token",
+ keyvalues={"session_id": session_id},
+ desc="delete_threepid_session_tokens",
+ )
diff --git a/synapse/storage/schema/delta/55/track_threepid_validations.sql b/synapse/storage/schema/delta/55/track_threepid_validations.sql
new file mode 100644
index 0000000000..a8eced2e0a
--- /dev/null
+++ b/synapse/storage/schema/delta/55/track_threepid_validations.sql
@@ -0,0 +1,31 @@
+/* Copyright 2019 The Matrix.org Foundation C.I.C.
+ *
+ * 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.
+ */
+CREATE TABLE IF NOT EXISTS threepid_validation_session (
+ session_id TEXT PRIMARY KEY,
+ medium TEXT NOT NULL,
+ address TEXT NOT NULL,
+ client_secret TEXT NOT NULL,
+ last_send_attempt BIGINT NOT NULL,
+ validated_at BIGINT
+);
+
+CREATE TABLE IF NOT EXISTS threepid_validation_token (
+ token TEXT PRIMARY KEY,
+ session_id TEXT NOT NULL,
+ next_link TEXT,
+ expires BIGINT NOT NULL
+);
+
+CREATE INDEX threepid_validation_token_session_id ON threepid_validation_token(session_id);
|