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
23 changed files with 630 additions and 33 deletions
  1. +1
    -0
      CHANGELOG.md
  2. +14
    -1
      hc/accounts/forms.py
  3. +23
    -0
      hc/accounts/migrations/0044_auto_20210730_0942.py
  4. +3
    -0
      hc/accounts/models.py
  5. +83
    -0
      hc/accounts/tests/test_add_totp.py
  6. +2
    -2
      hc/accounts/tests/test_add_webauthn.py
  7. +17
    -0
      hc/accounts/tests/test_login.py
  8. +77
    -0
      hc/accounts/tests/test_login_totp.py
  9. +8
    -0
      hc/accounts/tests/test_login_webauthn.py
  10. +18
    -3
      hc/accounts/tests/test_profile.py
  11. +11
    -0
      hc/accounts/tests/test_remove_credential.py
  12. +46
    -0
      hc/accounts/tests/test_remove_totp.py
  13. +4
    -1
      hc/accounts/urls.py
  14. +105
    -9
      hc/accounts/views.py
  15. +2
    -0
      requirements.txt
  16. +4
    -0
      static/css/login.css
  17. +9
    -0
      static/css/profile.css
  18. +53
    -0
      templates/accounts/add_totp.html
  19. +39
    -0
      templates/accounts/login_totp.html
  20. +8
    -0
      templates/accounts/login_webauthn.html
  21. +65
    -14
      templates/accounts/profile.html
  22. +0
    -3
      templates/accounts/remove_credential.html
  23. +38
    -0
      templates/accounts/remove_totp.html

+ 1
- 0
CHANGELOG.md View File

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


+ 14
- 1
hc/accounts/forms.py View File

@ -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
- 0
hc/accounts/migrations/0044_auto_20210730_0942.py View 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),
),
]

+ 3
- 0
hc/accounts/models.py View File

@ -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
- 0
hc/accounts/tests/test_add_totp.py View 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="[email protected]", 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="[email protected]", 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="[email protected]", 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="[email protected]", 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="[email protected]", 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="[email protected]", 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")

hc/accounts/tests/test_add_credential.py → hc/accounts/tests/test_add_webauthn.py View File


+ 17
- 0
hc/accounts/tests/test_login.py View File

@ -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": "[email protected]", "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
- 0
hc/accounts/tests/test_login_totp.py View 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="[email protected]", 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, "[email protected]", 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
- 0
hc/accounts/tests/test_login_webauthn.py View File

@ -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="[email protected]", password="password") self.client.login(username="[email protected]", password="password")


+ 18
- 3
hc/accounts/tests/test_profile.py View File

@ -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="[email protected]", password="password") self.client.login(username="[email protected]", 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="[email protected]", password="password") self.client.login(username="[email protected]", 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="[email protected]", 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
- 0
hc/accounts/tests/test_remove_credential.py View File

@ -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="[email protected]", 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="[email protected]", password="password") self.client.login(username="[email protected]", password="password")


+ 46
- 0
hc/accounts/tests/test_remove_totp.py View 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="[email protected]", 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="[email protected]", 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="[email protected]", 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="[email protected]", 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
- 1
hc/accounts/urls.py View File

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


+ 105
- 9
hc/accounts/views.py View File

@ -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())]
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") 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


+ 2
- 0
requirements.txt View File

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

+ 4
- 0
static/css/login.css View File

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

+ 9
- 0
static/css/profile.css View File

@ -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
- 0
templates/accounts/add_totp.html View 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
- 0
templates/accounts/login_totp.html View 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 %}

+ 8
- 0
templates/accounts/login_webauthn.html View File

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


+ 65
- 14
templates/accounts/profile.html View File

@ -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 %}
<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> </p>
{% endif %} {% 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> </form>
</div> </div>
@ -130,8 +170,19 @@
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>
{% endif %}
{% if disabled_totp %}
<div class="panel-footer">
Disabled the authenticator app.
</div>
{% endif %}
</div> </div>
{% endif %}
<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">


+ 0
- 3
templates/accounts/remove_credential.html View File

@ -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
- 0
templates/accounts/remove_totp.html View 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…
Cancel
Save