Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Constant session #2511

Merged
merged 3 commits into from
Dec 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading