diff --git a/CHANGELOG.md b/CHANGELOG.md index 40d77489..3780e187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file. - Add SITE_LOGO_URL setting (#323) - Add admin action to log in as any user - Add a "Manager" role (#484) +- Add support for 2FA using TOTP (#354) ### Bug Fixes - Fix dark mode styling issues in Cron Syntax Cheatsheet diff --git a/hc/accounts/forms.py b/hc/accounts/forms.py index 397b8320..232a0fd3 100644 --- a/hc/accounts/forms.py +++ b/hc/accounts/forms.py @@ -151,7 +151,7 @@ class TransferForm(forms.Form): email = LowercaseEmailField() -class AddCredentialForm(forms.Form): +class AddWebAuthnForm(forms.Form): name = forms.CharField(max_length=100) client_data_json = Base64Field() attestation_object = Base64Field() @@ -162,3 +162,16 @@ class WebAuthnForm(forms.Form): client_data_json = Base64Field() authenticator_data = Base64Field() signature = Base64Field() + + +class TotpForm(forms.Form): + error_css_class = "has-error" + code = forms.RegexField(regex=r"^\d{6}$") + + def __init__(self, totp, post=None, files=None): + self.totp = totp + super(TotpForm, self).__init__(post, files) + + def clean_code(self): + if not self.totp.verify(self.cleaned_data["code"], valid_window=1): + raise forms.ValidationError("The code you entered was incorrect.") diff --git a/hc/accounts/migrations/0044_auto_20210730_0942.py b/hc/accounts/migrations/0044_auto_20210730_0942.py new file mode 100644 index 00000000..d1b3bc74 --- /dev/null +++ b/hc/accounts/migrations/0044_auto_20210730_0942.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.4 on 2021-07-30 09:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0043_add_role_manager'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='totp', + field=models.CharField(blank=True, max_length=32, null=True), + ), + migrations.AddField( + model_name='profile', + name='totp_created', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/hc/accounts/models.py b/hc/accounts/models.py index 8c66ba44..88268aed 100644 --- a/hc/accounts/models.py +++ b/hc/accounts/models.py @@ -74,6 +74,9 @@ class Profile(models.Model): tz = models.CharField(max_length=36, default="UTC") theme = models.CharField(max_length=10, null=True, blank=True) + totp = models.CharField(max_length=32, null=True, blank=True) + totp_created = models.DateTimeField(null=True, blank=True) + objects = ProfileManager() def __str__(self): diff --git a/hc/accounts/tests/test_add_totp.py b/hc/accounts/tests/test_add_totp.py new file mode 100644 index 00000000..5d47c9fb --- /dev/null +++ b/hc/accounts/tests/test_add_totp.py @@ -0,0 +1,83 @@ +from unittest.mock import patch + +from hc.test import BaseTestCase + + +class AddTotpTestCase(BaseTestCase): + def setUp(self): + super().setUp() + + self.url = "/accounts/two_factor/totp/" + + def test_it_requires_sudo_mode(self): + self.client.login(username="alice@example.org", password="password") + + r = self.client.get(self.url) + self.assertContains(r, "We have sent a confirmation code") + + def test_it_shows_form(self): + self.client.login(username="alice@example.org", password="password") + self.set_sudo_flag() + + r = self.client.get(self.url) + self.assertContains(r, "Enter the six-digit code") + + # It should put a "totp_secret" key in the session: + self.assertIn("totp_secret", self.client.session) + + @patch("hc.accounts.views.pyotp.totp.TOTP") + def test_it_adds_totp(self, mock_TOTP): + mock_TOTP.return_value.verify.return_value = True + + self.client.login(username="alice@example.org", password="password") + self.set_sudo_flag() + + payload = {"code": "000000"} + r = self.client.post(self.url, payload, follow=True) + self.assertRedirects(r, "/accounts/profile/") + self.assertContains(r, "Successfully set up the Authenticator app") + + # totp_secret should be gone from the session: + self.assertNotIn("totp_secret", self.client.session) + + self.profile.refresh_from_db() + self.assertTrue(self.profile.totp) + self.assertTrue(self.profile.totp_created) + + @patch("hc.accounts.views.pyotp.totp.TOTP") + def test_it_handles_wrong_code(self, mock_TOTP): + mock_TOTP.return_value.verify.return_value = False + mock_TOTP.return_value.provisioning_uri.return_value = "test-uri" + + self.client.login(username="alice@example.org", password="password") + self.set_sudo_flag() + + payload = {"code": "000000"} + r = self.client.post(self.url, payload, follow=True) + self.assertContains(r, "The code you entered was incorrect.") + + self.profile.refresh_from_db() + self.assertIsNone(self.profile.totp) + self.assertIsNone(self.profile.totp_created) + + def test_it_checks_if_totp_already_configured(self): + self.profile.totp = "0" * 32 + self.profile.save() + + self.client.login(username="alice@example.org", password="password") + self.set_sudo_flag() + + r = self.client.get(self.url) + self.assertEqual(r.status_code, 400) + + @patch("hc.accounts.views.pyotp.totp.TOTP") + def test_it_handles_non_numeric_code(self, mock_TOTP): + mock_TOTP.return_value.verify.return_value = False + mock_TOTP.return_value.provisioning_uri.return_value = "test-uri" + + self.client.login(username="alice@example.org", password="password") + self.set_sudo_flag() + + payload = {"code": "AAAAAA"} + r = self.client.post(self.url, payload, follow=True) + self.assertContains(r, "Enter a valid value") diff --git a/hc/accounts/tests/test_add_credential.py b/hc/accounts/tests/test_add_webauthn.py similarity index 97% rename from hc/accounts/tests/test_add_credential.py rename to hc/accounts/tests/test_add_webauthn.py index 0d726316..77837122 100644 --- a/hc/accounts/tests/test_add_credential.py +++ b/hc/accounts/tests/test_add_webauthn.py @@ -6,11 +6,11 @@ from hc.accounts.models import Credential @override_settings(RP_ID="testserver") -class AddCredentialTestCase(BaseTestCase): +class AddWebauthnTestCase(BaseTestCase): def setUp(self): super().setUp() - self.url = "/accounts/two_factor/add/" + self.url = "/accounts/two_factor/webauthn/" def test_it_requires_sudo_mode(self): self.client.login(username="alice@example.org", password="password") diff --git a/hc/accounts/tests/test_login.py b/hc/accounts/tests/test_login.py index 80061b0f..b7d3c338 100644 --- a/hc/accounts/tests/test_login.py +++ b/hc/accounts/tests/test_login.py @@ -128,3 +128,20 @@ class LoginTestCase(BaseTestCase): # Instead, it should set 2fa_user_id in the session user_id, email, valid_until = self.client.session["2fa_user"] self.assertEqual(user_id, self.alice.id) + + def test_it_redirects_to_totp_form(self): + self.profile.totp = "0" * 32 + self.profile.save() + + form = {"action": "login", "email": "alice@example.org", "password": "password"} + r = self.client.post("/accounts/login/", form) + self.assertRedirects( + r, "/accounts/login/two_factor/totp/", fetch_redirect_response=False + ) + + # It should not log the user in yet + self.assertNotIn("_auth_user_id", self.client.session) + + # Instead, it should set 2fa_user_id in the session + user_id, email, valid_until = self.client.session["2fa_user"] + self.assertEqual(user_id, self.alice.id) diff --git a/hc/accounts/tests/test_login_totp.py b/hc/accounts/tests/test_login_totp.py new file mode 100644 index 00000000..8c70985e --- /dev/null +++ b/hc/accounts/tests/test_login_totp.py @@ -0,0 +1,77 @@ +import time +from unittest.mock import patch + +from hc.test import BaseTestCase + + +class LoginTotpTestCase(BaseTestCase): + def setUp(self): + super().setUp() + + # This is the user we're trying to authenticate + session = self.client.session + session["2fa_user"] = [self.alice.id, self.alice.email, (time.time()) + 300] + session.save() + + self.profile.totp = "0" * 32 + self.profile.save() + + self.url = "/accounts/login/two_factor/totp/" + self.checks_url = f"/projects/{self.project.code}/checks/" + + def test_it_shows_form(self): + r = self.client.get(self.url) + self.assertContains(r, "Please enter the six-digit code") + + def test_it_requires_unauthenticated_user(self): + self.client.login(username="alice@example.org", password="password") + + r = self.client.get(self.url) + self.assertEqual(r.status_code, 400) + + def test_it_requires_totp_secret(self): + self.profile.totp = None + self.profile.save() + + r = self.client.get(self.url) + self.assertEqual(r.status_code, 400) + + def test_it_rejects_changed_email(self): + session = self.client.session + session["2fa_user"] = [self.alice.id, "eve@example.org", int(time.time())] + session.save() + + r = self.client.get(self.url) + self.assertEqual(r.status_code, 400) + + def test_it_rejects_old_timestamp(self): + session = self.client.session + session["2fa_user"] = [self.alice.id, self.alice.email, int(time.time()) - 310] + session.save() + + r = self.client.get(self.url) + self.assertRedirects(r, "/accounts/login/") + + @patch("hc.accounts.views.pyotp.totp.TOTP") + def test_it_logs_in(self, mock_TOTP): + mock_TOTP.return_value.verify.return_value = True + + r = self.client.post(self.url, {"code": "000000"}) + self.assertRedirects(r, self.checks_url) + + self.assertNotIn("2fa_user_id", self.client.session) + + @patch("hc.accounts.views.pyotp.totp.TOTP") + def test_it_redirects_after_login(self, mock_TOTP): + mock_TOTP.return_value.verify.return_value = True + + url = self.url + "?next=" + self.channels_url + r = self.client.post(url, {"code": "000000"}) + self.assertRedirects(r, self.channels_url) + + @patch("hc.accounts.views.pyotp.totp.TOTP") + def test_it_handles_authentication_failure(self, mock_TOTP): + mock_TOTP.return_value.verify.return_value = False + + r = self.client.post(self.url, {"code": "000000"}) + self.assertContains(r, "The code you entered was incorrect.") diff --git a/hc/accounts/tests/test_login_webauthn.py b/hc/accounts/tests/test_login_webauthn.py index ac725a95..fad949b5 100644 --- a/hc/accounts/tests/test_login_webauthn.py +++ b/hc/accounts/tests/test_login_webauthn.py @@ -21,10 +21,18 @@ class LoginWebAuthnTestCase(BaseTestCase): def test_it_shows_form(self): r = self.client.get(self.url) self.assertContains(r, "Waiting for security key") + self.assertNotContains(r, "Use the authenticator app instead?") # It should put a "state" key in the session: self.assertIn("state", self.client.session) + def test_it_shows_totp_option(self): + self.profile.totp = "0" * 32 + self.profile.save() + + r = self.client.get(self.url) + self.assertContains(r, "Use the authenticator app instead?") + def test_it_requires_unauthenticated_user(self): self.client.login(username="alice@example.org", password="password") diff --git a/hc/accounts/tests/test_profile.py b/hc/accounts/tests/test_profile.py index e5c6ee75..106b0367 100644 --- a/hc/accounts/tests/test_profile.py +++ b/hc/accounts/tests/test_profile.py @@ -10,6 +10,7 @@ class ProfileTestCase(BaseTestCase): r = self.client.get("/accounts/profile/") self.assertContains(r, "Email and Password") self.assertContains(r, "Change Password") + self.assertContains(r, "Set Up Authenticator App") def test_leaving_works(self): self.client.login(username="bob@example.org", password="password") @@ -55,11 +56,13 @@ class ProfileTestCase(BaseTestCase): self.assertContains(r, "You do not have any projects. Create one!") @override_settings(RP_ID=None) - def test_it_hides_2fa_section_if_rp_id_not_set(self): + def test_it_hides_security_keys_bits_if_rp_id_not_set(self): self.client.login(username="alice@example.org", password="password") r = self.client.get("/accounts/profile/") - self.assertNotContains(r, "Two-factor Authentication") + self.assertContains(r, "Two-factor Authentication") + self.assertNotContains(r, "Security keys") + self.assertNotContains(r, "Add Security Key") @override_settings(RP_ID="testserver") def test_it_handles_no_credentials(self): @@ -67,7 +70,7 @@ class ProfileTestCase(BaseTestCase): r = self.client.get("/accounts/profile/") self.assertContains(r, "Two-factor Authentication") - self.assertContains(r, "Your account has no registered security keys") + self.assertContains(r, "Your account does not have any configured two-factor") @override_settings(RP_ID="testserver") def test_it_shows_security_key(self): @@ -88,3 +91,15 @@ class ProfileTestCase(BaseTestCase): r = self.client.get("/accounts/profile/") self.assertContains(r, "Set Password") self.assertNotContains(r, "Change Password") + + def test_it_shows_totp(self): + self.profile.totp = "0" * 32 + self.profile.totp_created = "2020-01-01T00:00:00+00:00" + self.profile.save() + + self.client.login(username="alice@example.org", password="password") + + r = self.client.get("/accounts/profile/") + self.assertContains(r, "Enabled") + self.assertContains(r, "configured on Jan 1, 2020") + self.assertNotContains(r, "Set Up Authenticator App") diff --git a/hc/accounts/tests/test_remove_credential.py b/hc/accounts/tests/test_remove_credential.py index a458f0a4..3f14bf6c 100644 --- a/hc/accounts/tests/test_remove_credential.py +++ b/hc/accounts/tests/test_remove_credential.py @@ -33,6 +33,17 @@ class RemoveCredentialTestCase(BaseTestCase): r = self.client.get(self.url) self.assertContains(r, "Remove Security Key") self.assertContains(r, "Alices Key") + self.assertContains(r, "two-factor authentication will no longer be active") + + def test_it_skips_warning_when_other_2fa_methods_exist(self): + self.profile.totp = "0" * 32 + self.profile.save() + + self.client.login(username="alice@example.org", password="password") + self.set_sudo_flag() + + r = self.client.get(self.url) + self.assertNotContains(r, "two-factor authentication will no longer be active") def test_it_removes_credential(self): self.client.login(username="alice@example.org", password="password") diff --git a/hc/accounts/tests/test_remove_totp.py b/hc/accounts/tests/test_remove_totp.py new file mode 100644 index 00000000..a6c89582 --- /dev/null +++ b/hc/accounts/tests/test_remove_totp.py @@ -0,0 +1,46 @@ +from hc.accounts.models import Credential +from hc.test import BaseTestCase + + +class RemoveCredentialTestCase(BaseTestCase): + def setUp(self): + super().setUp() + + self.profile.totp = "0" * 32 + self.profile.save() + + self.url = "/accounts/two_factor/totp/remove/" + + def test_it_requires_sudo_mode(self): + self.client.login(username="alice@example.org", password="password") + + r = self.client.get(self.url) + self.assertContains(r, "We have sent a confirmation code") + + def test_it_shows_form(self): + self.client.login(username="alice@example.org", password="password") + self.set_sudo_flag() + + r = self.client.get(self.url) + self.assertContains(r, "Disable Authenticator App") + self.assertContains(r, "two-factor authentication will no longer be active") + + def test_it_skips_warning_when_other_2fa_methods_exist(self): + self.c = Credential.objects.create(user=self.alice, name="Alices Key") + self.client.login(username="alice@example.org", password="password") + self.set_sudo_flag() + + r = self.client.get(self.url) + self.assertNotContains(r, "two-factor authentication will no longer be active") + + def test_it_removes_totp(self): + self.client.login(username="alice@example.org", password="password") + self.set_sudo_flag() + + r = self.client.post(self.url, {"disable_totp": "1"}, follow=True) + self.assertRedirects(r, "/accounts/profile/") + self.assertContains(r, "Disabled the authenticator app.") + + self.profile.refresh_from_db() + self.assertIsNone(self.profile.totp) + self.assertIsNone(self.profile.totp_created) diff --git a/hc/accounts/urls.py b/hc/accounts/urls.py index dd269c91..81f84e2a 100644 --- a/hc/accounts/urls.py +++ b/hc/accounts/urls.py @@ -4,6 +4,7 @@ from hc.accounts import views urlpatterns = [ path("login/", views.login, name="hc-login"), path("login/two_factor/", views.login_webauthn, name="hc-login-webauthn"), + path("login/two_factor/totp/", views.login_totp, name="hc-login-totp"), path("logout/", views.logout, name="hc-logout"), path("signup/", views.signup, name="hc-signup"), path("login_link_sent/", views.login_link_sent, name="hc-login-link-sent"), @@ -24,7 +25,9 @@ urlpatterns = [ path("set_password/", views.set_password, name="hc-set-password"), path("change_email/done/", views.change_email_done, name="hc-change-email-done"), path("change_email/", views.change_email, name="hc-change-email"), - path("two_factor/add/", views.add_credential, name="hc-add-credential"), + path("two_factor/webauthn/", views.add_webauthn, name="hc-add-webauthn"), + path("two_factor/totp/", views.add_totp, name="hc-add-totp"), + path("two_factor/totp/remove/", views.remove_totp, name="hc-remove-totp"), path( "two_factor//remove/", views.remove_credential, diff --git a/hc/accounts/views.py b/hc/accounts/views.py index 62eb8893..2a541a33 100644 --- a/hc/accounts/views.py +++ b/hc/accounts/views.py @@ -30,6 +30,9 @@ from hc.accounts.decorators import require_sudo_mode from hc.accounts.models import Credential, Profile, Project, Member from hc.api.models import Channel, Check, TokenBucket from hc.payments.models import Subscription +import pyotp +import segno + POST_LOGIN_ROUTES = ( "hc-checks", @@ -107,7 +110,8 @@ def _redirect_after_login(request): def _check_2fa(request, user): - if user.credentials.exists(): + have_keys = user.credentials.exists() + if have_keys or user.profile.totp: # We have verified user's password or token, and now must # verify their security key. We store the following in user's session: # - user.id, to look up the user in the login_webauthn view @@ -115,7 +119,11 @@ def _check_2fa(request, user): # - timestamp, to limit the max time between the auth steps request.session["2fa_user"] = [user.id, user.email, int(time.time())] - path = reverse("hc-login-webauthn") + if have_keys: + path = reverse("hc-login-webauthn") + else: + path = reverse("hc-login-totp") + redirect_url = request.GET.get("next") if _allow_redirect(redirect_url): path += "?next=%s" % redirect_url @@ -234,14 +242,16 @@ def profile(request): "2fa_status": "default", "added_credential_name": request.session.pop("added_credential_name", ""), "removed_credential_name": request.session.pop("removed_credential_name", ""), + "enabled_totp": request.session.pop("enabled_totp", False), + "disabled_totp": request.session.pop("disabled_totp", False), "credentials": list(request.user.credentials.order_by("id")), - "use_2fa": settings.RP_ID, + "use_webauthn": settings.RP_ID, } - if ctx["added_credential_name"]: + if ctx["added_credential_name"] or ctx["enabled_totp"]: ctx["2fa_status"] = "success" - if ctx["removed_credential_name"]: + if ctx["removed_credential_name"] or ctx["disabled_totp"]: ctx["2fa_status"] = "info" if request.session.pop("changed_password", False): @@ -629,12 +639,12 @@ def _get_credential_data(request, form): @login_required @require_sudo_mode -def add_credential(request): +def add_webauthn(request): if not settings.RP_ID: return HttpResponse(status=404) if request.method == "POST": - form = forms.AddCredentialForm(request.POST) + form = forms.AddWebAuthnForm(request.POST) if not form.is_valid(): return HttpResponseBadRequest() @@ -676,6 +686,51 @@ def add_credential(request): return render(request, "accounts/add_credential.html", ctx) +@login_required +@require_sudo_mode +def add_totp(request): + if request.profile.totp: + # TOTP is already configured, refuse to continue + return HttpResponseBadRequest() + + if "totp_secret" not in request.session: + request.session["totp_secret"] = pyotp.random_base32() + + totp = pyotp.totp.TOTP(request.session["totp_secret"]) + + if request.method == "POST": + form = forms.TotpForm(totp, request.POST) + if form.is_valid(): + request.profile.totp = request.session["totp_secret"] + request.profile.totp_created = now() + request.profile.save() + + request.session["enabled_totp"] = True + request.session.pop("totp_secret") + return redirect("hc-profile") + else: + form = forms.TotpForm(totp) + + uri = totp.provisioning_uri(name=request.user.email, issuer_name=settings.SITE_NAME) + qr_data_uri = segno.make(uri).png_data_uri(scale=8) + ctx = {"form": form, "qr_data_uri": qr_data_uri} + return render(request, "accounts/add_totp.html", ctx) + + +@login_required +@require_sudo_mode +def remove_totp(request): + if request.method == "POST" and "disable_totp" in request.POST: + request.profile.totp = None + request.profile.totp_created = None + request.profile.save() + request.session["disabled_totp"] = True + return redirect("hc-profile") + + ctx = {"is_last": not request.user.credentials.exists()} + return render(request, "accounts/remove_totp.html", ctx) + + @login_required @require_sudo_mode def remove_credential(request, code): @@ -692,7 +747,12 @@ def remove_credential(request, code): credential.delete() return redirect("hc-profile") - ctx = {"credential": credential, "is_last": request.user.credentials.count() == 1} + if request.profile.totp: + is_last = False + else: + is_last = request.user.credentials.count() == 1 + + ctx = {"credential": credential, "is_last": is_last} return render(request, "accounts/remove_credential.html", ctx) @@ -759,10 +819,46 @@ def login_webauthn(request): options, state = FIDO2_SERVER.authenticate_begin(credentials) request.session["state"] = state - ctx = {"options": base64.b64encode(cbor.encode(options)).decode()} + ctx = { + "options": base64.b64encode(cbor.encode(options)).decode(), + "offer_totp": True if user.profile.totp else False, + } return render(request, "accounts/login_webauthn.html", ctx) +def login_totp(request): + # Expect an unauthenticated user + if request.user.is_authenticated: + return HttpResponseBadRequest() + + if "2fa_user" not in request.session: + return HttpResponseBadRequest() + + user_id, email, timestamp = request.session["2fa_user"] + if timestamp + 300 < time.time(): + return redirect("hc-login") + + try: + user = User.objects.get(id=user_id, email=email) + except User.DoesNotExist: + return HttpResponseBadRequest() + + if not user.profile.totp: + return HttpResponseBadRequest() + + totp = pyotp.totp.TOTP(user.profile.totp) + if request.method == "POST": + form = forms.TotpForm(totp, request.POST) + if form.is_valid(): + request.session.pop("2fa_user") + auth_login(request, user, "hc.accounts.backends.EmailBackend") + return _redirect_after_login(request) + else: + form = forms.TotpForm(totp) + + return render(request, "accounts/login_totp.html", {"form": form}) + + @login_required def appearance(request): profile = request.profile diff --git a/requirements.txt b/requirements.txt index 9aae4daf..1b15eba1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,8 @@ Django==3.2.4 django-compressor==2.4 fido2==0.9.1 psycopg2==2.9.1 +pyotp==2.6.0 pytz==2021.1 requests==2.26.0 +segno==1.3.3 statsd==3.3.0 diff --git a/static/css/login.css b/static/css/login.css index 8d7c8126..4b0f1a8d 100644 --- a/static/css/login.css +++ b/static/css/login.css @@ -87,4 +87,8 @@ #lost-password-modal ol { line-height: 1.8; +} + +#waiting { + margin-bottom: 20px; } \ No newline at end of file diff --git a/static/css/profile.css b/static/css/profile.css index 5b508613..f72bf64a 100644 --- a/static/css/profile.css +++ b/static/css/profile.css @@ -67,6 +67,15 @@ span.loading { border-top: 0; } +#my-keys .missing { + font-style: italic; + color: var(--text-muted); +} + .settings-bar { line-height: 34px; +} + +.add-totp-step { + margin-top: 32px; } \ No newline at end of file diff --git a/templates/accounts/add_totp.html b/templates/accounts/add_totp.html new file mode 100644 index 00000000..4f274b92 --- /dev/null +++ b/templates/accounts/add_totp.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} +{% load compress static hc_extras %} + +{% block content %} + +
+
+

Set Up Authenticator App

+ +

{% site_name %} supports time-based one-time passwords (TOTP) as a + second authentication factor. To use this method, you will need + an authenticator app on your phone. +

+ + {% csrf_token %} +
+

+ Step 1. + Scan the QR code below using your authentication app. +

+ + + +

+ Step 2. + Enter the six-digit code from your authenticator app below. +

+ +
+ + {% if form.code.errors %} +
+ {{ form.code.errors|join:"" }} +
+ {% endif %} +
+ +
+ +
+
+
+{% endblock %} diff --git a/templates/accounts/login_totp.html b/templates/accounts/login_totp.html new file mode 100644 index 00000000..0cb59f2b --- /dev/null +++ b/templates/accounts/login_totp.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} +{% load compress static hc_extras %} + +{% block content %} + +
+
+

