Browse Source

Add ability to create/revoke individual keys

pull/563/head
Pēteris Caune 3 years ago
parent
commit
3dfdbc09ca
No known key found for this signature in database GPG Key ID: E28D7679E9A9EDE2
10 changed files with 196 additions and 113 deletions
  1. +1
    -0
      CHANGELOG.md
  2. +0
    -12
      hc/accounts/models.py
  3. +27
    -16
      hc/accounts/tests/test_project.py
  4. +27
    -12
      hc/accounts/views.py
  5. +1
    -1
      hc/api/urls.py
  6. +5
    -0
      hc/front/templatetags/hc_extras.py
  7. +4
    -0
      static/css/project.css
  8. +12
    -0
      static/js/project.js
  9. +118
    -72
      templates/accounts/project.html
  10. +1
    -0
      templates/base.html

+ 1
- 0
CHANGELOG.md View File

@ -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


+ 0
- 12
hc/accounts/models.py View File

@ -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)


+ 27
- 16
hc/accounts/tests/test_project.py View File

@ -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="[email protected]", 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="[email protected]", 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="[email protected]", 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="[email protected]", 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="[email protected]", 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="[email protected]", 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="[email protected]", 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)

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

@ -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()


+ 1
- 1
hc/api/urls.py View File

@ -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),


+ 5
- 0
hc/front/templatetags/hc_extras.py View File

@ -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
- 0
static/css/project.css View File

@ -0,0 +1,4 @@
#api-keys .not-set {
color: var(--text-muted);
font-style: italic;
}

+ 12
- 0
static/js/project.js View File

@ -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();
})
});

+ 118
- 72
templates/accounts/project.html View File

@ -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>
{% 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>
</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 %}
</form>
</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>
<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">&times;</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>


+ 1
- 0
templates/base.html View File

@ -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…
Cancel
Save