0
0
Fork 0
mirror of https://github.com/healthchecks/healthchecks.git synced 2025-04-06 21:58:48 +00:00

Refactor webauthn implementation, use webauthn-json

This commit is contained in:
Pēteris Caune 2022-06-19 10:10:57 +03:00
parent 64a6245736
commit 57021e962c
No known key found for this signature in database
GPG key ID: E28D7679E9A9EDE2
8 changed files with 356 additions and 135 deletions

View file

@ -1,9 +1,6 @@
import base64
import binascii
from datetime import timedelta as td from datetime import timedelta as td
from django import forms from django import forms
from django.core.exceptions import ValidationError
from django.contrib.auth import authenticate from django.contrib.auth import authenticate
from django.contrib.auth.models import User from django.contrib.auth.models import User
from hc.accounts.models import REPORT_CHOICES, Member from hc.accounts.models import REPORT_CHOICES, Member
@ -17,17 +14,6 @@ class LowercaseEmailField(forms.EmailField):
return value.lower() return value.lower()
class Base64Field(forms.CharField):
def to_python(self, value):
if value is None:
return None
try:
return base64.b64decode(value.encode())
except binascii.Error:
raise ValidationError(message="Cannot decode base64")
class SignupForm(forms.Form): class SignupForm(forms.Form):
# Call it "identity" instead of "email" # Call it "identity" instead of "email"
# to avoid some of the dumber bots # to avoid some of the dumber bots
@ -153,15 +139,11 @@ class TransferForm(forms.Form):
class AddWebAuthnForm(forms.Form): class AddWebAuthnForm(forms.Form):
name = forms.CharField(max_length=100) name = forms.CharField(max_length=100)
client_data_json = Base64Field() response = forms.CharField()
attestation_object = Base64Field()
class WebAuthnForm(forms.Form): class WebAuthnForm(forms.Form):
credential_id = Base64Field() response = forms.CharField()
client_data_json = Base64Field()
authenticator_data = Base64Field()
signature = Base64Field()
class TotpForm(forms.Form): class TotpForm(forms.Form):

View file

@ -1,6 +1,5 @@
import base64
from datetime import timedelta as td from datetime import timedelta as td
from secrets import token_bytes, token_urlsafe from secrets import token_urlsafe
from urllib.parse import urlparse from urllib.parse import urlparse
import time import time
import uuid import uuid
@ -20,16 +19,12 @@ from django.utils.timezone import now
from django.urls import resolve, reverse, Resolver404 from django.urls import resolve, reverse, Resolver404
from django.views.decorators.csrf import csrf_exempt 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 fido2.ctap2 import AttestationObject, AuthenticatorData
from fido2.client import ClientData
from fido2.server import Fido2Server
from fido2.webauthn import PublicKeyCredentialRpEntity
from fido2 import cbor
from hc.accounts import forms from hc.accounts import forms
from hc.accounts.decorators import require_sudo_mode from hc.accounts.decorators import require_sudo_mode
from hc.accounts.models import Credential, Profile, Project, Member from hc.accounts.models import Credential, Profile, Project, Member
from hc.api.models import Channel, Check, TokenBucket from hc.api.models import Channel, Check, TokenBucket
from hc.payments.models import Subscription from hc.payments.models import Subscription
from hc.lib.webauthn import Server
import pyotp import pyotp
import segno import segno
@ -46,7 +41,7 @@ POST_LOGIN_ROUTES = (
"hc-uncloak", "hc-uncloak",
) )
FIDO2_SERVER = Fido2Server(PublicKeyCredentialRpEntity(settings.RP_ID, "healthchecks")) WEBAUTHN = Server(settings.RP_ID, "healthchecks")
def _allow_redirect(redirect_url): def _allow_redirect(redirect_url):
@ -667,27 +662,6 @@ def remove_project(request, code):
return redirect("hc-index") return redirect("hc-index")
def _get_credential_data(request, form):
"""Complete WebAuthn registration, return binary credential data.
This function is an interface to the fido2 library. It is separated
out so that we don't need to mock ClientData, AttestationObject,
register_complete separately in tests.
"""
try:
auth_data = FIDO2_SERVER.register_complete(
request.session["state"],
ClientData(form.cleaned_data["client_data_json"]),
AttestationObject(form.cleaned_data["attestation_object"]),
)
except ValueError:
return None
return auth_data.credential_data
@login_required @login_required
@require_sudo_mode @require_sudo_mode
def add_webauthn(request): def add_webauthn(request):
@ -699,42 +673,25 @@ def add_webauthn(request):
if not form.is_valid(): if not form.is_valid():
return HttpResponseBadRequest() return HttpResponseBadRequest()
credential_data = _get_credential_data(request, form) try:
if not credential_data: auth_data = WEBAUTHN.register_complete(
request.session["state"], form.cleaned_data["response"]
)
except ValueError as e:
return HttpResponseBadRequest() return HttpResponseBadRequest()
c = Credential(user=request.user) c = Credential(user=request.user)
c.name = form.cleaned_data["name"] c.name = form.cleaned_data["name"]
c.data = credential_data c.data = auth_data.credential_data
c.save() c.save()
request.session["added_credential_name"] = c.name request.session["added_credential_name"] = c.name
return redirect("hc-profile") return redirect("hc-profile")
credentials = [c.unpack() for c in request.user.credentials.all()] credentials = [c.unpack() for c in request.user.credentials.all()]
# User handle is used in a username-less authentication, to map a credential options, state = WEBAUTHN.register_begin(request.user.email, credentials)
# received from browser with an user account in the database.
# Since we only use security keys as a second factor,
# the user handle is not of much use to us.
#
# The user handle:
# - must not be blank,
# - must not be a constant value,
# - must not contain personally identifiable information.
# So we use random bytes, and don't store them on our end:
options, state = FIDO2_SERVER.register_begin(
{
"id": token_bytes(16),
"name": request.user.email,
"displayName": request.user.email,
},
credentials,
)
request.session["state"] = state request.session["state"] = state
return render(request, "accounts/add_credential.html", {"options": options})
ctx = {"options": base64.b64encode(cbor.encode(options)).decode()}
return render(request, "accounts/add_credential.html", ctx)
@login_required @login_required
@ -811,30 +768,6 @@ def remove_credential(request, code):
return render(request, "accounts/remove_credential.html", ctx) return render(request, "accounts/remove_credential.html", ctx)
def _check_credential(request, form, credentials):
"""Complete WebAuthn authentication, return True on success.
This function is an interface to the fido2 library. It is separated
out so that we don't need to mock ClientData, AuthenticatorData,
authenticate_complete separately in tests.
"""
try:
FIDO2_SERVER.authenticate_complete(
request.session["state"],
credentials,
form.cleaned_data["credential_id"],
ClientData(form.cleaned_data["client_data_json"]),
AuthenticatorData(form.cleaned_data["authenticator_data"]),
form.cleaned_data["signature"],
)
except ValueError:
return False
return True
def login_webauthn(request): def login_webauthn(request):
# We require RP_ID. Fail predicably if it is not set: # We require RP_ID. Fail predicably if it is not set:
if not settings.RP_ID: if not settings.RP_ID:
@ -863,7 +796,13 @@ def login_webauthn(request):
if not form.is_valid(): if not form.is_valid():
return HttpResponseBadRequest() return HttpResponseBadRequest()
if not _check_credential(request, form, credentials): try:
WEBAUTHN.authenticate_complete(
request.session["state"],
credentials,
form.cleaned_data["response"],
)
except ValueError as e:
return HttpResponseBadRequest() return HttpResponseBadRequest()
request.session.pop("state") request.session.pop("state")
@ -871,7 +810,7 @@ def login_webauthn(request):
auth_login(request, user, "hc.accounts.backends.EmailBackend") auth_login(request, user, "hc.accounts.backends.EmailBackend")
return _redirect_after_login(request) return _redirect_after_login(request)
options, state = FIDO2_SERVER.authenticate_begin(credentials) options, state = WEBAUTHN.authenticate_begin(credentials)
request.session["state"] = state request.session["state"] = state
totp_url = None totp_url = None
@ -882,7 +821,7 @@ def login_webauthn(request):
totp_url += "?next=%s" % redirect_url totp_url += "?next=%s" % redirect_url
ctx = { ctx = {
"options": base64.b64encode(cbor.encode(options)).decode(), "options": options,
"totp_url": totp_url, "totp_url": totp_url,
} }
return render(request, "accounts/login_webauthn.html", ctx) return render(request, "accounts/login_webauthn.html", ctx)

