@ -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 | # 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) |
@ -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): | 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") | ||||
@ -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") |
@ -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") | ||||
@ -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 %} |