forked from GithubBackups/healthchecks
Add read-only API key support
This commit is contained in:
parent
182f9e1109
commit
432e592e44
@ -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
|
- Add "List-Unsubscribe" header to alert and report emails
|
||||||
- Don't send monthly reports to inactive accounts (no pings in 6 months)
|
- Don't send monthly reports to inactive accounts (no pings in 6 months)
|
||||||
- Add search box in the "My Checks" page
|
- Add search box in the "My Checks" page
|
||||||
- Refactor API key checking code
|
- Add read-only API key support
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
- During DST transition, handle ambiguous dates as pre-transition
|
- 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)
|
ping_log_limit = models.IntegerField(default=100)
|
||||||
check_limit = models.IntegerField(default=20)
|
check_limit = models.IntegerField(default=20)
|
||||||
token = models.CharField(max_length=128, blank=True)
|
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 = 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)
|
current_team = models.ForeignKey("self", models.SET_NULL, null=True)
|
||||||
bill_to = models.TextField(blank=True)
|
bill_to = models.TextField(blank=True)
|
||||||
last_sms_date = models.DateTimeField(null=True, 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)
|
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 = urlsafe_b64encode(os.urandom(24)).decode()
|
||||||
|
self.api_key_readonly = urlsafe_b64encode(os.urandom(24)).decode()
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
def checks_from_all_teams(self):
|
def checks_from_all_teams(self):
|
||||||
|
@ -30,7 +30,7 @@ class ProfileTestCase(BaseTestCase):
|
|||||||
def test_it_creates_api_key(self):
|
def test_it_creates_api_key(self):
|
||||||
self.client.login(username="alice@example.org", password="password")
|
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)
|
r = self.client.post("/accounts/profile/", form)
|
||||||
self.assertEqual(r.status_code, 200)
|
self.assertEqual(r.status_code, 200)
|
||||||
|
|
||||||
@ -40,14 +40,18 @@ class ProfileTestCase(BaseTestCase):
|
|||||||
self.assertFalse("b'" in api_key)
|
self.assertFalse("b'" in api_key)
|
||||||
|
|
||||||
def test_it_revokes_api_key(self):
|
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")
|
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)
|
r = self.client.post("/accounts/profile/", form)
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
|
|
||||||
self.profile.refresh_from_db()
|
self.profile.refresh_from_db()
|
||||||
self.assertEqual(self.profile.api_key, "")
|
self.assertEqual(self.profile.api_key, "")
|
||||||
|
self.assertEqual(self.profile.api_key_readonly, "")
|
||||||
|
|
||||||
def test_it_sends_report(self):
|
def test_it_sends_report(self):
|
||||||
check = Check(name="Test Check", user=self.alice)
|
check = Check(name="Test Check", user=self.alice)
|
||||||
|
@ -153,7 +153,7 @@ def profile(request):
|
|||||||
ctx = {
|
ctx = {
|
||||||
"page": "profile",
|
"page": "profile",
|
||||||
"profile": profile,
|
"profile": profile,
|
||||||
"show_api_key": False,
|
"show_api_keys": False,
|
||||||
"api_status": "default",
|
"api_status": "default",
|
||||||
"team_status": "default"
|
"team_status": "default"
|
||||||
}
|
}
|
||||||
@ -165,18 +165,20 @@ def profile(request):
|
|||||||
elif "set_password" in request.POST:
|
elif "set_password" in request.POST:
|
||||||
profile.send_set_password_link()
|
profile.send_set_password_link()
|
||||||
return redirect("hc-link-sent")
|
return redirect("hc-link-sent")
|
||||||
elif "create_api_key" in request.POST:
|
elif "create_api_keys" in request.POST:
|
||||||
profile.set_api_key()
|
profile.set_api_keys()
|
||||||
ctx["show_api_key"] = True
|
ctx["show_api_keys"] = True
|
||||||
ctx["api_key_created"] = True
|
ctx["api_keys_created"] = True
|
||||||
ctx["api_status"] = "success"
|
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 = ""
|
||||||
|
profile.api_key_readonly = ""
|
||||||
profile.save()
|
profile.save()
|
||||||
ctx["api_key_revoked"] = True
|
ctx["api_keys_revoked"] = True
|
||||||
ctx["api_status"] = "info"
|
ctx["api_status"] = "info"
|
||||||
elif "show_api_key" in request.POST:
|
elif "show_api_keys" in request.POST:
|
||||||
ctx["show_api_key"] = True
|
ctx["show_api_keys"] = True
|
||||||
elif "invite_team_member" in request.POST:
|
elif "invite_team_member" in request.POST:
|
||||||
if not profile.can_invite():
|
if not profile.can_invite():
|
||||||
return HttpResponseForbidden()
|
return HttpResponseForbidden()
|
||||||
|
@ -2,6 +2,7 @@ import json
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.db.models import Q
|
||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
from hc.lib.jsonschema import ValidationError, validate
|
from hc.lib.jsonschema import ValidationError, validate
|
||||||
|
|
||||||
@ -10,7 +11,7 @@ def error(msg, status=400):
|
|||||||
return JsonResponse({"error": msg}, status=status)
|
return JsonResponse({"error": msg}, status=status)
|
||||||
|
|
||||||
|
|
||||||
def check_api_key(f):
|
def authorize(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def wrapper(request, *args, **kwds):
|
def wrapper(request, *args, **kwds):
|
||||||
if "HTTP_X_API_KEY" in request.META:
|
if "HTTP_X_API_KEY" in request.META:
|
||||||
@ -27,7 +28,28 @@ def check_api_key(f):
|
|||||||
return error("wrong api key", 401)
|
return error("wrong api key", 401)
|
||||||
|
|
||||||
return f(request, *args, **kwds)
|
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
|
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})
|
r = self.post({"api_key": "X" * 32})
|
||||||
self.assertEqual(r.status_code, 403)
|
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()
|
doc = r.json()
|
||||||
self.assertTrue("checks" in doc)
|
self.assertTrue("checks" in doc)
|
||||||
self.assertEqual(len(doc["checks"]), 0)
|
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 django.views.decorators.http import require_POST
|
||||||
|
|
||||||
from hc.api import schemas
|
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.api.models import Check, Notification
|
||||||
from hc.lib.badges import check_signature, get_badge_svg
|
from hc.lib.badges import check_signature, get_badge_svg
|
||||||
|
|
||||||
@ -87,48 +87,56 @@ def _update(check, spec):
|
|||||||
return check
|
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)
|
@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):
|
def checks(request):
|
||||||
if request.method == "GET":
|
if request.method == "GET":
|
||||||
q = Check.objects.filter(user=request.user)
|
return get_checks(request)
|
||||||
|
|
||||||
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})
|
|
||||||
|
|
||||||
elif request.method == "POST":
|
elif request.method == "POST":
|
||||||
created = False
|
return create_check(request)
|
||||||
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)
|
|
||||||
|
|
||||||
# If request is neither GET nor POST, return "405 Method not allowed"
|
|
||||||
return HttpResponse(status=405)
|
return HttpResponse(status=405)
|
||||||
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@validate_json(schemas.check)
|
@validate_json(schemas.check)
|
||||||
@check_api_key
|
@authorize
|
||||||
def update(request, code):
|
def update(request, code):
|
||||||
check = get_object_or_404(Check, code=code)
|
check = get_object_or_404(Check, code=code)
|
||||||
if check.user != request.user:
|
if check.user != request.user:
|
||||||
@ -150,7 +158,7 @@ def update(request, code):
|
|||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
@require_POST
|
@require_POST
|
||||||
@validate_json()
|
@validate_json()
|
||||||
@check_api_key
|
@authorize
|
||||||
def pause(request, code):
|
def pause(request, code):
|
||||||
check = get_object_or_404(Check, code=code)
|
check = get_object_or_404(Check, code=code)
|
||||||
if check.user != request.user:
|
if check.user != request.user:
|
||||||
|
@ -61,8 +61,17 @@
|
|||||||
<div class="panel-body settings-block">
|
<div class="panel-body settings-block">
|
||||||
<h2>API Access</h2>
|
<h2>API Access</h2>
|
||||||
{% if profile.api_key %}
|
{% if profile.api_key %}
|
||||||
{% if show_api_key %}
|
{% if show_api_keys %}
|
||||||
API key: <code>{{ profile.api_key }}</code>
|
<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
|
<button
|
||||||
data-toggle="modal"
|
data-toggle="modal"
|
||||||
data-target="#revoke-api-key-modal"
|
data-target="#revoke-api-key-modal"
|
||||||
@ -76,8 +85,8 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
name="show_api_key"
|
name="show_api_keys"
|
||||||
class="btn btn-default pull-right">Show API key</button>
|
class="btn btn-default pull-right">Show API keys</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
@ -87,21 +96,21 @@
|
|||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
name="create_api_key"
|
name="create_api_keys"
|
||||||
class="btn btn-default pull-right">Create API key</button>
|
class="btn btn-default pull-right">Create API keys</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if api_key_created %}
|
{% if api_keys_created %}
|
||||||
<div class="panel-footer">
|
<div class="panel-footer">
|
||||||
API key created
|
API keys created
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% if api_key_revoked %}
|
{% if api_keys_revoked %}
|
||||||
<div class="panel-footer">
|
<div class="panel-footer">
|
||||||
API key revoked
|
API keys revoked
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@ -208,13 +217,13 @@
|
|||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<button type="button" class="close" data-dismiss="modal">×</button>
|
<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>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>You are about to revoke the current API key.</p>
|
<p>You are about to revoke your current API keys.</p>
|
||||||
<p>Afterwards, you can create a new API key, but there will
|
<p>Afterwards, you can create new API keys, but there will
|
||||||
be <strong>no way of getting the current API
|
be <strong>no way of getting the current API
|
||||||
key back</strong>.
|
keys back</strong>.
|
||||||
</p>
|
</p>
|
||||||
<p>Are you sure?</p>
|
<p>Are you sure?</p>
|
||||||
</div>
|
</div>
|
||||||
@ -222,8 +231,8 @@
|
|||||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
name="revoke_api_key"
|
name="revoke_api_keys"
|
||||||
class="btn btn-danger">Revoke API Key</button>
|
class="btn btn-danger">Revoke API Keys</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user