{{ _(
+ "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()