Browse Source

Add support for 2FA using TOTP

Fixes: #354
pull/551/head
Pēteris Caune 3 years ago
parent
commit
222722569e
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
  1. 1
      CHANGELOG.md
  2. 15
      hc/accounts/forms.py
  3. 23
      hc/accounts/migrations/0044_auto_20210730_0942.py
  4. 3
      hc/accounts/models.py
  5. 83
      hc/accounts/tests/test_add_totp.py
  6. 4
      hc/accounts/tests/test_add_webauthn.py
  7. 17
      hc/accounts/tests/test_login.py
  8. 77
      hc/accounts/tests/test_login_totp.py
  9. 8
      hc/accounts/tests/test_login_webauthn.py
  10. 21
      hc/accounts/tests/test_profile.py
  11. 11
      hc/accounts/tests/test_remove_credential.py
  12. 46
      hc/accounts/tests/test_remove_totp.py
  13. 5
      hc/accounts/urls.py
  14. 114
      hc/accounts/views.py
  15. 2
      requirements.txt
  16. 4
      static/css/login.css
  17. 9
      static/css/profile.css
  18. 53
      templates/accounts/add_totp.html
  19. 39
      templates/accounts/login_totp.html
  20. 8
      templates/accounts/login_webauthn.html
  21. 79
      templates/accounts/profile.html
  22. 3
      templates/accounts/remove_credential.html
  23. 38
      templates/accounts/remove_totp.html

1
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

15
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.")

23
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),
),
]

3
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):

83
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")

4
hc/accounts/tests/test_add_credential.py → 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")

17
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)

77
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.")

8
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")

21
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")

11
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")

46
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)

5
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/<uuid:code>/remove/",
views.remove_credential,

114
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

2
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

4
static/css/login.css

@ -87,4 +87,8 @@
#lost-password-modal ol {
line-height: 1.8;
}
#waiting {
margin-bottom: 20px;
}

9
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;
}

53
templates/accounts/add_totp.html

@ -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

@ -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 %}

8
templates/accounts/login_webauthn.html

@ -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>

79
templates/accounts/profile.html

@ -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 %}
<a
href="{% url 'hc-add-credential' %}"
class="btn btn-default pull-right">
Register New Security Key
</a>
<div class="pull-right">
{% if not profile.totp %}
<a
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,8 +170,19 @@
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>
{% endif %}
<div class="panel panel-{{ my_projects_status }}">
<div class="panel-body settings-block">

3
templates/accounts/remove_credential.html

@ -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

@ -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…
Cancel
Save