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