Browse Source

Add checks for RP_ID, add a 2FA section in README

pull/456/head
Pēteris Caune 4 years ago
parent
commit
7124383a53
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
9 changed files with 109 additions and 28 deletions
  1. +41
    -22
      README.md
  2. +10
    -0
      hc/accounts/tests/test_add_credential.py
  3. +7
    -0
      hc/accounts/tests/test_login_webauthn.py
  4. +23
    -1
      hc/accounts/tests/test_profile.py
  5. +11
    -0
      hc/accounts/tests/test_remove_credential.py
  6. +2
    -2
      hc/accounts/tests/test_sudo_mode.py
  7. +12
    -1
      hc/accounts/views.py
  8. +1
    -1
      hc/settings.py
  9. +2
    -1
      templates/accounts/profile.html

+ 41
- 22
README.md View File

@ -76,39 +76,45 @@ visit `http://localhost:8000/admin`
## Configuration ## Configuration
Site configuration is loaded from environment variables. This is
done in `hc/settings.py`. Additional configuration is loaded
from `hc/local_settings.py` file, if it exists. You can create this file
(should be right next to `settings.py` in the filesystem) and override
settings, or add extra settings as needed.
Healthchecks prepares its configuration in `hc/settings.py`. It reads configuration
from two places:
Configurations settings loaded from environment variables:
* environment variables (see the variable names in the table below)
* it imports configuration for `hc/local_settings.py` file, if it exists
You can use either mechanism, depending on what is more convenient. Using
`hc/local_settings.py` allows more flexibility: you can set
each and every [Django setting](https://docs.djangoproject.com/en/3.1/ref/settings/),
you can run Python code to load configuration from an external source.
Healthchecks reads configuration from the following environment variables:
| Environment variable | Default value | Notes | Environment variable | Default value | Notes
| -------------------- | ------------- | ----- | | -------------------- | ------------- | ----- |
| [SECRET_KEY](https://docs.djangoproject.com/en/2.2/ref/settings/#secret-key) | `"---"`
| [DEBUG](https://docs.djangoproject.com/en/2.2/ref/settings/#debug) | `True` | Set to `False` for production
| [ALLOWED_HOSTS](https://docs.djangoproject.com/en/2.2/ref/settings/#allowed-hosts) | `*` | Separate multiple hosts with commas
| [DEFAULT_FROM_EMAIL](https://docs.djangoproject.com/en/2.2/ref/settings/#default-from-email) | `"[email protected]"`
| [SECRET_KEY](https://docs.djangoproject.com/en/3.1/ref/settings/#secret-key) | `"---"`
| [DEBUG](https://docs.djangoproject.com/en/3.1/ref/settings/#debug) | `True` | Set to `False` for production
| [ALLOWED_HOSTS](https://docs.djangoproject.com/en/3.1/ref/settings/#allowed-hosts) | `*` | Separate multiple hosts with commas
| [DEFAULT_FROM_EMAIL](https://docs.djangoproject.com/en/3.1/ref/settings/#default-from-email) | `"[email protected]"`
| USE_PAYMENTS | `False` | USE_PAYMENTS | `False`
| REGISTRATION_OPEN | `True` | REGISTRATION_OPEN | `True`
| DB | `"sqlite"` | Set to `"postgres"` or `"mysql"` | DB | `"sqlite"` | Set to `"postgres"` or `"mysql"`
| [DB_HOST](https://docs.djangoproject.com/en/2.2/ref/settings/#host) | `""` *(empty string)*
| [DB_PORT](https://docs.djangoproject.com/en/2.2/ref/settings/#port) | `""` *(empty string)*
| [DB_NAME](https://docs.djangoproject.com/en/2.2/ref/settings/#name) | `"hc"` (PostgreSQL, MySQL) or `"/path/to/project/hc.sqlite"` (SQLite) | For SQLite, specify the full path to the database file.
| [DB_USER](https://docs.djangoproject.com/en/2.2/ref/settings/#user) | `"postgres"` or `"root"`
| [DB_PASSWORD](https://docs.djangoproject.com/en/2.2/ref/settings/#password) | `""` *(empty string)*
| [DB_CONN_MAX_AGE](https://docs.djangoproject.com/en/2.2/ref/settings/#conn-max-age) | `0`
| [DB_HOST](https://docs.djangoproject.com/en/3.1/ref/settings/#host) | `""` *(empty string)*
| [DB_PORT](https://docs.djangoproject.com/en/3.1/ref/settings/#port) | `""` *(empty string)*
| [DB_NAME](https://docs.djangoproject.com/en/3.1/ref/settings/#name) | `"hc"` (PostgreSQL, MySQL) or `"/path/to/project/hc.sqlite"` (SQLite) | For SQLite, specify the full path to the database file.
| [DB_USER](https://docs.djangoproject.com/en/3.1/ref/settings/#user) | `"postgres"` or `"root"`
| [DB_PASSWORD](https://docs.djangoproject.com/en/3.1/ref/settings/#password) | `""` *(empty string)*
| [DB_CONN_MAX_AGE](https://docs.djangoproject.com/en/3.1/ref/settings/#conn-max-age) | `0`
| DB_SSLMODE | `"prefer"` | PostgreSQL-specific, [details](https://blog.github.com/2018-10-21-october21-incident-report/) | DB_SSLMODE | `"prefer"` | PostgreSQL-specific, [details](https://blog.github.com/2018-10-21-october21-incident-report/)
| DB_TARGET_SESSION_ATTRS | `"read-write"` | PostgreSQL-specific, [details](https://www.postgresql.org/docs/10/static/libpq-connect.html#LIBPQ-CONNECT-TARGET-SESSION-ATTRS) | DB_TARGET_SESSION_ATTRS | `"read-write"` | PostgreSQL-specific, [details](https://www.postgresql.org/docs/10/static/libpq-connect.html#LIBPQ-CONNECT-TARGET-SESSION-ATTRS)
| EMAIL_HOST | `""` *(empty string)*
| EMAIL_PORT | `"587"`
| EMAIL_HOST_USER | `""` *(empty string)*
| EMAIL_HOST_PASSWORD | `""` *(empty string)*
| EMAIL_USE_TLS | `"True"`
| EMAIL_USE_VERIFICATION | `"True"`
| [EMAIL_HOST](https://docs.djangoproject.com/en/3.1/ref/settings/#email-host) | `""` *(empty string)*
| [EMAIL_PORT](https://docs.djangoproject.com/en/3.1/ref/settings/#email-port) | `"587"`
| [EMAIL_HOST_USER](https://docs.djangoproject.com/en/3.1/ref/settings/#email-host-user) | `""` *(empty string)*
| [EMAIL_HOST_PASSWORD](https://docs.djangoproject.com/en/3.1/ref/settings/#email-host-password) | `""` *(empty string)*
| [EMAIL_USE_TLS](https://docs.djangoproject.com/en/3.1/ref/settings/#email-use-tls) | `"True"`
| EMAIL_USE_VERIFICATION | `"True"` | Whether to send confirmation links when adding email integrations
| SITE_ROOT | `"http://localhost:8000"` | SITE_ROOT | `"http://localhost:8000"`
| SITE_NAME | `"Mychecks"` | SITE_NAME | `"Mychecks"`
| RP_ID | `None` | Enables WebAuthn support
| MASTER_BADGE_LABEL | `"Mychecks"` | MASTER_BADGE_LABEL | `"Mychecks"`
| PING_ENDPOINT | `"http://localhost:8000/ping/"` | PING_ENDPOINT | `"http://localhost:8000/ping/"`
| PING_EMAIL_DOMAIN | `"localhost"` | PING_EMAIL_DOMAIN | `"localhost"`
@ -310,6 +316,19 @@ test them on a copy of your database, not on the live database right away.
In a production setup, you should also have regular, automated database In a production setup, you should also have regular, automated database
backups set up. backups set up.
## Two-factor Authentication
Healthchecks optionally supports two-factor authentication using the WebAuthn
standard. To enable WebAuthn support, set the `RP_ID` (relying party identifier )
setting to a non-null value. Set its value to your site's domain without scheme
and without port. For example, if your site runs on `https://my-hc.example.org`,
set `RP_ID` to `my-hc.example.org`.
Note that WebAuthn requires HTTPS, even if running on localhost. To test WebAuthn
locally with a self-signed certificate, you can use the `runsslserver` command
from the `django-sslserver` package.
## Integrations ## Integrations
### Slack ### Slack


+ 10
- 0
hc/accounts/tests/test_add_credential.py View File

@ -1,9 +1,11 @@
from unittest.mock import patch from unittest.mock import patch
from django.test.utils import override_settings
from hc.test import BaseTestCase from hc.test import BaseTestCase
from hc.accounts.models import Credential from hc.accounts.models import Credential
@override_settings(RP_ID="testserver")
class AddCredentialTestCase(BaseTestCase): class AddCredentialTestCase(BaseTestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@ -16,6 +18,14 @@ class AddCredentialTestCase(BaseTestCase):
r = self.client.get(self.url) r = self.client.get(self.url)
self.assertContains(r, "We have sent a confirmation code") self.assertContains(r, "We have sent a confirmation code")
@override_settings(RP_ID=None)
def test_it_requires_rp_id(self):
self.client.login(username="[email protected]", password="password")
self.set_sudo_flag()
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)
def test_it_shows_form(self): def test_it_shows_form(self):
self.client.login(username="[email protected]", password="password") self.client.login(username="[email protected]", password="password")
self.set_sudo_flag() self.set_sudo_flag()


+ 7
- 0
hc/accounts/tests/test_login_webauthn.py View File

@ -1,8 +1,10 @@
from unittest.mock import patch from unittest.mock import patch
from django.test.utils import override_settings
from hc.test import BaseTestCase from hc.test import BaseTestCase
@override_settings(RP_ID="testserver")
class LoginWebauthnTestCase(BaseTestCase): class LoginWebauthnTestCase(BaseTestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@ -22,6 +24,11 @@ class LoginWebauthnTestCase(BaseTestCase):
# 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)
@override_settings(RP_ID=None)
def test_it_requires_rp_id(self):
r = self.client.get(self.url)
self.assertEqual(r.status_code, 500)
@patch("hc.accounts.views._check_credential") @patch("hc.accounts.views._check_credential")
def test_it_logs_in(self, mock_check_credential): def test_it_logs_in(self, mock_check_credential):
mock_check_credential.return_value = True mock_check_credential.return_value = True


+ 23
- 1
hc/accounts/tests/test_profile.py View File

@ -1,7 +1,7 @@
from datetime import timedelta as td from datetime import timedelta as td
from django.core import mail from django.core import mail
from django.conf import settings
from django.test.utils import override_settings
from django.utils.timezone import now from django.utils.timezone import now
from hc.test import BaseTestCase from hc.test import BaseTestCase
from hc.accounts.models import Credential from hc.accounts.models import Credential
@ -9,6 +9,12 @@ from hc.api.models import Check
class ProfileTestCase(BaseTestCase): class ProfileTestCase(BaseTestCase):
def test_it_shows_profile_page(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get("/accounts/profile/")
self.assertContains(r, "Email and Password")
def test_it_sends_report(self): def test_it_sends_report(self):
check = Check(project=self.project, name="Test Check") check = Check(project=self.project, name="Test Check")
check.last_ping = now() check.last_ping = now()
@ -118,6 +124,22 @@ class ProfileTestCase(BaseTestCase):
r = self.client.get("/accounts/profile/") r = self.client.get("/accounts/profile/")
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)
def test_it_hides_2fa_section_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")
@override_settings(RP_ID="testserver")
def test_it_handles_no_credentials(self):
self.client.login(username="[email protected]", password="password")
r = self.client.get("/accounts/profile/")
self.assertContains(r, "Two-factor Authentication")
self.assertContains(r, "Your account has no registered security keys")
@override_settings(RP_ID="testserver")
def test_it_shows_security_key(self): def test_it_shows_security_key(self):
Credential.objects.create(user=self.alice, name="Alices Key") Credential.objects.create(user=self.alice, name="Alices Key")


+ 11
- 0
hc/accounts/tests/test_remove_credential.py View File

@ -1,7 +1,10 @@
from django.test.utils import override_settings
from hc.test import BaseTestCase from hc.test import BaseTestCase
from hc.accounts.models import Credential from hc.accounts.models import Credential
@override_settings(RP_ID="testserver")
class RemoveCredentialTestCase(BaseTestCase): class RemoveCredentialTestCase(BaseTestCase):
def setUp(self): def setUp(self):
super().setUp() super().setUp()
@ -15,6 +18,14 @@ class RemoveCredentialTestCase(BaseTestCase):
r = self.client.get(self.url) r = self.client.get(self.url)
self.assertContains(r, "We have sent a confirmation code") self.assertContains(r, "We have sent a confirmation code")
@override_settings(RP_ID=None)
def test_it_requires_rp_id(self):
self.client.login(username="[email protected]", password="password")
self.set_sudo_flag()
r = self.client.get(self.url)
self.assertEqual(r.status_code, 404)
def test_it_shows_form(self): def test_it_shows_form(self):
self.client.login(username="[email protected]", password="password") self.client.login(username="[email protected]", password="password")
self.set_sudo_flag() self.set_sudo_flag()


+ 2
- 2
hc/accounts/tests/test_sudo_mode.py View File

@ -11,7 +11,7 @@ class SudoModeTestCase(BaseTestCase):
super().setUp() super().setUp()
self.c = Credential.objects.create(user=self.alice, name="Alices Key") self.c = Credential.objects.create(user=self.alice, name="Alices Key")
self.url = f"/accounts/two_factor/{self.c.code}/remove/"
self.url = f"/accounts/set_password/"
def test_it_sends_code(self): def test_it_sends_code(self):
self.client.login(username="[email protected]", password="password") self.client.login(username="[email protected]", password="password")
@ -60,7 +60,7 @@ class SudoModeTestCase(BaseTestCase):
session.save() session.save()
r = self.client.get(self.url) r = self.client.get(self.url)
self.assertContains(r, "Remove Security Key")
self.assertContains(r, "Please pick a password")
def test_it_uses_rate_limiting(self): def test_it_uses_rate_limiting(self):
self.client.login(username="[email protected]", password="password") self.client.login(username="[email protected]", password="password")


+ 12
- 1
hc/accounts/views.py View File

@ -12,7 +12,7 @@ from django.contrib.auth import authenticate, update_session_auth_hash
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core import signing from django.core import signing
from django.http import HttpResponseForbidden, HttpResponseBadRequest
from django.http import HttpResponse, HttpResponseForbidden, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.timezone import now from django.utils.timezone import now
from django.urls import resolve, reverse, Resolver404 from django.urls import resolve, reverse, Resolver404
@ -223,6 +223,7 @@ def profile(request):
"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", ""),
"credentials": request.user.credentials.order_by("id"), "credentials": request.user.credentials.order_by("id"),
"use_2fa": settings.RP_ID,
} }
if ctx["added_credential_name"]: if ctx["added_credential_name"]:
@ -594,6 +595,9 @@ def _get_credential_data(request, form):
@login_required @login_required
@require_sudo_mode @require_sudo_mode
def add_credential(request): def add_credential(request):
if not settings.RP_ID:
return HttpResponse(status=404)
if request.method == "POST": if request.method == "POST":
form = forms.AddCredentialForm(request.POST) form = forms.AddCredentialForm(request.POST)
if not form.is_valid(): if not form.is_valid():
@ -630,6 +634,9 @@ def add_credential(request):
@login_required @login_required
@require_sudo_mode @require_sudo_mode
def remove_credential(request, code): def remove_credential(request, code):
if not settings.RP_ID:
return HttpResponse(status=404)
try: try:
credential = Credential.objects.get(user=request.user, code=code) credential = Credential.objects.get(user=request.user, code=code)
except Credential.DoesNotExist: except Credential.DoesNotExist:
@ -669,6 +676,10 @@ def login_webauthn(request):
if "2fa_user_id" not in request.session: if "2fa_user_id" not in request.session:
return HttpResponseBadRequest() return HttpResponseBadRequest()
# We require RP_ID. Fail predicably if it is not set:
if not settings.RP_ID:
return HttpResponse(status=500)
user = User.objects.get(id=request.session["2fa_user_id"]) user = User.objects.get(id=request.session["2fa_user_id"])
credentials = [c.unpack() for c in user.credentials.all()] credentials = [c.unpack() for c in user.credentials.all()]


+ 1
- 1
hc/settings.py View File

@ -165,7 +165,7 @@ COMPRESS_OFFLINE = True
COMPRESS_CSS_HASHING_METHOD = "content" COMPRESS_CSS_HASHING_METHOD = "content"
# Webauthn # Webauthn
RP_ID = "localhost"
RP_ID = os.getenv("RP_ID")
# Discord integration # Discord integration
DISCORD_CLIENT_ID = os.getenv("DISCORD_CLIENT_ID") DISCORD_CLIENT_ID = os.getenv("DISCORD_CLIENT_ID")


+ 2
- 1
templates/accounts/profile.html View File

@ -62,6 +62,7 @@
{% 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">
@ -112,7 +113,7 @@
</div> </div>
{% endif %} {% 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">


Loading…
Cancel
Save