diff --git a/.gitignore b/.gitignore index a2f9270..6b11d9a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ __pycache__/ *.swo devices.json config.py +secret_key +cred +juice.db diff --git a/README.md b/README.md index 6f2a152..7a53ec6 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@ A hub for controlling IOT devices. ## Requirements Python 3.6+ -Python packages: `flask gunicorn requests` +Python packages: `flask gunicorn requests passlib argon2_cffi` ## Install 1. Get on the floor 2. Walk the dinosaur ## Usage -`gunicorn -b localhost:5300 -e SCRIPT_NAME=/juice juice:app` +`gunicorn -b localhost:5300 juice:app` diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..fe009ca --- /dev/null +++ b/auth.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +""" +Contains authentication methods for the app. +""" +import os +import functools + +from fido2.client import ClientData +from fido2.server import Fido2Server, RelyingParty +from fido2.ctap2 import AttestationObject, AuthenticatorData, \ + AttestedCredentialData +from fido2 import cbor +from flask import Blueprint, session, render_template, request, abort, \ + redirect, url_for, g +from passlib.hash import argon2 + +import config +import db + +auth_views = Blueprint("auth_views", __name__) + +rp = RelyingParty('steelbea.me', 'Juice') +server = Fido2Server(rp) + + +def auth_required(func): + """ + Wrapper for views which should be protected by authentication. + """ + @functools.wraps(func) + def wrapper(*args, **kwargs): + username = session.get('username') + token = session.get('token') + if not username or not token: + return redirect(url_for('auth_views.login')) + + data = db.get_user(username) + if not data: + session.pop('username') + return redirect(url_for('auth_views.login')) + token_hash = data[2] + if not token_hash: + return redirect(url_for('auth_views.login')) + + if argon2.verify(token, token_hash): + return func(*args, **kwargs) + else: + session.pop('token') + return redirect(url_for('auth_views.login')) + return wrapper + + +@auth_views.route('/api/register/begin', methods=['POST']) +def register_begin(): + if not config.registration_open: + return "Registration is closed." + + username = session.get('username') + if not username: + return 'invalid' + + data = db.get_user(username) + if not data: + session.pop('username') + return 'invalid' + user_id = data[0] + + exist_cred = db.get_credentials(user_id) + exist_cred = [AttestedCredentialData(cd[2]) for cd in exist_cred] + + registration_data, state = server.register_begin({ + 'id': str(user_id).encode('utf8'), + 'name': username, + 'displayName': username, + }, exist_cred, user_verification='discouraged') + + session['state'] = state + return cbor.encode(registration_data) + + +@auth_views.route('/api/register/complete', methods=['POST']) +def register_complete(): + if not config.registration_open: + return "Registration is closed." + + username = session.get('username') + if not username: + return 'invalid' + + data = db.get_user(username) + if not data: + session.pop('username') + return 'invalid' + user_id = data[0] + + data = cbor.decode(request.get_data()) + client_data = ClientData(data['clientDataJSON']) + att_obj = AttestationObject(data['attestationObject']) + + auth_data = server.register_complete( + session.pop('state'), + client_data, + att_obj + ) + + db.set_credential(user_id, auth_data.credential_data) + return cbor.encode({'status': 'OK'}) + + +@auth_views.route('/api/authenticate/begin', methods=['POST']) +def authenticate_begin(): + username = session.get('username') + if not username: + return 'invalid' + + data = db.get_user(session['username']) + if not data: + session.pop('username') + return 'invalid' + user_id = data[0] + + credentials = db.get_credentials(user_id) + credentials = [AttestedCredentialData(cd[2]) for cd in credentials] + + auth_data, state = server.authenticate_begin(credentials) + session['state'] = state + return cbor.encode(auth_data) + + +@auth_views.route('/api/authenticate/complete', methods=['POST']) +def authenticate_complete(): + username = session.get('username') + if not username: + return 'invalid' + + data = db.get_user(session['username']) + if not data: + session.pop('username') + return 'invalid' + user_id = data[0] + + credentials = db.get_credentials(user_id) + credentials = [AttestedCredentialData(cd[2]) for cd in credentials] + + data = cbor.decode(request.get_data()) + credential_id = data['credentialId'] + client_data = ClientData(data['clientDataJSON']) + auth_data = AuthenticatorData(data['authenticatorData']) + signature = data['signature'] + + server.authenticate_complete( + session.pop('state'), + credentials, + credential_id, + client_data, + auth_data, + signature + ) + + token = os.urandom(32) + token_hash = argon2.hash(token) + db.set_user_token(username, token_hash) + session['token'] = token + + return cbor.encode({'status': 'OK'}) + + +@auth_views.route('/register', methods=['GET', 'POST']) +def register(): + """ + Registration page. + """ + if not config.registration_open: + return "Registration is closed." + + if request.method == 'GET': + params = { + 'title': 'Register', + 'heading': 'Register an account', + 'form_url': url_for('auth_views.register'), + } + return render_template('auth.html', **params) + + username = request.form.get('username') + + if len(username) > 64: + return "username too long" + elif len(username) < 3: + return "username too short" + + db.set_user(username) # TODO: handle username error + session['username'] = username + + params = { + 'title': 'Register', + 'heading': 'Register your authenticator', + 'api_begin': url_for('auth_views.register_begin'), + 'api_complete': url_for('auth_views.register_complete'), + } + return render_template('auth_fido.html', **params) + + +@auth_views.route('/login', methods=['GET', 'POST']) +def login(): + """ + Login page. + """ + if request.method == 'GET': + params = { + 'title': 'Login', + 'heading': 'Login', + 'form_url': url_for('auth_views.login'), + } + return render_template('auth.html', **params) + + + username = request.form.get('username') + session['username'] = username + + params = { + 'title': 'Login', + 'heading': 'Login with your authenticator', + 'api_begin': url_for('auth_views.authenticate_begin'), + 'api_complete': url_for('auth_views.authenticate_complete'), + } + return render_template('auth_fido.html', **params) diff --git a/config.py.template b/config.py.template index baa2a9c..59afe73 100644 --- a/config.py.template +++ b/config.py.template @@ -3,5 +3,7 @@ Configuration settings for the Juice IOT hub server. `url_prefix` is the root path you wish app to reside at eg. https://example.com/juice. +`registration_open` whether or not new accounts may be registered. """ url_prefix = '/juice' +registration_open = True diff --git a/db.py b/db.py new file mode 100644 index 0000000..04faf44 --- /dev/null +++ b/db.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +""" +A module for interacting with Juice's database. +""" +import sqlite3 +import threading + + +DB_LOCK = threading.Lock() + + +def init_db(): + """ + Checks to see if the database is initialized yet or not. If not, + the appropriate tables are created. + """ + con = sqlite3.connect('juice.db') + cur = con.cursor() + try: + cur.execute("SELECT * FROM user LIMIT 1").fetchone() + cur.execute("SELECT * FROM credential LIMIT 1").fetchone() + except sqlite3.OperationalError: + cur.execute("CREATE TABLE user(" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "name TEXT UNIQUE, " + "token_hash TEXT" + ")" + ) + cur.execute("CREATE TABLE credential(" + "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "user_id INTEGER REFERENCES user(id) ON UPDATE CASCADE, " + "credential BLOB" + ")" + ) + con.commit() + con.close() +init_db() + + +def db_execute(*args, **kwargs): + """ + Opens a connection to the app's database and executes the SQL + statements passed to this function. + """ + with sqlite3.connect('juice.db') as con: + DB_LOCK.acquire() + cur = con.cursor() + res = cur.execute(*args, **kwargs) + DB_LOCK.release() + return res + + +def set_user(username): + """ + Adds a new user. + """ + db_execute( + "INSERT INTO user(name) VALUES (?)", + (username,) + ) + return True + + +def set_user_token(username, token_hash): + """ + Sets a user's token hash. + """ + db_execute( + "UPDATE user SET token_hash = ? WHERE name = ?", + (token_hash, username) + ) + return True + + +def get_user(username): + """ + Returns a user entry. + """ + data = db_execute( + "SELECT * FROM user WHERE name = ?", + (username,) + ).fetchone() + return data + + +def set_credential(user_id, credential): + """ + Adds a credential to the database. + """ + db_execute( + "INSERT INTO credential(user_id, credential) VALUES (?, ?)", + (user_id, credential) + ) + return True + + +def get_credentials(user_id): + """ + Returns all credentials registered to a user. + """ + data = db_execute( + "SELECT * FROM credential WHERE user_id = ?", + (user_id,) + ).fetchall() + return data diff --git a/juice.py b/juice.py index df3bf9c..ef6b0f7 100644 --- a/juice.py +++ b/juice.py @@ -12,7 +12,8 @@ from flask import Flask, render_template, request, abort, jsonify from flask import Blueprint import config - +import auth +from auth import auth_required class RelayDevice: """ @@ -134,6 +135,7 @@ def save_network(filepath="devices.json"): app_views = Blueprint('app_views', __name__) @app_views.route('/') +@auth_required def index(): """ The index page. @@ -142,6 +144,7 @@ def index(): @app_views.route('/toggle') +@auth_required def toggle(): """ Toggles the state of a RelayDevice. @@ -161,6 +164,7 @@ def toggle(): return res @app_views.route('/edit') +@auth_required def edit(): """ Edits the text of a particular field. @@ -212,8 +216,15 @@ def make_error(code, message): app = Flask(__name__) app.register_blueprint(app_views, url_prefix=config.url_prefix) -app.secret_key = os.urandom(32) -app.config['APPLICATION_ROOT'] = config.url_prefix +app.register_blueprint(auth.auth_views, url_prefix=config.url_prefix) +if os.path.isfile('secret_key'): + with open('secret_key', 'rb') as file: + app.secret_key = file.read() +else: + secret_key = os.urandom(32) + app.secret_key = secret_key + with open('secret_key', 'wb') as file: + file.write(secret_key) network = init_network() 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/static/juice.css b/static/juice.css index 637c48f..e42598b 100644 --- a/static/juice.css +++ b/static/juice.css @@ -1,11 +1,14 @@ body { padding: 5%; - display: flex; - justify-content: center; font-family: Helvetica,sans-serif; background-color: lightblue; } +#container { + display: flex; + justify-content: center; +} + .device { border: 2px solid darkgray; border-radius: 0.5em; diff --git a/static/juice.js b/static/juice.js index 41995a9..e0d8400 100644 --- a/static/juice.js +++ b/static/juice.js @@ -74,3 +74,59 @@ function save_field(field) { field.children[1].replaceWith(edit); }); } + +function register() { + fetch(url_prefix + '/api/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 + '/api/register/complete', { + method: 'POST', + headers: {'Content-Type': 'application/cbor'}, + body: CBOR.encode({ + "attestationObject": new Uint8Array(attestation.response.attestationObject), + "clientDataJSON": new Uint8Array(attestation.response.clientDataJSON), + }) + }); + }).then(function(response) { + let stat = response.ok ? 'successful' : 'unsuccessful'; + alert('Registration ' + stat + ' More details in server log...'); + }, function(reason) { + alert(reason); + }).then(function() { + window.location = url_prefix; + }); +} + +function login() { + fetch(url_prefix + '/api/authenticate/begin', { + method: 'POST', + }).then(function(response) { + if(!response.ok) { throw new Error('No credential available to authenticate!'); } + return response.arrayBuffer(); + }).then(CBOR.decode).then(function(options) { + return navigator.credentials.get(options); + }).then(function(assertion) { + return fetch(url_prefix + '/api/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) + }) + }) + }).then(function(response) { + let stat = response.ok ? 'successful' : 'unsuccessful'; + alert('Authentication ' + stat + ' More details in server log...'); + }, function(reason) { + alert(reason); + }).then(function() { + window.location = url_prefix; + }); +} diff --git a/templates/auth.html b/templates/auth.html new file mode 100644 index 0000000..659e737 --- /dev/null +++ b/templates/auth.html @@ -0,0 +1,16 @@ + + + + Juice - {{ title }} + + + + +

