Repository: liberapay/liberapay.com

Issue:
Impossible to connect + no "forgot password" button
I tried to connect to my account this morning but it's not working, the login page just ignore my request, I got not error message, and there is no button "forgot password" (maybe I do a mistake but I don't know!). I can't connect ;-)



Discussion:
Indeed we don't have a password reset procedure yet, and indeed our login form doesn't have error messages. I'll try to get this done today.


Resolving patch:
diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py
--- a/liberapay/models/participant.py
+++ b/liberapay/models/participant.py
@@ -9,7 +9,7 @@
 from time import sleep
 import uuid
 
-from six.moves.urllib.parse import quote
+from six.moves.urllib.parse import quote, urlencode
 
 from aspen.utils import utcnow
 import aspen_jinja2_renderer
@@ -145,7 +145,17 @@ def from_username(cls, username):
 
     @classmethod
     def _from_thing(cls, thing, value):
-        assert thing in ("id", "lower(username)", "mangopay_user_id")
+        assert thing in ("id", "lower(username)", "mangopay_user_id", "email")
+        if thing == 'email':
+            # This query looks for an unverified address if the participant
+            # doesn't have any verified address
+            return cls.db.one("""
+                SELECT p.*::participants
+                  FROM emails e
+                  JOIN participants p ON p.id = e.participant
+                 WHERE e.address = %s
+                   AND (p.email IS NULL OR p.email = e.address)
+            """, (value,))
         return cls.db.one("""
             SELECT participants.*::participants
               FROM participants
@@ -154,14 +164,13 @@ def _from_thing(cls, thing, value):
 
     @classmethod
     def authenticate(cls, k1, k2, v1=None, v2=None):
-        assert k1 in ('id', 'username')
+        assert k1 in ('id', 'username', 'email')
         if not (v1 and v2):
             return
-        p = cls.db.one("""
-            SELECT participants.*::participants
-              FROM participants
-             WHERE {0}=%s
-        """.format(k1), (v1,))
+        if k1 == 'username':
+            k1 = 'lower(username)'
+            v1 = v1.lower()
+        p = cls._from_thing(k1, v1)
         if not p:
             return
         if k2 == 'session':
@@ -234,15 +243,17 @@ def set_session_expires(self, expires):
                     )
         self.set_attributes(session_expires=expires)
 
-    def sign_in(self, cookies):
-        """Start a new session for the user.
+    def start_session(self):
+        """Start a new session for the user, invalidating the previous one.
         """
-        assert self.authenticated
         token = uuid.uuid4().hex
         expires = utcnow() + SESSION_TIMEOUT
         self.update_session(token, expires)
-        creds = '%s:%s' % (self.id, token)
-        set_cookie(cookies, SESSION, creds, expires)
+
+    def sign_in(self, cookies):
+        assert self.authenticated
+        creds = '%s:%s' % (self.id, self.session_token)
+        set_cookie(cookies, SESSION, creds, self.session_expires)
 
     def keep_signed_in(self, cookies):
         """Extend the user's current session.
@@ -665,6 +676,7 @@ def render(t, context):
         message['text'] = render('text/plain', context)
 
         self._mailer.messages.send(message=message)
+        self._log_email(message)
         return 1 # Sent
 
     def queue_email(self, spt_name, **context):
@@ -826,11 +838,14 @@ def add_event(self, c, type, payload, recorder=None):
             VALUES (%s, %s, %s, %s)
         """, (self.id, type, Json(payload), recorder))
 
-    def url(self, path=''):
+    def url(self, path='', query=''):
         scheme = liberapay.canonical_scheme
         host = liberapay.canonical_host
         username = self.username
-        return '{scheme}://{host}/{username}/{path}'.format(**locals())
+        if query:
+            assert '?' not in path
+            query = '?' + urlencode(query)
+        return '{scheme}://{host}/{username}/{path}{query}'.format(**locals())
 
     def get_teams(self):
         """Return a list of teams this user is a member of.
diff --git a/liberapay/security/authentication.py b/liberapay/security/authentication.py
--- a/liberapay/security/authentication.py
+++ b/liberapay/security/authentication.py
@@ -2,8 +2,12 @@
 """
 import binascii
 
+from six.moves.urllib.parse import urlencode
+
 from aspen import Response
-from liberapay.constants import SESSION
+
+from liberapay.constants import SESSION, SESSION_TIMEOUT
+from liberapay.exceptions import AuthRequired
 from liberapay.models.participant import Participant
 
 
@@ -19,21 +23,26 @@ class _ANON(object):
 ANON = _ANON()
 
 
-def sign_in_with_form_data(request, state):
+def _get_body(request):
     try:
         body = request.body
     except Response:
         return
-
     if not isinstance(body, dict):
         return
+    return body
 
