Skip to content

Commit

Permalink
Merge pull request #2511 from liberapay/constant-session
Browse files Browse the repository at this point in the history
  • Loading branch information
Changaco authored Dec 10, 2024
2 parents 3262454 + 12193d0 commit 7544f1c
Show file tree
Hide file tree
Showing 5 changed files with 193 additions and 6 deletions.
1 change: 1 addition & 0 deletions liberapay/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
27 changes: 21 additions & 6 deletions liberapay/models/participant.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand All @@ -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'

Expand All @@ -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'
Expand Down
1 change: 1 addition & 0 deletions sql/branch.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE user_secrets ADD COLUMN latest_use date;
95 changes: 95 additions & 0 deletions tests/py/test_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
75 changes: 75 additions & 0 deletions www/%username/access/constant-session.spt
Original file line number Diff line number Diff line change
@@ -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
<form action="" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
% if s and (s.latest_use or s.mtime.date()) >= (utcnow() - constants.SESSION_TIMEOUT_LONG).date()
<p>{{ _(
"You can automate downloading private information from your account "
"by including the following HTTP header in your requests:"
) }}</p>
<pre>Cookie: session={{ participant.id }}:{{ s.id }}:{{ s.secret }}</pre>
<p>{{ _(
"For example, you can download the list of your currently active patrons "
"by executing the following command:"
) }}</p>
<pre>curl -b 'session={{ participant.id }}:{{ s.id }}:{{ s.secret }}' '{{ participant.url('patrons/export.csv?scope=active') }}'</pre>
<p class="text-info">{{ 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."
) }}</p>
<button class="btn btn-danger" name="action" value="end">{{ _("Revoke credentials") }}</button>
% elif s
<p>{{ _("The credentials have expired.") }}</p>
<button class="btn btn-primary" name="action" value="start">{{ _("Regenerate credentials") }}</button>
% else
<p>{{ _(
"To simplify automating downloads of private information from your "
"account, you can generate long-lived read-only credentials."
) }}</p>
<button class="btn btn-primary" name="action" value="start">{{ _("Generate credentials") }}</button>
% endif
</form>
% endblock

0 comments on commit 7544f1c

Please sign in to comment.