From 96133d7ec6ba332b7f64c153fb963e67904484ad Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Thu, 8 Dec 2022 21:43:30 +0100 Subject: [PATCH 1/8] [feat] basic TOTP functionality --- js/log-in-link-is-valid.js | 12 +++++ liberapay/constants.py | 2 + liberapay/exceptions.py | 4 ++ liberapay/models/participant.py | 77 +++++++++++++++++++++++++-- liberapay/security/authentication.py | 21 +++++--- requirements_base.txt | 9 ++++ simplates/log-in-link-is-valid.spt | 12 ++++- sql/branch.sql | 5 ++ templates/log-in-form.html | 6 +++ templates/macros/auth.html | 12 ++++- www/%username/settings/edit.spt | 2 +- www/%username/settings/enable_2fa.spt | 48 +++++++++++++++++ www/%username/settings/handle_2fa.spt | 31 +++++++++++ www/%username/settings/index.html.spt | 2 + 14 files changed, 227 insertions(+), 16 deletions(-) create mode 100644 js/log-in-link-is-valid.js create mode 100644 sql/branch.sql create mode 100644 www/%username/settings/enable_2fa.spt create mode 100644 www/%username/settings/handle_2fa.spt diff --git a/js/log-in-link-is-valid.js b/js/log-in-link-is-valid.js new file mode 100644 index 0000000000..0376320eab --- /dev/null +++ b/js/log-in-link-is-valid.js @@ -0,0 +1,12 @@ +// Helper to append an optional TOTP-code to the login link when signing in via email +window.addEventListener("load", function () { + var totp_field = document.getElementById("log-in-link-is-valid_totp_field"); + if (totp_field) { + var login_button = document.getElementById("log-in-link-is-valid_button"); + totp_field.addEventListener("input", function () { + var url = new URLSearchParams(login_button.href); + url.set("log-in.totp", totp_field.value); + login_button.href = decodeURIComponent(url.toString()); + }); + } +}); diff --git a/liberapay/constants.py b/liberapay/constants.py index ef03ecf724..ceeb5efcb5 100644 --- a/liberapay/constants.py +++ b/liberapay/constants.py @@ -437,4 +437,6 @@ def __missing__(self, currency): USERNAME_MAX_SIZE = 32 USERNAME_SUFFIX_BLACKLIST = set('.txt .html .htm .json .xml'.split()) +VALID_WINDOW_2FA = 5 + del _ diff --git a/liberapay/exceptions.py b/liberapay/exceptions.py index c78e2ede5b..0b8a19642a 100644 --- a/liberapay/exceptions.py +++ b/liberapay/exceptions.py @@ -77,6 +77,10 @@ class AccountIsPasswordless(LoginRequired): pass +class FailedToVerifyOTP(Exception): + pass + + class NeedDatabase(LazyResponse): html_template = 'templates/exceptions/NeedDatabase.html' diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py index c5fb65ff72..75e64f3133 100644 --- a/liberapay/models/participant.py +++ b/liberapay/models/participant.py @@ -12,6 +12,10 @@ import unicodedata from urllib.parse import quote as urlquote, urlencode import uuid +import io +import png +from pyotp import totp, random_base32 +from pyqrcode import create as create_qrcode import aspen_jinja2_renderer from cached_property import cached_property @@ -32,7 +36,7 @@ PASSWORD_MAX_SIZE, PASSWORD_MIN_SIZE, PAYPAL_CURRENCIES, PERIOD_CONVERSION_RATES, PRIVILEGES, PUBLIC_NAME_MAX_SIZE, SEPA, SESSION, SESSION_TIMEOUT, - USERNAME_MAX_SIZE, USERNAME_SUFFIX_BLACKLIST, + USERNAME_MAX_SIZE, USERNAME_SUFFIX_BLACKLIST, VALID_WINDOW_2FA ) from liberapay.exceptions import ( AccountIsPasswordless, @@ -45,6 +49,7 @@ EmailAddressIsBlacklisted, EmailAlreadyTaken, EmailNotVerified, + FailedToVerifyOTP, InvalidId, LoginRequired, NonexistingElsewhere, @@ -240,12 +245,13 @@ def refetch(self): # =================== @classmethod - def authenticate_with_password(cls, p_id, password, context='log-in'): + def authenticate_with_password(cls, p_id, password, totp, context='log-in'): """Fetch a participant using its ID, but only if the provided password is valid. Args: p_id (int): the participant's ID password (str): the participant's password + totp (str): optional totp code context (str): the operation that this authentication is part of Return type: `Participant | None` @@ -266,6 +272,9 @@ def authenticate_with_password(cls, p_id, password, context='log-in'): return None p, stored_secret = r if context == 'log-in': + if p.is_totp_verified(): + if (not totp) or (not p.verify_totp(totp)): + return None cls.db.hit_rate_limit('log-in.password', p.id, TooManyPasswordLogins) request = website.state.get({}).get('request') if request: @@ -388,13 +397,67 @@ def check_password(self, password, context): self.add_event(website.db, 'password-check', None) return status + # 2FA Management (TOTP) + # ===================== + + def is_totp_verified(self): + return self.db.one( + "SELECT totp_verified FROM participants WHERE id = %s", + (self.id,) + ) + + def gen_totp_token(self): + totp_token = self.db.one( + "SELECT totp_token FROM participants WHERE id = %s", + (self.id,) + ) + if totp_token is None: + totp_token = random_base32() + self.db.run( + "UPDATE participants SET totp_token = %s WHERE id = %s", + (totp_token, self.id) + ) + return totp_token + + def generate_totp_qrcode(self): + totp_token = self.gen_totp_token() + uri = totp.TOTP(totp_token).provisioning_uri( + name=f'~{self.id}', + image='https://liberapay.com/assets/liberapay/icon-v2_white-on-yellow.200.png', + issuer_name='Liberapay' + ) + qrcode = create_qrcode(uri) + buffer = io.BytesIO() + qrcode.png(buffer, scale=4) + image = b64encode(buffer.getvalue()).decode() + return image + + def verify_totp(self, totp_code): + totp_token = self.gen_totp_token() + return totp.TOTP(totp_token).verify(totp_code) + + def enable_totp(self, verification_code): + if self.verify_totp(verification_code): + self.db.run("UPDATE participants SET totp_verified = true WHERE id = %s", (self.id,)) + else: + raise FailedToVerifyOTP + + def disable_totp(self): + self.db.run(""" + UPDATE participants + SET totp_token = NULL, + totp_verified = false + WHERE id = %s + """, + (self.id,)) + # Session Management # ================== @classmethod def authenticate_with_session( - cls, p_id, session_id, secret, allow_downgrade=False, cookies=None, + cls, p_id, session_id, secret, totp, allow_downgrade=False, cookies=None, context='log-in' ): """Fetch a participant using its ID, but only if the provided session is valid. @@ -402,13 +465,15 @@ def authenticate_with_session( p_id (int | str): the participant's ID session_id (int | str): the ID of the session secret (str): the actual secret + totp (str): optional totp code allow_downgrade (bool): allow downgrading to a read-only session cookies (SimpleCookie): the response cookies, only needed when `allow_downgrade` is `True` + context (str): the operation that this authentication is part of Return type: Tuple[Participant, Literal['valid']] | - Tuple[None, Literal['expired', 'invalid']] + Tuple[None, Literal['expired', 'invalid', 'require_totp']] """ if not secret: if session_id == '!': @@ -431,6 +496,10 @@ def authenticate_with_session( if not r: return None, 'invalid' p, stored_secret, mtime = r + if context == 'log-in': + if p.is_totp_verified(): + if (not totp) or (not p.verify_totp(totp)): + return p, 'require_totp' if not constant_time_compare(stored_secret, secret): return None, 'invalid' if mtime > utcnow() - SESSION_TIMEOUT: diff --git a/liberapay/security/authentication.py b/liberapay/security/authentication.py index ebdae91cd4..ed9777eccb 100644 --- a/liberapay/security/authentication.py +++ b/liberapay/security/authentication.py @@ -70,6 +70,7 @@ def sign_in_with_form_data(body, state): src_addr, src_country = request.source, request.country input_id = body['log-in.id'].strip() password = body.pop('log-in.password', None) + totp = body.pop('log-in.totp', None) id_type = None if input_id.find('@') > 0: id_type = 'email' @@ -84,7 +85,7 @@ def sign_in_with_form_data(body, state): else: p_id = Participant.get_id_for(id_type, input_id) try: - p = Participant.authenticate_with_password(p_id, password) + p = Participant.authenticate_with_password(p_id, password, totp) except AccountIsPasswordless: if id_type == 'email': state['log-in.email'] = input_id @@ -93,7 +94,7 @@ def sign_in_with_form_data(body, state): return if not p: state['log-in.error'] = ( - _("The submitted password is incorrect.") if p_id is not None else + _("The submitted password and/or otp is incorrect.") if p_id is not None else _("“{0}” is not a valid account ID.", input_id) if id_type == 'immutable' else _("No account has the username “{username}”.", username=input_id) if id_type == 'username' else _("No account has “{email_address}” as its primary email address.", email_address=input_id) @@ -260,8 +261,9 @@ def authenticate_user_if_possible(csrf_token, request, response, state, user, _) if len(creds) == 2: creds = [creds[0], 1, creds[1]] if len(creds) == 3: + creds.append('') session_p, state['session_status'] = Participant.authenticate_with_session( - *creds, allow_downgrade=True, cookies=response.headers.cookie + *creds, allow_downgrade=True, cookies=response.headers.cookie, context='cookie' ) if session_p: user = state['user'] = session_p @@ -307,6 +309,7 @@ def authenticate_user_if_possible(csrf_token, request, response, state, user, _) if request.qs.get('log-in.id'): # Email auth id = request.qs.get_int('log-in.id') + totp = request.qs.get('log-in.totp') session_id = request.qs.get('log-in.key') if not session_id or session_id < '1001' or session_id > '1010': raise response.render('simplates/log-in-link-is-invalid.spt', state) @@ -314,12 +317,12 @@ def authenticate_user_if_possible(csrf_token, request, response, state, user, _) if not (token and token.endswith('.em')): raise response.render('simplates/log-in-link-is-invalid.spt', state) required = request.qs.parse_boolean('log-in.required', default=True) - p = Participant.authenticate_with_session( - id, session_id, token, - allow_downgrade=not required, cookies=response.headers.cookie, - )[0] + p, reason = Participant.authenticate_with_session( + id, session_id, token, totp, + allow_downgrade=not required, cookies=response.headers.cookie + ) if p: - if p.id != user.id: + if p.id != user.id or reason == 'require_totp': submitted_confirmation_token = request.qs.get('log-in.confirmation') if submitted_confirmation_token: expected_confirmation_token = b64encode_s(blake2b( @@ -338,6 +341,8 @@ def authenticate_user_if_possible(csrf_token, request, response, state, user, _) 'querystring' ) del request.qs['log-in.confirmation'] + if not p.session and reason == 'require_totp': + raise response.render('simplates/log-in-link-is-valid.spt', state) else: raise response.render('simplates/log-in-link-is-valid.spt', state) redirect = True diff --git a/requirements_base.txt b/requirements_base.txt index d7143d4a0b..44e1d48662 100644 --- a/requirements_base.txt +++ b/requirements_base.txt @@ -259,3 +259,12 @@ OpenCC==1.1.3 \ gunicorn==20.1.0 \ --hash=sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e \ --hash=sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8 +pyotp==2.7.0 \ + --hash=sha256:2e746de4f15685878df6d022c5691627af9941eec18e0d513f05497f5fa7711f \ + --hash=sha256:ce989faba0df77dc032b45e51c6cca42bcf20896c8d3d1e7cd759a53dc7d6cb5 +PyQRCode==1.2.1 \ + --hash=sha256:1b2812775fa6ff5c527977c4cd2ccb07051ca7d0bc0aecf937a43864abe5eff6 \ + --hash=sha256:fdbf7634733e56b72e27f9bce46e4550b75a3a2c420414035cae9d9d26b234d5 +pypng==0.20220715.0 \ + --hash=sha256:4a43e969b8f5aaafb2a415536c1a8ec7e341cd6a3f957fd5b5f32a4cfeed902c \ + --hash=sha256:739c433ba96f078315de54c0db975aee537cbc3e1d0ae4ed9aab0ca1e427e2c1 diff --git a/simplates/log-in-link-is-valid.spt b/simplates/log-in-link-is-valid.spt index 79ca83fe9e..f0b69e35b3 100644 --- a/simplates/log-in-link-is-valid.spt +++ b/simplates/log-in-link-is-valid.spt @@ -3,6 +3,8 @@ from hashlib import blake2b from liberapay.utils import b64encode_s [---] +invalid_totp = False + cancel = request.qs.parse_boolean('log-in.cancel', default=False) p_id = request.qs.get_int('log-in.id') if cancel: @@ -35,9 +37,17 @@ response.code = 200 "to confirm or cancel.", identifier='%s'|safe % logging_in_as ) }}

