diff --git a/js/log-in-link-is-valid.js b/js/log-in-link-is-valid.js new file mode 100644 index 000000000..0376320ea --- /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 f2a757192..334b164ad 100644 --- a/liberapay/constants.py +++ b/liberapay/constants.py @@ -384,4 +384,6 @@ def generate_value(self, currency): USERNAME_MAX_SIZE = 32 USERNAME_SUFFIX_BLACKLIST = set('.txt .html .htm .json .xml'.split()) +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/exceptions.py b/liberapay/exceptions.py index c78e2ede5..0b8a19642 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 220e82d50..b41b72d83 100644 --- a/liberapay/models/participant.py +++ b/liberapay/models/participant.py @@ -12,6 +12,7 @@ import unicodedata from urllib.parse import quote as urlquote, urlencode import uuid +from pyotp import totp, random_base32 import aspen_jinja2_renderer from cached_property import cached_property @@ -32,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, + USERNAME_MAX_SIZE, USERNAME_SUFFIX_BLACKLIST, TOTP_TOLERANCE_PERIODS ) from liberapay.exceptions import ( AccountIsPasswordless, @@ -45,6 +46,7 @@ EmailAddressIsBlacklisted, EmailAlreadyTaken, EmailNotVerified, + FailedToVerifyOTP, InvalidId, LoginRequired, NonexistingElsewhere, @@ -241,12 +243,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` @@ -267,6 +270,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_enabled(): + 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: @@ -389,13 +395,53 @@ def check_password(self, password, context): self.add_event(website.db, 'password-check', None) return status + # 2FA Management (TOTP) + # ===================== + + def is_totp_enabled(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 verify_totp(self, totp_code): + totp_token = self.gen_totp_token() + 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): + 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. @@ -403,13 +449,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 == '!': @@ -441,6 +489,10 @@ def authenticate_with_session( if not r: return None, 'invalid' p, stored_secret, mtime = r + if context == 'log-in': + 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): return None, 'invalid' if rate_limit: diff --git a/liberapay/security/authentication.py b/liberapay/security/authentication.py index 83ea6e0bd..d0823a861 100644 --- a/liberapay/security/authentication.py +++ b/liberapay/security/authentication.py @@ -71,6 +71,7 @@ def sign_in_with_form_data(body, state): src_addr, src_country = request.source, request.source_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' @@ -86,7 +87,7 @@ def sign_in_with_form_data(body, state): p_id = Participant.get_id_for(id_type, input_id) if p_id: try: - p = Participant.authenticate_with_password(p_id, password) + p = Participant.authenticate_with_password(p_id, password, totp=totp) except AccountIsPasswordless: if id_type == 'email': state['log-in.email'] = input_id @@ -100,7 +101,7 @@ def sign_in_with_form_data(body, state): p = None 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) @@ -268,7 +269,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, 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 @@ -314,6 +315,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) @@ -321,12 +323,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( + p, reason = Participant.authenticate_with_session( id, session_id, token, - allow_downgrade=not required, cookies=response.headers.cookie, - )[0] + totp=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': response.headers[b'Referrer-Policy'] = b'strict-origin' submitted_confirmation_token = request.qs.get('log-in.confirmation') if submitted_confirmation_token: @@ -346,6 +348,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 0f8951a7c..95df34b5c 100644 --- a/requirements_base.txt +++ b/requirements_base.txt @@ -275,3 +275,12 @@ OpenCC==1.1.3 \ gunicorn==20.1.0 \ --hash=sha256:9dcc4547dbb1cb284accfb15ab5667a0e5d1881cc443e0677b4882a4067a807e \ --hash=sha256:e0a968b5ba15f8a328fdfd7ab1fcb5af4470c28aaf7e55df02a99bc13138e6e8 +pyotp==2.8.0 \ + --hash=sha256:889d037fdde6accad28531fc62a790f089e5dfd5b638773e9ee004cce074a2e5 \ + --hash=sha256:c2f5e17d9da92d8ec1f7de6331ab08116b9115adbabcba6e208d46fc49a98c5a +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 79ca83fe9..9fb412182 100644 --- a/simplates/log-in-link-is-valid.spt +++ b/simplates/log-in-link-is-valid.spt @@ -3,6 +3,7 @@ from hashlib import blake2b from liberapay.utils import b64encode_s [---] + cancel = request.qs.parse_boolean('log-in.cancel', default=False) p_id = request.qs.get_int('log-in.id') if cancel: @@ -37,7 +38,10 @@ response.code = 200 ) }}
- {{ _(
+
+ {{ _(
"Log in as {identifier}",
identifier=logging_in_as
) }}
diff --git a/sql/branch.sql b/sql/branch.sql
new file mode 100644
index 000000000..437a0a0d2
--- /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 b63767175..46a23f93d 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 3674cdb80..c43e10616 100644
--- a/templates/macros/auth.html
+++ b/templates/macros/auth.html
@@ -40,6 +40,14 @@ {{ _("Maximum length is {0}.", constants.PASSWORD_MAX_SIZE) }} {{ _("Liberapay does not yet support two-factor authentication.") }} {{ _(
+ "Two-Factor Authentication is already enabled."
+ ) }} {{ _(
+ "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."
+ ) }} {{ _("Failed to verify the submitted code. Please try again with another one.") }}{{ _("Password") }}
{{ _("2FA") }}
- {{ _("Two-Factor Authentication (2FA)") }}
+ % if participant.is_totp_enabled()
+
+ % else
+ {{ _("Enable Two-Factor Authentication") }}
+ % endif
% endmacro
diff --git a/tests/py/test_participant.py b/tests/py/test_participant.py
index 444dd4a99..f288154fa 100644
--- a/tests/py/test_participant.py
+++ b/tests/py/test_participant.py
@@ -16,12 +16,15 @@
UsernameIsEmpty,
UsernameIsRestricted,
UsernameTooLong,
+ FailedToVerifyOTP,
)
from liberapay.i18n.currencies import Money
from liberapay.models.participant import NeedConfirmation, Participant
from liberapay.models.tip import Tip
from liberapay.testing import EUR, USD, Harness
+from pyotp import totp
+
class TestTakeOver(Harness):
@@ -190,6 +193,44 @@ def test_getting_tips_not_made(self):
actual = self.stub.get_tip_to(user2).amount
assert actual == expected
+ def test_generate_a_totp_token(self):
+ token = self.stub.gen_totp_token()
+ assert len(token) == 32
+ assert self.stub.is_totp_enabled() == False
+
+ def test_make_sure_that_one_totp_is_generated(self):
+ token = self.stub.gen_totp_token()
+ token2 = self.stub.gen_totp_token()
+ assert token == token2
+ assert self.stub.is_totp_enabled() == False
+
+ def test_generate_new_totp_token(self):
+ token = self.stub.gen_totp_token()
+ self.stub.disable_totp()
+ token2 = self.stub.gen_totp_token()
+ assert token != token2
+ assert self.stub.is_totp_enabled() == False
+
+ def test_verify_totp_successful(self):
+ token = self.stub.gen_totp_token()
+ code = totp.TOTP(token).now()
+ assert self.stub.verify_totp(code) == True
+ assert self.stub.is_totp_enabled() == False
+
+ def test_verify_totp_failure(self):
+ assert self.stub.verify_totp('blah') == False
+ assert self.stub.is_totp_enabled() == False
+
+ def test_enable_totp_successful(self):
+ token = self.stub.gen_totp_token()
+ code = totp.TOTP(token).now()
+ assert self.stub.enable_totp(code) == None
+ assert self.stub.is_totp_enabled() == True
+
+ def test_enable_totp_failure(self):
+ with self.assertRaises(FailedToVerifyOTP):
+ self.stub.enable_totp('blah')
+
class Tests(Harness):
diff --git a/www/%username/settings/totp.spt b/www/%username/settings/totp.spt
new file mode 100644
index 000000000..c461d4750
--- /dev/null
+++ b/www/%username/settings/totp.spt
@@ -0,0 +1,84 @@
+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)
+
+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
+
+[---] text/html
+% extends "templates/layouts/settings.html"
+
+% block content
+ % if participant.is_totp_enabled()
+