mirror of
https://github.com/healthchecks/healthchecks.git
synced 2025-04-12 00:01:19 +00:00
Add read-only API key support
This commit is contained in:
parent
182f9e1109
commit
432e592e44
12 changed files with 187 additions and 63 deletions
|
@ -8,7 +8,7 @@ All notable changes to this project will be documented in this file.
|
|||
- Add "List-Unsubscribe" header to alert and report emails
|
||||
- Don't send monthly reports to inactive accounts (no pings in 6 months)
|
||||
- Add search box in the "My Checks" page
|
||||
- Refactor API key checking code
|
||||
- Add read-only API key support
|
||||
|
||||
### Bug Fixes
|
||||
- During DST transition, handle ambiguous dates as pre-transition
|
||||
|
|
20
hc/accounts/management/commands/createreadonlykeys.py
Normal file
20
hc/accounts/management/commands/createreadonlykeys.py
Normal file
|
@ -0,0 +1,20 @@
|
|||
from base64 import urlsafe_b64encode
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from hc.accounts.models import Profile
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = """Create read-only API keys."""
|
||||
|
||||
def handle(self, *args, **options):
|
||||
c = 0
|
||||
q = Profile.objects.filter(api_key_readonly="").exclude(api_key="")
|
||||
for profile in q:
|
||||
profile.api_key_readonly = urlsafe_b64encode(os.urandom(24)).decode()
|
||||
profile.save()
|
||||
c += 1
|
||||
|
||||
return "Done! Generated %d readonly keys." % c
|
23
hc/accounts/migrations/0015_auto_20181029_1858.py
Normal file
23
hc/accounts/migrations/0015_auto_20181029_1858.py
Normal file
|
@ -0,0 +1,23 @@
|
|||
# Generated by Django 2.1.2 on 2018-10-29 18:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0014_auto_20171227_1530'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='api_key_id',
|
||||
field=models.CharField(blank=True, max_length=128),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='profile',
|
||||
name='api_key_readonly',
|
||||
field=models.CharField(blank=True, max_length=128),
|
||||
),
|
||||
]
|
|
@ -50,7 +50,9 @@ class Profile(models.Model):
|
|||
ping_log_limit = models.IntegerField(default=100)
|
||||
check_limit = models.IntegerField(default=20)
|
||||
token = models.CharField(max_length=128, blank=True)
|
||||
api_key_id = models.CharField(max_length=128, blank=True)
|
||||
api_key = models.CharField(max_length=128, blank=True)
|
||||
api_key_readonly = models.CharField(max_length=128, blank=True)
|
||||
current_team = models.ForeignKey("self", models.SET_NULL, null=True)
|
||||
bill_to = models.TextField(blank=True)
|
||||
last_sms_date = models.DateTimeField(null=True, blank=True)
|
||||
|
@ -117,8 +119,10 @@ class Profile(models.Model):
|
|||
}
|
||||
emails.change_email(self.user.email, ctx)
|
||||
|
||||
def set_api_key(self):
|
||||
def set_api_keys(self, key_id=""):
|
||||
self.api_key_id = key_id
|
||||
self.api_key = urlsafe_b64encode(os.urandom(24)).decode()
|
||||
self.api_key_readonly = urlsafe_b64encode(os.urandom(24)).decode()
|
||||
self.save()
|
||||
|
||||
def checks_from_all_teams(self):
|
||||
|
|
|
@ -30,7 +30,7 @@ class ProfileTestCase(BaseTestCase):
|
|||
def test_it_creates_api_key(self):
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"create_api_key": "1"}
|
||||
form = {"create_api_keys": "1"}
|
||||
r = self.client.post("/accounts/profile/", form)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
||||
|
@ -40,14 +40,18 @@ class ProfileTestCase(BaseTestCase):
|
|||
self.assertFalse("b'" in api_key)
|
||||
|
||||
def test_it_revokes_api_key(self):
|
||||
self.profile.api_key_readonly = "R" * 32
|
||||
self.profile.save()
|
||||
|
||||
self.client.login(username="alice@example.org", password="password")
|
||||
|
||||
form = {"revoke_api_key": "1"}
|
||||
form = {"revoke_api_keys": "1"}
|
||||
r = self.client.post("/accounts/profile/", form)
|
||||
assert r.status_code == 200
|
||||
|
||||
self.profile.refresh_from_db()
|
||||
self.assertEqual(self.profile.api_key, "")
|
||||
self.assertEqual(self.profile.api_key_readonly, "")
|
||||
|
||||
def test_it_sends_report(self):
|
||||
check = Check(name="Test Check", user=self.alice)
|
||||
|
|
|
@ -153,7 +153,7 @@ def profile(request):
|
|||
ctx = {
|
||||
"page": "profile",
|
||||
"profile": profile,
|
||||
"show_api_key": False,
|
||||
"show_api_keys": False,
|
||||
"api_status": "default",
|
||||
"team_status": "default"
|
||||
}
|
||||
|
@ -165,18 +165,20 @@ def profile(request):
|
|||
elif "set_password" in request.POST:
|
||||
profile.send_set_password_link()
|
||||
return redirect("hc-link-sent")
|
||||
elif "create_api_key" in request.POST:
|
||||
profile.set_api_key()
|
||||
ctx["show_api_key"] = True
|
||||
ctx["api_key_created"] = True
|
||||
elif "create_api_keys" in request.POST:
|
||||
profile.set_api_keys()
|
||||
ctx["show_api_keys"] = True
|
||||
ctx["api_keys_created"] = True
|
||||
ctx["api_status"] = "success"
|
||||
elif "revoke_api_key" in request.POST:
|
||||
elif "revoke_api_keys" in request.POST:
|
||||
profile.api_key_id = ""
|
||||
profile.api_key = ""
|
||||
profile.api_key_readonly = ""
|
||||
profile.save()
|
||||
ctx["api_key_revoked"] = True
|
||||
ctx["api_keys_revoked"] = True
|
||||
ctx["api_status"] = "info"
|
||||
elif "show_api_key" in request.POST:
|
||||
ctx["show_api_key"] = True
|
||||
elif "show_api_keys" in request.POST:
|
||||
ctx["show_api_keys"] = True
|
||||
elif "invite_team_member" in request.POST:
|
||||
if not profile.can_invite():
|
||||
return HttpResponseForbidden()
|
||||
|
|
|
@ -2,6 +2,7 @@ import json
|
|||
from functools import wraps
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Q
|
||||
from django.http import JsonResponse
|
||||
from hc.lib.jsonschema import ValidationError, validate
|
||||
|
||||
|
@ -10,7 +11,7 @@ def error(msg, status=400):
|
|||
return JsonResponse({"error": msg}, status=status)
|
||||
|
||||
|
||||
def check_api_key(f):
|
||||
def authorize(f):
|
||||
@wraps(f)
|
||||
def wrapper(request, *args, **kwds):
|
||||
if "HTTP_X_API_KEY" in request.META:
|
||||
|
@ -27,7 +28,28 @@ def check_api_key(f):
|
|||
return error("wrong api key", 401)
|
||||
|
||||
return f(request, *args, **kwds)
|
||||
return wrapper
|
||||
|
||||
|
||||
def authorize_read(f):
|
||||
@wraps(f)
|
||||
def wrapper(request, *args, **kwds):
|
||||
if "HTTP_X_API_KEY" in request.META:
|
||||
api_key = request.META["HTTP_X_API_KEY"]
|
||||
else:
|
||||
api_key = str(request.json.get("api_key", ""))
|
||||
|
||||
if len(api_key) != 32:
|
||||
return error("missing api key", 401)
|
||||
|
||||
write_key_match = Q(profile__api_key=api_key)
|
||||
read_key_match = Q(profile__api_key_readonly=api_key)
|
||||
try:
|
||||
request.user = User.objects.get(write_key_match | read_key_match)
|
||||
except User.DoesNotExist:
|
||||
return error("wrong api key", 401)
|
||||
|
||||
return f(request, *args, **kwds)
|
||||
return wrapper
|
||||
|
||||
|
||||
|
|
18
hc/api/migrations/0042_auto_20181029_1522.py
Normal file
18
hc/api/migrations/0042_auto_20181029_1522.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 2.1.2 on 2018-10-29 15:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('api', '0041_check_desc'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='channel',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('email', 'Email'), ('webhook', 'Webhook'), ('hipchat', 'HipChat'), ('slack', 'Slack'), ('pd', 'PagerDuty'), ('pagertree', 'PagerTree'), ('po', 'Pushover'), ('pushbullet', 'Pushbullet'), ('opsgenie', 'OpsGenie'), ('victorops', 'VictorOps'), ('discord', 'Discord'), ('telegram', 'Telegram'), ('sms', 'SMS'), ('zendesk', 'Zendesk'), ('trello', 'Trello')], max_length=20),
|
||||
),
|
||||
]
|
|
@ -198,3 +198,10 @@ class CreateCheckTestCase(BaseTestCase):
|
|||
|
||||
r = self.post({"api_key": "X" * 32})
|
||||
self.assertEqual(r.status_code, 403)
|
||||
|
||||
def test_readonly_key_does_not_work(self):
|
||||
self.profile.api_key_readonly = "R" * 32
|
||||
self.profile.save()
|
||||
|
||||
r = self.post({"api_key": "R" * 32, "name": "Foo"})
|
||||
self.assertEqual(r.status_code, 401)
|
||||
|
|
|
@ -123,3 +123,10 @@ class ListChecksTestCase(BaseTestCase):
|
|||
doc = r.json()
|
||||
self.assertTrue("checks" in doc)
|
||||
self.assertEqual(len(doc["checks"]), 0)
|
||||
|
||||
def test_readonly_key_works(self):
|
||||
self.profile.api_key_readonly = "R" * 32
|
||||
self.profile.save()
|
||||
|
||||
r = self.client.get("/api/v1/checks/", HTTP_X_API_KEY="R" * 32)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
|
|
|
@ -11,7 +11,7 @@ from django.views.decorators.csrf import csrf_exempt
|
|||
from django.views.decorators.http import require_POST
|
||||
|
||||
from hc.api import schemas
|
||||
from hc.api.decorators import check_api_key, validate_json
|
||||
from hc.api.decorators import authorize, authorize_read, validate_json
|
||||
from hc.api.models import Check, Notification
|
||||
from hc.lib.badges import check_signature, get_badge_svg
|
||||
|
||||
|
@ -87,48 +87,56 @@ def _update(check, spec):
|
|||
return check
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@validate_json()
|
||||
@authorize_read
|
||||
def get_checks(request):
|
||||
q = Check.objects.filter(user=request.user)
|
||||
|
||||
tags = set(request.GET.getlist("tag"))
|
||||
for tag in tags:
|
||||
# approximate filtering by tags
|
||||
q = q.filter(tags__contains=tag)
|
||||
|
||||
checks = []
|
||||
for check in q:
|
||||
# precise, final filtering
|
||||
if not tags or check.matches_tag_set(tags):
|
||||
checks.append(check.to_dict())
|
||||
|
||||
return JsonResponse({"checks": checks})
|
||||
|
||||
|
||||
@validate_json(schemas.check)
|
||||
@check_api_key
|
||||
@authorize
|
||||
def create_check(request):
|
||||
created = False
|
||||
check = _lookup(request.user, request.json)
|
||||
if check is None:
|
||||
num_checks = Check.objects.filter(user=request.user).count()
|
||||
if num_checks >= request.user.profile.check_limit:
|
||||
return HttpResponseForbidden()
|
||||
|
||||
check = Check(user=request.user)
|
||||
created = True
|
||||
|
||||
_update(check, request.json)
|
||||
return JsonResponse(check.to_dict(), status=201 if created else 200)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def checks(request):
|
||||
if request.method == "GET":
|
||||
q = Check.objects.filter(user=request.user)
|
||||
|
||||
tags = set(request.GET.getlist("tag"))
|
||||
for tag in tags:
|
||||
# approximate filtering by tags
|
||||
q = q.filter(tags__contains=tag)
|
||||
|
||||
checks = []
|
||||
for check in q:
|
||||
# precise, final filtering
|
||||
if not tags or check.matches_tag_set(tags):
|
||||
checks.append(check.to_dict())
|
||||
|
||||
return JsonResponse({"checks": checks})
|
||||
return get_checks(request)
|
||||
|
||||
elif request.method == "POST":
|
||||
created = False
|
||||
check = _lookup(request.user, request.json)
|
||||
if check is None:
|
||||
num_checks = Check.objects.filter(user=request.user).count()
|
||||
if num_checks >= request.user.profile.check_limit:
|
||||
return HttpResponseForbidden()
|
||||
return create_check(request)
|
||||
|
||||
check = Check(user=request.user)
|
||||
created = True
|
||||
|
||||
_update(check, request.json)
|
||||
|
||||
return JsonResponse(check.to_dict(), status=201 if created else 200)
|
||||
|
||||
# If request is neither GET nor POST, return "405 Method not allowed"
|
||||
return HttpResponse(status=405)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@validate_json(schemas.check)
|
||||
@check_api_key
|
||||
@authorize
|
||||
def update(request, code):
|
||||
check = get_object_or_404(Check, code=code)
|
||||
if check.user != request.user:
|
||||
|
@ -150,7 +158,7 @@ def update(request, code):
|
|||
@csrf_exempt
|
||||
@require_POST
|
||||
@validate_json()
|
||||
@check_api_key
|
||||
@authorize
|
||||
def pause(request, code):
|
||||
check = get_object_or_404(Check, code=code)
|
||||
if check.user != request.user:
|
||||
|
|
|
@ -61,8 +61,17 @@
|
|||
<div class="panel-body settings-block">
|
||||
<h2>API Access</h2>
|
||||
{% if profile.api_key %}
|
||||
{% if show_api_key %}
|
||||
API key: <code>{{ profile.api_key }}</code>
|
||||
{% if show_api_keys %}
|
||||
<p>
|
||||
API key: <br />
|
||||
<code>{{ profile.api_key }}</code>
|
||||
</p>
|
||||
{% if profile.api_key_readonly %}
|
||||
<p>
|
||||
API key (read-only): <br />
|
||||
<code>{{ profile.api_key_readonly }}</code>
|
||||
</p>
|
||||
{% endif %}
|
||||
<button
|
||||
data-toggle="modal"
|
||||
data-target="#revoke-api-key-modal"
|
||||
|
@ -76,8 +85,8 @@
|
|||
|
||||
<button
|
||||
type="submit"
|
||||
name="show_api_key"
|
||||
class="btn btn-default pull-right">Show API key</button>
|
||||
name="show_api_keys"
|
||||
class="btn btn-default pull-right">Show API keys</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
|
@ -87,21 +96,21 @@
|
|||
{% csrf_token %}
|
||||
<button
|
||||
type="submit"
|
||||
name="create_api_key"
|
||||
class="btn btn-default pull-right">Create API key</button>
|
||||
name="create_api_keys"
|
||||
class="btn btn-default pull-right">Create API keys</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if api_key_created %}
|
||||
{% if api_keys_created %}
|
||||
<div class="panel-footer">
|
||||
API key created
|
||||
API keys created
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if api_key_revoked %}
|
||||
{% if api_keys_revoked %}
|
||||
<div class="panel-footer">
|
||||
API key revoked
|
||||
API keys revoked
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -208,13 +217,13 @@
|
|||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal">×</button>
|
||||
<h4 class="remove-check-title">Revoke API Key?</h4>
|
||||
<h4 class="remove-check-title">Revoke API Keys?</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>You are about to revoke the current API key.</p>
|
||||
<p>Afterwards, you can create a new API key, but there will
|
||||
<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
|
||||
key back</strong>.
|
||||
keys back</strong>.
|
||||
</p>
|
||||
<p>Are you sure?</p>
|
||||
</div>
|
||||
|
@ -222,8 +231,8 @@
|
|||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||
<button
|
||||
type="submit"
|
||||
name="revoke_api_key"
|
||||
class="btn btn-danger">Revoke API Key</button>
|
||||
name="revoke_api_keys"
|
||||
class="btn btn-danger">Revoke API Keys</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
|
Loading…
Add table
Reference in a new issue