+ % if invalid_otp +

{{ _( + "Invalid OTP-code. Please try again." + ) }}

+ % endif

- {{ _( + + {{ _( "Log in as {identifier}", identifier=logging_in_as ) }} diff --git a/sql/branch.sql b/sql/branch.sql new file mode 100644 index 0000000000..437a0a0d23 --- /dev/null +++ b/sql/branch.sql @@ -0,0 +1,5 @@ +-- add columns for TOTP-support in participants + +ALTER TABLE participants + ADD COLUMN totp_token TEXT, + ADD COLUMN totp_verified BOOLEAN DEFAULT false; diff --git a/templates/log-in-form.html b/templates/log-in-form.html index b637671756..46a23f93d7 100644 --- a/templates/log-in-form.html +++ b/templates/log-in-form.html @@ -44,6 +44,9 @@ + @@ -91,6 +94,9 @@ +

{{ _( diff --git a/templates/macros/auth.html b/templates/macros/auth.html index 79de2f6fc7..ee39e68954 100644 --- a/templates/macros/auth.html +++ b/templates/macros/auth.html @@ -41,6 +41,14 @@

{{ _("Password") }}

{{ _("Maximum length is {0}.", constants.PASSWORD_MAX_SIZE) }}

-

{{ _("2FA") }}

-

{{ _("Liberapay does not yet support two-factor authentication.") }}

+

{{ _("Two-Factor Authentication (2FA)") }}

+ % if participant.is_totp_verified() +
+ + + +
+ % else + {{ _("Enable Two-Factor Authentication") }} + % endif % endmacro diff --git a/www/%username/settings/edit.spt b/www/%username/settings/edit.spt index 1a2f67738c..006f3477fb 100644 --- a/www/%username/settings/edit.spt +++ b/www/%username/settings/edit.spt @@ -21,7 +21,7 @@ if 'new-password' in body or 'action' in body: else: try: p2 = Participant.authenticate_with_password( - p.id, body.get('cur-password'), context='change_password' + p.id, body.get('cur-password'), '', context='change_password' ) except AccountIsPasswordless: pass # user doesn't have a password yet, allow adding one diff --git a/www/%username/settings/enable_2fa.spt b/www/%username/settings/enable_2fa.spt new file mode 100644 index 0000000000..e3b6fc75d9 --- /dev/null +++ b/www/%username/settings/enable_2fa.spt @@ -0,0 +1,48 @@ +from liberapay.utils import get_participant + +[---] + +participant = get_participant(state, restrict=True) +image = participant.generate_totp_qrcode() + +subhead = _("Enable Two-Factor Authentication") +title = participant.username + +[---] text/html +% extends "templates/layouts/settings.html" + +% block content + % if participant.is_totp_verified() +

{{ _( + "Two-Factor Authentication is already enabled." + ) }}

+ % else +

{{ _( + "Please scan the QR-code below using an authenticator app on " + "your mobile device such as KeePass, FreeOTP, andOTP, " + "Google Authenticator, Microsoft Authenticator, 2FAS, etc." + ) }}

+

{{ _( + "Then type the code shown for the newly created entry " + "into the text box further down and press 'Verify'." + ) }}

+

{{ _( + "When Two-Factor Authentication is enabled, you will be asked " + "to enter the code from the entry in your authenticator app whenever " + "you wish to login." + ) }}

+ % if request.qs.parse_boolean('failed_to_verify', False) +

{{ _("Failed to verify the submitted code. Please try again with another one.") }}

+ % endif +

+
+ + +
+ +
+ +
+ % endif +% endblock diff --git a/www/%username/settings/handle_2fa.spt b/www/%username/settings/handle_2fa.spt new file mode 100644 index 0000000000..8818565317 --- /dev/null +++ b/www/%username/settings/handle_2fa.spt @@ -0,0 +1,31 @@ +from liberapay.exceptions import FailedToVerifyOTP +from liberapay.models.participant import Participant +from liberapay.utils import form_post_success, get_participant + +[---] + +request.allow('POST') + +body = request.body + +p = get_participant(state, restrict=True, allow_member=True) + +if not p.is_person: + raise response.error(403) + +action = request.body.get_choice('action', ('enable', 'disable'), default='enable') + +if action == 'disable': + p.disable_totp() + form_post_success(state, msg=_("You have disabled Two-Factor Authentication.")) +elif action == 'enable' and 'verification-code' in body: + verification_code = body['verification-code'] + try: + p.enable_totp(verification_code) + except FailedToVerifyOTP: + raise response.redirect(p.path('settings/enable_2fa?failed_to_verify=1')) + form_post_success(state, msg=_("You have enabled Two-Factor Authentication.")) +else: + raise response.error(400, "no known key found in body") + +[---] text/html diff --git a/www/%username/settings/index.html.spt b/www/%username/settings/index.html.spt index 12f289a649..a091627167 100644 --- a/www/%username/settings/index.html.spt +++ b/www/%username/settings/index.html.spt @@ -1,6 +1,7 @@ from liberapay.models.exchange_route import ExchangeRoute from liberapay.utils import get_participant + [-----------------------------------------------------------------------------] participant = get_participant(state, restrict=True) @@ -9,6 +10,7 @@ subhead = _("Account") is_a_person = participant.kind not in ('group', 'community') + [-----------------------------------------------------------------------------] % extends "templates/layouts/settings.html" From 240a720f89ce68c45614e66407197ce560d5f71c Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Sat, 10 Dec 2022 23:13:18 +0100 Subject: [PATCH 2/8] [feat] make auth calls a bit more according to the established convention --- liberapay/models/participant.py | 16 +++++++--------- liberapay/security/authentication.py | 9 ++++----- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py index 75e64f3133..29304d4f36 100644 --- a/liberapay/models/participant.py +++ b/liberapay/models/participant.py @@ -13,7 +13,6 @@ from urllib.parse import quote as urlquote, urlencode import uuid import io -import png from pyotp import totp, random_base32 from pyqrcode import create as create_qrcode @@ -245,7 +244,7 @@ def refetch(self): # =================== @classmethod - def authenticate_with_password(cls, p_id, password, totp, context='log-in'): + def authenticate_with_password(cls, p_id, password, totp='', context='log-in'): """Fetch a participant using its ID, but only if the provided password is valid. Args: @@ -422,9 +421,9 @@ def gen_totp_token(self): def generate_totp_qrcode(self): totp_token = self.gen_totp_token() uri = totp.TOTP(totp_token).provisioning_uri( - name=f'~{self.id}', - image='https://liberapay.com/assets/liberapay/icon-v2_white-on-yellow.200.png', - issuer_name='Liberapay' + name=f'~{self.id}', + image='https://liberapay.com/assets/liberapay/icon-v2_white-on-yellow.200.png', + issuer_name='Liberapay' ) qrcode = create_qrcode(uri) buffer = io.BytesIO() @@ -434,7 +433,7 @@ def generate_totp_qrcode(self): def verify_totp(self, totp_code): totp_token = self.gen_totp_token() - return totp.TOTP(totp_token).verify(totp_code) + return totp.TOTP(totp_token).verify(totp_code, valid_window=VALID_WINDOW_2FA) def enable_totp(self, verification_code): if self.verify_totp(verification_code): @@ -448,8 +447,7 @@ def disable_totp(self): SET totp_token = NULL, totp_verified = false WHERE id = %s - """, - (self.id,)) + """, (self.id,)) # Session Management @@ -457,7 +455,7 @@ def disable_totp(self): @classmethod def authenticate_with_session( - cls, p_id, session_id, secret, totp, allow_downgrade=False, cookies=None, context='log-in' + cls, p_id, session_id, secret, totp=totp, allow_downgrade=False, cookies=None, context='log-in' ): """Fetch a participant using its ID, but only if the provided session is valid. diff --git a/liberapay/security/authentication.py b/liberapay/security/authentication.py index ed9777eccb..74650a6a21 100644 --- a/liberapay/security/authentication.py +++ b/liberapay/security/authentication.py @@ -85,7 +85,7 @@ def sign_in_with_form_data(body, state): else: p_id = Participant.get_id_for(id_type, input_id) try: - p = Participant.authenticate_with_password(p_id, password, totp) + p = Participant.authenticate_with_password(p_id, password, totp=totp) except AccountIsPasswordless: if id_type == 'email': state['log-in.email'] = input_id @@ -261,9 +261,8 @@ def authenticate_user_if_possible(csrf_token, request, response, state, user, _) if len(creds) == 2: creds = [creds[0], 1, creds[1]] if len(creds) == 3: - creds.append('') session_p, state['session_status'] = Participant.authenticate_with_session( - *creds, allow_downgrade=True, cookies=response.headers.cookie, context='cookie' + *creds, totp='', allow_downgrade=True, cookies=response.headers.cookie, context='cookie' ) if session_p: user = state['user'] = session_p @@ -318,8 +317,8 @@ def authenticate_user_if_possible(csrf_token, request, response, state, user, _) raise response.render('simplates/log-in-link-is-invalid.spt', state) required = request.qs.parse_boolean('log-in.required', default=True) p, reason = Participant.authenticate_with_session( - id, session_id, token, totp, - allow_downgrade=not required, cookies=response.headers.cookie + id, session_id, token, + totp=totp, allow_downgrade=not required, cookies=response.headers.cookie ) if p: if p.id != user.id or reason == 'require_totp': From 39ca06889cfa7d28a6f54446570da97ca65bcfbc Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Sat, 10 Dec 2022 23:13:35 +0100 Subject: [PATCH 3/8] [fix] typo --- simplates/log-in-link-is-valid.spt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/simplates/log-in-link-is-valid.spt b/simplates/log-in-link-is-valid.spt index f0b69e35b3..ba0661d1a3 100644 --- a/simplates/log-in-link-is-valid.spt +++ b/simplates/log-in-link-is-valid.spt @@ -37,7 +37,7 @@ response.code = 200 "to confirm or cancel.", identifier='%s'|safe % logging_in_as ) }}

- % if invalid_otp + % if invalid_totp

{{ _( "Invalid OTP-code. Please try again." ) }}

From ba09066a6b7773bcc392a6dc198c0d303d99651a Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Sun, 11 Dec 2022 00:07:11 +0100 Subject: [PATCH 4/8] [fix] name clarification --- liberapay/models/participant.py | 6 +++--- templates/macros/auth.html | 2 +- www/%username/settings/enable_2fa.spt | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py index 29304d4f36..f1f3fba733 100644 --- a/liberapay/models/participant.py +++ b/liberapay/models/participant.py @@ -271,7 +271,7 @@ def authenticate_with_password(cls, p_id, password, totp='', context='log-in'): return None p, stored_secret = r if context == 'log-in': - if p.is_totp_verified(): + if p.is_totp_enabled(): if (not totp) or (not p.verify_totp(totp)): return None cls.db.hit_rate_limit('log-in.password', p.id, TooManyPasswordLogins) @@ -399,7 +399,7 @@ def check_password(self, password, context): # 2FA Management (TOTP) # ===================== - def is_totp_verified(self): + def is_totp_enabled(self): return self.db.one( "SELECT totp_verified FROM participants WHERE id = %s", (self.id,) @@ -495,7 +495,7 @@ def authenticate_with_session( return None, 'invalid' p, stored_secret, mtime = r if context == 'log-in': - if p.is_totp_verified(): + if p.is_totp_enabled(): if (not totp) or (not p.verify_totp(totp)): return p, 'require_totp' if not constant_time_compare(stored_secret, secret): diff --git a/templates/macros/auth.html b/templates/macros/auth.html index ee39e68954..b3541e85a9 100644 --- a/templates/macros/auth.html +++ b/templates/macros/auth.html @@ -42,7 +42,7 @@

{{ _("Password") }}

{{ _("Maximum length is {0}.", constants.PASSWORD_MAX_SIZE) }}

{{ _("Two-Factor Authentication (2FA)") }}

- % if participant.is_totp_verified() + % if participant.is_totp_enabled()
diff --git a/www/%username/settings/enable_2fa.spt b/www/%username/settings/enable_2fa.spt index e3b6fc75d9..086a3fee65 100644 --- a/www/%username/settings/enable_2fa.spt +++ b/www/%username/settings/enable_2fa.spt @@ -12,7 +12,7 @@ title = participant.username % extends "templates/layouts/settings.html" % block content - % if participant.is_totp_verified() + % if participant.is_totp_enabled()

{{ _( "Two-Factor Authentication is already enabled." ) }}

From 9f00a3943eac95373590710c96a837fdf9ed34c0 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Sun, 11 Dec 2022 00:07:49 +0100 Subject: [PATCH 5/8] [chore] added tests --- liberapay/models/participant.py | 2 +- liberapay/security/authentication.py | 2 +- simplates/log-in-link-is-valid.spt | 6 ---- tests/py/test_participant.py | 41 +++++++++++++++++++++++++++ www/%username/settings/edit.spt | 2 +- www/%username/settings/index.html.spt | 2 -- 6 files changed, 44 insertions(+), 11 deletions(-) diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py index f1f3fba733..47b3e73d44 100644 --- a/liberapay/models/participant.py +++ b/liberapay/models/participant.py @@ -455,7 +455,7 @@ def disable_totp(self): @classmethod def authenticate_with_session( - cls, p_id, session_id, secret, totp=totp, allow_downgrade=False, cookies=None, context='log-in' + cls, p_id, session_id, secret, totp='', allow_downgrade=False, cookies=None, context='log-in' ): """Fetch a participant using its ID, but only if the provided session is valid. diff --git a/liberapay/security/authentication.py b/liberapay/security/authentication.py index 74650a6a21..02268c7ebf 100644 --- a/liberapay/security/authentication.py +++ b/liberapay/security/authentication.py @@ -262,7 +262,7 @@ def authenticate_user_if_possible(csrf_token, request, response, state, user, _) creds = [creds[0], 1, creds[1]] if len(creds) == 3: session_p, state['session_status'] = Participant.authenticate_with_session( - *creds, totp='', allow_downgrade=True, cookies=response.headers.cookie, context='cookie' + *creds, allow_downgrade=True, cookies=response.headers.cookie, context='cookie' ) if session_p: user = state['user'] = session_p diff --git a/simplates/log-in-link-is-valid.spt b/simplates/log-in-link-is-valid.spt index ba0661d1a3..9fb4121822 100644 --- a/simplates/log-in-link-is-valid.spt +++ b/simplates/log-in-link-is-valid.spt @@ -3,7 +3,6 @@ from hashlib import blake2b from liberapay.utils import b64encode_s [---] -invalid_totp = False cancel = request.qs.parse_boolean('log-in.cancel', default=False) p_id = request.qs.get_int('log-in.id') @@ -37,11 +36,6 @@ response.code = 200 "to confirm or cancel.", identifier='%s'|safe % logging_in_as ) }}

- % if invalid_totp -

{{ _( - "Invalid OTP-code. Please try again." - ) }}

- % endif

Date: Wed, 14 Dec 2022 22:17:35 +0100 Subject: [PATCH 6/8] [fix] bumped pyotp to 2.8.0 --- requirements_base.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/requirements_base.txt b/requirements_base.txt index 44e1d48662..72053ef3c2 100644 --- a/requirements_base.txt +++ b/requirements_base.txt @@ -259,9 +259,9 @@ OpenCC==1.1.3 \ gunicorn==20.1.0 \ --hash=sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e \ --hash=sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8 -pyotp==2.7.0 \ - --hash=sha256:2e746de4f15685878df6d022c5691627af9941eec18e0d513f05497f5fa7711f \ - --hash=sha256:ce989faba0df77dc032b45e51c6cca42bcf20896c8d3d1e7cd759a53dc7d6cb5 +pyotp==2.8.0 \ + --hash=sha256:889d037fdde6accad28531fc62a790f089e5dfd5b638773e9ee004cce074a2e5 \ + --hash=sha256:c2f5e17d9da92d8ec1f7de6331ab08116b9115adbabcba6e208d46fc49a98c5a PyQRCode==1.2.1 \ --hash=sha256:1b2812775fa6ff5c527977c4cd2ccb07051ca7d0bc0aecf937a43864abe5eff6 \ --hash=sha256:fdbf7634733e56b72e27f9bce46e4550b75a3a2c420414035cae9d9d26b234d5 From fcd0984a2ab2da3181a733d60d9693bf0c57cbf0 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Tue, 20 Dec 2022 22:20:27 +0100 Subject: [PATCH 7/8] [fix] consolidate totp simplates into one --- liberapay/models/participant.py | 15 ------- templates/macros/auth.html | 4 +- www/%username/settings/handle_2fa.spt | 31 ------------- .../settings/{enable_2fa.spt => totp.spt} | 44 +++++++++++++++++-- 4 files changed, 42 insertions(+), 52 deletions(-) delete mode 100644 www/%username/settings/handle_2fa.spt rename www/%username/settings/{enable_2fa.spt => totp.spt} (50%) diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py index 47b3e73d44..d7589f89a4 100644 --- a/liberapay/models/participant.py +++ b/liberapay/models/participant.py @@ -12,9 +12,7 @@ import unicodedata from urllib.parse import quote as urlquote, urlencode import uuid -import io from pyotp import totp, random_base32 -from pyqrcode import create as create_qrcode import aspen_jinja2_renderer from cached_property import cached_property @@ -418,19 +416,6 @@ def gen_totp_token(self): ) return totp_token - def generate_totp_qrcode(self): - totp_token = self.gen_totp_token() - uri = totp.TOTP(totp_token).provisioning_uri( - name=f'~{self.id}', - image='https://liberapay.com/assets/liberapay/icon-v2_white-on-yellow.200.png', - issuer_name='Liberapay' - ) - qrcode = create_qrcode(uri) - buffer = io.BytesIO() - qrcode.png(buffer, scale=4) - image = b64encode(buffer.getvalue()).decode() - return image - def verify_totp(self, totp_code): totp_token = self.gen_totp_token() return totp.TOTP(totp_token).verify(totp_code, valid_window=VALID_WINDOW_2FA) diff --git a/templates/macros/auth.html b/templates/macros/auth.html index b3541e85a9..69496d4f34 100644 --- a/templates/macros/auth.html +++ b/templates/macros/auth.html @@ -43,12 +43,12 @@

{{ _("Password") }}

{{ _("Two-Factor Authentication (2FA)") }}

% if participant.is_totp_enabled() - + % else - {{ _("Enable Two-Factor Authentication") }} + {{ _("Enable Two-Factor Authentication") }} % endif % endmacro diff --git a/www/%username/settings/handle_2fa.spt b/www/%username/settings/handle_2fa.spt deleted file mode 100644 index 8818565317..0000000000 --- a/www/%username/settings/handle_2fa.spt +++ /dev/null @@ -1,31 +0,0 @@ -from liberapay.exceptions import FailedToVerifyOTP -from liberapay.models.participant import Participant -from liberapay.utils import form_post_success, get_participant - -[---] - -request.allow('POST') - -body = request.body - -p = get_participant(state, restrict=True, allow_member=True) - -if not p.is_person: - raise response.error(403) - -action = request.body.get_choice('action', ('enable', 'disable'), default='enable') - -if action == 'disable': - p.disable_totp() - form_post_success(state, msg=_("You have disabled Two-Factor Authentication.")) -elif action == 'enable' and 'verification-code' in body: - verification_code = body['verification-code'] - try: - p.enable_totp(verification_code) - except FailedToVerifyOTP: - raise response.redirect(p.path('settings/enable_2fa?failed_to_verify=1')) - form_post_success(state, msg=_("You have enabled Two-Factor Authentication.")) -else: - raise response.error(400, "no known key found in body") - -[---] text/html diff --git a/www/%username/settings/enable_2fa.spt b/www/%username/settings/totp.spt similarity index 50% rename from www/%username/settings/enable_2fa.spt rename to www/%username/settings/totp.spt index 086a3fee65..c461d47501 100644 --- a/www/%username/settings/enable_2fa.spt +++ b/www/%username/settings/totp.spt @@ -1,9 +1,45 @@ -from liberapay.utils import get_participant +import io +from base64 import b64encode +from pyotp import totp +from pyqrcode import create as create_qrcode +from liberapay.utils import form_post_success, get_participant +from liberapay.exceptions import FailedToVerifyOTP [---] participant = get_participant(state, restrict=True) -image = participant.generate_totp_qrcode() + +if request.method == 'POST': + body = request.body + + if not participant.is_person: + raise response.error(403) + + action = request.body.get_choice('action', ('enable', 'disable'), default='enable') + + if action == 'disable': + participant.disable_totp() + form_post_success(state, msg=_("You have disabled Two-Factor Authentication.")) + elif action == 'enable' and 'verification-code' in body: + verification_code = body['verification-code'] + try: + participant.enable_totp(verification_code) + except FailedToVerifyOTP: + raise response.redirect(participant.path('settings/totp?failed_to_verify=1')) + form_post_success(state, msg=_("You have enabled Two-Factor Authentication.")) + else: + raise response.error(400, "no known key found in body") + +totp_token = participant.gen_totp_token() +totp_uri = totp.TOTP(totp_token).provisioning_uri( + name=f'~{participant.id}', + image='https://liberapay.com/assets/liberapay/icon-v2_white-on-yellow.200.png', + issuer_name='Liberapay' +) +qr_code = create_qrcode(totp_uri) +buffer = io.BytesIO() +qr_code.png(buffer, scale=4) +qr_code_image = b64encode(buffer.getvalue()).decode() subhead = _("Enable Two-Factor Authentication") title = participant.username @@ -34,8 +70,8 @@ title = participant.username % if request.qs.parse_boolean('failed_to_verify', False)

{{ _("Failed to verify the submitted code. Please try again with another one.") }}

% endif -

-
+

{{ _('QR Code') }}

+
From 8f9dbe76fa2c659395877113a0e78b71bf657286 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Sun, 25 Dec 2022 14:11:50 +0100 Subject: [PATCH 8/8] [fix] renamed VALID_WINDOW_2FA to TOTP_TOLERANCE_PERIODS --- liberapay/constants.py | 2 +- liberapay/models/participant.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/liberapay/constants.py b/liberapay/constants.py index ceeb5efcb5..5c6e4b871b 100644 --- a/liberapay/constants.py +++ b/liberapay/constants.py @@ -437,6 +437,6 @@ def __missing__(self, currency): USERNAME_MAX_SIZE = 32 USERNAME_SUFFIX_BLACKLIST = set('.txt .html .htm .json .xml'.split()) -VALID_WINDOW_2FA = 5 +TOTP_TOLERANCE_PERIODS = 5 # Number of ticks (not seconds) that a code is valid after a new one is generated del _ diff --git a/liberapay/models/participant.py b/liberapay/models/participant.py index d7589f89a4..621ed2d320 100644 --- a/liberapay/models/participant.py +++ b/liberapay/models/participant.py @@ -33,7 +33,7 @@ PASSWORD_MAX_SIZE, PASSWORD_MIN_SIZE, PAYPAL_CURRENCIES, PERIOD_CONVERSION_RATES, PRIVILEGES, PUBLIC_NAME_MAX_SIZE, SEPA, SESSION, SESSION_TIMEOUT, - USERNAME_MAX_SIZE, USERNAME_SUFFIX_BLACKLIST, VALID_WINDOW_2FA + USERNAME_MAX_SIZE, USERNAME_SUFFIX_BLACKLIST, TOTP_TOLERANCE_PERIODS ) from liberapay.exceptions import ( AccountIsPasswordless, @@ -418,7 +418,7 @@ def gen_totp_token(self): def verify_totp(self, totp_code): totp_token = self.gen_totp_token() - return totp.TOTP(totp_token).verify(totp_code, valid_window=VALID_WINDOW_2FA) + return totp.TOTP(totp_token).verify(totp_code, valid_window=TOTP_TOLERANCE_PERIODS) def enable_totp(self, verification_code): if self.verify_totp(verification_code):