Two-factor Authentication

+ {% csrf_token %} + +

+ Please enter the six-digit code from your authenticator app. +

+ +
+ + {% if form.code.errors %} +
+ {{ form.code.errors|join:"" }} +
+ {% endif %} +
+ +
+ +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/accounts/login_webauthn.html b/templates/accounts/login_webauthn.html index 2f5e4600..edcbb469 100644 --- a/templates/accounts/login_webauthn.html +++ b/templates/accounts/login_webauthn.html @@ -44,6 +44,14 @@ + {% if offer_totp %} +

+ + Use the authenticator app instead? + +

+ {% endif %} +
Success! diff --git a/templates/accounts/profile.html b/templates/accounts/profile.html index 5c7cb3fd..58f494a2 100644 --- a/templates/accounts/profile.html +++ b/templates/accounts/profile.html @@ -72,50 +72,90 @@ {% endif %}
- {% if use_2fa %}
{% csrf_token %}

Two-factor Authentication

- {% if credentials %} + {% if use_webauthn %} {% for credential in credentials %} + {% empty %} + + + {% endfor %} + {% endif %} + + + + + {% if profile.totp %} + + + + + {% else %} + + + + {% endif %}
Security keys
- {{ credential.name|default:"unnamed" }} + {{ credential.name|default:"unnamed" }} + – registered on {{ credential.created|date:"M j, Y" }} + Remove
No registered security keys
Authenticator app
+ Enabled + + – configured on {{ profile.totp_created|date:"M j, Y" }} + + + Remove +
Not configured
{% if credentials|length == 1 %}

