mirror of
https://mau.dev/maunium/synapse.git
synced 2024-11-16 23:11:34 +01:00
Merge pull request #821 from matrix-org/dbkr/email_unsubscribe
Email unsubscribe links that don't require logging in
This commit is contained in:
commit
6bb9aacf9d
6 changed files with 125 additions and 17 deletions
|
@ -13,7 +13,6 @@
|
||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
|
|
||||||
"""This module contains classes for authenticating the user."""
|
|
||||||
from canonicaljson import encode_canonical_json
|
from canonicaljson import encode_canonical_json
|
||||||
from signedjson.key import decode_verify_key_bytes
|
from signedjson.key import decode_verify_key_bytes
|
||||||
from signedjson.sign import verify_signed_json, SignatureVerifyException
|
from signedjson.sign import verify_signed_json, SignatureVerifyException
|
||||||
|
@ -42,13 +41,20 @@ AuthEventTypes = (
|
||||||
|
|
||||||
|
|
||||||
class Auth(object):
|
class Auth(object):
|
||||||
|
"""
|
||||||
|
FIXME: This class contains a mix of functions for authenticating users
|
||||||
|
of our client-server API and authenticating events added to room graphs.
|
||||||
|
"""
|
||||||
def __init__(self, hs):
|
def __init__(self, hs):
|
||||||
self.hs = hs
|
self.hs = hs
|
||||||
self.clock = hs.get_clock()
|
self.clock = hs.get_clock()
|
||||||
self.store = hs.get_datastore()
|
self.store = hs.get_datastore()
|
||||||
self.state = hs.get_state_handler()
|
self.state = hs.get_state_handler()
|
||||||
self.TOKEN_NOT_FOUND_HTTP_STATUS = 401
|
self.TOKEN_NOT_FOUND_HTTP_STATUS = 401
|
||||||
|
# Docs for these currently lives at
|
||||||
|
# https://github.com/matrix-org/matrix-doc/blob/master/drafts/macaroons_caveats.rst
|
||||||
|
# In addition, we have type == delete_pusher which grants access only to
|
||||||
|
# delete pushers.
|
||||||
self._KNOWN_CAVEAT_PREFIXES = set([
|
self._KNOWN_CAVEAT_PREFIXES = set([
|
||||||
"gen = ",
|
"gen = ",
|
||||||
"guest = ",
|
"guest = ",
|
||||||
|
@ -525,7 +531,7 @@ class Auth(object):
|
||||||
return default
|
return default
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def get_user_by_req(self, request, allow_guest=False):
|
def get_user_by_req(self, request, allow_guest=False, rights="access"):
|
||||||
""" Get a registered user's ID.
|
""" Get a registered user's ID.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
@ -547,7 +553,7 @@ class Auth(object):
|
||||||
)
|
)
|
||||||
|
|
||||||
access_token = request.args["access_token"][0]
|
access_token = request.args["access_token"][0]
|
||||||
user_info = yield self.get_user_by_access_token(access_token)
|
user_info = yield self.get_user_by_access_token(access_token, rights)
|
||||||
user = user_info["user"]
|
user = user_info["user"]
|
||||||
token_id = user_info["token_id"]
|
token_id = user_info["token_id"]
|
||||||
is_guest = user_info["is_guest"]
|
is_guest = user_info["is_guest"]
|
||||||
|
@ -608,7 +614,7 @@ class Auth(object):
|
||||||
defer.returnValue(user_id)
|
defer.returnValue(user_id)
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def get_user_by_access_token(self, token):
|
def get_user_by_access_token(self, token, rights="access"):
|
||||||
""" Get a registered user's ID.
|
""" Get a registered user's ID.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
@ -619,7 +625,7 @@ class Auth(object):
|
||||||
AuthError if no user by that token exists or the token is invalid.
|
AuthError if no user by that token exists or the token is invalid.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
ret = yield self.get_user_from_macaroon(token)
|
ret = yield self.get_user_from_macaroon(token, rights)
|
||||||
except AuthError:
|
except AuthError:
|
||||||
# TODO(daniel): Remove this fallback when all existing access tokens
|
# TODO(daniel): Remove this fallback when all existing access tokens
|
||||||
# have been re-issued as macaroons.
|
# have been re-issued as macaroons.
|
||||||
|
@ -627,11 +633,11 @@ class Auth(object):
|
||||||
defer.returnValue(ret)
|
defer.returnValue(ret)
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def get_user_from_macaroon(self, macaroon_str):
|
def get_user_from_macaroon(self, macaroon_str, rights="access"):
|
||||||
try:
|
try:
|
||||||
macaroon = pymacaroons.Macaroon.deserialize(macaroon_str)
|
macaroon = pymacaroons.Macaroon.deserialize(macaroon_str)
|
||||||
|
|
||||||
self.validate_macaroon(macaroon, "access", self.hs.config.expire_access_token)
|
self.validate_macaroon(macaroon, rights, self.hs.config.expire_access_token)
|
||||||
|
|
||||||
user_prefix = "user_id = "
|
user_prefix = "user_id = "
|
||||||
user = None
|
user = None
|
||||||
|
@ -654,6 +660,13 @@ class Auth(object):
|
||||||
"is_guest": True,
|
"is_guest": True,
|
||||||
"token_id": None,
|
"token_id": None,
|
||||||
}
|
}
|
||||||
|
elif rights == "delete_pusher":
|
||||||
|
# We don't store these tokens in the database
|
||||||
|
ret = {
|
||||||
|
"user": user,
|
||||||
|
"is_guest": False,
|
||||||
|
"token_id": None,
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
# This codepath exists so that we can actually return a
|
# This codepath exists so that we can actually return a
|
||||||
# token ID, because we use token IDs in place of device
|
# token ID, because we use token IDs in place of device
|
||||||
|
@ -685,7 +698,8 @@ class Auth(object):
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
macaroon(pymacaroons.Macaroon): The macaroon to validate
|
macaroon(pymacaroons.Macaroon): The macaroon to validate
|
||||||
type_string(str): The kind of token this is (e.g. "access", "refresh")
|
type_string(str): The kind of token required (e.g. "access", "refresh",
|
||||||
|
"delete_pusher")
|
||||||
verify_expiry(bool): Whether to verify whether the macaroon has expired.
|
verify_expiry(bool): Whether to verify whether the macaroon has expired.
|
||||||
This should really always be True, but no clients currently implement
|
This should really always be True, but no clients currently implement
|
||||||
token refresh, so we can't enforce expiry yet.
|
token refresh, so we can't enforce expiry yet.
|
||||||
|
|
|
@ -21,6 +21,7 @@ from synapse.config._base import ConfigError
|
||||||
from synapse.config.database import DatabaseConfig
|
from synapse.config.database import DatabaseConfig
|
||||||
from synapse.config.logger import LoggingConfig
|
from synapse.config.logger import LoggingConfig
|
||||||
from synapse.config.emailconfig import EmailConfig
|
from synapse.config.emailconfig import EmailConfig
|
||||||
|
from synapse.config.key import KeyConfig
|
||||||
from synapse.http.site import SynapseSite
|
from synapse.http.site import SynapseSite
|
||||||
from synapse.metrics.resource import MetricsResource, METRICS_PREFIX
|
from synapse.metrics.resource import MetricsResource, METRICS_PREFIX
|
||||||
from synapse.storage.roommember import RoomMemberStore
|
from synapse.storage.roommember import RoomMemberStore
|
||||||
|
@ -63,6 +64,26 @@ class SlaveConfig(DatabaseConfig):
|
||||||
self.pid_file = self.abspath(config.get("pid_file"))
|
self.pid_file = self.abspath(config.get("pid_file"))
|
||||||
self.public_baseurl = config["public_baseurl"]
|
self.public_baseurl = config["public_baseurl"]
|
||||||
|
|
||||||
|
# some things used by the auth handler but not actually used in the
|
||||||
|
# pusher codebase
|
||||||
|
self.bcrypt_rounds = None
|
||||||
|
self.ldap_enabled = None
|
||||||
|
self.ldap_server = None
|
||||||
|
self.ldap_port = None
|
||||||
|
self.ldap_tls = None
|
||||||
|
self.ldap_search_base = None
|
||||||
|
self.ldap_search_property = None
|
||||||
|
self.ldap_email_property = None
|
||||||
|
self.ldap_full_name_property = None
|
||||||
|
|
||||||
|
# We would otherwise try to use the registration shared secret as the
|
||||||
|
# macaroon shared secret if there was no macaroon_shared_secret, but
|
||||||
|
# that means pulling in RegistrationConfig too. We don't need to be
|
||||||
|
# backwards compaitible in the pusher codebase so just make people set
|
||||||
|
# macaroon_shared_secret. We set this to None to prevent it referencing
|
||||||
|
# an undefined key.
|
||||||
|
self.registration_shared_secret = None
|
||||||
|
|
||||||
def default_config(self, server_name, **kwargs):
|
def default_config(self, server_name, **kwargs):
|
||||||
pid_file = self.abspath("pusher.pid")
|
pid_file = self.abspath("pusher.pid")
|
||||||
return """\
|
return """\
|
||||||
|
@ -95,7 +116,7 @@ class SlaveConfig(DatabaseConfig):
|
||||||
""" % locals()
|
""" % locals()
|
||||||
|
|
||||||
|
|
||||||
class PusherSlaveConfig(SlaveConfig, LoggingConfig, EmailConfig):
|
class PusherSlaveConfig(SlaveConfig, LoggingConfig, EmailConfig, KeyConfig):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -529,6 +529,11 @@ class AuthHandler(BaseHandler):
|
||||||
macaroon.add_first_party_caveat("time < %d" % (expiry,))
|
macaroon.add_first_party_caveat("time < %d" % (expiry,))
|
||||||
return macaroon.serialize()
|
return macaroon.serialize()
|
||||||
|
|
||||||
|
def generate_delete_pusher_token(self, user_id):
|
||||||
|
macaroon = self._generate_base_macaroon(user_id)
|
||||||
|
macaroon.add_first_party_caveat("type = delete_pusher")
|
||||||
|
return macaroon.serialize()
|
||||||
|
|
||||||
def validate_short_term_login_token_and_get_user_id(self, login_token):
|
def validate_short_term_login_token_and_get_user_id(self, login_token):
|
||||||
try:
|
try:
|
||||||
macaroon = pymacaroons.Macaroon.deserialize(login_token)
|
macaroon = pymacaroons.Macaroon.deserialize(login_token)
|
||||||
|
|
|
@ -279,5 +279,5 @@ class EmailPusher(object):
|
||||||
logger.info("Sending notif email for user %r", self.user_id)
|
logger.info("Sending notif email for user %r", self.user_id)
|
||||||
|
|
||||||
yield self.mailer.send_notification_mail(
|
yield self.mailer.send_notification_mail(
|
||||||
self.user_id, self.email, push_actions, reason
|
self.app_id, self.user_id, self.email, push_actions, reason
|
||||||
)
|
)
|
||||||
|
|
|
@ -81,6 +81,7 @@ class Mailer(object):
|
||||||
def __init__(self, hs, app_name):
|
def __init__(self, hs, app_name):
|
||||||
self.hs = hs
|
self.hs = hs
|
||||||
self.store = self.hs.get_datastore()
|
self.store = self.hs.get_datastore()
|
||||||
|
self.auth_handler = self.hs.get_auth_handler()
|
||||||
self.state_handler = self.hs.get_state_handler()
|
self.state_handler = self.hs.get_state_handler()
|
||||||
loader = jinja2.FileSystemLoader(self.hs.config.email_template_dir)
|
loader = jinja2.FileSystemLoader(self.hs.config.email_template_dir)
|
||||||
self.app_name = app_name
|
self.app_name = app_name
|
||||||
|
@ -96,7 +97,8 @@ class Mailer(object):
|
||||||
)
|
)
|
||||||
|
|
||||||
@defer.inlineCallbacks
|
@defer.inlineCallbacks
|
||||||
def send_notification_mail(self, user_id, email_address, push_actions, reason):
|
def send_notification_mail(self, app_id, user_id, email_address,
|
||||||
|
push_actions, reason):
|
||||||
raw_from = email.utils.parseaddr(self.hs.config.email_notif_from)[1]
|
raw_from = email.utils.parseaddr(self.hs.config.email_notif_from)[1]
|
||||||
raw_to = email.utils.parseaddr(email_address)[1]
|
raw_to = email.utils.parseaddr(email_address)[1]
|
||||||
|
|
||||||
|
@ -160,7 +162,9 @@ class Mailer(object):
|
||||||
|
|
||||||
template_vars = {
|
template_vars = {
|
||||||
"user_display_name": user_display_name,
|
"user_display_name": user_display_name,
|
||||||
"unsubscribe_link": self.make_unsubscribe_link(),
|
"unsubscribe_link": self.make_unsubscribe_link(
|
||||||
|
user_id, app_id, email_address
|
||||||
|
),
|
||||||
"summary_text": summary_text,
|
"summary_text": summary_text,
|
||||||
"app_name": self.app_name,
|
"app_name": self.app_name,
|
||||||
"rooms": rooms,
|
"rooms": rooms,
|
||||||
|
@ -426,9 +430,18 @@ class Mailer(object):
|
||||||
notif['room_id'], notif['event_id']
|
notif['room_id'], notif['event_id']
|
||||||
)
|
)
|
||||||
|
|
||||||
def make_unsubscribe_link(self):
|
def make_unsubscribe_link(self, user_id, app_id, email_address):
|
||||||
# XXX: matrix.to
|
params = {
|
||||||
return "https://vector.im/#/settings"
|
"access_token": self.auth_handler.generate_delete_pusher_token(user_id),
|
||||||
|
"app_id": app_id,
|
||||||
|
"pushkey": email_address,
|
||||||
|
}
|
||||||
|
|
||||||
|
# XXX: make r0 once API is stable
|
||||||
|
return "%s_matrix/client/unstable/pushers/remove?%s" % (
|
||||||
|
self.hs.config.public_baseurl,
|
||||||
|
urllib.urlencode(params),
|
||||||
|
)
|
||||||
|
|
||||||
def mxc_to_http_filter(self, value, width, height, resize_method="crop"):
|
def mxc_to_http_filter(self, value, width, height, resize_method="crop"):
|
||||||
if value[0:6] != "mxc://":
|
if value[0:6] != "mxc://":
|
||||||
|
|
|
@ -17,7 +17,11 @@ from twisted.internet import defer
|
||||||
|
|
||||||
from synapse.api.errors import SynapseError, Codes
|
from synapse.api.errors import SynapseError, Codes
|
||||||
from synapse.push import PusherConfigException
|
from synapse.push import PusherConfigException
|
||||||
from synapse.http.servlet import parse_json_object_from_request
|
from synapse.http.servlet import (
|
||||||
|
parse_json_object_from_request, parse_string, RestServlet
|
||||||
|
)
|
||||||
|
from synapse.http.server import finish_request
|
||||||
|
from synapse.api.errors import StoreError
|
||||||
|
|
||||||
from .base import ClientV1RestServlet, client_path_patterns
|
from .base import ClientV1RestServlet, client_path_patterns
|
||||||
|
|
||||||
|
@ -136,6 +140,57 @@ class PushersSetRestServlet(ClientV1RestServlet):
|
||||||
return 200, {}
|
return 200, {}
|
||||||
|
|
||||||
|
|
||||||
|
class PushersRemoveRestServlet(RestServlet):
|
||||||
|
"""
|
||||||
|
To allow pusher to be delete by clicking a link (ie. GET request)
|
||||||
|
"""
|
||||||
|
PATTERNS = client_path_patterns("/pushers/remove$")
|
||||||
|
SUCCESS_HTML = "<html><body>You have been unsubscribed</body><html>"
|
||||||
|
|
||||||
|
def __init__(self, hs):
|
||||||
|
super(RestServlet, self).__init__()
|
||||||
|
self.hs = hs
|
||||||
|
self.notifier = hs.get_notifier()
|
||||||
|
self.auth = hs.get_v1auth()
|
||||||
|
|
||||||
|
@defer.inlineCallbacks
|
||||||
|
def on_GET(self, request):
|
||||||
|
requester = yield self.auth.get_user_by_req(request, rights="delete_pusher")
|
||||||
|
user = requester.user
|
||||||
|
|
||||||
|
app_id = parse_string(request, "app_id", required=True)
|
||||||
|
pushkey = parse_string(request, "pushkey", required=True)
|
||||||
|
|
||||||
|
pusher_pool = self.hs.get_pusherpool()
|
||||||
|
|
||||||
|
try:
|
||||||
|
yield pusher_pool.remove_pusher(
|
||||||
|
app_id=app_id,
|
||||||
|
pushkey=pushkey,
|
||||||
|
user_id=user.to_string(),
|
||||||
|
)
|
||||||
|
except StoreError as se:
|
||||||
|
if se.code != 404:
|
||||||
|
# This is fine: they're already unsubscribed
|
||||||
|
raise
|
||||||
|
|
||||||
|
self.notifier.on_new_replication_data()
|
||||||
|
|
||||||
|
request.setResponseCode(200)
|
||||||
|
request.setHeader(b"Content-Type", b"text/html; charset=utf-8")
|
||||||
|
request.setHeader(b"Server", self.hs.version_string)
|
||||||
|
request.setHeader(b"Content-Length", b"%d" % (
|
||||||
|
len(PushersRemoveRestServlet.SUCCESS_HTML),
|
||||||
|
))
|
||||||
|
request.write(PushersRemoveRestServlet.SUCCESS_HTML)
|
||||||
|
finish_request(request)
|
||||||
|
defer.returnValue(None)
|
||||||
|
|
||||||
|
def on_OPTIONS(self, _):
|
||||||
|
return 200, {}
|
||||||
|
|
||||||
|
|
||||||
def register_servlets(hs, http_server):
|
def register_servlets(hs, http_server):
|
||||||
PushersRestServlet(hs).register(http_server)
|
PushersRestServlet(hs).register(http_server)
|
||||||
PushersSetRestServlet(hs).register(http_server)
|
PushersSetRestServlet(hs).register(http_server)
|
||||||
|
PushersRemoveRestServlet(hs).register(http_server)
|
||||||
|
|
Loading…
Reference in a new issue