mirror of
https://github.com/healthchecks/healthchecks.git
synced 2025-04-14 08:58:29 +00:00
Add a "verify number" step in the Signal onboarding flow
This commit is contained in:
parent
39baf36340
commit
8d06a3e896
8 changed files with 192 additions and 12 deletions
hc
static/js
templates/integrations
|
@ -1184,6 +1184,13 @@ class TokenBucket(models.Model):
|
||||||
# 6 messages for a single recipient per minute:
|
# 6 messages for a single recipient per minute:
|
||||||
return TokenBucket.authorize(value, 6, 60)
|
return TokenBucket.authorize(value, 6, 60)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def authorize_signal_verification(user):
|
||||||
|
value = "signal-verify-%d" % user.id
|
||||||
|
|
||||||
|
# 50 signal recipient verifications per day
|
||||||
|
return TokenBucket.authorize(value, 50, 3600 * 24)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def authorize_pushover(user_key):
|
def authorize_pushover(user_key):
|
||||||
salted_encoded = (user_key + settings.SECRET_KEY).encode()
|
salted_encoded = (user_key + settings.SECRET_KEY).encode()
|
||||||
|
|
|
@ -912,8 +912,9 @@ class Signal(Transport):
|
||||||
raise TransportError("Recipient not found")
|
raise TransportError("Recipient not found")
|
||||||
|
|
||||||
if result.get("type") == "RATE_LIMIT_FAILURE" and "token" in result:
|
if result.get("type") == "RATE_LIMIT_FAILURE" and "token" in result:
|
||||||
raw = reply_bytes.decode()
|
if self.channel:
|
||||||
self.channel.send_signal_captcha_alert(result["token"], raw)
|
raw = reply_bytes.decode()
|
||||||
|
self.channel.send_signal_captcha_alert(result["token"], raw)
|
||||||
raise TransportError("CAPTCHA proof required")
|
raise TransportError("CAPTCHA proof required")
|
||||||
|
|
||||||
code = reply["error"].get("code")
|
code = reply["error"].get("code")
|
||||||
|
|
77
hc/front/tests/test_verify_signal_number.py
Normal file
77
hc/front/tests/test_verify_signal_number.py
Normal file
|
@ -0,0 +1,77 @@
|
||||||
|
# coding: utf-8
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
from hc.api.models import TokenBucket
|
||||||
|
from hc.api.transports import TransportError
|
||||||
|
from hc.test import BaseTestCase
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(SIGNAL_CLI_SOCKET="/tmp/socket")
|
||||||
|
class VerifySignalNumberTestCase(BaseTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
|
||||||
|
self.url = "/signal_verify/"
|
||||||
|
|
||||||
|
@patch("hc.front.views.Signal")
|
||||||
|
def test_it_works(self, mock_signal):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"phone": "+1234567890"})
|
||||||
|
self.assertContains(r, "All good, the message was sent")
|
||||||
|
|
||||||
|
@patch("hc.front.views.Signal")
|
||||||
|
def test_it_handles_rate_limit(self, mock_signal):
|
||||||
|
mock_signal.return_value.send.side_effect = TransportError(
|
||||||
|
"CAPTCHA proof required"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"phone": "+1234567890"})
|
||||||
|
self.assertContains(r, "We hit a Signal rate-limit")
|
||||||
|
|
||||||
|
@patch("hc.front.views.Signal")
|
||||||
|
def test_it_handles_recipient_not_found(self, mock_signal):
|
||||||
|
mock_signal.return_value.send.side_effect = TransportError(
|
||||||
|
"Recipient not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"phone": "+1234567890"})
|
||||||
|
self.assertContains(r, "Recipient not found")
|
||||||
|
|
||||||
|
@patch("hc.front.views.Signal")
|
||||||
|
def test_it_handles_unhandled_error(self, mock_signal):
|
||||||
|
mock_signal.return_value.send.side_effect = TransportError(
|
||||||
|
"signal-cli call failed (123)"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"phone": "+1234567890"})
|
||||||
|
self.assertContains(r, "signal-cli call failed")
|
||||||
|
|
||||||
|
@patch("hc.front.views.Signal")
|
||||||
|
def test_it_handles_invalid_phone_number(self, mock_signal):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"phone": "+123"})
|
||||||
|
self.assertContains(r, "Invalid phone number")
|
||||||
|
|
||||||
|
def test_it_requires_post(self):
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.get(self.url)
|
||||||
|
self.assertEqual(r.status_code, 405)
|
||||||
|
|
||||||
|
def test_it_requires_authenticated_user(self):
|
||||||
|
r = self.client.post(self.url, {"phone": "+1234567890"})
|
||||||
|
self.assertRedirects(r, "/accounts/login/?next=" + self.url)
|
||||||
|
|
||||||
|
def test_it_obeys_verification_rate_limit(self):
|
||||||
|
TokenBucket.objects.create(value=f"signal-verify-{self.alice.id}", tokens=0)
|
||||||
|
|
||||||
|
self.client.login(username="alice@example.org", password="password")
|
||||||
|
r = self.client.post(self.url, {"phone": "+1234567890"})
|
||||||
|
self.assertContains(r, "Verification rate limit exceeded")
|
|
@ -114,4 +114,5 @@ urlpatterns = [
|
||||||
path("docs/search/", views.docs_search, name="hc-docs-search"),
|
path("docs/search/", views.docs_search, name="hc-docs-search"),
|
||||||
path("docs/<slug:doc>/", views.serve_doc, name="hc-serve-doc"),
|
path("docs/<slug:doc>/", views.serve_doc, name="hc-serve-doc"),
|
||||||
path("signal_captcha/", views.signal_captcha, name="hc-signal-captcha"),
|
path("signal_captcha/", views.signal_captcha, name="hc-signal-captcha"),
|
||||||
|
path("signal_verify/", views.verify_signal_number, name="hc-signal-verify"),
|
||||||
]
|
]
|
||||||
|
|
|
@ -46,6 +46,7 @@ from hc.api.models import (
|
||||||
Check,
|
Check,
|
||||||
Notification,
|
Notification,
|
||||||
Ping,
|
Ping,
|
||||||
|
TokenBucket,
|
||||||
)
|
)
|
||||||
from hc.api.transports import Signal, Telegram, TransportError
|
from hc.api.transports import Signal, Telegram, TransportError
|
||||||
from hc.front import forms
|
from hc.front import forms
|
||||||
|
@ -2406,4 +2407,28 @@ def signal_captcha(request: HttpRequest) -> HttpResponse:
|
||||||
return render(request, "front/signal_captcha.html", ctx)
|
return render(request, "front/signal_captcha.html", ctx)
|
||||||
|
|
||||||
|
|
||||||
|
@require_setting("SIGNAL_CLI_SOCKET")
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def verify_signal_number(request: HttpRequest):
|
||||||
|
def render_result(result):
|
||||||
|
return render(request, "integrations/signal_result.html", {"result": result})
|
||||||
|
|
||||||
|
if not TokenBucket.authorize_signal_verification(request.user):
|
||||||
|
return render_result("Verification rate limit exceeded")
|
||||||
|
|
||||||
|
form = forms.PhoneNumberForm(request.POST)
|
||||||
|
if not form.is_valid():
|
||||||
|
return render_result("Invalid phone number")
|
||||||
|
|
||||||
|
try:
|
||||||
|
phone = form.cleaned_data["phone"]
|
||||||
|
Signal(None).send(phone, f"Test message from {settings.SITE_NAME}")
|
||||||
|
except TransportError as e:
|
||||||
|
return render_result(e.message)
|
||||||
|
|
||||||
|
# Success!
|
||||||
|
return render_result(None)
|
||||||
|
|
||||||
|
|
||||||
# Forks: add custom views after this line
|
# Forks: add custom views after this line
|
||||||
|
|
24
static/js/signal_form.js
Normal file
24
static/js/signal_form.js
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
$(function() {
|
||||||
|
$("#id_number").on("change keyup", function() {
|
||||||
|
$("#submit-btn").attr("disabled", true);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#verify-btn").click(function() {
|
||||||
|
$("#verify-btn").attr("disabled", true);
|
||||||
|
|
||||||
|
var url = this.dataset.verifyUrl;
|
||||||
|
var token = $('input[name=csrfmiddlewaretoken]').val();
|
||||||
|
$.ajax({
|
||||||
|
url: url,
|
||||||
|
type: "post",
|
||||||
|
headers: {"X-CSRFToken": token},
|
||||||
|
data: {"phone": $("#id_number").val()},
|
||||||
|
success: function(data) {
|
||||||
|
$("#verify-result").html(data);
|
||||||
|
$("#submit-btn").attr("disabled", data.indexOf("alert-success") == -1);
|
||||||
|
$("#verify-btn").attr("disabled", false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
})
|
|
@ -1,5 +1,5 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% load humanize static hc_extras %}
|
{% load compress humanize static hc_extras %}
|
||||||
|
|
||||||
{% block title %}
|
{% block title %}
|
||||||
{% if is_new %}
|
{% if is_new %}
|
||||||
|
@ -47,14 +47,24 @@ Signal Settings - {% site_name %}
|
||||||
|
|
||||||
<div class="form-group {{ form.phone.css_classes }}">
|
<div class="form-group {{ form.phone.css_classes }}">
|
||||||
<label for="id_number" class="col-sm-2 control-label">Phone Number</label>
|
<label for="id_number" class="col-sm-2 control-label">Phone Number</label>
|
||||||
<div class="col-sm-3">
|
<div class="col-sm-6">
|
||||||
<input
|
<div class="input-group">
|
||||||
id="id_number"
|
<input
|
||||||
type="tel"
|
id="id_number"
|
||||||
class="form-control"
|
type="tel"
|
||||||
name="phone"
|
class="form-control"
|
||||||
placeholder="+1234567890"
|
name="phone"
|
||||||
value="{{ form.phone.value|default:"" }}">
|
placeholder="+1234567890"
|
||||||
|
value="{{ form.phone.value|default:"" }}">
|
||||||
|
|
||||||
|
<span class="input-group-btn">
|
||||||
|
<button
|
||||||
|
id="verify-btn"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-default"
|
||||||
|
data-verify-url="{% url 'hc-signal-verify' %}">Send Test Message!</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if form.phone.errors %}
|
{% if form.phone.errors %}
|
||||||
<div class="help-block">
|
<div class="help-block">
|
||||||
|
@ -66,6 +76,8 @@ Signal Settings - {% site_name %}
|
||||||
country code.
|
country code.
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<div id="verify-result"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -100,10 +112,21 @@ Signal Settings - {% site_name %}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-offset-2 col-sm-10">
|
<div class="col-sm-offset-2 col-sm-10">
|
||||||
<button type="submit" class="btn btn-primary">Save Integration</button>
|
<button
|
||||||
|
id="submit-btn"
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
disabled>Save Integration</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{{ block.super }}
|
||||||
|
{% compress js %}
|
||||||
|
<script src="{% static 'js/signal_form.js' %}"></script>
|
||||||
|
{% endcompress %}
|
||||||
|
{% endblock %}
|
||||||
|
|
22
templates/integrations/signal_result.html
Normal file
22
templates/integrations/signal_result.html
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
{% if result is None %}
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<strong>Success!</strong>
|
||||||
|
All good, the message was sent.
|
||||||
|
</div>
|
||||||
|
{% elif result == "CAPTCHA proof required" %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<p>We hit a Signal rate-limit while sending the message.</p>
|
||||||
|
|
||||||
|
<p>Let's try the other way around: from the Signal application, please send a
|
||||||
|
short message to our Signal account.
|
||||||
|
Then click the "Send Test Message!" button again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% elif result == "Recipient not found" %}
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<strong>Recipient not found.</strong>
|
||||||
|
Please make sure you have entered the phone number correctly.
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="alert alert-danger">{{ result }}</div>
|
||||||
|
{% endif %}
|
Loading…
Add table
Reference in a new issue