diff --git a/hc/accounts/migrations/0034_credential.py b/hc/accounts/migrations/0034_credential.py new file mode 100644 index 00000000..b3167f58 --- /dev/null +++ b/hc/accounts/migrations/0034_credential.py @@ -0,0 +1,27 @@ +# Generated by Django 3.1.2 on 2020-11-12 13:39 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('accounts', '0033_member_rw'), + ] + + operations = [ + migrations.CreateModel( + name='Credential', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.UUIDField(default=uuid.uuid4, unique=True)), + ('name', models.CharField(blank=True, max_length=200)), + ('data', models.BinaryField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/hc/accounts/models.py b/hc/accounts/models.py index 35b13004..939fb1b1 100644 --- a/hc/accounts/models.py +++ b/hc/accounts/models.py @@ -11,6 +11,7 @@ from django.db import models from django.db.models import Count, Q from django.urls import reverse from django.utils import timezone +from fido2.ctap2 import AttestedCredentialData from hc.lib import emails from hc.lib.date import month_boundaries @@ -390,3 +391,14 @@ class Member(models.Model): def can_accept(self): return self.user.profile.can_accept(self.project) + + +class Credential(models.Model): + code = models.UUIDField(default=uuid.uuid4, unique=True) + name = models.CharField(max_length=200, blank=True) + user = models.ForeignKey(User, models.CASCADE, related_name="credentials") + data = models.BinaryField() + + def unpack(self): + unpacked, remaining_data = AttestedCredentialData.unpack_from(self.data) + return unpacked diff --git a/hc/accounts/urls.py b/hc/accounts/urls.py index 4e6643c4..b5e20be0 100644 --- a/hc/accounts/urls.py +++ b/hc/accounts/urls.py @@ -23,4 +23,5 @@ urlpatterns = [ path("set_password//", views.set_password, name="hc-set-password"), path("change_email/done/", views.change_email_done, name="hc-change-email-done"), path("change_email//", views.change_email, name="hc-change-email"), + path("two_factor/add/", views.add_credential, name="hc-add-credential"), ] diff --git a/hc/accounts/views.py b/hc/accounts/views.py index c844352f..35a78266 100644 --- a/hc/accounts/views.py +++ b/hc/accounts/views.py @@ -1,3 +1,4 @@ +import base64 from datetime import timedelta as td from urllib.parse import urlparse import uuid @@ -17,8 +18,13 @@ from django.utils.timezone import now from django.urls import resolve, Resolver404 from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_POST +from fido2.client import ClientData +from fido2.ctap2 import AttestationObject +from fido2.server import Fido2Server +from fido2.webauthn import PublicKeyCredentialRpEntity +from fido2 import cbor from hc.accounts import forms -from hc.accounts.models import Profile, Project, Member +from hc.accounts.models import Credential, Profile, Project, Member from hc.api.models import Channel, Check, TokenBucket from hc.lib.date import choose_next_report_date from hc.payments.models import Subscription @@ -176,8 +182,8 @@ def check_token(request, username, token): # To work around this, we sign user in if the method is POST # *or* if the browser presents a cookie we had set when sending the login link. # - # If the method is GET, we instead serve a HTML form and a piece - # of Javascript to automatically submit it. + # If the method is GET and the auto-login cookie isn't present, we serve + # a HTML form with a submit button. if request.method == "POST" or "auto-login" in request.COOKIES: user = authenticate(username=username, token=token) @@ -541,3 +547,54 @@ def remove_project(request, code): project = get_object_or_404(Project, code=code, owner=request.user) project.delete() return redirect("hc-index") + + +def _verify_origin(aaa): + return lambda o: True + + +@login_required +def add_credential(request): + rp = PublicKeyCredentialRpEntity("localhost", "Healthchecks") + # FIXME use HTTPS, remove the verify_origin hack + server = Fido2Server(rp, verify_origin=_verify_origin) + + def decode(form, key): + return base64.b64decode(request.POST[key].encode()) + + if request.method == "POST": + # idea: use AddCredentialForm + client_data = ClientData(decode(request.POST, "clientDataJSON")) + att_obj = AttestationObject(decode(request.POST, "attestationObject")) + print("clientData", client_data) + print("AttestationObject:", att_obj) + + auth_data = server.register_complete( + request.session["state"], client_data, att_obj + ) + + c = Credential(user=request.user) + c.name = request.POST["name"] + c.data = auth_data.credential_data + c.save() + + print("REGISTERED CREDENTIAL:", auth_data.credential_data) + return render(request, "accounts/success.html") + + credentials = [c.unpack() for c in request.user.credentials.all()] + print(credentials) + + options, state = server.register_begin( + { + "id": request.user.username.encode(), + "name": request.user.email, + "displayName": request.user.email, + }, + credentials, + ) + + request.session["state"] = state + + # FIXME: avoid using cbor and cbor.js? + ctx = {"options": base64.b64encode(cbor.encode(options)).decode()} + return render(request, "accounts/add_credential.html", ctx) diff --git a/requirements.txt b/requirements.txt index 5f5c8a5f..0a07e899 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ cron-descriptor==1.2.24 croniter==0.3.31 Django==3.1.2 django-compressor==2.4 +fido2 psycopg2==2.8.4 pytz==2020.1 requests==2.23.0 diff --git a/static/js/add_credential.js b/static/js/add_credential.js new file mode 100644 index 00000000..32559cba --- /dev/null +++ b/static/js/add_credential.js @@ -0,0 +1,22 @@ +$(function() { + var form = document.getElementById("add-credential-form"); + var optionsBinary = btoa(form.dataset.options); + var array = Uint8Array.from(atob(form.dataset.options), c => c.charCodeAt(0)); + var options = CBOR.decode(array.buffer); + console.log("decoded options:", options); + + function b64(arraybuffer) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(arraybuffer))); + } + + navigator.credentials.create(options).then(function(attestation) { + console.log("got attestation: ", attestation); + + document.getElementById("attestationObject").value = b64(attestation.response.attestationObject); + document.getElementById("clientDataJSON").value = b64(attestation.response.clientDataJSON); + console.log("form updated, all is well"); + $("#add-credential-submit").prop("disabled", ""); + }).catch(function(err) { + console.log("Something went wrong", err); + }); +}); \ No newline at end of file diff --git a/static/js/cbor.js b/static/js/cbor.js new file mode 100644 index 00000000..3e1f300d --- /dev/null +++ b/static/js/cbor.js @@ -0,0 +1,406 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2014-2016 Patrick Gansterer + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +(function(global, undefined) { "use strict"; +var POW_2_24 = 5.960464477539063e-8, + POW_2_32 = 4294967296, + POW_2_53 = 9007199254740992; + +function encode(value) { + var data = new ArrayBuffer(256); + var dataView = new DataView(data); + var lastLength; + var offset = 0; + + function prepareWrite(length) { + var newByteLength = data.byteLength; + var requiredLength = offset + length; + while (newByteLength < requiredLength) + newByteLength <<= 1; + if (newByteLength !== data.byteLength) { + var oldDataView = dataView; + data = new ArrayBuffer(newByteLength); + dataView = new DataView(data); + var uint32count = (offset + 3) >> 2; + for (var i = 0; i < uint32count; ++i) + dataView.setUint32(i << 2, oldDataView.getUint32(i << 2)); + } + + lastLength = length; + return dataView; + } + function commitWrite() { + offset += lastLength; + } + function writeFloat64(value) { + commitWrite(prepareWrite(8).setFloat64(offset, value)); + } + function writeUint8(value) { + commitWrite(prepareWrite(1).setUint8(offset, value)); + } + function writeUint8Array(value) { + var dataView = prepareWrite(value.length); + for (var i = 0; i < value.length; ++i) + dataView.setUint8(offset + i, value[i]); + commitWrite(); + } + function writeUint16(value) { + commitWrite(prepareWrite(2).setUint16(offset, value)); + } + function writeUint32(value) { + commitWrite(prepareWrite(4).setUint32(offset, value)); + } + function writeUint64(value) { + var low = value % POW_2_32; + var high = (value - low) / POW_2_32; + var dataView = prepareWrite(8); + dataView.setUint32(offset, high); + dataView.setUint32(offset + 4, low); + commitWrite(); + } + function writeTypeAndLength(type, length) { + if (length < 24) { + writeUint8(type << 5 | length); + } else if (length < 0x100) { + writeUint8(type << 5 | 24); + writeUint8(length); + } else if (length < 0x10000) { + writeUint8(type << 5 | 25); + writeUint16(length); + } else if (length < 0x100000000) { + writeUint8(type << 5 | 26); + writeUint32(length); + } else { + writeUint8(type << 5 | 27); + writeUint64(length); + } + } + + function encodeItem(value) { + var i; + + if (value === false) + return writeUint8(0xf4); + if (value === true) + return writeUint8(0xf5); + if (value === null) + return writeUint8(0xf6); + if (value === undefined) + return writeUint8(0xf7); + + switch (typeof value) { + case "number": + if (Math.floor(value) === value) { + if (0 <= value && value <= POW_2_53) + return writeTypeAndLength(0, value); + if (-POW_2_53 <= value && value < 0) + return writeTypeAndLength(1, -(value + 1)); + } + writeUint8(0xfb); + return writeFloat64(value); + + case "string": + var utf8data = []; + for (i = 0; i < value.length; ++i) { + var charCode = value.charCodeAt(i); + if (charCode < 0x80) { + utf8data.push(charCode); + } else if (charCode < 0x800) { + utf8data.push(0xc0 | charCode >> 6); + utf8data.push(0x80 | charCode & 0x3f); + } else if (charCode < 0xd800) { + utf8data.push(0xe0 | charCode >> 12); + utf8data.push(0x80 | (charCode >> 6) & 0x3f); + utf8data.push(0x80 | charCode & 0x3f); + } else { + charCode = (charCode & 0x3ff) << 10; + charCode |= value.charCodeAt(++i) & 0x3ff; + charCode += 0x10000; + + utf8data.push(0xf0 | charCode >> 18); + utf8data.push(0x80 | (charCode >> 12) & 0x3f); + utf8data.push(0x80 | (charCode >> 6) & 0x3f); + utf8data.push(0x80 | charCode & 0x3f); + } + } + + writeTypeAndLength(3, utf8data.length); + return writeUint8Array(utf8data); + + default: + var length; + if (Array.isArray(value)) { + length = value.length; + writeTypeAndLength(4, length); + for (i = 0; i < length; ++i) + encodeItem(value[i]); + } else if (value instanceof Uint8Array) { + writeTypeAndLength(2, value.length); + writeUint8Array(value); + } else { + var keys = Object.keys(value); + length = keys.length; + writeTypeAndLength(5, length); + for (i = 0; i < length; ++i) { + var key = keys[i]; + encodeItem(key); + encodeItem(value[key]); + } + } + } + } + + encodeItem(value); + + if ("slice" in data) + return data.slice(0, offset); + + var ret = new ArrayBuffer(offset); + var retView = new DataView(ret); + for (var i = 0; i < offset; ++i) + retView.setUint8(i, dataView.getUint8(i)); + return ret; +} + +function decode(data, tagger, simpleValue) { + var dataView = new DataView(data); + var offset = 0; + + if (typeof tagger !== "function") + tagger = function(value) { return value; }; + if (typeof simpleValue !== "function") + simpleValue = function() { return undefined; }; + + function commitRead(length, value) { + offset += length; + return value; + } + function readArrayBuffer(length) { + return commitRead(length, new Uint8Array(data, offset, length)); + } + function readFloat16() { + var tempArrayBuffer = new ArrayBuffer(4); + var tempDataView = new DataView(tempArrayBuffer); + var value = readUint16(); + + var sign = value & 0x8000; + var exponent = value & 0x7c00; + var fraction = value & 0x03ff; + + if (exponent === 0x7c00) + exponent = 0xff << 10; + else if (exponent !== 0) + exponent += (127 - 15) << 10; + else if (fraction !== 0) + return (sign ? -1 : 1) * fraction * POW_2_24; + + tempDataView.setUint32(0, sign << 16 | exponent << 13 | fraction << 13); + return tempDataView.getFloat32(0); + } + function readFloat32() { + return commitRead(4, dataView.getFloat32(offset)); + } + function readFloat64() { + return commitRead(8, dataView.getFloat64(offset)); + } + function readUint8() { + return commitRead(1, dataView.getUint8(offset)); + } + function readUint16() { + return commitRead(2, dataView.getUint16(offset)); + } + function readUint32() { + return commitRead(4, dataView.getUint32(offset)); + } + function readUint64() { + return readUint32() * POW_2_32 + readUint32(); + } + function readBreak() { + if (dataView.getUint8(offset) !== 0xff) + return false; + offset += 1; + return true; + } + function readLength(additionalInformation) { + if (additionalInformation < 24) + return additionalInformation; + if (additionalInformation === 24) + return readUint8(); + if (additionalInformation === 25) + return readUint16(); + if (additionalInformation === 26) + return readUint32(); + if (additionalInformation === 27) + return readUint64(); + if (additionalInformation === 31) + return -1; + throw "Invalid length encoding"; + } + function readIndefiniteStringLength(majorType) { + var initialByte = readUint8(); + if (initialByte === 0xff) + return -1; + var length = readLength(initialByte & 0x1f); + if (length < 0 || (initialByte >> 5) !== majorType) + throw "Invalid indefinite length element"; + return length; + } + + function appendUtf16Data(utf16data, length) { + for (var i = 0; i < length; ++i) { + var value = readUint8(); + if (value & 0x80) { + if (value < 0xe0) { + value = (value & 0x1f) << 6 + | (readUint8() & 0x3f); + length -= 1; + } else if (value < 0xf0) { + value = (value & 0x0f) << 12 + | (readUint8() & 0x3f) << 6 + | (readUint8() & 0x3f); + length -= 2; + } else { + value = (value & 0x0f) << 18 + | (readUint8() & 0x3f) << 12 + | (readUint8() & 0x3f) << 6 + | (readUint8() & 0x3f); + length -= 3; + } + } + + if (value < 0x10000) { + utf16data.push(value); + } else { + value -= 0x10000; + utf16data.push(0xd800 | (value >> 10)); + utf16data.push(0xdc00 | (value & 0x3ff)); + } + } + } + + function decodeItem() { + var initialByte = readUint8(); + var majorType = initialByte >> 5; + var additionalInformation = initialByte & 0x1f; + var i; + var length; + + if (majorType === 7) { + switch (additionalInformation) { + case 25: + return readFloat16(); + case 26: + return readFloat32(); + case 27: + return readFloat64(); + } + } + + length = readLength(additionalInformation); + if (length < 0 && (majorType < 2 || 6 < majorType)) + throw "Invalid length"; + + switch (majorType) { + case 0: + return length; + case 1: + return -1 - length; + case 2: + if (length < 0) { + var elements = []; + var fullArrayLength = 0; + while ((length = readIndefiniteStringLength(majorType)) >= 0) { + fullArrayLength += length; + elements.push(readArrayBuffer(length)); + } + var fullArray = new Uint8Array(fullArrayLength); + var fullArrayOffset = 0; + for (i = 0; i < elements.length; ++i) { + fullArray.set(elements[i], fullArrayOffset); + fullArrayOffset += elements[i].length; + } + return fullArray; + } + return readArrayBuffer(length); + case 3: + var utf16data = []; + if (length < 0) { + while ((length = readIndefiniteStringLength(majorType)) >= 0) + appendUtf16Data(utf16data, length); + } else + appendUtf16Data(utf16data, length); + return String.fromCharCode.apply(null, utf16data); + case 4: + var retArray; + if (length < 0) { + retArray = []; + while (!readBreak()) + retArray.push(decodeItem()); + } else { + retArray = new Array(length); + for (i = 0; i < length; ++i) + retArray[i] = decodeItem(); + } + return retArray; + case 5: + var retObject = {}; + for (i = 0; i < length || length < 0 && !readBreak(); ++i) { + var key = decodeItem(); + retObject[key] = decodeItem(); + } + return retObject; + case 6: + return tagger(decodeItem(), length); + case 7: + switch (length) { + case 20: + return false; + case 21: + return true; + case 22: + return null; + case 23: + return undefined; + default: + return simpleValue(length); + } + } + } + + var ret = decodeItem(); + if (offset !== data.byteLength) + throw "Remaining bytes"; + return ret; +} + +var obj = { encode: encode, decode: decode }; + +if (typeof define === "function" && define.amd) + define("cbor/cbor", obj); +else if (typeof module !== "undefined" && module.exports) + module.exports = obj; +else if (!global.CBOR) + global.CBOR = obj; + +})(this); diff --git a/templates/accounts/add_credential.html b/templates/accounts/add_credential.html new file mode 100644 index 00000000..49b60d9d --- /dev/null +++ b/templates/accounts/add_credential.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} +{% load compress static %} + +{% block content %} +

Add Credential

+ +{{ registration_dict|json_script:"registration" }} + +
+ {% csrf_token %} + + + +
+ + +
+ Give this credential a descriptive name. Example: "My primary Yubikey" +
+
+ + +
+ +{% endblock %} + +{% block scripts %} +{% compress js %} + + + + +{% endcompress %} +{% endblock %} diff --git a/templates/accounts/profile.html b/templates/accounts/profile.html index 34edeeaa..065257a7 100644 --- a/templates/accounts/profile.html +++ b/templates/accounts/profile.html @@ -59,6 +59,35 @@ +
+
+
+ {% csrf_token %} +

Two Factor Authentication

+ {% if profile.user.credentials.exists %} + + {% for credential in profile.user.credentials.all %} + + + + + {% endfor %} +
{{ credential.code }}{{ credential.name|default:"unnamed" }}
+ + {% else %} +

+ Your account has no registered two factor authentication + methods. +

+ {% endif %} + Add 2FA Credential +
+
+
+ +
{% csrf_token %} diff --git a/templates/accounts/success.html b/templates/accounts/success.html new file mode 100644 index 00000000..07cb3c4c --- /dev/null +++ b/templates/accounts/success.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} + +{% block content %} +

Success

+{% endblock %} \ No newline at end of file