diff --git a/README.md b/README.md index 67acad8..fe4dfe4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A security shield for protecting a number of small web applications. ## Requirements Python 3.7+ PostgreSQL 11.5+ -Python packages: `wheel gunicorn aiohttp aiohttp_jinja2 asyncpg passlib argon2_cffi uvloop` +Python packages: `wheel gunicorn aiohttp aiohttp_jinja2 asyncpg passlib argon2_cffi uvloop fido2` ## Install ``` diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..9a008bc --- /dev/null +++ b/auth.py @@ -0,0 +1,217 @@ +#!/usr/bin/env python3 +""" +Endpoints for registering and authenticating FIDO2 security keys. +""" +import json +import secrets +import functools +from datetime import datetime + +from aiohttp import web +from fido2.client import ClientData +from fido2.server import Fido2Server, RelyingParty +from fido2.ctap2 import AttestationObject, AuthenticatorData, \ + AttestedCredentialData +from fido2 import cbor + +import config + +routes = web.RouteTableDef() +rp = RelyingParty(config.server_domain, 'Buckler') +server = Fido2Server(rp) + +def auth_required(func): + """ + Wrapper for views with should be protected by authtication. + """ + @functools.wraps(func) + async def wrapper(request, *args, **kwargs): + login_url = request.app.router['login'].url_for() + sid = request.cookies.get('session') + try: + user_id = int(request.cookies.get('userid', '0')) + except (ValueError, TypeError): + user_id = None + if not sid or not user_id: + raise web.HTTPFound(location=login_url) + async with request.app['pool'].acquire() as conn: + session = await conn.fetchrow( + "SELECT user_info.id, user_info.username, user_info.admin, " + "user_session.last_used " + "FROM user_info LEFT JOIN user_session " + "ON (user_info.id = user_session.user_id) " + "WHERE user_info.id = $1 AND user_session.id = $2 " + "AND user_session.expires > NOW()", + user_id, sid) + if session: + request['session'] = dict(session) + resp = await func(request, *args, **kwargs) + tz = session['last_used'].tzinfo + delta = datetime.now(tz) - session['last_used'] + if delta.seconds > 600: + async with request.app['pool'].acquire() as conn: + await conn.execute( + "UPDATE user_session SET last_used = NOW(), " + "expires = NOW() + INTERVAL '30 DAYS' " + "WHERE user_id = $1", + user_id) + resp.set_cookie( + 'userid', + user_id, + max_age=30*24*60*60, + secure=True, + httponly=True) + resp.set_cookie( + 'session', + sid, + max_age=30*24*60*60, + secure=True, + httponly=True) + return resp + else: + raise web.HTTPFound(location=login_url) + return wrapper + + +@routes.post(config.url_prefix + '/register/begin', name='register_begin') +@auth_required +async def register_begin(request): + user_id = request['session']['id'] + username = request['session']['username'] + + async with request.app['pool'].acquire() as conn: + exist_cred = await conn.fetch( + "SELECT * FROM user_credential WHERE user_id = $1", + user_id) + exist_cred = [AttestedCredentialData(c['credential']) for c in exist_cred] + + registration_data, state = server.register_begin({ + 'id': str(user_id).encode('utf8'), + 'name': username, + 'displayName': username, + }, exist_cred, user_verification='discouraged') + + resp = web.Response(body=cbor.encode(registration_data)) + resp.set_cookie('state', json.dumps(state)) + return resp + + +@routes.post(config.url_prefix + '/register/complete',name='register_complete') +@auth_required +async def register_complete(request): + user_id = request['session']['id'] + username = request['session']['username'] + + data = await request.read() + data = cbor.decode(data) + client_data = ClientData(data['clientDataJSON']) + att_obj = AttestationObject(data['attestationObject']) + + nick = data['security_key_nick'] + try: + assert 64 >= len(nick) >= 1 + except AssertionError: + d = {'ok': False, + 'status': 400, + 'message': "security key nick must be between 1 and 64 chars" + } + return web.json_response(d, status=400) + + state = json.loads(request.cookies.get('state')) + auth_data = server.register_complete( + state, + client_data, + att_obj + ) + + async with request.app['pool'].acquire() as conn: + await conn.execute( + "INSERT INTO user_credential (user_id, nick, credential) " + "VALUES ($1, $2, $3)", + user_id, nick, auth_data.credential_data) + + resp = web.json_response({'ok': True}) + resp.set_cookie('state', '', max_age=0) + return resp + + +@routes.post(config.url_prefix + '/authenticate/begin', + name='authenticate_begin') +async def authenticate_begin(request): + user_id = int(request.cookies.get('userid')) + if not user_id: + raise web.HTTPUnauthorized + data = await request.read() + data = cbor.decode(data) + async with request.app['pool'].acquire() as conn: + credentials = await conn.fetch( + "SELECT * FROM user_credential WHERE user_id = $1", + user_id) + + credentials =[AttestedCredentialData(c['credential']) for c in credentials] + + auth_data, state = server.authenticate_begin(credentials) + resp = web.Response(body=cbor.encode(auth_data)) + resp.set_cookie('state', json.dumps(state)) + return resp + + +@routes.post(config.url_prefix + '/authenticate/complete', + name='authenticate_complete') +async def authenticate_complete(request): + user_id = int(request.cookies.get('userid')) + + async with request.app['pool'].acquire() as conn: + credentials = await conn.fetch( + "SELECT * FROM user_credential WHERE user_id = $1", + user_id) + credentials =[AttestedCredentialData(c['credential']) for c in credentials] + + data = await request.read() + data = cbor.decode(data) + credential_id = data['credentialId'] + client_data = ClientData(data['clientDataJSON']) + auth_data = AuthenticatorData(data['authenticatorData']) + signature = data['signature'] + + state = json.loads(request.cookies.get('state')) + server.authenticate_complete( + state, + credentials, + credential_id, + client_data, + auth_data, + signature + ) + + # TODO: refactor so code isn't duplicated + url = request.cookies.get('redirect') + if not url: + url = request.app.router['index'].url_for() + resp = web.json_response({'ok': True, 'redirect': str(url)}) + resp.set_cookie('state', '', max_age=0) + + resp.set_cookie('redirect', '', max_age=0) + resp.set_cookie( + 'userid', + user_id, + max_age=30*24*60*60, + secure=True, + httponly=True) + sid = secrets.token_urlsafe(64) + ip_address = request.headers['X-Real-IP'] + async with request.app['pool'].acquire() as conn: + await conn.execute( + "INSERT INTO user_session (user_id, id, ip_address)" + "VALUES ($1, $2, $3)", + user_id, + sid, + ip_address) + resp.set_cookie( + 'session', + sid, + max_age=30*24*60*60, + secure=True, + httponly=True) + + return resp diff --git a/buckler.py b/buckler.py index 0f58f8b..0988958 100644 --- a/buckler.py +++ b/buckler.py @@ -4,7 +4,6 @@ A security shield for protecting a number of small web applications. """ import json import secrets -import functools from datetime import datetime from collections import defaultdict @@ -16,6 +15,7 @@ from passlib.hash import argon2 import asyncpg import uvloop +import auth import mail import config import validation @@ -23,61 +23,8 @@ import validation uvloop.install() routes = web.RouteTableDef() -def auth_required(func): - """ - Wrapper for views with should be protected by authtication. - """ - @functools.wraps(func) - async def wrapper(request, *args, **kwargs): - login_url = request.app.router['login'].url_for() - sid = request.cookies.get('session') - try: - user_id = int(request.cookies.get('userid', '0')) - except (ValueError, TypeError): - user_id = None - if not sid or not user_id: - raise web.HTTPFound(location=login_url) - async with request.app['pool'].acquire() as conn: - session = await conn.fetchrow( - "SELECT user_info.id, user_info.username, user_info.admin, " - "user_session.last_used " - "FROM user_info LEFT JOIN user_session " - "ON (user_info.id = user_session.user_id) " - "WHERE user_info.id = $1 AND user_session.id = $2 " - "AND user_session.expires > NOW()", - user_id, sid) - if session: - request['session'] = dict(session) - resp = await func(request, *args, **kwargs) - tz = session['last_used'].tzinfo - delta = datetime.now(tz) - session['last_used'] - if delta.seconds > 600: - async with request.app['pool'].acquire() as conn: - await conn.execute( - "UPDATE user_session SET last_used = NOW(), " - "expires = NOW() + INTERVAL '30 DAYS' " - "WHERE user_id = $1", - user_id) - resp.set_cookie( - 'userid', - user_id, - max_age=30*24*60*60, - secure=True, - httponly=True) - resp.set_cookie( - 'session', - sid, - max_age=30*24*60*60, - secure=True, - httponly=True) - return resp - else: - raise web.HTTPFound(location=login_url) - return wrapper - - @routes.get(config.url_prefix + '/', name='index') -@auth_required +@auth.auth_required async def index(request): """The index page.""" async with request.app['pool'].acquire() as conn: @@ -94,6 +41,9 @@ async def index(request): "SELECT user_info.id, user_info.username, app_user.app_id " "FROM user_info LEFT JOIN app_user " "ON (user_info.id = app_user.user_id)") + fido2_keys = await conn.fetch( + "SELECT * FROM user_credential WHERE user_id = $1", + request['session']['id']) if request['session']['admin']: apps = [app['name'] for app in apps] @@ -104,6 +54,13 @@ async def index(request): return render_template("index.html", request, locals()) +@routes.get(config.url_prefix + '/change_password', name='change_password') +@auth.auth_required +async def change_password(request): + """Allows a user to change their password.""" + pass + + @routes.get(config.url_prefix + '/login', name='login') @routes.post(config.url_prefix + '/login', name='login') async def login(request): @@ -120,12 +77,29 @@ async def login(request): user_info = await conn.fetchrow( "SELECT * FROM user_info WHERE username = $1", username) + if user_info: + has_cred = await conn.fetchrow( + "SELECT EXISTS(SELECT id FROM user_credential " + "WHERE user_id = $1)", + user_info['id']) if not user_info: login_failed = True return render_template("login.html", request, locals()) + if has_cred['exists'] and user_info['passwordless']: + url_prefix = config.url_prefix + resp = render_template("login_key.html", request, locals()) + resp.set_cookie('userid', user_info['id']) + return resp + if argon2.verify(password, user_info['password_hash']): + if has_cred['exists']: + url_prefix = config.url_prefix + resp = render_template("login_key.html", request, locals()) + resp.set_cookie('userid', user_info['id']) + return resp + url = request.cookies.get('redirect') if not url: url = request.app.router['index'].url_for() @@ -218,6 +192,14 @@ async def register(request): return render_template("register_result.html", request, locals()) +@routes.get(config.url_prefix + '/add_key', name='add_key') +@auth.auth_required +async def add_key(request): + """Add a new security key.""" + url_prefix = config.url_prefix + return render_template("register_key.html", request, locals()) + + @routes.get(config.url_prefix + '/get_session', name='get_session') async def get_session(request): """Returns a user's application session.""" @@ -296,6 +278,7 @@ async def init_app(): await conn.execute(file.read()) app.router.add_routes(routes) + app.router.add_routes(auth.routes) return app if __name__ == "__main__": diff --git a/buckler.sql b/buckler.sql index 0cfc576..b576075 100644 --- a/buckler.sql +++ b/buckler.sql @@ -5,7 +5,8 @@ CREATE TABLE IF NOT EXISTS user_info ( password_hash TEXT, date_created TIMESTAMP WITH TIME ZONE DEFAULT NOW(), active BOOLEAN NOT NULL DEFAULT FALSE, - admin BOOLEAN NOT NULL DEFAULT FALSE + admin BOOLEAN NOT NULL DEFAULT FALSE, + passwordless BOOLEAN NOT NULL DEFAULT FALSE ); CREATE TABLE IF NOT EXISTS user_session ( @@ -43,3 +44,10 @@ CREATE TABLE IF NOT EXISTS app_user ( session_data JSON, PRIMARY KEY (user_id, app_id) ); + +CREATE TABLE IF NOT EXISTS user_credential ( + id SERIAL PRIMARY KEY, + user_id INTEGER references user_info(id) ON DELETE CASCADE, + nick TEXT NOT NULL, + credential BYTEA NOT NULL +); diff --git a/static/buckler-auth.js b/static/buckler-auth.js new file mode 100644 index 0000000..7389524 --- /dev/null +++ b/static/buckler-auth.js @@ -0,0 +1,59 @@ +function register() { + fetch(url_prefix + '/register/begin', { + method: 'POST', + }).then(function(response) { + if(!response.ok) { throw new Error('Error getting registration data!'); } + return response.arrayBuffer(); + }).then(CBOR.decode).then(function(options) { + return navigator.credentials.create(options); + }).then(function(attestation) { + return fetch(url_prefix + '/register/complete', { + method: 'POST', + headers: {'Content-Type': 'application/cbor'}, + body: CBOR.encode({ + "attestationObject": new Uint8Array(attestation.response.attestationObject), + "clientDataJSON": new Uint8Array(attestation.response.clientDataJSON), + "security_key_nick": document.querySelector('#security_key_nick').value, + }) + }); + }).then(function(response) { + return response.json(); + }).then(function(json) { + console.log(json); + if (!json.ok) { throw new Error('HTTP error, status = ' + json.status + ', message = ' + json.message); } + window.location = url_prefix + '/login'; + }); +} + +function login() { + fetch(url_prefix + '/authenticate/begin', { + method: 'POST', + headers: {'Content-Type': 'application/cbor'}, + body: CBOR.encode({ + }) + }).then(function(response) { + if(!response.ok) { throw new Error('Error getting registration data!'); } + return response.arrayBuffer(); + }).then(CBOR.decode).then(function(options) { + return navigator.credentials.get(options); + }).then(function(assertion) { + return fetch(url_prefix + '/authenticate/complete', { + method: 'POST', + headers: {'Content-Type': 'application/cbor'}, + body: CBOR.encode({ + "credentialId": new Uint8Array(assertion.rawId), + "authenticatorData": new Uint8Array(assertion.response.authenticatorData), + "clientDataJSON": new Uint8Array(assertion.response.clientDataJSON), + "signature": new Uint8Array(assertion.response.signature) + }) + }); + }, function(reason) { + console.log('navigator.credentials.get() failed for the following reason: ' + reason); + }).then(function(response) { + return response.json(); + }).then(function(json) { + console.log(json); + if (!json.ok) { throw new Error('HTTP error, status = ' + json.status + ', message = ' + json.message); } + window.location = url_prefix + '/'; + }); +} diff --git a/static/buckler.css b/static/buckler.css index d28b286..085c403 100644 --- a/static/buckler.css +++ b/static/buckler.css @@ -18,6 +18,9 @@ header { main { display: grid; gap: 2em; +} + +main section { background-color: whitesmoke; padding: 5%; border-radius: 0.5em; diff --git a/static/cbor.js b/static/cbor.js new file mode 100644 index 0000000..3e1f300 --- /dev/null +++ b/static/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/index.html b/templates/index.html index d26fd90..0c1f393 100644 --- a/templates/index.html +++ b/templates/index.html @@ -14,37 +14,74 @@

