Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Commit

Permalink
Merge pull request #821 from matrix-org/dbkr/email_unsubscribe
Browse files Browse the repository at this point in the history
Email unsubscribe links that don't require logging in
  • Loading branch information
dbkr committed Jun 2, 2016
2 parents 7a5a5f2 + 745ddb4 commit 6bb9aac
Show file tree
Hide file tree
Showing 6 changed files with 125 additions and 17 deletions.
32 changes: 23 additions & 9 deletions synapse/api/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.

"""This module contains classes for authenticating the user."""
from canonicaljson import encode_canonical_json
from signedjson.key import decode_verify_key_bytes
from signedjson.sign import verify_signed_json, SignatureVerifyException
Expand Down Expand Up @@ -42,13 +41,20 @@


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):
self.hs = hs
self.clock = hs.get_clock()
self.store = hs.get_datastore()
self.state = hs.get_state_handler()
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([
"gen = ",
"guest = ",
Expand Down Expand Up @@ -525,7 +531,7 @@ def _get_named_level(self, auth_events, name, default):
return default

@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.
Args:
Expand All @@ -547,7 +553,7 @@ def get_user_by_req(self, request, allow_guest=False):
)

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"]
token_id = user_info["token_id"]
is_guest = user_info["is_guest"]
Expand Down Expand Up @@ -608,7 +614,7 @@ def _get_appservice_user_id(self, request_args):
defer.returnValue(user_id)

@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.
Args:
Expand All @@ -619,19 +625,19 @@ def get_user_by_access_token(self, token):
AuthError if no user by that token exists or the token is invalid.
"""
try:
ret = yield self.get_user_from_macaroon(token)
ret = yield self.get_user_from_macaroon(token, rights)
except AuthError:
# TODO(daniel): Remove this fallback when all existing access tokens
# have been re-issued as macaroons.
ret = yield self._look_up_user_by_access_token(token)
defer.returnValue(ret)

@defer.inlineCallbacks
def get_user_from_macaroon(self, macaroon_str):
def get_user_from_macaroon(self, macaroon_str, rights="access"):
try:
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 = None
Expand All @@ -654,6 +660,13 @@ def get_user_from_macaroon(self, macaroon_str):
"is_guest": True,
"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:
# This codepath exists so that we can actually return a
# token ID, because we use token IDs in place of device
Expand Down Expand Up @@ -685,7 +698,8 @@ def validate_macaroon(self, macaroon, type_string, verify_expiry):
Args:
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.
This should really always be True, but no clients currently implement
token refresh, so we can't enforce expiry yet.
Expand Down
23 changes: 22 additions & 1 deletion synapse/app/pusher.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from synapse.config.database import DatabaseConfig
from synapse.config.logger import LoggingConfig
from synapse.config.emailconfig import EmailConfig
from synapse.config.key import KeyConfig
from synapse.http.site import SynapseSite
from synapse.metrics.resource import MetricsResource, METRICS_PREFIX
from synapse.storage.roommember import RoomMemberStore
Expand Down Expand Up @@ -63,6 +64,26 @@ def read_config(self, config):
self.pid_file = self.abspath(config.get("pid_file"))
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):
pid_file = self.abspath("pusher.pid")
return """\
Expand Down Expand Up @@ -95,7 +116,7 @@ def default_config(self, server_name, **kwargs):
""" % locals()


class PusherSlaveConfig(SlaveConfig, LoggingConfig, EmailConfig):
class PusherSlaveConfig(SlaveConfig, LoggingConfig, EmailConfig, KeyConfig):
pass


Expand Down
5 changes: 5 additions & 0 deletions synapse/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,11 @@ def generate_short_term_login_token(self, user_id, duration_in_ms=(2 * 60 * 1000
macaroon.add_first_party_caveat("time < %d" % (expiry,))
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):
try:
macaroon = pymacaroons.Macaroon.deserialize(login_token)
Expand Down
2 changes: 1 addition & 1 deletion synapse/push/emailpusher.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,5 +279,5 @@ def send_notification(self, push_actions, reason):
logger.info("Sending notif email for user %r", self.user_id)

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
)
23 changes: 18 additions & 5 deletions synapse/push/mailer.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ class Mailer(object):
def __init__(self, hs, app_name):
self.hs = hs
self.store = self.hs.get_datastore()
self.auth_handler = self.hs.get_auth_handler()
self.state_handler = self.hs.get_state_handler()
loader = jinja2.FileSystemLoader(self.hs.config.email_template_dir)
self.app_name = app_name
Expand All @@ -96,7 +97,8 @@ def __init__(self, hs, app_name):
)

@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_to = email.utils.parseaddr(email_address)[1]

Expand Down Expand Up @@ -160,7 +162,9 @@ def _fetch_room_state(room_id):

template_vars = {
"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,
"app_name": self.app_name,
"rooms": rooms,
Expand Down Expand Up @@ -426,9 +430,18 @@ def make_notif_link(self, notif):
notif['room_id'], notif['event_id']
)

def make_unsubscribe_link(self):
# XXX: matrix.to
return "https://vector.im/#/settings"
def make_unsubscribe_link(self, user_id, app_id, email_address):
params = {
"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"):
if value[0:6] != "mxc://":
Expand Down
57 changes: 56 additions & 1 deletion synapse/rest/client/v1/pusher.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@

from synapse.api.errors import SynapseError, Codes
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

Expand Down Expand Up @@ -136,6 +140,57 @@ def on_OPTIONS(self, _):
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):
PushersRestServlet(hs).register(http_server)
PushersSetRestServlet(hs).register(http_server)
PushersRemoveRestServlet(hs).register(http_server)

0 comments on commit 6bb9aac

Please sign in to comment.