86
hc/lib/webauthn.py Normal file
View file

@ -0,0 +1,86 @@
import json
from secrets import token_bytes
from fido2.client import ClientData
from fido2.ctap2 import AttestationObject, AuthenticatorData
from fido2.server import Fido2Server
from fido2.utils import websafe_encode, websafe_decode
from fido2.webauthn import PublicKeyCredentialRpEntity
def bytes_to_b64(obj):
if isinstance(obj, dict):
return {k: bytes_to_b64(v) for k, v in obj.items()}
if isinstance(obj, list):
return [bytes_to_b64(v) for v in obj]
if isinstance(obj, bytes):
return websafe_encode(obj)
return obj
json_decode_map = {
"clientDataJSON": ClientData,
"attestationObject": AttestationObject,
"rawId": bytes,
"authenticatorData": AuthenticatorData,
"signature": bytes,
}
def json_decode_hook(d):
for key, cls in json_decode_map.items():
if key in d:
as_bytes = websafe_decode(d[key])
d[key] = cls(as_bytes)
return d
class Server(object):
def __init__(self, id, name):
self.server = Fido2Server(PublicKeyCredentialRpEntity(id, name))
def register_begin(self, email, credentials):
# User handle is used in a username-less authentication, to map a credential
# received from browser with an user account in the database.
# Since we only use security keys as a second factor,
# the user handle is not of much use to us.
#
# The user handle:
# - must not be blank,
# - must not be a constant value,
# - must not contain personally identifiable information.
# So we use random bytes, and don't store them on our end:
user = {
"id": token_bytes(16),
"name": email,
"displayName": email,
}
options, state = self.server.register_begin(user, credentials)
return bytes_to_b64(options), state
def register_complete(self, state, response_json):
doc = json.loads(response_json, object_hook=json_decode_hook)
return self.server.register_complete(
state,
doc["response"]["clientDataJSON"],
doc["response"]["attestationObject"],
)
def authenticate_begin(self, credentials):
options, state = self.server.authenticate_begin(credentials)
return bytes_to_b64(options), state
def authenticate_complete(self, state, credentials, response_json):
doc = json.loads(response_json, object_hook=json_decode_hook)
return self.server.authenticate_complete(
state,
credentials,
doc["rawId"],
doc["response"]["clientDataJSON"],
doc["response"]["authenticatorData"],
doc["response"]["signature"],
)

View file

