diff --git a/CHANGELOG.md b/CHANGELOG.md index 35c554ca..f6af79b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to this project will be documented in this file. ### Improvements - Add /api/v1/badges/ endpoint (#552) - Add ability to edit existing email, Signal, SMS, WhatsApp integrations +- Add new ping URL format: /{ping_key}/{slug} (#491) ### Bug Fixes - Add handling for non-latin-1 characters in webhook headers diff --git a/hc/accounts/models.py b/hc/accounts/models.py index a615db8e..7886e0ef 100644 --- a/hc/accounts/models.py +++ b/hc/accounts/models.py @@ -338,18 +338,6 @@ class Project(models.Model): def num_checks_available(self): return self.owner_profile.num_checks_available() - def set_api_keys(self): - def pick(nbytes=24): - while True: - candidate = token_urlsafe(nbytes) - if candidate[0] not in "-_" and candidate[-1] not in "-_": - return candidate - - self.api_key = pick() - self.api_key_readonly = pick() - self.ping_key = pick(16) - self.save() - def invite_suggestions(self): q = User.objects.filter(memberships__project__owner_id=self.owner_id) q = q.exclude(memberships__project=self) diff --git a/hc/accounts/tests/test_project.py b/hc/accounts/tests/test_project.py index aaeed31c..3445d4fd 100644 --- a/hc/accounts/tests/test_project.py +++ b/hc/accounts/tests/test_project.py @@ -23,58 +23,69 @@ class ProjectTestCase(BaseTestCase): r = self.client.get(self.url) self.assertContains(r, "Change Project Name") - def test_it_shows_api_keys(self): + def test_it_masks_keys_by_default(self): self.project.api_key_readonly = "R" * 32 + self.project.ping_key = "P" * 22 self.project.save() self.client.login(username="alice@example.org", password="password") - form = {"show_api_keys": "1"} + r = self.client.get(self.url) + self.assertEqual(r.status_code, 200) + + self.assertNotContains(r, "X" * 32) + self.assertNotContains(r, "R" * 32) + self.assertNotContains(r, "P" * 22) + + def test_it_shows_keys(self): + self.project.api_key_readonly = "R" * 32 + self.project.ping_key = "P" * 22 + self.project.save() + + self.client.login(username="alice@example.org", password="password") + + form = {"show_keys": "1"} r = self.client.post(self.url, form) self.assertEqual(r.status_code, 200) self.assertContains(r, "X" * 32) self.assertContains(r, "R" * 32) + self.assertContains(r, "P" * 22) self.assertContains(r, "Prometheus metrics endpoint") - def test_it_creates_api_key(self): + def test_it_creates_readonly_key(self): self.client.login(username="alice@example.org", password="password") - form = {"create_api_keys": "1"} + form = {"create_key": "api_key_readonly"} r = self.client.post(self.url, form) self.assertEqual(r.status_code, 200) self.project.refresh_from_db() - api_key = self.project.api_key - self.assertTrue(len(api_key) > 10) - self.assertFalse("b'" in api_key) + self.assertEqual(len(self.project.api_key_readonly), 32) + self.assertFalse("b'" in self.project.api_key_readonly) - def test_it_requires_rw_access_to_create_api_key(self): + def test_it_requires_rw_access_to_create_key(self): self.bobs_membership.role = "r" self.bobs_membership.save() self.client.login(username="bob@example.org", password="password") - r = self.client.post(self.url, {"create_api_keys": "1"}) + r = self.client.post(self.url, {"create_key": "api_key_readonly"}) self.assertEqual(r.status_code, 403) def test_it_revokes_api_key(self): - self.project.api_key_readonly = "R" * 32 - self.project.save() - self.client.login(username="alice@example.org", password="password") - r = self.client.post(self.url, {"revoke_api_keys": "1"}) + r = self.client.post(self.url, {"revoke_key": "api_key"}) self.assertEqual(r.status_code, 200) self.project.refresh_from_db() self.assertEqual(self.project.api_key, "") - self.assertEqual(self.project.api_key_readonly, "") def test_it_requires_rw_access_to_revoke_api_key(self): self.bobs_membership.role = "r" self.bobs_membership.save() self.client.login(username="bob@example.org", password="password") - r = self.client.post(self.url, {"revoke_api_keys": "1"}) + r = self.client.post(self.url, {"revoke_key": "api_key"}) self.assertEqual(r.status_code, 403) def test_it_adds_team_member(self): @@ -348,5 +359,5 @@ class ProjectTestCase(BaseTestCase): self.bobs_membership.save() self.client.login(username="bob@example.org", password="password") - r = self.client.post(self.url, {"show_api_keys": "1"}) + r = self.client.post(self.url, {"show_keys": "1"}) self.assertEqual(r.status_code, 403) diff --git a/hc/accounts/views.py b/hc/accounts/views.py index c094f7b9..35f1b493 100644 --- a/hc/accounts/views.py +++ b/hc/accounts/views.py @@ -1,6 +1,6 @@ import base64 from datetime import timedelta as td -from secrets import token_bytes +from secrets import token_bytes, token_urlsafe from urllib.parse import urlparse import time import uuid @@ -139,6 +139,13 @@ def _check_2fa(request, user): return _redirect_after_login(request) +def _new_key(nbytes=24): + while True: + candidate = token_urlsafe(nbytes) + if candidate[0] not in "-_" and candidate[-1] not in "-_": + return candidate + + def login(request): form = forms.PasswordLoginForm() magic_form = forms.EmailLoginForm() @@ -320,32 +327,40 @@ def project(request, code): } if request.method == "POST": - if "create_api_keys" in request.POST: + if "create_key" in request.POST: if not rw: return HttpResponseForbidden() - project.set_api_keys() + if request.POST["create_key"] == "api_key": + project.api_key = _new_key(24) + elif request.POST["create_key"] == "api_key_readonly": + project.api_key_readonly = _new_key(24) + elif request.POST["create_key"] == "ping_key": + project.ping_key = _new_key(16) project.save() - ctx["show_api_keys"] = True - ctx["api_keys_created"] = True + ctx["key_created"] = True ctx["api_status"] = "success" - elif "revoke_api_keys" in request.POST: + ctx["show_keys"] = True + elif "revoke_key" in request.POST: if not rw: return HttpResponseForbidden() - project.api_key = "" - project.api_key_readonly = "" - project.ping_key = None + if request.POST["revoke_key"] == "api_key": + project.api_key = "" + elif request.POST["revoke_key"] == "api_key_readonly": + project.api_key_readonly = "" + elif request.POST["revoke_key"] == "ping_key": + project.ping_key = None project.save() - ctx["api_keys_revoked"] = True + ctx["key_revoked"] = True ctx["api_status"] = "info" - elif "show_api_keys" in request.POST: + elif "show_keys" in request.POST: if not rw: return HttpResponseForbidden() - ctx["show_api_keys"] = True + ctx["show_keys"] = True elif "invite_team_member" in request.POST: if not is_manager: return HttpResponseForbidden() diff --git a/hc/api/urls.py b/hc/api/urls.py index 13fb9c66..71d1ce00 100644 --- a/hc/api/urls.py +++ b/hc/api/urls.py @@ -28,7 +28,7 @@ register_converter(QuoteConverter, "quoted") register_converter(SHA1Converter, "sha1") uuid_urls = [ - path("", views.ping, name="hc-ping"), + path("", views.ping), path("fail", views.ping, {"action": "fail"}), path("start", views.ping, {"action": "start"}), path("<int:exitstatus>", views.ping), diff --git a/hc/front/templatetags/hc_extras.py b/hc/front/templatetags/hc_extras.py index 2d7b7d53..18220ef4 100644 --- a/hc/front/templatetags/hc_extras.py +++ b/hc/front/templatetags/hc_extras.py @@ -227,3 +227,8 @@ def format_ping_endpoint(ping_url): assert ping_url.startswith(settings.PING_ENDPOINT) tail = ping_url[len(settings.PING_ENDPOINT) :] return mark_safe(FORMATTED_PING_ENDPOINT + escape(tail)) + + +@register.filter +def mask_key(key): + return key[:4] + "*" * len(key[4:]) diff --git a/static/css/project.css b/static/css/project.css new file mode 100644 index 00000000..cf83e57e --- /dev/null +++ b/static/css/project.css @@ -0,0 +1,4 @@ +#api-keys .not-set { + color: var(--text-muted); + font-style: italic; +} \ No newline at end of file diff --git a/static/js/project.js b/static/js/project.js index 45c865e4..7a765a94 100644 --- a/static/js/project.js +++ b/static/js/project.js @@ -30,4 +30,16 @@ $(function() { $("#transfer-confirm").prop("disabled", !this.value); }); + $("a[data-revoke-key]").click(function() { + $("#revoke-key-type").val(this.dataset.revokeKey); + $("#revoke-key-modal .name").text(this.dataset.name); + $("#revoke-key-modal").modal("show"); + }) + + $("a[data-create-key]").click(function() { + $("#create-key-type").val(this.dataset.createKey); + $("#create-key-form").submit(); + }) + + }); diff --git a/templates/accounts/project.html b/templates/accounts/project.html index 7ca8f6e7..26975531 100644 --- a/templates/accounts/project.html +++ b/templates/accounts/project.html @@ -75,86 +75,124 @@ {% endif %} </div> + {% if rw %} <div class="panel panel-{{ api_status|default:'default' }}"> <div class="panel-body settings-block"> <h2>API Access</h2> - {% if project.api_key %} - {% if show_api_keys %} - <p> - API key: <br /> - <pre>{{ project.api_key }}</pre> - </p> - {% if project.api_key_readonly %} - <p> - API key (read-only): <br /> - <pre>{{ project.api_key_readonly }}</pre> - </p> - {% endif %} - {% if project.ping_key %} - <p> - Ping key: <br /> - <pre>{{ project.ping_key }}</pre> - </p> - {% endif %} - <p>See also:</p> - <ul> - <li><a href="{% url 'hc-serve-doc' 'api' %}">API documentation</a></li> + <table id="api-keys" class="table"> + <tr> + <td>API key</td> + <td> + {% if project.api_key %} + {% if show_keys %} + <code>{{ project.api_key }}</code> + {% else %} + <code>{{ project.api_key|mask_key }}</code> + {% endif %} + {% else %} + <span class="not-set">not set</span> + {% endif %} + </td> + <td class="text-right"> + {% if project.api_key %} + <a href="#" + data-revoke-key="api_key" + data-name="API key">Revoke</a> + {% else %} + <a href="#" data-create-key="api_key">Create</a> + {% endif %} + </td> + </tr> + <tr> + <td>API key (read-only)</td> + <td> {% if project.api_key_readonly %} - {% if enable_prometheus %} - <li> - <a href="{% url 'hc-metrics' project.code project.api_key_readonly %}">Prometheus metrics endpoint</a> - </li> + {% if show_keys %} + <code>{{ project.api_key_readonly }}</code> + {% else %} + <code>{{ project.api_key_readonly|mask_key }}</code> {% endif %} - <li> - <a href="{{ project.dashboard_url }}">Read-only dashboard</a> - (<a href="https://github.com/healthchecks/dashboard/#security">security considerations</a>) - </li> + {% else %} + <span class="not-set">not set</span> {% endif %} - </ul> - <button - data-toggle="modal" - data-target="#revoke-api-key-modal" - class="btn btn-danger pull-right">Revoke</button> + </td> + <td class="text-right"> + {% if project.api_key_readonly %} + <a href="#" + data-revoke-key="api_key_readonly" + data-name="read-only API key">Revoke</a> + {% else %} + <a href="#" data-create-key="api_key_readonly">Create</a> + {% endif %} + </td> + </tr> + <tr> + <td>Ping key</td> + <td> + {% if project.ping_key %} + {% if show_keys %} + <code>{{ project.ping_key }}</code> + {% else %} + <code>{{ project.ping_key|mask_key }}</code> + {% endif %} + {% else %} + <span class="not-set">not set</span> + {% endif %} + </td> + <td class="text-right"> + {% if project.ping_key %} + <a href="#" + data-revoke-key="ping_key" + data-name="Ping key">Revoke</a> + {% else %} + <a href="#" data-create-key="ping_key">Create</a> + {% endif %} + </td> + </tr> + </table> - {% else %} - <form method="post"> - <span class="ic-ok"></span> - API access is enabled. - {% csrf_token %} - - {% if rw %} - <button - type="submit" - name="show_api_keys" - class="btn btn-default pull-right">Show API Keys</button> - {% endif %} - </form> + <p>See also:</p> + <ul> + <li><a href="{% url 'hc-serve-doc' 'api' %}">API documentation</a></li> + {% if project.api_key_readonly and show_keys %} + {% if enable_prometheus %} + <li> + <a href="{% url 'hc-metrics' project.code project.api_key_readonly %}">Prometheus metrics endpoint</a> + </li> {% endif %} - {% else %} - <span class="ic-cancel"></span> - API access is disabled. - <form method="post"> - {% csrf_token %} - <button - type="submit" - name="create_api_keys" - class="btn btn-default pull-right">Create API Keys</button> - </form> + <li> + <a href="{{ project.dashboard_url }}">Read-only dashboard</a> + (<a href="https://github.com/healthchecks/dashboard/#security">security considerations</a>) + </li> + {% endif %} + </ul> + + {% if not show_keys %} + {% if project.api_key or project.api_key_readonly or project.ping_key %} + <form method="post"> + {% csrf_token %} + <button + type="submit" + name="show_keys" + class="btn btn-default pull-right">Show Keys</button> + </form> + {% endif %} {% endif %} </div> - {% if api_keys_created %} + {% if key_created %} <div class="panel-footer"> - API keys created + Key created </div> {% endif %} - {% if api_keys_revoked %} + {% if key_revoked %} <div class="panel-footer"> - API keys revoked + Key revoked </div> {% endif %} </div> + {% endif %} {% with invite_suggestions=project.invite_suggestions %} <div class="panel panel-{{ team_status|default:'default' }}"> @@ -320,29 +358,37 @@ </div> </div> -<div id="revoke-api-key-modal" class="modal"> +<form id="create-key-form" method="post"> + {% csrf_token %} + <input id="create-key-type" type="hidden" name="create_key"> +</form> + +<div id="revoke-key-modal" class="modal"> <div class="modal-dialog"> - <form id="revoke-api-key-form" method="post"> + <form id="revoke-key-form" method="post"> {% csrf_token %} + <input id="revoke-key-type" type="hidden" name="revoke_key"> <div class="modal-content"> <div class="modal-header"> <button type="button" class="close" data-dismiss="modal">×</button> - <h4>Revoke API Keys?</h4> + <h4>Revoke <span class="name"></span>?</h4> </div> <div class="modal-body"> - <p>You are about to revoke your current API keys.</p> - <p>Afterwards, you can create new API keys, but there will - be <strong>no way of getting the current API - keys back</strong>. + <p> + You are about to revoke your current + <span class="name"></span>. + </p> + <p> + Afterwards, you can generate a new key, but there will + be <strong>no way of getting the key's current value back</strong>. </p> <p>Are you sure?</p> </div> <div class="modal-footer"> <button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button> - <button - type="submit" - name="revoke_api_keys" - class="btn btn-danger">Revoke API Keys</button> + <button type="submit" class="btn btn-danger"> + Revoke <span class="name"></span> + </button> </div> </div> </form> diff --git a/templates/base.html b/templates/base.html index 3300a300..bc46f82a 100644 --- a/templates/base.html +++ b/templates/base.html @@ -58,6 +58,7 @@ <link rel="stylesheet" href="{% static 'css/syntax.css' %}" type="text/css"> <link rel="stylesheet" href="{% static 'css/welcome.css' %}" type="text/css"> <link rel="stylesheet" href="{% static 'css/set_password.css' %}" type="text/css"> + <link rel="stylesheet" href="{% static 'css/project.css' %}" type="text/css"> {% endcompress %} </head> <body class="page-{{ page }}{% if request.user.is_authenticated and request.profile.theme == 'dark' %} dark{% endif%}">