From 85f1c08c58c8b478eb7d558d9601d3d0aa020a7c Mon Sep 17 00:00:00 2001 From: Changaco Date: Wed, 4 Dec 2024 09:57:45 +0100 Subject: [PATCH 1/3] allow constant sessions that almost never expire --- liberapay/constants.py | 1 + liberapay/models/participant.py | 27 +++++++++++++++++++++------ sql/branch.sql | 1 + 3 files changed, 23 insertions(+), 6 deletions(-) create mode 100644 sql/branch.sql diff --git a/liberapay/constants.py b/liberapay/constants.py index 7a748868e..6fed31bc2 100644 --- a/liberapay/constants.py +++ b/liberapay/constants.py @@ -343,6 +343,7 @@ def generate_value(self, currency): SESSION = 'session' SESSION_REFRESH = timedelta(hours=12) SESSION_TIMEOUT = timedelta(hours=6) +SESSION_TIMEOUT_LONG = timedelta(days=400) def make_standard_tip(label, weekly, currency): diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py index 0a8b51c6a..21eaf2227 100644 --- a/liberapay/models/participant.py +++ b/liberapay/models/participant.py @@ -34,7 +34,7 @@ DONATION_LIMITS, EMAIL_VERIFICATION_TIMEOUT, EVENTS, HTML_A, PASSWORD_MAX_SIZE, PASSWORD_MIN_SIZE, PAYPAL_CURRENCIES, PERIOD_CONVERSION_RATES, PRIVILEGES, - PUBLIC_NAME_MAX_SIZE, SEPA, SESSION, SESSION_TIMEOUT, + PUBLIC_NAME_MAX_SIZE, SEPA, SESSION, SESSION_TIMEOUT, SESSION_TIMEOUT_LONG, USERNAME_MAX_SIZE, USERNAME_SUFFIX_BLACKLIST, ) from liberapay.exceptions import ( @@ -441,7 +441,7 @@ def authenticate_with_session( else: rate_limit = False r = cls.db.one(""" - SELECT p, s.secret, s.mtime + SELECT p, s.secret, s.mtime, s.latest_use FROM user_secrets s JOIN participants p ON p.id = s.participant WHERE s.participant = %s @@ -450,26 +450,38 @@ def authenticate_with_session( if not r: erase_cookie(cookies, SESSION) return None, 'invalid' - p, stored_secret, mtime = r + p, stored_secret, mtime, latest_use = r if not constant_time_compare(stored_secret, secret): erase_cookie(cookies, SESSION) return None, 'invalid' if rate_limit: cls.db.decrement_rate_limit('log-in.session.ip-addr', str(request.source)) - if mtime > utcnow() - SESSION_TIMEOUT: + now = utcnow() + today = now.date() + if session_id >= 800 and session_id < 810: + if (latest_use or mtime.date()) < today - SESSION_TIMEOUT_LONG: + return None, 'expired' + elif mtime > utcnow() - SESSION_TIMEOUT: p.session = SimpleNamespace(id=session_id, secret=secret, mtime=mtime) elif allow_downgrade: - if mtime > utcnow() - FOUR_WEEKS: + if mtime > now - FOUR_WEEKS: p.regenerate_session( SimpleNamespace(id=session_id, secret=secret, mtime=mtime), cookies, suffix='.ro', # stands for "read only" ) else: - set_cookie(cookies, SESSION, f"{p.id}:!:", expires=utcnow() + TEN_YEARS) + set_cookie(cookies, SESSION, f"{p.id}:!:", expires=now + TEN_YEARS) return None, 'expired' else: return None, 'expired' + if not latest_use or latest_use < today: + cls.db.run(""" + UPDATE user_secrets + SET latest_use = current_date + WHERE participant = %s + AND id = %s + """, (p_id, session_id)) p.authenticated = True return p, 'valid' @@ -489,6 +501,9 @@ def regenerate_session(self, session, cookies, suffix=None): The new secret is guaranteed to be different from the old one. """ + if session.id >= 800 and session.id < 810: + # Sessions in this range aren't meant to be regenerated automatically. + return if self.is_suspended: if not suffix: suffix = '.ro' diff --git a/sql/branch.sql b/sql/branch.sql new file mode 100644 index 000000000..7616804cb --- /dev/null +++ b/sql/branch.sql @@ -0,0 +1 @@ +ALTER TABLE user_secrets ADD COLUMN latest_use date; From 003687938fc2c73a0e5d22b0093ee9fa5bce9bb7 Mon Sep 17 00:00:00 2001 From: Changaco Date: Fri, 6 Dec 2024 18:12:32 +0100 Subject: [PATCH 2/3] create page `/~/access/constant-session` --- www/%username/access/constant-session.spt | 75 +++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 www/%username/access/constant-session.spt diff --git a/www/%username/access/constant-session.spt b/www/%username/access/constant-session.spt new file mode 100644 index 000000000..ab3ba25b9 --- /dev/null +++ b/www/%username/access/constant-session.spt @@ -0,0 +1,75 @@ +from liberapay.utils import form_post_success, get_participant, utcnow + +[---] +participant = get_participant(state, restrict=True) +s = website.db.one(""" + SELECT id, secret, mtime, latest_use + FROM user_secrets + WHERE participant = %s + AND id = 800 +""", (participant.id,)) +if request.method == 'POST': + action = request.body['action'] + with website.db.get_cursor() as cursor: + if action == 'start': + s = cursor.one(""" + INSERT INTO user_secrets + (participant, id, secret) + VALUES (%s, 800, %s) + ON CONFLICT (participant, id) DO UPDATE + SET latest_use = null + , mtime = excluded.mtime + , secret = excluded.secret + RETURNING id, secret, mtime, latest_use + """, (participant.id, participant.generate_session_token() + '.ro')) + participant.add_event(cursor, 'start_infinite_session', {'id': 800}) + msg = _("The credentials have been generated.") + elif action == 'end': + cursor.run(""" + DELETE FROM user_secrets + WHERE participant = %s + AND id = 800 + """, (participant.id,)) + participant.add_event(cursor, 'end_infinite_session', {'id': 800}) + msg = _("The credentials have been revoked.") + else: + raise response.invalid_input(action, 'action', 'body') + form_post_success(state, msg=msg) + +title = _("Constant session") + +[---] text/html +% extends "templates/layouts/settings.html" + +% block content +
+ + % if s and (s.latest_use or s.mtime.date()) >= (utcnow() - constants.SESSION_TIMEOUT_LONG).date() +

{{ _( + "You can automate downloading private information from your account " + "by including the following HTTP header in your requests:" + ) }}

+
Cookie: session={{ participant.id }}:{{ s.id }}:{{ s.secret }}
+

{{ _( + "For example, you can download the list of your currently active patrons " + "by executing the following command:" + ) }}

+
curl -b 'session={{ participant.id }}:{{ s.id }}:{{ s.secret }}' '{{ participant.url('patrons/export.csv?scope=active') }}'
+

{{ icon('info-sign') }} {{ _( + "To minimize the risks to you, these credentials can only be used to fetch " + "data, not to modify anything in your account, and they will automatically " + "expire if unused for a year." + ) }}

+ + % elif s +

{{ _("The credentials have expired.") }}

+ + % else +

{{ _( + "To simplify automating downloads of private information from your " + "account, you can generate long-lived read-only credentials." + ) }}

+ + % endif +
+% endblock From 12193d0398213c8fc38f2d37ae2c158fa4cf1df9 Mon Sep 17 00:00:00 2001 From: Changaco Date: Sat, 7 Dec 2024 23:04:09 +0100 Subject: [PATCH 3/3] add a test of constant sessions --- tests/py/test_sessions.py | 95 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/py/test_sessions.py b/tests/py/test_sessions.py index 3bec81f2c..6b892f043 100644 --- a/tests/py/test_sessions.py +++ b/tests/py/test_sessions.py @@ -787,3 +787,98 @@ def test_a_read_only_session_can_be_used_to_view_an_account_but_not_modify_it(se assert r.code == 200, r.text r = self.client.PxST('/alice/edit/username', {}, auth_as=alice) assert r.code == 403, r.text + + def test_constant_sessions(self): + alice = self.make_participant('alice') + r = self.client.GET('/alice/access/constant-session', auth_as=alice) + assert r.code == 200, r.text + constant_sessions = self.db.all(""" + SELECT * + FROM user_secrets + WHERE participant = %s + AND id >= 800 + """, (alice.id,)) + assert not constant_sessions + del constant_sessions + # Test creating the constant session + r = self.client.PxST( + '/alice/access/constant-session', + {'action': 'start'}, + auth_as=alice, + ) + assert r.code == 302, r.text + constant_session = self.db.one(""" + SELECT * + FROM user_secrets + WHERE participant = %s + AND id >= 800 + """, (alice.id,)) + assert constant_session + r = self.client.GET('/alice/access/constant-session', auth_as=alice) + assert r.code == 200, r.text + assert constant_session.secret in r.text + # Test using the constant session + r = self.client.GxT( + '/about/me/', + cookies={ + 'session': f'{alice.id}:{constant_session.id}:{constant_session.secret}', + }, + ) + assert r.code == 302, r.text + # Test regenerating the constant session + r = self.client.PxST( + '/alice/access/constant-session', + {'action': 'start'}, + auth_as=alice, + ) + assert r.code == 302, r.text + old_constant_session = constant_session + constant_session = self.db.one(""" + SELECT * + FROM user_secrets + WHERE participant = %s + AND id >= 800 + """, (alice.id,)) + assert constant_session + assert constant_session.secret != old_constant_session.secret + # Test expiration of the session + self.db.run(""" + UPDATE user_secrets + SET mtime = mtime - interval '300 days' + , latest_use = latest_use - interval '300 days' + WHERE id = 800 + """) + r = self.client.GxT( + '/about/me/', + cookies={ + 'session': f'{alice.id}:{constant_session.id}:{constant_session.secret}', + }, + ) + assert r.code == 302, r.text + self.db.run(""" + UPDATE user_secrets + SET mtime = mtime - interval '500 days' + , latest_use = latest_use - interval '500 days' + WHERE id = 800 + """) + r = self.client.GxT( + '/about/me/', + cookies={ + 'session': f'{alice.id}:{constant_session.id}:{constant_session.secret}', + }, + ) + assert r.code == 403, r.text + # Test revoking the constant session + r = self.client.PxST( + '/alice/access/constant-session', + {'action': 'end'}, + auth_as=alice, + ) + assert r.code == 302, r.text + constant_session = self.db.one(""" + SELECT * + FROM user_secrets + WHERE participant = %s + AND id >= 800 + """, (alice.id,)) + assert not constant_session