@ -1,12 +1,5 @@
$(function() { $(function() {
var form = document.getElementById("add-credential-form"); var form = document.getElementById("add-credential-form");
var optionsBytes = Uint8Array.from(atob(form.dataset.options), c => c.charCodeAt(0));
// cbor.js expects ArrayBuffer as input when decoding
var options = CBOR.decode(optionsBytes.buffer);
function b64(arraybuffer) {
return btoa(String.fromCharCode.apply(null, new Uint8Array(arraybuffer)));
}
function requestCredentials() { function requestCredentials() {
// Hide error & success messages, show the "waiting" message // Hide error & success messages, show the "waiting" message
@ -15,10 +8,9 @@ $(function() {
$("#error").addClass("hide"); $("#error").addClass("hide");
$("#success").addClass("hide"); $("#success").addClass("hide");
navigator.credentials.create(options).then(function(attestation) { var options = JSON.parse($("#options").text());
$("#attestation_object").val(b64(attestation.response.attestationObject)); webauthnJSON.create(options).then(function(response) {
$("#client_data_json").val(b64(attestation.response.clientDataJSON)); $("#response").val(JSON.stringify(response));
// Show the success message and save button // Show the success message and save button
$("#waiting").addClass("hide"); $("#waiting").addClass("hide");
$("#success").removeClass("hide"); $("#success").removeClass("hide");

View file

@ -1,24 +1,14 @@
$(function() { $(function() {
var form = document.getElementById("login-tfa-form"); var form = document.getElementById("login-tfa-form");
var optionsBytes = Uint8Array.from(atob(form.dataset.options), c => c.charCodeAt(0));
// cbor.js expects ArrayBuffer as input when decoding
var options = CBOR.decode(optionsBytes.buffer);
function b64(arraybuffer) {
return btoa(String.fromCharCode.apply(null, new Uint8Array(arraybuffer)));
}
function authenticate() { function authenticate() {
$("#pick-method").addClass("hide"); $("#pick-method").addClass("hide");
$("#waiting").removeClass("hide"); $("#waiting").removeClass("hide");
$("#error").addClass("hide"); $("#error").addClass("hide");
navigator.credentials.get(options).then(function(assertion) { var options = JSON.parse($("#options").text());
$("#credential_id").val(b64(assertion.rawId)); webauthnJSON.get(options).then(function(response) {
$("#authenticator_data").val(b64(assertion.response.authenticatorData)); $("#response").val(JSON.stringify(response));
$("#client_data_json").val(b64(assertion.response.clientDataJSON));
$("#signature").val(b64(assertion.response.signature));
// Show the success message and save button // Show the success message and save button
$("#waiting").addClass("hide"); $("#waiting").addClass("hide");
$("#success").removeClass("hide"); $("#success").removeClass("hide");

View file

@ -0,0 +1,232 @@
(() => {
var __defProp = Object.defineProperty;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __async = (__this, __arguments, generator) => {
return new Promise((resolve, reject) => {
var fulfilled = (value) => {
try {
step(generator.next(value));
} catch (e) {
reject(e);
}
};
var rejected = (value) => {
try {
step(generator.throw(value));
} catch (e) {
reject(e);
}
};
var step = (x) => x.done ? resolve(x.value) : Promise.resolve(x.value).then(fulfilled, rejected);
step((generator = generator.apply(__this, __arguments)).next());
});
};
// src/webauthn-json/index.ts
var webauthn_json_exports = {};
__export(webauthn_json_exports, {
create: () => create,
get: () => get,
schema: () => schema,
supported: () => supported
});
// src/webauthn-json/base64url.ts
function base64urlToBuffer(baseurl64String) {
const padding = "==".slice(0, (4 - baseurl64String.length % 4) % 4);
const base64String = baseurl64String.replace(/-/g, "+").replace(/_/g, "/") + padding;
const str = atob(base64String);
const buffer = new ArrayBuffer(str.length);
const byteView = new Uint8Array(buffer);
for (let i = 0; i < str.length; i++) {
byteView[i] = str.charCodeAt(i);
}
return buffer;
}
function bufferToBase64url(buffer) {
const byteView = new Uint8Array(buffer);
let str = "";
for (const charCode of byteView) {
str += String.fromCharCode(charCode);
}
const base64String = btoa(str);
const base64urlString = base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
return base64urlString;
}
// src/webauthn-json/convert.ts
var copyValue = "copy";
var convertValue = "convert";
function convert(conversionFn, schema2, input) {
if (schema2 === copyValue) {
return input;
}
if (schema2 === convertValue) {
return conversionFn(input);
}
if (schema2 instanceof Array) {
return input.map((v) => convert(conversionFn, schema2[0], v));
}
if (schema2 instanceof Object) {
const output = {};
for (const [key, schemaField] of Object.entries(schema2)) {
if (schemaField.derive) {
const v = schemaField.derive(input);
if (v !== void 0) {
input[key] = v;
}
}
if (!(key in input)) {
if (schemaField.required) {
throw new Error(`Missing key: ${key}`);
}
continue;
}
if (input[key] == null) {
output[key] = null;
continue;
}
output[key] = convert(conversionFn, schemaField.schema, input[key]);
}
return output;
}
}
function derived(schema2, derive) {
return {
required: true,
schema: schema2,
derive
};
}
function required(schema2) {
return {
required: true,
schema: schema2
};
}
function optional(schema2) {
return {
required: false,
schema: schema2
};
}
// src/webauthn-json/basic/schema.ts
var publicKeyCredentialDescriptorSchema = {
type: required(copyValue),
id: required(convertValue),
transports: optional(copyValue)
};
var simplifiedExtensionsSchema = {
appid: optional(copyValue),
appidExclude: optional(copyValue),
credProps: optional(copyValue)
};
var simplifiedClientExtensionResultsSchema = {
appid: optional(copyValue),
appidExclude: optional(copyValue),
credProps: optional(copyValue)
};
var credentialCreationOptions = {
publicKey: required({
rp: required(copyValue),
user: required({
id: required(convertValue),
name: required(copyValue),
displayName: required(copyValue)
}),
challenge: required(convertValue),
pubKeyCredParams: required(copyValue),
timeout: optional(copyValue),
excludeCredentials: optional([publicKeyCredentialDescriptorSchema]),
authenticatorSelection: optional(copyValue),
attestation: optional(copyValue),
extensions: optional(simplifiedExtensionsSchema)
}),
signal: optional(copyValue)
};
var publicKeyCredentialWithAttestation = {
type: required(copyValue),
id: required(copyValue),
rawId: required(convertValue),
authenticatorAttachment: optional(copyValue),
response: required({
clientDataJSON: required(convertValue),
attestationObject: required(convertValue),
transports: derived(copyValue, (response) => {
var _a;
return ((_a = response.getTransports) == null ? void 0 : _a.call(response)) || [];
})
}),
clientExtensionResults: derived(simplifiedClientExtensionResultsSchema, (pkc) => pkc.getClientExtensionResults())
};
var credentialRequestOptions = {
mediation: optional(copyValue),
publicKey: required({
challenge: required(convertValue),
timeout: optional(copyValue),
rpId: optional(copyValue),
allowCredentials: optional([publicKeyCredentialDescriptorSchema]),
userVerification: optional(copyValue),
extensions: optional(simplifiedExtensionsSchema)
}),
signal: optional(copyValue)
};
var publicKeyCredentialWithAssertion = {
type: required(copyValue),
id: required(copyValue),
rawId: required(convertValue),
authenticatorAttachment: optional(copyValue),
response: required({
clientDataJSON: required(convertValue),
authenticatorData: required(convertValue),
signature: required(convertValue),
userHandle: required(convertValue)
}),
clientExtensionResults: derived(simplifiedClientExtensionResultsSchema, (pkc) => pkc.getClientExtensionResults())
};
var schema = {
credentialCreationOptions,
publicKeyCredentialWithAttestation,
credentialRequestOptions,
publicKeyCredentialWithAssertion
};
// src/webauthn-json/basic/api.ts
function createRequestFromJSON(requestJSON) {
return convert(base64urlToBuffer, credentialCreationOptions, requestJSON);
}
function createResponseToJSON(credential) {
return convert(bufferToBase64url, publicKeyCredentialWithAttestation, credential);
}
function create(requestJSON) {
return __async(this, null, function* () {
const credential = yield navigator.credentials.create(createRequestFromJSON(requestJSON));
return createResponseToJSON(credential);
});
}
function getRequestFromJSON(requestJSON) {
return convert(base64urlToBuffer, credentialRequestOptions, requestJSON);
}
function getResponseToJSON(credential) {
return convert(bufferToBase64url, publicKeyCredentialWithAssertion, credential);
}
function get(requestJSON) {
return __async(this, null, function* () {
const credential = yield navigator.credentials.get(getRequestFromJSON(requestJSON));
return getResponseToJSON(credential);
});
}
// src/webauthn-json/basic/supported.ts
function supported() {
return !!(navigator.credentials && navigator.credentials.create && navigator.credentials.get && window.PublicKeyCredential);
}
// src/webauthn-json/browser-global.ts
globalThis.webauthnJSON = webauthn_json_exports;
})();
//# sourceMappingURL=webauthn-json.browser-global.js.map

View file

@ -7,14 +7,12 @@
<form <form
id="add-credential-form" id="add-credential-form"
class="col-sm-6 col-sm-offset-3" class="col-sm-6 col-sm-offset-3"
data-options="{{ options }}"
method="post" method="post"
encrypt="multipart/form-data"> encrypt="multipart/form-data">
<h1>Add Security Key</h1> <h1>Add Security Key</h1>
{% csrf_token %} {% csrf_token %}
<input id="attestation_object" type="hidden" name="attestation_object"> <input id="response" type="hidden" name="response">
<input id="client_data_json" type="hidden" name="client_data_json">
<div class="form-group"> <div class="form-group">
<label for="name">Name</label> <label for="name">Name</label>
@ -81,13 +79,16 @@
</div> </div>
</form> </form>
</div> </div>
{{ options|json_script:"options" }}
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{% compress js %} {% compress js %}
<script src="{% static 'js/jquery-3.6.0.min.js' %}"></script> <script src="{% static 'js/jquery-3.6.0.min.js' %}"></script>
<script src="{% static 'js/bootstrap.min.js' %}"></script> <script src="{% static 'js/bootstrap.min.js' %}"></script>
<script src="{% static 'js/cbor.js' %}"></script> <script src="{% static 'js/webauthn-json.browser-global.js' %}"></script>
<script src="{% static 'js/add_credential.js' %}"></script> <script src="{% static 'js/add_credential.js' %}"></script>
{% endcompress %} {% endcompress %}
{% endblock %} {% endblock %}

View file

@ -7,7 +7,6 @@
<form <form
id="login-tfa-form" id="login-tfa-form"
class="col-sm-6 col-sm-offset-3" class="col-sm-6 col-sm-offset-3"
data-options="{{ options }}"
method="post" method="post"
encrypt="multipart/form-data"> encrypt="multipart/form-data">
<h1>Two-factor Authentication</h1> <h1>Two-factor Authentication</h1>
@ -34,10 +33,7 @@
</div> </div>
{% csrf_token %} {% csrf_token %}
<input id="credential_id" type="hidden" name="credential_id"> <input id="response" type="hidden" name="response">
<input id="authenticator_data" type="hidden" name="authenticator_data">
<input id="client_data_json" type="hidden" name="client_data_json">
<input id="signature" type="hidden" name="signature">
<div id="waiting" class="hide"> <div id="waiting" class="hide">
<h2>Waiting for security key</h2> <h2>Waiting for security key</h2>
@ -73,13 +69,16 @@
</div> </div>
</form> </form>
</div> </div>
{{ options|json_script:"options" }}
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
{% compress js %} {% compress js %}
<script src="{% static 'js/jquery-3.6.0.min.js' %}"></script> <script src="{% static 'js/jquery-3.6.0.min.js' %}"></script>
<script src="{% static 'js/bootstrap.min.js' %}"></script> <script src="{% static 'js/bootstrap.min.js' %}"></script>
<script src="{% static 'js/cbor.js' %}"></script> <script src="{% static 'js/webauthn-json.browser-global.js' %}"></script>
<script src="{% static 'js/login_tfa.js' %}"></script> <script src="{% static 'js/login_tfa.js' %}"></script>
{% endcompress %} {% endcompress %}
{% endblock %} {% endblock %}