+
+def sign_in_with_form_data(body, state):
     p = None
+    _, website = state['_'], state['website']
 
-    if body.get('log-in.username'):
+    if body.get('log-in.id'):
+        id = body.pop('log-in.id')
+        k = 'email' if '@' in id else 'username'
         p = Participant.authenticate(
-            'username', 'password',
-            body.pop('log-in.username'), body.pop('log-in.password')
+            k, 'password',
+            id, body.pop('log-in.password')
         )
         if p and p.status == 'closed':
             p.update_status('active')
@@ -44,7 +53,7 @@ def sign_in_with_form_data(request, state):
         kind = body.pop('sign-in.kind')
         if kind not in ('individual', 'organization'):
             raise Response(400, 'bad kind')
-        with state['website'].db.get_cursor() as c:
+        with website.db.get_cursor() as c:
             p = Participant.make_active(
                 body.pop('sign-in.username'), kind, body.pop('sign-in.password'),
                 cursor=c
@@ -52,6 +61,26 @@ def sign_in_with_form_data(request, state):
             p.add_email(body.pop('sign-in.email'), cursor=c)
         p.authenticated = True
 
+    elif body.get('email-login.email'):
+        email = body.pop('email-login.email')
+        p = Participant._from_thing('email', email)
+        if p:
+            p.start_session()
+            qs = {'log-in.id': p.id, 'log-in.token': p.session_token}
+            p.send_email(
+                'password_reset',
+                email=email,
+                link=p.url('settings/', qs),
+                link_validity=SESSION_TIMEOUT,
+            )
+            state['email-login.sent-to'] = email
+        else:
+            state['sign-in.error'] = _(
+                "We didn't find any account whose primary email address is {0}.",
+                email
+            )
+        p = None
+
     return p
 
 
@@ -61,7 +90,7 @@ def start_user_as_anon():
     return {'user': ANON}
 
 
-def authenticate_user_if_possible(request, state, user):
+def authenticate_user_if_possible(request, state, user, _):
     """This signs the user in.
     """
     if request.line.uri.startswith('/assets/'):
@@ -90,16 +119,36 @@ def authenticate_user_if_possible(request, state, user):
         p = Participant.authenticate('id', 'session', *creds)
         if p:
             state['user'] = p
+    session_p, p = p, None
+    redirect_url = request.line.uri
     if request.method == 'POST':
-        old_p = p
-        p = sign_in_with_form_data(request, state)
-        if p:
-            if old_p:
-                old_p.sign_out(response.headers.cookie)
-            p.sign_in(response.headers.cookie)
-            state['user'] = p
-            if request.body.pop('form.repost', None) != 'true':
-                response.redirect(request.line.uri)
+        body = _get_body(request)
+        if body:
+            p = sign_in_with_form_data(body, state)
+            carry_on = body.pop('email-login.carry-on', None)
+            if not p and carry_on:
+                p_email = session_p and (
+                    session_p.email or session_p.get_emails()[0].address
+                )
+                if p_email != carry_on:
+                    state['email-login.carry-on'] = carry_on
+                    raise AuthRequired
+    elif request.method == 'GET' and request.qs.get('log-in.id'):
+        id, token = request.qs.pop('log-in.id'), request.qs.pop('log-in.token')
+        p = Participant.authenticate('id', 'session', id, token)
+        if not p and (not session_p or session_p.id != id):
+            raise Response(400, _("This login link is expired or invalid."))
+        else:
+            qs = '?' + urlencode(request.qs, doseq=True) if request.qs else ''
+            redirect_url = request.path.raw + qs
+            session_p = p
+    if p:
+        if session_p:
+            session_p.sign_out(response.headers.cookie)
+        p.sign_in(response.headers.cookie)
+        state['user'] = p
+        if request.body.pop('form.repost', None) != 'true':
+            response.redirect(redirect_url)
 
 
 def add_auth_to_response(response, request=None, user=ANON):
diff --git a/liberapay/wireup.py b/liberapay/wireup.py
--- a/liberapay/wireup.py
+++ b/liberapay/wireup.py
@@ -4,6 +4,7 @@
 
 import atexit
 import fnmatch
+import json
 import os
 import re
 from tempfile import mkstemp
@@ -75,6 +76,21 @@ def mail(env, project_root='.'):
         emails[base_name] = compile_email_spt(spt)
     Participant._emails = emails
 
+    def log_email(message):
+        message = dict(message)
+        html, text = message.pop('html'), message.pop('text')
+        print('\n', ' ', '='*26, 'BEGIN EMAIL', '='*26)
+        print(json.dumps(message))
+        print('[---] text/html')
+        print(html)
+        print('[---] text/plain')
+        print(text)
+        print('  ', '='*27, 'END EMAIL', '='*27)
+
+    Participant._log_email = staticmethod(
+        log_email if env.log_emails else lambda *a: None
+    )
+
 
 def billing(env):
     from mangopaysdk.configuration import Configuration
@@ -344,6 +360,7 @@ def env():
         SENTRY_RERAISE                  = is_yesish,
         MANDRILL_KEY                    = str,
         GUNICORN_OPTS                   = str,
+        LOG_EMAILS                      = is_yesish,
     )
 
 


