forked from GithubBackups/healthchecks
parent
0d9d094882
commit
222722569e
@ -8,6 +8,7 @@ All notable changes to this project will be documented in this file.
|
|||||||
- Add SITE_LOGO_URL setting (#323)
|
- Add SITE_LOGO_URL setting (#323)
|
||||||
- Add admin action to log in as any user
|
- Add admin action to log in as any user
|
||||||
- Add a "Manager" role (#484)
|
- Add a "Manager" role (#484)
|
||||||
|
- Add support for 2FA using TOTP (#354)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
- Fix dark mode styling issues in Cron Syntax Cheatsheet
|
- Fix dark mode styling issues in Cron Syntax Cheatsheet
|
||||||
|
@ -151,7 +151,7 @@ class TransferForm(forms.Form):
|
|||||||
email = LowercaseEmailField()
|
email = LowercaseEmailField()
|
||||||
|
|
||||||
|
|
||||||
class AddCredentialForm(forms.Form):
|
class AddWebAuthnForm(forms.Form):
|
||||||
name = forms.CharField(max_length=100)
|
name = forms.CharField(max_length=100)
|
||||||
client_data_json = Base64Field()
|
client_data_json = Base64Field()
|
||||||
attestation_object = Base64Field()
|
attestation_object = Base64Field()
|
||||||
@ -162,3 +162,16 @@ class WebAuthnForm(forms.Form):
|
|||||||
client_data_json = Base64Field()
|
client_data_json = Base64Field()
|
||||||
authenticator_data = Base64Field()
|
authenticator_data = Base64Field()
|
||||||
signature = 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.")
|
||||||
|
23
hc/accounts/migrations/0044_auto_20210730_0942.py
Normal file
23
hc/accounts/migrations/0044_auto_20210730_0942.py
Normal file
@ -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),
|
||||||
|
),
|
||||||
|
]
|
@ -74,6 +74,9 @@ class Profile(models.Model):
|
|||||||
tz = models.CharField(max_length=36, default="UTC")
|
tz = models.CharField(max_length=36, default="UTC")
|
||||||
theme = models.CharField(max_length=10, null=True, blank=True)
|
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()
|
objects = ProfileManager()
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
83
hc/accounts/tests/test_add_totp.py
Normal file
83
hc/accounts/tests/test_add_totp.py
Normal file
@ -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")
|
@ -6,11 +6,11 @@ from hc.accounts.models import Credential
|
|||||||
|
|
||||||
|
|
||||||
@override_settings(RP_ID="testserver")
|
@override_settings(RP_ID="testserver")
|
||||||
class AddCredentialTestCase(BaseTestCase):
|
class AddWebauthnTestCase(BaseTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
|
||||||
self.url = "/accounts/two_factor/add/"
|
self.url = "/accounts/two_factor/webauthn/"
|
||||||
|
|
||||||
def test_it_requires_sudo_mode(self):
|
def test_it_requires_sudo_mode(self):
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
@ -128,3 +128,20 @@ class LoginTestCase(BaseTestCase):
|
|||||||
# Instead, it should set 2fa_user_id in the session
|
# Instead, it should set 2fa_user_id in the session
|
||||||
user_id, email, valid_until = self.client.session["2fa_user"]
|
user_id, email, valid_until = self.client.session["2fa_user"]
|
||||||
self.assertEqual(user_id, self.alice.id)
|
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)
|
||||||
|
77
hc/accounts/tests/test_login_totp.py
Normal file
77
hc/accounts/tests/test_login_totp.py
Normal file
@ -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.")
|
@ -21,10 +21,18 @@ class LoginWebAuthnTestCase(BaseTestCase):
|
|||||||
def test_it_shows_form(self):
|
def test_it_shows_form(self):
|
||||||
r = self.client.get(self.url)
|
r = self.client.get(self.url)
|
||||||
self.assertContains(r, "Waiting for security key")
|
self.assertContains(r, "Waiting for security key")
|
||||||
|
self.assertNotContains(r, "Use the authenticator app instead?")
|
||||||
|
|
||||||
# It should put a "state" key in the session:
|
# It should put a "state" key in the session:
|
||||||
self.assertIn("state", self.client.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):
|
def test_it_requires_unauthenticated_user(self):
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ class ProfileTestCase(BaseTestCase):
|
|||||||
r = self.client.get("/accounts/profile/")
|
r = self.client.get("/accounts/profile/")
|
||||||
self.assertContains(r, "Email and Password")
|
self.assertContains(r, "Email and Password")
|
||||||
self.assertContains(r, "Change Password")
|
self.assertContains(r, "Change Password")
|
||||||
|
self.assertContains(r, "Set Up Authenticator App")
|
||||||
|
|
||||||
def test_leaving_works(self):
|
def test_leaving_works(self):
|
||||||
self.client.login(username="bob@example.org", password="password")
|
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!")
|
self.assertContains(r, "You do not have any projects. Create one!")
|
||||||
|
|
||||||
@override_settings(RP_ID=None)
|
@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")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
|
||||||
r = self.client.get("/accounts/profile/")
|
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")
|
@override_settings(RP_ID="testserver")
|
||||||
def test_it_handles_no_credentials(self):
|
def test_it_handles_no_credentials(self):
|
||||||
@ -67,7 +70,7 @@ class ProfileTestCase(BaseTestCase):
|
|||||||
|
|
||||||
r = self.client.get("/accounts/profile/")
|
r = self.client.get("/accounts/profile/")
|
||||||
self.assertContains(r, "Two-factor Authentication")
|
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")
|
@override_settings(RP_ID="testserver")
|
||||||
def test_it_shows_security_key(self):
|
def test_it_shows_security_key(self):
|
||||||
@ -88,3 +91,15 @@ class ProfileTestCase(BaseTestCase):
|
|||||||
r = self.client.get("/accounts/profile/")
|
r = self.client.get("/accounts/profile/")
|
||||||
self.assertContains(r, "Set Password")
|
self.assertContains(r, "Set Password")
|
||||||
self.assertNotContains(r, "Change 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")
|
||||||
|
@ -33,6 +33,17 @@ class RemoveCredentialTestCase(BaseTestCase):
|
|||||||
r = self.client.get(self.url)
|
r = self.client.get(self.url)
|
||||||
self.assertContains(r, "Remove Security Key")
|
self.assertContains(r, "Remove Security Key")
|
||||||
self.assertContains(r, "Alices 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):
|
def test_it_removes_credential(self):
|
||||||
self.client.login(username="alice@example.org", password="password")
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
46
hc/accounts/tests/test_remove_totp.py
Normal file
46
hc/accounts/tests/test_remove_totp.py
Normal file
@ -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)
|
@ -4,6 +4,7 @@ from hc.accounts import views
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("login/", views.login, name="hc-login"),
|
path("login/", views.login, name="hc-login"),
|
||||||
path("login/two_factor/", views.login_webauthn, name="hc-login-webauthn"),
|
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("logout/", views.logout, name="hc-logout"),
|
||||||
path("signup/", views.signup, name="hc-signup"),
|
path("signup/", views.signup, name="hc-signup"),
|
||||||
path("login_link_sent/", views.login_link_sent, name="hc-login-link-sent"),
|
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("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/done/", views.change_email_done, name="hc-change-email-done"),
|
||||||
path("change_email/", views.change_email, name="hc-change-email"),
|
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(
|
path(
|
||||||
"two_factor/<uuid:code>/remove/",
|
"two_factor/<uuid:code>/remove/",
|
||||||
views.remove_credential,
|
views.remove_credential,
|
||||||
|
@ -30,6 +30,9 @@ from hc.accounts.decorators import require_sudo_mode
|
|||||||
from hc.accounts.models import Credential, Profile, Project, Member
|
from hc.accounts.models import Credential, Profile, Project, Member
|
||||||
from hc.api.models import Channel, Check, TokenBucket
|
from hc.api.models import Channel, Check, TokenBucket
|
||||||
from hc.payments.models import Subscription
|
from hc.payments.models import Subscription
|
||||||
|
import pyotp
|
||||||
|
import segno
|
||||||
|
|
||||||
|
|
||||||
POST_LOGIN_ROUTES = (
|
POST_LOGIN_ROUTES = (
|
||||||
"hc-checks",
|
"hc-checks",
|
||||||
@ -107,7 +110,8 @@ def _redirect_after_login(request):
|
|||||||
|
|
||||||
|
|
||||||
def _check_2fa(request, user):
|
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
|
# We have verified user's password or token, and now must
|
||||||
# verify their security key. We store the following in user's session:
|
# verify their security key. We store the following in user's session:
|
||||||
# - user.id, to look up the user in the login_webauthn view
|
# - 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
|
# - timestamp, to limit the max time between the auth steps
|
||||||
request.session["2fa_user"] = [user.id, user.email, int(time.time())]
|
request.session["2fa_user"] = [user.id, user.email, int(time.time())]
|
||||||
|
|
||||||
|
if have_keys:
|
||||||
path = reverse("hc-login-webauthn")
|
path = reverse("hc-login-webauthn")
|
||||||
|
else:
|
||||||
|
path = reverse("hc-login-totp")
|
||||||
|
|
||||||
redirect_url = request.GET.get("next")
|
redirect_url = request.GET.get("next")
|
||||||
if _allow_redirect(redirect_url):
|
if _allow_redirect(redirect_url):
|
||||||
path += "?next=%s" % redirect_url
|
path += "?next=%s" % redirect_url
|
||||||
@ -234,14 +242,16 @@ def profile(request):
|
|||||||
"2fa_status": "default",
|
"2fa_status": "default",
|
||||||
"added_credential_name": request.session.pop("added_credential_name", ""),
|
"added_credential_name": request.session.pop("added_credential_name", ""),
|
||||||
"removed_credential_name": request.session.pop("removed_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")),
|
"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"
|
ctx["2fa_status"] = "success"
|
||||||
|
|
||||||
if ctx["removed_credential_name"]:
|
if ctx["removed_credential_name"] or ctx["disabled_totp"]:
|
||||||
ctx["2fa_status"] = "info"
|
ctx["2fa_status"] = "info"
|
||||||
|
|
||||||
if request.session.pop("changed_password", False):
|
if request.session.pop("changed_password", False):
|
||||||
@ -629,12 +639,12 @@ def _get_credential_data(request, form):
|
|||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@require_sudo_mode
|
@require_sudo_mode
|
||||||
def add_credential(request):
|
def add_webauthn(request):
|
||||||
if not settings.RP_ID:
|
if not settings.RP_ID:
|
||||||
return HttpResponse(status=404)
|
return HttpResponse(status=404)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = forms.AddCredentialForm(request.POST)
|
form = forms.AddWebAuthnForm(request.POST)
|
||||||
if not form.is_valid():
|
if not form.is_valid():
|
||||||
return HttpResponseBadRequest()
|
return HttpResponseBadRequest()
|
||||||
|
|
||||||
@ -676,6 +686,51 @@ def add_credential(request):
|
|||||||
return render(request, "accounts/add_credential.html", ctx)
|
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
|
@login_required
|
||||||
@require_sudo_mode
|
@require_sudo_mode
|
||||||
def remove_credential(request, code):
|
def remove_credential(request, code):
|
||||||
@ -692,7 +747,12 @@ def remove_credential(request, code):
|
|||||||
credential.delete()
|
credential.delete()
|
||||||
return redirect("hc-profile")
|
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)
|
return render(request, "accounts/remove_credential.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
@ -759,10 +819,46 @@ def login_webauthn(request):
|
|||||||
options, state = FIDO2_SERVER.authenticate_begin(credentials)
|
options, state = FIDO2_SERVER.authenticate_begin(credentials)
|
||||||
request.session["state"] = state
|
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)
|
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
|
@login_required
|
||||||
def appearance(request):
|
def appearance(request):
|
||||||
profile = request.profile
|
profile = request.profile
|
||||||
|
@ -4,6 +4,8 @@ Django==3.2.4
|
|||||||
django-compressor==2.4
|
django-compressor==2.4
|
||||||
fido2==0.9.1
|
fido2==0.9.1
|
||||||
psycopg2==2.9.1
|
psycopg2==2.9.1
|
||||||
|
pyotp==2.6.0
|
||||||
pytz==2021.1
|
pytz==2021.1
|
||||||
requests==2.26.0
|
requests==2.26.0
|
||||||
|
segno==1.3.3
|
||||||
statsd==3.3.0
|
statsd==3.3.0
|
||||||
|
@ -88,3 +88,7 @@
|
|||||||
#lost-password-modal ol {
|
#lost-password-modal ol {
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#waiting {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
@ -67,6 +67,15 @@ span.loading {
|
|||||||
border-top: 0;
|
border-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#my-keys .missing {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-bar {
|
.settings-bar {
|
||||||
line-height: 34px;
|
line-height: 34px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-totp-step {
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
53
templates/accounts/add_totp.html
Normal file
53
templates/accounts/add_totp.html
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load compress static hc_extras %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<form class="col-sm-6 col-sm-offset-3" method="post">
|
||||||
|
<h1>Set Up Authenticator App</h1>
|
||||||
|
|
||||||
|
<p>{% 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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<p class="add-totp-step">
|
||||||
|
<strong>Step 1.</strong>
|
||||||
|
Scan the QR code below using your authentication app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<img src="{{ qr_data_uri }}" />
|
||||||
|
|
||||||
|
<p class="add-totp-step">
|
||||||
|
<strong>Step 2.</strong>
|
||||||
|
Enter the six-digit code from your authenticator app below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-group {{ form.code.css_classes }}">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="code"
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
title="six-digit code"
|
||||||
|
placeholder="123456"
|
||||||
|
class="form-control input-lg" />
|
||||||
|
{% if form.code.errors %}
|
||||||
|
<div class="help-block">
|
||||||
|
{{ form.code.errors|join:"" }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group text-right">
|
||||||
|
<input
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="submit"
|
||||||
|
name=""
|
||||||
|
value="Continue">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
39
templates/accounts/login_totp.html
Normal file
39
templates/accounts/login_totp.html
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load compress static hc_extras %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<form class="col-sm-6 col-sm-offset-3" method="post">
|
||||||
|
<h1>Two-factor Authentication</h1>
|
||||||
|
{% csrf_token %}
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Please enter the six-digit code from your authenticator app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-group {{ form.code.css_classes }}">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="code"
|
||||||
|
pattern="[0-9]{6}"
|
||||||
|
title="six-digit code"
|
||||||
|
placeholder="123456"
|
||||||
|
class="form-control input-lg" />
|
||||||
|
{% if form.code.errors %}
|
||||||
|
<div class="help-block">
|
||||||
|
{{ form.code.errors|join:"" }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group text-right">
|
||||||
|
<input
|
||||||
|
class="btn btn-primary"
|
||||||
|
type="submit"
|
||||||
|
name=""
|
||||||
|
value="Continue">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -44,6 +44,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if offer_totp %}
|
||||||
|
<p>
|
||||||
|
<a href="{% url 'hc-login-totp' %}">
|
||||||
|
Use the authenticator app instead?
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div id="success" class="hide">
|
<div id="success" class="hide">
|
||||||
<div class="alert alert-success">
|
<div class="alert alert-success">
|
||||||
<strong>Success!</strong>
|
<strong>Success!</strong>
|
||||||
|
@ -72,50 +72,90 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if use_2fa %}
|
|
||||||
<div class="panel panel-{{ 2fa_status }}">
|
<div class="panel panel-{{ 2fa_status }}">
|
||||||
<div class="panel-body settings-block">
|
<div class="panel-body settings-block">
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<h2>Two-factor Authentication</h2>
|
<h2>Two-factor Authentication</h2>
|
||||||
{% if credentials %}
|
|
||||||
|
|
||||||
<table id="my-keys" class="table">
|
<table id="my-keys" class="table">
|
||||||
|
{% if use_webauthn %}
|
||||||
<tr>
|
<tr>
|
||||||
<th>Security keys</th>
|
<th>Security keys</th>
|
||||||
</tr>
|
</tr>
|
||||||
{% for credential in credentials %}
|
{% for credential in credentials %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<strong>{{ credential.name|default:"unnamed" }}</strong>
|
{{ credential.name|default:"unnamed" }}
|
||||||
|
<span class="text-muted">
|
||||||
– registered on {{ credential.created|date:"M j, Y" }}
|
– registered on {{ credential.created|date:"M j, Y" }}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-right">
|
<td class="text-right">
|
||||||
<a href="{% url 'hc-remove-credential' credential.code %}">Remove</a>
|
<a href="{% url 'hc-remove-credential' credential.code %}">Remove</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td class="missing" colspan="2">No registered security keys</td>
|
||||||
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<th>Authenticator app</th>
|
||||||
|
</tr>
|
||||||
|
{% if profile.totp %}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
Enabled
|
||||||
|
<span class="text-muted">
|
||||||
|
– configured on {{ profile.totp_created|date:"M j, Y" }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-right">
|
||||||
|
<a href="{% url 'hc-remove-totp' %}">Remove</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td class="missing" colspan="2">Not configured</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{% if credentials|length == 1 %}
|
{% if credentials|length == 1 %}
|
||||||
<p class="alert alert-info">
|
<p class="alert alert-info">
|
||||||
<strong>Tip: add a second key!</strong>
|
<strong>Tip: add a second security key!</strong>
|
||||||
It is a good practice to register at least two security keys
|
It is a good practice to register at least two security keys
|
||||||
and store them separately.
|
and store them separately.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% else %}
|
{% if not credentials and not profile.totp %}
|
||||||
<p>
|
<p class="alert alert-info">
|
||||||
Two-factor authentication is not enabled yet.<br />
|
Two-factor authentication is currently <strong>inactive</strong>.
|
||||||
Your account has no registered security keys.
|
Your account does not have any configured two-factor authentication
|
||||||
|
methods.
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="pull-right">
|
||||||
|
{% if not profile.totp %}
|
||||||
<a
|
<a
|
||||||
href="{% url 'hc-add-credential' %}"
|
href="{% url 'hc-add-totp' %}"
|
||||||
class="btn btn-default pull-right">
|
class="btn btn-default">
|
||||||
Register New Security Key
|
Set Up Authenticator App
|
||||||
</a>
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if use_webauthn %}
|
||||||
|
<a
|
||||||
|
href="{% url 'hc-add-webauthn' %}"
|
||||||
|
class="btn btn-default">
|
||||||
|
Add Security Key
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -130,9 +170,20 @@
|
|||||||
Removed security key <strong>{{ removed_credential_name }}</strong>.
|
Removed security key <strong>{{ removed_credential_name }}</strong>.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if enabled_totp %}
|
||||||
|
<div class="panel-footer">
|
||||||
|
Successfully set up the Authenticator app.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if disabled_totp %}
|
||||||
|
<div class="panel-footer">
|
||||||
|
Disabled the authenticator app.
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel panel-{{ my_projects_status }}">
|
<div class="panel panel-{{ my_projects_status }}">
|
||||||
<div class="panel-body settings-block">
|
<div class="panel-body settings-block">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
@ -2,7 +2,6 @@
|
|||||||
{% load compress static hc_extras %}
|
{% load compress static hc_extras %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<form class="col-sm-6 col-sm-offset-3" method="post">
|
<form class="col-sm-6 col-sm-offset-3" method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@ -34,8 +33,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
38
templates/accounts/remove_totp.html
Normal file
38
templates/accounts/remove_totp.html
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% load compress static hc_extras %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<form class="col-sm-6 col-sm-offset-3" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="panel panel-default">
|
||||||
|
<div class="panel-body settings-block">
|
||||||
|
<h2>Disable Authenticator App</h2>
|
||||||
|
<p></p>
|
||||||
|
<p>You are about to remove the authenticator app from your
|
||||||
|
{% site_name %} account.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if is_last %}
|
||||||
|
<p>
|
||||||
|
After removing the authenticator app,
|
||||||
|
<strong>two-factor authentication will no longer be active.</strong>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p>Are you sure you want to continue?</p>
|
||||||
|
|
||||||
|
<div class="text-right">
|
||||||
|
<a
|
||||||
|
href="{% url 'hc-profile' %}"
|
||||||
|
class="btn btn-default">Cancel</a>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="disable_totp"
|
||||||
|
class="btn btn-danger">Disable Authenticator App</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
Loading…
x
Reference in New Issue
Block a user