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 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
|
||||
|
@ -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.")
|
||||
|
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")
|
||||
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):
|
||||
|
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")
|
||||
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")
|
@ -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)
|
||||
|
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):
|
||||
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")
|
||||
|
||||
|
@ -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")
|
||||
|
@ -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")
|
||||
|
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 = [
|
||||
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/<uuid:code>/remove/",
|
||||
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.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())]
|
||||
|
||||
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
|
||||
|
@ -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
|
||||
|
@ -88,3 +88,7 @@
|
||||
#lost-password-modal ol {
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
#waiting {
|
||||
margin-bottom: 20px;
|
||||
}
|
@ -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;
|
||||
}
|
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>
|
||||
|
||||
{% if offer_totp %}
|
||||
<p>
|
||||
<a href="{% url 'hc-login-totp' %}">
|
||||
Use the authenticator app instead?
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div id="success" class="hide">
|
||||
<div class="alert alert-success">
|
||||
<strong>Success!</strong>
|
||||
|
@ -72,50 +72,90 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if use_2fa %}
|
||||
<div class="panel panel-{{ 2fa_status }}">
|
||||
<div class="panel-body settings-block">
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<h2>Two-factor Authentication</h2>
|
||||
{% if credentials %}
|
||||
|
||||
<table id="my-keys" class="table">
|
||||
{% if use_webauthn %}
|
||||
<tr>
|
||||
<th>Security keys</th>
|
||||
</tr>
|
||||
{% for credential in credentials %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ credential.name|default:"unnamed" }}</strong>
|
||||
{{ credential.name|default:"unnamed" }}
|
||||
<span class="text-muted">
|
||||
– registered on {{ credential.created|date:"M j, Y" }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<a href="{% url 'hc-remove-credential' credential.code %}">Remove</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td class="missing" colspan="2">No registered security keys</td>
|
||||
</tr>
|
||||
{% 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>
|
||||
|
||||
{% if credentials|length == 1 %}
|
||||
<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
|
||||
and store them separately.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<p>
|
||||
Two-factor authentication is not enabled yet.<br />
|
||||
Your account has no registered security keys.
|
||||
{% if not credentials and not profile.totp %}
|
||||
<p class="alert alert-info">
|
||||
Two-factor authentication is currently <strong>inactive</strong>.
|
||||
Your account does not have any configured two-factor authentication
|
||||
methods.
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="pull-right">
|
||||
{% if not profile.totp %}
|
||||
<a
|
||||
href="{% url 'hc-add-credential' %}"
|
||||
class="btn btn-default pull-right">
|
||||
Register New Security Key
|
||||
href="{% url 'hc-add-totp' %}"
|
||||
class="btn btn-default">
|
||||
Set Up Authenticator App
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if use_webauthn %}
|
||||
<a
|
||||
href="{% url 'hc-add-webauthn' %}"
|
||||
class="btn btn-default">
|
||||
Add Security Key
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@ -130,9 +170,20 @@
|
||||
Removed security key <strong>{{ removed_credential_name }}</strong>.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if enabled_totp %}
|
||||
<div class="panel-footer">
|
||||
Successfully set up the Authenticator app.
|
||||
</div>
|
||||
{% 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-body settings-block">
|
||||
{% csrf_token %}
|
||||
|
@ -2,7 +2,6 @@
|
||||
{% load compress static hc_extras %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
<div class="row">
|
||||
<form class="col-sm-6 col-sm-offset-3" method="post">
|
||||
{% csrf_token %}
|
||||
@ -34,8 +33,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</form>
|
||||
</div>
|
||||
{% 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