Test patch:
diff --git a/tests/py/test_hooks.py b/tests/py/test_hooks.py
--- a/tests/py/test_hooks.py
+++ b/tests/py/test_hooks.py
@@ -3,6 +3,7 @@
 from base64 import b64encode
 import json
 
+from aspen.exceptions import MalformedBody, UnknownBodyType
 from aspen.http.request import Request
 from aspen.http.response import Response
 from environment import Environment
@@ -157,3 +158,15 @@ def test_sanitize_token_rejects_underlong_token(self):
     def test_sanitize_token_rejects_goofy_token(self):
         token = 'ddddeeeeaaaadddd bbbbeeeeeeeefff'
         assert csrf._sanitize_token(token) is None
+
+    def test_malformed_body(self):
+        with self.assertRaises(MalformedBody):
+            self.client.POST('/', body=b'a', content_type=b'application/json')
+
+    def test_unknown_body_type(self):
+        with self.assertRaises(UnknownBodyType):
+            self.client.POST('/', body=b'x', content_type=b'unknown/x')
+
+    def test_non_dict_body(self):
+        r = self.client.POST('/', body=b'[]', content_type=b'application/json')
+        assert r.code == 200
diff --git a/tests/py/test_sign_in.py b/tests/py/test_sign_in.py
--- a/tests/py/test_sign_in.py
+++ b/tests/py/test_sign_in.py
@@ -21,7 +21,7 @@
 class TestSignIn(EmailHarness):
 
     def log_in(self, username, password, **kw):
-        data = {'log-in.username': username, 'log-in.password': password}
+        data = {'log-in.id': username, 'log-in.password': password}
         return self.client.POST('/sign-in', data, raise_immediately=False, **kw)
 
     def test_log_in(self):
@@ -64,6 +64,51 @@ def test_log_in_bad_password(self):
         r = self.log_in('alice', 'deadbeef')
         assert SESSION not in r.headers.cookie
 
+    def test_email_login(self):
+        email = 'alice@example.net'
+        alice = self.make_participant('alice')
+        alice.add_email(email)
+
+        data = {'email-login.email': email}
+        r = self.client.POST('/', data, raise_immediately=False)
+        alice = alice.refetch()
+        assert alice.session_token not in r.headers.raw
+        assert alice.session_token not in r.body.decode('utf8')
+
+        Participant.dequeue_emails()
+        last_email = self.get_last_email()
+        assert last_email and last_email['subject'] == 'Password reset'
+        assert 'log-in.token='+alice.session_token in last_email['text']
+
+        url = '/alice/?foo=bar&log-in.id=%s&log-in.token=%s'
+        r = self.client.GxT(url % (alice.id, alice.session_token))
+        alice2 = alice.refetch()
+        assert alice2.session_token != alice.session_token
+        # ↑ this means that the link is only valid once
+        assert r.code == 302
+        assert r.headers['Location'] == '/alice/?foo=bar'
+        # ↑ checks that original path and query are preserved
+        expected_cookie = '%s:%s' % (alice.id, alice2.session_token)
+        assert r.headers.cookie[SESSION].value == expected_cookie
+        # ↑ checks that we are in fact logged in
+
+    def test_email_login_bad_email(self):
+        data = {'email-login.email': 'unknown@example.org'}
+        r = self.client.POST('/sign-in', data, raise_immediately=False)
+        assert r.code == 403
+        assert SESSION not in r.headers.cookie
+        Participant.dequeue_emails()
+        assert not self.get_emails()
+
+    def test_email_login_bad_id(self):
+        r = self.client.GxT('/?log-in.id=1&log-in.token=x')
+        assert r.code == 400
+
+    def test_email_login_bad_token(self):
+        alice = self.make_participant('alice')
+        r = self.client.GxT('/?log-in.id=%s&log-in.token=x' % alice.id)
+        assert r.code == 400
+
     def sign_in(self, custom):
         data = dict(good_data)
         for k, v in custom.items():
@@ -110,3 +155,7 @@ def test_sign_in_bad_kind(self):
     def test_sign_in_bad_email(self):
         r = self.sign_in(dict(email='foo@bar'))
         assert r.code == 400
+
+    def test_sign_in_terms_not_checked(self):
+        r = self.sign_in(dict(terms=''))
+        assert r.code == 400