Buckler

-
+
Available sites -
+ {% if request['session']['admin'] %} - - - - - {% for app in apps %} - +
+
User{{ app }}
+ + + + {% for app in apps %} + + {% endfor %} + + + + + {% for username, values in users.items() %} + + + {% for value in values %} + + {% endfor %} + + {% endfor %} - - - - {% for username, values in users.items() %} - - - {% for value in values %} - - {% endfor %} - - - {% endfor %} - -
User{{ app }}
{{ username }}
{{ username }}
+ + + {% endif %} +
+
+ +
+ +
+ +
+ +
+
+
+ {% if fido2_keys %} + + + + + + + + + {% for key in fido2_keys %} + + + + + {% endfor %} + +
NickDelete
{{ key['nick'] }}
+ {% else %} + No registered keys. + {% endif %} +
Add key +
diff --git a/templates/login_key.html b/templates/login_key.html new file mode 100644 index 0000000..87e3c14 --- /dev/null +++ b/templates/login_key.html @@ -0,0 +1,21 @@ + + + + Buckler - Login + + + + + + + +
+

Buckler Login

+
+
+
+ Operate your authenticator device now. +
+
+ + diff --git a/templates/register_key.html b/templates/register_key.html new file mode 100644 index 0000000..c6cbb6f --- /dev/null +++ b/templates/register_key.html @@ -0,0 +1,22 @@ + + + + Buckler - Register Security Key + + + + + + +
+
+
+

Register Security Key

+

Upon clicking submit, your security key will begin flashing. Have it ready. +

+

+

+
+
+ +