mirror of
https://github.com/healthchecks/healthchecks.git
synced 2025-04-03 12:25:31 +00:00
Add ability to create/revoke individual keys
This commit is contained in:
parent
688aa5b3c3
commit
3dfdbc09ca
10 changed files with 196 additions and 113 deletions
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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:])
|
||||
|
|
4
static/css/project.css
Normal file
4
static/css/project.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
#api-keys .not-set {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
|
@ -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();
|
||||
})
|
||||
|
||||
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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%}">
|
||||
|
|
Loading…
Add table
Reference in a new issue