diff --git a/auth.py b/auth.py index fe009ca..a0e7456 100644 --- a/auth.py +++ b/auth.py @@ -3,6 +3,9 @@ Contains authentication methods for the app. """ import os +import re +import time +import sqlite3 import functools from fido2.client import ClientData @@ -10,12 +13,13 @@ 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 flask import Blueprint, session, render_template, request, \ + redirect, url_for, jsonify from passlib.hash import argon2 -import config import db +import config +from tools import make_error auth_views = Blueprint("auth_views", __name__) @@ -37,13 +41,22 @@ def auth_required(func): data = db.get_user(username) if not data: session.pop('username') + session.pop('token') return redirect(url_for('auth_views.login')) - token_hash = data[2] - if not token_hash: + user_id = data[0] + + tokens = db.get_tokens(user_id) + if not tokens: return redirect(url_for('auth_views.login')) - if argon2.verify(token, token_hash): - return func(*args, **kwargs) + for token_line in tokens: + token_hash = token_line[2] + date_expired = token_line[4] + if int(time.time()) >= date_expired: + continue + if argon2.verify(token, token_hash): + db.refresh_token(token_line[0]) + return func(*args, **kwargs) else: session.pop('token') return redirect(url_for('auth_views.login')) @@ -86,10 +99,10 @@ def register_complete(): username = session.get('username') if not username: return 'invalid' + session.pop('username') data = db.get_user(username) if not data: - session.pop('username') return 'invalid' user_id = data[0] @@ -97,30 +110,34 @@ def register_complete(): client_data = ClientData(data['clientDataJSON']) att_obj = AttestationObject(data['attestationObject']) + nick = data['security_key_nick'] + try: + assert 64 >= len(nick) >= 1 + except AssertionError: + return make_error(400, "security key nick too long/short") + 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'}) + db.set_credential(user_id, nick, auth_data.credential_data) + return jsonify(ok=True) @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] + data = cbor.decode(request.get_data()) + username = data.get('username') + user = db.get_user(username) + if not user: + return make_error(404, "username not found") + session['username'] = username + user_id = user[0] credentials = db.get_credentials(user_id) - credentials = [AttestedCredentialData(cd[2]) for cd in credentials] + credentials = [AttestedCredentialData(cd[3]) for cd in credentials] auth_data, state = server.authenticate_begin(credentials) session['state'] = state @@ -130,17 +147,14 @@ def authenticate_begin(): @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: + user = db.get_user(username) + if not user: session.pop('username') - return 'invalid' - user_id = data[0] + return make_error(404, "username not found") + user_id = user[0] credentials = db.get_credentials(user_id) - credentials = [AttestedCredentialData(cd[2]) for cd in credentials] + credentials = [AttestedCredentialData(cd[3]) for cd in credentials] data = cbor.decode(request.get_data()) credential_id = data['credentialId'] @@ -159,10 +173,10 @@ def authenticate_complete(): token = os.urandom(32) token_hash = argon2.hash(token) - db.set_user_token(username, token_hash) + db.set_token(user_id, token_hash) session['token'] = token - return cbor.encode({'status': 'OK'}) + return jsonify(ok=True) @auth_views.route('/register', methods=['GET', 'POST']) @@ -175,52 +189,44 @@ def register(): if request.method == 'GET': params = { - 'title': 'Register', - 'heading': 'Register an account', 'form_url': url_for('auth_views.register'), + 'url_prefix': config.url_prefix, } - return render_template('auth.html', **params) + return render_template('register.html', **params) username = request.form.get('username') - - if len(username) > 64: - return "username too long" - elif len(username) < 3: - return "username too short" + email = request.form.get('email') - db.set_user(username) # TODO: handle username error + try: + assert 64 >= len(username) >= 3 + except AssertionError: + return make_error(400, "username too long/short") + + try: + assert 100 >= len(email) + except AssertionError: + return "email too long" + + try: + user_id = db.set_user(username, email) + except sqlite3.IntegrityError as e: + field = re.search(r'user\.(.*)', str(e)).group(1) + return make_error(400, f"{field} already exists") session['username'] = username + session['user_id'] = user_id params = { - 'title': 'Register', - 'heading': 'Register your authenticator', - 'api_begin': url_for('auth_views.register_begin'), - 'api_complete': url_for('auth_views.register_complete'), + 'url_prefix': config.url_prefix, } - return render_template('auth_fido.html', **params) + return render_template('register_key.html', **params) -@auth_views.route('/login', methods=['GET', 'POST']) +@auth_views.route('/login') 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'), + 'url_prefix': config.url_prefix, } - return render_template('auth_fido.html', **params) + return render_template('login.html', **params) diff --git a/db.py b/db.py index 04faf44..e9cc471 100644 --- a/db.py +++ b/db.py @@ -2,6 +2,7 @@ """ A module for interacting with Juice's database. """ +import time import sqlite3 import threading @@ -19,19 +20,29 @@ def init_db(): try: cur.execute("SELECT * FROM user LIMIT 1").fetchone() cur.execute("SELECT * FROM credential LIMIT 1").fetchone() + cur.execute("SELECT * FROM token LIMIT 1").fetchone() except sqlite3.OperationalError: cur.execute("CREATE TABLE user(" - "id INTEGER PRIMARY KEY AUTOINCREMENT, " - "name TEXT UNIQUE, " - "token_hash TEXT" + "id INTEGER PRIMARY KEY, " + "username TEXT UNIQUE, " + "email TEXT UNIQUE" ")" ) cur.execute("CREATE TABLE credential(" - "id INTEGER PRIMARY KEY AUTOINCREMENT, " + "id INTEGER PRIMARY KEY, " "user_id INTEGER REFERENCES user(id) ON UPDATE CASCADE, " + "nick TEXT, " "credential BLOB" ")" ) + cur.execute("CREATE TABLE token(" + "id INTEGER PRIMARY KEY, " + "user_id INTEGER REFERENCES user(id) ON UPDATE CASCADE, " + "token_hash TEXT, " + "date_issued INTEGER, " + "date_expired INTEGER" + ")" + ) con.commit() con.close() init_db() @@ -50,26 +61,15 @@ def db_execute(*args, **kwargs): return res -def set_user(username): +def set_user(username, email): """ 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 + user_id = db_execute( + "INSERT INTO user(username, email) VALUES (?, ?)", + (username, email) + ).lastrowid + return user_id def get_user(username): @@ -77,21 +77,60 @@ def get_user(username): Returns a user entry. """ data = db_execute( - "SELECT * FROM user WHERE name = ?", + "SELECT * FROM user WHERE username = ?", (username,) ).fetchone() return data -def set_credential(user_id, credential): +def set_token(user_id, token_hash): + """ + Sets a user's token hash. + """ + date_issued = int(time.time()) + date_expired = date_issued + 30*24*60*60 + token_id = db_execute( + "INSERT INTO " + "token(user_id, token_hash, date_issued, date_expired) " + "VALUES (?,?,?,?)", + (user_id, token_hash, date_issued, date_expired) + ).lastrowid + return token_id + + +def get_tokens(user_id): + """ + Returns all tokens assigned to a user. + """ + data = db_execute( + "SELECT * FROM token WHERE user_id = ?", + (user_id,) + ).fetchall() + return data + + +def refresh_token(token_id): + """ + Extends a token's expiration date. + """ + new_date_expired = int(time.time()) + 30*24*60*60 + db_execute( + "UPDATE token SET date_expired = ? WHERE id = ?", + (new_date_expired, token_id) + ) + return True + + +def set_credential(user_id, nick, credential): """ Adds a credential to the database. """ - db_execute( - "INSERT INTO credential(user_id, credential) VALUES (?, ?)", - (user_id, credential) - ) - return True + cred_id = db_execute( + "INSERT INTO credential(user_id, nick, credential) " + "VALUES (?, ?, ?)", + (user_id, nick, credential) + ).lastrowid + return cred_id def get_credentials(user_id): diff --git a/juice.py b/juice.py index d1f0183..198899a 100644 --- a/juice.py +++ b/juice.py @@ -11,9 +11,10 @@ import requests from flask import Flask, render_template, request, abort, jsonify from flask import Blueprint -import config import auth +import config from auth import auth_required +from tools import make_error class RelayDevice: """ @@ -225,6 +226,7 @@ def edit(): @app_views.route('/new_device') +@auth_required def new_device(): """ Allows adding a new device. Accepts device_type parameter, returns @@ -255,6 +257,7 @@ def new_device(): @app_views.route('/lock_device') +@auth_required def lock_device(): """ Locks or unlocks a device to prevent or allow editing it's fields. @@ -278,6 +281,7 @@ def lock_device(): @app_views.route('/delete') +@auth_required def delete(): """ Deletes a device. @@ -296,18 +300,10 @@ def delete(): return jsonify(True) -def make_error(code, message): - """ - Returns a JSON error. - """ - res = jsonify(ok=False, status=code, message=message) - res.status_code = code - return res - - app = Flask(__name__) app.register_blueprint(app_views, url_prefix=config.url_prefix) app.register_blueprint(auth.auth_views, url_prefix=config.url_prefix) +app.jinja_env.undefined = "StrictUndefined" if os.path.isfile('secret_key'): with open('secret_key', 'rb') as file: app.secret_key = file.read() @@ -320,4 +316,4 @@ network = init_network() if __name__ == '__main__': - app.run(host='0.0.0.0', port=5300) + app.run(host='0.0.0.0', port=5300, debug=True) diff --git a/static/juice-auth.js b/static/juice-auth.js new file mode 100644 index 0000000..bb8f46b --- /dev/null +++ b/static/juice-auth.js @@ -0,0 +1,60 @@ +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), + "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 + '/api/authenticate/begin', { + method: 'POST', + headers: {'Content-Type': 'application/cbor'}, + body: CBOR.encode({ + "username": document.querySelector('#username').value, + }) + }).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 + '/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) + }) + }); + }, 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/juice.css b/static/juice.css index f6104c3..63623d9 100644 --- a/static/juice.css +++ b/static/juice.css @@ -24,6 +24,11 @@ nav span { margin-right: 0.5em; } +nav a { + text-decoration: none; + color: inherit; +} + nav span:hover { background-color: lightgray; } diff --git a/templates/auth.html b/templates/auth.html deleted file mode 100644 index 659e737..0000000 --- a/templates/auth.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - Juice - {{ title }} - - - - -

{{ heading }}

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

{{ heading }}

-

Touch your authenticactor device now... - - - - diff --git a/templates/index.html b/templates/index.html index 6e7ba0d..e376fd1 100644 --- a/templates/index.html +++ b/templates/index.html @@ -9,9 +9,10 @@

+

Juice

diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..096b609 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,21 @@ + + + + Juice - Login + + + + + + +
+
+
+

Login

+

+

+

+
+
+ + diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..c32ed86 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,31 @@ + + + + Juice - Register + + + +
+
+
+

Register

+
+ + + + + + + + + + + + +
Username
Email
+
+
+
+
+ + diff --git a/templates/register_key.html b/templates/register_key.html new file mode 100644 index 0000000..6934b64 --- /dev/null +++ b/templates/register_key.html @@ -0,0 +1,22 @@ + + + + Juice - Register Security Key + + + + + + +
+
+
+

Register Security Key

+

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

+

+

+
+
+ + diff --git a/tools.py b/tools.py new file mode 100644 index 0000000..e3a22fd --- /dev/null +++ b/tools.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +""" +Miscellaneous tools and helper functions. +""" +from flask import jsonify + + +def make_error(code, message): + """ + Returns a JSON error. + """ + res = jsonify(ok=False, status=code, message=message) + res.status_code = code + return res