{{ heading }}

+
+ Username:
+ +
+ + + diff --git a/templates/auth_fido.html b/templates/auth_fido.html new file mode 100644 index 0000000..0db7e9e --- /dev/null +++ b/templates/auth_fido.html @@ -0,0 +1,53 @@ + + + + Juice - {{ title }} + + + + +

{{ heading }}

+

Touch your authenticactor device now... + + + + diff --git a/templates/index.html b/templates/index.html index 41272e4..0ee729d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -6,31 +6,33 @@ - {% for dev_type, devices in network.items() %} - {% for device in devices %} -

-
{{ device.id }}
-
{{ device.description }}
-
{{ device.location }}
-
{{ device.ip_address }}
-
- {% for sub_dev_type, sub_devs in device.sub_devices.items() %} - {% for sub_dev in sub_devs %} -
-
{{ sub_dev.id }}
- - - - - - -
{{ sub_dev.description }}
+
+ {% for dev_type, devices in network.items() %} + {% for device in devices %} +
+
{{ device.id }}
+
{{ device.description }}
+
{{ device.location }}
+
{{ device.ip_address }}
+
+ {% for sub_dev_type, sub_devs in device.sub_devices.items() %} + {% for sub_dev in sub_devs %} +
+
{{ sub_dev.id }}
+ + + + + + +
{{ sub_dev.description }}
+
+ {% endfor %} + {% endfor %}
- {% endfor %} - {% endfor %}
+ {% endfor %} + {% endfor %}
- {% endfor %} - {% endfor %}