- Tip: add a second key! + Tip: add a second security key! It is a good practice to register at least two security keys and store them separately.

{% endif %} - {% else %} -

- Two-factor authentication is not enabled yet.
- Your account has no registered security keys. + {% if not credentials and not profile.totp %} +

+ Two-factor authentication is currently inactive. + Your account does not have any configured two-factor authentication + methods.

{% endif %} - - Register New Security Key - + +
+ {% if not profile.totp %} + + Set Up Authenticator App + + {% endif %} + {% if use_webauthn %} + + Add Security Key + + {% endif %} +
@@ -130,8 +170,19 @@ Removed security key {{ removed_credential_name }}.
{% endif %} + + {% if enabled_totp %} + + {% endif %} + + {% if disabled_totp %} + + {% endif %}
- {% endif %}
diff --git a/templates/accounts/remove_credential.html b/templates/accounts/remove_credential.html index 48f3b3d1..9ebcd7e4 100644 --- a/templates/accounts/remove_credential.html +++ b/templates/accounts/remove_credential.html @@ -2,7 +2,6 @@ {% load compress static hc_extras %} {% block content %} -
{% csrf_token %} @@ -34,8 +33,6 @@
- - {% endblock %} diff --git a/templates/accounts/remove_totp.html b/templates/accounts/remove_totp.html new file mode 100644 index 00000000..afa0f6ac --- /dev/null +++ b/templates/accounts/remove_totp.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% load compress static hc_extras %} + +{% block content %} +
+
+ {% csrf_token %} +
+
+

Disable Authenticator App

+

+

You are about to remove the authenticator app from your + {% site_name %} account. +

+ + {% if is_last %} +

+ After removing the authenticator app, + two-factor authentication will no longer be active. +

+ {% endif %} + +

Are you sure you want to continue?

+ +
+ Cancel + +
+
+
+
+
+{% endblock %}