launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #06979
[Merge] lp:~rharding/launchpad/email_notice_959482 into lp:launchpad
Richard Harding has proposed merging lp:~rharding/launchpad/email_notice_959482 into lp:launchpad.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~rharding/launchpad/email_notice_959482/+merge/99995
= Summary =
Launchpad should make more noise when important user information is changed in case it came from a malicious user. The frequency should be low enough that the extra warnings are worth the extra email notifications sent to users.
== Proposed Fix ==
Whenever a user adds/removes an email address, changes their preferred email address, or adds/removes ssh keys, Launchpad should send a notification to the person that the information was changed with a note on how to proceed if those changes were not intentional.
== Implementation Details ==
We create a new event that can be triggered. The event expects to get the bit of user data that has changed and will generate an email notification with a short description on what was altered.
Note that in order to send a notification that the preferred email has changed, we actually need to notify the previous preferred email. This required us to change the PersonNotification to allow us to specify a specific email address since we can no longer just notify the updated preferred email.
== Tests ==
test_subscribers
test_personnotification
== Demo and Q/A ==
Changing any of the listed properties should schedule an email with the correct note about the change.
== Lint ==
Linting changed files:
lib/lp/registry/configure.zcml
lib/lp/registry/subscribers.py
lib/lp/registry/browser/person.py
lib/lp/registry/emailtemplates/person-details-change.txt
lib/lp/registry/model/person.py
lib/lp/registry/model/personnotification.py
lib/lp/registry/tests/test_personnotification.py
lib/lp/registry/tests/test_subscribers.py
lib/lp/services/identity/model/emailaddress.py
lib/lp/services/verification/model/logintoken.py
--
https://code.launchpad.net/~rharding/launchpad/email_notice_959482/+merge/99995
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~rharding/launchpad/email_notice_959482 into lp:launchpad.
=== modified file 'lib/lp/registry/browser/person.py'
--- lib/lp/registry/browser/person.py 2012-03-02 07:53:53 +0000
+++ lib/lp/registry/browser/person.py 2012-04-03 15:00:32 +0000
@@ -2649,7 +2649,7 @@
found = []
notfound = []
- # verify if we have multiple entries to deactive
+ # Verify if we have multiple entries to activate.
if not isinstance(key_ids, list):
key_ids = [key_ids]
=== modified file 'lib/lp/registry/configure.zcml'
--- lib/lp/registry/configure.zcml 2012-03-22 15:12:19 +0000
+++ lib/lp/registry/configure.zcml 2012-04-03 15:00:32 +0000
@@ -1072,6 +1072,9 @@
<subscriber
for="lp.registry.interfaces.product.IProduct zope.lifecycleevent.interfaces.IObjectModifiedEvent"
handler="lp.registry.subscribers.product_licenses_modified"/>
+ <subscriber
+ for="lp.registry.interfaces.person.IPersonViewRestricted zope.lifecycleevent.interfaces.IObjectModifiedEvent"
+ handler="lp.registry.subscribers.person_alteration_security_notice"/>
<class
class="lp.registry.model.mailinglist.MailingList">
<allow
=== added file 'lib/lp/registry/emailtemplates/person-details-change.txt'
--- lib/lp/registry/emailtemplates/person-details-change.txt 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/emailtemplates/person-details-change.txt 2012-04-03 15:00:32 +0000
@@ -0,0 +1,8 @@
+Hello %(user_name)s, your account has recently changed on Launchpad.net.
+
+Change:
+%(field_changed)s
+
+If you did not intend to make this change, please open a new Question on
+Launchpad [https://answers.launchpad.net/launchpad/+addquestion] or visit
+#launchpad in irc at Freenode to alert staff of the issue.
=== modified file 'lib/lp/registry/model/person.py'
--- lib/lp/registry/model/person.py 2012-03-29 06:02:46 +0000
+++ lib/lp/registry/model/person.py 2012-04-03 15:00:32 +0000
@@ -40,6 +40,8 @@
import weakref
from lazr.delegates import delegates
+from lazr.lifecycle.event import ObjectModifiedEvent
+from lazr.lifecycle.snapshot import Snapshot
from lazr.restful.utils import (
get_current_browser_request,
smartquote,
@@ -97,6 +99,7 @@
classImplements,
implementer,
implements,
+ providedBy,
)
from zope.lifecycleevent import ObjectCreatedEvent
from zope.publisher.interfaces import Unauthorized
@@ -2672,6 +2675,8 @@
"interface. %s doesn't." % email)
assert email.personID == self.id
+ person_before_mod = Snapshot(self, providing=providedBy(self))
+
existing_preferred_email = IMasterStore(EmailAddress).find(
EmailAddress, personID=self.id,
status=EmailAddressStatus.PREFERRED).one()
@@ -2685,6 +2690,11 @@
# Now we update our cache of the preferredemail.
get_property_cache(self).preferredemail = email
+ # Make sure we notify that the property was changed, but only if we've
+ # changed the preferred email and not set an initial one.
+ if person_before_mod.preferredemail:
+ notify(ObjectModifiedEvent(self, person_before_mod,
+ ['preferredemail'], user=self))
@cachedproperty
def preferredemail(self):
@@ -4619,6 +4629,14 @@
keytext = StringCol(dbName='keytext', notNull=True)
comment = StringCol(dbName='comment', notNull=True)
+ def destroySelf(self):
+ """We trigger some events on removal."""
+ # For security reasons we want to notify the preferred email address
+ # that this sshkey has been removed.
+ notify(ObjectModifiedEvent(self.person, self.person,
+ ['removedsshkey'], user=self.person))
+ super(SSHKey, self).destroySelf()
+
class SSHKeySet:
implements(ISSHKeySet)
@@ -4646,6 +4664,11 @@
else:
raise SSHKeyAdditionError
+ # We need to make sure we notify the user that the ssh key is added in
+ # case they didn't request this change.
+ notify(ObjectModifiedEvent(person, person,
+ ['newsshkey'], user=person))
+
return SSHKey(person=person, keytype=keytype, keytext=keytext,
comment=comment)
=== modified file 'lib/lp/registry/model/personnotification.py'
--- lib/lp/registry/model/personnotification.py 2011-12-30 06:14:56 +0000
+++ lib/lp/registry/model/personnotification.py 2012-04-03 15:00:32 +0000
@@ -62,12 +62,20 @@
"""See `IPersonNotification`."""
return len(self.to_addresses) > 0
- def send(self, logger=None):
- """See `IPersonNotification`."""
- if not self.can_send:
+ def send(self, logger=None, sendto=None):
+ """See `IPersonNotification`.
+
+ :param sendto: Name/email tuple to provide a custom recipient address
+ for cases when preferredemail isn't the intended target.
+ """
+ if not self.can_send and not sendto:
raise AssertionError(
"Can't send a notification to a person without an email.")
- to_addresses = self.to_addresses
+ if sendto:
+ # We're expecting a tuple of (name, email) for the sendto.
+ to_addresses = [format_address(*sendto)]
+ else:
+ to_addresses = self.to_addresses
if logger:
logger.info("Sending notification to %r." % to_addresses)
from_addr = config.canonical.bounce_address
=== modified file 'lib/lp/registry/subscribers.py'
--- lib/lp/registry/subscribers.py 2012-03-22 23:21:24 +0000
+++ lib/lp/registry/subscribers.py 2012-04-03 15:00:32 +0000
@@ -14,8 +14,12 @@
import pytz
from zope.security.proxy import removeSecurityProxy
-from lp.registry.interfaces.person import IPerson
+from lp.registry.interfaces.person import (
+ IPerson,
+ IPersonViewRestricted,
+ )
from lp.registry.interfaces.product import License
+from lp.registry.model.personnotification import PersonNotification
from lp.services.config import config
from lp.services.mail.helpers import get_email_template
from lp.services.mail.sendmail import (
@@ -29,6 +33,15 @@
)
+PERSON_DATA_MONITORED = {
+ 'preferredemail': 'Preferred email address changed.',
+ 'newemail': 'Email address added.',
+ 'removedemail': 'Email address removed.',
+ 'newsshkey': 'SSH key added.',
+ 'removedsshkey': 'SSH key removed.',
+}
+
+
def product_licenses_modified(product, event):
"""Send a notification if licenses changed and a license is special."""
if not event.edited_fields:
@@ -145,3 +158,66 @@
naked_product.reviewer_whiteboard = whiteboard
else:
naked_product.reviewer_whiteboard += '\n' + whiteboard
+
+
+def person_alteration_security_notice(person, event):
+ """Send a notification if important details on a person change."""
+ if not event.edited_fields:
+ return
+
+ # We want to keep tabs on which fields changed so we can attempt to have
+ # an intelligent reply message on what just happened.
+ changed_fields = set(PERSON_DATA_MONITORED.keys()) & set(event.edited_fields)
+
+ if changed_fields:
+ user = IPersonViewRestricted(event.user)
+ original_object = event.object_before_modification
+ prev_preferred_email = original_object.preferredemail.email
+
+ # In theory we could have a list of changed fields, but in practice we
+ # don't see that. Shortcutting to just grab the first changed field.
+ notification = PersonAlterationSecurityNotification(
+ changed_fields.pop(), user,
+ override_noticeto=(user.displayname, prev_preferred_email))
+ notification.send()
+
+
+class PersonAlterationSecurityNotification(object):
+ """Schedule an email notification to the user about account changes"""
+
+ def __init__(self, field, user, override_noticeto=None):
+ """Notify the user that their account has changed
+
+ :param field: the bit of account data that's altered
+ :param user: the user that changed
+ """
+ self.changed = field
+ self.user = user
+ self.notification = PersonNotification()
+ self.notification.person = user
+ self.override_noticeto = override_noticeto
+
+ def getTemplateName(self):
+ """Return the name of the email template to use in the notification."""
+ return 'person-details-change.txt'
+
+ def send(self):
+ """Send the notification to the user about their account change."""
+ self.notification.subject = (
+ "Your Launchpad.net account details have changed."
+ )
+ tpl_substitutions = dict(
+ user_displayname=self.user.displayname,
+ user_name=self.user.name,
+ field_changed=PERSON_DATA_MONITORED[self.changed]
+ )
+ template = get_email_template(
+ self.getTemplateName(), app='registry')
+ message = template % tpl_substitutions
+ self.notification.body = message
+ if self.override_noticeto:
+ self.notification.send(
+ sendto=(self.user.displayname, self.override_noticeto))
+ else:
+ self.notification.send()
+ return True
=== modified file 'lib/lp/registry/tests/test_personnotification.py'
--- lib/lp/registry/tests/test_personnotification.py 2012-01-01 02:58:52 +0000
+++ lib/lp/registry/tests/test_personnotification.py 2012-04-03 15:00:32 +0000
@@ -18,6 +18,7 @@
from zope.security.proxy import removeSecurityProxy
from lp.registry.interfaces.personnotification import IPersonNotificationSet
+from lp.registry.model.personnotification import PersonNotification
from lp.registry.scripts.personnotification import PersonNotificationManager
from lp.services.config import config
from lp.testing import (
@@ -25,6 +26,7 @@
TestCaseWithFactory,
)
from lp.testing.layers import DatabaseFunctionalLayer
+from lp.testing.mail_helpers import pop_notifications
class TestPersonNotification(TestCaseWithFactory):
@@ -64,6 +66,19 @@
self.assertEqual([email], notification.to_addresses)
self.assertTrue(notification.can_send)
+ def test_to_specified_email(self):
+ """We might want to notify a non-preferred email address."""
+ user = self.factory.makePerson()
+ note = PersonNotification()
+ note.person = user
+ note.body = 'body'
+ note.subject = 'subject'
+ pop_notifications()
+ note.send(send_to=(user.displayname, 'testing@xxxxxx'))
+ notifications = pop_notifications()
+ self.assertEqual(1, len(notifications))
+ self.assertTrue('testing@xxxxxx' in notifications[0].get('To'))
+
class TestPersonNotificationManager(TestCaseWithFactory):
"""Tests for the PersonNotificationManager use in scripts."""
=== modified file 'lib/lp/registry/tests/test_subscribers.py'
--- lib/lp/registry/tests/test_subscribers.py 2012-03-22 23:21:24 +0000
+++ lib/lp/registry/tests/test_subscribers.py 2012-04-03 15:00:32 +0000
@@ -8,20 +8,28 @@
from datetime import datetime
from lazr.lifecycle.event import ObjectModifiedEvent
+from lazr.lifecycle.interfaces import IObjectModifiedEvent
import pytz
+from zope.component import getUtility
from zope.security.proxy import removeSecurityProxy
+from lp.registry.interfaces.person import IPersonViewRestricted
from lp.registry.interfaces.product import License
from lp.registry.subscribers import (
LicenseNotification,
+ person_alteration_security_notice,
product_licenses_modified,
)
+from lp.services.verification.interfaces.logintoken import ILoginTokenSet
+from lp.services.verification.interfaces.authtoken import LoginTokenType
from lp.services.webapp.publisher import get_current_browser_request
from lp.testing import (
login_person,
logout,
+ person_logged_in,
TestCaseWithFactory,
)
+from lp.testing.event import TestEventListener
from lp.testing.layers import DatabaseFunctionalLayer
from lp.testing.mail_helpers import pop_notifications
@@ -258,3 +266,189 @@
product.commercial_subscription.date_expires.date().isoformat())
self.assertEqual(message, notification.getCommercialUseMessage())
self.assertEqual(message, notification.getCommercialUseMessage())
+
+
+class TestPersonDataModifiedHandler(TestCaseWithFactory):
+ """When some details of a person change, we need to notify the user."""
+ layer = DatabaseFunctionalLayer
+
+ def test_handler_generates_notification(self):
+ """Manually firing event should generate a proper notification."""
+ person = self.factory.makePerson(email='test@xxxxxxx')
+ login_person(person)
+ pop_notifications()
+ # After/before objects and list of edited fields.
+ event = ObjectModifiedEvent(person, person, ['preferredemail'])
+ person_alteration_security_notice(person, event)
+ notifications = pop_notifications()
+ self.assertEqual(1, len(notifications))
+ self.assertTrue('test@xxxxxxx' in notifications[0].get('To'))
+
+ def test_event_generates_notification(self):
+ """Triggering the event should generate a proper notification."""
+ person = self.factory.makePerson(email='test@xxxxxxx')
+ login_person(person)
+ pop_notifications()
+ new_email = self.factory.makeEmail('test@xxxxxxxx', person)
+ person.setPreferredEmail(new_email)
+ notifications = pop_notifications()
+ self.assertEqual(1, len(notifications))
+ self.assertTrue('test@xxxxxxx' in notifications[0].get('To'))
+ self.assertTrue(
+ 'Preferred email address' in notifications[0].as_string())
+
+
+class TestPersonAlterationEvent(TestCaseWithFactory):
+ """Test that the events are fired when the person is changed."""
+
+ layer = DatabaseFunctionalLayer
+ event_listener = None
+
+ def setup_event_listener(self):
+ self.events = []
+ if self.event_listener is None:
+ self.event_listener = TestEventListener(
+ IPersonViewRestricted, IObjectModifiedEvent, self.on_event)
+ else:
+ self.event_listener._active = True
+ self.addCleanup(self.event_listener.unregister)
+
+ def on_event(self, thing, event):
+ self.events.append(event)
+
+ def test_change_preferredemail(self):
+ # The project_reviewed property is not reset, if the new licenses
+ # are identical to the current licenses.
+ pop_notifications()
+ person = self.factory.makePerson(email='test@xxxxxxx')
+ new_email = self.factory.makeEmail('test@xxxxxxxx', person)
+ self.setup_event_listener()
+ with person_logged_in(person):
+ person.setPreferredEmail(new_email)
+ # Assert form within the context manager to get access to the
+ # email values.
+ self.assertEqual('test@xxxxxxxx', person.preferredemail.email)
+ self.assertEqual(1, len(self.events))
+
+ evt = self.events[0]
+ self.assertEqual(person, evt.object)
+ self.assertEqual('test@xxxxxxx',
+ evt.object_before_modification.preferredemail.email)
+ self.assertEqual(['preferredemail'], evt.edited_fields)
+
+ def test_no_event_on_no_change(self):
+ """If there's no change to the preferred email there's no event"""
+ pop_notifications()
+ person = self.factory.makePerson(email='test@xxxxxxx')
+ self.setup_event_listener()
+ with person_logged_in(person):
+ person.displayname = 'changed'
+ # Assert form within the context manager to get access to the
+ # email values.
+ self.assertEqual('test@xxxxxxx', person.preferredemail.email)
+ self.assertEqual(0, len(self.events))
+
+ def test_removed_email_address(self):
+ """When an email address is removed we should notify."""
+ pop_notifications()
+ self.setup_event_listener()
+ person = self.factory.makePerson(email='test@xxxxxxx')
+
+ with person_logged_in(person):
+ secondary_email = self.factory.makeEmail('test@xxxxxxxxxx', person)
+ secondary_email.destroySelf()
+ # We should only have one email address, the preferred.
+ self.assertEqual('test@xxxxxxx', person.preferredemail.email)
+ # The preferred email doesn't show in the list of validated emails
+ # so there are none left once the destroy is done.
+ self.assertEqual(0, person.validatedemails.count())
+ self.assertEqual(1, len(self.events))
+ evt = self.events[0]
+ self.assertEqual(person, evt.object)
+ self.assertEqual(['removedemail'], evt.edited_fields)
+
+ # The notice of this should be going to the preferred email user.
+ notifications = pop_notifications()
+ self.assertTrue('test@xxxxxxx' in notifications[0].get('To'))
+ self.assertTrue(
+ 'Email address removed' in notifications[0].as_string())
+
+ def test_new_email_request(self):
+ """When an email address is added we should notify.
+
+ We want to send the notification when the new email address is
+ requested. This doesn't actually create an email address yet. It
+ builds a LoginToken that the user must ack before the email address is
+ added. The issue is security in alerting the owner of the account that
+ a new address is being requested, so we need to notify at the time of
+ request and not wait for the user to ack the new email address.
+ """
+ pop_notifications()
+ self.setup_event_listener()
+ person = self.factory.makePerson(email='test@xxxxxxx')
+
+ with person_logged_in(person):
+ secondary_email = self.factory.makeEmail('test@xxxxxxxxxx', person)
+ # The way that a new email address gets requested is through the
+ # LoginToken done in the browser/person action_add_email.
+ getUtility(ILoginTokenSet).new(person,
+ person.preferredemail.email,
+ secondary_email.email,
+ LoginTokenType.VALIDATEEMAIL)
+ self.assertEqual(1, len(self.events))
+ evt = self.events[0]
+ self.assertEqual(person, evt.object)
+ self.assertEqual(['newemail'], evt.edited_fields)
+
+ # The notice of this should be going to the preferred email user.
+ notifications = pop_notifications()
+ self.assertTrue('test@xxxxxxx' in notifications[0].get('To'))
+ self.assertTrue(
+ 'Email address added' in notifications[0].as_string())
+
+ def test_new_ssh_key(self):
+ """We also want the notification when users add ssh keys."""
+ pop_notifications()
+ self.setup_event_listener()
+ person = self.factory.makePerson(email='test@xxxxxxx')
+
+ with person_logged_in(person):
+ # The factory method generates a fresh ssh key through the
+ # SSHKeySet that we're bound into. The view uses the same ssh key
+ # set .new method so it's safe to just let the factory trigger our
+ # event for us.
+ self.factory.makeSSHKey(person)
+ self.assertEqual(1, len(self.events))
+ evt = self.events[0]
+ self.assertEqual(person, evt.object)
+ self.assertEqual(['newsshkey'], evt.edited_fields)
+
+ # The notice of this should be going to the preferred email user.
+ notifications = pop_notifications()
+ self.assertTrue('test@xxxxxxx' in notifications[0].get('To'))
+ self.assertTrue(
+ 'SSH key added' in notifications[0].as_string())
+
+ def test_remove_ssh_key(self):
+ """Notifications should fire when we remove an ssh key."""
+ pop_notifications()
+ self.setup_event_listener()
+ person = self.factory.makePerson(email='test@xxxxxxx')
+
+ with person_logged_in(person):
+ sshkey = self.factory.makeSSHKey(person)
+ # Make sure to clear notifications/events before we remove the key.
+ pop_notifications()
+ self.events = []
+ sshkey.destroySelf()
+ self.assertEqual(1, len(self.events))
+ evt = self.events[0]
+ self.assertEqual(person, evt.object)
+ self.assertEqual(['removedsshkey'], evt.edited_fields)
+
+ # The notice of this should be going to the preferred email user.
+ notifications = pop_notifications()
+ self.assertTrue('test@xxxxxxx' in notifications[0].get('To'))
+ self.assertTrue(
+ 'SSH key removed' in notifications[0].as_string())
+
=== modified file 'lib/lp/services/identity/model/emailaddress.py'
--- lib/lp/services/identity/model/emailaddress.py 2012-01-05 00:23:45 +0000
+++ lib/lp/services/identity/model/emailaddress.py 2012-04-03 15:00:32 +0000
@@ -19,7 +19,10 @@
ForeignKey,
StringCol,
)
+
+from zope.event import notify
from zope.interface import implements
+from lazr.lifecycle.event import ObjectModifiedEvent
from lp.app.validators.email import valid_email
from lp.services.database.enumcol import EnumCol
@@ -85,6 +88,12 @@
for subscription in MailingListSubscription.selectBy(
email_address=self):
subscription.destroySelf()
+
+ # We need to notify the preferred email address that this address has
+ # been removed.
+ notify(ObjectModifiedEvent(self.person, self.person,
+ ['removedemail'], user=self.person))
+
super(EmailAddress, self).destroySelf()
@property
=== modified file 'lib/lp/services/verification/model/logintoken.py'
--- lib/lp/services/verification/model/logintoken.py 2012-01-05 00:23:45 +0000
+++ lib/lp/services/verification/model/logintoken.py 2012-04-03 15:00:32 +0000
@@ -19,8 +19,10 @@
)
from storm.expr import And
from zope.component import getUtility
+from zope.event import notify
from zope.interface import implements
from zope.security.proxy import removeSecurityProxy
+from lazr.lifecycle.event import ObjectModifiedEvent
from lp.app.errors import NotFoundError
from lp.app.validators.email import valid_email
@@ -392,6 +394,12 @@
raise ValueError(
"tokentype is not an item of LoginTokenType: %s" % tokentype)
+ # We want to alert the preferred email that this new address has been
+ # requested.
+ if tokentype == LoginTokenType.VALIDATEEMAIL:
+ notify(ObjectModifiedEvent(requester, requester,
+ ['newemail'], user=requester))
+
token = create_unique_token_for_table(20, LoginToken.token)
return LoginToken(requester=requester, requesteremail=requesteremail,
email=email, token=token, tokentype=tokentype,